diff --git a/.editorconfig b/.editorconfig
index 3b95e6dba8..bb34874398 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -7,11 +7,11 @@ root = true
[*]
charset = utf-8
end_of_line = lf
-trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 4
+trim_trailing_whitespace = true
# 2 space indentation
-[*.{yaml,.yml}]
+[*.{yaml,yml,vue,js,css}]
indent_size = 2
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 0000000000..53f95be159
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -0,0 +1,71 @@
+name: Release Builds
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ build:
+ if: "!github.event.release.prerelease"
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Extract Tag
+ run: echo "PACKAGE_VERSION=${{ github.ref }}" >> $GITHUB_ENV
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 7.3
+ extensions: opcache, gd
+ tools: composer:v2
+ coverage: none
+ env:
+ COMPOSER_TOKEN: ${{ secrets.GLOBAL_TOKEN }}
+
+ - name: Install Dependencies
+ run: |
+ sudo apt-get -y update -qq < /dev/null > /dev/null
+ sudo apt-get -y install -qq git zip < /dev/null > /dev/null
+
+ - name: Retrieval of Builder Scripts
+ run: |
+ # Real Grav URL
+ curl --silent -H "Authorization: token ${{ secrets.GLOBAL_TOKEN }}" -H "Accept: application/vnd.github.v3.raw" ${{ secrets.BUILD_SCRIPT_URL }} --output build-grav.sh
+
+ # Development Local URL
+ # curl ${{ secrets.BUILD_SCRIPT_URL }} --output build-grav.sh
+
+ - name: Grav Builder
+ run: |
+ bash ./build-grav.sh
+
+ - name: Upload packages to release
+ uses: svenstaro/upload-release-action@v2
+ with:
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ tag: ${{ env.PACKAGE_VERSION }}
+ file: ./grav-dist/*.zip
+ overwrite: true
+ file_glob: true
+
+ slack:
+ name: Slack
+ needs: build
+ runs-on: ubuntu-latest
+ if: always()
+ steps:
+ - uses: technote-space/workflow-conclusion-action@v2
+ - uses: 8398a7/action-slack@v3
+ with:
+ status: failure
+ fields: repo,message,author,action
+ icon_emoji: ':octocat:'
+ author_name: 'Github Action Build'
+ text: '🚚 Automated Build Failure'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GLOBAL_TOKEN }}
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ if: env.WORKFLOW_CONCLUSION == 'failure'
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
new file mode 100644
index 0000000000..90ed324b97
--- /dev/null
+++ b/.github/workflows/tests.yaml
@@ -0,0 +1,73 @@
+name: PHP Tests
+
+on:
+ push:
+ branches: [ develop ]
+ pull_request:
+ branches: [ develop ]
+
+jobs:
+
+ unit-tests:
+
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ matrix:
+ php: [ 8.1, 8.0, 7.4, 7.3]
+ os: [ubuntu-latest]
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: opcache, gd
+ tools: composer:v2
+ coverage: none
+ env:
+ COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+# - name: Update composer
+# run: composer update
+#
+# - name: Validate composer.json and composer.lock
+# run: composer validate
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+
+ - name: Cache dependencies
+ uses: actions/cache@v2
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ - name: Run test suite
+ run: vendor/bin/codecept run
+
+# slack:
+# name: Slack
+# needs: unit-tests
+# runs-on: ubuntu-latest
+# if: always()
+# steps:
+# - uses: technote-space/workflow-conclusion-action@v2
+# - uses: 8398a7/action-slack@v3
+# with:
+# status: failure
+# fields: repo,message,author,action
+# icon_emoji: ':octocat:'
+# author_name: 'Github Action Tests'
+# text: '💥 Automated Test Failure'
+# env:
+# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+# SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+# if: env.WORKFLOW_CONCLUSION == 'failure'
diff --git a/.github/workflows/trigger-skeletons.yml b/.github/workflows/trigger-skeletons.yml
new file mode 100644
index 0000000000..bc4f1a0c1a
--- /dev/null
+++ b/.github/workflows/trigger-skeletons.yml
@@ -0,0 +1,45 @@
+name: Trigger Skeletons Build
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Which Grav release to use'
+ required: true
+ default: 'latest'
+ admin:
+ description: 'Create also a package with Admin'
+ required: true
+ default: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ env:
+ WORKFLOW: "build-skeleton.yml"
+ AUTH: ":${{secrets.GLOBAL_TOKEN}}"
+ steps:
+ - uses: actions/checkout@v2
+ - name: Make it rain ☔️
+ run: |
+ SKELETONS=`curl -s "${{secrets.SKELETONS_JSON_LIST}}"`
+ echo "$SKELETONS" | jq -cr '.[]' | while read SKELETON; do
+ KEY=$(echo "$SKELETON" | jq -cr 'keys[0]')
+ VERSION=$(echo "$SKELETON" | jq -cr '.[]')
+ URL="https://api.github.com/repos/${KEY}/actions/workflows/${WORKFLOW}/dispatches"
+
+ curl -X POST \
+ -u "${AUTH}" \
+ -H "Accept: application/vnd.github.everest-preview+json" \
+ -H "Content-Type: application/json" \
+ -sS \
+ ${URL} \
+ --data '{ "ref": "develop",
+ "inputs": {
+ "tag": "'"$VERSION"'",
+ "version": "'"$INPUT_VERSION"'",
+ "admin": "'"$INPUT_ADMIN"'"
+ }
+ }' > /dev/null
+ echo "Dispatched Worfklow for ${KEY}@$VERSION"
+ done
diff --git a/.gitignore b/.gitignore
index 02082af89c..b0bbf8fa66 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,5 @@ tests/_support/_generated/*
tests/cache/*
tests/error.log
system/templates/testing/*
+/user/config/versions.yaml
+/user/cli/config/security.yaml
diff --git a/.htaccess b/.htaccess
index ef79a4bc26..098c582445 100644
--- a/.htaccess
+++ b/.htaccess
@@ -27,6 +27,9 @@ RewriteEngine On
# If you experience problems on your site block out the operations listed below
# This attempts to block the most common type of exploit `attempts` to Grav
#
+# Block out any script trying to use twig tags in URL.
+RewriteCond %{REQUEST_URI} ({{|}}|{%|%}) [OR]
+RewriteCond %{QUERY_STRING} ({{|}}|{%25|%25}) [OR]
# Block out any script trying to base64_encode data within the URL.
RewriteCond %{QUERY_STRING} base64_encode[^(]*\([^)]*\) [OR]
# Block out any script that includes a \n";
diff --git a/system/src/Grav/Common/Assets/InlineJsModule.php b/system/src/Grav/Common/Assets/InlineJsModule.php
new file mode 100644
index 0000000000..42ce6f14ab
--- /dev/null
+++ b/system/src/Grav/Common/Assets/InlineJsModule.php
@@ -0,0 +1,46 @@
+ 'js_module',
+ 'attributes' => ['type' => 'module'],
+ 'position' => 'after'
+ ];
+
+ $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements);
+
+ parent::__construct($merged_attributes, $key);
+ }
+
+ /**
+ * @return string
+ */
+ public function render()
+ {
+ return '\n";
+ }
+
+}
diff --git a/system/src/Grav/Common/Assets/Js.php b/system/src/Grav/Common/Assets/Js.php
index cce86d9ea8..8687a86b13 100644
--- a/system/src/Grav/Common/Assets/Js.php
+++ b/system/src/Grav/Common/Assets/Js.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Assets
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,9 +11,18 @@
use Grav\Common\Utils;
+/**
+ * Class Js
+ * @package Grav\Common\Assets
+ */
class Js extends BaseAsset
{
- public function __construct(array $elements = [], $key = null)
+ /**
+ * Js constructor.
+ * @param array $elements
+ * @param string|null $key
+ */
+ public function __construct(array $elements = [], ?string $key = null)
{
$base_options = [
'asset_type' => 'js',
@@ -24,13 +33,16 @@ public function __construct(array $elements = [], $key = null)
parent::__construct($merged_attributes, $key);
}
+ /**
+ * @return string
+ */
public function render()
{
if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') {
- $buffer = $this->gatherLinks( [$this], self::JS_ASSET);
+ $buffer = $this->gatherLinks([$this], self::JS_ASSET);
return '\n";
}
- return '\n";
+ return '\n";
}
}
diff --git a/system/src/Grav/Common/Assets/JsModule.php b/system/src/Grav/Common/Assets/JsModule.php
new file mode 100644
index 0000000000..5c2a836c21
--- /dev/null
+++ b/system/src/Grav/Common/Assets/JsModule.php
@@ -0,0 +1,49 @@
+ 'js_module',
+ 'attributes' => ['type' => 'module']
+ ];
+
+ $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements);
+
+ parent::__construct($merged_attributes, $key);
+ }
+
+ /**
+ * @return string
+ */
+ public function render()
+ {
+ if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') {
+ $buffer = $this->gatherLinks([$this], self::JS_MODULE_ASSET);
+ return '\n";
+ }
+
+ return '\n";
+ }
+}
diff --git a/system/src/Grav/Common/Assets/Link.php b/system/src/Grav/Common/Assets/Link.php
new file mode 100644
index 0000000000..ecafcea90f
--- /dev/null
+++ b/system/src/Grav/Common/Assets/Link.php
@@ -0,0 +1,43 @@
+ 'link',
+ ];
+
+ $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements);
+
+ parent::__construct($merged_attributes, $key);
+ }
+
+ /**
+ * @return string
+ */
+ public function render()
+ {
+ return 'renderAttributes() . $this->integrityHash($this->asset) . ">\n";
+ }
+}
diff --git a/system/src/Grav/Common/Assets/Pipeline.php b/system/src/Grav/Common/Assets/Pipeline.php
index b009585c8f..0010d7bd4d 100644
--- a/system/src/Grav/Common/Assets/Pipeline.php
+++ b/system/src/Grav/Common/Assets/Pipeline.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Assets
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,59 +11,73 @@
use Grav\Common\Assets\Traits\AssetUtilsTrait;
use Grav\Common\Config\Config;
+use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Uri;
use Grav\Common\Utils;
use Grav\Framework\Object\PropertyObject;
+use MatthiasMullie\Minify\CSS;
+use MatthiasMullie\Minify\JS;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use function array_key_exists;
+/**
+ * Class Pipeline
+ * @package Grav\Common\Assets
+ */
class Pipeline extends PropertyObject
{
use AssetUtilsTrait;
- protected const CSS_ASSET = true;
- protected const JS_ASSET = false;
+ protected const CSS_ASSET = 1;
+ protected const JS_ASSET = 2;
+ protected const JS_MODULE_ASSET = 3;
/** @const Regex to match CSS urls */
protected const CSS_URL_REGEX = '{url\(([\'\"]?)(.*?)\1\)}';
+ /** @const Regex to match JS imports */
+ protected const JS_IMPORT_REGEX = '{import.+from\s?[\'|\"](.+?)[\'|\"]}';
+
/** @const Regex to match CSS sourcemap comments */
protected const CSS_SOURCEMAP_REGEX = '{\/\*# (.*?) \*\/}';
- /** @const Regex to match CSS import content */
- protected const CSS_IMPORT_REGEX = '{@import(.*?);}';
-
protected const FIRST_FORWARDSLASH_REGEX = '{^\/{1}\w}';
- protected $css_minify;
- protected $css_minify_windows;
- protected $css_rewrite;
-
- protected $js_minify;
- protected $js_minify_windows;
-
- protected $base_url;
+ // Following variables come from the configuration:
+ /** @var bool */
+ protected $css_minify = false;
+ /** @var bool */
+ protected $css_minify_windows = false;
+ /** @var bool */
+ protected $css_rewrite = false;
+ /** @var bool */
+ protected $css_pipeline_include_externals = true;
+ /** @var bool */
+ protected $js_minify = false;
+ /** @var bool */
+ protected $js_minify_windows = false;
+ /** @var bool */
+ protected $js_pipeline_include_externals = true;
+
+ /** @var string */
protected $assets_dir;
+ /** @var string */
protected $assets_url;
+ /** @var string */
protected $timestamp;
+ /** @var array */
protected $attributes;
- protected $query;
+ /** @var string */
+ protected $query = '';
+ /** @var string */
protected $asset;
/**
- * Closure used by the pipeline to fetch assets.
- *
- * Useful when file_get_contents() function is not available in your PHP
- * installation or when you want to apply any kind of preprocessing to
- * your assets before they get pipelined.
- *
- * The closure will receive as the only parameter a string with the path/URL of the asset and
- * it should return the content of the asset file as a string.
- *
- * @var \Closure
+ * Pipeline constructor.
+ * @param array $elements
+ * @param string|null $key
*/
- protected $fetch_command;
-
public function __construct(array $elements = [], ?string $key = null)
{
parent::__construct($elements, $key);
@@ -78,7 +92,14 @@ public function __construct(array $elements = [], ?string $key = null)
$uri = Grav::instance()['uri'];
$this->base_url = rtrim($uri->rootUrl($config->get('system.absolute_urls')), '/') . '/';
- $this->assets_dir = $locator->findResource('asset://') . DS;
+ $this->assets_dir = $locator->findResource('asset://');
+ if (!$this->assets_dir) {
+ // Attempt to create assets folder if it doesn't exist yet.
+ $this->assets_dir = $locator->findResource('asset://', true, true);
+ Folder::mkdir($this->assets_dir);
+ $locator->clearCache();
+ }
+
$this->assets_url = $locator->findResource('asset://', false);
}
@@ -88,7 +109,6 @@ public function __construct(array $elements = [], ?string $key = null)
* @param array $assets
* @param string $group
* @param array $attributes
- *
* @return bool|string URL or generated content if available, else false
*/
public function renderCss($assets, $group, $attributes = [])
@@ -106,14 +126,13 @@ public function renderCss($assets, $group, $attributes = [])
// Compute uid based on assets and timestamp
$json_assets = json_encode($assets);
- $uid = md5($json_assets . $this->css_minify . $this->css_rewrite . $group);
+ $uid = md5($json_assets . (int)$this->css_minify . (int)$this->css_rewrite . $group);
$file = $uid . '.css';
$relative_path = "{$this->base_url}{$this->assets_url}/{$file}";
- $buffer = null;
-
- if (file_exists($this->assets_dir . $file)) {
- $buffer = file_get_contents($this->assets_dir . $file) . "\n";
+ $filepath = "{$this->assets_dir}/{$file}";
+ if (file_exists($filepath)) {
+ $buffer = file_get_contents($filepath) . "\n";
} else {
//if nothing found get out of here!
if (empty($assets)) {
@@ -125,14 +144,14 @@ public function renderCss($assets, $group, $attributes = [])
// Minify if required
if ($this->shouldMinify('css')) {
- $minifier = new \MatthiasMullie\Minify\CSS();
+ $minifier = new CSS();
$minifier->add($buffer);
$buffer = $minifier->minify();
}
// Write file
if (trim($buffer) !== '') {
- file_put_contents($this->assets_dir . $file, $buffer);
+ file_put_contents($filepath, $buffer);
}
}
@@ -140,7 +159,7 @@ public function renderCss($assets, $group, $attributes = [])
$output = "\n";
} else {
$this->asset = $relative_path;
- $output = 'renderAttributes() . ">\n";
+ $output = 'renderAttributes() . BaseAsset::integrityHash($this->asset) . ">\n";
}
return $output;
@@ -152,10 +171,9 @@ public function renderCss($assets, $group, $attributes = [])
* @param array $assets
* @param string $group
* @param array $attributes
- *
* @return bool|string URL or generated content if available, else false
*/
- public function renderJs($assets, $group, $attributes = [])
+ public function renderJs($assets, $group, $attributes = [], $type = self::JS_ASSET)
{
// temporary list of assets to pipeline
$inline_group = false;
@@ -174,10 +192,9 @@ public function renderJs($assets, $group, $attributes = [])
$file = $uid . '.js';
$relative_path = "{$this->base_url}{$this->assets_url}/{$file}";
- $buffer = null;
-
- if (file_exists($this->assets_dir . $file)) {
- $buffer = file_get_contents($this->assets_dir . $file) . "\n";
+ $filepath = "{$this->assets_dir}/{$file}";
+ if (file_exists($filepath)) {
+ $buffer = file_get_contents($filepath) . "\n";
} else {
//if nothing found get out of here!
if (empty($assets)) {
@@ -185,18 +202,18 @@ public function renderJs($assets, $group, $attributes = [])
}
// Concatenate files
- $buffer = $this->gatherLinks($assets, self::JS_ASSET);
+ $buffer = $this->gatherLinks($assets, $type);
// Minify if required
if ($this->shouldMinify('js')) {
- $minifier = new \MatthiasMullie\Minify\JS();
+ $minifier = new JS();
$minifier->add($buffer);
$buffer = $minifier->minify();
}
// Write file
if (trim($buffer) !== '') {
- file_put_contents($this->assets_dir . $file, $buffer);
+ file_put_contents($filepath, $buffer);
}
}
@@ -204,12 +221,25 @@ public function renderJs($assets, $group, $attributes = [])
$output = '\n";
} else {
$this->asset = $relative_path;
- $output = '\n";
+ $output = '\n";
}
return $output;
}
+ /**
+ * Minify and concatenate JS files.
+ *
+ * @param array $assets
+ * @param string $group
+ * @param array $attributes
+ * @return bool|string URL or generated content if available, else false
+ */
+ public function renderJs_Module($assets, $group, $attributes = [])
+ {
+ $attributes['type'] = 'module';
+ return $this->renderJs($assets, $group, $attributes, self::JS_MODULE_ASSET);
+ }
/**
* Finds relative CSS urls() and rewrites the URL with an absolute one
@@ -217,8 +247,7 @@ public function renderJs($assets, $group, $attributes = [])
* @param string $file the css source file
* @param string $dir , $local relative path to the css file
* @param bool $local is this a local or remote asset
- *
- * @return mixed
+ * @return string
*/
protected function cssRewrite($file, $dir, $local)
{
@@ -242,18 +271,54 @@ protected function cssRewrite($file, $dir, $local)
$old_url = ltrim($old_url, '/');
}
- $new_url = ($local ? $this->base_url: '') . $old_url;
+ $new_url = ($local ? $this->base_url : '') . $old_url;
- $fixed = str_replace($matches[2], $new_url, $matches[0]);
-
- return $fixed;
+ return str_replace($matches[2], $new_url, $matches[0]);
}, $file);
return $file;
}
+ /**
+ * Finds relative JS urls() and rewrites the URL with an absolute one
+ *
+ * @param string $file the css source file
+ * @param string $dir local relative path to the css file
+ * @param bool $local is this a local or remote asset
+ * @return string
+ */
+ protected function jsRewrite($file, $dir, $local)
+ {
+ // Find any js import elements, grab the URLs and calculate an absolute path
+ // Then replace the old url with the new one
+ $file = (string)preg_replace_callback(self::JS_IMPORT_REGEX, function ($matches) use ($dir, $local) {
+
+ $old_url = $matches[1];
+
+ // Ensure link is not rooted to web server, a data URL, or to a remote host
+ if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url) || $this->isRemoteLink($old_url)) {
+ return $matches[0];
+ }
+
+ // clean leading /
+ $old_url = Utils::normalizePath($dir . '/' . $old_url);
+ $old_url = str_replace('/./', '/', $old_url);
+ if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url)) {
+ $old_url = ltrim($old_url, '/');
+ }
+ $new_url = ($local ? $this->base_url : '') . $old_url;
+ return str_replace($matches[1], $new_url, $matches[0]);
+ }, $file);
+
+ return $file;
+ }
+
+ /**
+ * @param string $type
+ * @return bool
+ */
private function shouldMinify($type = 'css')
{
$check = $type . '_minify';
diff --git a/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php
index 0a1d149de6..0e3f392aff 100644
--- a/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php
+++ b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php
@@ -3,17 +3,42 @@
/**
* @package Grav\Common\Assets\Traits
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Assets\Traits;
+use Closure;
use Grav\Common\Grav;
use Grav\Common\Utils;
+use function dirname;
+use function in_array;
+use function is_array;
+/**
+ * Trait AssetUtilsTrait
+ * @package Grav\Common\Assets\Traits
+ */
trait AssetUtilsTrait
{
+ /**
+ * @var Closure|null
+ *
+ * Closure used by the pipeline to fetch assets.
+ *
+ * Useful when file_get_contents() function is not available in your PHP
+ * installation or when you want to apply any kind of preprocessing to
+ * your assets before they get pipelined.
+ *
+ * The closure will receive as the only parameter a string with the path/URL of the asset and
+ * it should return the content of the asset file as a string.
+ */
+ protected $fetch_command;
+
+ /** @var string */
+ protected $base_url;
+
/**
* Determine whether a link is local or remote.
* Understands both "http://" and "https://" as well as protocol agnostic links "//"
@@ -37,16 +62,13 @@ public static function isRemoteLink($link)
* Download and concatenate the content of several links.
*
* @param array $assets
- * @param bool $css
- *
+ * @param int $type
* @return string
*/
- protected function gatherLinks(array $assets, $css = true)
+ protected function gatherLinks(array $assets, int $type = self::CSS_ASSET): string
{
$buffer = '';
-
-
- foreach ($assets as $id => $asset) {
+ foreach ($assets as $asset) {
$local = true;
$link = $asset->getAsset();
@@ -57,7 +79,7 @@ protected function gatherLinks(array $assets, $css = true)
if (0 === strpos($link, '//')) {
$link = 'http:' . $link;
}
- $relative_dir = \dirname($relative_path);
+ $relative_dir = dirname($relative_path);
} else {
// Fix to remove relative dir if grav is in one
if (($this->base_url !== '/') && Utils::startsWith($relative_path, $this->base_url)) {
@@ -65,11 +87,12 @@ protected function gatherLinks(array $assets, $css = true)
$relative_path = ltrim(preg_replace($base_url, '/', $link, 1), '/');
}
- $relative_dir = \dirname($relative_path);
- $link = ROOT_DIR . $relative_path;
+ $relative_dir = dirname($relative_path);
+ $link = GRAV_ROOT . '/' . $relative_path;
}
- $file = ($this->fetch_command instanceof \Closure) ? @$this->fetch_command->__invoke($link) : @file_get_contents($link);
+ // TODO: looks like this is not being used.
+ $file = $this->fetch_command instanceof Closure ? @$this->fetch_command->__invoke($link) : @file_get_contents($link);
// No file found, skip it...
if ($file === false) {
@@ -77,21 +100,25 @@ protected function gatherLinks(array $assets, $css = true)
}
// Double check last character being
- if (!$css) {
+ if ($type === self::JS_ASSET || $type === self::JS_MODULE_ASSET) {
$file = rtrim($file, ' ;') . ';';
}
// If this is CSS + the file is local + rewrite enabled
- if ($css && $this->css_rewrite) {
+ if ($type === self::CSS_ASSET && $this->css_rewrite) {
$file = $this->cssRewrite($file, $relative_dir, $local);
}
+ if ($type === self::JS_MODULE_ASSET) {
+ $file = $this->jsRewrite($file, $relative_dir, $local);
+ }
+
$file = rtrim($file) . PHP_EOL;
$buffer .= $file;
}
// Pull out @imports and move to top
- if ($css) {
+ if ($type === self::CSS_ASSET) {
$buffer = $this->moveImports($buffer);
}
@@ -102,14 +129,15 @@ protected function gatherLinks(array $assets, $css = true)
* Moves @import statements to the top of the file per the CSS specification
*
* @param string $file the file containing the combined CSS files
- *
* @return string the modified file with any @imports at the top of the file
*/
protected function moveImports($file)
{
+ $regex = '{@import.*?["\']([^"\']+)["\'].*?;}';
+
$imports = [];
- $file = (string)preg_replace_callback(self::CSS_IMPORT_REGEX, function ($matches) use (&$imports) {
+ $file = (string)preg_replace_callback($regex, static function ($matches) use (&$imports) {
$imports[] = $matches[0];
return '';
@@ -130,14 +158,18 @@ protected function renderAttributes()
$no_key = ['loading'];
foreach ($this->attributes as $key => $value) {
+ if ($value === null) {
+ continue;
+ }
+
if (is_numeric($key)) {
$key = $value;
}
- if (\is_array($value)) {
+ if (is_array($value)) {
$value = implode(' ', $value);
}
- if (\in_array($key, $no_key, true)) {
+ if (in_array($key, $no_key, true)) {
$element = htmlentities($value, ENT_QUOTES, 'UTF-8', false);
} else {
$element = $key . '="' . htmlentities($value, ENT_QUOTES, 'UTF-8', false) . '"';
@@ -152,7 +184,7 @@ protected function renderAttributes()
/**
* Render Querystring
*
- * @param string $asset
+ * @param string|null $asset
* @return string
*/
protected function renderQueryString($asset = null)
@@ -170,7 +202,7 @@ protected function renderQueryString($asset = null)
}
if ($this->timestamp) {
- if (Utils::contains($asset, '?') || $querystring) {
+ if ($querystring || Utils::contains($asset, '?')) {
$querystring .= '&' . $this->timestamp;
} else {
$querystring .= '?' . $this->timestamp;
diff --git a/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php b/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php
index 229c2abae7..c1ef0a3c35 100644
--- a/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php
+++ b/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php
@@ -3,17 +3,23 @@
/**
* @package Grav\Common\Assets\Traits
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Assets\Traits;
use Grav\Common\Assets;
+use function count;
+use function is_array;
+use function is_int;
+/**
+ * Trait LegacyAssetsTrait
+ * @package Grav\Common\Assets\Traits
+ */
trait LegacyAssetsTrait
{
-
/**
* @param array $args
* @param string $type
@@ -39,31 +45,35 @@ protected function unifyLegacyArguments($args, $type = Assets::CSS_TYPE)
}
switch ($type) {
- case(Assets::JS_TYPE):
+ case (Assets::JS_TYPE):
$defaults = ['priority' => null, 'pipeline' => true, 'loading' => null, 'group' => null];
$arguments = $this->createArgumentsFromLegacy($args, $defaults);
break;
- case(Assets::INLINE_JS_TYPE):
+ case (Assets::INLINE_JS_TYPE):
$defaults = ['priority' => null, 'group' => null, 'attributes' => null];
$arguments = $this->createArgumentsFromLegacy($args, $defaults);
// special case to handle old attributes being passed in
if (isset($arguments['attributes'])) {
$old_attributes = $arguments['attributes'];
- $arguments = array_merge($arguments, $old_attributes);
+ if (is_array($old_attributes)) {
+ $arguments = array_merge($arguments, $old_attributes);
+ } else {
+ $arguments['type'] = $old_attributes;
+ }
}
unset($arguments['attributes']);
break;
- case(Assets::INLINE_CSS_TYPE):
+ case (Assets::INLINE_CSS_TYPE):
$defaults = ['priority' => null, 'group' => null];
$arguments = $this->createArgumentsFromLegacy($args, $defaults);
break;
default:
- case(Assets::CSS_TYPE):
+ case (Assets::CSS_TYPE):
$defaults = ['priority' => null, 'pipeline' => true, 'group' => null, 'loading' => null];
$arguments = $this->createArgumentsFromLegacy($args, $defaults);
}
@@ -71,6 +81,11 @@ protected function unifyLegacyArguments($args, $type = Assets::CSS_TYPE)
return $arguments;
}
+ /**
+ * @param array $args
+ * @param array $defaults
+ * @return array
+ */
protected function createArgumentsFromLegacy(array $args, array $defaults)
{
// Remove arguments with old default values.
@@ -93,8 +108,7 @@ protected function createArgumentsFromLegacy(array $args, array $defaults)
* @param int $priority
* @param bool $pipeline
* @param string $group name of the group
- *
- * @return \Grav\Common\Assets
+ * @return Assets
* @deprecated Please use dynamic method with ['loading' => 'async'].
*/
public function addAsyncJs($asset, $priority = 10, $pipeline = true, $group = 'head')
@@ -111,8 +125,7 @@ public function addAsyncJs($asset, $priority = 10, $pipeline = true, $group = 'h
* @param int $priority
* @param bool $pipeline
* @param string $group name of the group
- *
- * @return \Grav\Common\Assets
+ * @return Assets
* @deprecated Please use dynamic method with ['loading' => 'defer'].
*/
public function addDeferJs($asset, $priority = 10, $pipeline = true, $group = 'head')
@@ -121,5 +134,4 @@ public function addDeferJs($asset, $priority = 10, $pipeline = true, $group = 'h
return $this->addJs($asset, $priority, $pipeline, 'defer', $group);
}
-
}
diff --git a/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php b/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php
index 84b83e304f..b11b439b62 100644
--- a/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php
+++ b/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php
@@ -3,21 +3,29 @@
/**
* @package Grav\Common\Assets\Traits
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Assets\Traits;
+use FilesystemIterator;
use Grav\Common\Grav;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use RegexIterator;
+use function strlen;
+/**
+ * Trait TestingAssetsTrait
+ * @package Grav\Common\Assets\Traits
+ */
trait TestingAssetsTrait
{
/**
* Determines if an asset exists as a collection, CSS or JS reference
*
* @param string $asset
- *
* @return bool
*/
public function exists($asset)
@@ -39,7 +47,6 @@ public function getCollections()
* Set the array of collections explicitly
*
* @param array $collections
- *
* @return $this
*/
public function setCollection($collections)
@@ -54,7 +61,7 @@ public function setCollection($collections)
* If a $key is provided, it will try to return only that asset
* else it will return null
*
- * @param null|string $key the asset key
+ * @param string|null $key the asset key
* @return array
*/
public function getCss($key = null)
@@ -73,7 +80,7 @@ public function getCss($key = null)
* If a $key is provided, it will try to return only that asset
* else it will return null
*
- * @param null|string $key the asset key
+ * @param string|null $key the asset key
* @return array
*/
public function getJs($key = null)
@@ -91,7 +98,6 @@ public function getJs($key = null)
* Set the whole array of CSS assets
*
* @param array $css
- *
* @return $this
*/
public function setCss($css)
@@ -105,7 +111,6 @@ public function setCss($css)
* Set the whole array of JS assets
*
* @param array $js
- *
* @return $this
*/
public function setJs($js)
@@ -119,7 +124,6 @@ public function setJs($js)
* Removes an item from the CSS array if set
*
* @param string $key The asset key
- *
* @return $this
*/
public function removeCss($key)
@@ -136,7 +140,6 @@ public function removeCss($key)
* Removes an item from the JS array if set
*
* @param string $key The asset key
- *
* @return $this
*/
public function removeJs($key)
@@ -153,7 +156,6 @@ public function removeJs($key)
* Sets the state of CSS Pipeline
*
* @param bool $value
- *
* @return $this
*/
public function setCssPipeline($value)
@@ -167,7 +169,6 @@ public function setCssPipeline($value)
* Sets the state of JS Pipeline
*
* @param bool $value
- *
* @return $this
*/
public function setJsPipeline($value)
@@ -188,6 +189,7 @@ public function reset()
$this->resetJs();
$this->setCssPipeline(false);
$this->setJsPipeline(false);
+ $this->order = [];
return $this;
}
@@ -230,7 +232,7 @@ public function setTimestamp($value)
* Get the timestamp for assets
*
* @param bool $include_join
- * @return string
+ * @return string|null
*/
public function getTimestamp($include_join = true)
{
@@ -246,12 +248,11 @@ public function getTimestamp($include_join = true)
*
* @param string $directory Relative to the Grav root path, or a stream identifier
* @param string $pattern (regex)
- *
* @return $this
*/
public function addDir($directory, $pattern = self::DEFAULT_REGEX)
{
- $root_dir = rtrim(ROOT_DIR, '/');
+ $root_dir = GRAV_ROOT;
// Check if $directory is a stream.
if (strpos($directory, '://')) {
@@ -284,6 +285,15 @@ public function addDir($directory, $pattern = self::DEFAULT_REGEX)
return $this;
}
+ // Add JavaScript Module files
+ if ($pattern === self::JS_MODULE_REGEX) {
+ foreach ($files as $file) {
+ $this->addJsModule($file);
+ }
+
+ return $this;
+ }
+
// Unknown pattern.
foreach ($files as $asset) {
$this->add($asset);
@@ -296,7 +306,6 @@ public function addDir($directory, $pattern = self::DEFAULT_REGEX)
* Add all JavaScript assets within $directory
*
* @param string $directory Relative to the Grav root path, or a stream identifier
- *
* @return $this
*/
public function addDirJs($directory)
@@ -308,7 +317,6 @@ public function addDirJs($directory)
* Add all CSS assets within $directory
*
* @param string $directory Relative to the Grav root path, or a stream identifier
- *
* @return $this
*/
public function addDirCss($directory)
@@ -321,15 +329,16 @@ public function addDirCss($directory)
*
* @param string $directory
* @param string $pattern (regex)
- * @param string $ltrim Will be trimmed from the left of the file path
- *
+ * @param string|null $ltrim Will be trimmed from the left of the file path
* @return array
*/
protected function rglob($directory, $pattern, $ltrim = null)
{
- $iterator = new \RegexIterator(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory,
- \FilesystemIterator::SKIP_DOTS)), $pattern);
- $offset = \strlen($ltrim);
+ $iterator = new RegexIterator(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(
+ $directory,
+ FilesystemIterator::SKIP_DOTS
+ )), $pattern);
+ $offset = strlen($ltrim);
$files = [];
foreach ($iterator as $file) {
@@ -338,6 +347,4 @@ protected function rglob($directory, $pattern, $ltrim = null)
return $files;
}
-
-
}
diff --git a/system/src/Grav/Common/Backup/Backups.php b/system/src/Grav/Common/Backup/Backups.php
index 9582b9e620..ff4789d86b 100644
--- a/system/src/Grav/Common/Backup/Backups.php
+++ b/system/src/Grav/Common/Backup/Backups.php
@@ -3,12 +3,16 @@
/**
* @package Grav\Common\Backup
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Backup;
+use DateTime;
+use Exception;
+use FilesystemIterator;
+use GlobIterator;
use Grav\Common\Filesystem\Archiver;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Inflector;
@@ -17,107 +21,156 @@
use Grav\Common\Utils;
use Grav\Common\Grav;
use RocketTheme\Toolbox\Event\Event;
-use RocketTheme\Toolbox\Event\EventDispatcher;
use RocketTheme\Toolbox\File\JsonFile;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use RuntimeException;
+use SplFileInfo;
+use stdClass;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use function count;
+/**
+ * Class Backups
+ * @package Grav\Common\Backup
+ */
class Backups
{
protected const BACKUP_FILENAME_REGEXZ = "#(.*)--(\d*).zip#";
protected const BACKUP_DATE_FORMAT = 'YmdHis';
+ /** @var string */
protected static $backup_dir;
- protected static $backups = null;
+ /** @var array|null */
+ protected static $backups;
+ /**
+ * @return void
+ */
public function init()
{
+ $grav = Grav::instance();
+
/** @var EventDispatcher $dispatcher */
- $dispatcher = Grav::instance()['events'];
+ $dispatcher = $grav['events'];
$dispatcher->addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']);
- Grav::instance()->fireEvent('onBackupsInitialized', new Event(['backups' => $this]));
+
+ $grav->fireEvent('onBackupsInitialized', new Event(['backups' => $this]));
}
+ /**
+ * @return void
+ */
public function setup()
{
if (null === static::$backup_dir) {
- static::$backup_dir = Grav::instance()['locator']->findResource('backup://', true, true);
+ $grav = Grav::instance();
+ static::$backup_dir = $grav['locator']->findResource('backup://', true, true);
Folder::create(static::$backup_dir);
}
}
+ /**
+ * @param Event $event
+ * @return void
+ */
public function onSchedulerInitialized(Event $event)
{
+ $grav = Grav::instance();
+
/** @var Scheduler $scheduler */
$scheduler = $event['scheduler'];
/** @var Inflector $inflector */
- $inflector = Grav::instance()['inflector'];
+ $inflector = $grav['inflector'];
foreach (static::getBackupProfiles() as $id => $profile) {
$at = $profile['schedule_at'];
$name = $inflector::hyphenize($profile['name']);
$logs = 'logs/backup-' . $name . '.out';
/** @var Job $job */
- $job = $scheduler->addFunction('Grav\Common\Backup\Backups::backup', [$id], $name );
+ $job = $scheduler->addFunction('Grav\Common\Backup\Backups::backup', [$id], $name);
$job->at($at);
$job->output($logs);
$job->backlink('/tools/backups');
}
}
+ /**
+ * @param string $backup
+ * @param string $base_url
+ * @return string
+ */
public function getBackupDownloadUrl($backup, $base_url)
{
- $param_sep = $param_sep = Grav::instance()['config']->get('system.param_sep', ':');
- $download = urlencode(base64_encode($backup));
- $url = rtrim(Grav::instance()['uri']->rootUrl(true), '/') . '/' . trim($base_url,
- '/') . '/task' . $param_sep . 'backup/download' . $param_sep . $download . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form');
+ $param_sep = Grav::instance()['config']->get('system.param_sep', ':');
+ $download = urlencode(base64_encode(Utils::basename($backup)));
+ $url = rtrim(Grav::instance()['uri']->rootUrl(true), '/') . '/' . trim(
+ $base_url,
+ '/'
+ ) . '/task' . $param_sep . 'backup/download' . $param_sep . $download . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form');
return $url;
}
+ /**
+ * @return array
+ */
public static function getBackupProfiles()
{
return Grav::instance()['config']->get('backups.profiles');
}
+ /**
+ * @return array
+ */
public static function getPurgeConfig()
{
return Grav::instance()['config']->get('backups.purge');
}
+ /**
+ * @return array
+ */
public function getBackupNames()
{
return array_column(static::getBackupProfiles(), 'name');
}
+ /**
+ * @return float|int
+ */
public static function getTotalBackupsSize()
{
$backups = static::getAvailableBackups();
- $size = array_sum(array_column($backups, 'size'));
- return $size ?? 0;
+ return $backups ? array_sum(array_column($backups, 'size')) : 0;
}
+ /**
+ * @param bool $force
+ * @return array
+ */
public static function getAvailableBackups($force = false)
{
if ($force || null === static::$backups) {
static::$backups = [];
- $backups_itr = new \GlobIterator(static::$backup_dir . '/*.zip', \FilesystemIterator::KEY_AS_FILENAME);
- $inflector = Grav::instance()['inflector'];
+
+ $grav = Grav::instance();
+ $backups_itr = new GlobIterator(static::$backup_dir . '/*.zip', FilesystemIterator::KEY_AS_FILENAME);
+ $inflector = $grav['inflector'];
$long_date_format = DATE_RFC2822;
/**
* @var string $name
- * @var \SplFileInfo $file
+ * @var SplFileInfo $file
*/
foreach ($backups_itr as $name => $file) {
-
if (preg_match(static::BACKUP_FILENAME_REGEXZ, $name, $matches)) {
- $date = \DateTime::createFromFormat(static::BACKUP_DATE_FORMAT, $matches[2]);
+ $date = DateTime::createFromFormat(static::BACKUP_DATE_FORMAT, $matches[2]);
$timestamp = $date->getTimestamp();
- $backup = new \stdClass();
+ $backup = new stdClass();
$backup->title = $inflector->titleize($matches[1]);
$backup->time = $date;
$backup->date = $date->format($long_date_format);
@@ -137,28 +190,29 @@ public static function getAvailableBackups($force = false)
/**
* Backup
*
- * @param int $id
+ * @param int $id
* @param callable|null $status
- *
- * @return null|string
+ * @return string|null
*/
public static function backup($id = 0, callable $status = null)
{
+ $grav = Grav::instance();
+
$profiles = static::getBackupProfiles();
/** @var UniformResourceLocator $locator */
- $locator = Grav::instance()['locator'];
+ $locator = $grav['locator'];
if (isset($profiles[$id])) {
$backup = (object) $profiles[$id];
} else {
- throw new \RuntimeException('No backups defined...');
+ throw new RuntimeException('No backups defined...');
}
- $name = Grav::instance()['inflector']->underscorize($backup->name);
+ $name = $grav['inflector']->underscorize($backup->name);
$date = date(static::BACKUP_DATE_FORMAT, time());
$filename = trim($name, '_') . '--' . $date . '.zip';
$destination = static::$backup_dir . DS . $filename;
- $max_execution_time = ini_set('max_execution_time', 600);
+ $max_execution_time = ini_set('max_execution_time', '600');
$backup_root = $backup->root;
if ($locator->isStream($backup_root)) {
@@ -167,8 +221,8 @@ public static function backup($id = 0, callable $status = null)
$backup_root = rtrim(GRAV_ROOT . $backup_root, '/');
}
- if (!file_exists($backup_root)) {
- throw new \RuntimeException("Backup location: {$backup_root} does not exist...");
+ if (!$backup_root || !file_exists($backup_root)) {
+ throw new RuntimeException("Backup location: {$backup_root} does not exist...");
}
$options = [
@@ -176,7 +230,6 @@ public static function backup($id = 0, callable $status = null)
'exclude_paths' => static::convertExclude($backup->exclude_paths ?? ''),
];
- /** @var Archiver $archiver */
$archiver = Archiver::create('zip');
$archiver->setArchive($destination)->setOptions($options)->compress($backup_root, $status)->addEmptyFolders($options['exclude_paths'], $status);
@@ -195,16 +248,16 @@ public static function backup($id = 0, callable $status = null)
}
// Log the backup
- Grav::instance()['log']->notice('Backup Created: ' . $destination);
+ $grav['log']->notice('Backup Created: ' . $destination);
// Fire Finished event
- Grav::instance()->fireEvent('onBackupFinished', new Event(['backup' => $destination]));
+ $grav->fireEvent('onBackupFinished', new Event(['backup' => $destination]));
// Purge anything required
static::purge();
// Log
- $log = JsonFile::instance(Grav::instance()['locator']->findResource("log://backup.log", true, true));
+ $log = JsonFile::instance($locator->findResource("log://backup.log", true, true));
$log->content([
'time' => time(),
'location' => $destination
@@ -214,26 +267,29 @@ public static function backup($id = 0, callable $status = null)
return $destination;
}
+ /**
+ * @return void
+ * @throws Exception
+ */
public static function purge()
{
$purge_config = static::getPurgeConfig();
$trigger = $purge_config['trigger'];
$backups = static::getAvailableBackups(true);
- switch ($trigger)
- {
+ switch ($trigger) {
case 'number':
$backups_count = count($backups);
if ($backups_count > $purge_config['max_backups_count']) {
$last = end($backups);
- unlink ($last->path);
+ unlink($last->path);
static::purge();
}
break;
case 'time':
$last = end($backups);
- $now = new \DateTime();
+ $now = new DateTime();
$interval = $now->diff($last->time);
if ($interval->days > $purge_config['max_backups_time']) {
unlink($last->path);
@@ -253,9 +309,14 @@ public static function purge()
}
}
+ /**
+ * @param string $exclude
+ * @return array
+ */
protected static function convertExclude($exclude)
{
$lines = preg_split("/[\s,]+/", $exclude);
- return array_map('trim', $lines, array_fill(0, \count($lines), '/'));
+
+ return array_map('trim', $lines, array_fill(0, count($lines), '/'));
}
}
diff --git a/system/src/Grav/Common/Browser.php b/system/src/Grav/Common/Browser.php
index 037d454e28..ffc8da3138 100644
--- a/system/src/Grav/Common/Browser.php
+++ b/system/src/Grav/Common/Browser.php
@@ -3,17 +3,21 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
+use InvalidArgumentException;
+use function donatj\UserAgent\parse_user_agent;
+
/**
* Internally uses the PhpUserAgent package https://github.com/donatj/PhpUserAgent
*/
class Browser
{
+ /** @var string[] */
protected $useragent = [];
/**
@@ -23,7 +27,7 @@ public function __construct()
{
try {
$this->useragent = parse_user_agent();
- } catch (\InvalidArgumentException $e) {
+ } catch (InvalidArgumentException $e) {
$this->useragent = parse_user_agent("Mozilla/5.0 (compatible; Unknown;)");
}
}
@@ -108,7 +112,7 @@ public function getLongVersion()
/**
* Get the current major version identifier
*
- * @return string the browser major version identifier
+ * @return int the browser major version identifier
*/
public function getVersion()
{
@@ -135,7 +139,7 @@ public function isHuman()
return true;
}
-
+
/**
* Determine if “Do Not Track” is set by browser
* @see https://www.w3.org/TR/tracking-dnt/
diff --git a/system/src/Grav/Common/Cache.php b/system/src/Grav/Common/Cache.php
index 94eae595e7..ed40a085fb 100644
--- a/system/src/Grav/Common/Cache.php
+++ b/system/src/Grav/Common/Cache.php
@@ -3,19 +3,27 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
+use DirectoryIterator;
use \Doctrine\Common\Cache as DoctrineCache;
+use Exception;
use Grav\Common\Config\Config;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Scheduler\Scheduler;
+use LogicException;
use Psr\SimpleCache\CacheInterface;
use RocketTheme\Toolbox\Event\Event;
-use RocketTheme\Toolbox\Event\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use function dirname;
+use function extension_loaded;
+use function function_exists;
+use function in_array;
+use function is_array;
/**
* The GravCache object is used throughout Grav to store and retrieve cached data.
@@ -29,42 +37,41 @@
*/
class Cache extends Getters
{
- /**
- * @var string Cache key.
- */
+ /** @var string Cache key. */
protected $key;
+ /** @var int */
protected $lifetime;
+
+ /** @var int */
protected $now;
/** @var Config $config */
protected $config;
- /**
- * @var DoctrineCache\CacheProvider
- */
+ /** @var DoctrineCache\CacheProvider */
protected $driver;
- /**
- * @var CacheInterface
- */
+ /** @var CacheInterface */
protected $simpleCache;
+ /** @var string */
protected $driver_name;
+ /** @var string */
protected $driver_setting;
- /**
- * @var bool
- */
+ /** @var bool */
protected $enabled;
+ /** @var string */
protected $cache_dir;
protected static $standard_remove = [
'cache://twig/',
'cache://doctrine/',
'cache://compiled/',
+ 'cache://clockwork/',
'cache://validated-',
'cache://images',
'asset://',
@@ -74,6 +81,7 @@ class Cache extends Getters
'cache://twig/',
'cache://doctrine/',
'cache://compiled/',
+ 'cache://clockwork/',
'cache://validated-',
'asset://',
];
@@ -115,12 +123,10 @@ public function __construct(Grav $grav)
* Initialization that sets a base key and the driver based on configuration settings
*
* @param Grav $grav
- *
* @return void
*/
public function init(Grav $grav)
{
- /** @var Config $config */
$this->config = $grav['config'];
$this->now = time();
@@ -135,7 +141,7 @@ public function init(Grav $grav)
$uniqueness = substr(md5($uri->rootUrl(true) . $this->config->key() . GRAV_VERSION), 2, 8);
// Cache key allows us to invalidate all cache on configuration changes.
- $this->key = ($prefix ? $prefix : 'g') . '-' . $uniqueness;
+ $this->key = ($prefix ?: 'g') . '-' . $uniqueness;
$this->cache_dir = $grav['locator']->findResource('cache://doctrine/' . $uniqueness, true, true);
$this->driver_setting = $this->config->get('system.cache.driver');
$this->driver = $this->getCacheDriver();
@@ -171,10 +177,10 @@ public function getSimpleCache()
public function purgeOldCache()
{
$cache_dir = dirname($this->cache_dir);
- $current = basename($this->cache_dir);
+ $current = Utils::basename($this->cache_dir);
$count = 0;
- foreach (new \DirectoryIterator($cache_dir) as $file) {
+ foreach (new DirectoryIterator($cache_dir) as $file) {
$dir = $file->getBasename();
if ($dir === $current || $file->isDot() || $file->isFile()) {
continue;
@@ -191,6 +197,7 @@ public function purgeOldCache()
* Public accessor to set the enabled state of the cache
*
* @param bool|int $enabled
+ * @return void
*/
public function setEnabled($enabled)
{
@@ -260,24 +267,28 @@ public function getCacheDriver()
case 'memcache':
if (extension_loaded('memcache')) {
$memcache = new \Memcache();
- $memcache->connect($this->config->get('system.cache.memcache.server', 'localhost'),
- $this->config->get('system.cache.memcache.port', 11211));
+ $memcache->connect(
+ $this->config->get('system.cache.memcache.server', 'localhost'),
+ $this->config->get('system.cache.memcache.port', 11211)
+ );
$driver = new DoctrineCache\MemcacheCache();
$driver->setMemcache($memcache);
} else {
- throw new \LogicException('Memcache PHP extension has not been installed');
+ throw new LogicException('Memcache PHP extension has not been installed');
}
break;
case 'memcached':
if (extension_loaded('memcached')) {
$memcached = new \Memcached();
- $memcached->addServer($this->config->get('system.cache.memcached.server', 'localhost'),
- $this->config->get('system.cache.memcached.port', 11211));
+ $memcached->addServer(
+ $this->config->get('system.cache.memcached.server', 'localhost'),
+ $this->config->get('system.cache.memcached.port', 11211)
+ );
$driver = new DoctrineCache\MemcachedCache();
$driver->setMemcached($memcached);
} else {
- throw new \LogicException('Memcached PHP extension has not been installed');
+ throw new LogicException('Memcached PHP extension has not been installed');
}
break;
@@ -286,12 +297,15 @@ public function getCacheDriver()
$redis = new \Redis();
$socket = $this->config->get('system.cache.redis.socket', false);
$password = $this->config->get('system.cache.redis.password', false);
+ $databaseId = $this->config->get('system.cache.redis.database', 0);
if ($socket) {
$redis->connect($socket);
} else {
- $redis->connect($this->config->get('system.cache.redis.server', 'localhost'),
- $this->config->get('system.cache.redis.port', 6379));
+ $redis->connect(
+ $this->config->get('system.cache.redis.server', 'localhost'),
+ $this->config->get('system.cache.redis.port', 6379)
+ );
}
// Authenticate with password if set
@@ -299,10 +313,15 @@ public function getCacheDriver()
throw new \RedisException('Redis authentication failed');
}
+ // Select alternate ( !=0 ) database ID if set
+ if ($databaseId && !$redis->select($databaseId)) {
+ throw new \RedisException('Could not select alternate Redis database ID');
+ }
+
$driver = new DoctrineCache\RedisCache();
$driver->setRedis($redis);
} else {
- throw new \LogicException('Redis PHP extension has not been installed');
+ throw new LogicException('Redis PHP extension has not been installed');
}
break;
@@ -318,8 +337,7 @@ public function getCacheDriver()
* Gets a cached entry if it exists based on an id. If it does not exist, it returns false
*
* @param string $id the id of the cached entry
- *
- * @return object|bool returns the cached entry, can be any type, or false if doesn't exist
+ * @return mixed|bool returns the cached entry, can be any type, or false if doesn't exist
*/
public function fetch($id)
{
@@ -334,8 +352,8 @@ public function fetch($id)
* Stores a new cached entry.
*
* @param string $id the id of the cached entry
- * @param array|object $data the data for the cached entry to store
- * @param int $lifetime the lifetime to store the entry in seconds
+ * @param array|object|int $data the data for the cached entry to store
+ * @param int|null $lifetime the lifetime to store the entry in seconds
*/
public function save($id, $data, $lifetime = null)
{
@@ -393,6 +411,8 @@ public function contains($id)
/**
* Getter method to get the cache key
+ *
+ * @return string
*/
public function getKey()
{
@@ -401,6 +421,9 @@ public function getKey()
/**
* Setter method to set key (Advanced)
+ *
+ * @param string $key
+ * @return void
*/
public function setKey($key)
{
@@ -412,7 +435,6 @@ public function setKey($key)
* Helper method to clear all Grav caches
*
* @param string $remove standard|all|assets-only|images-only|cache-only
- *
* @return array
*/
public static function clearCache($remove = 'standard')
@@ -446,7 +468,6 @@ public static function clearCache($remove = 'standard')
} else {
$remove_paths = self::$standard_remove_no_images;
}
-
}
// Delete entries in the doctrine cache if required
@@ -459,11 +480,12 @@ public static function clearCache($remove = 'standard')
Grav::instance()->fireEvent('onBeforeCacheClear', new Event(['remove' => $remove, 'paths' => &$remove_paths]));
foreach ($remove_paths as $stream) {
-
// Convert stream to a real path
try {
$path = $locator->findResource($stream, true, true);
- if($path === false) continue;
+ if ($path === false) {
+ continue;
+ }
$anything = false;
$files = glob($path . '/*');
@@ -477,7 +499,7 @@ public static function clearCache($remove = 'standard')
$anything = true;
}
} elseif (is_dir($file)) {
- if (Folder::delete($file)) {
+ if (Folder::delete($file, false)) {
$anything = true;
}
}
@@ -487,7 +509,7 @@ public static function clearCache($remove = 'standard')
if ($anything) {
$output[] = 'Cleared: ' . $path . '/*';
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// stream not found or another error while deleting files.
$output[] = 'ERROR: ' . $e->getMessage();
}
@@ -510,9 +532,14 @@ public static function clearCache($remove = 'standard')
@opcache_reset();
}
+ Grav::instance()->fireEvent('onAfterCacheClear', new Event(['remove' => $remove, 'output' => &$output]));
+
return $output;
}
+ /**
+ * @return void
+ */
public static function invalidateCache()
{
$user_config = USER_DIR . 'config/system.yaml';
@@ -528,13 +555,13 @@ public static function invalidateCache()
if (function_exists('opcache_reset')) {
@opcache_reset();
}
-
}
/**
* Set the cache lifetime programmatically
*
* @param int $future timestamp
+ * @return void
*/
public function setLifetime($future)
{
@@ -552,7 +579,7 @@ public function setLifetime($future)
/**
* Retrieve the cache lifetime (in seconds)
*
- * @return mixed
+ * @return int
*/
public function getLifetime()
{
@@ -566,7 +593,7 @@ public function getLifetime()
/**
* Returns the current driver name
*
- * @return mixed
+ * @return string
*/
public function getDriverName()
{
@@ -576,7 +603,7 @@ public function getDriverName()
/**
* Returns the current driver setting
*
- * @return mixed
+ * @return string
*/
public function getDriverSetting()
{
@@ -591,29 +618,35 @@ public function getDriverSetting()
*/
public function isVolatileDriver($setting)
{
- if (in_array($setting, ['apc', 'apcu', 'xcache', 'wincache'])) {
- return true;
- }
-
- return false;
+ return in_array($setting, ['apc', 'apcu', 'xcache', 'wincache'], true);
}
/**
* Static function to call as a scheduled Job to purge old Doctrine files
+ *
+ * @param bool $echo
+ *
+ * @return string|void
*/
- public static function purgeJob()
+ public static function purgeJob($echo = false)
{
/** @var Cache $cache */
$cache = Grav::instance()['cache'];
$deleted_folders = $cache->purgeOldCache();
+ $msg = 'Purged ' . $deleted_folders . ' old cache folders...';
- echo 'Purged ' . $deleted_folders . ' old cache folders...';
+ if ($echo) {
+ echo $msg;
+ } else {
+ return $msg;
+ }
}
/**
* Static function to call as a scheduled Job to clear Grav cache
*
* @param string $type
+ * @return void
*/
public static function clearJob($type)
{
@@ -623,6 +656,10 @@ public static function clearJob($type)
echo strip_tags(implode("\n", $result));
}
+ /**
+ * @param Event $event
+ * @return void
+ */
public function onSchedulerInitialized(Event $event)
{
/** @var Scheduler $scheduler */
@@ -634,7 +671,7 @@ public function onSchedulerInitialized(Event $event)
$name = 'cache-purge';
$logs = 'logs/' . $name . '.out';
- $job = $scheduler->addFunction('Grav\Common\Cache::purgeJob', [], $name );
+ $job = $scheduler->addFunction('Grav\Common\Cache::purgeJob', [true], $name);
$job->at($at);
$job->output($logs);
$job->backlink('/config/system#caching');
@@ -645,12 +682,9 @@ public function onSchedulerInitialized(Event $event)
$name = 'cache-clear';
$logs = 'logs/' . $name . '.out';
- $job = $scheduler->addFunction('Grav\Common\Cache::clearJob', [$clear_type], $name );
+ $job = $scheduler->addFunction('Grav\Common\Cache::clearJob', [$clear_type], $name);
$job->at($at);
$job->output($logs);
$job->backlink('/config/system#caching');
-
}
-
-
}
diff --git a/system/src/Grav/Common/Composer.php b/system/src/Grav/Common/Composer.php
index c6abb2c6ca..667edf25c4 100644
--- a/system/src/Grav/Common/Composer.php
+++ b/system/src/Grav/Common/Composer.php
@@ -3,12 +3,18 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
+use function function_exists;
+
+/**
+ * Class Composer
+ * @package Grav\Common
+ */
class Composer
{
/** @const Default composer location */
@@ -21,12 +27,12 @@ class Composer
*/
public static function getComposerLocation()
{
- if (!\function_exists('shell_exec') || stripos(PHP_OS, 'win') === 0) {
+ if (!function_exists('shell_exec') || stripos(PHP_OS, 'win') === 0) {
return self::DEFAULT_PATH;
}
// check for global composer install
- $path = trim(shell_exec('command -v composer'));
+ $path = trim((string)shell_exec('command -v composer'));
// fall back to grav bundled composer
if (!$path || !preg_match('/(composer|composer\.phar)$/', $path)) {
diff --git a/system/src/Grav/Common/Config/CompiledBase.php b/system/src/Grav/Common/Config/CompiledBase.php
index 3e2022f0de..b462b4cfd9 100644
--- a/system/src/Grav/Common/Config/CompiledBase.php
+++ b/system/src/Grav/Common/Config/CompiledBase.php
@@ -3,78 +3,70 @@
/**
* @package Grav\Common\Config
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Config;
+use BadMethodCallException;
+use Exception;
use RocketTheme\Toolbox\File\PhpFile;
+use RuntimeException;
+use function get_class;
+use function is_array;
+/**
+ * Class CompiledBase
+ * @package Grav\Common\Config
+ */
abstract class CompiledBase
{
- /**
- * @var int Version number for the compiled file.
- */
+ /** @var int Version number for the compiled file. */
public $version = 1;
- /**
- * @var string Filename (base name) of the compiled configuration.
- */
+ /** @var string Filename (base name) of the compiled configuration. */
public $name;
- /**
- * @var string|bool Configuration checksum.
- */
+ /** @var string|bool Configuration checksum. */
public $checksum;
- /**
- * @var string Timestamp of compiled configuration
- */
- public $timestamp;
+ /** @var int Timestamp of compiled configuration */
+ public $timestamp = 0;
- /**
- * @var string Cache folder to be used.
- */
+ /** @var string Cache folder to be used. */
protected $cacheFolder;
- /**
- * @var array List of files to load.
- */
+ /** @var array List of files to load. */
protected $files;
- /**
- * @var string
- */
+ /** @var string */
protected $path;
- /**
- * @var mixed Configuration object.
- */
+ /** @var mixed Configuration object. */
protected $object;
/**
* @param string $cacheFolder Cache folder to be used.
* @param array $files List of files as returned from ConfigFileFinder class.
* @param string $path Base path for the file list.
- * @throws \BadMethodCallException
+ * @throws BadMethodCallException
*/
public function __construct($cacheFolder, array $files, $path)
{
if (!$cacheFolder) {
- throw new \BadMethodCallException('Cache folder not defined.');
+ throw new BadMethodCallException('Cache folder not defined.');
}
$this->path = $path ? rtrim($path, '\\/') . '/' : '';
$this->cacheFolder = $cacheFolder;
$this->files = $files;
- $this->timestamp = 0;
}
/**
* Get filename for the compiled PHP file.
*
- * @param string $name
+ * @param string|null $name
* @return $this
*/
public function name($name = null)
@@ -88,8 +80,12 @@ public function name($name = null)
/**
* Function gets called when cached configuration is saved.
+ *
+ * @return void
*/
- public function modified() {}
+ public function modified()
+ {
+ }
/**
* Get timestamp of compiled configuration
@@ -136,6 +132,9 @@ public function checksum()
return $this->checksum;
}
+ /**
+ * @return string
+ */
protected function createFilename()
{
return "{$this->cacheFolder}/{$this->name()->name}.php";
@@ -145,11 +144,14 @@ protected function createFilename()
* Create configuration object.
*
* @param array $data
+ * @return void
*/
abstract protected function createObject(array $data = []);
/**
* Finalize configuration object.
+ *
+ * @return void
*/
abstract protected function finalizeObject();
@@ -157,7 +159,8 @@ abstract protected function finalizeObject();
* Load single configuration file and append it to the correct position.
*
* @param string $name Name of the position.
- * @param string $filename File to be loaded.
+ * @param string|string[] $filename File(s) to be loaded.
+ * @return void
*/
abstract protected function loadFile($name, $filename);
@@ -197,10 +200,9 @@ protected function loadCompiledFile($filename)
}
$cache = include $filename;
- if (
- !\is_array($cache)
+ if (!is_array($cache)
|| !isset($cache['checksum'], $cache['data'], $cache['@class'])
- || $cache['@class'] !== \get_class($this)
+ || $cache['@class'] !== get_class($this)
) {
return false;
}
@@ -222,7 +224,8 @@ protected function loadCompiledFile($filename)
* Save compiled file.
*
* @param string $filename
- * @throws \RuntimeException
+ * @return void
+ * @throws RuntimeException
* @internal
*/
protected function saveCompiledFile($filename)
@@ -232,7 +235,7 @@ protected function saveCompiledFile($filename)
// Attempt to lock the file for writing.
try {
$file->lock(false);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// Another process has locked the file; we will check this in a bit.
}
@@ -242,7 +245,7 @@ protected function saveCompiledFile($filename)
}
$cache = [
- '@class' => \get_class($this),
+ '@class' => get_class($this),
'timestamp' => time(),
'checksum' => $this->checksum(),
'files' => $this->files,
@@ -256,6 +259,9 @@ protected function saveCompiledFile($filename)
$this->modified();
}
+ /**
+ * @return array
+ */
protected function getState()
{
return $this->object->toArray();
diff --git a/system/src/Grav/Common/Config/CompiledBlueprints.php b/system/src/Grav/Common/Config/CompiledBlueprints.php
index 9ad7be6f0b..e838591449 100644
--- a/system/src/Grav/Common/Config/CompiledBlueprints.php
+++ b/system/src/Grav/Common/Config/CompiledBlueprints.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Config
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -19,6 +19,12 @@
*/
class CompiledBlueprints extends CompiledBase
{
+ /**
+ * CompiledBlueprints constructor.
+ * @param string $cacheFolder
+ * @param array $files
+ * @param string $path
+ */
public function __construct($cacheFolder, array $files, $path)
{
parent::__construct($cacheFolder, $files, $path);
@@ -45,7 +51,7 @@ public function checksum()
/**
* Create configuration object.
*
- * @param array $data
+ * @param array $data
*/
protected function createObject(array $data = [])
{
@@ -64,6 +70,8 @@ protected function getTypes()
/**
* Finalize configuration object.
+ *
+ * @return void
*/
protected function finalizeObject()
{
@@ -74,6 +82,7 @@ protected function finalizeObject()
*
* @param string $name Name of the position.
* @param array $files Files to be loaded.
+ * @return void
*/
protected function loadFile($name, $files)
{
@@ -112,6 +121,9 @@ protected function loadFiles()
return true;
}
+ /**
+ * @return array
+ */
protected function getState()
{
return $this->object->getState();
diff --git a/system/src/Grav/Common/Config/CompiledConfig.php b/system/src/Grav/Common/Config/CompiledConfig.php
index 1c92edd9a2..2db6de5a6b 100644
--- a/system/src/Grav/Common/Config/CompiledConfig.php
+++ b/system/src/Grav/Common/Config/CompiledConfig.php
@@ -3,26 +3,33 @@
/**
* @package Grav\Common\Config
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Config;
use Grav\Common\File\CompiledYamlFile;
+use function is_callable;
+/**
+ * Class CompiledConfig
+ * @package Grav\Common\Config
+ */
class CompiledConfig extends CompiledBase
{
- /**
- * @var callable Blueprints loader.
- */
+ /** @var callable Blueprints loader. */
protected $callable;
+ /** @var bool */
+ protected $withDefaults = false;
+
/**
- * @var bool
+ * CompiledConfig constructor.
+ * @param string $cacheFolder
+ * @param array $files
+ * @param string $path
*/
- protected $withDefaults;
-
public function __construct($cacheFolder, array $files, $path)
{
parent::__construct($cacheFolder, $files, $path);
@@ -58,10 +65,11 @@ public function load($withDefaults = false)
* Create configuration object.
*
* @param array $data
+ * @return void
*/
protected function createObject(array $data = [])
{
- if ($this->withDefaults && empty($data) && \is_callable($this->callable)) {
+ if ($this->withDefaults && empty($data) && is_callable($this->callable)) {
$blueprints = $this->callable;
$data = $blueprints()->getDefaults();
}
@@ -71,6 +79,8 @@ protected function createObject(array $data = [])
/**
* Finalize configuration object.
+ *
+ * @return void
*/
protected function finalizeObject()
{
@@ -80,6 +90,8 @@ protected function finalizeObject()
/**
* Function gets called when cached configuration is saved.
+ *
+ * @return void
*/
public function modified()
{
@@ -91,6 +103,7 @@ public function modified()
*
* @param string $name Name of the position.
* @param string $filename File to be loaded.
+ * @return void
*/
protected function loadFile($name, $filename)
{
diff --git a/system/src/Grav/Common/Config/CompiledLanguages.php b/system/src/Grav/Common/Config/CompiledLanguages.php
index ef18145c8d..0a2668638b 100644
--- a/system/src/Grav/Common/Config/CompiledLanguages.php
+++ b/system/src/Grav/Common/Config/CompiledLanguages.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Config
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,8 +11,18 @@
use Grav\Common\File\CompiledYamlFile;
+/**
+ * Class CompiledLanguages
+ * @package Grav\Common\Config
+ */
class CompiledLanguages extends CompiledBase
{
+ /**
+ * CompiledLanguages constructor.
+ * @param string $cacheFolder
+ * @param array $files
+ * @param string $path
+ */
public function __construct($cacheFolder, array $files, $path)
{
parent::__construct($cacheFolder, $files, $path);
@@ -24,6 +34,7 @@ public function __construct($cacheFolder, array $files, $path)
* Create configuration object.
*
* @param array $data
+ * @return void
*/
protected function createObject(array $data = [])
{
@@ -32,6 +43,8 @@ protected function createObject(array $data = [])
/**
* Finalize configuration object.
+ *
+ * @return void
*/
protected function finalizeObject()
{
@@ -42,6 +55,8 @@ protected function finalizeObject()
/**
* Function gets called when cached configuration is saved.
+ *
+ * @return void
*/
public function modified()
{
@@ -53,6 +68,7 @@ public function modified()
*
* @param string $name Name of the position.
* @param string $filename File to be loaded.
+ * @return void
*/
protected function loadFile($name, $filename)
{
diff --git a/system/src/Grav/Common/Config/Config.php b/system/src/Grav/Common/Config/Config.php
index 7b808463b1..ca027bf46b 100644
--- a/system/src/Grav/Common/Config/Config.php
+++ b/system/src/Grav/Common/Config/Config.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Config
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -14,9 +14,15 @@
use Grav\Common\Data\Data;
use Grav\Common\Service\ConfigServiceProvider;
use Grav\Common\Utils;
+use function is_array;
+/**
+ * Class Config
+ * @package Grav\Common\Config
+ */
class Config extends Data
{
+ /** @var string */
public $environment;
/** @var string */
@@ -28,6 +34,9 @@ class Config extends Data
/** @var bool */
protected $modified = false;
+ /**
+ * @return string
+ */
public function key()
{
if (null === $this->key) {
@@ -37,6 +46,10 @@ public function key()
return $this->key;
}
+ /**
+ * @param string|null $checksum
+ * @return string|null
+ */
public function checksum($checksum = null)
{
if ($checksum !== null) {
@@ -46,6 +59,10 @@ public function checksum($checksum = null)
return $this->checksum;
}
+ /**
+ * @param bool|null $modified
+ * @return bool
+ */
public function modified($modified = null)
{
if ($modified !== null) {
@@ -55,6 +72,10 @@ public function modified($modified = null)
return $this->modified;
}
+ /**
+ * @param int|null $timestamp
+ * @return int
+ */
public function timestamp($timestamp = null)
{
if ($timestamp !== null) {
@@ -64,6 +85,9 @@ public function timestamp($timestamp = null)
return $this->timestamp;
}
+ /**
+ * @return $this
+ */
public function reload()
{
$grav = Grav::instance();
@@ -86,6 +110,9 @@ public function reload()
return $this;
}
+ /**
+ * @return void
+ */
public function debug()
{
/** @var Debugger $debugger */
@@ -97,11 +124,14 @@ public function debug()
}
}
+ /**
+ * @return void
+ */
public function init()
{
$setup = Grav::instance()['setup']->toArray();
foreach ($setup as $key => $value) {
- if ($key === 'streams' || !\is_array($value)) {
+ if ($key === 'streams' || !is_array($value)) {
// Optimized as streams and simple values are fully defined in setup.
$this->items[$key] = $value;
} else {
diff --git a/system/src/Grav/Common/Config/ConfigFileFinder.php b/system/src/Grav/Common/Config/ConfigFileFinder.php
index 1e7adca59a..28f9dc11a2 100644
--- a/system/src/Grav/Common/Config/ConfigFileFinder.php
+++ b/system/src/Grav/Common/Config/ConfigFileFinder.php
@@ -3,16 +3,23 @@
/**
* @package Grav\Common\Config
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Config;
+use DirectoryIterator;
use Grav\Common\Filesystem\Folder;
+use RecursiveDirectoryIterator;
+/**
+ * Class ConfigFileFinder
+ * @package Grav\Common\Config
+ */
class ConfigFileFinder
{
+ /** @var string */
protected $base = '';
/**
@@ -40,6 +47,7 @@ public function locateFiles(array $paths, $pattern = '|\.yaml$|', $levels = -1)
foreach ($paths as $folder) {
$list += $this->detectRecursive($folder, $pattern, $levels);
}
+
return $list;
}
@@ -61,6 +69,7 @@ public function getFiles(array $paths, $pattern = '|\.yaml$|', $levels = -1)
$list += $files[trim($path, '/')];
}
+
return $list;
}
@@ -78,6 +87,7 @@ public function listFiles(array $paths, $pattern = '|\.yaml$|', $levels = -1)
foreach ($paths as $folder) {
$list = array_merge_recursive($list, $this->detectAll($folder, $pattern, $levels));
}
+
return $list;
}
@@ -96,6 +106,7 @@ public function locateFileInFolder($filename, array $folders)
foreach ($folders as $folder) {
$list += $this->detectInFolder($folder, $filename);
}
+
return $list;
}
@@ -103,7 +114,7 @@ public function locateFileInFolder($filename, array $folders)
* Find filename from a list of folders.
*
* @param array $folders
- * @param string $filename
+ * @param string|null $filename
* @return array
*/
public function locateInFolders(array $folders, $filename = null)
@@ -113,6 +124,7 @@ public function locateInFolders(array $folders, $filename = null)
$path = trim(Folder::getRelativePath($folder), '/');
$list[$path] = $this->detectInFolder($folder, $filename);
}
+
return $list;
}
@@ -166,7 +178,7 @@ protected function detectRecursive($folder, $pattern, $levels)
'filters' => [
'pre-key' => $this->base,
'key' => $pattern,
- 'value' => function (\RecursiveDirectoryIterator $file) use ($path) {
+ 'value' => function (RecursiveDirectoryIterator $file) use ($path) {
return ['file' => "{$path}/{$file->getSubPathname()}", 'modified' => $file->getMTime()];
}
],
@@ -187,7 +199,7 @@ protected function detectRecursive($folder, $pattern, $levels)
* Detects all directories with the lookup file and returns them with last modification time.
*
* @param string $folder Location to look up from.
- * @param string $lookup Filename to be located (defaults to directory name).
+ * @param string|null $lookup Filename to be located (defaults to directory name).
* @return array
* @internal
*/
@@ -200,9 +212,7 @@ protected function detectInFolder($folder, $lookup = null)
$list = [];
if (is_dir($folder)) {
- $iterator = new \DirectoryIterator($folder);
-
- /** @var \DirectoryIterator $directory */
+ $iterator = new DirectoryIterator($folder);
foreach ($iterator as $directory) {
if (!$directory->isDir() || $directory->isDot()) {
continue;
@@ -244,7 +254,7 @@ protected function detectAll($folder, $pattern, $levels)
'filters' => [
'pre-key' => $this->base,
'key' => $pattern,
- 'value' => function (\RecursiveDirectoryIterator $file) use ($path) {
+ 'value' => function (RecursiveDirectoryIterator $file) use ($path) {
return ["{$path}/{$file->getSubPathname()}" => $file->getMTime()];
}
],
diff --git a/system/src/Grav/Common/Config/Languages.php b/system/src/Grav/Common/Config/Languages.php
index 9c76dc562a..05798c5739 100644
--- a/system/src/Grav/Common/Config/Languages.php
+++ b/system/src/Grav/Common/Config/Languages.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Config
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -12,24 +12,25 @@
use Grav\Common\Data\Data;
use Grav\Common\Utils;
+/**
+ * Class Languages
+ * @package Grav\Common\Config
+ */
class Languages extends Data
{
- /**
- * @var string|null
- */
+ /** @var string|null */
protected $checksum;
- /**
- * @var string|null
- */
- protected $modified;
+ /** @var bool */
+ protected $modified = false;
+
+ /** @var int */
+ protected $timestamp = 0;
/**
- * @var string|null
+ * @param string|null $checksum
+ * @return string|null
*/
- protected $timestamp;
-
-
public function checksum($checksum = null)
{
if ($checksum !== null) {
@@ -39,6 +40,10 @@ public function checksum($checksum = null)
return $this->checksum;
}
+ /**
+ * @param bool|null $modified
+ * @return bool
+ */
public function modified($modified = null)
{
if ($modified !== null) {
@@ -48,6 +53,10 @@ public function modified($modified = null)
return $this->modified;
}
+ /**
+ * @param int|null $timestamp
+ * @return int
+ */
public function timestamp($timestamp = null)
{
if ($timestamp !== null) {
@@ -57,6 +66,9 @@ public function timestamp($timestamp = null)
return $this->timestamp;
}
+ /**
+ * @return void
+ */
public function reformat()
{
if (isset($this->items['plugins'])) {
@@ -65,17 +77,29 @@ public function reformat()
}
}
+ /**
+ * @param array $data
+ * @return void
+ */
public function mergeRecursive(array $data)
{
$this->items = Utils::arrayMergeRecursiveUnique($this->items, $data);
}
+ /**
+ * @param string $lang
+ * @return array
+ */
public function flattenByLang($lang)
{
$language = $this->items[$lang];
return Utils::arrayFlattenDotNotation($language);
}
+ /**
+ * @param array $array
+ * @return array
+ */
public function unflatten($array)
{
return Utils::arrayUnflattenDotNotation($array);
diff --git a/system/src/Grav/Common/Config/Setup.php b/system/src/Grav/Common/Config/Setup.php
index 6a4d336bbe..b8c121a1c5 100644
--- a/system/src/Grav/Common/Config/Setup.php
+++ b/system/src/Grav/Common/Config/Setup.php
@@ -3,19 +3,28 @@
/**
* @package Grav\Common\Config
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Config;
+use BadMethodCallException;
use Grav\Common\File\CompiledYamlFile;
use Grav\Common\Data\Data;
use Grav\Common\Utils;
+use InvalidArgumentException;
use Pimple\Container;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use RuntimeException;
+use function defined;
+use function is_array;
+/**
+ * Class Setup
+ * @package Grav\Common\Config
+ */
class Setup extends Data
{
/**
@@ -28,28 +37,61 @@ class Setup extends Data
];
/**
- * @var string Current environment normalized to lower case.
+ * @var string|null Current environment normalized to lower case.
*/
public static $environment;
+ /** @var string */
+ public static $securityFile = 'config://security.yaml';
+
+ /** @var array */
protected $streams = [
- 'system' => [
+ 'user' => [
'type' => 'ReadOnlyStream',
+ 'force' => true,
'prefixes' => [
- '' => ['system'],
+ '' => [] // Set in constructor
]
],
- 'user' => [
- 'type' => 'ReadOnlyStream',
+ 'cache' => [
+ 'type' => 'Stream',
+ 'force' => true,
+ 'prefixes' => [
+ '' => [], // Set in constructor
+ 'images' => ['images']
+ ]
+ ],
+ 'log' => [
+ 'type' => 'Stream',
+ 'force' => true,
+ 'prefixes' => [
+ '' => [] // Set in constructor
+ ]
+ ],
+ 'tmp' => [
+ 'type' => 'Stream',
+ 'force' => true,
+ 'prefixes' => [
+ '' => [] // Set in constructor
+ ]
+ ],
+ 'backup' => [
+ 'type' => 'Stream',
'force' => true,
'prefixes' => [
- '' => ['user'],
+ '' => [] // Set in constructor
]
],
'environment' => [
'type' => 'ReadOnlyStream'
// If not defined, environment will be set up in the constructor.
],
+ 'system' => [
+ 'type' => 'ReadOnlyStream',
+ 'prefixes' => [
+ '' => ['system'],
+ ]
+ ],
'asset' => [
'type' => 'Stream',
'prefixes' => [
@@ -59,13 +101,13 @@ class Setup extends Data
'blueprints' => [
'type' => 'ReadOnlyStream',
'prefixes' => [
- '' => ['environment://blueprints', 'user://blueprints', 'system/blueprints'],
+ '' => ['environment://blueprints', 'user://blueprints', 'system://blueprints'],
]
],
'config' => [
'type' => 'ReadOnlyStream',
'prefixes' => [
- '' => ['environment://config', 'user://config', 'system/config'],
+ '' => ['environment://config', 'user://config', 'system://config'],
]
],
'plugins' => [
@@ -89,36 +131,7 @@ class Setup extends Data
'languages' => [
'type' => 'ReadOnlyStream',
'prefixes' => [
- '' => ['environment://languages', 'user://languages', 'system/languages'],
- ]
- ],
- 'cache' => [
- 'type' => 'Stream',
- 'force' => true,
- 'prefixes' => [
- '' => ['cache'],
- 'images' => ['images']
- ]
- ],
- 'log' => [
- 'type' => 'Stream',
- 'force' => true,
- 'prefixes' => [
- '' => ['logs']
- ]
- ],
- 'backup' => [
- 'type' => 'Stream',
- 'force' => true,
- 'prefixes' => [
- '' => ['backup']
- ]
- ],
- 'tmp' => [
- 'type' => 'Stream',
- 'force' => true,
- 'prefixes' => [
- '' => ['tmp']
+ '' => ['environment://languages', 'user://languages', 'system://languages'],
]
],
'image' => [
@@ -153,27 +166,59 @@ class Setup extends Data
*/
public function __construct($container)
{
+ // Configure main streams.
+ $abs = str_starts_with(GRAV_SYSTEM_PATH, '/');
+ $this->streams['system']['prefixes'][''] = $abs ? ['system', GRAV_SYSTEM_PATH] : ['system'];
+ $this->streams['user']['prefixes'][''] = [GRAV_USER_PATH];
+ $this->streams['cache']['prefixes'][''] = [GRAV_CACHE_PATH];
+ $this->streams['log']['prefixes'][''] = [GRAV_LOG_PATH];
+ $this->streams['tmp']['prefixes'][''] = [GRAV_TMP_PATH];
+ $this->streams['backup']['prefixes'][''] = [GRAV_BACKUP_PATH];
+
+ // If environment is not set, look for the environment variable and then the constant.
+ $environment = static::$environment ??
+ (defined('GRAV_ENVIRONMENT') ? GRAV_ENVIRONMENT : (getenv('GRAV_ENVIRONMENT') ?: null));
+
// If no environment is set, make sure we get one (CLI or hostname).
- if (!static::$environment) {
- if (\defined('GRAV_CLI')) {
- static::$environment = 'cli';
+ if (null === $environment) {
+ if (defined('GRAV_CLI')) {
+ $request = null;
+ $uri = null;
+ $environment = 'cli';
} else {
/** @var ServerRequestInterface $request */
$request = $container['request'];
- $host = $request->getUri()->getHost();
-
- static::$environment = Utils::substrToString($host, ':');
+ $uri = $request->getUri();
+ $environment = $uri->getHost();
}
}
// Resolve server aliases to the proper environment.
- $environment = $this->environments[static::$environment] ?? static::$environment;
+ static::$environment = static::$environments[$environment] ?? $environment;
// Pre-load setup.php which contains our initial configuration.
// Configuration may contain dynamic parts, which is why we need to always load it.
- // If "GRAV_SETUP_PATH" has been defined, use it, otherwise use defaults.
- $file = \defined('GRAV_SETUP_PATH') ? GRAV_SETUP_PATH : GRAV_ROOT . '/setup.php';
- $setup = is_file($file) ? (array) include $file : [];
+ // If GRAV_SETUP_PATH has been defined, use it, otherwise use defaults.
+ $setupFile = defined('GRAV_SETUP_PATH') ? GRAV_SETUP_PATH : (getenv('GRAV_SETUP_PATH') ?: null);
+ if (null !== $setupFile) {
+ // Make sure that the custom setup file exists. Terminates the script if not.
+ if (!str_starts_with($setupFile, '/')) {
+ $setupFile = GRAV_WEBROOT . '/' . $setupFile;
+ }
+ if (!is_file($setupFile)) {
+ echo 'GRAV_SETUP_PATH is defined but does not point to existing setup file.';
+ exit(1);
+ }
+ } else {
+ $setupFile = GRAV_WEBROOT . '/setup.php';
+ if (!is_file($setupFile)) {
+ $setupFile = GRAV_WEBROOT . '/' . GRAV_USER_PATH . '/setup.php';
+ }
+ if (!is_file($setupFile)) {
+ $setupFile = null;
+ }
+ }
+ $setup = $setupFile ? (array) include $setupFile : [];
// Add default streams defined in beginning of the class.
if (!isset($setup['streams']['schemes'])) {
@@ -184,19 +229,41 @@ public function __construct($container)
// Initialize class.
parent::__construct($setup);
+ $this->def('environment', static::$environment);
+
+ // Figure out path for the current environment.
+ $envPath = defined('GRAV_ENVIRONMENT_PATH') ? GRAV_ENVIRONMENT_PATH : (getenv('GRAV_ENVIRONMENT_PATH') ?: null);
+ if (null === $envPath) {
+ // Find common path for all environments and append current environment into it.
+ $envPath = defined('GRAV_ENVIRONMENTS_PATH') ? GRAV_ENVIRONMENTS_PATH : (getenv('GRAV_ENVIRONMENTS_PATH') ?: null);
+ if (null !== $envPath) {
+ $envPath .= '/';
+ } else {
+ // Use default location. Start with Grav 1.7 default.
+ $envPath = GRAV_WEBROOT. '/' . GRAV_USER_PATH . '/env';
+ if (is_dir($envPath)) {
+ $envPath = 'user://env/';
+ } else {
+ // Fallback to Grav 1.6 default.
+ $envPath = 'user://';
+ }
+ }
+ $envPath .= $this->get('environment');
+ }
+
// Set up environment.
- $this->def('environment', $environment);
- $this->def('streams.schemes.environment.prefixes', ['' => ["user://{$this->get('environment')}"]]);
+ $this->def('environment', static::$environment);
+ $this->def('streams.schemes.environment.prefixes', ['' => [$envPath]]);
}
/**
* @return $this
- * @throws \RuntimeException
- * @throws \InvalidArgumentException
+ * @throws RuntimeException
+ * @throws InvalidArgumentException
*/
public function init()
{
- $locator = new UniformResourceLocator(GRAV_ROOT);
+ $locator = new UniformResourceLocator(GRAV_WEBROOT);
$files = [];
$guard = 5;
@@ -220,7 +287,7 @@ public function init()
} while (--$guard);
if (!$guard) {
- throw new \RuntimeException('Setup: Configuration reload loop detected!');
+ throw new RuntimeException('Setup: Configuration reload loop detected!');
}
// Make sure we have valid setup.
@@ -233,7 +300,8 @@ public function init()
* Initialize resource locator by using the configuration.
*
* @param UniformResourceLocator $locator
- * @throws \BadMethodCallException
+ * @return void
+ * @throws BadMethodCallException
*/
public function initializeLocator(UniformResourceLocator $locator)
{
@@ -279,32 +347,66 @@ public function getStreams()
/**
* @param UniformResourceLocator $locator
- * @throws \InvalidArgumentException
- * @throws \BadMethodCallException
- * @throws \RuntimeException
+ * @return void
+ * @throws InvalidArgumentException
+ * @throws BadMethodCallException
+ * @throws RuntimeException
*/
protected function check(UniformResourceLocator $locator)
{
$streams = $this->items['streams']['schemes'] ?? null;
- if (!\is_array($streams)) {
- throw new \InvalidArgumentException('Configuration is missing streams.schemes!');
+ if (!is_array($streams)) {
+ throw new InvalidArgumentException('Configuration is missing streams.schemes!');
}
$diff = array_keys(array_diff_key($this->streams, $streams));
if ($diff) {
- throw new \InvalidArgumentException(
+ throw new InvalidArgumentException(
sprintf('Configuration is missing keys %s from streams.schemes!', implode(', ', $diff))
);
}
try {
+ // If environment is found, remove all missing override locations (B/C compatibility).
+ if ($locator->findResource('environment://', true)) {
+ $force = $this->get('streams.schemes.environment.force', false);
+ if (!$force) {
+ $prefixes = $this->get('streams.schemes.environment.prefixes.');
+ $update = false;
+ foreach ($prefixes as $i => $prefix) {
+ if ($locator->isStream($prefix)) {
+ if ($locator->findResource($prefix, true)) {
+ break;
+ }
+ } elseif (file_exists($prefix)) {
+ break;
+ }
+
+ unset($prefixes[$i]);
+ $update = true;
+ }
+
+ if ($update) {
+ $this->set('streams.schemes.environment.prefixes', ['' => array_values($prefixes)]);
+ $this->initializeLocator($locator);
+ }
+ }
+ }
+
if (!$locator->findResource('environment://config', true)) {
// If environment does not have its own directory, remove it from the lookup.
- $this->set('streams.schemes.environment.prefixes', ['config' => []]);
+ $prefixes = $this->get('streams.schemes.environment.prefixes');
+ $prefixes['config'] = [];
+
+ $this->set('streams.schemes.environment.prefixes', $prefixes);
$this->initializeLocator($locator);
}
- // Create security.yaml if it doesn't exist.
- $filename = $locator->findResource('config://security.yaml', true, true);
+ // Create security.yaml salt if it doesn't exist into existing configuration environment if possible.
+ $securityFile = Utils::basename(static::$securityFile);
+ $securityFolder = substr(static::$securityFile, 0, -\strlen($securityFile));
+ $securityFolder = $locator->findResource($securityFolder, true) ?: $locator->findResource($securityFolder, true, true);
+ $filename = "{$securityFolder}/{$securityFile}";
+
$security_file = CompiledYamlFile::instance($filename);
$security_content = (array)$security_file->content();
@@ -314,8 +416,8 @@ protected function check(UniformResourceLocator $locator)
$security_file->save();
$security_file->free();
}
- } catch (\RuntimeException $e) {
- throw new \RuntimeException(sprintf('Grav failed to initialize: %s', $e->getMessage()), 500, $e);
+ } catch (RuntimeException $e) {
+ throw new RuntimeException(sprintf('Grav failed to initialize: %s', $e->getMessage()), 500, $e);
}
}
}
diff --git a/system/src/Grav/Common/Data/Blueprint.php b/system/src/Grav/Common/Data/Blueprint.php
index e7037774f6..906fd79176 100644
--- a/system/src/Grav/Common/Data/Blueprint.php
+++ b/system/src/Grav/Common/Data/Blueprint.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Data
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -14,34 +14,69 @@
use Grav\Common\User\Interfaces\UserInterface;
use RocketTheme\Toolbox\Blueprints\BlueprintForm;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use RuntimeException;
+use function call_user_func_array;
+use function count;
+use function function_exists;
+use function in_array;
+use function is_array;
+use function is_int;
+use function is_object;
+use function is_string;
+use function strlen;
+/**
+ * Class Blueprint
+ * @package Grav\Common\Data
+ */
class Blueprint extends BlueprintForm
{
/** @var string */
protected $context = 'blueprints://';
+ /** @var string|null */
protected $scope;
- /** @var BlueprintSchema */
+ /** @var BlueprintSchema|null */
protected $blueprintSchema;
- /** @var array */
+ /** @var object|null */
+ protected $object;
+
+ /** @var array|null */
protected $defaults;
+ /** @var array */
protected $handlers = [];
+ /**
+ * Clone blueprint.
+ */
public function __clone()
{
- if ($this->blueprintSchema) {
+ if (null !== $this->blueprintSchema) {
$this->blueprintSchema = clone $this->blueprintSchema;
}
}
+ /**
+ * @param string $scope
+ * @return void
+ */
public function setScope($scope)
{
$this->scope = $scope;
}
+ /**
+ * @param object $object
+ * @return void
+ */
+ public function setObject($object)
+ {
+ $this->object = $object;
+ }
+
/**
* Set default values for field types.
*
@@ -57,6 +92,29 @@ public function setTypes(array $types)
return $this;
}
+ /**
+ * @param string $name
+ * @return array|mixed|null
+ * @since 1.7
+ */
+ public function getDefaultValue(string $name)
+ {
+ $path = explode('.', $name);
+ $current = $this->getDefaults();
+
+ foreach ($path as $field) {
+ if (is_object($current) && isset($current->{$field})) {
+ $current = $current->{$field};
+ } elseif (is_array($current) && isset($current[$field])) {
+ $current = $current[$field];
+ } else {
+ return null;
+ }
+ }
+
+ return $current;
+ }
+
/**
* Get nested structure containing default values defined in the blueprints.
*
@@ -88,7 +146,7 @@ public function init()
$current = &$this->items;
foreach ($path as $field) {
- if (\is_object($current)) {
+ if (is_object($current)) {
// Handle objects.
if (!isset($current->{$field})) {
$current->{$field} = [];
@@ -97,7 +155,7 @@ public function init()
$current = &$current->{$field};
} else {
// Handle arrays and scalars.
- if (!\is_array($current)) {
+ if (!is_array($current)) {
$current = [$field => []];
} elseif (!isset($current[$field])) {
$current[$field] = [];
@@ -111,6 +169,7 @@ public function init()
foreach ($data as $property => $call) {
$action = $call['action'];
$method = 'dynamic' . ucfirst($action);
+ $call['object'] = $this->object;
if (isset($this->handlers[$action])) {
$callable = $this->handlers[$action];
@@ -124,12 +183,44 @@ public function init()
return $this;
}
+ /**
+ * Extend blueprint with another blueprint.
+ *
+ * @param BlueprintForm|array $extends
+ * @param bool $append
+ * @return $this
+ */
+ public function extend($extends, $append = false)
+ {
+ parent::extend($extends, $append);
+
+ $this->deepInit($this->items);
+
+ return $this;
+ }
+
+ /**
+ * @param string $name
+ * @param mixed $value
+ * @param string $separator
+ * @param bool $append
+ * @return $this
+ */
+ public function embed($name, $value, $separator = '/', $append = false)
+ {
+ parent::embed($name, $value, $separator, $append);
+
+ $this->deepInit($this->items);
+
+ return $this;
+ }
+
/**
* Merge two arrays by using blueprints.
*
* @param array $data1
* @param array $data2
- * @param string $name Optional
+ * @param string|null $name Optional
* @param string $separator Optional
* @return array
*/
@@ -172,13 +263,15 @@ public function extra(array $data, $prefix = '')
* Validate data against blueprints.
*
* @param array $data
- * @throws \RuntimeException
+ * @param array $options
+ * @return void
+ * @throws RuntimeException
*/
- public function validate(array $data)
+ public function validate(array $data, array $options = [])
{
$this->initInternals();
- $this->blueprintSchema->validate($data);
+ $this->blueprintSchema->validate($data, $options);
}
/**
@@ -193,21 +286,23 @@ public function filter(array $data, bool $missingValuesAsNull = false, bool $kee
{
$this->initInternals();
- return $this->blueprintSchema->filter($data, $missingValuesAsNull, $keepEmptyValues);
+ return $this->blueprintSchema->filter($data, $missingValuesAsNull, $keepEmptyValues) ?? [];
}
/**
* Flatten data by using blueprints.
*
- * @param array $data
+ * @param array $data Data to be flattened.
+ * @param bool $includeAll True if undefined properties should also be included.
+ * @param string $name Property which will be flattened, useful for flattening repeating data.
* @return array
*/
- public function flattenData(array $data)
+ public function flattenData(array $data, bool $includeAll = false, string $name = '')
{
$this->initInternals();
- return $this->blueprintSchema->flattenData($data);
+ return $this->blueprintSchema->flattenData($data, $includeAll, $name);
}
@@ -223,6 +318,11 @@ public function schema()
return $this->blueprintSchema;
}
+ /**
+ * @param string $name
+ * @param callable $callable
+ * @return void
+ */
public function addDynamicHandler(string $name, callable $callable): void
{
$this->handlers[$name] = $callable;
@@ -230,6 +330,8 @@ public function addDynamicHandler(string $name, callable $callable): void
/**
* Initialize validator.
+ *
+ * @return void
*/
protected function initInternals()
{
@@ -250,12 +352,12 @@ protected function initInternals()
/**
* @param string $filename
- * @return string
+ * @return array
*/
protected function loadFile($filename)
{
$file = CompiledYamlFile::instance($filename);
- $content = $file->content();
+ $content = (array)$file->content();
$file->free();
return $content;
@@ -263,7 +365,7 @@ protected function loadFile($filename)
/**
* @param string|array $path
- * @param string $context
+ * @param string|null $context
* @return array
*/
protected function getFiles($path, $context = null)
@@ -271,16 +373,24 @@ protected function getFiles($path, $context = null)
/** @var UniformResourceLocator $locator */
$locator = Grav::instance()['locator'];
- if (\is_string($path) && !$locator->isStream($path)) {
+ if (is_string($path) && !$locator->isStream($path)) {
+ if (is_file($path)) {
+ return [$path];
+ }
+
// Find path overrides.
- $paths = (array) ($this->overrides[$path] ?? null);
+ if (null === $context) {
+ $paths = (array) ($this->overrides[$path] ?? null);
+ } else {
+ $paths = [];
+ }
// Add path pointing to default context.
if ($context === null) {
$context = $this->context;
}
- if ($context && $context[\strlen($context)-1] !== '/') {
+ if ($context && $context[strlen($context)-1] !== '/') {
$context .= '/';
}
@@ -297,7 +407,7 @@ protected function getFiles($path, $context = null)
$files = [];
foreach ($paths as $lookup) {
- if (\is_string($lookup) && strpos($lookup, '://')) {
+ if (is_string($lookup) && strpos($lookup, '://')) {
$files = array_merge($files, $locator->findResources($lookup));
} else {
$files[] = $lookup;
@@ -311,12 +421,13 @@ protected function getFiles($path, $context = null)
* @param array $field
* @param string $property
* @param array $call
+ * @return void
*/
protected function dynamicData(array &$field, $property, array &$call)
{
$params = $call['params'];
- if (\is_array($params)) {
+ if (is_array($params)) {
$function = array_shift($params);
} else {
$function = $params;
@@ -327,18 +438,18 @@ protected function dynamicData(array &$field, $property, array &$call)
$data = null;
if (!$f) {
- if (\function_exists($o)) {
- $data = \call_user_func_array($o, $params);
+ if (function_exists($o)) {
+ $data = call_user_func_array($o, $params);
}
} else {
if (method_exists($o, $f)) {
- $data = \call_user_func_array([$o, $f], $params);
+ $data = call_user_func_array([$o, $f], $params);
}
}
// If function returns a value,
if (null !== $data) {
- if (\is_array($data) && isset($field[$property]) && \is_array($field[$property])) {
+ if (is_array($data) && isset($field[$property]) && is_array($field[$property])) {
// Combine field and @data-field together.
$field[$property] += $data;
} else {
@@ -352,15 +463,33 @@ protected function dynamicData(array &$field, $property, array &$call)
* @param array $field
* @param string $property
* @param array $call
+ * @return void
*/
protected function dynamicConfig(array &$field, $property, array &$call)
{
- $value = $call['params'];
+ $params = $call['params'];
+ if (is_array($params)) {
+ $value = array_shift($params);
+ $params = array_shift($params);
+ } else {
+ $value = $params;
+ $params = [];
+ }
+
$default = $field[$property] ?? null;
$config = Grav::instance()['config']->get($value, $default);
+ if (!empty($field['value_only'])) {
+ $config = array_combine($config, $config);
+ }
if (null !== $config) {
- $field[$property] = $config;
+ if (!empty($params['append']) && is_array($config) && isset($field[$property]) && is_array($field[$property])) {
+ // Combine field and @config-field together.
+ $field[$property] += $config;
+ } else {
+ // Or create/replace field with @config-field.
+ $field[$property] = $config;
+ }
}
}
@@ -368,6 +497,7 @@ protected function dynamicConfig(array &$field, $property, array &$call)
* @param array $field
* @param string $property
* @param array $call
+ * @return void
*/
protected function dynamicSecurity(array &$field, $property, array &$call)
{
@@ -380,18 +510,48 @@ protected function dynamicSecurity(array &$field, $property, array &$call)
/** @var UserInterface|null $user */
$user = $grav['user'] ?? null;
- foreach ($actions as $action) {
- if (!$user || !$user->authorize($action)) {
- $this->addPropertyRecursive($field, 'validate', ['ignore' => true]);
- return;
+ $success = null !== $user;
+ if ($success) {
+ $success = $this->resolveActions($user, $actions);
+ }
+ if (!$success) {
+ static::addPropertyRecursive($field, 'validate', ['ignore' => true]);
+ }
+ }
+
+ /**
+ * @param UserInterface|null $user
+ * @param array $actions
+ * @param string $op
+ * @return bool
+ */
+ protected function resolveActions(?UserInterface $user, array $actions, string $op = 'and')
+ {
+ if (null === $user) {
+ return false;
+ }
+
+ $c = $i = count($actions);
+ foreach ($actions as $key => $action) {
+ if (!is_int($key) && is_array($actions)) {
+ $i -= $this->resolveActions($user, $action, $key);
+ } elseif ($user->authorize($action)) {
+ $i--;
}
}
+
+ if ($op === 'and') {
+ return $i === 0;
+ }
+
+ return $c !== $i;
}
/**
* @param array $field
* @param string $property
* @param array $call
+ * @return void
*/
protected function dynamicScope(array &$field, $property, array &$call)
{
@@ -400,20 +560,26 @@ protected function dynamicScope(array &$field, $property, array &$call)
}
$scopes = (array)$call['params'];
- $matches = \in_array($this->scope, $scopes, true);
+ $matches = in_array($this->scope, $scopes, true);
if ($this->scope && $property !== 'ignore') {
$matches = !$matches;
}
if ($matches) {
- $this->addPropertyRecursive($field, 'validate', ['ignore' => true]);
+ static::addPropertyRecursive($field, 'validate', ['ignore' => true]);
return;
}
}
- protected function addPropertyRecursive(array &$field, $property, $value)
+ /**
+ * @param array $field
+ * @param string $property
+ * @param mixed $value
+ * @return void
+ */
+ public static function addPropertyRecursive(array &$field, $property, $value)
{
- if (\is_array($value) && isset($field[$property]) && \is_array($field[$property])) {
+ if (is_array($value) && isset($field[$property]) && is_array($field[$property])) {
$field[$property] = array_merge_recursive($field[$property], $value);
} else {
$field[$property] = $value;
@@ -421,7 +587,7 @@ protected function addPropertyRecursive(array &$field, $property, $value)
if (!empty($field['fields'])) {
foreach ($field['fields'] as $key => &$child) {
- $this->addPropertyRecursive($child, $property, $value);
+ static::addPropertyRecursive($child, $property, $value);
}
}
}
diff --git a/system/src/Grav/Common/Data/BlueprintSchema.php b/system/src/Grav/Common/Data/BlueprintSchema.php
index 898450d28f..8cc63036ed 100644
--- a/system/src/Grav/Common/Data/BlueprintSchema.php
+++ b/system/src/Grav/Common/Data/BlueprintSchema.php
@@ -3,21 +3,33 @@
/**
* @package Grav\Common\Data
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Data;
+use Grav\Common\Config\Config;
use Grav\Common\Grav;
use RocketTheme\Toolbox\ArrayTraits\Export;
use RocketTheme\Toolbox\ArrayTraits\ExportInterface;
use RocketTheme\Toolbox\Blueprints\BlueprintSchema as BlueprintSchemaBase;
+use RuntimeException;
+use function is_array;
+use function is_string;
+/**
+ * Class BlueprintSchema
+ * @package Grav\Common\Data
+ */
class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface
{
use Export;
+ /** @var array */
+ protected $filter = ['validation' => true, 'xss_check' => true];
+
+ /** @var array */
protected $ignoreFormKeys = [
'title' => true,
'help' => true,
@@ -44,23 +56,34 @@ public function getType($name)
return $this->types[$name] ?? [];
}
+ /**
+ * @param string $name
+ * @return array|null
+ */
+ public function getNestedRules(string $name)
+ {
+ return $this->getNested($name);
+ }
+
/**
* Validate data against blueprints.
*
* @param array $data
- * @throws \RuntimeException
+ * @param array $options
+ * @return void
+ * @throws RuntimeException
*/
- public function validate(array $data)
+ public function validate(array $data, array $options = [])
{
try {
- $messages = $this->validateArray($data, $this->nested);
-
- } catch (\RuntimeException $e) {
+ $validation = $this->items['']['form']['validation'] ?? 'loose';
+ $messages = $this->validateArray($data, $this->nested, $validation === 'strict', $options['xss_check'] ?? true);
+ } catch (RuntimeException $e) {
throw (new ValidationException($e->getMessage(), $e->getCode(), $e))->setMessages();
}
if (!empty($messages)) {
- throw (new ValidationException())->setMessages($messages);
+ throw (new ValidationException('', 400))->setMessages($messages);
}
}
@@ -71,7 +94,7 @@ public function validate(array $data)
*/
public function processForm(array $data, array $toggles = [])
{
- return $this->processFormRecursive($data, $toggles, $this->nested);
+ return $this->processFormRecursive($data, $toggles, $this->nested) ?? [];
}
/**
@@ -84,18 +107,37 @@ public function processForm(array $data, array $toggles = [])
*/
public function filter(array $data, $missingValuesAsNull = false, $keepEmptyValues = false)
{
- return $this->filterArray($data, $this->nested, $missingValuesAsNull, $keepEmptyValues);
+ $this->buildIgnoreNested($this->nested);
+
+ return $this->filterArray($data, $this->nested, '', $missingValuesAsNull, $keepEmptyValues) ?? [];
}
/**
* Flatten data by using blueprints.
*
- * @param array $data Data to be flattened.
+ * @param array $data Data to be flattened.
+ * @param bool $includeAll True if undefined properties should also be included.
+ * @param string $name Property which will be flattened, useful for flattening repeating data.
* @return array
*/
- public function flattenData(array $data)
+ public function flattenData(array $data, bool $includeAll = false, string $name = '')
{
- return $this->flattenArray($data, $this->nested, '');
+ $prefix = $name !== '' ? $name . '.' : '';
+
+ $list = [];
+ if ($includeAll) {
+ $items = $name !== '' ? $this->getProperty($name)['fields'] ?? [] : $this->items;
+ foreach ($items as $key => $rules) {
+ $type = $rules['type'] ?? '';
+ if (!str_starts_with($type, '_') && !str_contains($key, '*')) {
+ $list[$prefix . $key] = null;
+ }
+ }
+ }
+
+ $nested = $this->getNestedRules($name);
+
+ return array_replace($list, $this->flattenArray($data, $nested, $prefix));
}
/**
@@ -123,22 +165,26 @@ protected function flattenArray(array $data, array $rules, string $prefix)
$array[$prefix.$key] = $field;
}
}
+
return $array;
}
/**
* @param array $data
* @param array $rules
+ * @param bool $strict
+ * @param bool $xss
* @return array
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
- protected function validateArray(array $data, array $rules)
+ protected function validateArray(array $data, array $rules, bool $strict, bool $xss = true)
{
$messages = $this->checkRequired($data, $rules);
- foreach ($data as $key => $field) {
+ foreach ($data as $key => $child) {
$val = $rules[$key] ?? $rules['*'] ?? null;
- $rule = \is_string($val) ? $this->items[$val] : null;
+ $rule = is_string($val) ? $this->items[$val] : null;
+ $checkXss = $xss;
if ($rule) {
// Item has been defined in blueprints.
@@ -147,13 +193,26 @@ protected function validateArray(array $data, array $rules)
continue;
}
- $messages += Validation::validate($field, $rule);
- } elseif (\is_array($field) && \is_array($val)) {
+ $messages += Validation::validate($child, $rule);
+
+ } elseif (is_array($child) && is_array($val)) {
// Array has been defined in blueprints.
- $messages += $this->validateArray($field, $val);
- } elseif (isset($rules['validation']) && $rules['validation'] === 'strict') {
- // Undefined/extra item.
- throw new \RuntimeException(sprintf('%s is not defined in blueprints', $key));
+ $messages += $this->validateArray($child, $val, $strict);
+ $checkXss = false;
+
+ } elseif ($strict) {
+ // Undefined/extra item in strict mode.
+ /** @var Config $config */
+ $config = Grav::instance()['config'];
+ if (!$config->get('system.strict_mode.blueprint_strict_compat', true)) {
+ throw new RuntimeException(sprintf('%s is not defined in blueprints', $key), 400);
+ }
+
+ user_error(sprintf('Having extra key %s in your data is deprecated with blueprint having \'validation: strict\'', $key), E_USER_DEPRECATED);
+ }
+
+ if ($checkXss) {
+ $messages += Validation::checkSafety($child, $rule ?: ['name' => $key]);
}
}
@@ -163,51 +222,55 @@ protected function validateArray(array $data, array $rules)
/**
* @param array $data
* @param array $rules
+ * @param string $parent
* @param bool $missingValuesAsNull
* @param bool $keepEmptyValues
- * @return array
+ * @return array|null
*/
- protected function filterArray(array $data, array $rules, $missingValuesAsNull, $keepEmptyValues)
+ protected function filterArray(array $data, array $rules, string $parent, bool $missingValuesAsNull, bool $keepEmptyValues)
{
$results = [];
- if ($missingValuesAsNull) {
- // First pass is to fill up all the fields with null. This is done to lock the ordering of the fields.
- foreach ($rules as $key => $rule) {
- if ($key && !isset($results[$key])) {
- $val = $rules[$key] ?? $rules['*'] ?? null;
- $rule = \is_string($val) ? $this->items[$val] : null;
-
- if (empty($rule['disabled']) && empty($rule['validate']['ignore'])) {
- continue;
- }
- }
- }
- }
-
foreach ($data as $key => $field) {
$val = $rules[$key] ?? $rules['*'] ?? null;
- $rule = \is_string($val) ? $this->items[$val] : null;
+ $rule = is_string($val) ? $this->items[$val] : $this->items[$parent . $key] ?? null;
- if ($rule) {
- // Item has been defined in blueprints.
- if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) {
- // Skip any data in the ignored field.
+ if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) {
+ // Skip any data in the ignored field.
+ unset($results[$key]);
+ continue;
+ }
+
+ if (null === $field) {
+ if ($missingValuesAsNull) {
+ $results[$key] = null;
+ } else {
unset($results[$key]);
- continue;
}
+ continue;
+ }
+ $isParent = isset($val['*']);
+ $type = $rule['type'] ?? null;
+
+ if (!$isParent && $type && $type !== '_parent') {
$field = Validation::filter($field, $rule);
- } elseif (\is_array($field) && \is_array($val)) {
+ } elseif (is_array($field) && is_array($val)) {
// Array has been defined in blueprints.
- $field = $this->filterArray($field, $val, $missingValuesAsNull, $keepEmptyValues);
+ $k = $isParent ? '*' : $key;
+ $field = $this->filterArray($field, $val, $parent . $k . '.', $missingValuesAsNull, $keepEmptyValues);
+ if (null === $field) {
+ // Nested parent has no values.
+ unset($results[$key]);
+ continue;
+ }
} elseif (isset($rules['validation']) && $rules['validation'] === 'strict') {
// Skip any extra data.
continue;
}
- if ($keepEmptyValues || (null !== $field && (!\is_array($field) || !empty($field)))) {
+ if ($keepEmptyValues || (null !== $field && (!is_array($field) || !empty($field)))) {
$results[$key] = $field;
}
}
@@ -215,6 +278,31 @@ protected function filterArray(array $data, array $rules, $missingValuesAsNull,
return $results ?: null;
}
+ /**
+ * @param array $nested
+ * @param string $parent
+ * @return bool
+ */
+ protected function buildIgnoreNested(array $nested, $parent = '')
+ {
+ $ignore = true;
+ foreach ($nested as $key => $val) {
+ $key = $parent . $key;
+ if (is_array($val)) {
+ $ignore = $this->buildIgnoreNested($val, $key . '.') && $ignore; // Keep the order!
+ } else {
+ $child = $this->items[$key] ?? null;
+ $ignore = $ignore && (!$child || !empty($child['disabled']) || !empty($child['validate']['ignore']));
+ }
+ }
+ if ($ignore) {
+ $key = trim($parent, '.');
+ $this->items[$key]['validate']['ignore'] = true;
+ }
+
+ return $ignore;
+ }
+
/**
* @param array|null $data
* @param array $toggles
@@ -232,8 +320,23 @@ protected function processFormRecursive(?array $data, array $toggles, array $nes
continue;
}
if (is_array($value)) {
+ // Special toggle handling for all the nested data.
+ $toggle = $toggles[$key] ?? [];
+ if (!is_array($toggle)) {
+ if (!$toggle) {
+ $data[$key] = null;
+
+ continue;
+ }
+
+ $toggle = [];
+ }
// Recursively fetch the items.
- $data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggles[$key] ?? [], $value);
+ $childData = $data[$key] ?? null;
+ if (null !== $childData && !is_array($childData)) {
+ throw new \RuntimeException(sprintf("Bad form data for field collection '%s': %s used instead of an array", $key, gettype($childData)));
+ }
+ $data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggle, $value);
} else {
$field = $this->get($value);
// Do not add the field if:
@@ -244,8 +347,8 @@ protected function processFormRecursive(?array $data, array $toggles, array $nes
|| !empty($field['disabled'])
// Field validation is set to be ignored
|| !empty($field['validate']['ignore'])
- // Field is toggleable and the toggle is turned off
- || (!empty($field['toggleable']) && empty($toggles[$key]))
+ // Field is overridable and the toggle is turned off
+ || (!empty($field['overridable']) && empty($toggles[$key]))
) {
continue;
}
@@ -268,7 +371,7 @@ protected function checkRequired(array $data, array $fields)
$messages = [];
foreach ($fields as $name => $field) {
- if (!\is_string($field)) {
+ if (!is_string($field)) {
continue;
}
@@ -279,10 +382,15 @@ protected function checkRequired(array $data, array $fields)
continue;
}
+ // Skip overridable fields without value.
+ // TODO: We need better overridable support, which is not just ignoring required values but also looking if defaults are good.
+ if (!empty($field['overridable']) && !isset($data[$name])) {
+ continue;
+ }
+
// Check if required.
if (isset($field['validate']['required'])
&& $field['validate']['required'] === true) {
-
if (isset($data[$name])) {
continue;
}
@@ -304,6 +412,7 @@ protected function checkRequired(array $data, array $fields)
* @param array $field
* @param string $property
* @param array $call
+ * @return void
*/
protected function dynamicConfig(array &$field, $property, array &$call)
{
diff --git a/system/src/Grav/Common/Data/Blueprints.php b/system/src/Grav/Common/Data/Blueprints.php
index 1d0fcc689c..dfdd46f726 100644
--- a/system/src/Grav/Common/Data/Blueprints.php
+++ b/system/src/Grav/Common/Data/Blueprints.php
@@ -3,15 +3,23 @@
/**
* @package Grav\Common\Data
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Data;
+use DirectoryIterator;
use Grav\Common\Grav;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use RuntimeException;
+use function is_array;
+use function is_object;
+/**
+ * Class Blueprints
+ * @package Grav\Common\Data
+ */
class Blueprints
{
/** @var array|string */
@@ -34,7 +42,7 @@ public function __construct($search = 'blueprints://')
*
* @param string $type Blueprint type.
* @return Blueprint
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
public function get($type)
{
@@ -65,10 +73,9 @@ public function types()
if ($locator->isStream($this->search)) {
$iterator = $locator->getIterator($this->search);
} else {
- $iterator = new \DirectoryIterator($this->search);
+ $iterator = new DirectoryIterator($this->search);
}
- /** @var \DirectoryIterator $file */
foreach ($iterator as $file) {
if (!$file->isFile() || '.' . $file->getExtension() !== YAML_EXT) {
continue;
@@ -92,7 +99,7 @@ protected function loadFile($name)
{
$blueprint = new Blueprint($name);
- if (\is_array($this->search) || \is_object($this->search)) {
+ if (is_array($this->search) || is_object($this->search)) {
// Page types.
$blueprint->setOverrides($this->search);
$blueprint->setContext('blueprints://pages');
@@ -102,7 +109,7 @@ protected function loadFile($name)
try {
$blueprint->load()->init();
- } catch (\RuntimeException $e) {
+ } catch (RuntimeException $e) {
$log = Grav::instance()['log'];
$log->error(sprintf('Blueprint %s cannot be loaded: %s', $name, $e->getMessage()));
diff --git a/system/src/Grav/Common/Data/Data.php b/system/src/Grav/Common/Data/Data.php
index bfae2dae84..0f09b2d26c 100644
--- a/system/src/Grav/Common/Data/Data.php
+++ b/system/src/Grav/Common/Data/Data.php
@@ -3,43 +3,80 @@
/**
* @package Grav\Common\Data
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Data;
+use ArrayAccess;
+use Exception;
+use JsonSerializable;
use RocketTheme\Toolbox\ArrayTraits\Countable;
use RocketTheme\Toolbox\ArrayTraits\Export;
use RocketTheme\Toolbox\ArrayTraits\ExportInterface;
use RocketTheme\Toolbox\ArrayTraits\NestedArrayAccessWithGetters;
-use RocketTheme\Toolbox\File\File;
use RocketTheme\Toolbox\File\FileInterface;
+use RuntimeException;
+use function func_get_args;
+use function is_array;
+use function is_callable;
+use function is_object;
-class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable, ExportInterface
+/**
+ * Class Data
+ * @package Grav\Common\Data
+ */
+class Data implements DataInterface, ArrayAccess, \Countable, JsonSerializable, ExportInterface
{
use NestedArrayAccessWithGetters, Countable, Export;
/** @var string */
protected $gettersVariable = 'items';
-
/** @var array */
protected $items;
-
- /** @var Blueprint */
+ /** @var Blueprint|callable|null */
protected $blueprints;
-
- /** @var File */
+ /** @var FileInterface|null */
protected $storage;
+ /** @var bool */
+ private $missingValuesAsNull = false;
+ /** @var bool */
+ private $keepEmptyValues = true;
+
/**
* @param array $items
- * @param Blueprint|callable $blueprints
+ * @param Blueprint|callable|null $blueprints
*/
public function __construct(array $items = [], $blueprints = null)
{
$this->items = $items;
- $this->blueprints = $blueprints;
+ if (null !== $blueprints) {
+ $this->blueprints = $blueprints;
+ }
+ }
+
+ /**
+ * @param bool $value
+ * @return $this
+ */
+ public function setKeepEmptyValues(bool $value)
+ {
+ $this->keepEmptyValues = $value;
+
+ return $this;
+ }
+
+ /**
+ * @param bool $value
+ * @return $this
+ */
+ public function setMissingValuesAsNull(bool $value)
+ {
+ $this->missingValuesAsNull = $value;
+
+ return $this;
}
/**
@@ -64,20 +101,20 @@ public function value($name, $default = null, $separator = '.')
* @param mixed $value Value to be joined.
* @param string $separator Separator, defaults to '.'
* @return $this
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
public function join($name, $value, $separator = '.')
{
$old = $this->get($name, null, $separator);
if ($old !== null) {
- if (!\is_array($old)) {
- throw new \RuntimeException('Value ' . $old);
+ if (!is_array($old)) {
+ throw new RuntimeException('Value ' . $old);
}
- if (\is_object($value)) {
+ if (is_object($value)) {
$value = (array) $value;
- } elseif (!\is_array($value)) {
- throw new \RuntimeException('Value ' . $value);
+ } elseif (!is_array($value)) {
+ throw new RuntimeException('Value ' . $value);
}
$value = $this->blueprints()->mergeData($old, $value, $name, $separator);
@@ -110,7 +147,7 @@ public function getDefaults()
*/
public function joinDefaults($name, $value, $separator = '.')
{
- if (\is_object($value)) {
+ if (is_object($value)) {
$value = (array) $value;
}
@@ -131,14 +168,14 @@ public function joinDefaults($name, $value, $separator = '.')
* @param array|object $value Value to be joined.
* @param string $separator Separator, defaults to '.'
* @return array
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
public function getJoined($name, $value, $separator = '.')
{
- if (\is_object($value)) {
+ if (is_object($value)) {
$value = (array) $value;
- } elseif (!\is_array($value)) {
- throw new \RuntimeException('Value ' . $value);
+ } elseif (!is_array($value)) {
+ throw new RuntimeException('Value ' . $value);
}
$old = $this->get($name, null, $separator);
@@ -148,8 +185,8 @@ public function getJoined($name, $value, $separator = '.')
return $value;
}
- if (!\is_array($old)) {
- throw new \RuntimeException('Value ' . $old);
+ if (!is_array($old)) {
+ throw new RuntimeException('Value ' . $old);
}
// Return joined data.
@@ -187,7 +224,7 @@ public function setDefaults(array $data)
* Validate by blueprints.
*
* @return $this
- * @throws \Exception
+ * @throws Exception
*/
public function validate()
{
@@ -202,8 +239,8 @@ public function validate()
public function filter()
{
$args = func_get_args();
- $missingValuesAsNull = (bool)(array_shift($args) ?: false);
- $keepEmptyValues = (bool)(array_shift($args) ?: false);
+ $missingValuesAsNull = (bool)(array_shift($args) ?? $this->missingValuesAsNull);
+ $keepEmptyValues = (bool)(array_shift($args) ?? $this->keepEmptyValues);
$this->items = $this->blueprints()->filter($this->items, $missingValuesAsNull, $keepEmptyValues);
@@ -227,19 +264,22 @@ public function extra()
*/
public function blueprints()
{
- if (!$this->blueprints){
- $this->blueprints = new Blueprint;
- } elseif (\is_callable($this->blueprints)) {
+ if (null === $this->blueprints) {
+ $this->blueprints = new Blueprint();
+ } elseif (is_callable($this->blueprints)) {
// Lazy load blueprints.
$blueprints = $this->blueprints;
$this->blueprints = $blueprints();
}
+
return $this->blueprints;
}
/**
* Save data if storage has been defined.
- * @throws \RuntimeException
+ *
+ * @return void
+ * @throws RuntimeException
*/
public function save()
{
@@ -280,8 +320,8 @@ public function raw()
/**
* Set or get the data storage.
*
- * @param FileInterface $storage Optionally enter a new storage.
- * @return FileInterface
+ * @param FileInterface|null $storage Optionally enter a new storage.
+ * @return FileInterface|null
*/
public function file(FileInterface $storage = null)
{
@@ -292,6 +332,10 @@ public function file(FileInterface $storage = null)
return $this->storage;
}
+ /**
+ * @return array
+ */
+ #[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->items;
diff --git a/system/src/Grav/Common/Data/DataInterface.php b/system/src/Grav/Common/Data/DataInterface.php
index 91279b28ea..a4bc2140d1 100644
--- a/system/src/Grav/Common/Data/DataInterface.php
+++ b/system/src/Grav/Common/Data/DataInterface.php
@@ -3,14 +3,19 @@
/**
* @package Grav\Common\Data
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Data;
+use Exception;
use RocketTheme\Toolbox\File\FileInterface;
+/**
+ * Interface DataInterface
+ * @package Grav\Common\Data
+ */
interface DataInterface
{
/**
@@ -35,35 +40,44 @@ public function merge(array $data);
/**
* Return blueprints.
+ *
+ * @return Blueprint
*/
public function blueprints();
/**
* Validate by blueprints.
*
- * @throws \Exception
+ * @return $this
+ * @throws Exception
*/
public function validate();
/**
* Filter all items by using blueprints.
+ *
+ * @return $this
*/
public function filter();
/**
* Get extra items which haven't been defined in blueprints.
+ *
+ * @return array
*/
public function extra();
/**
* Save data into the file.
+ *
+ * @return void
*/
public function save();
/**
* Set or get the data storage.
*
- * @param FileInterface $storage Optionally enter a new storage.
+ * @param FileInterface|null $storage Optionally enter a new storage.
* @return FileInterface
*/
public function file(FileInterface $storage = null);
diff --git a/system/src/Grav/Common/Data/Validation.php b/system/src/Grav/Common/Data/Validation.php
index 7779b1c27a..4b2bbf776a 100644
--- a/system/src/Grav/Common/Data/Validation.php
+++ b/system/src/Grav/Common/Data/Validation.php
@@ -3,16 +3,35 @@
/**
* @package Grav\Common\Data
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Data;
+use ArrayAccess;
+use Countable;
+use DateTime;
+use Grav\Common\Config\Config;
use Grav\Common\Grav;
+use Grav\Common\Language\Language;
+use Grav\Common\Security;
+use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Common\Yaml;
+use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
+use Traversable;
+use function count;
+use function is_array;
+use function is_bool;
+use function is_float;
+use function is_int;
+use function is_string;
+/**
+ * Class Validation
+ * @package Grav\Common\Data
+ */
class Validation
{
/**
@@ -44,7 +63,7 @@ public static function validate($value, array $field)
$name = ucfirst($field['label'] ?? $field['name']);
$message = (string) isset($field['validate']['message'])
? $language->translate($field['validate']['message'])
- : $language->translate('GRAV.FORM.INVALID_INPUT', null, true) . ' "' . $language->translate($name) . '"';
+ : $language->translate('GRAV.FORM.INVALID_INPUT') . ' "' . $language->translate($name) . '"';
// Validate type with fallback type text.
@@ -78,6 +97,92 @@ public static function validate($value, array $field)
return $messages;
}
+ /**
+ * @param mixed $value
+ * @param array $field
+ * @return array
+ */
+ public static function checkSafety($value, array $field)
+ {
+ $messages = [];
+
+ $type = $field['validate']['type'] ?? $field['type'] ?? 'text';
+ $options = $field['xss_check'] ?? [];
+ if ($options === false || $type === 'unset') {
+ return $messages;
+ }
+ if (!is_array($options)) {
+ $options = [];
+ }
+
+ $name = ucfirst($field['label'] ?? $field['name'] ?? 'UNKNOWN');
+
+ /** @var UserInterface $user */
+ $user = Grav::instance()['user'] ?? null;
+ /** @var Config $config */
+ $config = Grav::instance()['config'];
+
+ $xss_whitelist = $config->get('security.xss_whitelist', 'admin.super');
+
+ // Get language class.
+ /** @var Language $language */
+ $language = Grav::instance()['language'];
+
+ if (!static::authorize($xss_whitelist, $user)) {
+ $defaults = Security::getXssDefaults();
+ $options += $defaults;
+ $options['enabled_rules'] += $defaults['enabled_rules'];
+ if (!empty($options['safe_protocols'])) {
+ $options['invalid_protocols'] = array_diff($options['invalid_protocols'], $options['safe_protocols']);
+ }
+ if (!empty($options['safe_tags'])) {
+ $options['dangerous_tags'] = array_diff($options['dangerous_tags'], $options['safe_tags']);
+ }
+
+ if (is_string($value)) {
+ $violation = Security::detectXss($value, $options);
+ if ($violation) {
+ $messages[$name][] = $language->translate(['GRAV.FORM.XSS_ISSUES', $language->translate($name)], null, true);
+ }
+ } elseif (is_array($value)) {
+ $violations = Security::detectXssFromArray($value, "{$name}.", $options);
+ if ($violations) {
+ $messages[$name][] = $language->translate(['GRAV.FORM.XSS_ISSUES', $language->translate($name)], null, true);
+ }
+ }
+ }
+
+ return $messages;
+ }
+
+ /**
+ * Checks user authorisation to the action.
+ *
+ * @param string|string[] $action
+ * @param UserInterface|null $user
+ * @return bool
+ */
+ public static function authorize($action, UserInterface $user = null)
+ {
+ if (!$user) {
+ return false;
+ }
+
+ $action = (array)$action;
+ foreach ($action as $a) {
+ // Ignore 'admin.super' if it's not the only value to be checked.
+ if ($a === 'admin.super' && count($action) > 1 && $user instanceof FlexObjectInterface) {
+ continue;
+ }
+
+ if ($user->authorize($a)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Filter value against a blueprint field definition.
*
@@ -123,7 +228,7 @@ public static function filter($value, array $field)
*/
public static function typeText($value, array $params, array $field)
{
- if (!\is_string($value) && !is_numeric($value)) {
+ if (!is_string($value) && !is_numeric($value)) {
return false;
}
@@ -133,62 +238,123 @@ public static function typeText($value, array $params, array $field)
$value = trim($value);
}
- if (isset($params['min']) && \strlen($value) < $params['min']) {
+ $value = preg_replace("/\r\n|\r/um", "\n", $value);
+ $len = mb_strlen($value);
+
+ $min = (int)($params['min'] ?? 0);
+ if ($min && $len < $min) {
return false;
}
- if (isset($params['max']) && \strlen($value) > $params['max']) {
+ $multiline = isset($params['multiline']) && $params['multiline'];
+
+ $max = (int)($params['max'] ?? ($multiline ? 65536 : 2048));
+ if ($max && $len > $max) {
return false;
}
- $min = $params['min'] ?? 0;
- if (isset($params['step']) && (\strlen($value) - $min) % $params['step'] === 0) {
+ $step = (int)($params['step'] ?? 0);
+ if ($step && ($len - $min) % $step === 0) {
return false;
}
- if ((!isset($params['multiline']) || !$params['multiline']) && preg_match('/\R/um', $value)) {
+ if (!$multiline && preg_match('/\R/um', $value)) {
return false;
}
return true;
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return string
+ */
protected static function filterText($value, array $params, array $field)
{
- if (!\is_string($value) && !is_numeric($value)) {
+ if (!is_string($value) && !is_numeric($value)) {
return '';
}
+ $value = (string)$value;
+
if (!empty($params['trim'])) {
$value = trim($value);
}
- return (string) $value;
+ return preg_replace("/\r\n|\r/um", "\n", $value);
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return string|null
+ */
protected static function filterCheckbox($value, array $params, array $field)
{
- return (bool) $value;
+ $value = (string)$value;
+ $field_value = (string)($field['value'] ?? '1');
+
+ return $value === $field_value ? $value : null;
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return array|array[]|false|string[]
+ */
protected static function filterCommaList($value, array $params, array $field)
{
- return \is_array($value) ? $value : preg_split('/\s*,\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
+ return is_array($value) ? $value : preg_split('/\s*,\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return bool
+ */
public static function typeCommaList($value, array $params, array $field)
{
- return \is_array($value) ? true : self::typeText($value, $params, $field);
+ if (!isset($params['max'])) {
+ $params['max'] = 2048;
+ }
+
+ return is_array($value) ? true : self::typeText($value, $params, $field);
+ }
+
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return array|array[]|false|string[]
+ */
+ protected static function filterLines($value, array $params, array $field)
+ {
+ return is_array($value) ? $value : preg_split('/\s*[\r\n]+\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @return string
+ */
protected static function filterLower($value, array $params)
{
- return strtolower($value);
+ return mb_strtolower($value);
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @return string
+ */
protected static function filterUpper($value, array $params)
{
- return strtoupper($value);
+ return mb_strtoupper($value);
}
@@ -219,6 +385,10 @@ public static function typeTextarea($value, array $params, array $field)
*/
public static function typePassword($value, array $params, array $field)
{
+ if (!isset($params['max'])) {
+ $params['max'] = 256;
+ }
+
return self::typeText($value, $params, $field);
}
@@ -251,6 +421,12 @@ public static function typeCheckboxes($value, array $params, array $field)
return self::typeArray((array) $value, $params, $field);
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return array|null
+ */
protected static function filterCheckboxes($value, array $params, array $field)
{
return self::filterArray($value, $params, $field);
@@ -295,7 +471,7 @@ public static function typeRadio($value, array $params, array $field)
*/
public static function typeToggle($value, array $params, array $field)
{
- if (\is_bool($value)) {
+ if (is_bool($value)) {
$value = (int)$value;
}
@@ -315,6 +491,12 @@ public static function typeFile($value, array $params, array $field)
return self::typeArray((array)$value, $params, $field);
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return array
+ */
protected static function filterFile($value, array $params, array $field)
{
return (array)$value;
@@ -347,35 +529,61 @@ public static function typeNumber($value, array $params, array $field)
return false;
}
- if (isset($params['min']) && $value < $params['min']) {
- return false;
+ $value = (float)$value;
+
+ $min = 0;
+ if (isset($params['min'])) {
+ $min = (float)$params['min'];
+ if ($value < $min) {
+ return false;
+ }
}
- if (isset($params['max']) && $value > $params['max']) {
- return false;
+ if (isset($params['max'])) {
+ $max = (float)$params['max'];
+ if ($value > $max) {
+ return false;
+ }
}
- $min = $params['min'] ?? 0;
+ if (isset($params['step'])) {
+ $step = (float)$params['step'];
+ // Count of how many steps we are above/below the minimum value.
+ $pos = ($value - $min) / $step;
- return !(isset($params['step']) && fmod($value - $min, $params['step']) === 0);
+ return is_int(static::filterNumber($pos, $params, $field));
+ }
+
+ return true;
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return float|int
+ */
protected static function filterNumber($value, array $params, array $field)
{
- return (string)(int)$value !== (string)(float)$value ? (float) $value : (int) $value;
+ return (string)(int)$value !== (string)(float)$value ? (float)$value : (int)$value;
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return string
+ */
protected static function filterDateTime($value, array $params, array $field)
{
$format = Grav::instance()['config']->get('system.pages.dateformat.default');
if ($format) {
- $converted = new \DateTime($value);
+ $converted = new DateTime($value);
return $converted->format($format);
}
return $value;
}
-
/**
* HTML5 input: range
*
@@ -389,6 +597,12 @@ public static function typeRange($value, array $params, array $field)
return self::typeNumber($value, $params, $field);
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return float|int
+ */
protected static function filterRange($value, array $params, array $field)
{
return self::filterNumber($value, $params, $field);
@@ -404,7 +618,7 @@ protected static function filterRange($value, array $params, array $field)
*/
public static function typeColor($value, array $params, array $field)
{
- return preg_match('/^\#[0-9a-fA-F]{3}[0-9a-fA-F]{3}?$/u', $value);
+ return (bool)preg_match('/^\#[0-9a-fA-F]{3}[0-9a-fA-F]{3}?$/u', $value);
}
/**
@@ -417,7 +631,11 @@ public static function typeColor($value, array $params, array $field)
*/
public static function typeEmail($value, array $params, array $field)
{
- $values = !\is_array($value) ? explode(',', preg_replace('/\s+/', '', $value)) : $value;
+ if (!isset($params['max'])) {
+ $params['max'] = 320;
+ }
+
+ $values = !is_array($value) ? explode(',', preg_replace('/\s+/', '', $value)) : $value;
foreach ($values as $val) {
if (!(self::typeText($val, $params, $field) && filter_var($val, FILTER_VALIDATE_EMAIL))) {
@@ -436,9 +654,12 @@ public static function typeEmail($value, array $params, array $field)
* @param array $field Blueprint for the field.
* @return bool True if validation succeeded.
*/
-
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);
}
@@ -452,17 +673,17 @@ public static function typeUrl($value, array $params, array $field)
*/
public static function typeDatetime($value, array $params, array $field)
{
- if ($value instanceof \DateTime) {
+ if ($value instanceof DateTime) {
return true;
}
- if (!\is_string($value)) {
+ if (!is_string($value)) {
return false;
}
if (!isset($params['format'])) {
return false !== strtotime($value);
}
- $dateFromFormat = \DateTime::createFromFormat($params['format'], $value);
+ $dateFromFormat = DateTime::createFromFormat($params['format'], $value);
return $dateFromFormat && $value === date($params['format'], $dateFromFormat->getTimestamp());
}
@@ -558,34 +779,42 @@ public static function typeWeek($value, array $params, array $field)
*/
public static function typeArray($value, array $params, array $field)
{
- if (!\is_array($value)) {
+ if (!is_array($value)) {
return false;
}
if (isset($field['multiple'])) {
- if (isset($params['min']) && \count($value) < $params['min']) {
+ if (isset($params['min']) && count($value) < $params['min']) {
return false;
}
- if (isset($params['max']) && \count($value) > $params['max']) {
+ if (isset($params['max']) && count($value) > $params['max']) {
return false;
}
$min = $params['min'] ?? 0;
- if (isset($params['step']) && (\count($value) - $min) % $params['step'] === 0) {
+ if (isset($params['step']) && (count($value) - $min) % $params['step'] === 0) {
return false;
}
}
// If creating new values is allowed, no further checks are needed.
- if (!empty($field['selectize']['create'])) {
+ $validateOptions = $field['validate']['options'] ?? null;
+ if (!empty($field['selectize']['create']) || $validateOptions === 'ignore') {
return true;
}
$options = $field['options'] ?? [];
$use = $field['use'] ?? 'values';
- if (empty($field['selectize']) || empty($field['multiple'])) {
+ if ($validateOptions) {
+ // Use custom options structure.
+ foreach ($options as &$option) {
+ $option = $option[$validateOptions] ?? null;
+ }
+ unset($option);
+ $options = array_values($options);
+ } elseif (empty($field['selectize']) || empty($field['multiple'])) {
$options = array_keys($options);
}
if ($use === 'keys') {
@@ -595,13 +824,32 @@ public static function typeArray($value, array $params, array $field)
return !($options && array_diff($value, $options));
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return array|null
+ */
+ protected static function filterFlatten_array($value, $params, $field)
+ {
+ $value = static::filterArray($value, $params, $field);
+
+ return is_array($value) ? Utils::arrayUnflattenDotNotation($value) : null;
+ }
+
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return array|null
+ */
protected static function filterArray($value, $params, $field)
{
$values = (array) $value;
$options = isset($field['options']) ? array_keys($field['options']) : [];
$multi = $field['multiple'] ?? false;
- if (\count($values) === 1 && isset($values[0]) && $values[0] === '') {
+ if (count($values) === 1 && isset($values[0]) && $values[0] === '') {
return null;
}
@@ -615,7 +863,7 @@ protected static function filterArray($value, $params, $field)
if ($multi) {
foreach ($values as $key => $val) {
- if (\is_array($val)) {
+ if (is_array($val)) {
$val = implode(',', $val);
$values[$key] = array_map('trim', explode(',', $val));
} else {
@@ -624,28 +872,90 @@ protected static function filterArray($value, $params, $field)
}
}
- if (isset($field['ignore_empty']) && Utils::isPositive($field['ignore_empty'])) {
- foreach ($values as $key => $val) {
- if ($val === '') {
+ $ignoreEmpty = isset($field['ignore_empty']) && Utils::isPositive($field['ignore_empty']);
+ $valueType = $params['value_type'] ?? null;
+ $keyType = $params['key_type'] ?? null;
+ if ($ignoreEmpty || $valueType || $keyType) {
+ $values = static::arrayFilterRecurse($values, ['value_type' => $valueType, 'key_type' => $keyType, 'ignore_empty' => $ignoreEmpty]);
+ }
+
+ return $values;
+ }
+
+ /**
+ * @param array $values
+ * @param array $params
+ * @return array
+ */
+ protected static function arrayFilterRecurse(array $values, array $params): array
+ {
+ foreach ($values as $key => &$val) {
+ if ($params['key_type']) {
+ switch ($params['key_type']) {
+ case 'int':
+ $result = is_int($key);
+ break;
+ case 'string':
+ $result = is_string($key);
+ break;
+ default:
+ $result = false;
+ }
+ if (!$result) {
unset($values[$key]);
- } elseif (\is_array($val)) {
- foreach ($val as $inner_key => $inner_value) {
- if ($inner_value === '') {
- unset($val[$inner_key]);
- }
+ }
+ }
+ if (is_array($val)) {
+ $val = static::arrayFilterRecurse($val, $params);
+ if ($params['ignore_empty'] && empty($val)) {
+ unset($values[$key]);
+ }
+ } else {
+ if ($params['value_type'] && $val !== '' && $val !== null) {
+ switch ($params['value_type']) {
+ case 'bool':
+ if (Utils::isPositive($val)) {
+ $val = true;
+ } elseif (Utils::isNegative($val)) {
+ $val = false;
+ } else {
+ // Ignore invalid bool values.
+ $val = null;
+ }
+ break;
+ case 'int':
+ $val = (int)$val;
+ break;
+ case 'float':
+ $val = (float)$val;
+ break;
+ case 'string':
+ $val = (string)$val;
+ break;
+ case 'trim':
+ $val = trim($val);
+ break;
}
}
- $values[$key] = $val;
+ if ($params['ignore_empty'] && ($val === '' || $val === null)) {
+ unset($values[$key]);
+ }
}
}
return $values;
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return bool
+ */
public static function typeList($value, array $params, array $field)
{
- if (!\is_array($value)) {
+ if (!is_array($value)) {
return false;
}
@@ -662,19 +972,29 @@ public static function typeList($value, array $params, array $field)
return true;
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return array
+ */
protected static function filterList($value, array $params, array $field)
{
return (array) $value;
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @return array
+ */
public static function filterYaml($value, $params)
{
- if (!\is_string($value)) {
+ if (!is_string($value)) {
return $value;
}
return (array) Yaml::parse($value);
-
}
/**
@@ -690,6 +1010,12 @@ public static function typeIgnore($value, array $params, array $field)
return true;
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return mixed
+ */
public static function filterIgnore($value, array $params, array $field)
{
return $value;
@@ -708,6 +1034,12 @@ public static function typeUnset($value, array $params, array $field)
return true;
}
+ /**
+ * @param mixed $value
+ * @param array $params
+ * @param array $field
+ * @return null
+ */
public static function filterUnset($value, array $params, array $field)
{
return null;
@@ -715,6 +1047,11 @@ public static function filterUnset($value, array $params, array $field)
// HTML5 attributes (min, max and range are handled inside the types)
+ /**
+ * @param mixed $value
+ * @param bool $params
+ * @return bool
+ */
public static function validateRequired($value, $params)
{
if (is_scalar($value)) {
@@ -724,79 +1061,170 @@ public static function validateRequired($value, $params)
return (bool) $params !== true || !empty($value);
}
+ /**
+ * @param mixed $value
+ * @param string $params
+ * @return bool
+ */
public static function validatePattern($value, $params)
{
return (bool) preg_match("`^{$params}$`u", $value);
}
-
// Internal types
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
public static function validateAlpha($value, $params)
{
return ctype_alpha($value);
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
public static function validateAlnum($value, $params)
{
return ctype_alnum($value);
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
public static function typeBool($value, $params)
{
- return \is_bool($value) || $value == 1 || $value == 0;
+ return is_bool($value) || $value == 1 || $value == 0;
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
public static function validateBool($value, $params)
{
- return \is_bool($value) || $value == 1 || $value == 0;
+ return is_bool($value) || $value == 1 || $value == 0;
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
protected static function filterBool($value, $params)
{
return (bool) $value;
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
public static function validateDigit($value, $params)
{
return ctype_digit($value);
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
public static function validateFloat($value, $params)
{
- return \is_float(filter_var($value, FILTER_VALIDATE_FLOAT));
+ return is_float(filter_var($value, FILTER_VALIDATE_FLOAT));
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return float
+ */
protected static function filterFloat($value, $params)
{
return (float) $value;
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
public static function validateHex($value, $params)
{
return ctype_xdigit($value);
}
+ /**
+ * Custom input: int
+ *
+ * @param mixed $value Value to be validated.
+ * @param array $params Validation parameters.
+ * @param array $field Blueprint for the field.
+ * @return bool True if validation succeeded.
+ */
+ public static function typeInt($value, array $params, array $field)
+ {
+ $params['step'] = max(1, (int)($params['step'] ?? 0));
+
+ return self::typeNumber($value, $params, $field);
+ }
+
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
public static function validateInt($value, $params)
{
return is_numeric($value) && (int)$value == $value;
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return int
+ */
protected static function filterInt($value, $params)
{
return (int)$value;
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
public static function validateArray($value, $params)
{
- return \is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable && $value instanceof \Countable);
+ return is_array($value) || ($value instanceof ArrayAccess && $value instanceof Traversable && $value instanceof Countable);
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return array
+ */
public static function filterItem_List($value, $params)
{
- return array_values(array_filter($value, function($v) { return !empty($v); } ));
+ return array_values(array_filter($value, static function ($v) {
+ return !empty($v);
+ }));
}
+ /**
+ * @param mixed $value
+ * @param mixed $params
+ * @return bool
+ */
public static function validateJson($value, $params)
{
return (bool) (@json_decode($value));
diff --git a/system/src/Grav/Common/Data/ValidationException.php b/system/src/Grav/Common/Data/ValidationException.php
index 8bdb0572d7..9a3d900d1b 100644
--- a/system/src/Grav/Common/Data/ValidationException.php
+++ b/system/src/Grav/Common/Data/ValidationException.php
@@ -3,36 +3,65 @@
/**
* @package Grav\Common\Data
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Data;
use Grav\Common\Grav;
+use JsonSerializable;
+use RuntimeException;
-class ValidationException extends \RuntimeException
+/**
+ * Class ValidationException
+ * @package Grav\Common\Data
+ */
+class ValidationException extends RuntimeException implements JsonSerializable
{
+ /** @var array */
protected $messages = [];
+ protected $escape = true;
- public function setMessages(array $messages = []) {
+ /**
+ * @param array $messages
+ * @return $this
+ */
+ public function setMessages(array $messages = [])
+ {
$this->messages = $messages;
$language = Grav::instance()['language'];
$this->message = $language->translate('GRAV.FORM.VALIDATION_FAIL', null, true) . ' ' . $this->message;
- foreach ($messages as $variable => &$list) {
+ foreach ($messages as $list) {
$list = array_unique($list);
foreach ($list as $message) {
- $this->message .= "
$message";
+ $this->message .= '
' . htmlspecialchars($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}
return $this;
}
- public function getMessages()
+ public function setSimpleMessage(bool $escape = true): void
+ {
+ $first = reset($this->messages);
+ $message = reset($first);
+
+ $this->message = $escape ? htmlspecialchars($message, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $message;
+ }
+
+ /**
+ * @return array
+ */
+ public function getMessages(): array
{
return $this->messages;
}
+
+ public function jsonSerialize(): array
+ {
+ return ['validation' => $this->messages];
+ }
}
diff --git a/system/src/Grav/Common/Debugger.php b/system/src/Grav/Common/Debugger.php
index 3afce3798d..e7b162454e 100644
--- a/system/src/Grav/Common/Debugger.php
+++ b/system/src/Grav/Common/Debugger.php
@@ -3,12 +3,19 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
+use Clockwork\Clockwork;
+use Clockwork\DataSource\MonologDataSource;
+use Clockwork\DataSource\PsrMessageDataSource;
+use Clockwork\DataSource\XdebugDataSource;
+use Clockwork\Helpers\ServerTiming;
+use Clockwork\Request\UserData;
+use Clockwork\Storage\FileStorage;
use DebugBar\DataCollector\ConfigCollector;
use DebugBar\DataCollector\DataCollectorInterface;
use DebugBar\DataCollector\ExceptionsCollector;
@@ -18,76 +25,106 @@
use DebugBar\DataCollector\RequestDataCollector;
use DebugBar\DataCollector\TimeDataCollector;
use DebugBar\DebugBar;
+use DebugBar\DebugBarException;
use DebugBar\JavascriptRenderer;
-use DebugBar\StandardDebugBar;
use Grav\Common\Config\Config;
use Grav\Common\Processors\ProcessorInterface;
+use Grav\Common\Twig\TwigClockworkDataSource;
+use Grav\Framework\Psr7\Response;
+use Monolog\Logger;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use ReflectionObject;
+use SplFileInfo;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Throwable;
+use Twig\Environment;
use Twig\Template;
use Twig\TemplateWrapper;
+use function array_slice;
+use function call_user_func;
+use function count;
+use function define;
+use function defined;
+use function extension_loaded;
+use function get_class;
+use function gettype;
+use function is_array;
+use function is_bool;
+use function is_object;
+use function is_scalar;
+use function is_string;
+/**
+ * Class Debugger
+ * @package Grav\Common
+ */
class Debugger
{
- /** @var Grav $grav */
+ /** @var static */
+ protected static $instance;
+ /** @var Grav|null */
protected $grav;
-
- /** @var Config $config */
+ /** @var Config|null */
protected $config;
-
- /** @var JavascriptRenderer $renderer */
+ /** @var JavascriptRenderer|null */
protected $renderer;
-
- /** @var StandardDebugBar $debugbar */
+ /** @var DebugBar|null */
protected $debugbar;
-
+ /** @var Clockwork|null */
+ protected $clockwork;
+ /** @var bool */
+ protected $enabled = false;
/** @var bool */
- protected $enabled;
-
protected $initialized = false;
-
/** @var array */
protected $timers = [];
-
- /** @var array $deprecations */
+ /** @var array */
protected $deprecations = [];
-
- /** @var callable */
+ /** @var callable|null */
protected $errorHandler;
+ /** @var float */
+ protected $requestTime;
+ /** @var float */
+ protected $currentTime;
+ /** @var int */
+ protected $profiling = 0;
+ /** @var bool */
+ protected $censored = false;
/**
* Debugger constructor.
*/
public function __construct()
{
- $currentTime = microtime(true);
-
- if (!\defined('GRAV_REQUEST_TIME')) {
- \define('GRAV_REQUEST_TIME', $currentTime);
- }
-
- // Enable debugger until $this->init() gets called.
- $this->enabled = true;
+ static::$instance = $this;
- $debugbar = new DebugBar();
- $debugbar->addCollector(new PhpInfoCollector());
- $debugbar->addCollector(new MessagesCollector());
- $debugbar->addCollector(new RequestDataCollector());
- $debugbar->addCollector(new TimeDataCollector($_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME));
+ $this->currentTime = microtime(true);
- $debugbar['time']->addMeasure('Server', $debugbar['time']->getRequestStartTime(), GRAV_REQUEST_TIME);
- $debugbar['time']->addMeasure('Loading', GRAV_REQUEST_TIME, $currentTime);
- $debugbar['time']->addMeasure('Debugger', $currentTime, microtime(true));
+ if (!defined('GRAV_REQUEST_TIME')) {
+ define('GRAV_REQUEST_TIME', $this->currentTime);
+ }
- $this->debugbar = $debugbar;
+ $this->requestTime = $_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME;
// Set deprecation collector.
$this->setErrorHandler();
}
+ /**
+ * @return Clockwork|null
+ */
+ public function getClockwork(): ?Clockwork
+ {
+ return $this->enabled ? $this->clockwork : null;
+ }
+
/**
* Initialize the debugger
*
* @return $this
- * @throws \DebugBar\DebugBarException
+ * @throws DebugBarException
*/
public function init()
{
@@ -100,30 +137,242 @@ public function init()
// Enable/disable debugger based on configuration.
$this->enabled = (bool)$this->config->get('system.debugger.enabled');
+ $this->censored = (bool)$this->config->get('system.debugger.censored', false);
- if ($this->enabled()) {
+ if ($this->enabled) {
$this->initialized = true;
- $plugins_config = (array)$this->config->get('plugins');
+ $clockwork = $debugbar = null;
+ switch ($this->config->get('system.debugger.provider', 'debugbar')) {
+ case 'clockwork':
+ $this->clockwork = $clockwork = new Clockwork();
+ break;
+ default:
+ $this->debugbar = $debugbar = new DebugBar();
+ }
+
+ $plugins_config = (array)$this->config->get('plugins');
ksort($plugins_config);
- $debugbar = $this->debugbar;
- $debugbar->addCollector(new MemoryCollector());
- $debugbar->addCollector(new ExceptionsCollector());
- $debugbar->addCollector(new ConfigCollector((array)$this->config->get('system'), 'Config'));
- $debugbar->addCollector(new ConfigCollector($plugins_config, 'Plugins'));
- $this->addMessage('Grav v' . GRAV_VERSION);
+ if ($clockwork) {
+ $log = $this->grav['log'];
+ $clockwork->setStorage(new FileStorage('cache://clockwork'));
+ if (extension_loaded('xdebug')) {
+ $clockwork->addDataSource(new XdebugDataSource());
+ }
+ if ($log instanceof Logger) {
+ $clockwork->addDataSource(new MonologDataSource($log));
+ }
+
+ $timeline = $clockwork->timeline();
+ if ($this->requestTime !== GRAV_REQUEST_TIME) {
+ $event = $timeline->event('Server');
+ $event->finalize($this->requestTime, GRAV_REQUEST_TIME);
+ }
+ if ($this->currentTime !== GRAV_REQUEST_TIME) {
+ $event = $timeline->event('Loading');
+ $event->finalize(GRAV_REQUEST_TIME, $this->currentTime);
+ }
+ $event = $timeline->event('Site Setup');
+ $event->finalize($this->currentTime, microtime(true));
+ }
+
+ if ($this->censored) {
+ $censored = ['CENSORED' => true];
+ }
+
+ if ($debugbar) {
+ $debugbar->addCollector(new PhpInfoCollector());
+ $debugbar->addCollector(new MessagesCollector());
+ if (!$this->censored) {
+ $debugbar->addCollector(new RequestDataCollector());
+ }
+ $debugbar->addCollector(new TimeDataCollector($this->requestTime));
+ $debugbar->addCollector(new MemoryCollector());
+ $debugbar->addCollector(new ExceptionsCollector());
+ $debugbar->addCollector(new ConfigCollector($censored ?? (array)$this->config->get('system'), 'Config'));
+ $debugbar->addCollector(new ConfigCollector($censored ?? $plugins_config, 'Plugins'));
+ $debugbar->addCollector(new ConfigCollector($this->config->get('streams.schemes'), 'Streams'));
+
+ if ($this->requestTime !== GRAV_REQUEST_TIME) {
+ $debugbar['time']->addMeasure('Server', $debugbar['time']->getRequestStartTime(), GRAV_REQUEST_TIME);
+ }
+ if ($this->currentTime !== GRAV_REQUEST_TIME) {
+ $debugbar['time']->addMeasure('Loading', GRAV_REQUEST_TIME, $this->currentTime);
+ }
+ $debugbar['time']->addMeasure('Site Setup', $this->currentTime, microtime(true));
+ }
+
+ $this->addMessage('Grav v' . GRAV_VERSION . ' - PHP ' . PHP_VERSION);
+ $this->config->debug();
+
+ if ($clockwork) {
+ $clockwork->info('System Configuration', $censored ?? $this->config->get('system'));
+ $clockwork->info('Plugins Configuration', $censored ?? $plugins_config);
+ $clockwork->info('Streams', $this->config->get('streams.schemes'));
+ }
}
return $this;
}
+ public function finalize(): void
+ {
+ if ($this->clockwork && $this->enabled) {
+ $this->stopProfiling('Profiler Analysis');
+ $this->addMeasures();
+
+ $deprecations = $this->getDeprecations();
+ $count = count($deprecations);
+ if (!$count) {
+ return;
+ }
+
+ /** @var UserData $userData */
+ $userData = $this->clockwork->userData('Deprecated');
+ $userData->counters([
+ 'Deprecated' => count($deprecations)
+ ]);
+ /*
+ foreach ($deprecations as &$deprecation) {
+ $d = $deprecation;
+ unset($d['message']);
+ $this->clockwork->log('deprecated', $deprecation['message'], $d);
+ }
+ unset($deprecation);
+ */
+
+ $userData->table('Your site is using following deprecated features', $deprecations);
+ }
+ }
+
+ public function logRequest(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+ {
+ if (!$this->enabled || !$this->clockwork) {
+ return $response;
+ }
+
+ $clockwork = $this->clockwork;
+
+ $this->finalize();
+
+ $clockwork->timeline()->finalize($request->getAttribute('request_time'));
+
+ if ($this->censored) {
+ $censored = 'CENSORED';
+ $request = $request
+ ->withCookieParams([$censored => ''])
+ ->withUploadedFiles([])
+ ->withHeader('cookie', $censored);
+ $request = $request->withParsedBody([$censored => '']);
+ }
+
+ $clockwork->addDataSource(new PsrMessageDataSource($request, $response));
+
+ $clockwork->resolveRequest();
+ $clockwork->storeRequest();
+
+ $clockworkRequest = $clockwork->getRequest();
+
+ $response = $response
+ ->withHeader('X-Clockwork-Id', $clockworkRequest->id)
+ ->withHeader('X-Clockwork-Version', $clockwork::VERSION);
+
+ $grav = Grav::instance();
+ $basePath = $this->grav['base_url_relative'] . $grav['pages']->base();
+ if ($basePath) {
+ $response = $response->withHeader('X-Clockwork-Path', $basePath . '/__clockwork/');
+ }
+
+ return $response->withHeader('Server-Timing', ServerTiming::fromRequest($clockworkRequest)->value());
+ }
+
+
+ public function debuggerRequest(RequestInterface $request): Response
+ {
+ $clockwork = $this->clockwork;
+
+ $headers = [
+ 'Content-Type' => 'application/json',
+ 'Grav-Internal-SkipShutdown' => 1
+ ];
+
+ $path = $request->getUri()->getPath();
+ $clockworkDataUri = '#/__clockwork(?:/(?[0-9-]+))?(?:/(?(?:previous|next)))?(?:/(?\d+))?#';
+ if (preg_match($clockworkDataUri, $path, $matches) === false) {
+ $response = ['message' => 'Bad Input'];
+
+ return new Response(400, $headers, json_encode($response));
+ }
+
+ $id = $matches['id'] ?? null;
+ $direction = $matches['direction'] ?? null;
+ $count = $matches['count'] ?? null;
+
+ $storage = $clockwork->getStorage();
+
+ if ($direction === 'previous') {
+ $data = $storage->previous($id, $count);
+ } elseif ($direction === 'next') {
+ $data = $storage->next($id, $count);
+ } elseif ($id === 'latest') {
+ $data = $storage->latest();
+ } else {
+ $data = $storage->find($id);
+ }
+
+ if (preg_match('#(?[0-9-]+|latest)/extended#', $path)) {
+ $clockwork->extendRequest($data);
+ }
+
+ if (!$data) {
+ $response = ['message' => 'Not Found'];
+
+ return new Response(404, $headers, json_encode($response));
+ }
+
+ $data = is_array($data) ? array_map(static function ($item) {
+ return $item->toArray();
+ }, $data) : $data->toArray();
+
+ return new Response(200, $headers, json_encode($data));
+ }
+
+ /**
+ * @return void
+ */
+ protected function addMeasures(): void
+ {
+ if (!$this->enabled) {
+ return;
+ }
+
+ $nowTime = microtime(true);
+ $clkTimeLine = $this->clockwork ? $this->clockwork->timeline() : null;
+ $debTimeLine = $this->debugbar ? $this->debugbar['time'] : null;
+ foreach ($this->timers as $name => $data) {
+ $description = $data[0];
+ $startTime = $data[1] ?? null;
+ $endTime = $data[2] ?? $nowTime;
+ if ($clkTimeLine) {
+ $event = $clkTimeLine->event($description);
+ $event->finalize($startTime, $endTime);
+ } elseif ($debTimeLine) {
+ if ($endTime - $startTime < 0.001) {
+ continue;
+ }
+
+ $debTimeLine->addMeasure($description ?? $name, $startTime, $endTime);
+ }
+ }
+ $this->timers = [];
+ }
+
/**
* Set/get the enabled state of the debugger
*
- * @param bool $state If null, the method returns the enabled value. If set, the method sets the enabled state
- *
+ * @param bool|null $state If null, the method returns the enabled value. If set, the method sets the enabled state
* @return bool
*/
public function enabled($state = null)
@@ -142,8 +391,7 @@ public function enabled($state = null)
*/
public function addAssets()
{
- if ($this->enabled()) {
-
+ if ($this->enabled) {
// Only add assets if Page is HTML
$page = $this->grav['page'];
if ($page->templateFormat() !== 'html') {
@@ -153,28 +401,42 @@ public function addAssets()
/** @var Assets $assets */
$assets = $this->grav['assets'];
- // Add jquery library
- $assets->add('jquery', 101);
+ // Clockwork specific assets
+ if ($this->clockwork) {
+ $assets->addCss('/system/assets/debugger/clockwork.css', ['loading' => 'inline']);
+ $assets->addJs('/system/assets/debugger/clockwork.js', ['loading' => 'inline']);
+ }
- $this->renderer = $this->debugbar->getJavascriptRenderer();
- $this->renderer->setIncludeVendors(false);
- // Get the required CSS files
- list($css_files, $js_files) = $this->renderer->getAssets(null, JavascriptRenderer::RELATIVE_URL);
- foreach ((array)$css_files as $css) {
- $assets->addCss($css);
- }
+ // Debugbar specific assets
+ if ($this->debugbar) {
+ // Add jquery library
+ $assets->add('jquery', 101);
+
+ $this->renderer = $this->debugbar->getJavascriptRenderer();
+ $this->renderer->setIncludeVendors(false);
+
+ [$css_files, $js_files] = $this->renderer->getAssets(null, JavascriptRenderer::RELATIVE_URL);
+
+ foreach ((array)$css_files as $css) {
+ $assets->addCss($css);
+ }
- $assets->addCss('/system/assets/debugger.css');
+ $assets->addCss('/system/assets/debugger/phpdebugbar.css', ['loading' => 'inline']);
- foreach ((array)$js_files as $js) {
- $assets->addJs($js);
+ foreach ((array)$js_files as $js) {
+ $assets->addJs($js);
+ }
}
}
return $this;
}
+ /**
+ * @param int $limit
+ * @return array
+ */
public function getCaller($limit = 2)
{
$trace = debug_backtrace(false, $limit);
@@ -186,13 +448,14 @@ public function getCaller($limit = 2)
* Adds a data collector
*
* @param DataCollectorInterface $collector
- *
* @return $this
- * @throws \DebugBar\DebugBarException
+ * @throws DebugBarException
*/
public function addCollector($collector)
{
- $this->debugbar->addCollector($collector);
+ if ($this->debugbar && !$this->debugbar->hasCollector($collector->getName())) {
+ $this->debugbar->addCollector($collector);
+ }
return $this;
}
@@ -200,14 +463,17 @@ public function addCollector($collector)
/**
* Returns a data collector
*
- * @param DataCollectorInterface $collector
- *
- * @return DataCollectorInterface
- * @throws \DebugBar\DebugBarException
+ * @param string $name
+ * @return DataCollectorInterface|null
+ * @throws DebugBarException
*/
- public function getCollector($collector)
+ public function getCollector($name)
{
- return $this->debugbar->getCollector($collector);
+ if ($this->debugbar && $this->debugbar->hasCollector($name)) {
+ return $this->debugbar->getCollector($name);
+ }
+
+ return null;
}
/**
@@ -217,13 +483,14 @@ public function getCollector($collector)
*/
public function render()
{
- if ($this->enabled()) {
+ if ($this->enabled && $this->debugbar) {
// Only add assets if Page is HTML
$page = $this->grav['page'];
if (!$this->renderer || $page->templateFormat() !== 'html') {
return $this;
}
+ $this->addMeasures();
$this->addDeprecations();
echo $this->renderer->render();
@@ -239,7 +506,8 @@ public function render()
*/
public function sendDataInHeaders()
{
- if ($this->enabled()) {
+ if ($this->enabled && $this->debugbar) {
+ $this->addMeasures();
$this->addDeprecations();
$this->debugbar->sendDataInHeaders();
}
@@ -250,34 +518,182 @@ public function sendDataInHeaders()
/**
* Returns collected debugger data.
*
- * @return array
+ * @return array|null
*/
public function getData()
{
- if (!$this->enabled()) {
+ if (!$this->enabled || !$this->debugbar) {
return null;
}
+ $this->addMeasures();
$this->addDeprecations();
$this->timers = [];
return $this->debugbar->getData();
}
+ /**
+ * Hierarchical Profiler support.
+ *
+ * @param callable $callable
+ * @param string|null $message
+ * @return mixed
+ */
+ public function profile(callable $callable, string $message = null)
+ {
+ $this->startProfiling();
+ $response = $callable();
+ $this->stopProfiling($message);
+
+ return $response;
+ }
+
+ public function addTwigProfiler(Environment $twig): void
+ {
+ $clockwork = $this->getClockwork();
+ if ($clockwork) {
+ $source = new TwigClockworkDataSource($twig);
+ $source->listenToEvents();
+ $clockwork->addDataSource($source);
+ }
+ }
+
+ /**
+ * Start profiling code.
+ *
+ * @return void
+ */
+ public function startProfiling(): void
+ {
+ if ($this->enabled && extension_loaded('tideways_xhprof')) {
+ $this->profiling++;
+ if ($this->profiling === 1) {
+ // @phpstan-ignore-next-line
+ \tideways_xhprof_enable(TIDEWAYS_XHPROF_FLAGS_NO_BUILTINS);
+ }
+ }
+ }
+
+ /**
+ * Stop profiling code. Returns profiling array or null if profiling couldn't be done.
+ *
+ * @param string|null $message
+ * @return array|null
+ */
+ public function stopProfiling(string $message = null): ?array
+ {
+ $timings = null;
+ if ($this->enabled && extension_loaded('tideways_xhprof')) {
+ $profiling = $this->profiling - 1;
+ if ($profiling === 0) {
+ // @phpstan-ignore-next-line
+ $timings = \tideways_xhprof_disable();
+ $timings = $this->buildProfilerTimings($timings);
+
+ if ($this->clockwork) {
+ /** @var UserData $userData */
+ $userData = $this->clockwork->userData('Profiler');
+ $userData->counters([
+ 'Calls' => count($timings)
+ ]);
+ $userData->table('Profiler', $timings);
+ } else {
+ $this->addMessage($message ?? 'Profiler Analysis', 'debug', $timings);
+ }
+ }
+ $this->profiling = max(0, $profiling);
+ }
+
+ return $timings;
+ }
+
+ /**
+ * @param array $timings
+ * @return array
+ */
+ protected function buildProfilerTimings(array $timings): array
+ {
+ // Filter method calls which take almost no time.
+ $timings = array_filter($timings, function ($value) {
+ return $value['wt'] > 50;
+ });
+
+ uasort($timings, function (array $a, array $b) {
+ return $b['wt'] <=> $a['wt'];
+ });
+
+ $table = [];
+ foreach ($timings as $key => $timing) {
+ $parts = explode('==>', $key);
+ $method = $this->parseProfilerCall(array_pop($parts));
+ $context = $this->parseProfilerCall(array_pop($parts));
+
+ // Skip redundant method calls.
+ if ($context === 'Grav\Framework\RequestHandler\RequestHandler::handle()') {
+ continue;
+ }
+
+ // Do not profile library calls.
+ if (strpos($context, 'Grav\\') !== 0) {
+ continue;
+ }
+
+ $table[] = [
+ 'Context' => $context,
+ 'Method' => $method,
+ 'Calls' => $timing['ct'],
+ 'Time (ms)' => $timing['wt'] / 1000,
+ ];
+ }
+
+ return $table;
+ }
+
+ /**
+ * @param string|null $call
+ * @return mixed|string|null
+ */
+ protected function parseProfilerCall(?string $call)
+ {
+ if (null === $call) {
+ return '';
+ }
+ if (strpos($call, '@')) {
+ [$call,] = explode('@', $call);
+ }
+ if (strpos($call, '::')) {
+ [$class, $call] = explode('::', $call);
+ }
+
+ if (!isset($class)) {
+ return $call;
+ }
+
+ // It is also possible to display twig files, but they are being logged in views.
+ /*
+ if (strpos($class, '__TwigTemplate_') === 0 && class_exists($class)) {
+ $env = new Environment();
+ / ** @var Template $template * /
+ $template = new $class($env);
+
+ return $template->getTemplateName();
+ }
+ */
+
+ return "{$class}::{$call}()";
+ }
+
/**
* Start a timer with an associated name and description
*
* @param string $name
* @param string|null $description
- *
* @return $this
*/
public function startTimer($name, $description = null)
{
- if (strpos($name, '_') === 0 || $this->enabled()) {
- $this->debugbar['time']->startMeasure($name, $description);
- $this->timers[] = $name;
- }
+ $this->timers[$name] = [$description, microtime(true)];
return $this;
}
@@ -286,13 +702,13 @@ public function startTimer($name, $description = null)
* Stop the named timer
*
* @param string $name
- *
* @return $this
*/
public function stopTimer($name)
{
- if (\in_array($name, $this->timers, true) && (strpos($name, '_') === 0 || $this->enabled())) {
- $this->debugbar['time']->stopMeasure($name);
+ if (isset($this->timers[$name])) {
+ $endTime = microtime(true);
+ $this->timers[$name][] = $endTime;
}
return $this;
@@ -303,14 +719,76 @@ public function stopTimer($name)
*
* @param mixed $message
* @param string $label
- * @param bool $isString
- *
+ * @param mixed|bool $isString
* @return $this
*/
public function addMessage($message, $label = 'info', $isString = true)
{
- if ($this->enabled()) {
- $this->debugbar['messages']->addMessage($message, $label, $isString);
+ if ($this->enabled) {
+ if ($this->censored) {
+ if (!is_scalar($message)) {
+ $message = 'CENSORED';
+ }
+ if (!is_scalar($isString)) {
+ $isString = ['CENSORED'];
+ }
+ }
+
+ if ($this->debugbar) {
+ if (is_array($isString)) {
+ $message = $isString;
+ $isString = false;
+ } elseif (is_string($isString)) {
+ $message = $isString;
+ $isString = true;
+ }
+ $this->debugbar['messages']->addMessage($message, $label, $isString);
+ }
+
+ if ($this->clockwork) {
+ $context = $isString;
+ if (!is_scalar($message)) {
+ $context = $message;
+ $message = gettype($context);
+ }
+ if (is_bool($context)) {
+ $context = [];
+ } elseif (!is_array($context)) {
+ $type = gettype($context);
+ $context = [$type => $context];
+ }
+
+ $this->clockwork->log($label, $message, $context);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param string $name
+ * @param object $event
+ * @param EventDispatcherInterface $dispatcher
+ * @param float|null $time
+ * @return $this
+ */
+ public function addEvent(string $name, $event, EventDispatcherInterface $dispatcher, float $time = null)
+ {
+ if ($this->enabled && $this->clockwork) {
+ $time = $time ?? microtime(true);
+ $duration = (microtime(true) - $time) * 1000;
+
+ $data = null;
+ if ($event && method_exists($event, '__debugInfo')) {
+ $data = $event;
+ }
+
+ $listeners = [];
+ foreach ($dispatcher->getListeners($name) as $listener) {
+ $listeners[] = $this->resolveCallable($listener);
+ }
+
+ $this->clockwork->addEvent($name, $data, $time, ['listeners' => $listeners, 'duration' => $duration]);
}
return $this;
@@ -319,18 +797,31 @@ public function addMessage($message, $label = 'info', $isString = true)
/**
* Dump exception into the Messages tab of the Debug Bar
*
- * @param \Exception $e
+ * @param Throwable $e
* @return Debugger
*/
- public function addException(\Exception $e)
+ public function addException(Throwable $e)
{
- if ($this->initialized && $this->enabled()) {
- $this->debugbar['exceptions']->addException($e);
+ if ($this->initialized && $this->enabled) {
+ if ($this->debugbar) {
+ $this->debugbar['exceptions']->addThrowable($e);
+ }
+
+ if ($this->clockwork) {
+ /** @var UserData $exceptions */
+ $exceptions = $this->clockwork->userData('Exceptions');
+ $exceptions->data(['message' => $e->getMessage()]);
+
+ $this->clockwork->alert($e->getMessage(), ['exception' => $e]);
+ }
}
return $this;
}
+ /**
+ * @return void
+ */
public function setErrorHandler()
{
$this->errorHandler = set_error_handler(
@@ -347,15 +838,15 @@ public function setErrorHandler()
*/
public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline)
{
- if ($errno !== E_USER_DEPRECATED) {
+ if ($errno !== E_USER_DEPRECATED && $errno !== E_DEPRECATED) {
if ($this->errorHandler) {
- return \call_user_func($this->errorHandler, $errno, $errstr, $errfile, $errline);
+ return call_user_func($this->errorHandler, $errno, $errstr, $errfile, $errline);
}
return true;
}
- if (!$this->enabled()) {
+ if (!$this->enabled) {
return true;
}
@@ -365,6 +856,10 @@ public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline)
$scope = 'grav';
} elseif (strpos($errfile, '/twig/') !== false) {
$scope = 'twig';
+ // TODO: remove when upgrading to Twig 2+
+ if (str_contains($errstr, '#[\ReturnTypeWillChange]') || str_contains($errstr, 'Passing null to parameter')) {
+ return true;
+ }
} elseif (stripos($errfile, '/yaml/') !== false) {
$scope = 'yaml';
} elseif (strpos($errfile, '/vendor/') !== false) {
@@ -382,10 +877,10 @@ public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline)
foreach ($backtrace as $current) {
if (isset($current['args'])) {
foreach ($current['args'] as $arg) {
- if ($arg instanceof \SplFileInfo) {
+ if ($arg instanceof SplFileInfo) {
$arg = $arg->getPathname();
}
- if (\is_string($arg) && preg_match('/.+\.(yaml|md)$/i', $arg)) {
+ if (is_string($arg) && preg_match('/.+\.(yaml|md)$/i', $arg)) {
$errfile = $arg;
$errline = 0;
@@ -403,18 +898,18 @@ public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline)
if (isset($current['args'])) {
$args = [];
foreach ($current['args'] as $arg) {
- if (\is_string($arg)) {
+ if (is_string($arg)) {
$arg = "'" . $arg . "'";
if (mb_strlen($arg) > 100) {
$arg = 'string';
}
- } elseif (\is_bool($arg)) {
+ } elseif (is_bool($arg)) {
$arg = $arg ? 'true' : 'false';
- } elseif (\is_scalar($arg)) {
+ } elseif (is_scalar($arg)) {
$arg = $arg;
- } elseif (\is_object($arg)) {
+ } elseif (is_object($arg)) {
$arg = get_class($arg) . ' $object';
- } elseif (\is_array($arg)) {
+ } elseif (is_array($arg)) {
$arg = '$array';
} else {
$arg = '$object';
@@ -430,7 +925,7 @@ public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline)
$reflection = null;
if ($object instanceof TemplateWrapper) {
- $reflection = new \ReflectionObject($object);
+ $reflection = new ReflectionObject($object);
$property = $reflection->getProperty('template');
$property->setAccessible(true);
$object = $property->getValue($object);
@@ -540,6 +1035,28 @@ public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline)
return true;
}
+ /**
+ * @return array
+ */
+ protected function getDeprecations(): array
+ {
+ if (!$this->deprecations) {
+ return [];
+ }
+
+ $list = [];
+ /** @var array $deprecated */
+ foreach ($this->deprecations as $deprecated) {
+ $list[] = $this->getDepracatedMessage($deprecated)[0];
+ }
+
+ return $list;
+ }
+
+ /**
+ * @return void
+ * @throws DebugBarException
+ */
protected function addDeprecations()
{
if (!$this->deprecations) {
@@ -558,6 +1075,10 @@ protected function addDeprecations()
}
}
+ /**
+ * @param array $deprecated
+ * @return array
+ */
protected function getDepracatedMessage($deprecated)
{
$scope = $deprecated['scope'];
@@ -595,6 +1116,10 @@ protected function getDepracatedMessage($deprecated)
];
}
+ /**
+ * @param array $trace
+ * @return string
+ */
protected function getFunction($trace)
{
if (!isset($trace['function'])) {
@@ -603,4 +1128,17 @@ protected function getFunction($trace)
return $trace['function'] . '(' . implode(', ', $trace['args'] ?? []) . ')';
}
+
+ /**
+ * @param callable $callable
+ * @return string
+ */
+ protected function resolveCallable(callable $callable)
+ {
+ if (is_array($callable)) {
+ return get_class($callable[0]) . '->' . $callable[1] . '()';
+ }
+
+ return 'unknown';
+ }
}
diff --git a/system/src/Grav/Common/Errors/BareHandler.php b/system/src/Grav/Common/Errors/BareHandler.php
index 52effc6d4e..909382717e 100644
--- a/system/src/Grav/Common/Errors/BareHandler.php
+++ b/system/src/Grav/Common/Errors/BareHandler.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Errors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,22 +11,23 @@
use Whoops\Handler\Handler;
+/**
+ * Class BareHandler
+ * @package Grav\Common\Errors
+ */
class BareHandler extends Handler
{
-
/**
- * @return int|null
+ * @return int
*/
public function handle()
{
$inspector = $this->getInspector();
$code = $inspector->getException()->getCode();
- if ( ($code >= 400) && ($code < 600) )
- {
- $this->getRun()->sendHttpCode($code);
+ if (($code >= 400) && ($code < 600)) {
+ $this->getRun()->sendHttpCode($code);
}
return Handler::QUIT;
}
-
}
diff --git a/system/src/Grav/Common/Errors/Errors.php b/system/src/Grav/Common/Errors/Errors.php
index dbc955ed07..92f885bfd8 100644
--- a/system/src/Grav/Common/Errors/Errors.php
+++ b/system/src/Grav/Common/Errors/Errors.php
@@ -3,17 +3,29 @@
/**
* @package Grav\Common\Errors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Errors;
+use Exception;
use Grav\Common\Grav;
-use Whoops;
+use Whoops\Handler\JsonResponseHandler;
+use Whoops\Handler\PrettyPageHandler;
+use Whoops\Run;
+use Whoops\Util\Misc;
+use function is_int;
+/**
+ * Class Errors
+ * @package Grav\Common\Errors
+ */
class Errors
{
+ /**
+ * @return void
+ */
public function resetHandlers()
{
$grav = Grav::instance();
@@ -22,7 +34,7 @@ public function resetHandlers()
// Setup Whoops-based error handler
$system = new SystemFacade;
- $whoops = new Whoops\Run($system);
+ $whoops = new Run($system);
$verbosity = 1;
@@ -36,30 +48,30 @@ public function resetHandlers()
switch ($verbosity) {
case 1:
- $error_page = new Whoops\Handler\PrettyPageHandler;
+ $error_page = new PrettyPageHandler();
$error_page->setPageTitle('Crikey! There was an error...');
$error_page->addResourcePath(GRAV_ROOT . '/system/assets');
$error_page->addCustomCss('whoops.css');
- $whoops->pushHandler($error_page);
+ $whoops->prependHandler($error_page);
break;
case -1:
- $whoops->pushHandler(new BareHandler);
+ $whoops->prependHandler(new BareHandler);
break;
default:
- $whoops->pushHandler(new SimplePageHandler);
+ $whoops->prependHandler(new SimplePageHandler);
break;
}
- if (Whoops\Util\Misc::isAjaxRequest() || $jsonRequest) {
- $whoops->pushHandler(new Whoops\Handler\JsonResponseHandler);
+ if ($jsonRequest || Misc::isAjaxRequest()) {
+ $whoops->prependHandler(new JsonResponseHandler());
}
if (isset($config['log']) && $config['log']) {
$logger = $grav['log'];
- $whoops->pushHandler(function($exception, $inspector, $run) use ($logger) {
+ $whoops->pushHandler(function ($exception, $inspector, $run) use ($logger) {
try {
$logger->addCritical($exception->getMessage() . ' - Trace: ' . $exception->getTraceAsString());
- } catch (\Exception $e) {
+ } catch (Exception $e) {
echo $e;
}
});
diff --git a/system/src/Grav/Common/Errors/SimplePageHandler.php b/system/src/Grav/Common/Errors/SimplePageHandler.php
index 311d722442..80c4f96769 100644
--- a/system/src/Grav/Common/Errors/SimplePageHandler.php
+++ b/system/src/Grav/Common/Errors/SimplePageHandler.php
@@ -3,20 +3,29 @@
/**
* @package Grav\Common\Errors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Errors;
+use ErrorException;
+use InvalidArgumentException;
+use RuntimeException;
use Whoops\Handler\Handler;
use Whoops\Util\Misc;
use Whoops\Util\TemplateHelper;
+/**
+ * Class SimplePageHandler
+ * @package Grav\Common\Errors
+ */
class SimplePageHandler extends Handler
{
- private $searchPaths = array();
- private $resourceCache = array();
+ /** @var array */
+ private $searchPaths = [];
+ /** @var array */
+ private $resourceCache = [];
public function __construct()
{
@@ -25,7 +34,7 @@ public function __construct()
}
/**
- * @return int|null
+ * @return int
*/
public function handle()
{
@@ -36,13 +45,12 @@ public function handle()
$cssFile = $this->getResource('error.css');
$code = $inspector->getException()->getCode();
- if ( ($code >= 400) && ($code < 600) )
- {
- $this->getRun()->sendHttpCode($code);
+ if (($code >= 400) && ($code < 600)) {
+ $this->getRun()->sendHttpCode($code);
}
$message = $inspector->getException()->getMessage();
- if ($inspector->getException() instanceof \ErrorException) {
+ if ($inspector->getException() instanceof ErrorException) {
$code = Misc::translateErrorCode($code);
}
@@ -60,9 +68,8 @@ public function handle()
/**
* @param string $resource
- *
* @return string
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
protected function getResource($resource)
{
@@ -85,15 +92,19 @@ protected function getResource($resource)
}
// If we got this far, nothing was found.
- throw new \RuntimeException(
+ throw new RuntimeException(
"Could not find resource '{$resource}' in any resource paths (searched: " . implode(', ', $this->searchPaths). ')'
);
}
+ /**
+ * @param string $path
+ * @return void
+ */
public function addResourcePath($path)
{
if (!is_dir($path)) {
- throw new \InvalidArgumentException(
+ throw new InvalidArgumentException(
"'{$path}' is not a valid directory"
);
}
@@ -101,6 +112,9 @@ public function addResourcePath($path)
array_unshift($this->searchPaths, $path);
}
+ /**
+ * @return array
+ */
public function getResourcePaths()
{
return $this->searchPaths;
diff --git a/system/src/Grav/Common/Errors/SystemFacade.php b/system/src/Grav/Common/Errors/SystemFacade.php
index 02ef0cf8c3..e13b0f6e79 100644
--- a/system/src/Grav/Common/Errors/SystemFacade.php
+++ b/system/src/Grav/Common/Errors/SystemFacade.php
@@ -3,19 +3,23 @@
/**
* @package Grav\Common\Errors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Errors;
+/**
+ * Class SystemFacade
+ * @package Grav\Common\Errors
+ */
class SystemFacade extends \Whoops\Util\SystemFacade
{
+ /** @var callable */
protected $whoopsShutdownHandler;
/**
* @param callable $function
- *
* @return void
*/
public function registerShutdownFunction(callable $function)
@@ -26,6 +30,8 @@ public function registerShutdownFunction(callable $function)
/**
* Special case to deal with Fatal errors and the like.
+ *
+ * @return void
*/
public function handleShutdown()
{
@@ -37,4 +43,25 @@ public function handleShutdown()
$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);
+ }
}
diff --git a/system/src/Grav/Common/File/CompiledFile.php b/system/src/Grav/Common/File/CompiledFile.php
index f4b1e8d4d7..f7fb767565 100644
--- a/system/src/Grav/Common/File/CompiledFile.php
+++ b/system/src/Grav/Common/File/CompiledFile.php
@@ -3,72 +3,97 @@
/**
* @package Grav\Common\File
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
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;
+use Throwable;
+use function function_exists;
+use function get_class;
+/**
+ * Trait CompiledFile
+ * @package Grav\Common\File
+ */
trait CompiledFile
{
/**
* Get/set parsed file contents.
*
* @param mixed $var
- * @return string
+ * @return array
*/
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();
-
if (!$modified) {
- return $this->decode($this->raw());
+ try {
+ return $this->decode($this->raw());
+ } catch (Throwable $e) {
+ // If the compiled file is broken, we can safely ignore the error and continue.
+ }
}
$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'])
+ 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);
- } catch (\Exception $e) {
- // Another process has locked the file; we will check this in a bit.
+ $locked = $file->lock(false);
+ } 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');
}
// 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);
}
}
}
@@ -76,16 +101,76 @@ public function content($var = null)
$this->content = $cache['data'];
}
-
- } catch (\Exception $e) {
- throw new \RuntimeException(sprintf('Failed to read %s: %s', basename($this->filename), $e->getMessage()), 500, $e);
+ } catch (Exception $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.
+ *
+ * @return array
*/
public function __sleep()
{
diff --git a/system/src/Grav/Common/File/CompiledJsonFile.php b/system/src/Grav/Common/File/CompiledJsonFile.php
index d73e817b2d..e8347bb071 100644
--- a/system/src/Grav/Common/File/CompiledJsonFile.php
+++ b/system/src/Grav/Common/File/CompiledJsonFile.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\File
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,6 +11,10 @@
use RocketTheme\Toolbox\File\JsonFile;
+/**
+ * Class CompiledJsonFile
+ * @package Grav\Common\File
+ */
class CompiledJsonFile extends JsonFile
{
use CompiledFile;
@@ -20,7 +24,7 @@ class CompiledJsonFile extends JsonFile
*
* @param string $var
* @param bool $assoc
- * @return array mixed
+ * @return array
*/
protected function decode($var, $assoc = true)
{
diff --git a/system/src/Grav/Common/File/CompiledMarkdownFile.php b/system/src/Grav/Common/File/CompiledMarkdownFile.php
index 74b282014f..108311177a 100644
--- a/system/src/Grav/Common/File/CompiledMarkdownFile.php
+++ b/system/src/Grav/Common/File/CompiledMarkdownFile.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\File
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,6 +11,10 @@
use RocketTheme\Toolbox\File\MarkdownFile;
+/**
+ * Class CompiledMarkdownFile
+ * @package Grav\Common\File
+ */
class CompiledMarkdownFile extends MarkdownFile
{
use CompiledFile;
diff --git a/system/src/Grav/Common/File/CompiledYamlFile.php b/system/src/Grav/Common/File/CompiledYamlFile.php
index 2eb1e9838c..0f0c64fd67 100644
--- a/system/src/Grav/Common/File/CompiledYamlFile.php
+++ b/system/src/Grav/Common/File/CompiledYamlFile.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\File
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,6 +11,10 @@
use RocketTheme\Toolbox\File\YamlFile;
+/**
+ * Class CompiledYamlFile
+ * @package Grav\Common\File
+ */
class CompiledYamlFile extends YamlFile
{
use CompiledFile;
diff --git a/system/src/Grav/Common/Filesystem/Archiver.php b/system/src/Grav/Common/Filesystem/Archiver.php
index 50e40fbeab..4c291a5693 100644
--- a/system/src/Grav/Common/Filesystem/Archiver.php
+++ b/system/src/Grav/Common/Filesystem/Archiver.php
@@ -3,23 +3,37 @@
/**
* @package Grav\Common\Filesystem
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Filesystem;
+use FilesystemIterator;
use Grav\Common\Utils;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use function function_exists;
+/**
+ * Class Archiver
+ * @package Grav\Common\Filesystem
+ */
abstract class Archiver
{
+ /** @var array */
protected $options = [
'exclude_files' => ['.DS_Store'],
'exclude_paths' => []
];
+ /** @var string */
protected $archive_file;
+ /**
+ * @param string $compression
+ * @return ZipArchiver
+ */
public static function create($compression)
{
if ($compression === 'zip') {
@@ -29,38 +43,66 @@ public static function create($compression)
return new ZipArchiver();
}
+ /**
+ * @param string $archive_file
+ * @return $this
+ */
public function setArchive($archive_file)
{
$this->archive_file = $archive_file;
+
return $this;
}
+ /**
+ * @param array $options
+ * @return $this
+ */
public function setOptions($options)
{
// Set infinite PHP execution time if possible.
- if (function_exists('set_time_limit') && !Utils::isFunctionDisabled('set_time_limit')) {
- set_time_limit(0);
+ if (Utils::functionExists('set_time_limit')) {
+ @set_time_limit(0);
}
$this->options = $options + $this->options;
+
return $this;
}
- public abstract function compress($folder, callable $status = null);
-
- public abstract function extract($destination, callable $status = null);
-
- public abstract function addEmptyFolders($folders, callable $status = null);
-
+ /**
+ * @param string $folder
+ * @param callable|null $status
+ * @return $this
+ */
+ abstract public function compress($folder, callable $status = null);
+
+ /**
+ * @param string $destination
+ * @param callable|null $status
+ * @return $this
+ */
+ abstract public function extract($destination, callable $status = null);
+
+ /**
+ * @param array $folders
+ * @param callable|null $status
+ * @return $this
+ */
+ abstract public function addEmptyFolders($folders, callable $status = null);
+
+ /**
+ * @param string $rootPath
+ * @return RecursiveIteratorIterator
+ */
protected function getArchiveFiles($rootPath)
{
$exclude_paths = $this->options['exclude_paths'];
$exclude_files = $this->options['exclude_files'];
- $dirItr = new \RecursiveDirectoryIterator($rootPath, \RecursiveDirectoryIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::UNIX_PATHS);
+ $dirItr = new RecursiveDirectoryIterator($rootPath, RecursiveDirectoryIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS | FilesystemIterator::UNIX_PATHS);
$filterItr = new RecursiveDirectoryFilterIterator($dirItr, $rootPath, $exclude_paths, $exclude_files);
- $files = new \RecursiveIteratorIterator($filterItr, \RecursiveIteratorIterator::SELF_FIRST);
+ $files = new RecursiveIteratorIterator($filterItr, RecursiveIteratorIterator::SELF_FIRST);
return $files;
}
-
}
diff --git a/system/src/Grav/Common/Filesystem/Folder.php b/system/src/Grav/Common/Filesystem/Folder.php
index f17410c491..ad49869a8f 100644
--- a/system/src/Grav/Common/Filesystem/Folder.php
+++ b/system/src/Grav/Common/Filesystem/Folder.php
@@ -3,43 +3,62 @@
/**
* @package Grav\Common\Filesystem
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Filesystem;
+use DirectoryIterator;
+use Exception;
+use FilesystemIterator;
use Grav\Common\Grav;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use RegexIterator;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use RuntimeException;
+use function count;
+use function dirname;
+use function is_callable;
+/**
+ * Class Folder
+ * @package Grav\Common\Filesystem
+ */
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
{
$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);
-
- /** @var \RecursiveDirectoryIterator $file */
- foreach ($iterator as $dir) {
- $dir_modified = $dir->getMTime();
- if ($dir_modified > $last_modified) {
- $last_modified = $dir_modified;
+ $flags = RecursiveDirectoryIterator::SKIP_DOTS;
+
+ 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;
+ }
}
}
@@ -49,35 +68,40 @@ public static function lastModifiedFolder($path)
/**
* 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
{
$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;
+ $flags = RecursiveDirectoryIterator::SKIP_DOTS;
+
+ 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());
}
}
@@ -87,26 +111,31 @@ public static function lastModifiedFile($path, $extensions = 'md|yaml')
/**
* 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
{
- $flags = \RecursiveDirectoryIterator::SKIP_DOTS;
$files = [];
- /** @var UniformResourceLocator $locator */
- $locator = Grav::instance()['locator'];
- if ($locator->isStream($path)) {
- $directory = $locator->getRecursiveIterator($path, $flags);
- } else {
- $directory = new \RecursiveDirectoryIterator($path, $flags);
- }
+ foreach ($paths as $path) {
+ if (file_exists($path)) {
+ $flags = RecursiveDirectoryIterator::SKIP_DOTS;
- $iterator = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST);
+ /** @var UniformResourceLocator $locator */
+ $locator = Grav::instance()['locator'];
+ if ($locator->isStream($path)) {
+ $directory = $locator->getRecursiveIterator($path, $flags);
+ } else {
+ $directory = new RecursiveDirectoryIterator($path, $flags);
+ }
- foreach ($iterator as $file) {
- $files[] = $file->getPathname() . '?'. $file->getMTime();
+ $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);
+
+ foreach ($iterator as $file) {
+ $files[] = $file->getPathname() . '?'. $file->getMTime();
+ }
+ }
}
return md5(serialize($files));
@@ -115,9 +144,8 @@ public static function hashAllFiles($path)
/**
* Get relative path between target and base path. If path isn't relative, return full path.
*
- * @param string $path
- * @param mixed|string $base
- *
+ * @param string $path
+ * @param string $base
* @return string
*/
public static function getRelativePath($path, $base = GRAV_ROOT)
@@ -175,7 +203,7 @@ public static function getRelativePathDotDot($path, $base)
* Shift first directory out of the path.
*
* @param string $path
- * @return string
+ * @return string|null
*/
public static function shift(&$path)
{
@@ -192,12 +220,15 @@ public static function shift(&$path)
* @param string $path
* @param array $params
* @return array
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
public static function all($path, array $params = [])
{
- if ($path === false) {
- throw new \RuntimeException("Path doesn't exist.");
+ if (!$path) {
+ throw new RuntimeException("Path doesn't exist.");
+ }
+ if (!file_exists($path)) {
+ return [];
}
$compare = isset($params['compare']) ? 'get' . $params['compare'] : null;
@@ -213,29 +244,29 @@ public static function all($path, array $params = [])
/** @var UniformResourceLocator $locator */
$locator = Grav::instance()['locator'];
if ($recursive) {
- $flags = \RecursiveDirectoryIterator::SKIP_DOTS + \FilesystemIterator::UNIX_PATHS
- + \FilesystemIterator::CURRENT_AS_SELF + \FilesystemIterator::FOLLOW_SYMLINKS;
+ $flags = RecursiveDirectoryIterator::SKIP_DOTS + FilesystemIterator::UNIX_PATHS
+ + FilesystemIterator::CURRENT_AS_SELF + FilesystemIterator::FOLLOW_SYMLINKS;
if ($locator->isStream($path)) {
$directory = $locator->getRecursiveIterator($path, $flags);
} else {
- $directory = new \RecursiveDirectoryIterator($path, $flags);
+ $directory = new RecursiveDirectoryIterator($path, $flags);
}
- $iterator = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST);
+ $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);
$iterator->setMaxDepth(max($levels, -1));
} else {
if ($locator->isStream($path)) {
$iterator = $locator->getIterator($path);
} else {
- $iterator = new \FilesystemIterator($path);
+ $iterator = new FilesystemIterator($path);
}
}
$results = [];
- /** @var \RecursiveDirectoryIterator $file */
+ /** @var RecursiveDirectoryIterator $file */
foreach ($iterator as $file) {
// Ignore hidden files.
- if (strpos($file->getFilename(), '.') === 0) {
+ if (strpos($file->getFilename(), '.') === 0 && $file->isFile()) {
continue;
}
if (!$folders && $file->isDir()) {
@@ -279,8 +310,9 @@ public static function all($path, array $params = [])
*
* @param string $source
* @param string $target
- * @param string $ignore Ignore files matching pattern (regular expression).
- * @throws \RuntimeException
+ * @param string|null $ignore Ignore files matching pattern (regular expression).
+ * @return void
+ * @throws RuntimeException
*/
public static function copy($source, $target, $ignore = null)
{
@@ -288,7 +320,7 @@ public static function copy($source, $target, $ignore = null)
$target = rtrim($target, '\\/');
if (!is_dir($source)) {
- throw new \RuntimeException('Cannot copy non-existing folder.');
+ throw new RuntimeException('Cannot copy non-existing folder.');
}
// Make sure that path to the target exists before copying.
@@ -318,7 +350,7 @@ public static function copy($source, $target, $ignore = null)
if (!$success) {
$error = error_get_last();
- throw new \RuntimeException($error['message'] ?? 'Unknown error');
+ throw new RuntimeException($error['message'] ?? 'Unknown error');
}
// Make sure that the change will be detected when caching.
@@ -330,13 +362,14 @@ public static function copy($source, $target, $ignore = null)
*
* @param string $source
* @param string $target
- * @throws \RuntimeException
+ * @return void
+ * @throws RuntimeException
*/
public static function move($source, $target)
{
if (!file_exists($source) || !is_dir($source)) {
// Rename fails if source folder does not exist.
- throw new \RuntimeException('Cannot move non-existing folder.');
+ throw new RuntimeException('Cannot move non-existing folder.');
}
// Don't do anything if the source is the same as the new target
@@ -344,9 +377,13 @@ public static function move($source, $target)
return;
}
+ if (strpos($target, $source . '/') === 0) {
+ throw new RuntimeException('Cannot move folder to itself');
+ }
+
if (file_exists($target)) {
// Rename fails if target folder exists.
- throw new \RuntimeException('Cannot move files to existing folder/file.');
+ throw new RuntimeException('Cannot move files to existing folder/file.');
}
// Make sure that path to the target exists before moving.
@@ -356,11 +393,7 @@ public static function move($source, $target)
@rename($source, $target);
// Rename function can fail while still succeeding, so let's check if the folder exists.
- if (!file_exists($target) || !is_dir($target)) {
- // In some rare cases rename() creates file, not a folder. Get rid of it.
- if (file_exists($target)) {
- @unlink($target);
- }
+ if (is_dir($source)) {
// Rename doesn't support moving folders across filesystems. Use copy instead.
self::copy($source, $target);
self::delete($source);
@@ -378,7 +411,7 @@ public static function move($source, $target)
* @param string $target
* @param bool $include_target
* @return bool
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
public static function delete($target, $include_target = true)
{
@@ -390,7 +423,8 @@ public static function delete($target, $include_target = true)
if (!$success) {
$error = error_get_last();
- throw new \RuntimeException($error['message']);
+
+ throw new RuntimeException($error['message'] ?? 'Unknown error');
}
// Make sure that the change will be detected when caching.
@@ -405,7 +439,8 @@ public static function delete($target, $include_target = true)
/**
* @param string $folder
- * @throws \RuntimeException
+ * @return void
+ * @throws RuntimeException
*/
public static function mkdir($folder)
{
@@ -414,7 +449,8 @@ public static function mkdir($folder)
/**
* @param string $folder
- * @throws \RuntimeException
+ * @return void
+ * @throws RuntimeException
*/
public static function create($folder)
{
@@ -429,7 +465,7 @@ public static function create($folder)
// Take yet another look, make sure that the folder doesn't exist.
clearstatcache(true, $folder);
if (!@is_dir($folder)) {
- throw new \RuntimeException(sprintf('Unable to create directory: %s', $folder));
+ throw new RuntimeException(sprintf('Unable to create directory: %s', $folder));
}
}
}
@@ -439,9 +475,8 @@ public static function create($folder)
*
* @param string $src
* @param string $dest
- *
* @return bool
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
public static function rcopy($src, $dest)
{
@@ -458,8 +493,7 @@ public static function rcopy($src, $dest)
}
// Open the source directory to read in files
- $i = new \DirectoryIterator($src);
- /** @var \DirectoryIterator $f */
+ $i = new DirectoryIterator($src);
foreach ($i as $f) {
if ($f->isFile()) {
copy($f->getRealPath(), "{$dest}/" . $f->getFilename());
@@ -472,6 +506,22 @@ public static function rcopy($src, $dest)
return true;
}
+ /**
+ * Does a directory contain children
+ *
+ * @param string $directory
+ * @return int|false
+ */
+ public static function countChildren($directory)
+ {
+ if (!is_dir($directory)) {
+ return false;
+ }
+ $directories = glob($directory . '/*', GLOB_ONLYDIR);
+
+ return $directories ? count($directories) : false;
+ }
+
/**
* @param string $folder
* @param bool $include_target
@@ -486,7 +536,8 @@ protected static function doDelete($folder, $include_target = true)
}
// Go through all items in filesystem and recursively remove everything.
- $files = array_diff(scandir($folder, SCANDIR_SORT_NONE), array('.', '..'));
+ $files = scandir($folder, SCANDIR_SORT_NONE);
+ $files = $files ? array_diff($files, ['.', '..']) : [];
foreach ($files as $file) {
$path = "{$folder}/{$file}";
is_dir($path) ? self::doDelete($path) : @unlink($path);
diff --git a/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php
index c662fc466e..75c19bcc0f 100644
--- a/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php
+++ b/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php
@@ -3,27 +3,39 @@
/**
* @package Grav\Common\Filesystem
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Filesystem;
-class RecursiveDirectoryFilterIterator extends \RecursiveFilterIterator
+use RecursiveFilterIterator;
+use RecursiveIterator;
+use SplFileInfo;
+use function in_array;
+
+/**
+ * Class RecursiveDirectoryFilterIterator
+ * @package Grav\Common\Filesystem
+ */
+class RecursiveDirectoryFilterIterator extends RecursiveFilterIterator
{
+ /** @var string */
protected static $root;
+ /** @var array */
protected static $ignore_folders;
+ /** @var array */
protected static $ignore_files;
/**
* Create a RecursiveFilterIterator from a RecursiveIterator
*
- * @param \RecursiveIterator $iterator
+ * @param RecursiveIterator $iterator
* @param string $root
* @param array $ignore_folders
* @param array $ignore_files
*/
- public function __construct(\RecursiveIterator $iterator, $root, $ignore_folders, $ignore_files)
+ public function __construct(RecursiveIterator $iterator, $root, $ignore_folders, $ignore_files)
{
parent::__construct($iterator);
@@ -39,7 +51,7 @@ public function __construct(\RecursiveIterator $iterator, $root, $ignore_folders
*/
public function accept()
{
- /** @var \SplFileInfo $file */
+ /** @var SplFileInfo $file */
$file = $this->current();
$filename = $file->getFilename();
$relative_filename = str_replace($this::$root . '/', '', $file->getPathname());
@@ -57,6 +69,9 @@ public function accept()
return false;
}
+ /**
+ * @return RecursiveDirectoryFilterIterator|RecursiveFilterIterator
+ */
public function getChildren()
{
/** @var RecursiveDirectoryFilterIterator $iterator */
diff --git a/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php
index eb493db6a0..12d6c6b0f1 100644
--- a/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php
+++ b/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php
@@ -3,25 +3,33 @@
/**
* @package Grav\Common\Filesystem
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Filesystem;
use Grav\Common\Grav;
+use RecursiveIterator;
+use SplFileInfo;
+use function in_array;
+/**
+ * Class RecursiveFolderFilterIterator
+ * @package Grav\Common\Filesystem
+ */
class RecursiveFolderFilterIterator extends \RecursiveFilterIterator
{
+ /** @var array */
protected static $ignore_folders;
/**
* Create a RecursiveFilterIterator from a RecursiveIterator
*
- * @param \RecursiveIterator $iterator
+ * @param RecursiveIterator $iterator
* @param array $ignore_folders
*/
- public function __construct(\RecursiveIterator $iterator, $ignore_folders = [])
+ public function __construct(RecursiveIterator $iterator, $ignore_folders = [])
{
parent::__construct($iterator);
@@ -39,7 +47,7 @@ public function __construct(\RecursiveIterator $iterator, $ignore_folders = [])
*/
public function accept()
{
- /** @var \SplFileInfo $current */
+ /** @var SplFileInfo $current */
$current = $this->current();
return $current->isDir() && !in_array($current->getFilename(), $this::$ignore_folders, true);
diff --git a/system/src/Grav/Common/Filesystem/ZipArchiver.php b/system/src/Grav/Common/Filesystem/ZipArchiver.php
index 91212f7705..4f2a9eed66 100644
--- a/system/src/Grav/Common/Filesystem/ZipArchiver.php
+++ b/system/src/Grav/Common/Filesystem/ZipArchiver.php
@@ -3,52 +3,71 @@
/**
* @package Grav\Common\Filesystem
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Filesystem;
+use InvalidArgumentException;
+use RuntimeException;
+use ZipArchive;
+use function extension_loaded;
+use function strlen;
+
+/**
+ * Class ZipArchiver
+ * @package Grav\Common\Filesystem
+ */
class ZipArchiver extends Archiver
{
-
+ /**
+ * @param string $destination
+ * @param callable|null $status
+ * @return $this
+ */
public function extract($destination, callable $status = null)
{
- $zip = new \ZipArchive();
+ $zip = new ZipArchive();
$archive = $zip->open($this->archive_file);
if ($archive === true) {
Folder::create($destination);
if (!$zip->extractTo($destination)) {
- throw new \RuntimeException('ZipArchiver: ZIP failed to extract ' . $this->archive_file . ' to ' . $destination);
+ throw new RuntimeException('ZipArchiver: ZIP failed to extract ' . $this->archive_file . ' to ' . $destination);
}
$zip->close();
+
return $this;
}
- throw new \RuntimeException('ZipArchiver: Failed to open ' . $this->archive_file);
+ throw new RuntimeException('ZipArchiver: Failed to open ' . $this->archive_file);
}
+ /**
+ * @param string $source
+ * @param callable|null $status
+ * @return $this
+ */
public function compress($source, callable $status = null)
{
if (!extension_loaded('zip')) {
- throw new \InvalidArgumentException('ZipArchiver: Zip PHP module not installed...');
+ throw new InvalidArgumentException('ZipArchiver: Zip PHP module not installed...');
}
- if (!file_exists($source)) {
- throw new \InvalidArgumentException('ZipArchiver: ' . $source . ' cannot be found...');
+ // Get real path for our folder
+ $rootPath = realpath($source);
+ if (!$rootPath) {
+ throw new InvalidArgumentException('ZipArchiver: ' . $source . ' cannot be found...');
}
- $zip = new \ZipArchive();
- if (!$zip->open($this->archive_file, \ZipArchive::CREATE)) {
- throw new \InvalidArgumentException('ZipArchiver:' . $this->archive_file . ' cannot be created...');
+ $zip = new ZipArchive();
+ if (!$zip->open($this->archive_file, ZipArchive::CREATE)) {
+ throw new InvalidArgumentException('ZipArchiver:' . $this->archive_file . ' cannot be created...');
}
- // Get real path for our folder
- $rootPath = realpath($source);
-
$files = $this->getArchiveFiles($rootPath);
$status && $status([
@@ -81,15 +100,20 @@ public function compress($source, callable $status = null)
return $this;
}
+ /**
+ * @param array $folders
+ * @param callable|null $status
+ * @return $this
+ */
public function addEmptyFolders($folders, callable $status = null)
{
if (!extension_loaded('zip')) {
- throw new \InvalidArgumentException('ZipArchiver: Zip PHP module not installed...');
+ throw new InvalidArgumentException('ZipArchiver: Zip PHP module not installed...');
}
- $zip = new \ZipArchive();
+ $zip = new ZipArchive();
if (!$zip->open($this->archive_file)) {
- throw new \InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be opened...');
+ throw new InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be opened...');
}
$status && $status([
@@ -97,7 +121,7 @@ public function addEmptyFolders($folders, callable $status = null)
'message' => 'Adding empty folders...'
]);
- foreach($folders as $folder) {
+ foreach ($folders as $folder) {
$zip->addEmptyDir($folder);
$status && $status([
'type' => 'progress',
diff --git a/system/src/Grav/Common/Flex/FlexCollection.php b/system/src/Grav/Common/Flex/FlexCollection.php
new file mode 100644
index 0000000000..704e21f5e0
--- /dev/null
+++ b/system/src/Grav/Common/Flex/FlexCollection.php
@@ -0,0 +1,28 @@
+
+ */
+abstract class FlexCollection extends \Grav\Framework\Flex\FlexCollection
+{
+ use FlexGravTrait;
+ use FlexCollectionTrait;
+}
diff --git a/system/src/Grav/Common/Flex/FlexIndex.php b/system/src/Grav/Common/Flex/FlexIndex.php
new file mode 100644
index 0000000000..f903c88b24
--- /dev/null
+++ b/system/src/Grav/Common/Flex/FlexIndex.php
@@ -0,0 +1,29 @@
+
+ */
+abstract class FlexIndex extends \Grav\Framework\Flex\FlexIndex
+{
+ use FlexGravTrait;
+ use FlexIndexTrait;
+}
diff --git a/system/src/Grav/Common/Flex/FlexObject.php b/system/src/Grav/Common/Flex/FlexObject.php
new file mode 100644
index 0000000000..887d7a3f27
--- /dev/null
+++ b/system/src/Grav/Common/Flex/FlexObject.php
@@ -0,0 +1,74 @@
+getNestedProperty($name, null, $separator);
+
+ // Handle media order field.
+ if (null === $value && $name === 'media_order') {
+ return implode(',', $this->getMediaOrder());
+ }
+
+ // Handle media fields.
+ $settings = $this->getFieldSettings($name);
+ if (($settings['media_field'] ?? false) === true) {
+ return $this->parseFileProperty($value, $settings);
+ }
+
+ return $value ?? $default;
+ }
+
+ /**
+ * {@inheritdoc}
+ * @see FlexObjectInterface::prepareStorage()
+ */
+ public function prepareStorage(): array
+ {
+ // Remove extra content from media fields.
+ $fields = $this->getMediaFields();
+ foreach ($fields as $field) {
+ $data = $this->getNestedProperty($field);
+ if (is_array($data)) {
+ foreach ($data as $name => &$image) {
+ unset($image['image_url'], $image['thumb_url']);
+ }
+ unset($image);
+ $this->setNestedProperty($field, $data);
+ }
+ }
+
+ return parent::prepareStorage();
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php b/system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php
new file mode 100644
index 0000000000..b919b40c3a
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php
@@ -0,0 +1,51 @@
+ 'flex',
+ 'directory' => $this->getFlexDirectory(),
+ 'collection' => $this
+ ]);
+ }
+ if (strpos($name, 'onFlexCollection') !== 0 && strpos($name, 'on') === 0) {
+ $name = 'onFlexCollection' . substr($name, 2);
+ }
+
+ $container = $this->getContainer();
+ if ($event instanceof Event) {
+ $container->fireEvent($name, $event);
+ } else {
+ $container->dispatchEvent($event);
+ }
+
+ return $this;
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php b/system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php
new file mode 100644
index 0000000000..9291a4257a
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php
@@ -0,0 +1,54 @@
+getContainer();
+
+ /** @var Twig $twig */
+ $twig = $container['twig'];
+
+ try {
+ return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout));
+ } catch (LoaderError $e) {
+ /** @var Debugger $debugger */
+ $debugger = Grav::instance()['debugger'];
+ $debugger->addException($e);
+
+ return $twig->twig()->resolveTemplate(['flex/404.html.twig']);
+ }
+ }
+
+ abstract protected function getTemplatePaths(string $layout): array;
+ abstract protected function getContainer(): Grav;
+}
diff --git a/system/src/Grav/Common/Flex/Traits/FlexGravTrait.php b/system/src/Grav/Common/Flex/Traits/FlexGravTrait.php
new file mode 100644
index 0000000000..c34b89da97
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Traits/FlexGravTrait.php
@@ -0,0 +1,74 @@
+getContainer();
+
+ /** @var Flex $flex */
+ $flex = $container['flex'];
+
+ return $flex;
+ }
+
+ /**
+ * @return UserInterface|null
+ */
+ protected function getActiveUser(): ?UserInterface
+ {
+ $container = $this->getContainer();
+
+ /** @var UserInterface|null $user */
+ $user = $container['user'] ?? null;
+
+ return $user;
+ }
+
+ /**
+ * @return bool
+ */
+ protected function isAdminSite(): bool
+ {
+ $container = $this->getContainer();
+
+ return isset($container['admin']);
+ }
+
+ /**
+ * @return string
+ */
+ protected function getAuthorizeScope(): string
+ {
+ return $this->isAdminSite() ? 'admin' : 'site';
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php b/system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php
new file mode 100644
index 0000000000..a71eb991d6
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php
@@ -0,0 +1,20 @@
+ 'onFlexObjectRender',
+ 'onBeforeSave' => 'onFlexObjectBeforeSave',
+ 'onAfterSave' => 'onFlexObjectAfterSave',
+ 'onBeforeDelete' => 'onFlexObjectBeforeDelete',
+ 'onAfterDelete' => 'onFlexObjectAfterDelete'
+ ];
+
+ if (null === $event) {
+ $event = new Event([
+ 'type' => 'flex',
+ 'directory' => $this->getFlexDirectory(),
+ 'object' => $this
+ ]);
+ }
+
+ if (isset($events['name'])) {
+ $name = $events['name'];
+ } elseif (strpos($name, 'onFlexObject') !== 0 && strpos($name, 'on') === 0) {
+ $name = 'onFlexObject' . substr($name, 2);
+ }
+
+ $container = $this->getContainer();
+ if ($event instanceof Event) {
+ $container->fireEvent($name, $event);
+ } else {
+ $container->dispatchEvent($event);
+ }
+
+ return $this;
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php b/system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php
new file mode 100644
index 0000000000..e4ec3d459c
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php
@@ -0,0 +1,24 @@
+
+ */
+class GenericCollection extends FlexCollection
+{
+}
diff --git a/system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php b/system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php
new file mode 100644
index 0000000000..fdba5039c3
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php
@@ -0,0 +1,24 @@
+
+ */
+class GenericIndex extends FlexIndex
+{
+}
diff --git a/system/src/Grav/Common/Flex/Types/Generic/GenericObject.php b/system/src/Grav/Common/Flex/Types/Generic/GenericObject.php
new file mode 100644
index 0000000000..eba642be99
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Generic/GenericObject.php
@@ -0,0 +1,22 @@
+
+ * @implements PageCollectionInterface
+ *
+ * Incompatibilities with Grav\Common\Page\Collection:
+ * $page = $collection->key() will not work at all
+ * $clone = clone $collection does not clone objects inside the collection, does it matter?
+ * $string = (string)$collection returns collection id instead of comma separated list
+ * $collection->add() incompatible method signature
+ * $collection->remove() incompatible method signature
+ * $collection->filter() incompatible method signature (takes closure instead of callable)
+ * $collection->prev() does not rewind the internal pointer
+ * AND most methods are immutable; they do not update the current collection, but return updated one
+ *
+ * @method PageIndex getIndex()
+ */
+class PageCollection extends FlexPageCollection implements PageCollectionInterface
+{
+ use FlexGravTrait;
+ use FlexCollectionTrait;
+
+ /** @var array|null */
+ protected $_params;
+
+ /**
+ * @return array
+ */
+ public static function getCachedMethods(): array
+ {
+ return [
+ // Collection specific methods
+ 'getRoot' => false,
+ 'getParams' => false,
+ 'setParams' => false,
+ 'params' => false,
+ 'addPage' => false,
+ 'merge' => false,
+ 'intersect' => false,
+ 'prev' => false,
+ 'nth' => false,
+ 'random' => false,
+ 'append' => false,
+ 'batch' => false,
+ 'order' => false,
+
+ // Collection filtering
+ 'dateRange' => true,
+ 'visible' => true,
+ 'nonVisible' => true,
+ 'pages' => true,
+ 'modules' => true,
+ 'modular' => true,
+ 'nonModular' => true,
+ 'published' => true,
+ 'nonPublished' => true,
+ 'routable' => true,
+ 'nonRoutable' => true,
+ 'ofType' => true,
+ 'ofOneOfTheseTypes' => true,
+ 'ofOneOfTheseAccessLevels' => true,
+ 'withOrdered' => true,
+ 'withModules' => true,
+ 'withPages' => true,
+ 'withTranslation' => true,
+ 'filterBy' => true,
+
+ 'toExtendedArray' => false,
+ 'getLevelListing' => false,
+ ] + parent::getCachedMethods();
+ }
+
+ /**
+ * @return PageInterface
+ */
+ public function getRoot()
+ {
+ return $this->getIndex()->getRoot();
+ }
+
+ /**
+ * Get the collection params
+ *
+ * @return array
+ */
+ public function getParams(): array
+ {
+ return $this->_params ?? [];
+ }
+
+ /**
+ * Set parameters to the Collection
+ *
+ * @param array $params
+ * @return $this
+ */
+ public function setParams(array $params)
+ {
+ $this->_params = $this->_params ? array_merge($this->_params, $params) : $params;
+
+ return $this;
+ }
+
+ /**
+ * Get the collection params
+ *
+ * @return array
+ */
+ public function params(): array
+ {
+ return $this->getParams();
+ }
+
+ /**
+ * Add a single page to a collection
+ *
+ * @param PageInterface $page
+ * @return $this
+ */
+ public function addPage(PageInterface $page)
+ {
+ if (!$page instanceof PageObject) {
+ throw new InvalidArgumentException('$page is not a flex page.');
+ }
+
+ // FIXME: support other keys.
+ $this->set($page->getKey(), $page);
+
+ return $this;
+ }
+
+ /**
+ *
+ * Merge another collection with the current collection
+ *
+ * @param PageCollectionInterface $collection
+ * @return static
+ * @phpstan-return static
+ */
+ public function merge(PageCollectionInterface $collection)
+ {
+ throw new RuntimeException(__METHOD__ . '(): Not Implemented');
+ }
+
+ /**
+ * Intersect another collection with the current collection
+ *
+ * @param PageCollectionInterface $collection
+ * @return static
+ * @phpstan-return static
+ */
+ public function intersect(PageCollectionInterface $collection)
+ {
+ throw new RuntimeException(__METHOD__ . '(): Not Implemented');
+ }
+
+ /**
+ * Set current page.
+ */
+ public function setCurrent(string $path): void
+ {
+ throw new RuntimeException(__METHOD__ . '(): Not Implemented');
+ }
+
+ /**
+ * Return previous item.
+ *
+ * @return PageInterface|false
+ * @phpstan-return T|false
+ */
+ public function prev()
+ {
+ // FIXME: this method does not rewind the internal pointer!
+ $key = (string)$this->key();
+ $prev = $this->prevSibling($key);
+
+ return $prev !== $this->current() ? $prev : false;
+ }
+
+ /**
+ * Return nth item.
+ * @param int $key
+ * @return PageInterface|bool
+ * @phpstan-return T|false
+ */
+ public function nth($key)
+ {
+ return $this->slice($key, 1)[0] ?? false;
+ }
+
+ /**
+ * Pick one or more random entries.
+ *
+ * @param int $num Specifies how many entries should be picked.
+ * @return static
+ * @phpstan-return static
+ */
+ public function random($num = 1)
+ {
+ return $this->createFrom($this->shuffle()->slice(0, $num));
+ }
+
+ /**
+ * Append new elements to the list.
+ *
+ * @param array $items Items to be appended. Existing keys will be overridden with the new values.
+ * @return static
+ * @phpstan-return static
+ */
+ public function append($items)
+ {
+ throw new RuntimeException(__METHOD__ . '(): Not Implemented');
+ }
+
+ /**
+ * Split collection into array of smaller collections.
+ *
+ * @param int $size
+ * @return static[]
+ * @phpstan-return static[]
+ */
+ public function batch($size): array
+ {
+ $chunks = $this->chunk($size);
+
+ $list = [];
+ foreach ($chunks as $chunk) {
+ $list[] = $this->createFrom($chunk);
+ }
+
+ return $list;
+ }
+
+ /**
+ * Reorder collection.
+ *
+ * @param string $by
+ * @param string $dir
+ * @param array|null $manual
+ * @param int|null $sort_flags
+ * @return static
+ * @phpstan-return static
+ */
+ public function order($by, $dir = 'asc', $manual = null, $sort_flags = null)
+ {
+ if (!$this->count()) {
+ return $this;
+ }
+
+ if ($by === 'random') {
+ return $this->shuffle();
+ }
+
+ $keys = $this->buildSort($by, $dir, $manual, $sort_flags);
+
+ return $this->createFrom(array_replace(array_flip($keys), $this->toArray()) ?? []);
+ }
+
+ /**
+ * @param string $order_by
+ * @param string $order_dir
+ * @param array|null $manual
+ * @param int|null $sort_flags
+ * @return array
+ */
+ protected function buildSort($order_by = 'default', $order_dir = 'asc', $manual = null, $sort_flags = null): array
+ {
+ // do this header query work only once
+ $header_query = null;
+ $header_default = null;
+ if (strpos($order_by, 'header.') === 0) {
+ $query = explode('|', str_replace('header.', '', $order_by), 2);
+ $header_query = array_shift($query) ?? '';
+ $header_default = array_shift($query);
+ }
+
+ $list = [];
+ foreach ($this as $key => $child) {
+ switch ($order_by) {
+ case 'title':
+ $list[$key] = $child->title();
+ break;
+ case 'date':
+ $list[$key] = $child->date();
+ $sort_flags = SORT_REGULAR;
+ break;
+ case 'modified':
+ $list[$key] = $child->modified();
+ $sort_flags = SORT_REGULAR;
+ break;
+ case 'publish_date':
+ $list[$key] = $child->publishDate();
+ $sort_flags = SORT_REGULAR;
+ break;
+ case 'unpublish_date':
+ $list[$key] = $child->unpublishDate();
+ $sort_flags = SORT_REGULAR;
+ break;
+ case 'slug':
+ $list[$key] = $child->slug();
+ break;
+ case 'basename':
+ $list[$key] = Utils::basename($key);
+ break;
+ case 'folder':
+ $list[$key] = $child->folder();
+ break;
+ case 'manual':
+ case 'default':
+ default:
+ if (is_string($header_query)) {
+ /** @var Header $child_header */
+ $child_header = $child->header();
+ $header_value = $child_header->get($header_query);
+ if (is_array($header_value)) {
+ $list[$key] = implode(',', $header_value);
+ } elseif ($header_value) {
+ $list[$key] = $header_value;
+ } else {
+ $list[$key] = $header_default ?: $key;
+ }
+ $sort_flags = $sort_flags ?: SORT_REGULAR;
+ break;
+ }
+ $list[$key] = $key;
+ $sort_flags = $sort_flags ?: SORT_REGULAR;
+ }
+ }
+
+ if (null === $sort_flags) {
+ $sort_flags = SORT_NATURAL | SORT_FLAG_CASE;
+ }
+
+ // else just sort the list according to specified key
+ if (extension_loaded('intl') && Grav::instance()['config']->get('system.intl_enabled')) {
+ $locale = setlocale(LC_COLLATE, '0'); //`setlocale` with a '0' param returns the current locale set
+ $col = Collator::create($locale);
+ if ($col) {
+ $col->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON);
+ if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) {
+ $list = preg_replace_callback('~([0-9]+)\.~', static function ($number) {
+ return sprintf('%032d.', $number[0]);
+ }, $list);
+ if (!is_array($list)) {
+ throw new RuntimeException('Internal Error');
+ }
+
+ $list_vals = array_values($list);
+ if (is_numeric(array_shift($list_vals))) {
+ $sort_flags = Collator::SORT_REGULAR;
+ } else {
+ $sort_flags = Collator::SORT_STRING;
+ }
+ }
+
+ $col->asort($list, $sort_flags);
+ } else {
+ asort($list, $sort_flags);
+ }
+ } else {
+ asort($list, $sort_flags);
+ }
+
+ // Move manually ordered items into the beginning of the list. Order of the unlisted items does not change.
+ if (is_array($manual) && !empty($manual)) {
+ $i = count($manual);
+ $new_list = [];
+ foreach ($list as $key => $dummy) {
+ $child = $this[$key] ?? null;
+ $order = $child ? array_search($child->slug, $manual, true) : false;
+ if ($order === false) {
+ $order = $i++;
+ }
+ $new_list[$key] = (int)$order;
+ }
+
+ $list = $new_list;
+
+ // Apply manual ordering to the list.
+ asort($list, SORT_NUMERIC);
+ }
+
+ if ($order_dir !== 'asc') {
+ $list = array_reverse($list);
+ }
+
+ return array_keys($list);
+ }
+
+ /**
+ * Mimicks Pages class.
+ *
+ * @return $this
+ * @deprecated 1.7 Not needed anymore in Flex Pages (does nothing).
+ */
+ public function all()
+ {
+ return $this;
+ }
+
+ /**
+ * Returns the items between a set of date ranges of either the page date field (default) or
+ * an arbitrary datetime page field where start date and end date are optional
+ * Dates must be passed in as text that strtotime() can process
+ * http://php.net/manual/en/function.strtotime.php
+ *
+ * @param string|null $startDate
+ * @param string|null $endDate
+ * @param string|null $field
+ * @return static
+ * @phpstan-return static
+ * @throws Exception
+ */
+ public function dateRange($startDate = null, $endDate = null, $field = null)
+ {
+ $start = $startDate ? Utils::date2timestamp($startDate) : null;
+ $end = $endDate ? Utils::date2timestamp($endDate) : null;
+
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if (!$object) {
+ continue;
+ }
+
+ $date = $field ? strtotime($object->getNestedProperty($field)) : $object->date();
+
+ if ((!$start || $date >= $start) && (!$end || $date <= $end)) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Creates new collection with only visible pages
+ *
+ * @return static The collection with only visible pages
+ * @phpstan-return static
+ */
+ public function visible()
+ {
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if ($object && $object->visible()) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Creates new collection with only non-visible pages
+ *
+ * @return static The collection with only non-visible pages
+ * @phpstan-return static
+ */
+ public function nonVisible()
+ {
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if ($object && !$object->visible()) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Creates new collection with only pages
+ *
+ * @return static The collection with only pages
+ * @phpstan-return static
+ */
+ public function pages()
+ {
+ $entries = [];
+ /**
+ * @var int|string $key
+ * @var PageInterface|null $object
+ */
+ foreach ($this as $key => $object) {
+ if ($object && !$object->isModule()) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Creates new collection with only modules
+ *
+ * @return static The collection with only modules
+ * @phpstan-return static
+ */
+ public function modules()
+ {
+ $entries = [];
+ /**
+ * @var int|string $key
+ * @var PageInterface|null $object
+ */
+ foreach ($this as $key => $object) {
+ if ($object && $object->isModule()) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Alias of modules()
+ *
+ * @return static
+ * @phpstan-return static
+ */
+ public function modular()
+ {
+ return $this->modules();
+ }
+
+ /**
+ * Alias of pages()
+ *
+ * @return static
+ * @phpstan-return static
+ */
+ public function nonModular()
+ {
+ return $this->pages();
+ }
+
+ /**
+ * Creates new collection with only published pages
+ *
+ * @return static The collection with only published pages
+ * @phpstan-return static
+ */
+ public function published()
+ {
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if ($object && $object->published()) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Creates new collection with only non-published pages
+ *
+ * @return static The collection with only non-published pages
+ * @phpstan-return static
+ */
+ public function nonPublished()
+ {
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if ($object && !$object->published()) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Creates new collection with only routable pages
+ *
+ * @return static The collection with only routable pages
+ * @phpstan-return static
+ */
+ public function routable()
+ {
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if ($object && $object->routable()) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Creates new collection with only non-routable pages
+ *
+ * @return static The collection with only non-routable pages
+ * @phpstan-return static
+ */
+ public function nonRoutable()
+ {
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if ($object && !$object->routable()) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Creates new collection with only pages of the specified type
+ *
+ * @param string $type
+ * @return static The collection
+ * @phpstan-return static
+ */
+ public function ofType($type)
+ {
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if ($object && $object->template() === $type) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Creates new collection with only pages of one of the specified types
+ *
+ * @param string[] $types
+ * @return static The collection
+ * @phpstan-return static
+ */
+ public function ofOneOfTheseTypes($types)
+ {
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if ($object && in_array($object->template(), $types, true)) {
+ $entries[$key] = $object;
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * Creates new collection with only pages of one of the specified access levels
+ *
+ * @param array $accessLevels
+ * @return static The collection
+ * @phpstan-return static
+ */
+ public function ofOneOfTheseAccessLevels($accessLevels)
+ {
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if ($object && isset($object->header()->access)) {
+ if (is_array($object->header()->access)) {
+ //Multiple values for access
+ $valid = false;
+
+ foreach ($object->header()->access as $index => $accessLevel) {
+ if (is_array($accessLevel)) {
+ foreach ($accessLevel as $innerIndex => $innerAccessLevel) {
+ if (in_array($innerAccessLevel, $accessLevels)) {
+ $valid = true;
+ }
+ }
+ } else {
+ if (in_array($index, $accessLevels)) {
+ $valid = true;
+ }
+ }
+ }
+ if ($valid) {
+ $entries[$key] = $object;
+ }
+ } else {
+ //Single value for access
+ if (in_array($object->header()->access, $accessLevels)) {
+ $entries[$key] = $object;
+ }
+ }
+ }
+ }
+
+ return $this->createFrom($entries);
+ }
+
+ /**
+ * @param bool $bool
+ * @return static
+ * @phpstan-return static
+ */
+ public function withOrdered(bool $bool = true)
+ {
+ $list = array_keys(array_filter($this->call('isOrdered', [$bool])));
+
+ return $this->select($list);
+ }
+
+ /**
+ * @param bool $bool
+ * @return static
+ * @phpstan-return static
+ */
+ public function withModules(bool $bool = true)
+ {
+ $list = array_keys(array_filter($this->call('isModule', [$bool])));
+
+ return $this->select($list);
+ }
+
+ /**
+ * @param bool $bool
+ * @return static
+ * @phpstan-return static
+ */
+ public function withPages(bool $bool = true)
+ {
+ $list = array_keys(array_filter($this->call('isPage', [$bool])));
+
+ return $this->select($list);
+ }
+
+ /**
+ * @param bool $bool
+ * @param string|null $languageCode
+ * @param bool|null $fallback
+ * @return static
+ * @phpstan-return static
+ */
+ public function withTranslation(bool $bool = true, string $languageCode = null, bool $fallback = null)
+ {
+ $list = array_keys(array_filter($this->call('hasTranslation', [$languageCode, $fallback])));
+
+ return $bool ? $this->select($list) : $this->unselect($list);
+ }
+
+ /**
+ * @param string|null $languageCode
+ * @param bool|null $fallback
+ * @return PageIndex
+ */
+ public function withTranslated(string $languageCode = null, bool $fallback = null)
+ {
+ return $this->getIndex()->withTranslated($languageCode, $fallback);
+ }
+
+ /**
+ * Filter pages by given filters.
+ *
+ * - search: string
+ * - page_type: string|string[]
+ * - modular: bool
+ * - visible: bool
+ * - routable: bool
+ * - published: bool
+ * - page: bool
+ * - translated: bool
+ *
+ * @param array $filters
+ * @param bool $recursive
+ * @return static
+ * @phpstan-return static
+ */
+ public function filterBy(array $filters, bool $recursive = false)
+ {
+ $list = array_keys(array_filter($this->call('filterBy', [$filters, $recursive])));
+
+ return $this->select($list);
+ }
+
+ /**
+ * Get the extended version of this Collection with each page keyed by route
+ *
+ * @return array
+ * @throws Exception
+ */
+ public function toExtendedArray(): array
+ {
+ $entries = [];
+ foreach ($this as $key => $object) {
+ if ($object) {
+ $entries[$object->route()] = $object->toArray();
+ }
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ public function getLevelListing(array $options): array
+ {
+ /** @var PageIndex $index */
+ $index = $this->getIndex();
+
+ return method_exists($index, 'getLevelListing') ? $index->getLevelListing($options) : [];
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Pages/PageIndex.php b/system/src/Grav/Common/Flex/Types/Pages/PageIndex.php
new file mode 100644
index 0000000000..f919d432cd
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Pages/PageIndex.php
@@ -0,0 +1,1198 @@
+
+ * @implements PageCollectionInterface
+ *
+ * @method PageIndex withModules(bool $bool = true)
+ * @method PageIndex withPages(bool $bool = true)
+ * @method PageIndex withTranslation(bool $bool = true, string $languageCode = null, bool $fallback = null)
+ */
+class PageIndex extends FlexPageIndex implements PageCollectionInterface
+{
+ use FlexGravTrait;
+ use FlexIndexTrait;
+
+ public const VERSION = parent::VERSION . '.5';
+ public const ORDER_LIST_REGEX = '/(\/\d+)\.[^\/]+/u';
+ public const PAGE_ROUTE_REGEX = '/\/\d+\./u';
+
+ /** @var PageObject|array */
+ protected $_root;
+ /** @var array|null */
+ protected $_params;
+
+ /**
+ * @param array $entries
+ * @param FlexDirectory|null $directory
+ */
+ public function __construct(array $entries = [], FlexDirectory $directory = null)
+ {
+ // Remove root if it's taken.
+ if (isset($entries[''])) {
+ $this->_root = $entries[''];
+ unset($entries['']);
+ }
+
+ parent::__construct($entries, $directory);
+ }
+
+ /**
+ * @param FlexStorageInterface $storage
+ * @return array
+ */
+ public static function loadEntriesFromStorage(FlexStorageInterface $storage): array
+ {
+ // Load saved index.
+ $index = static::loadIndex($storage);
+
+ $version = $index['version'] ?? 0;
+ $force = static::VERSION !== $version;
+
+ // TODO: Following check flex index to be out of sync after some saves, disabled until better solution is found.
+ //$timestamp = $index['timestamp'] ?? 0;
+ //if (!$force && $timestamp && $timestamp > time() - 1) {
+ // return $index['index'];
+ //}
+
+ // Load up to date index.
+ $entries = parent::loadEntriesFromStorage($storage);
+
+ return static::updateIndexFile($storage, $index['index'], $entries, ['include_missing' => true, 'force_update' => $force]);
+ }
+
+ /**
+ * @param string $key
+ * @return PageObject|null
+ * @phpstan-return T|null
+ */
+ public function get($key)
+ {
+ if (mb_strpos($key, '|') !== false) {
+ [$key, $params] = explode('|', $key, 2);
+ }
+
+ $element = parent::get($key);
+ if (null === $element) {
+ return null;
+ }
+
+ if (isset($params)) {
+ $element = $element->getTranslation(ltrim($params, '.'));
+ }
+
+ \assert(null === $element || $element instanceof PageObject);
+
+ return $element;
+ }
+
+ /**
+ * @return PageInterface
+ */
+ public function getRoot()
+ {
+ $root = $this->_root;
+ if (is_array($root)) {
+ $directory = $this->getFlexDirectory();
+ $storage = $directory->getStorage();
+
+ $defaults = [
+ 'header' => [
+ 'routable' => false,
+ 'permissions' => [
+ 'inherit' => false
+ ]
+ ]
+ ];
+
+ $row = $storage->readRows(['' => null])[''] ?? null;
+ if (null !== $row) {
+ if (isset($row['__ERROR'])) {
+ /** @var Debugger $debugger */
+ $debugger = Grav::instance()['debugger'];
+ $message = sprintf('Flex Pages: root page is broken in storage: %s', $row['__ERROR']);
+
+ $debugger->addException(new RuntimeException($message));
+ $debugger->addMessage($message, 'error');
+
+ $row = ['__META' => $root];
+ }
+
+ } else {
+ $row = ['__META' => $root];
+ }
+
+ $row = array_merge_recursive($defaults, $row);
+
+ /** @var PageObject $root */
+ $root = $this->getFlexDirectory()->createObject($row, '/', false);
+ $root->name('root.md');
+ $root->root(true);
+
+ $this->_root = $root;
+ }
+
+ return $root;
+ }
+
+ /**
+ * @param string|null $languageCode
+ * @param bool|null $fallback
+ * @return static
+ * @phpstan-return static
+ */
+ public function withTranslated(string $languageCode = null, bool $fallback = null)
+ {
+ if (null === $languageCode) {
+ return $this;
+ }
+
+ $entries = $this->translateEntries($this->getEntries(), $languageCode, $fallback);
+ $params = ['language' => $languageCode, 'language_fallback' => $fallback] + $this->getParams();
+
+ return $this->createFrom($entries)->setParams($params);
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getLanguage(): ?string
+ {
+ return $this->_params['language'] ?? null;
+ }
+
+ /**
+ * Get the collection params
+ *
+ * @return array
+ */
+ public function getParams(): array
+ {
+ return $this->_params ?? [];
+ }
+
+ /**
+ * Get the collection param
+ *
+ * @param string $name
+ * @return mixed
+ */
+ public function getParam(string $name)
+ {
+ return $this->_params[$name] ?? null;
+ }
+
+ /**
+ * Set parameters to the Collection
+ *
+ * @param array $params
+ * @return $this
+ */
+ public function setParams(array $params)
+ {
+ $this->_params = $this->_params ? array_merge($this->_params, $params) : $params;
+
+ return $this;
+ }
+
+ /**
+ * Set a parameter to the Collection
+ *
+ * @param string $name
+ * @param mixed $value
+ * @return $this
+ */
+ public function setParam(string $name, $value)
+ {
+ $this->_params[$name] = $value;
+
+ return $this;
+ }
+
+ /**
+ * Get the collection params
+ *
+ * @return array
+ */
+ public function params(): array
+ {
+ return $this->getParams();
+ }
+
+ /**
+ * {@inheritdoc}
+ * @see FlexCollectionInterface::getCacheKey()
+ */
+ public function getCacheKey(): string
+ {
+ return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->getKeys()) . $this->getKeyField() . $this->getLanguage());
+ }
+
+ /**
+ * Filter pages by given filters.
+ *
+ * - search: string
+ * - page_type: string|string[]
+ * - modular: bool
+ * - visible: bool
+ * - routable: bool
+ * - published: bool
+ * - page: bool
+ * - translated: bool
+ *
+ * @param array $filters
+ * @param bool $recursive
+ * @return static
+ * @phpstan-return static
+ */
+ public function filterBy(array $filters, bool $recursive = false)
+ {
+ if (!$filters) {
+ return $this;
+ }
+
+ if ($recursive) {
+ return $this->__call('filterBy', [$filters, true]);
+ }
+
+ $list = [];
+ $index = $this;
+ foreach ($filters as $key => $value) {
+ switch ($key) {
+ case 'search':
+ $index = $index->search((string)$value);
+ break;
+ case 'page_type':
+ if (!is_array($value)) {
+ $value = is_string($value) && $value !== '' ? explode(',', $value) : [];
+ }
+ $index = $index->ofOneOfTheseTypes($value);
+ break;
+ case 'routable':
+ $index = $index->withRoutable((bool)$value);
+ break;
+ case 'published':
+ $index = $index->withPublished((bool)$value);
+ break;
+ case 'visible':
+ $index = $index->withVisible((bool)$value);
+ break;
+ case 'module':
+ $index = $index->withModules((bool)$value);
+ break;
+ case 'page':
+ $index = $index->withPages((bool)$value);
+ break;
+ case 'folder':
+ $index = $index->withPages(!$value);
+ break;
+ case 'translated':
+ $index = $index->withTranslation((bool)$value);
+ break;
+ default:
+ $list[$key] = $value;
+ }
+ }
+
+ return $list ? $index->filterByParent($list) : $index;
+ }
+
+ /**
+ * @param array $filters
+ * @return static
+ * @phpstan-return static
+ */
+ protected function filterByParent(array $filters)
+ {
+ /** @var static $index */
+ $index = parent::filterBy($filters);
+
+ return $index;
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ public function getLevelListing(array $options): array
+ {
+ // Undocumented B/C
+ $order = $options['order'] ?? 'asc';
+ if ($order === SORT_ASC) {
+ $options['order'] = 'asc';
+ } elseif ($order === SORT_DESC) {
+ $options['order'] = 'desc';
+ }
+
+ $options += [
+ 'field' => null,
+ 'route' => null,
+ 'leaf_route' => null,
+ 'sortby' => null,
+ 'order' => 'asc',
+ 'lang' => null,
+ 'filters' => [],
+ ];
+
+ $options['filters'] += [
+ 'type' => ['root', 'dir'],
+ ];
+
+ $key = 'page.idx.lev.' . sha1(json_encode($options, JSON_THROW_ON_ERROR) . $this->getCacheKey());
+ $checksum = $this->getCacheChecksum();
+
+ $cache = $this->getCache('object');
+
+ /** @var Debugger $debugger */
+ $debugger = Grav::instance()['debugger'];
+
+ $result = null;
+ try {
+ $cached = $cache->get($key);
+ $test = $cached[0] ?? null;
+ $result = $test === $checksum ? ($cached[1] ?? null) : null;
+ } catch (\Psr\SimpleCache\InvalidArgumentException $e) {
+ $debugger->addException($e);
+ }
+
+ try {
+ if (null === $result) {
+ $result = $this->getLevelListingRecurse($options);
+ $cache->set($key, [$checksum, $result]);
+ }
+ } catch (\Psr\SimpleCache\InvalidArgumentException $e) {
+ $debugger->addException($e);
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param array $entries
+ * @param string|null $keyField
+ * @return static
+ * @phpstan-return static
+ */
+ protected function createFrom(array $entries, string $keyField = null)
+ {
+ /** @var static $index */
+ $index = parent::createFrom($entries, $keyField);
+ $index->_root = $this->getRoot();
+
+ return $index;
+ }
+
+ /**
+ * @param array $entries
+ * @param string $lang
+ * @param bool|null $fallback
+ * @return array
+ */
+ protected function translateEntries(array $entries, string $lang, bool $fallback = null): array
+ {
+ $languages = $this->getFallbackLanguages($lang, $fallback);
+ foreach ($entries as $key => &$entry) {
+ // Find out which version of the page we should load.
+ $translations = $this->getLanguageTemplates((string)$key);
+ if (!$translations) {
+ // No translations found, is this a folder?
+ continue;
+ }
+
+ // Find a translation.
+ $template = null;
+ foreach ($languages as $code) {
+ if (isset($translations[$code])) {
+ $template = $translations[$code];
+ break;
+ }
+ }
+
+ // We couldn't find a translation, remove entry from the list.
+ if (!isset($code, $template)) {
+ unset($entries['key']);
+ continue;
+ }
+
+ // Get the main key without template and language.
+ [$main_key,] = explode('|', $entry['storage_key'] . '|', 2);
+
+ // Update storage key and language.
+ $entry['storage_key'] = $main_key . '|' . $template . '.' . $code;
+ $entry['lang'] = $code;
+ }
+ unset($entry);
+
+ return $entries;
+ }
+
+ /**
+ * @return array
+ */
+ protected function getLanguageTemplates(string $key): array
+ {
+ $meta = $this->getMetaData($key);
+ $template = $meta['template'] ?? 'folder';
+ $translations = $meta['markdown'] ?? [];
+ $list = [];
+ foreach ($translations as $code => $search) {
+ if (isset($search[$template])) {
+ // Use main template if possible.
+ $list[$code] = $template;
+ } elseif (!empty($search)) {
+ // Fall back to first matching template.
+ $list[$code] = key($search);
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * @param string|null $languageCode
+ * @param bool|null $fallback
+ * @return array
+ */
+ protected function getFallbackLanguages(string $languageCode = null, bool $fallback = null): array
+ {
+ $fallback = $fallback ?? true;
+ if (!$fallback && null !== $languageCode) {
+ return [$languageCode];
+ }
+
+ $grav = Grav::instance();
+
+ /** @var Language $language */
+ $language = $grav['language'];
+ $languageCode = $languageCode ?? '';
+ if ($languageCode === '' && $fallback) {
+ return $language->getFallbackLanguages(null, true);
+ }
+
+ return $fallback ? $language->getFallbackLanguages($languageCode, true) : [$languageCode];
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ protected function getLevelListingRecurse(array $options): array
+ {
+ $filters = $options['filters'] ?? [];
+ $field = $options['field'];
+ $route = $options['route'];
+ $leaf_route = $options['leaf_route'];
+ $sortby = $options['sortby'];
+ $order = $options['order'];
+ $language = $options['lang'];
+
+ $status = 'error';
+ $response = [];
+ $extra = null;
+
+ // Handle leaf_route
+ $leaf = null;
+ if ($leaf_route && $route !== $leaf_route) {
+ $nodes = explode('/', $leaf_route);
+ $sub_route = '/' . implode('/', array_slice($nodes, 1, $options['level']++));
+ $options['route'] = $sub_route;
+
+ [$status,,$leaf,$extra] = $this->getLevelListingRecurse($options);
+ }
+
+ // Handle no route, assume page tree root
+ if (!$route) {
+ $page = $this->getRoot();
+ } else {
+ $page = $this->get(trim($route, '/'));
+ }
+ $path = $page ? $page->path() : null;
+
+ if ($field) {
+ // Get forced filters from the field.
+ $blueprint = $page ? $page->getBlueprint() : $this->getFlexDirectory()->getBlueprint();
+ $settings = $blueprint->schema()->getProperty($field);
+ $filters = array_merge([], $filters, $settings['filters'] ?? []);
+ }
+
+ // Clean up filter.
+ $filter_type = (array)($filters['type'] ?? []);
+ unset($filters['type']);
+ $filters = array_filter($filters, static function($val) { return $val !== null && $val !== ''; });
+
+ if ($page) {
+ $status = 'success';
+ $msg = 'PLUGIN_ADMIN.PAGE_ROUTE_FOUND';
+
+ if ($page->root() && (!$filter_type || in_array('root', $filter_type, true))) {
+ if ($field) {
+ $response[] = [
+ 'name' => '',
+ 'value' => '/',
+ 'item-key' => '',
+ 'filename' => '.',
+ 'extension' => '',
+ 'type' => 'root',
+ 'modified' => $page->modified(),
+ 'size' => 0,
+ 'symlink' => false,
+ 'has-children' => false
+ ];
+ } else {
+ $response[] = [
+ 'item-key' => '-root-',
+ 'icon' => 'root',
+ 'title' => 'Root', // FIXME
+ 'route' => [
+ 'display' => '<root>', // FIXME
+ 'raw' => '_root',
+ ],
+ 'modified' => $page->modified(),
+ 'extras' => [
+ 'template' => $page->template(),
+ //'lang' => null,
+ //'translated' => null,
+ 'langs' => [],
+ 'published' => false,
+ 'visible' => false,
+ 'routable' => false,
+ 'tags' => ['root', 'non-routable'],
+ 'actions' => ['edit'], // FIXME
+ ]
+ ];
+ }
+ }
+
+ /** @var PageCollection|PageIndex $children */
+ $children = $page->children();
+ /** @var PageIndex $children */
+ $children = $children->getIndex();
+ $selectedChildren = $children->filterBy($filters + ['language' => $language], true);
+
+ /** @var Header $header */
+ $header = $page->header();
+
+ 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);
+ $custom = $header->get('content.order.custom');
+ }
+
+ if ($sortby) {
+ // Sort children.
+ $selectedChildren = $selectedChildren->order($sortby, $order, $custom ?? null);
+ }
+
+ /** @var UserInterface|null $user */
+ $user = Grav::instance()['user'] ?? null;
+
+ /** @var PageObject $child */
+ foreach ($selectedChildren as $child) {
+ $selected = $child->path() === $extra;
+ $includeChildren = is_array($leaf) && !empty($leaf) && $selected;
+ if ($field) {
+ $child_count = count($child->children());
+ $payload = [
+ 'name' => $child->menu(),
+ 'value' => $child->rawRoute(),
+ 'item-key' => Utils::basename($child->rawRoute() ?? ''),
+ 'filename' => $child->folder(),
+ 'extension' => $child->extension(),
+ 'type' => 'dir',
+ 'modified' => $child->modified(),
+ 'size' => $child_count,
+ 'symlink' => false,
+ 'has-children' => $child_count > 0
+ ];
+ } else {
+ $lang = $child->findTranslation($language) ?? 'n/a';
+ /** @var PageObject $child */
+ $child = $child->getTranslation($language) ?? $child;
+
+ // TODO: all these features are independent from each other, we cannot just have one icon/color to catch all.
+ // TODO: maybe icon by home/modular/page/folder (or even from blueprints) and color by visibility etc..
+ if ($child->home()) {
+ $icon = 'home';
+ } elseif ($child->isModule()) {
+ $icon = 'modular';
+ } elseif ($child->visible()) {
+ $icon = 'visible';
+ } elseif ($child->isPage()) {
+ $icon = 'page';
+ } else {
+ // TODO: add support
+ $icon = 'folder';
+ }
+ $tags = [
+ $child->published() ? 'published' : 'non-published',
+ $child->visible() ? 'visible' : 'non-visible',
+ $child->routable() ? 'routable' : 'non-routable'
+ ];
+ $extras = [
+ 'template' => $child->template(),
+ 'lang' => $lang ?: null,
+ 'translated' => $lang ? $child->hasTranslation($language, false) : null,
+ 'langs' => $child->getAllLanguages(true) ?: null,
+ 'published' => $child->published(),
+ 'published_date' => $this->jsDate($child->publishDate()),
+ 'unpublished_date' => $this->jsDate($child->unpublishDate()),
+ 'visible' => $child->visible(),
+ 'routable' => $child->routable(),
+ 'tags' => $tags,
+ 'actions' => $this->getListingActions($child, $user),
+ ];
+ $extras = array_filter($extras, static function ($v) {
+ return $v !== null;
+ });
+
+ /** @var PageIndex $tmp */
+ $tmp = $child->children()->getIndex();
+ $child_count = $tmp->count();
+ $count = $filters ? $tmp->filterBy($filters, true)->count() : null;
+ $route = $child->getRoute();
+ $route = $route ? ($route->toString(false) ?: '/') : '';
+ $payload = [
+ 'item-key' => htmlspecialchars(Utils::basename($child->rawRoute() ?? $child->getKey())),
+ 'icon' => $icon,
+ 'title' => htmlspecialchars($child->menu()),
+ 'route' => [
+ 'display' => htmlspecialchars($route) ?: null,
+ 'raw' => htmlspecialchars($child->rawRoute()),
+ ],
+ 'modified' => $this->jsDate($child->modified()),
+ 'child_count' => $child_count ?: null,
+ 'count' => $count ?? null,
+ 'filters_hit' => $filters ? ($child->filterBy($filters, false) ?: null) : null,
+ 'extras' => $extras
+ ];
+ $payload = array_filter($payload, static function ($v) {
+ return $v !== null;
+ });
+ }
+
+ // Add children if any
+ if ($includeChildren) {
+ $payload['children'] = array_values($leaf);
+ }
+
+ $response[] = $payload;
+ }
+ } else {
+ $msg = 'PLUGIN_ADMIN.PAGE_ROUTE_NOT_FOUND';
+ }
+
+ if ($field) {
+ $temp_array = [];
+ foreach ($response as $index => $item) {
+ $temp_array[$item['type']][$index] = $item;
+ }
+
+ $sorted = Utils::sortArrayByArray($temp_array, $filter_type);
+ $response = Utils::arrayFlatten($sorted);
+ }
+
+ return [$status, $msg, $response, $path];
+ }
+
+ /**
+ * @param PageObject $object
+ * @param UserInterface $user
+ * @return array
+ */
+ protected function getListingActions(PageObject $object, UserInterface $user): array
+ {
+ $actions = [];
+ if ($object->isAuthorized('read', null, $user)) {
+ $actions[] = 'preview';
+ $actions[] = 'edit';
+ }
+ if ($object->isAuthorized('update', null, $user)) {
+ $actions[] = 'copy';
+ $actions[] = 'move';
+ }
+ if ($object->isAuthorized('delete', null, $user)) {
+ $actions[] = 'delete';
+ }
+
+ return $actions;
+ }
+
+ /**
+ * @param FlexStorageInterface $storage
+ * @return CompiledJsonFile|CompiledYamlFile|null
+ */
+ protected static function getIndexFile(FlexStorageInterface $storage)
+ {
+ if (!method_exists($storage, 'isIndexed') || !$storage->isIndexed()) {
+ return null;
+ }
+
+ // Load saved index file.
+ $grav = Grav::instance();
+ $locator = $grav['locator'];
+
+ $filename = $locator->findResource('user-data://flex/indexes/pages.json', true, true);
+
+ return CompiledJsonFile::instance($filename);
+ }
+
+ /**
+ * @param int|null $timestamp
+ * @return string|null
+ */
+ private function jsDate(int $timestamp = null): ?string
+ {
+ if (!$timestamp) {
+ return null;
+ }
+
+ $config = Grav::instance()['config'];
+ $dateFormat = $config->get('system.pages.dateformat.long');
+
+ return date($dateFormat, $timestamp) ?: null;
+ }
+
+ /**
+ * Add a single page to a collection
+ *
+ * @param PageInterface $page
+ * @return PageCollection
+ * @phpstan-return C
+ */
+ public function addPage(PageInterface $page)
+ {
+ return $this->getCollection()->addPage($page);
+ }
+
+ /**
+ *
+ * Create a copy of this collection
+ *
+ * @return static
+ * @phpstan-return static
+ */
+ public function copy()
+ {
+ return clone $this;
+ }
+
+ /**
+ *
+ * Merge another collection with the current collection
+ *
+ * @param PageCollectionInterface $collection
+ * @return PageCollection
+ * @phpstan-return C
+ */
+ public function merge(PageCollectionInterface $collection)
+ {
+ return $this->getCollection()->merge($collection);
+ }
+
+
+ /**
+ * Intersect another collection with the current collection
+ *
+ * @param PageCollectionInterface $collection
+ * @return PageCollection
+ * @phpstan-return C
+ */
+ public function intersect(PageCollectionInterface $collection)
+ {
+ return $this->getCollection()->intersect($collection);
+ }
+
+ /**
+ * Split collection into array of smaller collections.
+ *
+ * @param int $size
+ * @return PageCollection[]
+ * @phpstan-return C[]
+ */
+ public function batch($size)
+ {
+ return $this->getCollection()->batch($size);
+ }
+
+ /**
+ * Remove item from the list.
+ *
+ * @param string $key
+ * @return PageObject|null
+ * @phpstan-return T|null
+ * @throws InvalidArgumentException
+ */
+ public function remove($key)
+ {
+ return $this->getCollection()->remove($key);
+ }
+
+ /**
+ * Reorder collection.
+ *
+ * @param string $by
+ * @param string $dir
+ * @param array $manual
+ * @param string $sort_flags
+ * @return static
+ * @phpstan-return static
+ */
+ public function order($by, $dir = 'asc', $manual = null, $sort_flags = null)
+ {
+ /** @var PageCollectionInterface $collection */
+ $collection = $this->__call('order', [$by, $dir, $manual, $sort_flags]);
+
+ return $collection;
+ }
+
+ /**
+ * Check to see if this item is the first in the collection.
+ *
+ * @param string $path
+ * @return bool True if item is first.
+ */
+ public function isFirst($path): bool
+ {
+ /** @var bool $result */
+ $result = $this->__call('isFirst', [$path]);
+
+ return $result;
+
+ }
+
+ /**
+ * Check to see if this item is the last in the collection.
+ *
+ * @param string $path
+ * @return bool True if item is last.
+ */
+ public function isLast($path): bool
+ {
+ /** @var bool $result */
+ $result = $this->__call('isLast', [$path]);
+
+ return $result;
+ }
+
+ /**
+ * Gets the previous sibling based on current position.
+ *
+ * @param string $path
+ * @return PageObject|null The previous item.
+ * @phpstan-return T|null
+ */
+ public function prevSibling($path)
+ {
+ /** @var PageObject|null $result */
+ $result = $this->__call('prevSibling', [$path]);
+
+ return $result;
+ }
+
+ /**
+ * Gets the next sibling based on current position.
+ *
+ * @param string $path
+ * @return PageObject|null The next item.
+ * @phpstan-return T|null
+ */
+ public function nextSibling($path)
+ {
+ /** @var PageObject|null $result */
+ $result = $this->__call('nextSibling', [$path]);
+
+ return $result;
+ }
+
+ /**
+ * Returns the adjacent sibling based on a direction.
+ *
+ * @param string $path
+ * @param int $direction either -1 or +1
+ * @return PageObject|false The sibling item.
+ * @phpstan-return T|false
+ */
+ public function adjacentSibling($path, $direction = 1)
+ {
+ /** @var PageObject|false $result */
+ $result = $this->__call('adjacentSibling', [$path, $direction]);
+
+ return $result;
+ }
+
+ /**
+ * Returns the item in the current position.
+ *
+ * @param string $path the path the item
+ * @return int|null The index of the current page, null if not found.
+ */
+ public function currentPosition($path): ?int
+ {
+ /** @var int|null $result */
+ $result = $this->__call('currentPosition', [$path]);
+
+ return $result;
+ }
+
+ /**
+ * Returns the items between a set of date ranges of either the page date field (default) or
+ * an arbitrary datetime page field where start date and end date are optional
+ * Dates must be passed in as text that strtotime() can process
+ * http://php.net/manual/en/function.strtotime.php
+ *
+ * @param string|null $startDate
+ * @param string|null $endDate
+ * @param string|null $field
+ * @return static
+ * @phpstan-return static
+ * @throws Exception
+ */
+ public function dateRange($startDate = null, $endDate = null, $field = null)
+ {
+ $collection = $this->__call('dateRange', [$startDate, $endDate, $field]);
+
+ return $collection;
+ }
+
+ /**
+ * Mimicks Pages class.
+ *
+ * @return $this
+ * @deprecated 1.7 Not needed anymore in Flex Pages (does nothing).
+ */
+ public function all()
+ {
+ return $this;
+ }
+
+ /**
+ * Creates new collection with only visible pages
+ *
+ * @return static The collection with only visible pages
+ * @phpstan-return static
+ */
+ public function visible()
+ {
+ $collection = $this->__call('visible', []);
+
+ return $collection;
+ }
+
+ /**
+ * Creates new collection with only non-visible pages
+ *
+ * @return static The collection with only non-visible pages
+ * @phpstan-return static
+ */
+ public function nonVisible()
+ {
+ $collection = $this->__call('nonVisible', []);
+
+ return $collection;
+ }
+
+ /**
+ * Creates new collection with only non-modular pages
+ *
+ * @return static The collection with only non-modular pages
+ * @phpstan-return static
+ */
+ public function pages()
+ {
+ $collection = $this->__call('pages', []);
+
+ return $collection;
+ }
+
+ /**
+ * Creates new collection with only modular pages
+ *
+ * @return static The collection with only modular pages
+ * @phpstan-return static
+ */
+ public function modules()
+ {
+ $collection = $this->__call('modules', []);
+
+ return $collection;
+ }
+
+ /**
+ * Creates new collection with only modular pages
+ *
+ * @return static The collection with only modular pages
+ * @phpstan-return static
+ */
+ public function modular()
+ {
+ return $this->modules();
+ }
+
+ /**
+ * Creates new collection with only non-modular pages
+ *
+ * @return static The collection with only non-modular pages
+ * @phpstan-return static
+ */
+ public function nonModular()
+ {
+ return $this->pages();
+ }
+
+ /**
+ * Creates new collection with only published pages
+ *
+ * @return static The collection with only published pages
+ * @phpstan-return static
+ */
+ public function published()
+ {
+ $collection = $this->__call('published', []);
+
+ return $collection;
+ }
+
+ /**
+ * Creates new collection with only non-published pages
+ *
+ * @return static The collection with only non-published pages
+ * @phpstan-return static
+ */
+ public function nonPublished()
+ {
+ $collection = $this->__call('nonPublished', []);
+
+ return $collection;
+ }
+
+ /**
+ * Creates new collection with only routable pages
+ *
+ * @return static The collection with only routable pages
+ * @phpstan-return static
+ */
+ public function routable()
+ {
+ $collection = $this->__call('routable', []);
+
+ return $collection;
+ }
+
+ /**
+ * Creates new collection with only non-routable pages
+ *
+ * @return static The collection with only non-routable pages
+ * @phpstan-return static
+ */
+ public function nonRoutable()
+ {
+ $collection = $this->__call('nonRoutable', []);
+
+ return $collection;
+ }
+
+ /**
+ * Creates new collection with only pages of the specified type
+ *
+ * @param string $type
+ * @return static The collection
+ * @phpstan-return static
+ */
+ public function ofType($type)
+ {
+ $collection = $this->__call('ofType', []);
+
+ return $collection;
+ }
+
+ /**
+ * Creates new collection with only pages of one of the specified types
+ *
+ * @param string[] $types
+ * @return static The collection
+ * @phpstan-return static
+ */
+ public function ofOneOfTheseTypes($types)
+ {
+ $collection = $this->__call('ofOneOfTheseTypes', []);
+
+ return $collection;
+ }
+
+ /**
+ * Creates new collection with only pages of one of the specified access levels
+ *
+ * @param array $accessLevels
+ * @return static The collection
+ * @phpstan-return static
+ */
+ public function ofOneOfTheseAccessLevels($accessLevels)
+ {
+ $collection = $this->__call('ofOneOfTheseAccessLevels', []);
+
+ return $collection;
+ }
+
+ /**
+ * Converts collection into an array.
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ return $this->getCollection()->toArray();
+ }
+
+ /**
+ * Get the extended version of this Collection with each page keyed by route
+ *
+ * @return array
+ * @throws Exception
+ */
+ public function toExtendedArray()
+ {
+ return $this->getCollection()->toExtendedArray();
+ }
+
+}
diff --git a/system/src/Grav/Common/Flex/Types/Pages/PageObject.php b/system/src/Grav/Common/Flex/Types/Pages/PageObject.php
new file mode 100644
index 0000000000..6a0afdb84a
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Pages/PageObject.php
@@ -0,0 +1,744 @@
+ true,
+ 'full_order' => true,
+ 'filterBy' => true,
+ 'translated' => false,
+ ] + parent::getCachedMethods();
+ }
+
+ /**
+ * @return void
+ */
+ public function initialize(): void
+ {
+ if (!$this->_initialized) {
+ Grav::instance()->fireEvent('onPageProcessed', new Event(['page' => $this]));
+ $this->_initialized = true;
+ }
+ }
+
+ /**
+ * @param string|array $query
+ * @return Route|null
+ */
+ public function getRoute($query = []): ?Route
+ {
+ $path = $this->route();
+ if (null === $path) {
+ return null;
+ }
+
+ $route = RouteFactory::createFromString($path);
+ if ($lang = $route->getLanguage()) {
+ $grav = Grav::instance();
+ if (!$grav['config']->get('system.languages.include_default_lang')) {
+ /** @var Language $language */
+ $language = $grav['language'];
+ if ($lang === $language->getDefault()) {
+ $route = $route->withLanguage('');
+ }
+ }
+ }
+ if (is_array($query)) {
+ foreach ($query as $key => $value) {
+ $route = $route->withQueryParam($key, $value);
+ }
+ } else {
+ $route = $route->withAddedPath($query);
+ }
+
+ return $route;
+ }
+
+ /**
+ * @inheritdoc PageInterface
+ */
+ public function getFormValue(string $name, $default = null, string $separator = null)
+ {
+ $test = new stdClass();
+
+ $value = $this->pageContentValue($name, $test);
+ if ($value !== $test) {
+ return $value;
+ }
+
+ switch ($name) {
+ case 'name':
+ // TODO: this should not be template!
+ return $this->getProperty('template');
+ case 'route':
+ $filesystem = Filesystem::getInstance(false);
+ $key = $filesystem->dirname($this->hasKey() ? '/' . $this->getKey() : '/');
+ return $key !== '/' ? $key : null;
+ case 'full_route':
+ return $this->hasKey() ? '/' . $this->getKey() : '';
+ case 'full_order':
+ return $this->full_order();
+ case 'lang':
+ return $this->getLanguage() ?? '';
+ case 'translations':
+ return $this->getLanguages();
+ }
+
+ return parent::getFormValue($name, $default, $separator);
+ }
+
+ /**
+ * {@inheritdoc}
+ * @see FlexObjectInterface::getCacheKey()
+ */
+ public function getCacheKey(): string
+ {
+ $cacheKey = parent::getCacheKey();
+ if ($cacheKey) {
+ /** @var Language $language */
+ $language = Grav::instance()['language'];
+ $cacheKey .= '_' . $language->getActive();
+ }
+
+ return $cacheKey;
+ }
+
+ /**
+ * @param array $variables
+ * @return array
+ */
+ protected function onBeforeSave(array $variables)
+ {
+ $reorder = $variables[0] ?? true;
+
+ $meta = $this->getMetaData();
+ if (($meta['copy'] ?? false) === true) {
+ $this->folder = $this->getKey();
+ }
+
+ // Figure out storage path to the new route.
+ $parentKey = $this->getProperty('parent_key');
+ if ($parentKey !== '') {
+ $parentRoute = $this->getProperty('route');
+
+ // Root page cannot be moved.
+ if ($this->root()) {
+ throw new RuntimeException(sprintf('Root page cannot be moved to %s', $parentRoute));
+ }
+
+ // Make sure page isn't being moved under itself.
+ $key = $this->getStorageKey();
+
+ /** @var PageObject|null $parent */
+ $parent = $parentKey !== false ? $this->getFlexDirectory()->getObject($parentKey, 'storage_key') : null;
+ if (!$parent) {
+ // Page cannot be moved to non-existing location.
+ throw new RuntimeException(sprintf('Page /%s cannot be moved to non-existing path %s', $key, $parentRoute));
+ }
+
+ // TODO: make sure that the page doesn't exist yet if moved/copied.
+ }
+
+ if ($reorder === true && !$this->root()) {
+ $reorder = $this->_reorder;
+ }
+
+ // Force automatic reorder if item is supposed to be added to the last.
+ if (!is_array($reorder) && (int)$this->order() >= 999999) {
+ $reorder = [];
+ }
+
+ // Reorder siblings.
+ $siblings = is_array($reorder) ? ($this->reorderSiblings($reorder) ?? []) : [];
+
+ $data = $this->prepareStorage();
+ unset($data['header']);
+
+ foreach ($siblings as $sibling) {
+ $data = $sibling->prepareStorage();
+ unset($data['header']);
+ }
+
+ return ['reorder' => $reorder, 'siblings' => $siblings];
+ }
+
+ /**
+ * @param array $variables
+ * @return array
+ */
+ protected function onSave(array $variables): array
+ {
+ /** @var PageCollection $siblings */
+ $siblings = $variables['siblings'];
+ /** @var PageObject $sibling */
+ foreach ($siblings as $sibling) {
+ $sibling->save(false);
+ }
+
+ return $variables;
+ }
+
+ /**
+ * @param array $variables
+ */
+ protected function onAfterSave(array $variables): void
+ {
+ $this->getFlexDirectory()->reloadIndex();
+ }
+
+ /**
+ * @param UserInterface|null $user
+ */
+ public function check(UserInterface $user = null): void
+ {
+ parent::check($user);
+
+ if ($user && $this->isMoved()) {
+ $parentKey = $this->getProperty('parent_key');
+
+ /** @var PageObject|null $parent */
+ $parent = $this->getFlexDirectory()->getObject($parentKey, 'storage_key');
+ if (!$parent || !$parent->isAuthorized('create', null, $user)) {
+ throw new \RuntimeException('Forbidden', 403);
+ }
+ }
+ }
+
+ /**
+ * @param array|bool $reorder
+ * @return static
+ */
+ public function save($reorder = true)
+ {
+ $variables = $this->onBeforeSave(func_get_args());
+
+ // Backwards compatibility with older plugins.
+ $fireEvents = $reorder && $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true);
+ $grav = $this->getContainer();
+ if ($fireEvents) {
+ $self = $this;
+ $grav->fireEvent('onAdminSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => &$self]));
+ if ($self !== $this) {
+ throw new RuntimeException('Switching Flex Page object during onAdminSave event is not supported! Please update plugin.');
+ }
+ }
+
+ /** @var static $instance */
+ $instance = parent::save();
+ $variables = $this->onSave($variables);
+
+ $this->onAfterSave($variables);
+
+ // Backwards compatibility with older plugins.
+ if ($fireEvents) {
+ $grav->fireEvent('onAdminAfterSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => $this]));
+ }
+
+ // Reset original after save events have all been called.
+ $this->_originalObject = null;
+
+ return $instance;
+ }
+
+ /**
+ * @return static
+ */
+ public function delete()
+ {
+ $result = parent::delete();
+
+ // Backwards compatibility with older plugins.
+ $fireEvents = $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true);
+ if ($fireEvents) {
+ $this->getContainer()->fireEvent('onAdminAfterDelete', new Event(['object' => $this]));
+ }
+
+ return $result;
+ }
+
+ /**
+ * Prepare move page to new location. Moves also everything that's under the current page.
+ *
+ * You need to call $this->save() in order to perform the move.
+ *
+ * @param PageInterface $parent New parent page.
+ * @return $this
+ */
+ public function move(PageInterface $parent)
+ {
+ if (!$parent instanceof FlexObjectInterface) {
+ throw new RuntimeException('Failed: Parent is not Flex Object');
+ }
+
+ $this->_reorder = [];
+ $this->setProperty('parent_key', $parent->getStorageKey());
+ $this->storeOriginal();
+
+ return $this;
+ }
+
+ /**
+ * @param UserInterface $user
+ * @param string $action
+ * @param string $scope
+ * @param bool $isMe
+ * @return bool|null
+ */
+ protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe): ?bool
+ {
+ // Special case: creating a new page means checking parent for its permissions.
+ if ($action === 'create' && !$this->exists()) {
+ $parent = $this->parent();
+ if ($parent && method_exists($parent, 'isAuthorized')) {
+ return $parent->isAuthorized($action, $scope, $user);
+ }
+
+ return false;
+ }
+
+ return parent::isAuthorizedOverride($user, $action, $scope, $isMe);
+ }
+
+ /**
+ * @return bool
+ */
+ protected function isMoved(): bool
+ {
+ $storageKey = $this->getMasterKey();
+ $filesystem = Filesystem::getInstance(false);
+ $oldParentKey = ltrim($filesystem->dirname("/{$storageKey}"), '/');
+ $newParentKey = $this->getProperty('parent_key');
+
+ return $this->exists() && $oldParentKey !== $newParentKey;
+ }
+
+ /**
+ * @param array $ordering
+ * @return PageCollection|null
+ * @phpstan-return ObjectCollection|null
+ */
+ protected function reorderSiblings(array $ordering)
+ {
+ $storageKey = $this->getMasterKey();
+ $isMoved = $this->isMoved();
+ $order = !$isMoved ? $this->order() : false;
+ if ($order !== false) {
+ $order = (int)$order;
+ }
+
+ $parent = $this->parent();
+ if (!$parent) {
+ throw new RuntimeException('Cannot reorder a page which has no parent');
+ }
+
+ /** @var PageCollection $siblings */
+ $siblings = $parent->children();
+ $siblings = $siblings->getCollection()->withOrdered();
+
+ // Handle special case where ordering isn't given.
+ if ($ordering === []) {
+ if ($order >= 999999) {
+ // Set ordering to point to be the last item, ignoring the object itself.
+ $order = 0;
+ foreach ($siblings as $sibling) {
+ if ($sibling->getKey() !== $this->getKey()) {
+ $order = max($order, (int)$sibling->order());
+ }
+ }
+ $this->order($order + 1);
+ }
+
+ // Do not change sibling ordering.
+ return null;
+ }
+
+ $siblings = $siblings->orderBy(['order' => 'ASC']);
+
+ if ($storageKey !== null) {
+ if ($order !== false) {
+ // Add current page back to the list if it's ordered.
+ $siblings->set($storageKey, $this);
+ } else {
+ // Remove old copy of the current page from the siblings list.
+ $siblings->remove($storageKey);
+ }
+ }
+
+ // Add missing siblings into the end of the list, keeping the previous ordering between them.
+ foreach ($siblings as $sibling) {
+ $folder = (string)$sibling->getProperty('folder');
+ $basename = preg_replace('|^\d+\.|', '', $folder);
+ if (!in_array($basename, $ordering, true)) {
+ $ordering[] = $basename;
+ }
+ }
+
+ // Reorder.
+ $ordering = array_flip(array_values($ordering));
+ $count = count($ordering);
+ foreach ($siblings as $sibling) {
+ $folder = (string)$sibling->getProperty('folder');
+ $basename = preg_replace('|^\d+\.|', '', $folder);
+ $newOrder = $ordering[$basename] ?? null;
+ $newOrder = null !== $newOrder ? $newOrder + 1 : (int)$sibling->order() + $count;
+ $sibling->order($newOrder);
+ }
+
+ $siblings = $siblings->orderBy(['order' => 'ASC']);
+ $siblings->removeElement($this);
+
+ // If menu item was moved, just make it to be the last in order.
+ if ($isMoved && $this->order() !== false) {
+ $parentKey = $this->getProperty('parent_key');
+ if ($parentKey === '') {
+ /** @var PageIndex $index */
+ $index = $this->getFlexDirectory()->getIndex();
+ $newParent = $index->getRoot();
+ } else {
+ $newParent = $this->getFlexDirectory()->getObject($parentKey, 'storage_key');
+ if (!$newParent instanceof PageInterface) {
+ throw new RuntimeException("New parent page '{$parentKey}' not found.");
+ }
+ }
+ /** @var PageCollection $newSiblings */
+ $newSiblings = $newParent->children();
+ $newSiblings = $newSiblings->getCollection()->withOrdered();
+ $order = 0;
+ foreach ($newSiblings as $sibling) {
+ $order = max($order, (int)$sibling->order());
+ }
+ $this->order($order + 1);
+ }
+
+ return $siblings;
+ }
+
+ /**
+ * @return string
+ */
+ public function full_order(): string
+ {
+ $route = $this->path() . '/' . $this->folder();
+
+ return preg_replace(PageIndex::ORDER_LIST_REGEX, '\\1', $route) ?? $route;
+ }
+
+ /**
+ * @param string $name
+ * @return Blueprint
+ */
+ protected function doGetBlueprint(string $name = ''): Blueprint
+ {
+ try {
+ // Make sure that pages has been initialized.
+ Pages::getTypes();
+
+ // TODO: We need to move raw blueprint logic to Grav itself to remove admin dependency here.
+ if ($name === 'raw') {
+ // Admin RAW mode.
+ if ($this->isAdminSite()) {
+ /** @var Admin $admin */
+ $admin = Grav::instance()['admin'];
+
+ $template = $this->isModule() ? 'modular_raw' : ($this->root() ? 'root_raw' : 'raw');
+
+ return $admin->blueprints("admin/pages/{$template}");
+ }
+ }
+
+ $template = $this->getProperty('template') . ($name ? '.' . $name : '');
+
+ $blueprint = $this->getFlexDirectory()->getBlueprint($template, 'blueprints://pages');
+ } catch (RuntimeException $e) {
+ $template = 'default' . ($name ? '.' . $name : '');
+
+ $blueprint = $this->getFlexDirectory()->getBlueprint($template, 'blueprints://pages');
+ }
+
+ $isNew = $blueprint->get('initialized', false) === false;
+ if ($isNew === true && $name === '') {
+ // Support onBlueprintCreated event just like in Pages::blueprints($template)
+ $blueprint->set('initialized', true);
+ $blueprint->setFilename($template);
+
+ Grav::instance()->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $template]));
+ }
+
+ return $blueprint;
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ public function getLevelListing(array $options): array
+ {
+ $index = $this->getFlexDirectory()->getIndex();
+ if (!is_callable([$index, 'getLevelListing'])) {
+ return [];
+ }
+
+ // Deal with relative paths.
+ $initial = $options['initial'] ?? null;
+ $var = $initial ? 'leaf_route' : 'route';
+ $route = $options[$var] ?? '';
+ if ($route !== '' && !str_starts_with($route, '/')) {
+ $filesystem = Filesystem::getInstance();
+
+ $route = "/{$this->getKey()}/{$route}";
+ $route = $filesystem->normalize($route);
+
+ $options[$var] = $route;
+ }
+
+ [$status, $message, $response,] = $index->getLevelListing($options);
+
+ return [$status, $message, $response, $options[$var] ?? null];
+ }
+
+ /**
+ * Filter page (true/false) by given filters.
+ *
+ * - search: string
+ * - extension: string
+ * - module: bool
+ * - visible: bool
+ * - routable: bool
+ * - published: bool
+ * - page: bool
+ * - translated: bool
+ *
+ * @param array $filters
+ * @param bool $recursive
+ * @return bool
+ */
+ 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 = $test->search((string)$value) > 0.0;
+ break;
+ case 'page_type':
+ $types = $value ? explode(',', $value) : [];
+ $matches = in_array($test->template(), $types, true);
+ break;
+ case 'extension':
+ $matches = Utils::contains((string)$value, $test->extension());
+ break;
+ case 'routable':
+ $matches = $test->isRoutable() === (bool)$value;
+ break;
+ case 'published':
+ $matches = $test->isPublished() === (bool)$value;
+ break;
+ case 'visible':
+ $matches = $test->isVisible() === (bool)$value;
+ break;
+ case 'module':
+ $matches = $test->isModule() === (bool)$value;
+ break;
+ case 'page':
+ $matches = $test->isPage() === (bool)$value;
+ break;
+ case 'folder':
+ $matches = $test->isPage() === !$value;
+ break;
+ case 'translated':
+ $matches = $test->hasTranslation() === (bool)$value;
+ break;
+ default:
+ $matches = true;
+ break;
+ }
+
+ // If current filter does not match, we still may have match as a parent.
+ if ($matches === false) {
+ if (!$recursive) {
+ return false;
+ }
+
+ /** @var PageIndex $index */
+ $index = $this->children()->getIndex();
+
+ return $index->filterBy($filters, true)->count() > 0;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * {@inheritdoc}
+ * @see FlexObjectInterface::exists()
+ */
+ public function exists(): bool
+ {
+ return $this->root ?: parent::exists();
+ }
+
+ /**
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ $list = parent::__debugInfo();
+
+ return $list + [
+ '_content_meta:private' => $this->getContentMeta(),
+ '_content:private' => $this->getRawContent()
+ ];
+ }
+
+ /**
+ * @param array $elements
+ * @param bool $extended
+ */
+ protected function filterElements(array &$elements, bool $extended = false): void
+ {
+ // Change parent page if needed.
+ if (array_key_exists('route', $elements) && isset($elements['folder'], $elements['name'])) {
+ $elements['template'] = $elements['name'];
+
+ // Figure out storage path to the new route.
+ $parentKey = trim($elements['route'] ?? '', '/');
+ if ($parentKey !== '') {
+ /** @var PageObject|null $parent */
+ $parent = $this->getFlexDirectory()->getObject($parentKey);
+ $parentKey = $parent ? $parent->getStorageKey() : $parentKey;
+ }
+
+ $elements['parent_key'] = $parentKey;
+ }
+
+ // Deal with ordering=bool and order=page1,page2,page3.
+ if ($this->root()) {
+ // Root page doesn't have ordering.
+ unset($elements['ordering'], $elements['order']);
+ } elseif (array_key_exists('ordering', $elements) && array_key_exists('order', $elements)) {
+ // Store ordering.
+ $ordering = $elements['order'] ?? null;
+ $this->_reorder = !empty($ordering) ? explode(',', $ordering) : [];
+
+ $order = false;
+ if ((bool)($elements['ordering'] ?? false)) {
+ $order = $this->order();
+ if ($order === false) {
+ $order = 999999;
+ }
+ }
+
+ $elements['order'] = $order;
+ }
+
+ parent::filterElements($elements, true);
+ }
+
+ /**
+ * @return array
+ */
+ public function prepareStorage(): array
+ {
+ $meta = $this->getMetaData();
+ $oldLang = $meta['lang'] ?? '';
+ $newLang = $this->getProperty('lang') ?? '';
+
+ // Always clone the page to the new language.
+ if ($oldLang !== $newLang) {
+ $meta['clone'] = true;
+ }
+
+ // Make sure that certain elements are always sent to the storage layer.
+ $elements = [
+ '__META' => $meta,
+ 'storage_key' => $this->getStorageKey(),
+ 'parent_key' => $this->getProperty('parent_key'),
+ 'order' => $this->getProperty('order'),
+ 'folder' => preg_replace('|^\d+\.|', '', $this->getProperty('folder') ?? ''),
+ 'template' => preg_replace('|modular/|', '', $this->getProperty('template') ?? ''),
+ 'lang' => $newLang
+ ] + parent::prepareStorage();
+
+ return $elements;
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php b/system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php
new file mode 100644
index 0000000000..f590e36614
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php
@@ -0,0 +1,700 @@
+flags = FilesystemIterator::KEY_AS_FILENAME | FilesystemIterator::CURRENT_AS_FILEINFO
+ | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS;
+
+ $grav = Grav::instance();
+
+ $config = $grav['config'];
+ $this->ignore_hidden = (bool)$config->get('system.pages.ignore_hidden');
+ $this->ignore_files = (array)$config->get('system.pages.ignore_files');
+ $this->ignore_folders = (array)$config->get('system.pages.ignore_folders');
+ $this->include_default_lang_file_extension = (bool)$config->get('system.languages.include_default_lang_file_extension', true);
+ $this->recurse = (bool)($options['recurse'] ?? true);
+ $this->regex = '/(\.([\w\d_-]+))?\.md$/D';
+ }
+
+ /**
+ * @param string $key
+ * @param bool $variations
+ * @return array
+ */
+ public function parseKey(string $key, bool $variations = true): array
+ {
+ if (mb_strpos($key, '|') !== false) {
+ [$key, $params] = explode('|', $key, 2);
+ } else {
+ $params = '';
+ }
+ $key = ltrim($key, '/');
+
+ $keys = parent::parseKey($key, false) + ['params' => $params];
+
+ if ($variations) {
+ $keys += $this->parseParams($key, $params);
+ }
+
+ return $keys;
+ }
+
+ /**
+ * @param string $key
+ * @return string
+ */
+ public function readFrontmatter(string $key): string
+ {
+ $path = $this->getPathFromKey($key);
+ $file = $this->getFile($path);
+ try {
+ if ($file instanceof MarkdownFile) {
+ $frontmatter = $file->frontmatter();
+ } else {
+ $frontmatter = $file->raw();
+ }
+ } catch (RuntimeException $e) {
+ $frontmatter = 'ERROR: ' . $e->getMessage();
+ } finally {
+ $file->free();
+ unset($file);
+ }
+
+ return $frontmatter;
+ }
+
+ /**
+ * @param string $key
+ * @return string
+ */
+ public function readRaw(string $key): string
+ {
+ $path = $this->getPathFromKey($key);
+ $file = $this->getFile($path);
+ try {
+ $raw = $file->raw();
+ } catch (RuntimeException $e) {
+ $raw = 'ERROR: ' . $e->getMessage();
+ } finally {
+ $file->free();
+ unset($file);
+ }
+
+ return $raw;
+ }
+
+ /**
+ * @param array $keys
+ * @param bool $includeParams
+ * @return string
+ */
+ public function buildStorageKey(array $keys, bool $includeParams = true): string
+ {
+ $key = $keys['key'] ?? null;
+ if (null === $key) {
+ $key = $keys['parent_key'] ?? '';
+ if ($key !== '') {
+ $key .= '/';
+ }
+ $order = $keys['order'] ?? null;
+ $folder = $keys['folder'] ?? 'undefined';
+ $key .= is_numeric($order) ? sprintf('%02d.%s', $order, $folder) : $folder;
+ }
+
+ $params = $includeParams ? $this->buildStorageKeyParams($keys) : '';
+
+ return $params ? "{$key}|{$params}" : $key;
+ }
+
+ /**
+ * @param array $keys
+ * @return string
+ */
+ public function buildStorageKeyParams(array $keys): string
+ {
+ $params = $keys['template'] ?? '';
+ $language = $keys['lang'] ?? '';
+ if ($language) {
+ $params .= '.' . $language;
+ }
+
+ return $params;
+ }
+
+ /**
+ * @param array $keys
+ * @return string
+ */
+ public function buildFolder(array $keys): string
+ {
+ return $this->dataFolder . '/' . $this->buildStorageKey($keys, false);
+ }
+
+ /**
+ * @param array $keys
+ * @return string
+ */
+ public function buildFilename(array $keys): string
+ {
+ $file = $this->buildStorageKeyParams($keys);
+
+ // Template is optional; if it is missing, we need to have to load the object metadata.
+ if ($file && $file[0] === '.') {
+ $meta = $this->getObjectMeta($this->buildStorageKey($keys, false));
+ $file = ($meta['template'] ?? 'folder') . $file;
+ }
+
+ return $file . $this->dataExt;
+ }
+
+ /**
+ * @param array $keys
+ * @return string
+ */
+ public function buildFilepath(array $keys): string
+ {
+ $folder = $this->buildFolder($keys);
+ $filename = $this->buildFilename($keys);
+
+ return rtrim($folder, '/') !== $folder ? $folder . $filename : $folder . '/' . $filename;
+ }
+
+ /**
+ * @param array $row
+ * @param bool $setDefaultLang
+ * @return array
+ */
+ public function extractKeysFromRow(array $row, bool $setDefaultLang = true): array
+ {
+ $meta = $row['__META'] ?? null;
+ $storageKey = $row['storage_key'] ?? $meta['storage_key'] ?? '';
+ $keyMeta = $storageKey !== '' ? $this->extractKeysFromStorageKey($storageKey) : null;
+ $parentKey = $row['parent_key'] ?? $meta['parent_key'] ?? $keyMeta['parent_key'] ?? '';
+ $order = $row['order'] ?? $meta['order'] ?? $keyMeta['order'] ?? null;
+ $folder = $row['folder'] ?? $meta['folder'] ?? $keyMeta['folder'] ?? '';
+ $template = $row['template'] ?? $meta['template'] ?? $keyMeta['template'] ?? '';
+ $lang = $row['lang'] ?? $meta['lang'] ?? $keyMeta['lang'] ?? '';
+
+ // Handle default language, if it should be saved without language extension.
+ if ($setDefaultLang && empty($meta['markdown'][$lang])) {
+ $grav = Grav::instance();
+
+ /** @var Language $language */
+ $language = $grav['language'];
+ $default = $language->getDefault();
+ // Make sure that the default language file doesn't exist before overriding it.
+ if (empty($meta['markdown'][$default])) {
+ if ($this->include_default_lang_file_extension) {
+ if ($lang === '') {
+ $lang = $language->getDefault();
+ }
+ } elseif ($lang === $language->getDefault()) {
+ $lang = '';
+ }
+ }
+ }
+
+ $keys = [
+ 'key' => null,
+ 'params' => null,
+ 'parent_key' => $parentKey,
+ 'order' => is_numeric($order) ? (int)$order : null,
+ 'folder' => $folder,
+ 'template' => $template,
+ 'lang' => $lang
+ ];
+
+ $keys['key'] = $this->buildStorageKey($keys, false);
+ $keys['params'] = $this->buildStorageKeyParams($keys);
+
+ return $keys;
+ }
+
+ /**
+ * @param string $key
+ * @return array
+ */
+ public function extractKeysFromStorageKey(string $key): array
+ {
+ if (mb_strpos($key, '|') !== false) {
+ [$key, $params] = explode('|', $key, 2);
+ [$template, $language] = mb_strpos($params, '.') !== false ? explode('.', $params, 2) : [$params, ''];
+ } else {
+ $params = $template = $language = '';
+ }
+ $objectKey = Utils::basename($key);
+ if (preg_match('|^(\d+)\.(.+)$|', $objectKey, $matches)) {
+ [, $order, $folder] = $matches;
+ } else {
+ [$order, $folder] = ['', $objectKey];
+ }
+
+ $filesystem = Filesystem::getInstance(false);
+
+ $parentKey = ltrim($filesystem->dirname('/' . $key), '/');
+
+ return [
+ 'key' => $key,
+ 'params' => $params,
+ 'parent_key' => $parentKey,
+ 'order' => is_numeric($order) ? (int)$order : null,
+ 'folder' => $folder,
+ 'template' => $template,
+ 'lang' => $language
+ ];
+ }
+
+ /**
+ * @param string $key
+ * @param string $params
+ * @return array
+ */
+ protected function parseParams(string $key, string $params): array
+ {
+ if (mb_strpos($params, '.') !== false) {
+ [$template, $language] = explode('.', $params, 2);
+ } else {
+ $template = $params;
+ $language = '';
+ }
+
+ if ($template === '') {
+ $meta = $this->getObjectMeta($key);
+ $template = $meta['template'] ?? 'folder';
+ }
+
+ return [
+ 'file' => $template . ($language ? '.' . $language : ''),
+ 'template' => $template,
+ 'lang' => $language
+ ];
+ }
+
+ /**
+ * Prepares the row for saving and returns the storage key for the record.
+ *
+ * @param array $row
+ */
+ protected function prepareRow(array &$row): void
+ {
+ // Remove keys used in the filesystem.
+ unset($row['parent_key'], $row['order'], $row['folder'], $row['template'], $row['lang']);
+ }
+
+ /**
+ * @param string $key
+ * @return array
+ */
+ protected function loadRow(string $key): ?array
+ {
+ $data = parent::loadRow($key);
+
+ // Special case for root page.
+ if ($key === '' && null !== $data) {
+ $data['root'] = true;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Page storage supports moving and copying the pages and their languages.
+ *
+ * $row['__META']['copy'] = true Use this if you want to copy the whole folder, otherwise it will be moved
+ * $row['__META']['clone'] = true Use this if you want to clone the file, otherwise it will be renamed
+ *
+ * @param string $key
+ * @param array $row
+ * @return array
+ */
+ protected function saveRow(string $key, array $row): array
+ {
+ // Initialize all key-related variables.
+ $newKeys = $this->extractKeysFromRow($row);
+ $newKey = $this->buildStorageKey($newKeys);
+ $newFolder = $this->buildFolder($newKeys);
+ $newFilename = $this->buildFilename($newKeys);
+ $newFilepath = rtrim($newFolder, '/') !== $newFolder ? $newFolder . $newFilename : $newFolder . '/' . $newFilename;
+
+ try {
+ if ($key === '' && empty($row['root'])) {
+ throw new RuntimeException('Page has no path');
+ }
+
+ $grav = Grav::instance();
+
+ /** @var Debugger $debugger */
+ $debugger = $grav['debugger'];
+ $debugger->addMessage("Save page: {$newKey}", 'debug');
+
+ // Check if the row already exists.
+ $oldKey = $row['__META']['storage_key'] ?? null;
+ if (is_string($oldKey)) {
+ // Initialize all old key-related variables.
+ $oldKeys = $this->extractKeysFromRow(['__META' => $row['__META']], false);
+ $oldFolder = $this->buildFolder($oldKeys);
+ $oldFilename = $this->buildFilename($oldKeys);
+
+ // Check if folder has changed.
+ if ($oldFolder !== $newFolder && file_exists($oldFolder)) {
+ $isCopy = $row['__META']['copy'] ?? false;
+ if ($isCopy) {
+ if (strpos($newFolder, $oldFolder . '/') === 0) {
+ throw new RuntimeException(sprintf('Page /%s cannot be copied to itself', $oldKey));
+ }
+
+ $this->copyRow($oldKey, $newKey);
+ $debugger->addMessage("Page copied: {$oldFolder} => {$newFolder}", 'debug');
+ } else {
+ if (strpos($newFolder, $oldFolder . '/') === 0) {
+ throw new RuntimeException(sprintf('Page /%s cannot be moved to itself', $oldKey));
+ }
+
+ $this->renameRow($oldKey, $newKey);
+ $debugger->addMessage("Page moved: {$oldFolder} => {$newFolder}", 'debug');
+ }
+ }
+
+ // Check if filename has changed.
+ if ($oldFilename !== $newFilename) {
+ // Get instance of the old file (we have already copied/moved it).
+ $oldFilepath = "{$newFolder}/{$oldFilename}";
+ $file = $this->getFile($oldFilepath);
+
+ // Rename the file if we aren't supposed to clone it.
+ $isClone = $row['__META']['clone'] ?? false;
+ if (!$isClone && $file->exists()) {
+ /** @var UniformResourceLocator $locator */
+ $locator = $grav['locator'];
+ $toPath = $locator->isStream($newFilepath) ? $locator->findResource($newFilepath, true, true) : GRAV_ROOT . "/{$newFilepath}";
+ $success = $file->rename($toPath);
+ if (!$success) {
+ throw new RuntimeException("Changing page template failed: {$oldFilepath} => {$newFilepath}");
+ }
+ $debugger->addMessage("Page template changed: {$oldFilename} => {$newFilename}", 'debug');
+ } else {
+ $file = null;
+ $debugger->addMessage("Page template created: {$newFilename}", 'debug');
+ }
+ }
+ }
+
+ // Clean up the data to be saved.
+ $this->prepareRow($row);
+ unset($row['__META'], $row['__ERROR']);
+
+ if (!isset($file)) {
+ $file = $this->getFile($newFilepath);
+ }
+
+ // Compare existing file content to the new one and save the file only if content has been changed.
+ $file->free();
+ $oldRaw = $file->raw();
+ $file->content($row);
+ $newRaw = $file->raw();
+ if ($oldRaw !== $newRaw) {
+ $file->save($row);
+ $debugger->addMessage("Page content saved: {$newFilepath}", 'debug');
+ } else {
+ $debugger->addMessage('Page content has not been changed, do not update the file', 'debug');
+ }
+ } catch (RuntimeException $e) {
+ $name = isset($file) ? $file->filename() : $newKey;
+
+ throw new RuntimeException(sprintf('Flex saveRow(%s): %s', $name, $e->getMessage()));
+ } finally {
+ /** @var UniformResourceLocator $locator */
+ $locator = Grav::instance()['locator'];
+ $locator->clearCache();
+
+ if (isset($file)) {
+ $file->free();
+ unset($file);
+ }
+ }
+
+ $row['__META'] = $this->getObjectMeta($newKey, true);
+
+ return $row;
+ }
+
+ /**
+ * Check if page folder should be deleted.
+ *
+ * Deleting page can be done either by deleting everything or just a single language.
+ * If key contains the language, delete only it, unless it is the last language.
+ *
+ * @param string $key
+ * @return bool
+ */
+ protected function canDeleteFolder(string $key): bool
+ {
+ // Return true if there's no language in the key.
+ $keys = $this->extractKeysFromStorageKey($key);
+ if (!$keys['lang']) {
+ return true;
+ }
+
+ // Get the main key and reload meta.
+ $key = $this->buildStorageKey($keys);
+ $meta = $this->getObjectMeta($key, true);
+
+ // Return true if there aren't any markdown files left.
+ return empty($meta['markdown'] ?? []);
+ }
+
+ /**
+ * Get key from the filesystem path.
+ *
+ * @param string $path
+ * @return string
+ */
+ protected function getKeyFromPath(string $path): string
+ {
+ if ($this->base_path) {
+ $path = $this->base_path . '/' . $path;
+ }
+
+ return $path;
+ }
+
+ /**
+ * Returns list of all stored keys in [key => timestamp] pairs.
+ *
+ * @return array
+ */
+ protected function buildIndex(): array
+ {
+ $this->clearCache();
+
+ return $this->getIndexMeta();
+ }
+
+ /**
+ * @param string $key
+ * @param bool $reload
+ * @return array
+ */
+ protected function getObjectMeta(string $key, bool $reload = false): array
+ {
+ $keys = $this->extractKeysFromStorageKey($key);
+ $key = $keys['key'];
+
+ if ($reload || !isset($this->meta[$key])) {
+ /** @var UniformResourceLocator $locator */
+ $locator = Grav::instance()['locator'];
+ if (mb_strpos($key, '@@') === false) {
+ $path = $this->getStoragePath($key);
+ if (is_string($path)) {
+ $path = $locator->isStream($path) ? $locator->findResource($path) : GRAV_ROOT . "/{$path}";
+ } else {
+ $path = null;
+ }
+ } else {
+ $path = null;
+ }
+
+ $modified = 0;
+ $markdown = [];
+ $children = [];
+
+ if (is_string($path) && is_dir($path)) {
+ $modified = filemtime($path);
+ $iterator = new FilesystemIterator($path, $this->flags);
+
+ /** @var SplFileInfo $info */
+ foreach ($iterator as $k => $info) {
+ // Ignore all hidden files if set.
+ if ($k === '' || ($this->ignore_hidden && $k[0] === '.')) {
+ continue;
+ }
+
+ if ($info->isDir()) {
+ // Ignore all folders in ignore list.
+ if ($this->ignore_folders && in_array($k, $this->ignore_folders, true)) {
+ continue;
+ }
+
+ $children[$k] = false;
+ } else {
+ // Ignore all files in ignore list.
+ if ($this->ignore_files && in_array($k, $this->ignore_files, true)) {
+ continue;
+ }
+
+ $timestamp = $info->getMTime();
+
+ // Page is the one that matches to $page_extensions list with the lowest index number.
+ if (preg_match($this->regex, $k, $matches)) {
+ $mark = $matches[2] ?? '';
+ $ext = $matches[1] ?? '';
+ $ext .= $this->dataExt;
+ $markdown[$mark][Utils::basename($k, $ext)] = $timestamp;
+ }
+
+ $modified = max($modified, $timestamp);
+ }
+ }
+ }
+
+ $rawRoute = trim(preg_replace(PageIndex::PAGE_ROUTE_REGEX, '/', "/{$key}") ?? '', '/');
+ $route = PageIndex::normalizeRoute($rawRoute);
+
+ ksort($markdown, SORT_NATURAL | SORT_FLAG_CASE);
+ ksort($children, SORT_NATURAL | SORT_FLAG_CASE);
+
+ $file = array_key_first($markdown[''] ?? (reset($markdown) ?: []));
+
+ $meta = [
+ 'key' => $route,
+ 'storage_key' => $key,
+ 'template' => $file,
+ 'storage_timestamp' => $modified,
+ ];
+ if ($markdown) {
+ $meta['markdown'] = $markdown;
+ }
+ if ($children) {
+ $meta['children'] = $children;
+ }
+ $meta['checksum'] = md5(json_encode($meta) ?: '');
+
+ // Cache meta as copy.
+ $this->meta[$key] = $meta;
+ } else {
+ $meta = $this->meta[$key];
+ }
+
+ $params = $keys['params'];
+ if ($params) {
+ $language = $keys['lang'];
+ $template = $keys['template'] ?: array_key_first($meta['markdown'][$language]) ?? $meta['template'];
+ $meta['exists'] = ($template && !empty($meta['children'])) || isset($meta['markdown'][$language][$template]);
+ $meta['storage_key'] .= '|' . $params;
+ $meta['template'] = $template;
+ $meta['lang'] = $language;
+ }
+
+ return $meta;
+ }
+
+ /**
+ * @return array
+ */
+ protected function getIndexMeta(): array
+ {
+ $queue = [''];
+ $list = [];
+ do {
+ $current = array_pop($queue);
+ if ($current === null) {
+ break;
+ }
+
+ $meta = $this->getObjectMeta($current);
+ $storage_key = $meta['storage_key'];
+
+ if (!empty($meta['children'])) {
+ $prefix = $storage_key . ($storage_key !== '' ? '/' : '');
+
+ foreach ($meta['children'] as $child => $value) {
+ $queue[] = $prefix . $child;
+ }
+ }
+
+ $list[$storage_key] = $meta;
+ } while ($queue);
+
+ ksort($list, SORT_NATURAL | SORT_FLAG_CASE);
+
+ // Update parent timestamps.
+ foreach (array_reverse($list) as $storage_key => $meta) {
+ if ($storage_key !== '') {
+ $filesystem = Filesystem::getInstance(false);
+
+ $storage_key = (string)$storage_key;
+ $parentKey = $filesystem->dirname($storage_key);
+ if ($parentKey === '.') {
+ $parentKey = '';
+ }
+
+ /** @phpstan-var array{'storage_key': string, 'storage_timestamp': int, 'children': array} $parent */
+ $parent = &$list[$parentKey];
+ $basename = Utils::basename($storage_key);
+
+ if (isset($parent['children'][$basename])) {
+ $timestamp = $meta['storage_timestamp'];
+ $parent['children'][$basename] = $timestamp;
+ if ($basename && $basename[0] === '_') {
+ $parent['storage_timestamp'] = max($parent['storage_timestamp'], $timestamp);
+ }
+ }
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getNewKey(): string
+ {
+ throw new RuntimeException('Generating random key is disabled for pages');
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php
new file mode 100644
index 0000000000..9a2c7c3389
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php
@@ -0,0 +1,75 @@
+getProperty($property) : null;
+ if (null === $value) {
+ $value = $this->language() . ($var ?? ($this->modified() . md5($this->filePath() ?? $this->getKey())));
+
+ $this->setProperty($property, $value);
+ if ($this->doHasProperty($property)) {
+ $value = $this->getProperty($property);
+ }
+ }
+
+ return $value;
+ }
+
+
+ /**
+ * @inheritdoc
+ */
+ public function date($var = null): int
+ {
+ return $this->loadHeaderProperty(
+ 'date',
+ $var,
+ function ($value) {
+ $value = $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : false;
+
+ if (!$value) {
+ // Get the specific translation updated date.
+ $meta = $this->getMetaData();
+ $language = $meta['lang'] ?? '';
+ $template = $this->getProperty('template');
+ $value = $meta['markdown'][$language][$template] ?? 0;
+ }
+
+ return $value ?: $this->modified();
+ }
+ );
+ }
+
+ /**
+ * @inheritdoc
+ * @param bool $bool
+ */
+ public function isPage(bool $bool = true): bool
+ {
+ $meta = $this->getMetaData();
+
+ return empty($meta['markdown']) !== $bool;
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php
new file mode 100644
index 0000000000..98f3949247
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php
@@ -0,0 +1,236 @@
+path() ?? '';
+
+ return $pages->children($path);
+ }
+
+ /**
+ * Check to see if this item is the first in an array of sub-pages.
+ *
+ * @return bool True if item is first.
+ */
+ public function isFirst(): bool
+ {
+ if (Utils::isAdminPlugin()) {
+ return parent::isFirst();
+ }
+
+ $path = $this->path();
+ $parent = $this->parent();
+ $collection = $parent ? $parent->collection('content', false) : null;
+ if (null !== $path && $collection instanceof PageCollectionInterface) {
+ return $collection->isFirst($path);
+ }
+
+ return true;
+ }
+
+ /**
+ * Check to see if this item is the last in an array of sub-pages.
+ *
+ * @return bool True if item is last
+ */
+ public function isLast(): bool
+ {
+ if (Utils::isAdminPlugin()) {
+ return parent::isLast();
+ }
+
+ $path = $this->path();
+ $parent = $this->parent();
+ $collection = $parent ? $parent->collection('content', false) : null;
+ if (null !== $path && $collection instanceof PageCollectionInterface) {
+ return $collection->isLast($path);
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the adjacent sibling based on a direction.
+ *
+ * @param int $direction either -1 or +1
+ * @return PageInterface|false the sibling page
+ */
+ public function adjacentSibling($direction = 1)
+ {
+ if (Utils::isAdminPlugin()) {
+ return parent::adjacentSibling($direction);
+ }
+
+ $path = $this->path();
+ $parent = $this->parent();
+ $collection = $parent ? $parent->collection('content', false) : null;
+ if (null !== $path && $collection instanceof PageCollectionInterface) {
+ $child = $collection->adjacentSibling($path, $direction);
+ if ($child instanceof PageInterface) {
+ return $child;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Helper method to return an ancestor page.
+ *
+ * @param string|null $lookup Name of the parent folder
+ * @return PageInterface|null page you were looking for if it exists
+ */
+ public function ancestor($lookup = null)
+ {
+ if (Utils::isAdminPlugin()) {
+ return parent::ancestor($lookup);
+ }
+
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ return $pages->ancestor($this->getProperty('parent_route'), $lookup);
+ }
+
+ /**
+ * Method that contains shared logic for inherited() and inheritedField()
+ *
+ * @param string $field Name of the parent folder
+ * @return array
+ */
+ protected function getInheritedParams($field): array
+ {
+ if (Utils::isAdminPlugin()) {
+ return parent::getInheritedParams($field);
+ }
+
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ $inherited = $pages->inherited($this->getProperty('parent_route'), $field);
+ $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : [];
+ $currentParams = (array)$this->getFormValue('header.' . $field);
+ if ($inheritedParams && is_array($inheritedParams)) {
+ $currentParams = array_replace_recursive($inheritedParams, $currentParams);
+ }
+
+ return [$inherited, $currentParams];
+ }
+
+ /**
+ * Helper method to return a page.
+ *
+ * @param string $url the url of the page
+ * @param bool $all
+ * @return PageInterface|null page you were looking for if it exists
+ */
+ public function find($url, $all = false)
+ {
+ if (Utils::isAdminPlugin()) {
+ return parent::find($url, $all);
+ }
+
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ return $pages->find($url, $all);
+ }
+
+ /**
+ * Get a collection of pages in the current context.
+ *
+ * @param string|array $params
+ * @param bool $pagination
+ * @return PageCollectionInterface|Collection
+ * @throws InvalidArgumentException
+ */
+ public function collection($params = 'content', $pagination = true)
+ {
+ if (Utils::isAdminPlugin()) {
+ return parent::collection($params, $pagination);
+ }
+
+ if (is_string($params)) {
+ // Look into a page header field.
+ $params = (array)$this->getFormValue('header.' . $params);
+ } elseif (!is_array($params)) {
+ throw new InvalidArgumentException('Argument should be either header variable name or array of parameters');
+ }
+
+ $context = [
+ 'pagination' => $pagination,
+ 'self' => $this
+ ];
+
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ return $pages->getCollection($params, $context);
+ }
+
+ /**
+ * @param string|array $value
+ * @param bool $only_published
+ * @return PageCollectionInterface|Collection
+ */
+ public function evaluate($value, $only_published = true)
+ {
+ if (Utils::isAdminPlugin()) {
+ return parent::collection($value, $only_published);
+ }
+
+ $params = [
+ 'items' => $value,
+ 'published' => $only_published
+ ];
+ $context = [
+ 'event' => false,
+ 'pagination' => false,
+ 'url_taxonomy_filters' => false,
+ 'self' => $this
+ ];
+
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ return $pages->getCollection($params, $context);
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php
new file mode 100644
index 0000000000..ace74dc38e
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php
@@ -0,0 +1,122 @@
+root()) {
+ return null;
+ }
+
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ $filesystem = Filesystem::getInstance(false);
+
+ // FIXME: this does not work, needs to use $pages->get() with cached parent id!
+ $key = $this->getKey();
+ $parent_route = $filesystem->dirname('/' . $key);
+
+ return $parent_route !== '/' ? $pages->find($parent_route) : $pages->root();
+ }
+
+ /**
+ * Returns the item in the current position.
+ *
+ * @return int|null the index of the current page.
+ */
+ public function currentPosition(): ?int
+ {
+ $path = $this->path();
+ $parent = $this->parent();
+ $collection = $parent ? $parent->collection('content', false) : null;
+ if (null !== $path && $collection instanceof PageCollectionInterface) {
+ return $collection->currentPosition($path);
+ }
+
+ return 1;
+ }
+
+ /**
+ * Returns whether or not this page is the currently active page requested via the URL.
+ *
+ * @return bool True if it is active
+ */
+ public function active(): bool
+ {
+ $grav = Grav::instance();
+ $uri_path = rtrim(urldecode($grav['uri']->path()), '/') ?: '/';
+ $routes = $grav['pages']->routes();
+
+ return isset($routes[$uri_path]) && $routes[$uri_path] === $this->path();
+ }
+
+ /**
+ * Returns whether or not this URI's URL contains the URL of the active page.
+ * Or in other words, is this page's URL in the current URL
+ *
+ * @return bool True if active child exists
+ */
+ public function activeChild(): bool
+ {
+ $grav = Grav::instance();
+ /** @var Uri $uri */
+ $uri = $grav['uri'];
+ /** @var Pages $pages */
+ $pages = $grav['pages'];
+ $uri_path = rtrim(urldecode($uri->path()), '/');
+ $routes = $pages->routes();
+
+ if (isset($routes[$uri_path])) {
+ $page = $pages->find($uri->route());
+ /** @var PageInterface|null $child_page */
+ $child_page = $page ? $page->parent() : null;
+ while ($child_page && !$child_page->root()) {
+ if ($this->path() === $child_page->path()) {
+ return true;
+ }
+ $child_page = $child_page->parent();
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php
new file mode 100644
index 0000000000..a30ae1a3e8
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php
@@ -0,0 +1,108 @@
+getLanguageTemplates();
+ if (!$translated) {
+ return $translated;
+ }
+
+ $grav = Grav::instance();
+
+ /** @var Language $language */
+ $language = $grav['language'];
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $grav['locator'];
+
+ $languages = $language->getLanguages();
+ $languages[] = '';
+ $defaultCode = $language->getDefault();
+
+ if (isset($translated[$defaultCode])) {
+ unset($translated['']);
+ }
+
+ foreach ($translated as $key => &$template) {
+ $template .= $key !== '' ? ".{$key}.md" : '.md';
+ }
+ unset($template);
+
+ $translated = array_intersect_key($translated, array_flip($languages));
+
+ $folder = $this->getStorageFolder();
+ if (!$folder) {
+ return [];
+ }
+ $folder = $locator->isStream($folder) ? $locator->getResource($folder) : GRAV_ROOT . "/{$folder}";
+
+ $list = array_fill_keys($languages, null);
+ foreach ($translated as $languageCode => $languageFile) {
+ $languageExtension = $languageCode ? ".{$languageCode}.md" : '.md';
+ $path = "{$folder}/{$languageFile}";
+
+ // FIXME: use flex, also rawRoute() does not fully work?
+ $aPage = new Page();
+ $aPage->init(new SplFileInfo($path), $languageExtension);
+ if ($onlyPublished && !$aPage->published()) {
+ continue;
+ }
+
+ $header = $aPage->header();
+ // @phpstan-ignore-next-line
+ $routes = $header->routes ?? [];
+ $route = $routes['default'] ?? $aPage->rawRoute();
+ if (!$route) {
+ $route = $aPage->route();
+ }
+
+ $list[$languageCode ?: $defaultCode] = $route ?? '';
+ }
+
+ $list = array_filter($list, static function ($var) {
+ return null !== $var;
+ });
+
+ // Hack to get the same result as with old pages.
+ foreach ($list as &$path) {
+ if ($path === '') {
+ $path = null;
+ }
+ }
+
+ return $list;
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php
new file mode 100644
index 0000000000..3a91d77cc5
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php
@@ -0,0 +1,56 @@
+
+ */
+class UserGroupCollection extends FlexCollection
+{
+ /**
+ * @return array
+ */
+ public static function getCachedMethods(): array
+ {
+ return [
+ 'authorize' => false,
+ ] + parent::getCachedMethods();
+ }
+
+ /**
+ * Checks user authorization to the action.
+ *
+ * @param string $action
+ * @param string|null $scope
+ * @return bool|null
+ */
+ public function authorize(string $action, string $scope = null): ?bool
+ {
+ $authorized = null;
+ /** @var UserGroupObject $object */
+ foreach ($this as $object) {
+ $auth = $object->authorize($action, $scope);
+ if ($auth === true) {
+ $authorized = true;
+ } elseif ($auth === false) {
+ return false;
+ }
+ }
+
+ return $authorized;
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php
new file mode 100644
index 0000000000..66de52e736
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php
@@ -0,0 +1,24 @@
+
+ */
+class UserGroupIndex extends FlexIndex
+{
+}
diff --git a/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php
new file mode 100644
index 0000000000..4a59d520a8
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php
@@ -0,0 +1,121 @@
+ false,
+ ] + parent::getCachedMethods();
+ }
+
+ /**
+ * @return string
+ */
+ public function getTitle(): string
+ {
+ return $this->getProperty('readableName');
+ }
+
+ /**
+ * Checks user authorization to the action.
+ *
+ * @param string $action
+ * @param string|null $scope
+ * @return bool|null
+ */
+ public function authorize(string $action, string $scope = null): ?bool
+ {
+ if ($scope === 'test') {
+ $scope = null;
+ } elseif (!$this->getProperty('enabled', true)) {
+ return null;
+ }
+
+ $access = $this->getAccess();
+
+ $authorized = $access->authorize($action, $scope);
+ if (is_bool($authorized)) {
+ return $authorized;
+ }
+
+ return $access->authorize('admin.super') ? true : null;
+ }
+
+ /**
+ * @return Access
+ */
+ protected function getAccess(): Access
+ {
+ if (null === $this->_access) {
+ $this->getProperty('access');
+ }
+
+ return $this->_access;
+ }
+
+ /**
+ * @param mixed $value
+ * @return array
+ */
+ protected function offsetLoad_access($value): array
+ {
+ if (!$value instanceof Access) {
+ $value = new Access($value);
+ }
+
+ $this->_access = $value;
+
+ return $value->jsonSerialize();
+ }
+
+ /**
+ * @param mixed $value
+ * @return array
+ */
+ protected function offsetPrepare_access($value): array
+ {
+ return $this->offsetLoad_access($value);
+ }
+
+ /**
+ * @param array|null $value
+ * @return array|null
+ */
+ protected function offsetSerialize_access(?array $value): ?array
+ {
+ return $value;
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php b/system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php
new file mode 100644
index 0000000000..664f24faa0
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php
@@ -0,0 +1,47 @@
+update($data)` instead (same but with data validation & filtering, file upload support).
+ */
+ public function merge(array $data)
+ {
+ user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->update($data) method instead', E_USER_DEPRECATED);
+
+ $this->setElements($this->getBlueprint()->mergeData($this->toArray(), $data));
+
+ return $this;
+ }
+
+ /**
+ * Return media object for the User's avatar.
+ *
+ * @return ImageMedium|StaticImageMedium|null
+ * @deprecated 1.6 Use ->getAvatarImage() method instead.
+ */
+ public function getAvatarMedia()
+ {
+ user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getAvatarImage() method instead', E_USER_DEPRECATED);
+
+ return $this->getAvatarImage();
+ }
+
+ /**
+ * Return the User's avatar URL
+ *
+ * @return string
+ * @deprecated 1.6 Use ->getAvatarUrl() method instead.
+ */
+ public function avatarUrl()
+ {
+ user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getAvatarUrl() method instead', E_USER_DEPRECATED);
+
+ return $this->getAvatarUrl();
+ }
+
+ /**
+ * Checks user authorization to the action.
+ * Ensures backwards compatibility
+ *
+ * @param string $action
+ * @return bool
+ * @deprecated 1.5 Use ->authorize() method instead.
+ */
+ public function authorise($action)
+ {
+ user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->authorize() method instead', E_USER_DEPRECATED);
+
+ return $this->authorize($action) ?? false;
+ }
+
+ /**
+ * Implements Countable interface.
+ *
+ * @return int
+ * @deprecated 1.6 Method makes no sense for user account.
+ */
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED);
+
+ return count($this->jsonSerialize());
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Users/UserCollection.php b/system/src/Grav/Common/Flex/Types/Users/UserCollection.php
new file mode 100644
index 0000000000..f5641e7324
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Users/UserCollection.php
@@ -0,0 +1,135 @@
+
+ */
+class UserCollection extends FlexCollection implements UserCollectionInterface
+{
+ /**
+ * @return array
+ */
+ public static function getCachedMethods(): array
+ {
+ return [
+ 'authorize' => 'session',
+ ] + parent::getCachedMethods();
+ }
+
+ /**
+ * Load user account.
+ *
+ * Always creates user object. To check if user exists, use $this->exists().
+ *
+ * @param string $username
+ * @return UserObject
+ */
+ public function load($username): UserInterface
+ {
+ $username = (string)$username;
+
+ if ($username !== '') {
+ $key = $this->filterUsername($username);
+ $user = $this->get($key);
+ if ($user) {
+ return $user;
+ }
+ } else {
+ $key = '';
+ }
+
+ $directory = $this->getFlexDirectory();
+
+ /** @var UserObject $object */
+ $object = $directory->createObject(
+ [
+ 'username' => $username,
+ 'state' => 'enabled'
+ ],
+ $key
+ );
+
+ return $object;
+ }
+
+ /**
+ * Find a user by username, email, etc
+ *
+ * @param string $query the query to search for
+ * @param string|string[] $fields the fields to search
+ * @return UserObject
+ */
+ public function find($query, $fields = ['username', 'email']): UserInterface
+ {
+ if (is_string($query) && $query !== '') {
+ foreach ((array)$fields as $field) {
+ if ($field === 'key') {
+ $user = $this->get($query);
+ } elseif ($field === 'storage_key') {
+ $user = $this->withKeyField('storage_key')->get($query);
+ } elseif ($field === 'flex_key') {
+ $user = $this->withKeyField('flex_key')->get($query);
+ } elseif ($field === 'username') {
+ $user = $this->get($this->filterUsername($query));
+ } else {
+ $user = parent::find($query, $field);
+ }
+ if ($user instanceof UserObject) {
+ return $user;
+ }
+ }
+ }
+
+ return $this->load('');
+ }
+
+ /**
+ * Delete user account.
+ *
+ * @param string $username
+ * @return bool True if user account was found and was deleted.
+ */
+ public function delete($username): bool
+ {
+ $user = $this->load($username);
+
+ $exists = $user->exists();
+ if ($exists) {
+ $user->delete();
+ }
+
+ return $exists;
+ }
+
+ /**
+ * @param string $key
+ * @return string
+ */
+ protected function filterUsername(string $key): string
+ {
+ $storage = $this->getFlexDirectory()->getStorage();
+ if (method_exists($storage, 'normalizeKey')) {
+ return $storage->normalizeKey($key);
+ }
+
+ return mb_strtolower($key);
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Users/UserIndex.php b/system/src/Grav/Common/Flex/Types/Users/UserIndex.php
new file mode 100644
index 0000000000..868aad756a
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Users/UserIndex.php
@@ -0,0 +1,206 @@
+
+ */
+class UserIndex extends FlexIndex implements UserCollectionInterface
+{
+ public const VERSION = parent::VERSION . '.2';
+
+ /**
+ * @param FlexStorageInterface $storage
+ * @return array
+ */
+ public static function loadEntriesFromStorage(FlexStorageInterface $storage): array
+ {
+ // Load saved index.
+ $index = static::loadIndex($storage);
+
+ $version = $index['version'] ?? 0;
+ $force = static::VERSION !== $version;
+
+ // TODO: Following check flex index to be out of sync after some saves, disabled until better solution is found.
+ //$timestamp = $index['timestamp'] ?? 0;
+ //if (!$force && $timestamp && $timestamp > time() - 1) {
+ // return $index['index'];
+ //}
+
+ // Load up-to-date index.
+ $entries = parent::loadEntriesFromStorage($storage);
+
+ return static::updateIndexFile($storage, $index['index'], $entries, ['force_update' => $force]);
+ }
+
+ /**
+ * @param array $meta
+ * @param array $data
+ * @param FlexStorageInterface $storage
+ * @return void
+ */
+ public static function updateObjectMeta(array &$meta, array $data, FlexStorageInterface $storage): void
+ {
+ // Username can also be number and stored as such.
+ $key = (string)($data['username'] ?? $meta['key'] ?? $meta['storage_key']);
+ $meta['key'] = static::filterUsername($key, $storage);
+ $meta['email'] = isset($data['email']) ? mb_strtolower($data['email']) : null;
+ }
+
+ /**
+ * Load user account.
+ *
+ * Always creates user object. To check if user exists, use $this->exists().
+ *
+ * @param string $username
+ * @return UserObject
+ */
+ public function load($username): UserInterface
+ {
+ $username = (string)$username;
+
+ if ($username !== '') {
+ $key = static::filterUsername($username, $this->getFlexDirectory()->getStorage());
+ $user = $this->get($key);
+ if ($user) {
+ return $user;
+ }
+ } else {
+ $key = '';
+ }
+
+ $directory = $this->getFlexDirectory();
+
+ /** @var UserObject $object */
+ $object = $directory->createObject(
+ [
+ 'username' => $username,
+ 'state' => 'enabled'
+ ],
+ $key
+ );
+
+ return $object;
+ }
+
+ /**
+ * Delete user account.
+ *
+ * @param string $username
+ * @return bool True if user account was found and was deleted.
+ */
+ public function delete($username): bool
+ {
+ $user = $this->load($username);
+
+ $exists = $user->exists();
+ if ($exists) {
+ $user->delete();
+ }
+
+ return $exists;
+ }
+
+ /**
+ * Find a user by username, email, etc
+ *
+ * @param string $query the query to search for
+ * @param array $fields the fields to search
+ * @return UserObject
+ */
+ public function find($query, $fields = ['username', 'email']): UserInterface
+ {
+ if (is_string($query) && $query !== '') {
+ foreach ((array)$fields as $field) {
+ if ($field === 'key') {
+ $user = $this->get($query);
+ } elseif ($field === 'storage_key') {
+ $user = $this->withKeyField('storage_key')->get($query);
+ } elseif ($field === 'flex_key') {
+ $user = $this->withKeyField('flex_key')->get($query);
+ } elseif ($field === 'email') {
+ $email = mb_strtolower($query);
+ $user = $this->withKeyField('email')->get($email);
+ } elseif ($field === 'username') {
+ $username = static::filterUsername($query, $this->getFlexDirectory()->getStorage());
+ $user = $this->get($username);
+ } else {
+ $user = $this->__call('find', [$query, $field]);
+ }
+ if ($user) {
+ return $user;
+ }
+ }
+ }
+
+ return $this->load('');
+ }
+
+ /**
+ * @param string $key
+ * @param FlexStorageInterface $storage
+ * @return string
+ */
+ protected static function filterUsername(string $key, FlexStorageInterface $storage): string
+ {
+ return method_exists($storage, 'normalizeKey') ? $storage->normalizeKey($key) : $key;
+ }
+
+ /**
+ * @param FlexStorageInterface $storage
+ * @return CompiledYamlFile|null
+ */
+ protected static function getIndexFile(FlexStorageInterface $storage)
+ {
+ // Load saved index file.
+ $grav = Grav::instance();
+ $locator = $grav['locator'];
+ $filename = $locator->findResource('user-data://flex/indexes/accounts.yaml', true, true);
+
+ return CompiledYamlFile::instance($filename);
+ }
+
+ /**
+ * @param array $entries
+ * @param array $added
+ * @param array $updated
+ * @param array $removed
+ */
+ protected static function onChanges(array $entries, array $added, array $updated, array $removed): void
+ {
+ $message = sprintf('Flex: User index updated, %d objects (%d added, %d updated, %d removed).', count($entries), count($added), count($updated), count($removed));
+
+ $grav = Grav::instance();
+
+ /** @var Logger $logger */
+ $logger = $grav['log'];
+ $logger->addDebug($message);
+
+ /** @var Debugger $debugger */
+ $debugger = $grav['debugger'];
+ $debugger->addMessage($message, 'debug');
+ }
+}
diff --git a/system/src/Grav/Common/Flex/Types/Users/UserObject.php b/system/src/Grav/Common/Flex/Types/Users/UserObject.php
new file mode 100644
index 0000000000..f6c9982d46
--- /dev/null
+++ b/system/src/Grav/Common/Flex/Types/Users/UserObject.php
@@ -0,0 +1,1059 @@
+ 'session',
+ 'load' => false,
+ 'find' => false,
+ 'remove' => false,
+ 'get' => true,
+ 'set' => false,
+ 'undef' => false,
+ 'def' => false,
+ ] + parent::getCachedMethods();
+ }
+
+ /**
+ * UserObject constructor.
+ * @param array $elements
+ * @param string $key
+ * @param FlexDirectory $directory
+ * @param bool $validate
+ */
+ public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false)
+ {
+ // User can only be authenticated via login.
+ unset($elements['authenticated'], $elements['authorized']);
+
+ // Define username if it's not set.
+ if (!isset($elements['username'])) {
+ $storageKey = $elements['__META']['storage_key'] ?? null;
+ $storage = $directory->getStorage();
+ if (null !== $storageKey && method_exists($storage, 'normalizeKey') && $key === $storage->normalizeKey($storageKey)) {
+ $elements['username'] = $storageKey;
+ } else {
+ $elements['username'] = $key;
+ }
+ }
+
+ // Define state if it isn't set.
+ if (!isset($elements['state'])) {
+ $elements['state'] = 'enabled';
+ }
+
+ parent::__construct($elements, $key, $directory, $validate);
+ }
+
+ public function __clone()
+ {
+ $this->_access = null;
+ $this->_groups = null;
+
+ parent::__clone();
+ }
+
+ /**
+ * @return void
+ */
+ public function onPrepareRegistration(): void
+ {
+ if (!$this->getProperty('access')) {
+ /** @var Config $config */
+ $config = Grav::instance()['config'];
+
+ $groups = $config->get('plugins.login.user_registration.groups', '');
+ $access = $config->get('plugins.login.user_registration.access', ['site' => ['login' => true]]);
+
+ $this->setProperty('groups', $groups);
+ $this->setProperty('access', $access);
+ }
+ }
+
+ /**
+ * Helper to get content editor will fall back if not set
+ *
+ * @return string
+ */
+ public function getContentEditor(): string
+ {
+ return $this->getProperty('content_editor', 'default');
+ }
+
+ /**
+ * 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)
+ {
+ return $this->getNestedProperty($name, $default, $separator);
+ }
+
+ /**
+ * Set value by using dot notation for nested arrays/objects.
+ *
+ * @example $data->set('this.is.my.nested.variable', $value);
+ *
+ * @param string $name Dot separated path to the requested value.
+ * @param mixed $value New value.
+ * @param string|null $separator Separator, defaults to '.'
+ * @return $this
+ */
+ public function set($name, $value, $separator = null)
+ {
+ $this->setNestedProperty($name, $value, $separator);
+
+ return $this;
+ }
+
+ /**
+ * Unset value by using dot notation for nested arrays/objects.
+ *
+ * @example $data->undef('this.is.my.nested.variable');
+ *
+ * @param string $name Dot separated path to the requested value.
+ * @param string|null $separator Separator, defaults to '.'
+ * @return $this
+ */
+ public function undef($name, $separator = null)
+ {
+ $this->unsetNestedProperty($name, $separator);
+
+ return $this;
+ }
+
+ /**
+ * Set default value by using dot notation for nested arrays/objects.
+ *
+ * @example $data->def('this.is.my.nested.variable', 'default');
+ *
+ * @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 $this
+ */
+ public function def($name, $default = null, $separator = null)
+ {
+ $this->defNestedProperty($name, $default, $separator);
+
+ return $this;
+ }
+
+ /**
+ * @param UserInterface|null $user
+ * @return bool
+ */
+ public function isMyself(?UserInterface $user = null): bool
+ {
+ if (null === $user) {
+ $user = $this->getActiveUser();
+ if ($user && !$user->authenticated) {
+ $user = null;
+ }
+ }
+
+ return $user && $this->username === $user->username;
+ }
+
+ /**
+ * Checks user authorization to the action.
+ *
+ * @param string $action
+ * @param string|null $scope
+ * @return bool|null
+ */
+ public function authorize(string $action, string $scope = null): ?bool
+ {
+ if ($scope === 'test') {
+ // Special scope to test user permissions.
+ $scope = null;
+ } else {
+ // User needs to be enabled.
+ if ($this->getProperty('state') !== 'enabled') {
+ return false;
+ }
+
+ // User needs to be logged in.
+ if (!$this->getProperty('authenticated')) {
+ return false;
+ }
+
+ if (strpos($action, 'login') === false && !$this->getProperty('authorized')) {
+ // User needs to be authorized (2FA).
+ return false;
+ }
+
+ // Workaround bug in Login::isUserAuthorizedForPage() <= Login v3.0.4
+ if ((string)(int)$action === $action) {
+ return false;
+ }
+ }
+
+ // Check custom application access.
+ $authorizeCallable = static::$authorizeCallable;
+ if ($authorizeCallable instanceof Closure) {
+ $callable = $authorizeCallable->bindTo($this, $this);
+ $authorized = $callable($action, $scope);
+ if (is_bool($authorized)) {
+ return $authorized;
+ }
+ }
+
+ // Check user access.
+ $access = $this->getAccess();
+ $authorized = $access->authorize($action, $scope);
+ if (is_bool($authorized)) {
+ return $authorized;
+ }
+
+ // Check group access.
+ $authorized = $this->getGroups()->authorize($action, $scope);
+ if (is_bool($authorized)) {
+ return $authorized;
+ }
+
+ // If any specific rule isn't hit, check if user is a superuser.
+ return $access->authorize('admin.super') === true;
+ }
+
+ /**
+ * @param string $property
+ * @param mixed $default
+ * @return mixed
+ */
+ public function getProperty($property, $default = null)
+ {
+ $value = parent::getProperty($property, $default);
+
+ if ($property === 'avatar') {
+ $settings = $this->getMediaFieldSettings($property);
+ $value = $this->parseFileProperty($value, $settings);
+ }
+
+ return $value;
+ }
+
+ /**
+ * @return UserGroupIndex
+ */
+ public function getRoles(): UserGroupIndex
+ {
+ return $this->getGroups();
+ }
+
+ /**
+ * Convert object into an array.
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ $array = $this->jsonSerialize();
+
+ $settings = $this->getMediaFieldSettings('avatar');
+ $array['avatar'] = $this->parseFileProperty($array['avatar'] ?? null, $settings);
+
+ return $array;
+ }
+
+ /**
+ * Convert object into YAML string.
+ *
+ * @param int $inline The level where you switch to inline YAML.
+ * @param int $indent The amount of spaces to use for indentation of nested nodes.
+ * @return string A YAML string representing the object.
+ */
+ public function toYaml($inline = 5, $indent = 2)
+ {
+ $yaml = new YamlFormatter(['inline' => $inline, 'indent' => $indent]);
+
+ return $yaml->encode($this->toArray());
+ }
+
+ /**
+ * Convert object into JSON string.
+ *
+ * @return string
+ */
+ public function toJson()
+ {
+ $json = new JsonFormatter();
+
+ return $json->encode($this->toArray());
+ }
+
+ /**
+ * Join nested values together by using blueprints.
+ *
+ * @param string $name Dot separated path to the requested value.
+ * @param mixed $value Value to be joined.
+ * @param string|null $separator Separator, defaults to '.'
+ * @return $this
+ * @throws RuntimeException
+ */
+ public function join($name, $value, $separator = null)
+ {
+ $separator = $separator ?? '.';
+ $old = $this->get($name, null, $separator);
+ if ($old !== null) {
+ if (!is_array($old)) {
+ throw new RuntimeException('Value ' . $old);
+ }
+
+ if (is_object($value)) {
+ $value = (array) $value;
+ } elseif (!is_array($value)) {
+ throw new RuntimeException('Value ' . $value);
+ }
+
+ $value = $this->getBlueprint()->mergeData($old, $value, $name, $separator);
+ }
+
+ $this->set($name, $value, $separator);
+
+ return $this;
+ }
+
+ /**
+ * Get nested structure containing default values defined in the blueprints.
+ *
+ * Fields without default value are ignored in the list.
+
+ * @return array
+ */
+ public function getDefaults()
+ {
+ return $this->getBlueprint()->getDefaults();
+ }
+
+ /**
+ * Set default values by using blueprints.
+ *
+ * @param string $name Dot separated path to the requested value.
+ * @param mixed $value Value to be joined.
+ * @param string|null $separator Separator, defaults to '.'
+ * @return $this
+ */
+ public function joinDefaults($name, $value, $separator = null)
+ {
+ if (is_object($value)) {
+ $value = (array) $value;
+ }
+
+ $old = $this->get($name, null, $separator);
+ if ($old !== null) {
+ $value = $this->getBlueprint()->mergeData($value, $old, $name, $separator ?? '.');
+ }
+
+ $this->setNestedProperty($name, $value, $separator);
+
+ return $this;
+ }
+
+ /**
+ * Get value from the configuration and join it with given data.
+ *
+ * @param string $name Dot separated path to the requested value.
+ * @param array|object $value Value to be joined.
+ * @param string $separator Separator, defaults to '.'
+ * @return array
+ * @throws RuntimeException
+ */
+ public function getJoined($name, $value, $separator = null)
+ {
+ if (is_object($value)) {
+ $value = (array) $value;
+ } elseif (!is_array($value)) {
+ throw new RuntimeException('Value ' . $value);
+ }
+
+ $old = $this->get($name, null, $separator);
+
+ if ($old === null) {
+ // No value set; no need to join data.
+ return $value;
+ }
+
+ if (!is_array($old)) {
+ throw new RuntimeException('Value ' . $old);
+ }
+
+ // Return joined data.
+ return $this->getBlueprint()->mergeData($old, $value, $name, $separator ?? '.');
+ }
+
+ /**
+ * Set default values to the configuration if variables were not set.
+ *
+ * @param array $data
+ * @return $this
+ */
+ public function setDefaults(array $data)
+ {
+ $this->setElements($this->getBlueprint()->mergeData($data, $this->toArray()));
+
+ return $this;
+ }
+
+ /**
+ * Validate by blueprints.
+ *
+ * @return $this
+ * @throws \Exception
+ */
+ public function validate()
+ {
+ $this->getBlueprint()->validate($this->toArray());
+
+ return $this;
+ }
+
+ /**
+ * Filter all items by using blueprints.
+ * @return $this
+ */
+ public function filter()
+ {
+ $this->setElements($this->getBlueprint()->filter($this->toArray()));
+
+ return $this;
+ }
+
+ /**
+ * Get extra items which haven't been defined in blueprints.
+ *
+ * @return array
+ */
+ public function extra()
+ {
+ return $this->getBlueprint()->extra($this->toArray());
+ }
+
+ /**
+ * Return unmodified data as raw string.
+ *
+ * NOTE: This function only returns data which has been saved to the storage.
+ *
+ * @return string
+ */
+ public function raw()
+ {
+ $file = $this->file();
+
+ return $file ? $file->raw() : '';
+ }
+
+ /**
+ * Set or get the data storage.
+ *
+ * @param FileInterface|null $storage Optionally enter a new storage.
+ * @return FileInterface|null
+ */
+ public function file(FileInterface $storage = null)
+ {
+ if (null !== $storage) {
+ $this->_storage = $storage;
+ }
+
+ return $this->_storage;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isValid(): bool
+ {
+ return $this->getProperty('state') !== null;
+ }
+
+ /**
+ * Save user
+ *
+ * @return static
+ */
+ public function save()
+ {
+ // TODO: We may want to handle this in the storage layer in the future.
+ $key = $this->getStorageKey();
+ if (!$key || strpos($key, '@@')) {
+ $storage = $this->getFlexDirectory()->getStorage();
+ if ($storage instanceof FileStorage) {
+ $this->setStorageKey($this->getKey());
+ }
+ }
+
+ $password = $this->getProperty('password') ?? $this->getProperty('password1');
+ if (null !== $password && '' !== $password) {
+ $password2 = $this->getProperty('password2');
+ if (!\is_string($password) || ($password2 && $password !== $password2)) {
+ throw new \RuntimeException('Passwords did not match.');
+ }
+
+ $this->setProperty('hashed_password', Authentication::create($password));
+ }
+ $this->unsetProperty('password');
+ $this->unsetProperty('password1');
+ $this->unsetProperty('password2');
+
+ // Backwards compatibility with older plugins.
+ $fireEvents = $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true);
+ $grav = $this->getContainer();
+ if ($fireEvents) {
+ $self = $this;
+ $grav->fireEvent('onAdminSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => &$self]));
+ if ($self !== $this) {
+ throw new RuntimeException('Switching Flex User object during onAdminSave event is not supported! Please update plugin.');
+ }
+ }
+
+ $instance = parent::save();
+
+ // Backwards compatibility with older plugins.
+ if ($fireEvents) {
+ $grav->fireEvent('onAdminAfterSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => $this]));
+ }
+
+ return $instance;
+ }
+
+ /**
+ * @return array
+ */
+ public function prepareStorage(): array
+ {
+ $elements = parent::prepareStorage();
+
+ // Do not save authorization information.
+ unset($elements['authenticated'], $elements['authorized']);
+
+ return $elements;
+ }
+
+ /**
+ * @return MediaCollectionInterface
+ */
+ public function getMedia()
+ {
+ /** @var Media $media */
+ $media = $this->getFlexMedia();
+
+ // Deal with shared avatar folder.
+ $path = $this->getAvatarFile();
+ if ($path && !$media[$path] && is_file($path)) {
+ $medium = MediumFactory::fromFile($path);
+ if ($medium) {
+ $media->add($path, $medium);
+ $name = Utils::basename($path);
+ if ($name !== $path) {
+ $media->add($name, $medium);
+ }
+ }
+ }
+
+ return $media;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getMediaFolder(): ?string
+ {
+ $folder = $this->getFlexMediaFolder();
+
+ // Check for shared media
+ if (!$folder && !$this->getFlexDirectory()->getMediaFolder()) {
+ $this->_loadMedia = false;
+ $folder = $this->getBlueprint()->fields()['avatar']['destination'] ?? 'account://avatars';
+ }
+
+ 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
+ */
+ protected function doGetBlueprint(string $name = ''): Blueprint
+ {
+ $blueprint = $this->getFlexDirectory()->getBlueprint($name ? '.' . $name : $name);
+
+ // HACK: With folder storage we need to ignore the avatar destination.
+ if ($this->getFlexDirectory()->getMediaFolder()) {
+ $field = $blueprint->get('form/fields/avatar');
+ if ($field) {
+ unset($field['destination']);
+ $blueprint->set('form/fields/avatar', $field);
+ }
+ }
+
+ return $blueprint;
+ }
+
+ /**
+ * @param UserInterface $user
+ * @param string $action
+ * @param string $scope
+ * @param bool $isMe
+ * @return bool|null
+ */
+ protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe = false): ?bool
+ {
+ // Check custom application access.
+ $isAuthorizedCallable = static::$isAuthorizedCallable;
+ if ($isAuthorizedCallable instanceof Closure) {
+ $callable = $isAuthorizedCallable->bindTo($this, $this);
+ $authorized = $callable($user, $action, $scope, $isMe);
+ if (is_bool($authorized)) {
+ return $authorized;
+ }
+ }
+
+ if ($user instanceof self && $user->getStorageKey() === $this->getStorageKey()) {
+ // User cannot delete his own account, otherwise he has full access.
+ return $action !== 'delete';
+ }
+
+ return parent::isAuthorizedOverride($user, $action, $scope, $isMe);
+ }
+
+ /**
+ * @return string|null
+ */
+ protected function getAvatarFile(): ?string
+ {
+ $avatars = $this->getElement('avatar');
+ if (is_array($avatars) && $avatars) {
+ $avatar = array_shift($avatars);
+
+ return $avatar['path'] ?? null;
+ }
+
+ return null;
+ }
+
+ /**
+ * Gets the associated media collection (original images).
+ *
+ * @return MediaCollectionInterface Representation of associated media.
+ */
+ protected function getOriginalMedia()
+ {
+ $folder = $this->getMediaFolder();
+ if ($folder) {
+ $folder .= '/original';
+ }
+
+ return (new Media($folder ?? '', $this->getMediaOrder()))->setTimestamps();
+ }
+
+ /**
+ * @param array $files
+ * @return void
+ */
+ protected function setUpdatedMedia(array $files): void
+ {
+ /** @var UniformResourceLocator $locator */
+ $locator = Grav::instance()['locator'];
+
+ $media = $this->getMedia();
+ if (!$media instanceof MediaUploadInterface) {
+ return;
+ }
+
+ $filesystem = Filesystem::getInstance(false);
+
+ $list = [];
+ $list_original = [];
+ foreach ($files as $field => $group) {
+ // Ignore files without a field.
+ if ($field === '') {
+ continue;
+ }
+ $field = (string)$field;
+
+ // Load settings for the field.
+ $settings = $this->getMediaFieldSettings($field);
+ foreach ($group as $filename => $file) {
+ if ($file) {
+ // File upload.
+ $filename = $file->getClientFilename();
+
+ /** @var FormFlashFile $file */
+ $data = $file->jsonSerialize();
+ unset($data['tmp_name'], $data['path']);
+ } else {
+ // File delete.
+ $data = null;
+ }
+
+ if ($file) {
+ // Check file upload against media limits (except for max size).
+ $filename = $media->checkUploadedFile($file, $filename, ['filesize' => 0] + $settings);
+ }
+
+ $self = $settings['self'];
+ if ($this->_loadMedia && $self) {
+ $filepath = $filename;
+ } else {
+ $filepath = "{$settings['destination']}/{$filename}";
+
+ // For backwards compatibility we are always using relative path from the installation root.
+ if ($locator->isStream($filepath)) {
+ $filepath = $locator->findResource($filepath, false, true);
+ }
+ }
+
+ // Special handling for original images.
+ if (strpos($field, '/original')) {
+ if ($this->_loadMedia && $self) {
+ $list_original[$filename] = [$file, $settings];
+ }
+ continue;
+ }
+
+ // Calculate path without the retina scaling factor.
+ $realpath = $filesystem->pathname($filepath) . str_replace(['@3x', '@2x'], '', Utils::basename($filepath));
+
+ $list[$filename] = [$file, $settings];
+
+ $path = str_replace('.', "\n", $field);
+ if (null !== $data) {
+ $data['name'] = $filename;
+ $data['path'] = $filepath;
+
+ $this->setNestedProperty("{$path}\n{$realpath}", $data, "\n");
+ } else {
+ $this->unsetNestedProperty("{$path}\n{$realpath}", "\n");
+ }
+ }
+ }
+
+ $this->clearMediaCache();
+
+ $this->_uploads = $list;
+ $this->_uploads_original = $list_original;
+ }
+
+ protected function saveUpdatedMedia(): void
+ {
+ $media = $this->getMedia();
+ if (!$media instanceof MediaUploadInterface) {
+ throw new RuntimeException('Internal error UO101');
+ }
+
+ // Upload/delete original sized images.
+ /**
+ * @var string $filename
+ * @var UploadedFileInterface|array|null $file
+ */
+ foreach ($this->_uploads_original ?? [] as $filename => $file) {
+ $filename = 'original/' . $filename;
+ if (is_array($file)) {
+ [$file, $settings] = $file;
+ } else {
+ $settings = null;
+ }
+ if ($file instanceof UploadedFileInterface) {
+ $media->copyUploadedFile($file, $filename, $settings);
+ } else {
+ $media->deleteFile($filename, $settings);
+ }
+ }
+
+ // Upload/delete altered files.
+ /**
+ * @var string $filename
+ * @var UploadedFileInterface|array|null $file
+ */
+ foreach ($this->getUpdatedMedia() as $filename => $file) {
+ if (is_array($file)) {
+ [$file, $settings] = $file;
+ } else {
+ $settings = null;
+ }
+ if ($file instanceof UploadedFileInterface) {
+ $media->copyUploadedFile($file, $filename, $settings);
+ } else {
+ $media->deleteFile($filename, $settings);
+ }
+ }
+
+ $this->setUpdatedMedia([]);
+ $this->clearMediaCache();
+ }
+
+ /**
+ * @return array
+ */
+ protected function doSerialize(): array
+ {
+ return [
+ 'type' => $this->getFlexType(),
+ 'key' => $this->getKey(),
+ 'elements' => $this->jsonSerialize(),
+ 'storage' => $this->getMetaData()
+ ];
+ }
+
+ /**
+ * @return UserGroupIndex
+ */
+ protected function getUserGroups()
+ {
+ $grav = Grav::instance();
+
+ /** @var Flex $flex */
+ $flex = $grav['flex'];
+
+ /** @var UserGroupCollection|null $groups */
+ $groups = $flex->getDirectory('user-groups');
+ if ($groups) {
+ /** @var UserGroupIndex $index */
+ $index = $groups->getIndex();
+
+ return $index;
+ }
+
+ return $grav['user_groups'];
+ }
+
+ /**
+ * @return UserGroupIndex
+ */
+ protected function getGroups()
+ {
+ if (null === $this->_groups) {
+ /** @var UserGroupIndex $groups */
+ $groups = $this->getUserGroups()->select((array)$this->getProperty('groups'));
+ $this->_groups = $groups;
+ }
+
+ return $this->_groups;
+ }
+
+ /**
+ * @return Access
+ */
+ protected function getAccess(): Access
+ {
+ if (null === $this->_access) {
+ $this->_access = new Access($this->getProperty('access'));
+ }
+
+ return $this->_access;
+ }
+
+ /**
+ * @param mixed $value
+ * @return array
+ */
+ protected function offsetLoad_access($value): array
+ {
+ if (!$value instanceof Access) {
+ $value = new Access($value);
+ }
+
+ return $value->jsonSerialize();
+ }
+
+ /**
+ * @param mixed $value
+ * @return array
+ */
+ protected function offsetPrepare_access($value): array
+ {
+ return $this->offsetLoad_access($value);
+ }
+
+ /**
+ * @param array|null $value
+ * @return array|null
+ */
+ protected function offsetSerialize_access(?array $value): ?array
+ {
+ return $value;
+ }
+}
diff --git a/system/src/Grav/Common/Form/FormFlash.php b/system/src/Grav/Common/Form/FormFlash.php
index b67313bd37..7acaf7fdc0 100644
--- a/system/src/Grav/Common/Form/FormFlash.php
+++ b/system/src/Grav/Common/Form/FormFlash.php
@@ -3,15 +3,21 @@
/**
* @package Grav\Common\Form
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Form;
use Grav\Common\Filesystem\Folder;
+use Grav\Common\Utils;
use Grav\Framework\Form\FormFlash as FrameworkFormFlash;
+use function is_array;
+/**
+ * Class FormFlash
+ * @package Grav\Common\Form
+ */
class FormFlash extends FrameworkFormFlash
{
/**
@@ -26,7 +32,7 @@ public function getLegacyFiles(): array
continue;
}
foreach ($files as $file) {
- if (\is_array($file)) {
+ if (is_array($file)) {
$file['tmp_name'] = $this->getTmpDir() . '/' . $file['tmp_name'];
$fields[$field][$file['path'] ?? $file['name']] = $file;
}
@@ -53,7 +59,7 @@ public function uploadFile(string $field, string $filename, array $upload): bool
Folder::create($tmp_dir);
$tmp_file = $upload['file']['tmp_name'];
- $basename = basename($tmp_file);
+ $basename = Utils::basename($tmp_file);
if (!move_uploaded_file($tmp_file, $tmp_dir . '/' . $basename)) {
return false;
@@ -85,7 +91,7 @@ public function cropFile(string $field, string $filename, array $upload, array $
Folder::create($tmp_dir);
$tmp_file = $upload['file']['tmp_name'];
- $basename = basename($tmp_file);
+ $basename = Utils::basename($tmp_file);
if (!move_uploaded_file($tmp_file, $tmp_dir . '/' . $basename)) {
return false;
diff --git a/system/src/Grav/Common/GPM/AbstractCollection.php b/system/src/Grav/Common/GPM/AbstractCollection.php
index a9e1ac9754..91fa69eb4a 100644
--- a/system/src/Grav/Common/GPM/AbstractCollection.php
+++ b/system/src/Grav/Common/GPM/AbstractCollection.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,13 +11,23 @@
use Grav\Common\Iterator;
+/**
+ * Class AbstractCollection
+ * @package Grav\Common\GPM
+ */
abstract class AbstractCollection extends Iterator
{
+ /**
+ * @return string
+ */
public function toJson()
{
- return json_encode($this->toArray());
+ return json_encode($this->toArray(), JSON_THROW_ON_ERROR);
}
+ /**
+ * @return array
+ */
public function toArray()
{
$items = [];
diff --git a/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php
index bc4ecab9d9..7b918e0efc 100644
--- a/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php
+++ b/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,10 +11,18 @@
use Grav\Common\Iterator;
+/**
+ * Class AbstractPackageCollection
+ * @package Grav\Common\GPM\Common
+ */
abstract class AbstractPackageCollection extends Iterator
{
+ /** @var string */
protected $type;
+ /**
+ * @return string
+ */
public function toJson()
{
$items = [];
@@ -23,9 +31,12 @@ public function toJson()
$items[$name] = $package->toArray();
}
- return json_encode($items);
+ return json_encode($items, JSON_THROW_ON_ERROR);
}
+ /**
+ * @return array
+ */
public function toArray()
{
$items = [];
diff --git a/system/src/Grav/Common/GPM/Common/CachedCollection.php b/system/src/Grav/Common/GPM/Common/CachedCollection.php
index a6fca4db0d..c3b62e5cd2 100644
--- a/system/src/Grav/Common/GPM/Common/CachedCollection.php
+++ b/system/src/Grav/Common/GPM/Common/CachedCollection.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,10 +11,20 @@
use Grav\Common\Iterator;
+/**
+ * Class CachedCollection
+ * @package Grav\Common\GPM\Common
+ */
class CachedCollection extends Iterator
{
- protected static $cache;
-
+ /** @var array */
+ protected static $cache = [];
+
+ /**
+ * CachedCollection constructor.
+ *
+ * @param array $items
+ */
public function __construct($items)
{
parent::__construct();
diff --git a/system/src/Grav/Common/GPM/Common/Package.php b/system/src/Grav/Common/GPM/Common/Package.php
index 40d3759128..5d960117c4 100644
--- a/system/src/Grav/Common/GPM/Common/Package.php
+++ b/system/src/Grav/Common/GPM/Common/Package.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,13 +11,19 @@
use Grav\Common\Data\Data;
+/**
+ * @property string $name
+ */
class Package
{
- /**
- * @var Data
- */
+ /** @var Data */
protected $data;
+ /**
+ * Package constructor.
+ * @param Data $package
+ * @param string|null $type
+ */
public function __construct(Data $package, $type = null)
{
$this->data = $package;
@@ -35,26 +41,49 @@ public function getData()
return $this->data;
}
+ /**
+ * @param string $key
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
public function __get($key)
{
return $this->data->get($key);
}
+ /**
+ * @param string $key
+ * @param mixed $value
+ * @return void
+ */
+ #[\ReturnTypeWillChange]
public function __set($key, $value)
{
- return $this->data->set($key, $value);
+ $this->data->set($key, $value);
}
+ /**
+ * @param string $key
+ * @return bool
+ */
+ #[\ReturnTypeWillChange]
public function __isset($key)
{
return isset($this->data->{$key});
}
+ /**
+ * @return string
+ */
+ #[\ReturnTypeWillChange]
public function __toString()
{
return $this->toJson();
}
+ /**
+ * @return string
+ */
public function toJson()
{
return $this->data->toJson();
diff --git a/system/src/Grav/Common/GPM/GPM.php b/system/src/Grav/Common/GPM/GPM.php
index 6c9e7b0e4a..4ca84e3f0f 100644
--- a/system/src/Grav/Common/GPM/GPM.php
+++ b/system/src/Grav/Common/GPM/GPM.php
@@ -3,44 +3,48 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
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;
use RocketTheme\Toolbox\File\YamlFile;
+use RuntimeException;
+use stdClass;
+use function array_key_exists;
+use function count;
+use function in_array;
+use function is_array;
+use function is_object;
+/**
+ * Class GPM
+ * @package Grav\Common\GPM
+ */
class GPM extends Iterator
{
- /**
- * Local installed Packages
- * @var Local\Packages
- */
+ /** @var Local\Packages Local installed Packages */
private $installed;
-
- /**
- * Remote available Packages
- * @var Remote\Packages
- */
+ /** @var Remote\Packages|null Remote available Packages */
private $repository;
-
- /**
- * @var Remote\GravCore
- */
- public $grav;
-
- /**
- * Internal cache
- * @var array
- */
+ /** @var Remote\GravCore|null Remove Grav Packages */
+ private $grav;
+ /** @var bool */
+ private $refresh;
+ /** @var callable|null */
+ private $callback;
+
+ /** @var array Internal cache */
protected $cache;
-
+ /** @var array */
protected $install_paths = [
'plugins' => 'user/plugins/%name%',
'themes' => 'user/themes/%name%',
@@ -49,19 +53,54 @@ class GPM extends Iterator
/**
* Creates a new GPM instance with Local and Remote packages available
+ *
* @param bool $refresh Applies to Remote Packages only and forces a refetch of data
- * @param callable $callback Either a function or callback in array notation
+ * @param callable|null $callback Either a function or callback in array notation
*/
public function __construct($refresh = false, $callback = null)
{
parent::__construct();
+
+ Folder::create(CACHE_DIR . '/gpm');
+
$this->cache = [];
$this->installed = new Local\Packages();
- try {
- $this->repository = new Remote\Packages($refresh, $callback);
- $this->grav = new Remote\GravCore($refresh, $callback);
- } catch (\Exception $e) {
+ $this->refresh = $refresh;
+ $this->callback = $callback;
+ }
+
+ /**
+ * Magic getter method
+ *
+ * @param string $offset Asset name value
+ * @return mixed Asset value
+ */
+ #[\ReturnTypeWillChange]
+ public function __get($offset)
+ {
+ switch ($offset) {
+ case 'grav':
+ return $this->getGrav();
}
+
+ return parent::__get($offset);
+ }
+
+ /**
+ * Magic method to determine if the attribute is set
+ *
+ * @param string $offset Asset name value
+ * @return bool True if the value is set
+ */
+ #[\ReturnTypeWillChange]
+ public function __isset($offset)
+ {
+ switch ($offset) {
+ case 'grav':
+ return $this->getGrav() !== null;
+ }
+
+ return parent::__isset($offset);
}
/**
@@ -92,11 +131,13 @@ public function getInstallable($list_type_installed = ['plugins' => true, 'theme
$items[$type] = $to_install;
$items['total'] += count($to_install);
}
+
return $items;
}
/**
* Returns the amount of locally installed packages
+ *
* @return int Amount of installed packages
*/
public function countInstalled()
@@ -110,29 +151,22 @@ public function countInstalled()
* Return the instance of a specific Package
*
* @param string $slug The slug of the Package
- * @return Local\Package The instance of the Package
+ * @return Local\Package|null The instance of the Package
*/
public function getInstalledPackage($slug)
{
- if (isset($this->installed['plugins'][$slug])) {
- return $this->installed['plugins'][$slug];
- }
-
- if (isset($this->installed['themes'][$slug])) {
- return $this->installed['themes'][$slug];
- }
-
- return null;
+ return $this->getInstalledPlugin($slug) ?? $this->getInstalledTheme($slug);
}
/**
* Return the instance of a specific Plugin
+ *
* @param string $slug The slug of the Plugin
- * @return Local\Package The instance of the Plugin
+ * @return Local\Package|null The instance of the Plugin
*/
public function getInstalledPlugin($slug)
{
- return $this->installed['plugins'][$slug];
+ return $this->installed['plugins'][$slug] ?? null;
}
/**
@@ -144,33 +178,56 @@ public function getInstalledPlugins()
return $this->installed['plugins'];
}
+
+ /**
+ * Returns the plugin's enabled state
+ *
+ * @param string $slug
+ * @return bool True if the Plugin is Enabled. False if manually set to enable:false. Null otherwise.
+ */
+ public function isPluginEnabled($slug): bool
+ {
+ $grav = Grav::instance();
+
+ return ($grav['config']['plugins'][$slug]['enabled'] ?? false) === true;
+ }
+
/**
* Checks if a Plugin is installed
+ *
* @param string $slug The slug of the Plugin
* @return bool True if the Plugin has been installed. False otherwise
*/
- public function isPluginInstalled($slug)
+ public function isPluginInstalled($slug): bool
{
return isset($this->installed['plugins'][$slug]);
}
+ /**
+ * @param string $slug
+ * @return bool
+ */
public function isPluginInstalledAsSymlink($slug)
{
- return $this->installed['plugins'][$slug]->symlink;
+ $plugin = $this->getInstalledPlugin($slug);
+
+ return (bool)($plugin->symlink ?? false);
}
/**
* Return the instance of a specific Theme
+ *
* @param string $slug The slug of the Theme
- * @return Local\Package The instance of the Theme
+ * @return Local\Package|null The instance of the Theme
*/
public function getInstalledTheme($slug)
{
- return $this->installed['themes'][$slug];
+ return $this->installed['themes'][$slug] ?? null;
}
/**
* Returns the Locally installed themes
+ *
* @return Iterator The installed themes
*/
public function getInstalledThemes()
@@ -178,40 +235,52 @@ public function getInstalledThemes()
return $this->installed['themes'];
}
+ /**
+ * Checks if a Theme is enabled
+ *
+ * @param string $slug The slug of the Theme
+ * @return bool True if the Theme has been set to the default theme. False if installed, but not enabled. Null otherwise.
+ */
+ public function isThemeEnabled($slug): bool
+ {
+ $grav = Grav::instance();
+
+ $current_theme = $grav['config']['system']['pages']['theme'] ?? null;
+
+ return $current_theme === $slug;
+ }
+
/**
* Checks if a Theme is installed
+ *
* @param string $slug The slug of the Theme
* @return bool True if the Theme has been installed. False otherwise
*/
- public function isThemeInstalled($slug)
+ public function isThemeInstalled($slug): bool
{
return isset($this->installed['themes'][$slug]);
}
/**
* Returns the amount of updates available
+ *
* @return int Amount of available updates
*/
public function countUpdates()
{
- $count = 0;
-
- $count += count($this->getUpdatablePlugins());
- $count += count($this->getUpdatableThemes());
-
- return $count;
+ return count($this->getUpdatablePlugins()) + count($this->getUpdatableThemes());
}
/**
* Returns an array of Plugins and Themes that can be updated.
* Plugins and Themes are extended with the `available` property that relies to the remote version
+ *
* @param array $list_type_update specifies what type of package to update
* @return array Array of updatable Plugins and Themes.
* Format: ['total' => int, 'plugins' => array, 'themes' => array]
*/
public function getUpdatable($list_type_update = ['plugins' => true, 'themes' => true])
{
-
$items = ['total' => 0];
foreach ($list_type_update as $type => $type_updatable) {
if ($type_updatable === false) {
@@ -222,18 +291,26 @@ public function getUpdatable($list_type_update = ['plugins' => true, 'themes' =>
$items[$type] = $to_update;
$items['total'] += count($to_update);
}
+
return $items;
}
/**
* Returns an array of Plugins that can be updated.
* The Plugins are extended with the `available` property that relies to the remote version
+ *
* @return array Array of updatable Plugins
*/
public function getUpdatablePlugins()
{
$items = [];
- $repository = $this->repository['plugins'];
+
+ $repository = $this->getRepository();
+ if (null === $repository) {
+ return $items;
+ }
+
+ $plugins = $repository['plugins'];
// local cache to speed things up
if (isset($this->cache[__METHOD__])) {
@@ -241,18 +318,18 @@ public function getUpdatablePlugins()
}
foreach ($this->installed['plugins'] as $slug => $plugin) {
- if (!isset($repository[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {
+ if (!isset($plugins[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {
continue;
}
- $local_version = $plugin->version ?: 'Unknown';
- $remote_version = $repository[$slug]->version;
+ $local_version = $plugin->version ?? 'Unknown';
+ $remote_version = $plugins[$slug]->version;
if (version_compare($local_version, $remote_version) < 0) {
- $repository[$slug]->available = $remote_version;
- $repository[$slug]->version = $local_version;
- $repository[$slug]->type = $repository[$slug]->release_type;
- $items[$slug] = $repository[$slug];
+ $plugins[$slug]->available = $remote_version;
+ $plugins[$slug]->version = $local_version;
+ $plugins[$slug]->type = $plugins[$slug]->release_type;
+ $items[$slug] = $plugins[$slug];
}
}
@@ -265,20 +342,24 @@ public function getUpdatablePlugins()
* Get the latest release of a package from the GPM
*
* @param string $package_name
- *
* @return string|null
*/
public function getLatestVersionOfPackage($package_name)
{
- $repository = $this->repository['plugins'];
- if (isset($repository[$package_name])) {
- return $repository[$package_name]->available ?: $repository[$package_name]->version;
+ $repository = $this->getRepository();
+ if (null === $repository) {
+ return null;
+ }
+
+ $plugins = $repository['plugins'];
+ if (isset($plugins[$package_name])) {
+ return $plugins[$package_name]->available ?: $plugins[$package_name]->version;
}
//Not a plugin, it's a theme?
- $repository = $this->repository['themes'];
- if (isset($repository[$package_name])) {
- return $repository[$package_name]->available ?: $repository[$package_name]->version;
+ $themes = $repository['themes'];
+ if (isset($themes[$package_name])) {
+ return $themes[$package_name]->available ?: $themes[$package_name]->version;
}
return null;
@@ -286,6 +367,7 @@ public function getLatestVersionOfPackage($package_name)
/**
* Check if a Plugin or Theme is updatable
+ *
* @param string $slug The slug of the package
* @return bool True if updatable. False otherwise or if not found
*/
@@ -296,6 +378,7 @@ public function isUpdatable($slug)
/**
* Checks if a Plugin is updatable
+ *
* @param string $plugin The slug of the Plugin
* @return bool True if the Plugin is updatable. False otherwise
*/
@@ -307,12 +390,19 @@ public function isPluginUpdatable($plugin)
/**
* Returns an array of Themes that can be updated.
* The Themes are extended with the `available` property that relies to the remote version
+ *
* @return array Array of updatable Themes
*/
public function getUpdatableThemes()
{
$items = [];
- $repository = $this->repository['themes'];
+
+ $repository = $this->getRepository();
+ if (null === $repository) {
+ return $items;
+ }
+
+ $themes = $repository['themes'];
// local cache to speed things up
if (isset($this->cache[__METHOD__])) {
@@ -320,18 +410,18 @@ public function getUpdatableThemes()
}
foreach ($this->installed['themes'] as $slug => $plugin) {
- if (!isset($repository[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {
+ if (!isset($themes[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {
continue;
}
- $local_version = $plugin->version ?: 'Unknown';
- $remote_version = $repository[$slug]->version;
+ $local_version = $plugin->version ?? 'Unknown';
+ $remote_version = $themes[$slug]->version;
if (version_compare($local_version, $remote_version) < 0) {
- $repository[$slug]->available = $remote_version;
- $repository[$slug]->version = $local_version;
- $repository[$slug]->type = $repository[$slug]->release_type;
- $items[$slug] = $repository[$slug];
+ $themes[$slug]->available = $remote_version;
+ $themes[$slug]->version = $local_version;
+ $themes[$slug]->type = $themes[$slug]->release_type;
+ $items[$slug] = $themes[$slug];
}
}
@@ -342,6 +432,7 @@ public function getUpdatableThemes()
/**
* Checks if a Theme is Updatable
+ *
* @param string $theme The slug of the Theme
* @return bool True if the Theme is updatable. False otherwise
*/
@@ -354,20 +445,24 @@ public function isThemeUpdatable($theme)
* Get the release type of a package (stable / testing)
*
* @param string $package_name
- *
* @return string|null
*/
public function getReleaseType($package_name)
{
- $repository = $this->repository['plugins'];
- if (isset($repository[$package_name])) {
- return $repository[$package_name]->release_type;
+ $repository = $this->getRepository();
+ if (null === $repository) {
+ return null;
+ }
+
+ $plugins = $repository['plugins'];
+ if (isset($plugins[$package_name])) {
+ return $plugins[$package_name]->release_type;
}
//Not a plugin, it's a theme?
- $repository = $this->repository['themes'];
- if (isset($repository[$package_name])) {
- return $repository[$package_name]->release_type;
+ $themes = $repository['themes'];
+ if (isset($themes[$package_name])) {
+ return $themes[$package_name]->release_type;
}
return null;
@@ -377,7 +472,6 @@ public function getReleaseType($package_name)
* Returns true if the package latest release is stable
*
* @param string $package_name
- *
* @return bool
*/
public function isStableRelease($package_name)
@@ -389,81 +483,107 @@ public function isStableRelease($package_name)
* Returns true if the package latest release is testing
*
* @param string $package_name
- *
* @return bool
*/
public function isTestingRelease($package_name)
{
- $hasTesting = isset($this->getInstalledPackage($package_name)->testing);
- $testing = $hasTesting ? $this->getInstalledPackage($package_name)->testing : false;
+ $package = $this->getInstalledPackage($package_name);
+ $testing = $package->testing ?? false;
return $this->getReleaseType($package_name) === 'testing' || $testing;
}
/**
* Returns a Plugin from the repository
+ *
* @param string $slug The slug of the Plugin
- * @return mixed Package if found, NULL if not
+ * @return Remote\Package|null Package if found, NULL if not
*/
public function getRepositoryPlugin($slug)
{
- return @$this->repository['plugins'][$slug];
+ $packages = $this->getRepositoryPlugins();
+
+ return $packages ? ($packages[$slug] ?? null) : null;
}
/**
* Returns the list of Plugins available in the repository
- * @return Iterator The Plugins remotely available
+ *
+ * @return Iterator|null The Plugins remotely available
*/
public function getRepositoryPlugins()
{
- return $this->repository['plugins'];
+ return $this->getRepository()['plugins'] ?? null;
}
/**
* Returns a Theme from the repository
+ *
* @param string $slug The slug of the Theme
- * @return mixed Package if found, NULL if not
+ * @return Remote\Package|null Package if found, NULL if not
*/
public function getRepositoryTheme($slug)
{
- return @$this->repository['themes'][$slug];
+ $packages = $this->getRepositoryThemes();
+
+ return $packages ? ($packages[$slug] ?? null) : null;
}
/**
* Returns the list of Themes available in the repository
- * @return Iterator The Themes remotely available
+ *
+ * @return Iterator|null The Themes remotely available
*/
public function getRepositoryThemes()
{
- return $this->repository['themes'];
+ return $this->getRepository()['themes'] ?? null;
}
/**
* Returns the list of Plugins and Themes available in the repository
- * @return Remote\Packages Available Plugins and Themes
+ *
+ * @return Remote\Packages|null Available Plugins and Themes
* Format: ['plugins' => array, 'themes' => array]
*/
public function getRepository()
{
+ if (null === $this->repository) {
+ try {
+ $this->repository = new Remote\Packages($this->refresh, $this->callback);
+ } catch (Exception $e) {}
+ }
+
return $this->repository;
}
+ /**
+ * Returns Grav version available in the repository
+ *
+ * @return Remote\GravCore|null
+ */
+ public function getGrav()
+ {
+ if (null === $this->grav) {
+ try {
+ $this->grav = new Remote\GravCore($this->refresh, $this->callback);
+ } catch (Exception $e) {}
+ }
+
+ return $this->grav;
+ }
+
/**
* Searches for a Package in the repository
+ *
* @param string $search Can be either the slug or the name
* @param bool $ignore_exception True if should not fire an exception (for use in Twig)
- * @return Remote\Package|bool Package if found, FALSE if not
+ * @return Remote\Package|false Package if found, FALSE if not
*/
public function findPackage($search, $ignore_exception = false)
{
$search = strtolower($search);
- $found = $this->getRepositoryTheme($search);
- if ($found) {
- return $found;
- }
-
- $found = $this->getRepositoryPlugin($search);
+ $found = $this->getRepositoryPlugin($search) ?? $this->getRepositoryTheme($search);
if ($found) {
return $found;
}
@@ -471,31 +591,27 @@ public function findPackage($search, $ignore_exception = false)
$themes = $this->getRepositoryThemes();
$plugins = $this->getRepositoryPlugins();
- if (!$themes && !$plugins) {
- if (!is_writable(ROOT_DIR . '/cache/gpm')) {
- throw new \RuntimeException("The cache/gpm folder is not writable. Please check the folder permissions.");
+ if (null === $themes || null === $plugins) {
+ if (!is_writable(GRAV_ROOT . '/cache/gpm')) {
+ throw new RuntimeException('The cache/gpm folder is not writable. Please check the folder permissions.');
}
if ($ignore_exception) {
return false;
}
- throw new \RuntimeException("GPM not reachable. Please check your internet connection or check the Grav site is reachable");
+ throw new RuntimeException('GPM not reachable. Please check your internet connection or check the Grav site is reachable');
}
- if ($themes) {
- foreach ($themes as $slug => $theme) {
- if ($search == $slug || $search == $theme->name) {
- return $theme;
- }
+ foreach ($themes as $slug => $theme) {
+ if ($search === $slug || $search === $theme->name) {
+ return $theme;
}
}
- if ($plugins) {
- foreach ($plugins as $slug => $plugin) {
- if ($search == $slug || $search == $plugin->name) {
- return $plugin;
- }
+ foreach ($plugins as $slug => $plugin) {
+ if ($search === $slug || $search === $plugin->name) {
+ return $plugin;
}
}
@@ -507,15 +623,19 @@ public function findPackage($search, $ignore_exception = false)
*
* @param string $package_file
* @param string $tmp
- * @return null|string
+ * @return string|null
*/
public static function downloadPackage($package_file, $tmp)
{
$package = parse_url($package_file);
- $filename = basename($package['path']);
+ if (!is_array($package)) {
+ throw new \RuntimeException("Malformed GPM URL: {$package_file}");
+ }
+
+ $filename = Utils::basename($package['path'] ?? '');
- if (Grav::instance()['config']->get('system.gpm.official_gpm_only') && $package['host'] !== 'getgrav.org') {
- throw new \RuntimeException("Only official GPM URLs are allowed. You can modify this behavior in the System configuration.");
+ if (Grav::instance()['config']->get('system.gpm.official_gpm_only') && ($package['host'] ?? null) !== 'getgrav.org') {
+ throw new RuntimeException('Only official GPM URLs are allowed. You can modify this behavior in the System configuration.');
}
$output = Response::get($package_file, []);
@@ -534,16 +654,16 @@ public static function downloadPackage($package_file, $tmp)
*
* @param string $package_file
* @param string $tmp
- * @return null|string
+ * @return string|null
*/
public static function copyPackage($package_file, $tmp)
{
$package_file = realpath($package_file);
- if (file_exists($package_file)) {
- $filename = basename($package_file);
+ if ($package_file && file_exists($package_file)) {
+ $filename = Utils::basename($package_file);
Folder::create($tmp);
- copy(realpath($package_file), $tmp . DS . $filename);
+ copy($package_file, $tmp . DS . $filename);
return $tmp . DS . $filename;
}
@@ -554,15 +674,14 @@ public static function copyPackage($package_file, $tmp)
* Try to guess the package type from the source files
*
* @param string $source
- * @return bool|string
+ * @return string|false
*/
public static function getPackageType($source)
{
$plugin_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Plugin/m';
$theme_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Theme/m';
- if (
- file_exists($source . 'system/defines.php') &&
+ if (file_exists($source . 'system/defines.php') &&
file_exists($source . 'system/config/system.yaml')
) {
return 'grav';
@@ -574,15 +693,20 @@ public static function getPackageType($source)
}
// either theme or plugin
- $name = basename($source);
+ $name = Utils::basename($source);
if (Utils::contains($name, 'theme')) {
return 'theme';
}
if (Utils::contains($name, 'plugin')) {
return 'plugin';
}
- foreach (glob($source . '*.php') as $filename) {
+
+ $glob = glob($source . '*.php') ?: [];
+ foreach ($glob as $filename) {
$contents = file_get_contents($filename);
+ if (!$contents) {
+ continue;
+ }
if (preg_match($theme_regex, $contents)) {
return 'theme';
}
@@ -599,19 +723,22 @@ public static function getPackageType($source)
* Try to guess the package name from the source files
*
* @param string $source
- * @return bool|string
+ * @return string|false
*/
public static function getPackageName($source)
{
$ignore_yaml_files = ['blueprints', 'languages'];
- foreach (glob($source . '*.yaml') as $filename) {
- $name = strtolower(basename($filename, '.yaml'));
+ $glob = glob($source . '*.yaml') ?: [];
+ foreach ($glob as $filename) {
+ $name = strtolower(Utils::basename($filename, '.yaml'));
if (in_array($name, $ignore_yaml_files)) {
continue;
}
+
return $name;
}
+
return false;
}
@@ -619,7 +746,7 @@ public static function getPackageName($source)
* Find/Parse the blueprint file
*
* @param string $source
- * @return array|bool
+ * @return array|false
*/
public static function getBlueprints($source)
{
@@ -651,11 +778,13 @@ public static function getInstallPath($type, $name)
} else {
$install_path = $locator->findResource('plugins://', false) . DS . $name;
}
+
return $install_path;
}
/**
* Searches for a list of Packages in the repository
+ *
* @param array $searches An array of either slugs or names
* @return array Array of found Packages
* Format: ['total' => int, 'not_found' => array, ]
@@ -695,7 +824,7 @@ public function findPackages($searches = [])
$type = 'plugins';
}
- $not_found = new \stdClass();
+ $not_found = new stdClass();
$not_found->name = $inflector::camelize($search);
$not_found->slug = $search;
$not_found->package_type = $type;
@@ -712,7 +841,6 @@ public function findPackages($searches = [])
* Return the list of packages that have the passed one as dependency
*
* @param string $slug The slug name of the package
- *
* @return array
*/
public function getPackagesThatDependOnPackage($slug)
@@ -721,23 +849,21 @@ public function getPackagesThatDependOnPackage($slug)
$themes = $this->getInstalledThemes();
$packages = array_merge($plugins->toArray(), $themes->toArray());
- $dependent_packages = [];
-
+ $list = [];
foreach ($packages as $package_name => $package) {
- if (isset($package['dependencies'])) {
- foreach ($package['dependencies'] as $dependency) {
- if (is_array($dependency) && isset($dependency['name'])) {
- $dependency = $dependency['name'];
- }
+ $dependencies = $package['dependencies'] ?? [];
+ foreach ($dependencies as $dependency) {
+ if (is_array($dependency) && isset($dependency['name'])) {
+ $dependency = $dependency['name'];
+ }
- if ($dependency === $slug) {
- $dependent_packages[] = $package_name;
- }
+ if ($dependency === $slug) {
+ $list[] = $package_name;
}
}
}
- return $dependent_packages;
+ return $list;
}
@@ -746,12 +872,11 @@ public function getPackagesThatDependOnPackage($slug)
*
* @param string $package_slug
* @param string $dependency_slug
- *
- * @return mixed
+ * @return mixed|null
*/
public function getVersionOfDependencyRequiredByPackage($package_slug, $dependency_slug)
{
- $dependencies = $this->getInstalledPackage($package_slug)->dependencies;
+ $dependencies = $this->getInstalledPackage($package_slug)->dependencies ?? [];
foreach ($dependencies as $dependency) {
if (isset($dependency[$dependency_slug])) {
return $dependency[$dependency_slug];
@@ -768,35 +893,28 @@ public function getVersionOfDependencyRequiredByPackage($package_slug, $dependen
* @param string $slug
* @param string $version_with_operator
* @param array $ignore_packages_list
- *
* @return bool
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
- public function checkNoOtherPackageNeedsThisDependencyInALowerVersion(
- $slug,
- $version_with_operator,
- $ignore_packages_list
- ) {
-
+ public function checkNoOtherPackageNeedsThisDependencyInALowerVersion($slug, $version_with_operator, $ignore_packages_list)
+ {
// check if any of the currently installed package need this in a lower version than the one we need. In case, abort and tell which package
$dependent_packages = $this->getPackagesThatDependOnPackage($slug);
$version = $this->calculateVersionNumberFromDependencyVersion($version_with_operator);
if (count($dependent_packages)) {
foreach ($dependent_packages as $dependent_package) {
- $other_dependency_version_with_operator = $this->getVersionOfDependencyRequiredByPackage($dependent_package,
- $slug);
+ $other_dependency_version_with_operator = $this->getVersionOfDependencyRequiredByPackage($dependent_package, $slug);
$other_dependency_version = $this->calculateVersionNumberFromDependencyVersion($other_dependency_version_with_operator);
// check version is compatible with the one needed by the current package
if ($this->versionFormatIsNextSignificantRelease($other_dependency_version_with_operator)) {
- $compatible = $this->checkNextSignificantReleasesAreCompatible($version,
- $other_dependency_version);
- if (!$compatible) {
- if (!in_array($dependent_package, $ignore_packages_list, true)) {
- throw new \RuntimeException("Package $slug is required in an older version by package $dependent_package. This package needs a newer version, and because of this it cannot be installed. The $dependent_package package must be updated to use a newer release of $slug.",
- 2);
- }
+ $compatible = $this->checkNextSignificantReleasesAreCompatible($version, $other_dependency_version);
+ if (!$compatible && !in_array($dependent_package, $ignore_packages_list, true)) {
+ throw new RuntimeException(
+ "Package $slug is required in an older version by package $dependent_package. This package needs a newer version, and because of this it cannot be installed. The $dependent_package package must be updated to use a newer release of $slug.",
+ 2
+ );
}
}
}
@@ -809,14 +927,14 @@ public function checkNoOtherPackageNeedsThisDependencyInALowerVersion(
* Check the passed packages list can be updated
*
* @param array $packages_names_list
- *
- * @throws \Exception
+ * @return void
+ * @throws Exception
*/
public function checkPackagesCanBeInstalled($packages_names_list)
{
foreach ($packages_names_list as $package_name) {
- $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package_name,
- $this->getLatestVersionOfPackage($package_name), $packages_names_list);
+ $latest = $this->getLatestVersionOfPackage($package_name);
+ $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package_name, $latest, $packages_names_list);
}
}
@@ -829,27 +947,25 @@ public function checkPackagesCanBeInstalled($packages_names_list)
* `update` means the package is already installed and must be updated as a dependency needs a higher version.
*
* @param array $packages
- *
- * @return mixed
- * @throws \Exception
+ * @return array
+ * @throws RuntimeException
*/
public function getDependencies($packages)
{
$dependencies = $this->calculateMergedDependenciesOfPackages($packages);
foreach ($dependencies as $dependency_slug => $dependencyVersionWithOperator) {
- if (\in_array($dependency_slug, $packages, true)) {
+ $dependency_slug = (string)$dependency_slug;
+ if (in_array($dependency_slug, $packages, true)) {
unset($dependencies[$dependency_slug]);
continue;
}
// Check PHP version
if ($dependency_slug === 'php') {
- $current_php_version = phpversion();
- if (version_compare($this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator),
- $current_php_version) === 1
- ) {
+ $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);
+ if (version_compare($testVersion, PHP_VERSION) === 1) {
//Needs a Grav update first
- throw new \RuntimeException("One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this");
+ throw new RuntimeException("One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this");
}
unset($dependencies[$dependency_slug]);
@@ -858,11 +974,10 @@ public function getDependencies($packages)
//First, check for Grav dependency. If a dependency requires Grav > the current version, abort and tell.
if ($dependency_slug === 'grav') {
- if (version_compare($this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator),
- GRAV_VERSION) === 1
- ) {
+ $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);
+ if (version_compare($testVersion, GRAV_VERSION) === 1) {
//Needs a Grav update first
- throw new \RuntimeException("One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release.");
+ throw new RuntimeException("One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release.");
}
unset($dependencies[$dependency_slug]);
@@ -886,15 +1001,15 @@ public function getDependencies($packages)
$currentlyInstalledVersion = $package_yaml['version'];
// if requirement is next significant release, check is compatible with currently installed version, might not be
- if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) {
- if ($this->firstVersionIsLower($dependencyVersion, $currentlyInstalledVersion)) {
- $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion,
- $currentlyInstalledVersion);
+ if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)
+ && $this->firstVersionIsLower($dependencyVersion, $currentlyInstalledVersion)) {
+ $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion, $currentlyInstalledVersion);
- if (!$compatible) {
- throw new \RuntimeException('Dependency ' . $dependency_slug . ' is required in an older version than the one installed. This package must be updated. Please get in touch with its developer.',
- 2);
- }
+ if (!$compatible) {
+ throw new RuntimeException(
+ 'Dependency ' . $dependency_slug . ' is required in an older version than the one installed. This package must be updated. Please get in touch with its developer.',
+ 2
+ );
}
}
@@ -903,19 +1018,19 @@ public function getDependencies($packages)
if ($this->firstVersionIsLower($latestRelease, $dependencyVersion)) {
//throw an exception if a required version cannot be found in the GPM yet
- throw new \RuntimeException('Dependency ' . $package_yaml['name'] . ' is required in version ' . $dependencyVersion . ' which is higher than the latest release, ' . $latestRelease . '. Try running `bin/gpm -f index` to force a refresh of the GPM cache',
- 1);
+ throw new RuntimeException(
+ 'Dependency ' . $package_yaml['name'] . ' is required in version ' . $dependencyVersion . ' which is higher than the latest release, ' . $latestRelease . '. Try running `bin/gpm -f index` to force a refresh of the GPM cache',
+ 1
+ );
}
if ($this->firstVersionIsLower($currentlyInstalledVersion, $dependencyVersion)) {
$dependencies[$dependency_slug] = 'update';
+ } elseif ($currentlyInstalledVersion === $latestRelease) {
+ unset($dependencies[$dependency_slug]);
} else {
- if ($currentlyInstalledVersion == $latestRelease) {
- unset($dependencies[$dependency_slug]);
- } else {
- // an update is not strictly required mark as 'ignore'
- $dependencies[$dependency_slug] = 'ignore';
- }
+ // an update is not strictly required mark as 'ignore'
+ $dependencies[$dependency_slug] = 'ignore';
}
} else {
$dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);
@@ -924,12 +1039,16 @@ public function getDependencies($packages)
if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) {
$latestVersionOfPackage = $this->getLatestVersionOfPackage($dependency_slug);
if ($this->firstVersionIsLower($dependencyVersion, $latestVersionOfPackage)) {
- $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion,
- $latestVersionOfPackage);
+ $compatible = $this->checkNextSignificantReleasesAreCompatible(
+ $dependencyVersion,
+ $latestVersionOfPackage
+ );
if (!$compatible) {
- throw new \Exception('Dependency ' . $dependency_slug . ' is required in an older version than the latest release available, and it cannot be installed. This package must be updated. Please get in touch with its developer.',
- 2);
+ throw new RuntimeException(
+ 'Dependency ' . $dependency_slug . ' is required in an older version than the latest release available, and it cannot be installed. This package must be updated. Please get in touch with its developer.',
+ 2
+ );
}
}
}
@@ -944,14 +1063,26 @@ public function getDependencies($packages)
return $dependencies;
}
+ /**
+ * @param array $dependencies_slugs
+ * @return void
+ */
public function checkNoOtherPackageNeedsTheseDependenciesInALowerVersion($dependencies_slugs)
{
foreach ($dependencies_slugs as $dependency_slug) {
- $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($dependency_slug,
- $this->getLatestVersionOfPackage($dependency_slug), $dependencies_slugs);
+ $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion(
+ $dependency_slug,
+ $this->getLatestVersionOfPackage($dependency_slug),
+ $dependencies_slugs
+ );
}
}
+ /**
+ * @param string $firstVersion
+ * @param string $secondVersion
+ * @return bool
+ */
private function firstVersionIsLower($firstVersion, $secondVersion)
{
return version_compare($firstVersion, $secondVersion) === -1;
@@ -961,83 +1092,69 @@ private function firstVersionIsLower($firstVersion, $secondVersion)
* Calculates and merges the dependencies of a package
*
* @param string $packageName The package information
- *
* @param array $dependencies The dependencies array
- *
* @return array
- * @throws \Exception
*/
private function calculateMergedDependenciesOfPackage($packageName, $dependencies)
{
$packageData = $this->findPackage($packageName);
- //Check for dependencies
- if (isset($packageData->dependencies)) {
- foreach ($packageData->dependencies as $dependency) {
- $current_package_name = $dependency['name'];
- if (isset($dependency['version'])) {
- $current_package_version_information = $dependency['version'];
- }
+ if (empty($packageData->dependencies)) {
+ return $dependencies;
+ }
- if (!isset($dependencies[$current_package_name])) {
- // Dependency added for the first time
+ foreach ($packageData->dependencies as $dependency) {
+ $dependencyName = $dependency['name'] ?? null;
+ if (!$dependencyName) {
+ continue;
+ }
- if (!isset($current_package_version_information)) {
- $dependencies[$current_package_name] = '*';
- } else {
- $dependencies[$current_package_name] = $current_package_version_information;
- }
+ $dependencyVersion = $dependency['version'] ?? '*';
- //Factor in the package dependencies too
- $dependencies = $this->calculateMergedDependenciesOfPackage($current_package_name, $dependencies);
- } else {
- // Dependency already added by another package
- //if this package requires a version higher than the currently stored one, store this requirement instead
- if (isset($current_package_version_information) && $current_package_version_information !== '*') {
+ if (!isset($dependencies[$dependencyName])) {
+ // Dependency added for the first time
+ $dependencies[$dependencyName] = $dependencyVersion;
- $currently_stored_version_information = $dependencies[$current_package_name];
- $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currently_stored_version_information);
+ //Factor in the package dependencies too
+ $dependencies = $this->calculateMergedDependenciesOfPackage($dependencyName, $dependencies);
+ } elseif ($dependencyVersion !== '*') {
+ // Dependency already added by another package
+ // If this package requires a version higher than the currently stored one, store this requirement instead
+ $currentDependencyVersion = $dependencies[$dependencyName];
+ $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currentDependencyVersion);
- $currently_stored_version_is_in_next_significant_release_format = false;
- if ($this->versionFormatIsNextSignificantRelease($currently_stored_version_information)) {
- $currently_stored_version_is_in_next_significant_release_format = true;
- }
+ $currently_stored_version_is_in_next_significant_release_format = false;
+ if ($this->versionFormatIsNextSignificantRelease($currentDependencyVersion)) {
+ $currently_stored_version_is_in_next_significant_release_format = true;
+ }
- if (!$currently_stored_version_number) {
- $currently_stored_version_number = '*';
- }
+ if (!$currently_stored_version_number) {
+ $currently_stored_version_number = '*';
+ }
- $current_package_version_number = $this->calculateVersionNumberFromDependencyVersion($current_package_version_information);
- if (!$current_package_version_number) {
- throw new \RuntimeException('Bad format for version of dependency ' . $current_package_name . ' for package ' . $packageName,
- 1);
- }
+ $current_package_version_number = $this->calculateVersionNumberFromDependencyVersion($dependencyVersion);
+ if (!$current_package_version_number) {
+ throw new RuntimeException("Bad format for version of dependency {$dependencyName} for package {$packageName}", 1);
+ }
- $current_package_version_is_in_next_significant_release_format = false;
- if ($this->versionFormatIsNextSignificantRelease($current_package_version_information)) {
- $current_package_version_is_in_next_significant_release_format = true;
- }
+ $current_package_version_is_in_next_significant_release_format = false;
+ if ($this->versionFormatIsNextSignificantRelease($dependencyVersion)) {
+ $current_package_version_is_in_next_significant_release_format = true;
+ }
- //If I had stored '*', change right away with the more specific version required
- if ($currently_stored_version_number === '*') {
- $dependencies[$current_package_name] = $current_package_version_information;
- } else {
- if (!$currently_stored_version_is_in_next_significant_release_format && !$current_package_version_is_in_next_significant_release_format) {
- //Comparing versions equals or higher, a simple version_compare is enough
- if (version_compare($currently_stored_version_number,
- $current_package_version_number) === -1
- ) { //Current package version is higher
- $dependencies[$current_package_name] = $current_package_version_information;
- }
- } else {
- $compatible = $this->checkNextSignificantReleasesAreCompatible($currently_stored_version_number,
- $current_package_version_number);
- if (!$compatible) {
- throw new \RuntimeException('Dependency ' . $current_package_name . ' is required in two incompatible versions',
- 2);
- }
- }
- }
+ //If I had stored '*', change right away with the more specific version required
+ if ($currently_stored_version_number === '*') {
+ $dependencies[$dependencyName] = $dependencyVersion;
+ } elseif (!$currently_stored_version_is_in_next_significant_release_format && !$current_package_version_is_in_next_significant_release_format) {
+ //Comparing versions equals or higher, a simple version_compare is enough
+ if (version_compare($currently_stored_version_number, $current_package_version_number) === -1) {
+ //Current package version is higher
+ $dependencies[$dependencyName] = $dependencyVersion;
+ }
+ } else {
+ $compatible = $this->checkNextSignificantReleasesAreCompatible($currently_stored_version_number, $current_package_version_number);
+ if (!$compatible) {
+ throw new RuntimeException("Dependency {$dependencyName} is required in two incompatible versions", 2);
}
}
}
@@ -1050,9 +1167,7 @@ private function calculateMergedDependenciesOfPackage($packageName, $dependencie
* Calculates and merges the dependencies of the passed packages
*
* @param array $packages
- *
- * @return mixed
- * @throws \Exception
+ * @return array
*/
public function calculateMergedDependenciesOfPackages($packages)
{
@@ -1075,8 +1190,7 @@ public function calculateMergedDependenciesOfPackages($packages)
* $versionInformation == '' => returns null
*
* @param string $version
- *
- * @return null|string
+ * @return string|null
*/
public function calculateVersionNumberFromDependencyVersion($version)
{
@@ -1102,7 +1216,6 @@ public function calculateVersionNumberFromDependencyVersion($version)
* Example: returns true for $version: '~2.0'
*
* @param string $version
- *
* @return bool
*/
public function versionFormatIsNextSignificantRelease($version): bool
@@ -1116,7 +1229,6 @@ public function versionFormatIsNextSignificantRelease($version): bool
* Example: returns true for $version: '>=2.0'
*
* @param string $version
- *
* @return bool
*/
public function versionFormatIsEqualOrHigher($version): bool
@@ -1134,7 +1246,6 @@ public function versionFormatIsEqualOrHigher($version): bool
*
* @param string $version1 the version string (e.g. '2.0.0' or '1.0')
* @param string $version2 the version string (e.g. '2.0.0' or '1.0')
- *
* @return bool
*/
public function checkNextSignificantReleasesAreCompatible($version1, $version2): bool
@@ -1142,13 +1253,13 @@ public function checkNextSignificantReleasesAreCompatible($version1, $version2):
$version1array = explode('.', $version1);
$version2array = explode('.', $version2);
- if (\count($version1array) > \count($version2array)) {
- list($version1array, $version2array) = [$version2array, $version1array];
+ if (count($version1array) > count($version2array)) {
+ [$version1array, $version2array] = [$version2array, $version1array];
}
$i = 0;
- while ($i < \count($version1array) - 1) {
- if ($version1array[$i] != $version2array[$i]) {
+ while ($i < count($version1array) - 1) {
+ if ($version1array[$i] !== $version2array[$i]) {
return false;
}
$i++;
@@ -1156,5 +1267,4 @@ public function checkNextSignificantReleasesAreCompatible($version1, $version2):
return true;
}
-
}
diff --git a/system/src/Grav/Common/GPM/Installer.php b/system/src/Grav/Common/GPM/Installer.php
index cd5116383f..8a1a5303e5 100644
--- a/system/src/Grav/Common/GPM/Installer.php
+++ b/system/src/Grav/Common/GPM/Installer.php
@@ -3,15 +3,26 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\GPM;
+use DirectoryIterator;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
+use Grav\Common\Utils;
+use RuntimeException;
+use ZipArchive;
+use function count;
+use function in_array;
+use function is_string;
+/**
+ * Class Installer
+ * @package Grav\Common\GPM
+ */
class Installer
{
/** @const No error */
@@ -33,31 +44,19 @@ class Installer
/** @const Invalid source file */
public const INVALID_SOURCE = 128;
- /**
- * Destination folder on which validation checks are applied
- * @var string
- */
+ /** @var string Destination folder on which validation checks are applied */
protected static $target;
- /**
- * @var int Error Code
- */
+ /** @var int|string Error code or string */
protected static $error = 0;
- /**
- * @var int Zip Error Code
- */
+ /** @var int Zip Error Code */
protected static $error_zip = 0;
- /**
- * @var string Post install message
- */
+ /** @var string Post install message */
protected static $message = '';
- /**
- * Default options for the install
- * @var array
- */
+ /** @var array Default options for the install */
protected static $options = [
'overwrite' => true,
'ignore_symlinks' => true,
@@ -74,7 +73,7 @@ class Installer
* @param string $zip the local path to ZIP package
* @param string $destination The local path to the Grav Instance
* @param array $options Options to use for installing. ie, ['install_path' => 'user/themes/antimatter']
- * @param string $extracted The local path to the extacted ZIP package
+ * @param string|null $extracted The local path to the extacted ZIP package
* @param bool $keepExtracted True if you want to keep the original files
* @return bool True if everything went fine, False otherwise.
*/
@@ -84,8 +83,10 @@ public static function install($zip, $destination, $options = [], $extracted = n
$options = array_merge(self::$options, $options);
$install_path = rtrim($destination . DS . ltrim($options['install_path'], DS), DS);
- if (!self::isGravInstance($destination) || !self::isValidDestination($install_path,
- $options['exclude_checks'])
+ if (!self::isGravInstance($destination) || !self::isValidDestination(
+ $install_path,
+ $options['exclude_checks']
+ )
) {
return false;
}
@@ -135,7 +136,10 @@ public static function install($zip, $destination, $options = [], $extracted = n
}
if (!$options['sophisticated']) {
- if ($options['theme']) {
+ $isTheme = $options['theme'] ?? false;
+ // Make sure that themes are always being copied, even if option was not set!
+ $isTheme = $isTheme || preg_match('|/themes/[^/]+|ui', $install_path);
+ if ($isTheme) {
self::copyInstall($extracted, $install_path);
} else {
self::moveInstall($extracted, $install_path);
@@ -160,7 +164,6 @@ public static function install($zip, $destination, $options = [], $extracted = n
self::$error = self::OK;
return true;
-
}
/**
@@ -168,11 +171,11 @@ public static function install($zip, $destination, $options = [], $extracted = n
*
* @param string $zip_file
* @param string $destination
- * @return bool|string
+ * @return string|false
*/
public static function unZip($zip_file, $destination)
{
- $zip = new \ZipArchive();
+ $zip = new ZipArchive();
$archive = $zip->open($zip_file);
if ($archive === true) {
@@ -188,7 +191,11 @@ public static function unZip($zip_file, $destination)
return false;
}
- $package_folder_name = preg_replace('#\./$#', '', $zip->getNameIndex(0));
+ $package_folder_name = $zip->getNameIndex(0);
+ if ($package_folder_name === false) {
+ throw new \RuntimeException('Bad package file: ' . Utils::basename($zip_file));
+ }
+ $package_folder_name = preg_replace('#\./$#', '', $package_folder_name);
$zip->close();
return $destination . '/' . $package_folder_name;
@@ -196,6 +203,7 @@ public static function unZip($zip_file, $destination)
self::$error = self::ZIP_EXTRACT_ERROR;
self::$error_zip = $archive;
+
return false;
}
@@ -204,23 +212,20 @@ public static function unZip($zip_file, $destination)
*
* @param string $installer_file_folder The folder path that contains install.php
* @param bool $is_install True if install, false if removal
- *
- * @return null|string
+ * @return string|null
*/
private static function loadInstaller($installer_file_folder, $is_install)
{
- $installer = null;
-
$installer_file_folder = rtrim($installer_file_folder, DS);
$install_file = $installer_file_folder . DS . 'install.php';
- if (file_exists($install_file)) {
- require_once $install_file;
- } else {
+ if (!file_exists($install_file)) {
return null;
}
+ require_once $install_file;
+
if ($is_install) {
$slug = '';
if (($pos = strpos($installer_file_folder, 'grav-plugin-')) !== false) {
@@ -243,19 +248,18 @@ private static function loadInstaller($installer_file_folder, $is_install)
return $class_name;
}
- $class_name_alphanumeric = preg_replace('/[^a-zA-Z0-9]+/', '', $class_name);
+ $class_name_alphanumeric = preg_replace('/[^a-zA-Z0-9]+/', '', $class_name) ?? $class_name;
if (class_exists($class_name_alphanumeric)) {
return $class_name_alphanumeric;
}
- return $installer;
+ return null;
}
/**
* @param string $source_path
* @param string $install_path
- *
* @return bool
*/
public static function moveInstall($source_path, $install_path)
@@ -272,13 +276,12 @@ public static function moveInstall($source_path, $install_path)
/**
* @param string $source_path
* @param string $install_path
- *
* @return bool
*/
public static function copyInstall($source_path, $install_path)
{
if (empty($source_path)) {
- throw new \RuntimeException("Directory $source_path is missing");
+ throw new RuntimeException("Directory $source_path is missing");
}
Folder::rcopy($source_path, $install_path);
@@ -291,14 +294,12 @@ public static function copyInstall($source_path, $install_path)
* @param string $install_path
* @param array $ignores
* @param bool $keep_source
- *
* @return bool
*/
public static function sophisticatedInstall($source_path, $install_path, $ignores = [], $keep_source = false)
{
- foreach (new \DirectoryIterator($source_path) as $file) {
-
- if ($file->isLink() || $file->isDot() || \in_array($file->getFilename(), $ignores, true)) {
+ foreach (new DirectoryIterator($source_path) as $file) {
+ if ($file->isLink() || $file->isDot() || in_array($file->getFilename(), $ignores, true)) {
continue;
}
@@ -313,7 +314,8 @@ public static function sophisticatedInstall($source_path, $install_path, $ignore
}
if ($file->getFilename() === 'bin') {
- foreach (glob($path . DS . '*') as $bin_file) {
+ $glob = glob($path . DS . '*') ?: [];
+ foreach ($glob as $bin_file) {
@chmod($bin_file, 0755);
}
}
@@ -331,7 +333,6 @@ public static function sophisticatedInstall($source_path, $install_path, $ignore
*
* @param string $path The slug of the package(s)
* @param array $options Options to use for uninstalling
- *
* @return bool True if everything went fine, False otherwise.
*/
public static function uninstall($path, $options = [])
@@ -373,7 +374,6 @@ public static function uninstall($path, $options = [])
*
* @param string $destination The directory to run validations at
* @param array $exclude An array of constants to exclude from the validation
- *
* @return bool True if validation passed. False otherwise
*/
public static function isValidDestination($destination, $exclude = [])
@@ -391,7 +391,7 @@ public static function isValidDestination($destination, $exclude = [])
self::$error = self::NOT_DIRECTORY;
}
- if (\count($exclude) && \in_array(self::$error, $exclude, true)) {
+ if (count($exclude) && in_array(self::$error, $exclude, true)) {
return true;
}
@@ -402,7 +402,6 @@ public static function isValidDestination($destination, $exclude = [])
* Validates if the given path is a Grav Instance
*
* @param string $target The local path to the Grav Instance
- *
* @return bool True if is a Grav Instance. False otherwise
*/
public static function isGravInstance($target)
@@ -410,8 +409,7 @@ public static function isGravInstance($target)
self::$error = 0;
self::$target = $target;
- if (
- !file_exists($target . DS . 'index.php') ||
+ if (!file_exists($target . DS . 'index.php') ||
!file_exists($target . DS . 'bin') ||
!file_exists($target . DS . 'user') ||
!file_exists($target . DS . 'system' . DS . 'config' . DS . 'system.yaml')
@@ -424,6 +422,7 @@ public static function isGravInstance($target)
/**
* Returns the last message added by the installer
+ *
* @return string The message
*/
public static function getMessage()
@@ -433,6 +432,7 @@ public static function getMessage()
/**
* Returns the last error occurred in a string message format
+ *
* @return string The message of the last error
*/
public static function lastErrorMsg()
@@ -473,36 +473,36 @@ public static function lastErrorMsg()
case self::ZIP_EXTRACT_ERROR:
$msg = 'Unable to extract the package. ';
if (self::$error_zip) {
- switch(self::$error_zip) {
- case \ZipArchive::ER_EXISTS:
+ switch (self::$error_zip) {
+ case ZipArchive::ER_EXISTS:
$msg .= 'File already exists.';
break;
- case \ZipArchive::ER_INCONS:
+ case ZipArchive::ER_INCONS:
$msg .= 'Zip archive inconsistent.';
break;
- case \ZipArchive::ER_MEMORY:
+ case ZipArchive::ER_MEMORY:
$msg .= 'Memory allocation failure.';
break;
- case \ZipArchive::ER_NOENT:
+ case ZipArchive::ER_NOENT:
$msg .= 'No such file.';
break;
- case \ZipArchive::ER_NOZIP:
+ case ZipArchive::ER_NOZIP:
$msg .= 'Not a zip archive.';
break;
- case \ZipArchive::ER_OPEN:
+ case ZipArchive::ER_OPEN:
$msg .= "Can't open file.";
break;
- case \ZipArchive::ER_READ:
+ case ZipArchive::ER_READ:
$msg .= 'Read error.';
break;
- case \ZipArchive::ER_SEEK:
+ case ZipArchive::ER_SEEK:
$msg .= 'Seek error.';
break;
}
@@ -523,6 +523,7 @@ public static function lastErrorMsg()
/**
* Returns the last error code of the occurred error
+ *
* @return int|string The code of the last error
*/
public static function lastErrorCode()
@@ -534,8 +535,8 @@ public static function lastErrorCode()
* Allows to manually set an error
*
* @param int|string $error the Error code
+ * @return void
*/
-
public static function setError($error)
{
self::$error = $error;
diff --git a/system/src/Grav/Common/GPM/Licenses.php b/system/src/Grav/Common/GPM/Licenses.php
index 14c9258321..e932bd4f76 100644
--- a/system/src/Grav/Common/GPM/Licenses.php
+++ b/system/src/Grav/Common/GPM/Licenses.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,6 +11,8 @@
use Grav\Common\File\CompiledYamlFile;
use Grav\Common\Grav;
+use RocketTheme\Toolbox\File\FileInterface;
+use function is_string;
/**
* Class Licenses
@@ -19,23 +21,16 @@
*/
class Licenses
{
-
- /**
- * Regex to validate the format of a License
- *
- * @var string
- */
+ /** @var string Regex to validate the format of a License */
protected static $regex = '^(?:[A-F0-9]{8}-){3}(?:[A-F0-9]{8}){1}$';
-
+ /** @var FileInterface */
protected static $file;
-
/**
* Returns the license for a Premium package
*
* @param string $slug
* @param string $license
- *
* @return bool
*/
public static function set($slug, $license)
@@ -48,7 +43,7 @@ public static function set($slug, $license)
return false;
}
- if (!\is_string($license)) {
+ if (!is_string($license)) {
if (isset($data['licenses'][$slug])) {
unset($data['licenses'][$slug]);
} else {
@@ -67,21 +62,21 @@ public static function set($slug, $license)
/**
* Returns the license for a Premium package
*
- * @param string $slug
- *
- * @return array|string
+ * @param string|null $slug
+ * @return string[]|string
*/
public static function get($slug = null)
{
$licenses = self::getLicenseFile();
$data = (array)$licenses->content();
$licenses->free();
- $slug = strtolower($slug);
- if (!$slug) {
+ if (null === $slug) {
return $data['licenses'] ?? [];
}
+ $slug = strtolower($slug);
+
return $data['licenses'][$slug] ?? '';
}
@@ -89,8 +84,7 @@ public static function get($slug = null)
/**
* Validates the License format
*
- * @param string $license
- *
+ * @param string|null $license
* @return bool
*/
public static function validate($license = null)
@@ -99,16 +93,15 @@ public static function validate($license = null)
return false;
}
- return preg_match('#' . self::$regex. '#', $license);
+ return (bool)preg_match('#' . self::$regex. '#', $license);
}
/**
* Get the License File object
*
- * @return \RocketTheme\Toolbox\File\FileInterface
+ * @return FileInterface
*/
public static function getLicenseFile()
-
{
if (!isset(self::$file)) {
$path = Grav::instance()['locator']->findResource('user-data://') . '/licenses.yaml';
diff --git a/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php
index be565c9c96..bc7ceab5e3 100644
--- a/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php
+++ b/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,8 +11,17 @@
use Grav\Common\GPM\Common\AbstractPackageCollection as BaseCollection;
+/**
+ * Class AbstractPackageCollection
+ * @package Grav\Common\GPM\Local
+ */
abstract class AbstractPackageCollection extends BaseCollection
{
+ /**
+ * AbstractPackageCollection constructor.
+ *
+ * @param array $items
+ */
public function __construct($items)
{
parent::__construct();
diff --git a/system/src/Grav/Common/GPM/Local/Package.php b/system/src/Grav/Common/GPM/Local/Package.php
index ff797a78c5..08444a76df 100644
--- a/system/src/Grav/Common/GPM/Local/Package.php
+++ b/system/src/Grav/Common/GPM/Local/Package.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,11 +11,22 @@
use Grav\Common\Data\Data;
use Grav\Common\GPM\Common\Package as BasePackage;
+use Parsedown;
+/**
+ * Class Package
+ * @package Grav\Common\GPM\Local
+ */
class Package extends BasePackage
{
+ /** @var array */
protected $settings;
+ /**
+ * Package constructor.
+ * @param Data $package
+ * @param string|null $package_type
+ */
public function __construct(Data $package, $package_type = null)
{
$data = new Data($package->blueprints()->toArray());
@@ -23,7 +34,7 @@ public function __construct(Data $package, $package_type = null)
$this->settings = $package->toArray();
- $html_description = \Parsedown::instance()->line($this->__get('description'));
+ $html_description = Parsedown::instance()->line($this->__get('description'));
$this->data->set('slug', $package->__get('slug'));
$this->data->set('description_html', $html_description);
$this->data->set('description_plain', strip_tags($html_description));
@@ -31,10 +42,10 @@ public function __construct(Data $package, $package_type = null)
}
/**
- * @return mixed
+ * @return bool
*/
public function isEnabled()
{
- return $this->settings['enabled'];
+ return (bool)$this->settings['enabled'];
}
}
diff --git a/system/src/Grav/Common/GPM/Local/Packages.php b/system/src/Grav/Common/GPM/Local/Packages.php
index 14a0b0b47b..b63b93b546 100644
--- a/system/src/Grav/Common/GPM/Local/Packages.php
+++ b/system/src/Grav/Common/GPM/Local/Packages.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,6 +11,10 @@
use Grav\Common\GPM\Common\CachedCollection;
+/**
+ * Class Packages
+ * @package Grav\Common\GPM\Local
+ */
class Packages extends CachedCollection
{
public function __construct()
diff --git a/system/src/Grav/Common/GPM/Local/Plugins.php b/system/src/Grav/Common/GPM/Local/Plugins.php
index 19adfb88ea..a1626f8bac 100644
--- a/system/src/Grav/Common/GPM/Local/Plugins.php
+++ b/system/src/Grav/Common/GPM/Local/Plugins.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,11 +11,13 @@
use Grav\Common\Grav;
+/**
+ * Class Plugins
+ * @package Grav\Common\GPM\Local
+ */
class Plugins extends AbstractPackageCollection
{
- /**
- * @var string
- */
+ /** @var string */
protected $type = 'plugins';
/**
diff --git a/system/src/Grav/Common/GPM/Local/Themes.php b/system/src/Grav/Common/GPM/Local/Themes.php
index 8607e6b7f2..d153794398 100644
--- a/system/src/Grav/Common/GPM/Local/Themes.php
+++ b/system/src/Grav/Common/GPM/Local/Themes.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,11 +11,13 @@
use Grav\Common\Grav;
+/**
+ * Class Themes
+ * @package Grav\Common\GPM\Local
+ */
class Themes extends AbstractPackageCollection
{
- /**
- * @var string
- */
+ /** @var string */
protected $type = 'themes';
/**
@@ -23,6 +25,9 @@ class Themes extends AbstractPackageCollection
*/
public function __construct()
{
- parent::__construct(Grav::instance()['themes']->all());
+ /** @var \Grav\Common\Themes $themes */
+ $themes = Grav::instance()['themes'];
+
+ parent::__construct($themes->all());
}
}
diff --git a/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php
index 429c5b1dc5..d8663dc8d1 100644
--- a/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php
+++ b/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php
@@ -3,47 +3,46 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
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;
+/**
+ * Class AbstractPackageCollection
+ * @package Grav\Common\GPM\Remote
+ */
class AbstractPackageCollection extends BaseCollection
{
- /**
- * The cached data previously fetched
- * @var string
- */
+ /** @var string The cached data previously fetched */
protected $raw;
-
- /**
- * The lifetime to store the entry in seconds
- * @var int
- */
- private $lifetime = 86400;
-
+ /** @var string */
protected $repository;
-
+ /** @var FilesystemCache */
protected $cache;
+ /** @var int The lifetime to store the entry in seconds */
+ private $lifetime = 86400;
+
/**
* AbstractPackageCollection constructor.
*
- * @param null $repository
+ * @param string|null $repository
* @param bool $refresh
- * @param null $callback
+ * @param callable|null $callback
*/
public function __construct($repository = null, $refresh = false, $callback = null)
{
parent::__construct();
if ($repository === null) {
- throw new \RuntimeException('A repository is required to indicate the origin of the remote collection');
+ throw new RuntimeException('A repository is required to indicate the origin of the remote collection');
}
$channel = Grav::instance()['config']->get('system.gpm.releases', 'stable');
@@ -55,7 +54,7 @@ public function __construct($repository = null, $refresh = false, $callback = nu
$this->fetch($refresh, $callback);
foreach (json_decode($this->raw, true) as $slug => $data) {
- // Temporarily fix for using multisites
+ // Temporarily fix for using multi-sites
if (isset($data['install_path'])) {
$path = preg_replace('~^user/~i', 'user://', $data['install_path']);
$data['install_path'] = Grav::instance()['locator']->findResource($path, false, true);
@@ -64,6 +63,11 @@ public function __construct($repository = null, $refresh = false, $callback = nu
}
}
+ /**
+ * @param bool $refresh
+ * @param callable|null $callback
+ * @return string
+ */
public function fetch($refresh = false, $callback = null)
{
if (!$this->raw || $refresh) {
diff --git a/system/src/Grav/Common/GPM/Remote/GravCore.php b/system/src/Grav/Common/GPM/Remote/GravCore.php
index 1d30120d02..2998975cfe 100644
--- a/system/src/Grav/Common/GPM/Remote/GravCore.php
+++ b/system/src/Grav/Common/GPM/Remote/GravCore.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,20 +11,30 @@
use Grav\Common\Grav;
use \Doctrine\Common\Cache\FilesystemCache;
+use InvalidArgumentException;
+/**
+ * Class GravCore
+ * @package Grav\Common\GPM\Remote
+ */
class GravCore extends AbstractPackageCollection
{
+ /** @var string */
protected $repository = 'https://getgrav.org/downloads/grav.json';
- private $data;
+ /** @var array */
+ private $data;
+ /** @var string */
private $version;
+ /** @var string */
private $date;
+ /** @var string|null */
private $min_php;
/**
* @param bool $refresh
- * @param null $callback
- * @throws \InvalidArgumentException
+ * @param callable|null $callback
+ * @throws InvalidArgumentException
*/
public function __construct($refresh = false, $callback = null)
{
@@ -61,8 +71,7 @@ public function getAssets()
/**
* Returns the changelog list for each version of Grav
*
- * @param string $diff the version number to start the diff from
- *
+ * @param string|null $diff the version number to start the diff from
* @return array changelog list for each version
*/
public function getChangelog($diff = null)
@@ -118,14 +127,15 @@ public function getVersion()
/**
* Returns the minimum PHP version
*
- * @return null|string
+ * @return string
*/
public function getMinPHPVersion()
{
// If non min set, assume current PHP version
if (null === $this->min_php) {
- $this->min_php = phpversion();
+ $this->min_php = PHP_VERSION;
}
+
return $this->min_php;
}
diff --git a/system/src/Grav/Common/GPM/Remote/Package.php b/system/src/Grav/Common/GPM/Remote/Package.php
index 196e92bd9e..519325551e 100644
--- a/system/src/Grav/Common/GPM/Remote/Package.php
+++ b/system/src/Grav/Common/GPM/Remote/Package.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -12,16 +12,55 @@
use Grav\Common\Data\Data;
use Grav\Common\GPM\Common\Package as BasePackage;
+/**
+ * Class Package
+ * @package Grav\Common\GPM\Remote
+ */
class Package extends BasePackage implements \JsonSerializable
{
+ /**
+ * Package constructor.
+ * @param array $package
+ * @param string|null $package_type
+ */
public function __construct($package, $package_type = null)
{
$data = new Data($package);
parent::__construct($data, $package_type);
}
+ /**
+ * @return array
+ */
+ #[\ReturnTypeWillChange]
public function jsonSerialize()
{
- return $this->data;
+ return $this->data->toArray();
+ }
+
+ /**
+ * Returns the changelog list for each version of a package
+ *
+ * @param string|null $diff the version number to start the diff from
+ * @return array changelog list for each version
+ */
+ public function getChangelog($diff = null)
+ {
+ if (!$diff) {
+ return $this->data['changelog'];
+ }
+
+ $diffLog = [];
+ foreach ((array)$this->data['changelog'] as $version => $changelog) {
+ preg_match("/[\w\-.]+/", $version, $cleanVersion);
+
+ if (!$cleanVersion || version_compare($diff, $cleanVersion[0], '>=')) {
+ continue;
+ }
+
+ $diffLog[$version] = $changelog;
+ }
+
+ return $diffLog;
}
}
diff --git a/system/src/Grav/Common/GPM/Remote/Packages.php b/system/src/Grav/Common/GPM/Remote/Packages.php
index 46bc31fff3..0ac2ef967d 100644
--- a/system/src/Grav/Common/GPM/Remote/Packages.php
+++ b/system/src/Grav/Common/GPM/Remote/Packages.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,8 +11,17 @@
use Grav\Common\GPM\Common\CachedCollection;
+/**
+ * Class Packages
+ * @package Grav\Common\GPM\Remote
+ */
class Packages extends CachedCollection
{
+ /**
+ * Packages constructor.
+ * @param bool $refresh
+ * @param callable|null $callback
+ */
public function __construct($refresh = false, $callback = null)
{
$items = [
diff --git a/system/src/Grav/Common/GPM/Remote/Plugins.php b/system/src/Grav/Common/GPM/Remote/Plugins.php
index 1d905e3785..8383cd7820 100644
--- a/system/src/Grav/Common/GPM/Remote/Plugins.php
+++ b/system/src/Grav/Common/GPM/Remote/Plugins.php
@@ -3,25 +3,27 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\GPM\Remote;
+/**
+ * Class Plugins
+ * @package Grav\Common\GPM\Remote
+ */
class Plugins extends AbstractPackageCollection
{
- /**
- * @var string
- */
+ /** @var string */
protected $type = 'plugins';
-
+ /** @var string */
protected $repository = 'https://getgrav.org/downloads/plugins.json';
/**
* Local Plugins Constructor
* @param bool $refresh
- * @param callable $callback Either a function or callback in array notation
+ * @param callable|null $callback Either a function or callback in array notation
*/
public function __construct($refresh = false, $callback = null)
{
diff --git a/system/src/Grav/Common/GPM/Remote/Themes.php b/system/src/Grav/Common/GPM/Remote/Themes.php
index 8024b40c6d..a8ee9fae22 100644
--- a/system/src/Grav/Common/GPM/Remote/Themes.php
+++ b/system/src/Grav/Common/GPM/Remote/Themes.php
@@ -3,25 +3,27 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\GPM\Remote;
+/**
+ * Class Themes
+ * @package Grav\Common\GPM\Remote
+ */
class Themes extends AbstractPackageCollection
{
- /**
- * @var string
- */
+ /** @var string */
protected $type = 'themes';
-
+ /** @var string */
protected $repository = 'https://getgrav.org/downloads/themes.json';
/**
* Local Themes Constructor
* @param bool $refresh
- * @param callable $callback Either a function or callback in array notation
+ * @param callable|null $callback Either a function or callback in array notation
*/
public function __construct($refresh = false, $callback = null)
{
diff --git a/system/src/Grav/Common/GPM/Response.php b/system/src/Grav/Common/GPM/Response.php
index 72276076db..98654b6a1b 100644
--- a/system/src/Grav/Common/GPM/Response.php
+++ b/system/src/Grav/Common/GPM/Response.php
@@ -1,434 +1,3 @@
[
- CURLOPT_REFERER => 'Grav GPM',
- CURLOPT_USERAGENT => 'Grav GPM',
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_FOLLOWLOCATION => true,
- CURLOPT_FAILONERROR => true,
- CURLOPT_TIMEOUT => 15,
- CURLOPT_HEADER => false,
- //CURLOPT_SSL_VERIFYPEER => true, // this is set in the constructor since it's a setting
- /**
- * Example of callback parameters from within your own class
- */
- //CURLOPT_NOPROGRESS => false,
- //CURLOPT_PROGRESSFUNCTION => [$this, 'progress']
- ],
- 'fopen' => [
- 'method' => 'GET',
- 'user_agent' => 'Grav GPM',
- 'max_redirects' => 5,
- 'follow_location' => 1,
- 'timeout' => 15,
- /* // this is set in the constructor since it's a setting
- 'ssl' => [
- 'verify_peer' => true,
- 'verify_peer_name' => true,
- ],
- */
- /**
- * Example of callback parameters from within your own class
- */
- //'notification' => [$this, 'progress']
- ]
- ];
-
- /**
- * Sets the preferred method to use for making HTTP calls.
- *
- * @param string $method Default is `auto`
- *
- * @return Response
- */
- public static function setMethod($method = 'auto')
- {
- if (!\in_array($method, ['auto', 'curl', 'fopen'], true)) {
- $method = 'auto';
- }
-
- self::$method = $method;
-
- return new self();
- }
-
- /**
- * Makes a request to the URL by using the preferred method
- *
- * @param string $uri URL to call
- * @param array $options An array of parameters for both `curl` and `fopen`
- * @param callable $callback Either a function or callback in array notation
- *
- * @return string The response of the request
- */
- public static function get($uri = '', $options = [], $callback = null)
- {
- if (!self::isCurlAvailable() && !self::isFopenAvailable()) {
- throw new \RuntimeException('Could not start an HTTP request. `allow_url_open` is disabled and `cURL` is not available');
- }
-
- // check if this function is available, if so use it to stop any timeouts
- try {
- if (function_exists('set_time_limit') && !Utils::isFunctionDisabled('set_time_limit')) {
- set_time_limit(0);
- }
- } catch (\Exception $e) {
- }
-
- $config = Grav::instance()['config'];
- $overrides = [];
-
- // Override CA Bundle
- $caPathOrFile = \Composer\CaBundle\CaBundle::getSystemCaRootBundlePath();
- if (is_dir($caPathOrFile) || (is_link($caPathOrFile) && is_dir(readlink($caPathOrFile)))) {
- $overrides['curl'][CURLOPT_CAPATH] = $caPathOrFile;
- $overrides['fopen']['ssl']['capath'] = $caPathOrFile;
- } else {
- $overrides['curl'][CURLOPT_CAINFO] = $caPathOrFile;
- $overrides['fopen']['ssl']['cafile'] = $caPathOrFile;
- }
-
- // SSL Verify Peer and Proxy Setting
- $settings = [
- 'method' => $config->get('system.gpm.method', self::$method),
- 'verify_peer' => $config->get('system.gpm.verify_peer', true),
- // `system.proxy_url` is for fallback
- // introduced with 1.1.0-beta.1 probably safe to remove at some point
- 'proxy_url' => $config->get('system.gpm.proxy_url', $config->get('system.proxy_url', false)),
- ];
-
- if (!$settings['verify_peer']) {
- $overrides = array_replace_recursive([], $overrides, [
- 'curl' => [
- CURLOPT_SSL_VERIFYPEER => $settings['verify_peer']
- ],
- 'fopen' => [
- 'ssl' => [
- 'verify_peer' => $settings['verify_peer'],
- 'verify_peer_name' => $settings['verify_peer'],
- ]
- ]
- ]);
- }
-
- // Proxy Setting
- if ($settings['proxy_url']) {
- $proxy = parse_url($settings['proxy_url']);
- $fopen_proxy = ($proxy['scheme'] ?: 'http') . '://' . $proxy['host'] . (isset($proxy['port']) ? ':' . $proxy['port'] : '');
-
- $overrides = array_replace_recursive([], $overrides, [
- 'curl' => [
- CURLOPT_PROXY => $proxy['host'],
- CURLOPT_PROXYTYPE => 'HTTP'
- ],
- 'fopen' => [
- 'proxy' => $fopen_proxy,
- 'request_fulluri' => true
- ]
- ]);
-
- if (isset($proxy['port'])) {
- $overrides['curl'][CURLOPT_PROXYPORT] = $proxy['port'];
- }
-
- if (isset($proxy['user'], $proxy['pass'])) {
- $fopen_auth = $auth = base64_encode($proxy['user'] . ':' . $proxy['pass']);
- $overrides['curl'][CURLOPT_PROXYUSERPWD] = $proxy['user'] . ':' . $proxy['pass'];
- $overrides['fopen']['header'] = "Proxy-Authorization: Basic $fopen_auth";
- }
- }
-
- $options = array_replace_recursive(self::$defaults, $options, $overrides);
- $method = 'get' . ucfirst(strtolower($settings['method']));
-
- self::$callback = $callback;
- return static::$method($uri, $options, $callback);
- }
-
- /**
- * Checks if cURL is available
- *
- * @return bool
- */
- public static function isCurlAvailable()
- {
- return function_exists('curl_version');
- }
-
- /**
- * Checks if the remote fopen request is enabled in PHP
- *
- * @return bool
- */
- public static function isFopenAvailable()
- {
- return preg_match('/1|yes|on|true/i', ini_get('allow_url_fopen'));
- }
-
- /**
- * Is this a remote file or not
- *
- * @param string $file
- * @return bool
- */
- public static function isRemote($file)
- {
- return (bool) filter_var($file, FILTER_VALIDATE_URL);
- }
-
- /**
- * Progress normalized for cURL and Fopen
- * Accepts a variable length of arguments passed in by stream method
- */
- public static function progress()
- {
- static $filesize = null;
-
- $args = func_get_args();
- $isCurlResource = is_resource($args[0]) && get_resource_type($args[0]) === 'curl';
-
- $notification_code = !$isCurlResource ? $args[0] : false;
- $bytes_transferred = $isCurlResource ? $args[2] : $args[4];
-
- if ($isCurlResource) {
- $filesize = $args[1];
- } elseif ($notification_code == STREAM_NOTIFY_FILE_SIZE_IS) {
- $filesize = $args[5];
- }
-
- if ($bytes_transferred > 0) {
- if ($notification_code == STREAM_NOTIFY_PROGRESS | STREAM_NOTIFY_COMPLETED || $isCurlResource) {
-
- $progress = [
- 'code' => $notification_code,
- 'filesize' => $filesize,
- 'transferred' => $bytes_transferred,
- 'percent' => $filesize <= 0 ? '-' : round(($bytes_transferred * 100) / $filesize, 1)
- ];
-
- if (self::$callback !== null) {
- call_user_func(self::$callback, $progress);
- }
- }
- }
- }
-
- /**
- * Automatically picks the preferred method
- *
- * @return string The response of the request
- */
- private static function getAuto()
- {
- if (!ini_get('open_basedir') && self::isFopenAvailable()) {
- return self::getFopen(func_get_args());
- }
-
- if (self::isCurlAvailable()) {
- return self::getCurl(func_get_args());
- }
-
- return null;
- }
-
- /**
- * Starts a HTTP request via fopen
- *
- * @return string The response of the request
- */
- private static function getFopen()
- {
- if (\count($args = func_get_args()) === 1) {
- $args = $args[0];
- }
-
- $uri = $args[0];
- $options = $args[1] ?? [];
- $callback = $args[2] ?? null;
-
- if ($callback) {
- $options['fopen']['notification'] = ['self', 'progress'];
- }
-
- if (isset($options['fopen']['ssl'])) {
- $ssl = $options['fopen']['ssl'];
- unset($options['fopen']['ssl']);
-
- $stream = stream_context_create([
- 'http' => $options['fopen'],
- 'ssl' => $ssl
- ], $options['fopen']);
- } else {
- $stream = stream_context_create(['http' => $options['fopen']], $options['fopen']);
- }
-
-
- $content = @file_get_contents($uri, false, $stream);
-
- if ($content === false) {
- $code = null;
- // Function file_get_contents() creates local variable $http_response_header, check it
- if (isset($http_response_header)) {
- $code = explode(' ', $http_response_header[0] ?? '')[1] ?? null;
- }
-
- switch ($code) {
- case '404':
- throw new \RuntimeException('Page not found');
- case '401':
- throw new \RuntimeException('Invalid LICENSE');
- default:
- throw new \RuntimeException("Error while trying to download (code: {$code}): {$uri}\n");
- }
- }
-
- return $content;
- }
-
- /**
- * Starts a HTTP request via cURL
- *
- * @return string The response of the request
- */
- private static function getCurl()
- {
- $args = func_get_args();
- $args = count($args) > 1 ? $args : array_shift($args);
-
- $uri = $args[0];
- $options = $args[1] ?? [];
- $callback = $args[2] ?? null;
-
- $ch = curl_init($uri);
-
- $response = static::curlExecFollow($ch, $options, $callback);
- $errno = curl_errno($ch);
-
- if ($errno) {
- $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- $error_message = curl_strerror($errno) . "\n" . curl_error($ch);
-
- switch ($code) {
- case '404':
- throw new \RuntimeException('Page not found');
- case '401':
- throw new \RuntimeException('Invalid LICENSE');
- default:
- throw new \RuntimeException("Error while trying to download (code: $code): $uri \nMessage: $error_message");
- }
- }
-
- curl_close($ch);
-
- return $response;
- }
-
- /**
- * @param resource $ch
- * @param array $options
- * @param bool $callback
- *
- * @return bool|mixed
- */
- private static function curlExecFollow($ch, $options, $callback)
- {
- if ($callback) {
- curl_setopt_array(
- $ch,
- [
- CURLOPT_NOPROGRESS => false,
- CURLOPT_PROGRESSFUNCTION => ['self', 'progress']
- ]
- );
- }
-
- // no open_basedir set, we can proceed normally
- if (!ini_get('open_basedir')) {
- curl_setopt_array($ch, $options['curl']);
- return curl_exec($ch);
- }
-
- $max_redirects = $options['curl'][CURLOPT_MAXREDIRS] ?? 5;
- $options['curl'][CURLOPT_FOLLOWLOCATION] = false;
-
- // open_basedir set but no redirects to follow, we can disable followlocation and proceed normally
- curl_setopt_array($ch, $options['curl']);
- if ($max_redirects <= 0) {
- return curl_exec($ch);
- }
-
- $uri = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
- $rch = curl_copy_handle($ch);
-
- curl_setopt($rch, CURLOPT_HEADER, true);
- curl_setopt($rch, CURLOPT_NOBODY, true);
- curl_setopt($rch, CURLOPT_FORBID_REUSE, false);
- curl_setopt($rch, CURLOPT_RETURNTRANSFER, true);
-
- do {
- curl_setopt($rch, CURLOPT_URL, $uri);
- $header = curl_exec($rch);
-
- if (curl_errno($rch)) {
- $code = 0;
- } else {
- $code = (int)curl_getinfo($rch, CURLINFO_HTTP_CODE);
- if ($code === 301 || $code === 302 || $code === 303) {
- preg_match('/Location:(.*?)\n/', $header, $matches);
- $uri = trim(array_pop($matches));
- } else {
- $code = 0;
- }
- }
- } while ($code && --$max_redirects);
-
- curl_close($rch);
-
- if (!$max_redirects) {
- if ($max_redirects === null) {
- trigger_error('Too many redirects. When following redirects, libcurl hit the maximum amount.', E_USER_WARNING);
- }
-
- return false;
- }
-
- curl_setopt($ch, CURLOPT_URL, $uri);
-
- return curl_exec($ch);
- }
-}
+// Create alias for the deprecated class.
+class_alias(\Grav\Common\HTTP\Response::class, \Grav\Common\GPM\Response::class);
diff --git a/system/src/Grav/Common/GPM/Upgrader.php b/system/src/Grav/Common/GPM/Upgrader.php
index 5d9406d5a1..b3e68696d5 100644
--- a/system/src/Grav/Common/GPM/Upgrader.php
+++ b/system/src/Grav/Common/GPM/Upgrader.php
@@ -3,13 +3,14 @@
/**
* @package Grav\Common\GPM
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\GPM;
use Grav\Common\GPM\Remote\GravCore;
+use InvalidArgumentException;
/**
* Class Upgrader
@@ -18,21 +19,18 @@
*/
class Upgrader
{
- /**
- * Remote details about latest Grav version
- *
- * @var GravCore
- */
+ /** @var GravCore Remote details about latest Grav version */
private $remote;
+ /** @var string|null */
private $min_php;
/**
* Creates a new GPM instance with Local and Remote packages available
*
* @param boolean $refresh Applies to Remote Packages only and forces a refetch of data
- * @param callable $callback Either a function or callback in array notation
- * @throws \InvalidArgumentException
+ * @param callable|null $callback Either a function or callback in array notation
+ * @throws InvalidArgumentException
*/
public function __construct($refresh = false, $callback = null)
{
@@ -82,8 +80,7 @@ public function getAssets()
/**
* Returns the changelog list for each version of Grav
*
- * @param string $diff the version number to start the diff from
- *
+ * @param string|null $diff the version number to start the diff from
* @return array return the changelog list for each version
*/
public function getChangelog($diff = null)
@@ -98,8 +95,7 @@ public function getChangelog($diff = null)
*/
public function meetsRequirements()
{
- $current_php_version = phpversion();
- if (version_compare($current_php_version, $this->minPHPVersion(), '<')) {
+ if (version_compare(PHP_VERSION, $this->minPHPVersion(), '<')) {
return false;
}
@@ -109,20 +105,21 @@ public function meetsRequirements()
/**
* Get minimum PHP version from remote
*
- * @return null
+ * @return string
*/
public function minPHPVersion()
{
if (null === $this->min_php) {
$this->min_php = $this->remote->getMinPHPVersion();
}
+
return $this->min_php;
}
/**
* Checks if the currently installed Grav is upgradable to a newer version
*
- * @return boolean True if it's upgradable, False otherwise.
+ * @return bool True if it's upgradable, False otherwise.
*/
public function isUpgradable()
{
@@ -132,9 +129,8 @@ public function isUpgradable()
/**
* Checks if Grav is currently symbolically linked
*
- * @return boolean True if Grav is symlinked, False otherwise.
+ * @return bool True if Grav is symlinked, False otherwise.
*/
-
public function isSymlink()
{
return $this->remote->isSymlink();
diff --git a/system/src/Grav/Common/Getters.php b/system/src/Grav/Common/Getters.php
index e69116e15f..ed03499d13 100644
--- a/system/src/Grav/Common/Getters.php
+++ b/system/src/Grav/Common/Getters.php
@@ -3,27 +3,32 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
-abstract class Getters implements \ArrayAccess, \Countable
+use ArrayAccess;
+use Countable;
+use function count;
+
+/**
+ * Class Getters
+ * @package Grav\Common
+ */
+abstract class Getters implements ArrayAccess, Countable
{
- /**
- * Define variable used in getters.
- *
- * @var string
- */
+ /** @var string Define variable used in getters. */
protected $gettersVariable = null;
/**
* Magic setter method
*
- * @param mixed $offset Medium name value
+ * @param int|string $offset Medium name value
* @param mixed $value Medium value
*/
+ #[\ReturnTypeWillChange]
public function __set($offset, $value)
{
$this->offsetSet($offset, $value);
@@ -32,10 +37,10 @@ public function __set($offset, $value)
/**
* Magic getter method
*
- * @param mixed $offset Medium name value
- *
+ * @param int|string $offset Medium name value
* @return mixed Medium value
*/
+ #[\ReturnTypeWillChange]
public function __get($offset)
{
return $this->offsetGet($offset);
@@ -44,10 +49,10 @@ public function __get($offset)
/**
* Magic method to determine if the attribute is set
*
- * @param mixed $offset Medium name value
- *
+ * @param int|string $offset Medium name value
* @return boolean True if the value is set
*/
+ #[\ReturnTypeWillChange]
public function __isset($offset)
{
return $this->offsetExists($offset);
@@ -56,18 +61,19 @@ public function __isset($offset)
/**
* Magic method to unset the attribute
*
- * @param mixed $offset The name value to unset
+ * @param int|string $offset The name value to unset
*/
+ #[\ReturnTypeWillChange]
public function __unset($offset)
{
$this->offsetUnset($offset);
}
/**
- * @param mixed $offset
- *
+ * @param int|string $offset
* @return bool
*/
+ #[\ReturnTypeWillChange]
public function offsetExists($offset)
{
if ($this->gettersVariable) {
@@ -80,10 +86,10 @@ public function offsetExists($offset)
}
/**
- * @param mixed $offset
- *
+ * @param int|string $offset
* @return mixed
*/
+ #[\ReturnTypeWillChange]
public function offsetGet($offset)
{
if ($this->gettersVariable) {
@@ -96,9 +102,10 @@ public function offsetGet($offset)
}
/**
- * @param mixed $offset
+ * @param int|string $offset
* @param mixed $value
*/
+ #[\ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
if ($this->gettersVariable) {
@@ -110,8 +117,9 @@ public function offsetSet($offset, $value)
}
/**
- * @param mixed $offset
+ * @param int|string $offset
*/
+ #[\ReturnTypeWillChange]
public function offsetUnset($offset)
{
if ($this->gettersVariable) {
@@ -125,14 +133,15 @@ public function offsetUnset($offset)
/**
* @return int
*/
+ #[\ReturnTypeWillChange]
public function count()
{
if ($this->gettersVariable) {
$var = $this->gettersVariable;
- return \count($this->{$var});
+ return count($this->{$var});
}
- return \count($this->toArray());
+ return count($this->toArray());
}
/**
diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php
index 009d57de56..d2fce73512 100644
--- a/system/src/Grav/Common/Grav.php
+++ b/system/src/Grav/Common/Grav.php
@@ -3,25 +3,24 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
+use Composer\Autoload\ClassLoader;
use Grav\Common\Config\Config;
use Grav\Common\Config\Setup;
+use Grav\Common\Helpers\Exif;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Medium\ImageMedium;
use Grav\Common\Page\Medium\Medium;
+use Grav\Common\Page\Pages;
use Grav\Common\Processors\AssetsProcessor;
use Grav\Common\Processors\BackupsProcessor;
-use Grav\Common\Processors\ConfigurationProcessor;
use Grav\Common\Processors\DebuggerAssetsProcessor;
-use Grav\Common\Processors\DebuggerProcessor;
-use Grav\Common\Processors\ErrorsProcessor;
use Grav\Common\Processors\InitializeProcessor;
-use Grav\Common\Processors\LoggerProcessor;
use Grav\Common\Processors\PagesProcessor;
use Grav\Common\Processors\PluginsProcessor;
use Grav\Common\Processors\RenderProcessor;
@@ -30,13 +29,45 @@
use Grav\Common\Processors\TasksProcessor;
use Grav\Common\Processors\ThemesProcessor;
use Grav\Common\Processors\TwigProcessor;
+use Grav\Common\Scheduler\Scheduler;
+use Grav\Common\Service\AccountsServiceProvider;
+use Grav\Common\Service\AssetsServiceProvider;
+use Grav\Common\Service\BackupsServiceProvider;
+use Grav\Common\Service\ConfigServiceProvider;
+use Grav\Common\Service\ErrorServiceProvider;
+use Grav\Common\Service\FilesystemServiceProvider;
+use Grav\Common\Service\FlexServiceProvider;
+use Grav\Common\Service\InflectorServiceProvider;
+use Grav\Common\Service\LoggerServiceProvider;
+use Grav\Common\Service\OutputServiceProvider;
+use Grav\Common\Service\PagesServiceProvider;
+use Grav\Common\Service\RequestServiceProvider;
+use Grav\Common\Service\SessionServiceProvider;
+use Grav\Common\Service\StreamsServiceProvider;
+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;
+use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
-use RocketTheme\Toolbox\Event\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use function array_key_exists;
+use function call_user_func_array;
+use function function_exists;
+use function get_class;
+use function in_array;
+use function is_array;
+use function is_callable;
+use function is_int;
+use function is_string;
+use function strlen;
/**
* Grav container is the heart of Grav.
@@ -45,14 +76,10 @@
*/
class Grav extends Container
{
- /**
- * @var string Processed output for the page.
- */
+ /** @var string Processed output for the page. */
public $output;
- /**
- * @var static The singleton instance
- */
+ /** @var static The singleton instance */
protected static $instance;
/**
@@ -60,40 +87,38 @@ class Grav extends Container
* to the dependency injection container.
*/
protected static $diMap = [
- 'Grav\Common\Service\AccountsServiceProvider',
- 'Grav\Common\Service\AssetsServiceProvider',
- 'Grav\Common\Service\BackupsServiceProvider',
- 'Grav\Common\Service\ConfigServiceProvider',
- 'Grav\Common\Service\ErrorServiceProvider',
- 'Grav\Common\Service\FilesystemServiceProvider',
- 'Grav\Common\Service\InflectorServiceProvider',
- 'Grav\Common\Service\LoggerServiceProvider',
- 'Grav\Common\Service\OutputServiceProvider',
- 'Grav\Common\Service\PagesServiceProvider',
- 'Grav\Common\Service\RequestServiceProvider',
- 'Grav\Common\Service\SessionServiceProvider',
- 'Grav\Common\Service\StreamsServiceProvider',
- 'Grav\Common\Service\TaskServiceProvider',
- 'browser' => 'Grav\Common\Browser',
- 'cache' => 'Grav\Common\Cache',
- 'events' => 'RocketTheme\Toolbox\Event\EventDispatcher',
- 'exif' => 'Grav\Common\Helpers\Exif',
- 'plugins' => 'Grav\Common\Plugins',
- 'scheduler' => 'Grav\Common\Scheduler\Scheduler',
- 'taxonomy' => 'Grav\Common\Taxonomy',
- 'themes' => 'Grav\Common\Themes',
- 'twig' => 'Grav\Common\Twig\Twig',
- 'uri' => 'Grav\Common\Uri',
+ AccountsServiceProvider::class,
+ AssetsServiceProvider::class,
+ BackupsServiceProvider::class,
+ ConfigServiceProvider::class,
+ ErrorServiceProvider::class,
+ FilesystemServiceProvider::class,
+ FlexServiceProvider::class,
+ InflectorServiceProvider::class,
+ LoggerServiceProvider::class,
+ OutputServiceProvider::class,
+ PagesServiceProvider::class,
+ RequestServiceProvider::class,
+ SessionServiceProvider::class,
+ StreamsServiceProvider::class,
+ TaskServiceProvider::class,
+ 'browser' => Browser::class,
+ 'cache' => Cache::class,
+ 'events' => EventDispatcher::class,
+ 'exif' => Exif::class,
+ 'plugins' => Plugins::class,
+ 'scheduler' => Scheduler::class,
+ 'taxonomy' => Taxonomy::class,
+ 'themes' => Themes::class,
+ 'twig' => Twig::class,
+ 'uri' => Uri::class,
];
/**
* @var array All middleware processors that are processed in $this->process()
*/
protected $middleware = [
- 'configurationProcessor',
- 'loggerProcessor',
- 'errorsProcessor',
- 'debuggerProcessor',
+ 'multipartRequestSupport',
'initializeProcessor',
'pluginsProcessor',
'themesProcessor',
@@ -108,14 +133,18 @@ class Grav extends Container
'renderProcessor',
];
+ /** @var array */
protected $initialized = [];
/**
* Reset the Grav instance.
+ *
+ * @return void
*/
- public static function resetInstance()
+ public static function resetInstance(): void
{
if (self::$instance) {
+ // @phpstan-ignore-next-line
self::$instance = null;
}
}
@@ -124,13 +153,19 @@ public static function resetInstance()
* Return the Grav instance. Create it if it's not already instanced
*
* @param array $values
- *
* @return Grav
*/
public static function instance(array $values = [])
{
- if (!self::$instance) {
+ if (null === self::$instance) {
self::$instance = static::load($values);
+
+ /** @var ClassLoader|null $loader */
+ $loader = self::$instance['loader'] ?? null;
+ if ($loader) {
+ // Load fix for Deferred Twig Extension
+ $loader->addPsr4('Phive\\Twig\\Extensions\\Deferred\\', LIB_DIR . 'Phive/Twig/Extensions/Deferred/', true);
+ }
} elseif ($values) {
$instance = self::$instance;
foreach ($values as $key => $value) {
@@ -142,9 +177,25 @@ public static function instance(array $values = [])
}
/**
- * Setup Grav instance using specific environment.
+ * Get Grav version.
*
- * Initializes Grav streams by
+ * @return string
+ */
+ public function getVersion(): string
+ {
+ return GRAV_VERSION;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isSetup(): bool
+ {
+ return isset($this->initialized['setup']);
+ }
+
+ /**
+ * Setup Grav instance using specific environment.
*
* @param string|null $environment
* @return $this
@@ -157,23 +208,47 @@ public function setup(string $environment = null)
$this->initialized['setup'] = true;
- $this->measureTime('_setup', 'Site Setup', function () use ($environment) {
- // Force environment if passed to the method.
- if ($environment) {
- Setup::$environment = $environment;
- }
+ // Force environment if passed to the method.
+ if ($environment) {
+ Setup::$environment = $environment;
+ }
- $this['setup'];
- $this['streams'];
- });
+ // Initialize setup and streams.
+ $this['setup'];
+ $this['streams'];
+
+ return $this;
+ }
+
+ /**
+ * Initialize CLI environment.
+ *
+ * Call after `$grav->setup($environment)`
+ *
+ * - Load configuration
+ * - Initialize logger
+ * - Disable debugger
+ * - Set timezone, locale
+ * - Load plugins (call PluginsLoadedEvent)
+ * - Set Pages and Users type to be used in the site
+ *
+ * This method WILL NOT initialize assets, twig or pages.
+ *
+ * @return $this
+ */
+ public function initializeCli()
+ {
+ InitializeProcessor::initializeCli($this);
return $this;
}
/**
* Process a request
+ *
+ * @return void
*/
- public function process()
+ public function process(): void
{
if (isset($this->initialized['process'])) {
return;
@@ -186,17 +261,8 @@ public function process()
$container = new Container(
[
- 'configurationProcessor' => function () {
- return new ConfigurationProcessor($this);
- },
- 'loggerProcessor' => function () {
- return new LoggerProcessor($this);
- },
- 'errorsProcessor' => function () {
- return new ErrorsProcessor($this);
- },
- 'debuggerProcessor' => function () {
- return new DebuggerProcessor($this);
+ 'multipartRequestSupport' => function () {
+ return new MultipartRequestSupport();
},
'initializeProcessor' => function () {
return new InitializeProcessor($this);
@@ -237,80 +303,201 @@ public function process()
]
);
- $default = function (ServerRequestInterface $request) {
- return new Response(404);
+ $default = static function () {
+ return new Response(404, ['Expires' => 0, 'Cache-Control' => 'no-store, max-age=0'], 'Not Found');
};
- /** @var Debugger $debugger */
- $debugger = $this['debugger'];
-
$collection = new RequestHandler($this->middleware, $default, $container);
$response = $collection->handle($this['request']);
+ $body = $response->getBody();
+
+ /** @var Messages $messages */
+ $messages = $this['messages'];
+
+ // Prevent caching if session messages were displayed in the page.
+ $noCache = $messages->isCleared();
+ if ($noCache) {
+ $response = $response->withHeader('Cache-Control', 'no-store, max-age=0');
+ }
+
+ // Handle ETag and If-None-Match headers.
+ if ($response->getHeaderLine('ETag') === '1') {
+ $etag = md5($body);
+ $response = $response->withHeader('ETag', '"' . $etag . '"');
+
+ $search = trim($this['request']->getHeaderLine('If-None-Match'), '"');
+ if ($noCache === false && $search === $etag) {
+ $response = $response->withStatus(304);
+ $body = '';
+ }
+ }
+ // Echo page content.
$this->header($response);
- echo $response->getBody();
+ echo $body;
- $debugger->render();
+ $this['debugger']->render();
- register_shutdown_function([$this, 'shutdown']);
+ // Response object can turn off all shutdown processing. This can be used for example to speed up AJAX responses.
+ // Note that using this feature will also turn off response compression.
+ if ($response->getHeaderLine('Grav-Internal-SkipShutdown') !== '1') {
+ register_shutdown_function([$this, 'shutdown']);
+ }
}
/**
- * Set the system locale based on the language and configuration
+ * 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 setLocale()
+ public function cleanOutputBuffers(): void
{
- // Initialize Locale if set and configured.
- if ($this['language']->enabled() && $this['config']->get('system.languages.override_locale')) {
- $language = $this['language']->getLanguage();
- setlocale(LC_ALL, \strlen($language) < 3 ? ($language . '_' . strtoupper($language)) : $language);
- } elseif ($this['config']->get('system.default_locale')) {
- setlocale(LC_ALL, $this['config']->get('system.default_locale'));
+ // 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');
}
/**
- * Redirect browser to another location.
+ * Terminates Grav request with a response.
*
- * @param string $route Internal route.
- * @param int $code Redirection code (30x)
+ * Please use this method instead of calling `die();` or `exit();`. Note that you need to create a response object.
+ *
+ * @param ResponseInterface $response
+ * @return never-return
*/
- public function redirect($route, $code = null)
+ public function close(ResponseInterface $response): void
{
- /** @var Uri $uri */
- $uri = $this['uri'];
+ $this->cleanOutputBuffers();
- //Check for code in route
- $regex = '/.*(\[(30[1-7])\])$/';
- preg_match($regex, $route, $matches);
- if ($matches) {
- $route = str_replace($matches[1], '', $matches[0]);
- $code = $matches[2];
+ // Close the session.
+ if (isset($this['session'])) {
+ $this['session']->close();
}
- if ($code === null) {
- $code = $this['config']->get('system.pages.redirect_default_code', 302);
+ /** @var ServerRequestInterface $request */
+ $request = $this['request'];
+
+ /** @var Debugger $debugger */
+ $debugger = $this['debugger'];
+ $response = $debugger->logRequest($request, $response);
+
+ $body = $response->getBody();
+
+ /** @var Messages $messages */
+ $messages = $this['messages'];
+
+ // Prevent caching if session messages were displayed in the page.
+ $noCache = $messages->isCleared();
+ if ($noCache) {
+ $response = $response->withHeader('Cache-Control', 'no-store, max-age=0');
}
- if (isset($this['session'])) {
- $this['session']->close();
+ // Handle ETag and If-None-Match headers.
+ if ($response->getHeaderLine('ETag') === '1') {
+ $etag = md5($body);
+ $response = $response->withHeader('ETag', '"' . $etag . '"');
+
+ $search = trim($this['request']->getHeaderLine('If-None-Match'), '"');
+ if ($noCache === false && $search === $etag) {
+ $response = $response->withStatus(304);
+ $body = '';
+ }
}
- if ($uri->isExternal($route)) {
- $url = $route;
- } else {
- $url = rtrim($uri->rootUrl(), '/') . '/';
+ // Echo page content.
+ $this->header($response);
+ echo $body;
+ exit();
+ }
- if ($this['config']->get('system.pages.redirect_trailing_slash', true)) {
- $url .= trim($route, '/'); // Remove trailing slash
+ /**
+ * @param ResponseInterface $response
+ * @return never-return
+ * @deprecated 1.7 Use $grav->close() instead.
+ */
+ public function exit(ResponseInterface $response): void
+ {
+ $this->close($response);
+ }
+
+ /**
+ * Terminates Grav request and redirects browser to another location.
+ *
+ * Please use this method instead of calling `header("Location: {$url}", true, 302); exit();`.
+ *
+ * @param Route|string $route Internal route.
+ * @param int|null $code Redirection code (30x)
+ * @return never-return
+ */
+ public function redirect($route, $code = null): void
+ {
+ $response = $this->getRedirectResponse($route, $code);
+
+ $this->close($response);
+ }
+
+ /**
+ * Returns redirect response object from Grav.
+ *
+ * @param Route|string $route Internal route.
+ * @param int|null $code Redirection code (30x)
+ * @return ResponseInterface
+ */
+ public function getRedirectResponse($route, $code = null): ResponseInterface
+ {
+ /** @var Uri $uri */
+ $uri = $this['uri'];
+
+ if (is_string($route)) {
+ // Clean route for redirect
+ $route = preg_replace("#^\/[\\\/]+\/#", '/', $route);
+
+ if (null === $code) {
+ // Check for redirect code in the route: e.g. /new/[301], /new[301]/route or /new[301].html
+ $regex = '/.*(\[(30[1-7])\])(.\w+|\/.*?)?$/';
+ preg_match($regex, $route, $matches);
+ if ($matches) {
+ $route = str_replace($matches[1], '', $matches[0]);
+ $code = $matches[2];
+ }
+ }
+
+ if ($uri::isExternal($route)) {
+ $url = $route;
} else {
- $url .= ltrim($route, '/'); // Support trailing slash default routes
+ $url = rtrim($uri->rootUrl(), '/') . '/';
+
+ if ($this['config']->get('system.pages.redirect_trailing_slash', true)) {
+ $url .= trim($route, '/'); // Remove trailing slash
+ } else {
+ $url .= ltrim($route, '/'); // Support trailing slash default routes
+ }
}
+ } elseif ($route instanceof Route) {
+ $url = $route->toString(true);
+ } else {
+ throw new InvalidArgumentException('Bad $route');
}
- header("Location: {$url}", true, $code);
- exit();
+ if ($code < 300 || $code > 399) {
+ $code = null;
+ }
+
+ if ($code === null) {
+ $code = $this['config']->get('system.pages.redirect_default_code', 302);
+ }
+
+ if ($uri->extension() === 'json') {
+ return new Response(200, ['Content-Type' => 'application/json'], json_encode(['code' => $code, 'redirect' => $url], JSON_THROW_ON_ERROR));
+ }
+
+ return new Response($code, ['Location' => $url]);
}
/**
@@ -318,8 +505,9 @@ public function redirect($route, $code = null)
*
* @param string $route Internal route.
* @param int $code Redirection code (30x)
+ * @return void
*/
- public function redirectLangSafe($route, $code = null)
+ public function redirectLangSafe($route, $code = null): void
{
if (!$this['uri']->isExternal($route)) {
$this->redirect($this['pages']->route($route), $code);
@@ -332,8 +520,9 @@ public function redirectLangSafe($route, $code = null)
* Set response header.
*
* @param ResponseInterface|null $response
+ * @return void
*/
- public function header(ResponseInterface $response = null)
+ public function header(ResponseInterface $response = null): void
{
if (null === $response) {
/** @var PageInterface $page */
@@ -343,36 +532,86 @@ public function header(ResponseInterface $response = null)
header("HTTP/{$response->getProtocolVersion()} {$response->getStatusCode()} {$response->getReasonPhrase()}");
foreach ($response->getHeaders() as $key => $values) {
+ // Skip internal Grav headers.
+ if (strpos($key, 'Grav-Internal-') === 0) {
+ continue;
+ }
foreach ($values as $i => $value) {
header($key . ': ' . $value, $i === 0);
}
}
}
+ /**
+ * Set the system locale based on the language and configuration
+ *
+ * @return void
+ */
+ public function setLocale(): void
+ {
+ // Initialize Locale if set and configured.
+ if ($this['language']->enabled() && $this['config']->get('system.languages.override_locale')) {
+ $language = $this['language']->getLanguage();
+ setlocale(LC_ALL, strlen($language) < 3 ? ($language . '_' . strtoupper($language)) : $language);
+ } elseif ($this['config']->get('system.default_locale')) {
+ setlocale(LC_ALL, $this['config']->get('system.default_locale'));
+ }
+ }
+
+ /**
+ * @param object $event
+ * @return object
+ */
+ public function dispatchEvent($event)
+ {
+ /** @var EventDispatcherInterface $events */
+ $events = $this['events'];
+ $eventName = get_class($event);
+
+ $timestamp = microtime(true);
+ $event = $events->dispatch($event);
+
+ /** @var Debugger $debugger */
+ $debugger = $this['debugger'];
+ $debugger->addEvent($eventName, $event, $events, $timestamp);
+
+ return $event;
+ }
+
/**
* Fires an event with optional parameters.
*
* @param string $eventName
- * @param Event $event
- *
+ * @param Event|null $event
* @return Event
*/
public function fireEvent($eventName, Event $event = null)
{
- /** @var EventDispatcher $events */
+ /** @var EventDispatcherInterface $events */
$events = $this['events'];
+ if (null === $event) {
+ $event = new Event();
+ }
- return $events->dispatch($eventName, $event);
+ $timestamp = microtime(true);
+ $events->dispatch($event, $eventName);
+
+ /** @var Debugger $debugger */
+ $debugger = $this['debugger'];
+ $debugger->addEvent($eventName, $event, $events, $timestamp);
+
+ return $event;
}
/**
* Set the final content length for the page and flush the buffer
*
+ * @return void
*/
- public function shutdown()
+ public function shutdown(): void
{
// Prevent user abort allowing onShutdown event to run without interruptions.
- if (\function_exists('ignore_user_abort')) {
+ if (function_exists('ignore_user_abort')) {
@ignore_user_abort(true);
}
@@ -381,32 +620,33 @@ public function shutdown()
$this['session']->close();
}
- if ($this['config']->get('system.debugger.shutdown.close_connection', true)) {
+ /** @var Config $config */
+ $config = $this['config'];
+ if ($config->get('system.debugger.shutdown.close_connection', true)) {
// Flush the response and close the connection to allow time consuming tasks to be performed without leaving
// the connection to the client open. This will make page loads to feel much faster.
// FastCGI allows us to flush all response data to the client and finish the request.
- $success = \function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false;
-
+ $success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false;
if (!$success) {
// Unfortunately without FastCGI there is no way to force close the connection.
// We need to ask browser to close the connection for us.
- if ($this['config']->get('system.cache.gzip')) {
- // Flush gzhandler buffer if gzip setting was enabled.
- ob_end_flush();
- } else {
+ if ($config->get('system.cache.gzip')) {
+ // Flush gzhandler buffer if gzip setting was enabled to get the size of the compressed output.
+ ob_end_flush();
+ } elseif ($config->get('system.cache.allow_webserver_gzip')) {
+ // Let web server to do the hard work.
+ header('Content-Encoding: identity');
+ } elseif (function_exists('apache_setenv')) {
// Without gzip we have no other choice than to prevent server from compressing the output.
// This action turns off mod_deflate which would prevent us from closing the connection.
- if ($this['config']->get('system.cache.allow_webserver_gzip')) {
- header('Content-Encoding: identity');
- } else {
- header('Content-Encoding: none');
- }
-
+ @apache_setenv('no-gzip', '1');
+ } else {
+ // Fall back to unknown content encoding, it prevents most servers from deflating the content.
+ header('Content-Encoding: none');
}
-
// Get length and close the connection.
header('Content-Length: ' . ob_get_length());
header('Connection: close');
@@ -427,11 +667,17 @@ public function shutdown()
* Used to call closures.
*
* Source: http://stackoverflow.com/questions/419804/closures-as-class-members
+ *
+ * @param string $method
+ * @param array $args
+ * @return mixed|null
*/
+ #[\ReturnTypeWillChange]
public function __call($method, $args)
{
- $closure = $this->{$method};
- \call_user_func_array($closure, $args);
+ $closure = $this->{$method} ?? null;
+
+ return is_callable($closure) ? $closure(...$args) : null;
}
/**
@@ -456,7 +702,6 @@ public function measureTime(string $timerId, string $timerTitle, callable $callb
* Initialize and return a Grav instance
*
* @param array $values
- *
* @return static
*/
protected static function load(array $values)
@@ -470,9 +715,7 @@ protected static function load(array $values)
return $container;
};
- $container->measureTime('_services', 'Services', function () use ($container) {
- $container->registerServices();
- });
+ $container->registerServices();
return $container;
}
@@ -485,10 +728,10 @@ protected static function load(array $values)
*
* @return void
*/
- protected function registerServices()
+ protected function registerServices(): void
{
foreach (self::$diMap as $serviceKey => $serviceClass) {
- if (\is_int($serviceKey)) {
+ if (is_int($serviceKey)) {
$this->register(new $serviceClass);
} else {
$this[$serviceKey] = function ($c) use ($serviceClass) {
@@ -502,10 +745,14 @@ protected function registerServices()
* This attempts to find media, other files, and download them
*
* @param string $path
+ * @return PageInterface|false
*/
public function fallbackUrl($path)
{
- $this->fireEvent('onPageFallBackUrl');
+ $path_parts = Utils::pathinfo($path);
+ if (!is_array($path_parts)) {
+ return false;
+ }
/** @var Uri $uri */
$uri = $this['uri'];
@@ -513,35 +760,46 @@ public function fallbackUrl($path)
/** @var Config $config */
$config = $this['config'];
- $uri_extension = strtolower($uri->extension());
- $fallback_types = $config->get('system.media.allowed_fallback_types', null);
+ /** @var Pages $pages */
+ $pages = $this['pages'];
+ $page = $pages->find($path_parts['dirname'], true);
+
+ $uri_extension = strtolower($uri->extension() ?? '');
+ $fallback_types = $config->get('system.media.allowed_fallback_types');
$supported_types = $config->get('media.types');
+ $parsed_url = parse_url(rawurldecode($uri->basename()));
+ $media_file = $parsed_url['path'];
+
+ $event = new Event([
+ 'uri' => $uri,
+ 'page' => &$page,
+ 'filename' => &$media_file,
+ 'extension' => $uri_extension,
+ 'allowed_fallback_types' => &$fallback_types,
+ 'media_types' => &$supported_types
+ ]);
+
+ $this->fireEvent('onPageFallBackUrl', $event);
+
// Check whitelist first, then ensure extension is a valid media type
- if (!empty($fallback_types) && !\in_array($uri_extension, $fallback_types, true)) {
+ if (!empty($fallback_types) && !in_array($uri_extension, $fallback_types, true)) {
return false;
}
if (!array_key_exists($uri_extension, $supported_types)) {
return false;
}
- $path_parts = pathinfo($path);
-
- /** @var PageInterface $page */
- $page = $this['pages']->dispatch($path_parts['dirname'], true);
-
if ($page) {
$media = $page->media()->all();
- $parsed_url = parse_url(rawurldecode($uri->basename()));
- $media_file = $parsed_url['path'];
// if this is a media object, try actions first
if (isset($media[$media_file])) {
/** @var Medium $medium */
$medium = $media[$media_file];
foreach ($uri->query(null, true) as $action => $params) {
- if (\in_array($action, ImageMedium::$magic_actions, true)) {
- \call_user_func_array([&$medium, $action], explode(',', $params));
+ if (in_array($action, ImageMedium::$magic_actions, true)) {
+ call_user_func_array([&$medium, $action], explode(',', $params));
}
}
Utils::download($medium->path(), false);
@@ -550,26 +808,22 @@ public function fallbackUrl($path)
// unsupported media type, try to download it...
if ($uri_extension) {
$extension = $uri_extension;
+ } elseif (isset($path_parts['extension'])) {
+ $extension = $path_parts['extension'];
} else {
- if (isset($path_parts['extension'])) {
- $extension = $path_parts['extension'];
- } else {
- $extension = null;
- }
+ $extension = null;
}
if ($extension) {
$download = true;
- if (\in_array(ltrim($extension, '.'), $config->get('system.media.unsupported_inline_types', []), true)) {
+ if (in_array(ltrim($extension, '.'), $config->get('system.media.unsupported_inline_types', []), true)) {
$download = false;
}
Utils::download($page->path() . DIRECTORY_SEPARATOR . $uri->basename(), $download);
}
-
- // Nothing found
- return false;
}
- return $page;
+ // Nothing found
+ return false;
}
}
diff --git a/system/src/Grav/Common/GravTrait.php b/system/src/Grav/Common/GravTrait.php
index a82c23a9ed..5416286127 100644
--- a/system/src/Grav/Common/GravTrait.php
+++ b/system/src/Grav/Common/GravTrait.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -14,6 +14,7 @@
*/
trait GravTrait
{
+ /** @var Grav */
protected static $grav;
/**
@@ -24,7 +25,7 @@ public static function getGrav()
{
user_error(__TRAIT__ . ' is deprecated since Grav 1.4, use Grav::instance() instead', E_USER_DEPRECATED);
- if (!self::$grav) {
+ if (null === self::$grav) {
self::$grav = Grav::instance();
}
diff --git a/system/src/Grav/Common/HTTP/Client.php b/system/src/Grav/Common/HTTP/Client.php
new file mode 100644
index 0000000000..c85d6b2957
--- /dev/null
+++ b/system/src/Grav/Common/HTTP/Client.php
@@ -0,0 +1,130 @@
+ 'Grav CMS'
+ ];
+
+ public static function getClient(array $overrides = [], int $connections = 6, callable $callback = null): HttpClientInterface
+ {
+ $config = Grav::instance()['config'];
+ $options = static::getOptions();
+
+ // Use callback if provided
+ if ($callback) {
+ self::$callback = $callback;
+ $options->setOnProgress([Client::class, 'progress']);
+ }
+
+ $settings = array_merge($options->toArray(), $overrides);
+ $preferred_method = $config->get('system.http.method');
+ // Try old GPM setting if value is the same as system default
+ if ($preferred_method === 'auto') {
+ $preferred_method = $config->get('system.gpm.method', 'auto');
+ }
+
+ switch ($preferred_method) {
+ case 'curl':
+ $client = new CurlHttpClient($settings, $connections);
+ break;
+ case 'fopen':
+ case 'native':
+ $client = new NativeHttpClient($settings, $connections);
+ break;
+ default:
+ $client = HttpClient::create($settings, $connections);
+ }
+
+ return $client;
+ }
+
+ /**
+ * Get HTTP Options
+ *
+ * @return HttpOptions
+ */
+ public static function getOptions(): HttpOptions
+ {
+ $config = Grav::instance()['config'];
+ $referer = defined('GRAV_CLI') ? 'grav_cli' : Grav::instance()['uri']->rootUrl(true);
+
+ $options = new HttpOptions();
+
+ // Set default Headers
+ $options->setHeaders(array_merge([ 'Referer' => $referer ], self::$headers));
+
+ // Disable verify Peer if required
+ $verify_peer = $config->get('system.http.verify_peer');
+ // Try old GPM setting if value is default
+ if ($verify_peer === true) {
+ $verify_peer = $config->get('system.gpm.verify_peer', null) ?? $verify_peer;
+ }
+ $options->verifyPeer($verify_peer);
+
+ // Set verify Host
+ $verify_host = $config->get('system.http.verify_host', true);
+ $options->verifyHost($verify_host);
+
+ // New setting and must be enabled for Proxy to work
+ if ($config->get('system.http.enable_proxy', true)) {
+ // Set proxy url if provided
+ $proxy_url = $config->get('system.http.proxy_url', $config->get('system.gpm.proxy_url', null));
+ if ($proxy_url !== null) {
+ $options->setProxy($proxy_url);
+ }
+
+ // Certificate
+ $proxy_cert = $config->get('system.http.proxy_cert_path', null);
+ if ($proxy_cert !== null) {
+ $options->setCaPath($proxy_cert);
+ }
+ }
+
+ return $options;
+ }
+
+ /**
+ * Progress normalized for cURL and Fopen
+ * Accepts a variable length of arguments passed in by stream method
+ *
+ * @return void
+ */
+ public static function progress(int $bytes_transferred, int $filesize, array $info)
+ {
+
+ if ($bytes_transferred > 0) {
+ $percent = $filesize <= 0 ? 0 : (int)(($bytes_transferred * 100) / $filesize);
+
+ $progress = [
+ 'code' => $info['http_code'],
+ 'filesize' => $filesize,
+ 'transferred' => $bytes_transferred,
+ 'percent' => $percent < 100 ? $percent : 100
+ ];
+
+ if (self::$callback !== null) {
+ call_user_func(self::$callback, $progress);
+ }
+ }
+ }
+}
diff --git a/system/src/Grav/Common/HTTP/Response.php b/system/src/Grav/Common/HTTP/Response.php
new file mode 100644
index 0000000000..4a5bc8bb2e
--- /dev/null
+++ b/system/src/Grav/Common/HTTP/Response.php
@@ -0,0 +1,96 @@
+getContent();
+ }
+
+
+ /**
+ * Makes a request to the URL by using the preferred method
+ *
+ * @param string $method method to call such as GET, PUT, etc
+ * @param string $uri URL to call
+ * @param array $overrides An array of parameters for both `curl` and `fopen`
+ * @param callable|null $callback Either a function or callback in array notation
+ * @return ResponseInterface
+ * @throws TransportExceptionInterface
+ */
+ public static function request(string $method, string $uri, array $overrides = [], callable $callback = null): ResponseInterface
+ {
+ if (empty($method)) {
+ throw new TransportException('missing method (GET, PUT, etc.)');
+ }
+
+ if (empty($uri)) {
+ throw new TransportException('missing URI');
+ }
+
+ // check if this function is available, if so use it to stop any timeouts
+ try {
+ if (Utils::functionExists('set_time_limit')) {
+ @set_time_limit(0);
+ }
+ } catch (Exception $e) {}
+
+ $client = Client::getClient($overrides, 6, $callback);
+
+ return $client->request($method, $uri);
+ }
+
+
+ /**
+ * Is this a remote file or not
+ *
+ * @param string $file
+ * @return bool
+ */
+ public static function isRemote($file): bool
+ {
+ return (bool) filter_var($file, FILTER_VALIDATE_URL);
+ }
+
+
+}
diff --git a/system/src/Grav/Common/Helpers/Base32.php b/system/src/Grav/Common/Helpers/Base32.php
index f138557027..0d1c21ae0b 100644
--- a/system/src/Grav/Common/Helpers/Base32.php
+++ b/system/src/Grav/Common/Helpers/Base32.php
@@ -3,15 +3,26 @@
/**
* @package Grav\Common\Helpers
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Helpers;
+use function chr;
+use function count;
+use function ord;
+use function strlen;
+
+/**
+ * Class Base32
+ * @package Grav\Common\Helpers
+ */
class Base32
{
+ /** @var string */
protected static $base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+ /** @var array */
protected static $base32Lookup = [
0xFF,0xFF,0x1A,0x1B,0x1C,0x1D,0x1E,0x1F, // '0', '1', '2', '3', '4', '5', '6', '7'
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, // '8', '9', ':', ';', '<', '=', '>', '?'
@@ -33,17 +44,18 @@ class Base32
*/
public static function encode($bytes)
{
- $i = 0; $index = 0;
+ $i = 0;
+ $index = 0;
$base32 = '';
- $bytesLen = \strlen($bytes);
+ $bytesLen = strlen($bytes);
while ($i < $bytesLen) {
- $currByte = \ord($bytes[$i]);
+ $currByte = ord($bytes[$i]);
/* Is the current digit going to span a byte boundary? */
if ($index > 3) {
if (($i + 1) < $bytesLen) {
- $nextByte = \ord($bytes[$i+1]);
+ $nextByte = ord($bytes[$i+1]);
} else {
$nextByte = 0;
}
@@ -75,15 +87,15 @@ public static function encode($bytes)
public static function decode($base32)
{
$bytes = [];
- $base32Len = \strlen($base32);
- $base32LookupLen = \count(self::$base32Lookup);
+ $base32Len = strlen($base32);
+ $base32LookupLen = count(self::$base32Lookup);
for ($i = $base32Len * 5 / 8 - 1; $i >= 0; --$i) {
$bytes[] = 0;
}
for ($i = 0, $index = 0, $offset = 0; $i < $base32Len; $i++) {
- $lookup = \ord($base32[$i]) - \ord('0');
+ $lookup = ord($base32[$i]) - ord('0');
/* Skip chars outside the lookup table */
if ($lookup < 0 || $lookup >= $base32LookupLen) {
@@ -102,7 +114,7 @@ public static function decode($base32)
if ($index === 0) {
$bytes[$offset] |= $digit;
$offset++;
- if ($offset >= \count($bytes)) {
+ if ($offset >= count($bytes)) {
break;
}
} else {
@@ -112,7 +124,7 @@ public static function decode($base32)
$index = ($index + 5) % 8;
$bytes[$offset] |= ($digit >> $index);
$offset++;
- if ($offset >= \count($bytes)) {
+ if ($offset >= count($bytes)) {
break;
}
$bytes[$offset] |= $digit << (8 - $index);
@@ -121,7 +133,7 @@ public static function decode($base32)
$bites = '';
foreach ($bytes as $byte) {
- $bites .= \chr($byte);
+ $bites .= chr($byte);
}
return $bites;
diff --git a/system/src/Grav/Common/Helpers/Excerpts.php b/system/src/Grav/Common/Helpers/Excerpts.php
index f07c3aa5ab..a62c7e5f43 100644
--- a/system/src/Grav/Common/Helpers/Excerpts.php
+++ b/system/src/Grav/Common/Helpers/Excerpts.php
@@ -3,16 +3,24 @@
/**
* @package Grav\Common\Helpers
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Helpers;
+use DOMDocument;
+use DOMElement;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Markdown\Excerpts as ExcerptsObject;
+use Grav\Common\Page\Medium\Link;
use Grav\Common\Page\Medium\Medium;
+use function is_array;
+/**
+ * Class Excerpts
+ * @package Grav\Common\Helpers
+ */
class Excerpts
{
/**
@@ -25,6 +33,9 @@ class Excerpts
public static function processImageHtml($html, PageInterface $page = null)
{
$excerpt = static::getExcerptFromHtml($html, 'img');
+ if (null === $excerpt) {
+ return '';
+ }
$original_src = $excerpt['element']['attributes']['src'];
$excerpt['element']['attributes']['href'] = $original_src;
@@ -32,7 +43,7 @@ public static function processImageHtml($html, PageInterface $page = null)
$excerpt = static::processLinkExcerpt($excerpt, $page, 'image');
$excerpt['element']['attributes']['src'] = $excerpt['element']['attributes']['href'];
- unset ($excerpt['element']['attributes']['href']);
+ unset($excerpt['element']['attributes']['href']);
$excerpt = static::processImageExcerpt($excerpt, $page);
@@ -43,6 +54,29 @@ public static function processImageHtml($html, PageInterface $page = null)
return $html;
}
+ /**
+ * Process Grav page link URL from HTML tag
+ *
+ * @param string $html HTML tag e.g. `Page Link`
+ * @param PageInterface|null $page Page, defaults to the current page object
+ * @return string Returns final HTML string
+ */
+ public static function processLinkHtml($html, PageInterface $page = null)
+ {
+ $excerpt = static::getExcerptFromHtml($html, 'a');
+ if (null === $excerpt) {
+ return '';
+ }
+
+ $original_href = $excerpt['element']['attributes']['href'];
+ $excerpt = static::processLinkExcerpt($excerpt, $page, 'link');
+ $excerpt['element']['attributes']['data-href'] = $original_href;
+
+ $html = static::getHtmlFromExcerpt($excerpt);
+
+ return $html;
+ }
+
/**
* Get an Excerpt array from a chunk of HTML
*
@@ -52,22 +86,34 @@ public static function processImageHtml($html, PageInterface $page = null)
*/
public static function getExcerptFromHtml($html, $tag)
{
- $doc = new \DOMDocument();
- $doc->loadHTML($html);
- $images = $doc->getElementsByTagName($tag);
+ $doc = new DOMDocument('1.0', 'UTF-8');
+ $internalErrors = libxml_use_internal_errors(true);
+ $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
+ libxml_use_internal_errors($internalErrors);
+
+ $elements = $doc->getElementsByTagName($tag);
$excerpt = null;
+ $inner = [];
- foreach ($images as $image) {
+ foreach ($elements as $element) {
$attributes = [];
- foreach ($image->attributes as $name => $value) {
+ foreach ($element->attributes as $name => $value) {
$attributes[$name] = $value->value;
}
$excerpt = [
'element' => [
- 'name' => $image->tagName,
+ 'name' => $element->tagName,
'attributes' => $attributes
]
];
+
+ foreach ($element->childNodes as $node) {
+ $inner[] = $doc->saveHTML($node);
+ }
+
+ $excerpt = array_merge_recursive($excerpt, ['element' => ['text' => implode('', $inner)]]);
+
+
}
return $excerpt;
@@ -95,7 +141,7 @@ public static function getHtmlFromExcerpt($excerpt)
if (isset($element['text'])) {
$html .= '>';
- $html .= $element['text'];
+ $html .= is_array($element['text']) ? static::getHtmlFromExcerpt(['element' => $element['text']]) : $element['text'];
$html .= ''.$element['name'].'>';
} else {
$html .= ' />';
@@ -139,7 +185,7 @@ public static function processImageExcerpt(array $excerpt, PageInterface $page =
* @param Medium $medium
* @param string|array $url
* @param PageInterface|null $page Page, defaults to the current page object
- * @return Medium
+ * @return Medium|Link
*/
public static function processMediaActions($medium, $url, PageInterface $page = null)
{
diff --git a/system/src/Grav/Common/Helpers/Exif.php b/system/src/Grav/Common/Helpers/Exif.php
index 15aff66777..a7dc973307 100644
--- a/system/src/Grav/Common/Helpers/Exif.php
+++ b/system/src/Grav/Common/Helpers/Exif.php
@@ -3,39 +3,46 @@
/**
* @package Grav\Common\Helpers
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Helpers;
use Grav\Common\Grav;
+use PHPExif\Reader\Reader;
+use RuntimeException;
+use function function_exists;
+/**
+ * Class Exif
+ * @package Grav\Common\Helpers
+ */
class Exif
{
+ /** @var Reader */
public $reader;
/**
* Exif constructor.
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
public function __construct()
{
if (Grav::instance()['config']->get('system.media.auto_metadata_exif')) {
- if (function_exists('exif_read_data') && class_exists('\PHPExif\Reader\Reader')) {
- $this->reader = \PHPExif\Reader\Reader::factory(\PHPExif\Reader\Reader::TYPE_NATIVE);
+ if (function_exists('exif_read_data') && class_exists(Reader::class)) {
+ $this->reader = Reader::factory(Reader::TYPE_NATIVE);
} else {
- throw new \RuntimeException('Please enable the Exif extension for PHP or disable Exif support in Grav system configuration');
+ throw new RuntimeException('Please enable the Exif extension for PHP or disable Exif support in Grav system configuration');
}
}
}
+ /**
+ * @return Reader
+ */
public function getReader()
{
- if ($this->reader) {
- return $this->reader;
- }
-
- return false;
+ return $this->reader;
}
}
diff --git a/system/src/Grav/Common/Helpers/LogViewer.php b/system/src/Grav/Common/Helpers/LogViewer.php
index 397fb17544..cf5ffe9af5 100644
--- a/system/src/Grav/Common/Helpers/LogViewer.php
+++ b/system/src/Grav/Common/Helpers/LogViewer.php
@@ -3,14 +3,24 @@
/**
* @package Grav\Common\Helpers
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Helpers;
+use DateTime;
+use function array_slice;
+use function is_array;
+use function is_string;
+
+/**
+ * Class LogViewer
+ * @package Grav\Common\Helpers
+ */
class LogViewer
{
+ /** @var string */
protected $pattern = '/\[(?P.*)\] (?P\w+).(?P\w+): (?P.*[^ ]+) (?P[^ ]+) (?P[^ ]+)/';
/**
@@ -24,7 +34,7 @@ class LogViewer
public function objectTail($filepath, $lines = 1, $desc = true)
{
$data = $this->tail($filepath, $lines);
- $tailed_log = explode(PHP_EOL, $data);
+ $tailed_log = $data ? explode(PHP_EOL, $data) : [];
$line_objects = [];
foreach ($tailed_log as $line) {
@@ -39,21 +49,24 @@ public function objectTail($filepath, $lines = 1, $desc = true)
*
* @param string $filepath
* @param int $lines
- * @return bool|string
+ * @return string|false
*/
- public function tail($filepath, $lines = 1) {
-
- $f = @fopen($filepath, "rb");
- if ($f === false) return false;
+ public function tail($filepath, $lines = 1)
+ {
+ $f = $filepath ? @fopen($filepath, 'rb') : false;
+ if ($f === false) {
+ return false;
+ }
- else $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096));
+ $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096));
fseek($f, -1, SEEK_END);
- if (fread($f, 1) != "\n") $lines -= 1;
+ if (fread($f, 1) !== "\n") {
+ --$lines;
+ }
// Start reading
$output = '';
- $chunk = '';
// While we would like more
while (ftell($f) > 0 && $lines >= 0) {
// Figure out how far back we should jump
@@ -61,7 +74,11 @@ public function tail($filepath, $lines = 1) {
// Do the jump (backwards, relative to where we are)
fseek($f, -$seek, SEEK_CUR);
// Read a chunk and prepend it to our output
- $output = ($chunk = fread($f, $seek)) . $output;
+ $chunk = fread($f, $seek);
+ if ($chunk === false) {
+ throw new \RuntimeException('Cannot read file');
+ }
+ $output = $chunk . $output;
// Jump back to where we started reading
fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR);
// Decrease our line counter
@@ -83,7 +100,7 @@ public function tail($filepath, $lines = 1) {
* Helper class to get level color
*
* @param string $level
- * @return mixed|string
+ * @return string
*/
public static function levelColor($level)
{
@@ -108,12 +125,13 @@ public static function levelColor($level)
*/
public function parse($line)
{
- if( !is_string($line) || strlen($line) === 0) {
- return array();
+ if (!is_string($line) || $line === '') {
+ return [];
}
+
preg_match($this->pattern, $line, $data);
if (!isset($data['date'])) {
- return array();
+ return [];
}
preg_match('/(.*)- Trace:(.*)/', $data['message'], $matches);
@@ -122,15 +140,15 @@ public function parse($line)
$data['trace'] = trim($matches[2]);
}
- return array(
- 'date' => \DateTime::createFromFormat('Y-m-d H:i:s', $data['date']),
+ return [
+ 'date' => DateTime::createFromFormat('Y-m-d H:i:s', $data['date']),
'logger' => $data['logger'],
'level' => $data['level'],
'message' => $data['message'],
- 'trace' => isset($data['trace']) ? $this->parseTrace($data['trace']) : null,
+ 'trace' => isset($data['trace']) ? self::parseTrace($data['trace']) : null,
'context' => json_decode($data['context'], true),
'extra' => json_decode($data['extra'], true)
- );
+ ];
}
/**
@@ -143,7 +161,7 @@ public function parse($line)
public static function parseTrace($trace, $rows = 10)
{
$lines = array_filter(preg_split('/#\d*/m', $trace));
+
return array_slice($lines, 0, $rows);
}
-
}
diff --git a/system/src/Grav/Common/Helpers/Truncator.php b/system/src/Grav/Common/Helpers/Truncator.php
index d116bb3418..03c3eeaaba 100644
--- a/system/src/Grav/Common/Helpers/Truncator.php
+++ b/system/src/Grav/Common/Helpers/Truncator.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Helpers
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -15,6 +15,8 @@
use DOMNode;
use DOMWordsIterator;
use DOMLettersIterator;
+use function in_array;
+use function strlen;
/**
* This file is part of https://github.com/Bluetel-Solutions/twig-truncate-extension
@@ -25,11 +27,11 @@
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
-
-class Truncator {
-
+class Truncator
+{
/**
* Safely truncates HTML by a given number of words.
+ *
* @param string $html Input HTML.
* @param int $limit Limit to how many words we preserve.
* @param string $ellipsis String to use as ellipsis (if any).
@@ -49,10 +51,8 @@ public static function truncateWords($html, $limit = 0, $ellipsis = '')
$words = new DOMWordsIterator($container);
$truncated = false;
foreach ($words as $word) {
-
// If we have exceeded the limit, we delete the remainder of the content.
if ($words->key() >= $limit) {
-
// Grab current position.
$currentWordPosition = $words->currentWordPosition();
$curNode = $currentWordPosition[0];
@@ -75,7 +75,6 @@ public static function truncateWords($html, $limit = 0, $ellipsis = '')
break;
}
-
}
// Return original HTML if not truncated.
@@ -88,6 +87,7 @@ public static function truncateWords($html, $limit = 0, $ellipsis = '')
/**
* Safely truncates HTML by a given number of letters.
+ *
* @param string $html Input HTML.
* @param int $limit Limit to how many letters we preserve.
* @param string $ellipsis String to use as ellipsis (if any).
@@ -107,10 +107,8 @@ public static function truncateLetters($html, $limit = 0, $ellipsis = '')
$letters = new DOMLettersIterator($container);
$truncated = false;
foreach ($letters as $letter) {
-
// If we have exceeded the limit, we want to delete the remainder of this document.
if ($letters->key() >= $limit) {
-
$currentText = $letters->currentTextPosition();
$currentText[0]->nodeValue = mb_substr($currentText[0]->nodeValue, 0, $currentText[1] + 1);
self::removeProceedingNodes($currentText[0], $container);
@@ -135,8 +133,9 @@ public static function truncateLetters($html, $limit = 0, $ellipsis = '')
/**
* Builds a DOMDocument object from a string containing HTML.
+ *
* @param string $html HTML to load
- * @returns DOMDocument Returns a DOMDocument object.
+ * @return DOMDocument Returns a DOMDocument object.
*/
public static function htmlToDomDocument($html)
{
@@ -160,12 +159,14 @@ public static function htmlToDomDocument($html)
/**
* Removes all nodes after the current node.
+ *
* @param DOMNode|DOMElement $domNode
* @param DOMNode|DOMElement $topNode
* @return void
*/
private static function removeProceedingNodes($domNode, $topNode)
{
+ /** @var DOMNode|null $nextNode */
$nextNode = $domNode->nextSibling;
if ($nextNode !== null) {
@@ -190,25 +191,25 @@ private static function removeProceedingNodes($domNode, $topNode)
* Clean extra code
*
* @param DOMDocument $doc
- * @param $container
+ * @param DOMNode $container
* @return string
*/
- private static function getCleanedHTML(DOMDocument $doc, $container)
+ private static function getCleanedHTML(DOMDocument $doc, DOMNode $container)
{
while ($doc->firstChild) {
$doc->removeChild($doc->firstChild);
}
- while ($container->firstChild ) {
+ while ($container->firstChild) {
$doc->appendChild($container->firstChild);
}
- $html = trim($doc->saveHTML());
- return $html;
+ return trim($doc->saveHTML());
}
/**
* Inserts an ellipsis
+ *
* @param DOMNode|DOMElement $domNode Element to insert after.
* @param string $ellipsis Text used to suffix our document.
* @return void
@@ -221,12 +222,13 @@ private static function insertEllipsis($domNode, $ellipsis)
// Append as text node to parent instead
$textNode = new DOMText($ellipsis);
- if ($domNode->parentNode->parentNode->nextSibling) {
+ /** @var DOMNode|null $nextSibling */
+ $nextSibling = $domNode->parentNode->parentNode->nextSibling;
+ if ($nextSibling) {
$domNode->parentNode->parentNode->insertBefore($textNode, $domNode->parentNode->parentNode->nextSibling);
} else {
$domNode->parentNode->parentNode->appendChild($textNode);
}
-
} else {
// Append to current node
$domNode->nodeValue = rtrim($domNode->nodeValue) . $ellipsis;
@@ -234,7 +236,12 @@ private static function insertEllipsis($domNode, $ellipsis)
}
/**
- *
+ * @param string $text
+ * @param int $length
+ * @param string $ending
+ * @param bool $exact
+ * @param bool $considerHtml
+ * @return string
*/
public function truncate(
$text,
@@ -248,11 +255,13 @@ public function truncate(
if (strlen(preg_replace('/<.*?>/', '', $text)) <= $length) {
return $text;
}
+
// splits all html-tags to scanable lines
preg_match_all('/(<.+?>)?([^<>]*)/s', $text, $lines, PREG_SET_ORDER);
$total_length = strlen($ending);
- $open_tags = array();
$truncate = '';
+ $open_tags = [];
+
foreach ($lines as $line_matchings) {
// if there is any html-tag in this line, handle it and add it (uncounted) to the output
if (!empty($line_matchings[1])) {
@@ -260,14 +269,14 @@ public function truncate(
if (preg_match('/^<(\s*.+?\/\s*|\s*(img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param)(\s.+?)?)>$/is', $line_matchings[1])) {
// do nothing
// if tag is a closing tag
- } else if (preg_match('/^<\s*\/([^\s]+?)\s*>$/s', $line_matchings[1], $tag_matchings)) {
+ } elseif (preg_match('/^<\s*\/([^\s]+?)\s*>$/s', $line_matchings[1], $tag_matchings)) {
// delete tag from $open_tags list
$pos = array_search($tag_matchings[1], $open_tags);
if ($pos !== false) {
unset($open_tags[$pos]);
}
// if tag is an opening tag
- } else if (preg_match('/^<\s*([^\s>!]+).*?>$/s', $line_matchings[1], $tag_matchings)) {
+ } elseif (preg_match('/^<\s*([^\s>!]+).*?>$/s', $line_matchings[1], $tag_matchings)) {
// add tag to the beginning of $open_tags list
array_unshift($open_tags, strtolower($tag_matchings[1]));
}
@@ -301,35 +310,35 @@ public function truncate(
$total_length += $content_length;
}
// if the maximum length is reached, get off the loop
- if($total_length>= $length) {
+ if ($total_length>= $length) {
break;
}
}
} else {
if (strlen($text) <= $length) {
return $text;
- } else {
- $truncate = substr($text, 0, $length - strlen($ending));
}
+
+ $truncate = substr($text, 0, $length - strlen($ending));
}
// if the words shouldn't be cut in the middle...
if (!$exact) {
// ...search the last occurance of a space...
$spacepos = strrpos($truncate, ' ');
- if (isset($spacepos)) {
+ if (false !== $spacepos) {
// ...and cut the text in this position
$truncate = substr($truncate, 0, $spacepos);
}
}
// add the defined ending to the text
$truncate .= $ending;
- if($considerHtml) {
+ if (isset($open_tags)) {
// close all unclosed html-tags
foreach ($open_tags as $tag) {
$truncate .= '' . $tag . '>';
}
}
+
return $truncate;
}
-
}
diff --git a/system/src/Grav/Common/Helpers/YamlLinter.php b/system/src/Grav/Common/Helpers/YamlLinter.php
index b7e608d004..b31180f0e5 100644
--- a/system/src/Grav/Common/Helpers/YamlLinter.php
+++ b/system/src/Grav/Common/Helpers/YamlLinter.php
@@ -3,38 +3,62 @@
/**
* @package Grav\Common\Helpers
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Helpers;
+use Exception;
use Grav\Common\Grav;
+use Grav\Common\Utils;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use RegexIterator;
use RocketTheme\Toolbox\File\MarkdownFile;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
use Symfony\Component\Yaml\Yaml;
+/**
+ * Class YamlLinter
+ * @package Grav\Common\Helpers
+ */
class YamlLinter
{
- public static function lint()
+ /**
+ * @param string|null $folder
+ * @return array
+ */
+ public static function lint(string $folder = null)
{
- $errors = static::lintConfig();
- $errors = $errors + static::lintPages();
- $errors = $errors + static::lintBlueprints();
-
- return $errors;
+ if (null !== $folder) {
+ $folder = $folder ?: GRAV_ROOT;
+
+ return static::recurseFolder($folder);
+ }
+
+ return array_merge(static::lintConfig(), static::lintPages(), static::lintBlueprints());
}
+ /**
+ * @return array
+ */
public static function lintPages()
{
return static::recurseFolder('page://');
}
+ /**
+ * @return array
+ */
public static function lintConfig()
{
return static::recurseFolder('config://');
}
+ /**
+ * @return array
+ */
public static function lintBlueprints()
{
/** @var UniformResourceLocator $locator */
@@ -47,26 +71,31 @@ public static function lintBlueprints()
return static::recurseFolder('blueprints://');
}
- public static function recurseFolder($path, $extensions = 'md|yaml')
+ /**
+ * @param string $path
+ * @param string $extensions
+ * @return array
+ */
+ public static function recurseFolder($path, $extensions = '(md|yaml)')
{
$lint_errors = [];
/** @var UniformResourceLocator $locator */
$locator = Grav::instance()['locator'];
- $flags = \RecursiveDirectoryIterator::SKIP_DOTS;
+ $flags = RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS;
if ($locator->isStream($path)) {
$directory = $locator->getRecursiveIterator($path, $flags);
} else {
- $directory = new \RecursiveDirectoryIterator($path, $flags);
+ $directory = new RecursiveDirectoryIterator($path, $flags);
}
- $recursive = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST);
- $iterator = new \RegexIterator($recursive, '/^.+\.'.$extensions.'$/i');
+ $recursive = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);
+ $iterator = new RegexIterator($recursive, '/^.+\.'.$extensions.'$/ui');
- /** @var \RecursiveDirectoryIterator $file */
+ /** @var RecursiveDirectoryIterator $file */
foreach ($iterator as $filepath => $file) {
try {
Yaml::parse(static::extractYaml($filepath));
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$lint_errors[str_replace(GRAV_ROOT, '', $filepath)] = $e->getMessage();
}
}
@@ -74,16 +103,20 @@ public static function recurseFolder($path, $extensions = 'md|yaml')
return $lint_errors;
}
+ /**
+ * @param string $path
+ * @return string
+ */
protected static function extractYaml($path)
{
- $extension = pathinfo($path, PATHINFO_EXTENSION);
+ $extension = Utils::pathinfo($path, PATHINFO_EXTENSION);
if ($extension === 'md') {
$file = MarkdownFile::instance($path);
$contents = $file->frontmatter();
+ $file->free();
} else {
$contents = file_get_contents($path);
}
return $contents;
}
-
}
diff --git a/system/src/Grav/Common/Inflector.php b/system/src/Grav/Common/Inflector.php
index a24bbfd848..216b73350e 100644
--- a/system/src/Grav/Common/Inflector.php
+++ b/system/src/Grav/Common/Inflector.php
@@ -3,33 +3,53 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
+use DateInterval;
+use DateTime;
+use Grav\Common\Language\Language;
+use function in_array;
+use function is_array;
+use function strlen;
+
/**
* This file was originally part of the Akelos Framework
*/
-
class Inflector
{
+ /** @var bool */
+ protected static $initialized = false;
+ /** @var array|null */
protected static $plural;
+ /** @var array|null */
protected static $singular;
+ /** @var array|null */
protected static $uncountable;
+ /** @var array|null */
protected static $irregular;
+ /** @var array|null */
protected static $ordinals;
+ /**
+ * @return void
+ */
public static function init()
{
- if (empty(static::$plural)) {
+ if (!static::$initialized) {
+ static::$initialized = true;
+ /** @var Language $language */
$language = Grav::instance()['language'];
- static::$plural = $language->translate('GRAV.INFLECTOR_PLURALS', null, true) ?: [];
- static::$singular = $language->translate('GRAV.INFLECTOR_SINGULAR', null, true) ?: [];
- static::$uncountable = $language->translate('GRAV.INFLECTOR_UNCOUNTABLE', null, true) ?: [];
- static::$irregular = $language->translate('GRAV.INFLECTOR_IRREGULAR', null, true) ?: [];
- static::$ordinals = $language->translate('GRAV.INFLECTOR_ORDINALS', null, true) ?: [];
+ if (!$language->isDebug()) {
+ static::$plural = $language->translate('GRAV.INFLECTOR_PLURALS', null, true);
+ static::$singular = $language->translate('GRAV.INFLECTOR_SINGULAR', null, true);
+ static::$uncountable = $language->translate('GRAV.INFLECTOR_UNCOUNTABLE', null, true);
+ static::$irregular = $language->translate('GRAV.INFLECTOR_IRREGULAR', null, true);
+ static::$ordinals = $language->translate('GRAV.INFLECTOR_ORDINALS', null, true);
+ }
}
}
@@ -38,8 +58,7 @@ public static function init()
*
* @param string $word English noun to pluralize
* @param int $count The count
- *
- * @return string Plural noun
+ * @return string|false Plural noun
*/
public static function pluralize($word, $count = 2)
{
@@ -51,26 +70,31 @@ public static function pluralize($word, $count = 2)
$lowercased_word = strtolower($word);
- foreach (static::$uncountable as $_uncountable) {
- if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) {
- return $word;
+ if (is_array(static::$uncountable)) {
+ foreach (static::$uncountable as $_uncountable) {
+ if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) {
+ return $word;
+ }
}
}
- foreach (static::$irregular as $_plural => $_singular) {
- if (preg_match('/(' . $_plural . ')$/i', $word, $arr)) {
- return preg_replace('/(' . $_plural . ')$/i', substr($arr[0], 0, 1) . substr($_singular, 1), $word);
+ if (is_array(static::$irregular)) {
+ foreach (static::$irregular as $_plural => $_singular) {
+ if (preg_match('/(' . $_plural . ')$/i', $word, $arr)) {
+ return preg_replace('/(' . $_plural . ')$/i', substr($arr[0], 0, 1) . substr($_singular, 1), $word);
+ }
}
}
- foreach (static::$plural as $rule => $replacement) {
- if (preg_match($rule, $word)) {
- return preg_replace($rule, $replacement, $word);
+ if (is_array(static::$plural)) {
+ foreach (static::$plural as $rule => $replacement) {
+ if (preg_match($rule, $word)) {
+ return preg_replace($rule, $replacement, $word);
+ }
}
}
return false;
-
}
/**
@@ -90,21 +114,28 @@ public static function singularize($word, $count = 1)
}
$lowercased_word = strtolower($word);
- foreach (static::$uncountable as $_uncountable) {
- if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) {
- return $word;
+
+ if (is_array(static::$uncountable)) {
+ foreach (static::$uncountable as $_uncountable) {
+ if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) {
+ return $word;
+ }
}
}
- foreach (static::$irregular as $_plural => $_singular) {
- if (preg_match('/(' . $_singular . ')$/i', $word, $arr)) {
- return preg_replace('/(' . $_singular . ')$/i', substr($arr[0], 0, 1) . substr($_plural, 1), $word);
+ if (is_array(static::$irregular)) {
+ foreach (static::$irregular as $_plural => $_singular) {
+ if (preg_match('/(' . $_singular . ')$/i', $word, $arr)) {
+ return preg_replace('/(' . $_singular . ')$/i', substr($arr[0], 0, 1) . substr($_plural, 1), $word);
+ }
}
}
- foreach (static::$singular as $rule => $replacement) {
- if (preg_match($rule, $word)) {
- return preg_replace($rule, $replacement, $word);
+ if (is_array(static::$singular)) {
+ foreach (static::$singular as $rule => $replacement) {
+ if (preg_match($rule, $word)) {
+ return preg_replace($rule, $replacement, $word);
+ }
}
}
@@ -144,8 +175,7 @@ public static function titleize($word, $uppercase = '')
*
* @see variablize
*
- * @param string $word Word to convert to camel case
- *
+ * @param string $word Word to convert to camel case
* @return string UpperCamelCasedWord
*/
public static function camelize($word)
@@ -161,8 +191,7 @@ public static function camelize($word)
*
* This can be really useful for creating friendly URLs.
*
- * @param string $word Word to underscore
- *
+ * @param string $word Word to underscore
* @return string Underscored word
*/
public static function underscorize($word)
@@ -182,8 +211,7 @@ public static function underscorize($word)
*
* This can be really useful for creating friendly URLs.
*
- * @param string $word Word to hyphenate
- *
+ * @param string $word Word to hyphenate
* @return string hyphenized word
*/
public static function hyphenize($word)
@@ -193,6 +221,8 @@ public static function hyphenize($word)
$regex3 = preg_replace('/([0-9])([A-Z])/', '\1-\2', $regex2);
$regex4 = preg_replace('/[^A-Z^a-z^0-9]+/', '-', $regex3);
+ $regex4 = trim($regex4, '-');
+
return strtolower($regex4);
}
@@ -228,8 +258,7 @@ public static function humanize($word, $uppercase = '')
*
* @see camelize
*
- * @param string $word Word to lowerCamelCase
- *
+ * @param string $word Word to lowerCamelCase
* @return string Returns a lowerCamelCasedWord
*/
public static function variablize($word)
@@ -247,8 +276,7 @@ public static function variablize($word)
*
* @see classify
*
- * @param string $class_name Class name for getting related table_name.
- *
+ * @param string $class_name Class name for getting related table_name.
* @return string plural_table_name
*/
public static function tableize($class_name)
@@ -264,8 +292,7 @@ public static function tableize($class_name)
*
* @see tableize
*
- * @param string $table_name Table name for getting related ClassName.
- *
+ * @param string $table_name Table name for getting related ClassName.
* @return string SingularClassName
*/
public static function classify($table_name)
@@ -278,15 +305,18 @@ public static function classify($table_name)
*
* This method converts 13 to 13th, 2 to 2nd ...
*
- * @param int $number Number to get its ordinal value
- *
+ * @param int $number Number to get its ordinal value
* @return string Ordinal representation of given string.
*/
public static function ordinalize($number)
{
+ if (!is_array(static::$ordinals)) {
+ return (string)$number;
+ }
+
static::init();
- if (\in_array($number % 100, range(11, 13), true)) {
+ if (in_array($number % 100, range(11, 13), true)) {
return $number . static::$ordinals['default'];
}
@@ -306,15 +336,14 @@ public static function ordinalize($number)
* Converts a number of days to a number of months
*
* @param int $days
- *
* @return int
*/
public static function monthize($days)
{
- $now = new \DateTime();
- $end = new \DateTime();
+ $now = new DateTime();
+ $end = new DateTime();
- $duration = new \DateInterval("P{$days}D");
+ $duration = new DateInterval("P{$days}D");
$diff = $end->add($duration)->diff($now);
diff --git a/system/src/Grav/Common/Iterator.php b/system/src/Grav/Common/Iterator.php
index 5f6dc7ea09..5db7bd0452 100644
--- a/system/src/Grav/Common/Iterator.php
+++ b/system/src/Grav/Common/Iterator.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -15,14 +15,20 @@
use RocketTheme\Toolbox\ArrayTraits\Countable;
use RocketTheme\Toolbox\ArrayTraits\Export;
use RocketTheme\Toolbox\ArrayTraits\Serializable;
+use function array_slice;
+use function count;
+use function is_callable;
+use function is_object;
+/**
+ * Class Iterator
+ * @package Grav\Common
+ */
class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable
{
use Constructor, ArrayAccessWithGetters, ArrayIterator, Countable, Serializable, Export;
- /**
- * @var array
- */
+ /** @var array */
protected $items = [];
/**
@@ -30,9 +36,9 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable
*
* @param string $key
* @param mixed $args
- *
* @return mixed
*/
+ #[\ReturnTypeWillChange]
public function __call($key, $args)
{
return $this->items[$key] ?? null;
@@ -41,10 +47,11 @@ public function __call($key, $args)
/**
* Clone the iterator.
*/
+ #[\ReturnTypeWillChange]
public function __clone()
{
foreach ($this as $key => $value) {
- if (\is_object($value)) {
+ if (is_object($value)) {
$this->{$key} = clone $this->{$key};
}
}
@@ -55,6 +62,7 @@ public function __clone()
*
* @return string
*/
+ #[\ReturnTypeWillChange]
public function __toString()
{
return implode(',', $this->items);
@@ -64,6 +72,7 @@ public function __toString()
* Remove item from the list.
*
* @param string $key
+ * @return void
*/
public function remove($key)
{
@@ -84,7 +93,6 @@ public function prev()
* Return nth item.
*
* @param int $key
- *
* @return mixed|bool
*/
public function nth($key)
@@ -133,7 +141,7 @@ public function reverse()
/**
* @param mixed $needle Searched value.
*
- * @return string|bool Key if found, otherwise false.
+ * @return string|int|false Key if found, otherwise false.
*/
public function indexOf($needle)
{
@@ -170,13 +178,12 @@ public function shuffle()
* Slice the list.
*
* @param int $offset
- * @param int $length
- *
+ * @param int|null $length
* @return $this
*/
public function slice($offset, $length = null)
{
- $this->items = \array_slice($this->items, $offset, $length);
+ $this->items = array_slice($this->items, $offset, $length);
return $this;
}
@@ -185,12 +192,11 @@ public function slice($offset, $length = null)
* Pick one or more random entries.
*
* @param int $num Specifies how many entries should be picked.
- *
* @return $this
*/
public function random($num = 1)
{
- $count = \count($this->items);
+ $count = count($this->items);
if ($num > $count) {
$num = $count;
}
@@ -204,7 +210,6 @@ public function random($num = 1)
* Append new elements to the list.
*
* @param array|Iterator $items Items to be appended. Existing keys will be overridden with the new values.
- *
* @return $this
*/
public function append($items)
@@ -228,10 +233,7 @@ public function append($items)
public function filter(callable $callback = null)
{
foreach ($this->items as $key => $value) {
- if (
- (!$callback && !(bool)$value) ||
- ($callback && !$callback($value, $key))
- ) {
+ if ((!$callback && !(bool)$value) || ($callback && !$callback($value, $key))) {
unset($this->items[$key]);
}
}
@@ -244,16 +246,13 @@ public function filter(callable $callback = null)
* Sorts elements from the list and returns a copy of the list in the proper order
*
* @param callable|null $callback
- *
* @param bool $desc
- *
* @return $this|array
- * @internal param bool $asc
*
*/
public function sort(callable $callback = null, $desc = false)
{
- if (!$callback || !\is_callable($callback)) {
+ if (!$callback || !is_callable($callback)) {
return $this;
}
diff --git a/system/src/Grav/Common/Language/Language.php b/system/src/Grav/Common/Language/Language.php
index a625146a22..fe006ed2cb 100644
--- a/system/src/Grav/Common/Language/Language.php
+++ b/system/src/Grav/Common/Language/Language.php
@@ -3,65 +3,96 @@
/**
* @package Grav\Common\Language
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Language;
+use Grav\Common\Debugger;
use Grav\Common\Grav;
use Grav\Common\Config\Config;
use Negotiation\AcceptLanguage;
use Negotiation\LanguageNegotiator;
+use function array_key_exists;
+use function count;
+use function in_array;
+use function is_array;
+use function is_string;
+/**
+ * Class Language
+ * @package Grav\Common\Language
+ */
class Language
{
+ /** @var Grav */
protected $grav;
+ /** @var Config */
+ protected $config;
+ /** @var bool */
protected $enabled = true;
- /**
- * @var array
- */
+ /** @var array */
protected $languages = [];
- protected $page_extensions = [];
+ /** @var array */
protected $fallback_languages = [];
+ /** @var array */
+ protected $fallback_extensions = [];
+ /** @var array */
+ protected $page_extensions = [];
+ /** @var string|false */
protected $default;
- protected $active = null;
-
- /** @var Config $config */
- protected $config;
-
+ /** @var string|false */
+ protected $active;
+ /** @var array */
protected $http_accept_language;
+ /** @var bool */
protected $lang_in_url = false;
/**
* Constructor
*
- * @param \Grav\Common\Grav $grav
+ * @param Grav $grav
*/
public function __construct(Grav $grav)
{
$this->grav = $grav;
$this->config = $grav['config'];
- $this->languages = $this->config->get('system.languages.supported', []);
+
+ $languages = $this->config->get('system.languages.supported', []);
+ foreach ($languages as &$language) {
+ $language = (string)$language;
+ }
+ unset($language);
+
+ $this->languages = $languages;
+
$this->init();
}
/**
* Initialize the default and enabled languages
+ *
+ * @return void
*/
public function init()
{
$default = $this->config->get('system.languages.default_lang');
- if (isset($default) && $this->validate($default)) {
- $this->default = $default;
- } else {
- $this->default = reset($this->languages);
+ if (null !== $default) {
+ $default = (string)$default;
}
- $this->page_extensions = null;
+ // Note that reset returns false on empty languages.
+ $this->default = $default ?? reset($this->languages);
+
+ $this->resetFallbackPageExtensions();
if (empty($this->languages)) {
+ // If no languages are set, turn of multi-language support.
$this->enabled = false;
+ } elseif ($default && !in_array($default, $this->languages, true)) {
+ // If default language isn't in the language list, we need to add it.
+ array_unshift($this->languages, $default);
}
}
@@ -75,6 +106,16 @@ public function enabled()
return $this->enabled;
}
+ /**
+ * Returns true if language debugging is turned on.
+ *
+ * @return bool
+ */
+ public function isDebug(): bool
+ {
+ return !$this->config->get('system.languages.translations', true);
+ }
+
/**
* Gets the array of supported languages
*
@@ -89,24 +130,27 @@ public function getLanguages()
* Sets the current supported languages manually
*
* @param array $langs
+ * @return void
*/
public function setLanguages($langs)
{
$this->languages = $langs;
+
$this->init();
}
/**
* Gets a pipe-separated string of available languages
*
+ * @param string|null $delimiter Delimiter to be quoted.
* @return string
*/
- public function getAvailable()
+ public function getAvailable($delimiter = null)
{
$languagesArray = $this->languages; //Make local copy
- $languagesArray = array_map(function($value) {
- return preg_quote($value);
+ $languagesArray = array_map(static function ($value) use ($delimiter) {
+ return preg_quote($value, $delimiter);
}, $languagesArray);
sort($languagesArray);
@@ -117,7 +161,7 @@ public function getAvailable()
/**
* Gets language, active if set, else default
*
- * @return string
+ * @return string|false
*/
public function getLanguage()
{
@@ -127,7 +171,7 @@ public function getLanguage()
/**
* Gets current default language
*
- * @return mixed
+ * @return string|false
*/
public function getDefault()
{
@@ -138,11 +182,11 @@ public function getDefault()
* Sets default language manually
*
* @param string $lang
- *
- * @return bool
+ * @return string|bool
*/
public function setDefault($lang)
{
+ $lang = (string)$lang;
if ($this->validate($lang)) {
$this->default = $lang;
@@ -155,7 +199,7 @@ public function setDefault($lang)
/**
* Gets current active language
*
- * @return string
+ * @return string|false
*/
public function getActive()
{
@@ -165,13 +209,17 @@ public function getActive()
/**
* Sets active language manually
*
- * @param string $lang
- *
- * @return string|bool
+ * @param string|false $lang
+ * @return string|false
*/
public function setActive($lang)
{
+ $lang = (string)$lang;
if ($this->validate($lang)) {
+ /** @var Debugger $debugger */
+ $debugger = $this->grav['debugger'];
+ $debugger->addMessage('Active language set to ' . $lang, 'debug');
+
$this->active = $lang;
return $lang;
@@ -184,7 +232,6 @@ public function setActive($lang)
* Sets the active language based on the first part of the URL
*
* @param string $uri
- *
* @return string
*/
public function setActiveFromUri($uri)
@@ -196,7 +243,7 @@ public function setActiveFromUri($uri)
// Try setting language from prefix of URL (/en/blah/blah).
if (preg_match($regex, $uri, $matches)) {
$this->lang_in_url = true;
- $this->active = $matches[2];
+ $this->setActive($matches[2]);
$uri = preg_replace("/\\" . $matches[1] . '/', '', $uri, 1);
// Store in session if language is different.
@@ -210,22 +257,20 @@ public function setActiveFromUri($uri)
// Try getting language from the session, else no active.
if (isset($this->grav['session']) && $this->grav['session']->isStarted() &&
$this->config->get('system.languages.session_store_active', true)) {
- $this->active = $this->grav['session']->active_language ?: null;
+ $this->setActive($this->grav['session']->active_language ?: null);
}
// if still null, try from http_accept_language header
if ($this->active === null &&
$this->config->get('system.languages.http_accept_language') &&
$accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? false) {
-
$negotiator = new LanguageNegotiator();
$best_language = $negotiator->getBest($accept, $this->languages);
if ($best_language instanceof AcceptLanguage) {
- $this->active = $best_language->getType();
+ $this->setActive($best_language->getType());
} else {
- $this->active = $this->getDefault();
+ $this->setActive($this->getDefault());
}
-
}
}
}
@@ -241,6 +286,10 @@ public function setActiveFromUri($uri)
*/
public function getLanguageURLPrefix($lang = null)
{
+ if (!$this->enabled()) {
+ return '';
+ }
+
// if active lang is not passed in, use current active
if (!$lang) {
$lang = $this->getLanguage();
@@ -257,6 +306,10 @@ public function getLanguageURLPrefix($lang = null)
*/
public function isIncludeDefaultLanguage($lang = null)
{
+ if (!$this->enabled()) {
+ return false;
+ }
+
// if active lang is not passed in, use current active
if (!$lang) {
$lang = $this->getLanguage();
@@ -275,52 +328,63 @@ public function isLanguageInUrl()
return (bool) $this->lang_in_url;
}
-
/**
- * Gets an array of valid extensions with active first, then fallback extensions
- *
- * @param string|null $file_ext
+ * Get full list of used language page extensions: [''=>'.md', 'en'=>'.en.md', ...]
*
+ * @param string|null $fileExtension
* @return array
*/
- public function getFallbackPageExtensions($file_ext = null)
+ public function getPageExtensions($fileExtension = null)
{
- if (empty($this->page_extensions)) {
- if (!$file_ext) {
- $file_ext = CONTENT_EXT;
+ $fileExtension = $fileExtension ?: CONTENT_EXT;
+
+ if (!isset($this->fallback_extensions[$fileExtension])) {
+ $extensions[''] = $fileExtension;
+ foreach ($this->languages as $code) {
+ $extensions[$code] = ".{$code}{$fileExtension}";
}
- if ($this->enabled()) {
- $valid_lang_extensions = [];
- foreach ($this->languages as $lang) {
- $valid_lang_extensions[] = '.' . $lang . $file_ext;
- }
+ $this->fallback_extensions[$fileExtension] = $extensions;
+ }
- if ($this->active) {
- $active_extension = '.' . $this->active . $file_ext;
- $key = \array_search($active_extension, $valid_lang_extensions, true);
+ return $this->fallback_extensions[$fileExtension];
+ }
- // Default behavior is to find any language other than active
- if ($this->config->get('system.languages.pages_fallback_only')) {
- $slice = \array_slice($valid_lang_extensions, 0, $key+1);
- $valid_lang_extensions = array_reverse($slice);
- } else {
- unset($valid_lang_extensions[$key]);
- array_unshift($valid_lang_extensions, $active_extension);
- }
+ /**
+ * Gets an array of valid extensions with active first, then fallback extensions
+ *
+ * @param string|null $fileExtension
+ * @param string|null $languageCode
+ * @param bool $assoc Return values in ['en' => '.en.md', ...] format.
+ * @return array Key is the language code, value is the file extension to be used.
+ */
+ public function getFallbackPageExtensions(string $fileExtension = null, string $languageCode = null, bool $assoc = false)
+ {
+ $fileExtension = $fileExtension ?: CONTENT_EXT;
+ $key = $fileExtension . '-' . ($languageCode ?? 'default') . '-' . (int)$assoc;
+
+ if (!isset($this->fallback_extensions[$key])) {
+ $all = $this->getPageExtensions($fileExtension);
+ $list = [];
+ $fallback = $this->getFallbackLanguages($languageCode, true);
+ foreach ($fallback as $code) {
+ $ext = $all[$code] ?? null;
+ if (null !== $ext) {
+ $list[$code] = $ext;
}
- $valid_lang_extensions[] = $file_ext;
- $this->page_extensions = $valid_lang_extensions;
- } else {
- $this->page_extensions = (array)$file_ext;
}
+ if (!$assoc) {
+ $list = array_values($list);
+ }
+
+ $this->fallback_extensions[$key] = $list;
}
- return $this->page_extensions;
+ return $this->fallback_extensions[$key];
}
/**
- * Resets the page_extensions value.
+ * Resets the fallback_languages value.
*
* Useful to re-initialize the pages and change site language at runtime, example:
*
@@ -329,48 +393,90 @@ public function getFallbackPageExtensions($file_ext = null)
* $this->grav['language']->resetFallbackPageExtensions();
* $this->grav['pages']->init();
* ```
+ *
+ * @return void
*/
public function resetFallbackPageExtensions()
{
- $this->page_extensions = null;
+ $this->fallback_languages = [];
+ $this->fallback_extensions = [];
+ $this->page_extensions = [];
}
/**
- * Gets an array of languages with active first, then fallback languages
+ * Gets an array of languages with active first, then fallback languages.
+ *
*
+ * @param string|null $languageCode
+ * @param bool $includeDefault If true, list contains '', which can be used for default
* @return array
*/
- public function getFallbackLanguages()
+ public function getFallbackLanguages(string $languageCode = null, bool $includeDefault = false)
{
- if (empty($this->fallback_languages)) {
- if ($this->enabled()) {
- $fallback_languages = $this->languages;
-
- if ($this->active) {
- $active_extension = $this->active;
- $key = \array_search($active_extension, $fallback_languages, true);
- unset($fallback_languages[$key]);
- array_unshift($fallback_languages, $active_extension);
+ // Handle default.
+ if ($languageCode === '' || !$this->enabled()) {
+ return [''];
+ }
+
+ $default = $this->getDefault() ?? 'en';
+ $active = $languageCode ?? $this->getActive() ?? $default;
+ $key = $active . '-' . (int)$includeDefault;
+
+ if (!isset($this->fallback_languages[$key])) {
+ $fallback = $this->config->get('system.languages.content_fallback.' . $active);
+ $fallback_languages = [];
+
+ if (null === $fallback && $this->config->get('system.languages.pages_fallback_only', false)) {
+ user_error('Configuration option `system.languages.pages_fallback_only` is deprecated since Grav 1.7, use `system.languages.content_fallback` instead', E_USER_DEPRECATED);
+
+ // Special fallback list returns itself and all the previous items in reverse order:
+ // active: 'v2', languages: ['v1', 'v2', 'v3', 'v4'] => ['v2', 'v1', '']
+ if ($includeDefault) {
+ $fallback_languages[''] = '';
+ }
+ foreach ($this->languages as $code) {
+ $fallback_languages[$code] = $code;
+ if ($code === $active) {
+ break;
+ }
+ }
+ $fallback_languages = array_reverse($fallback_languages);
+ } else {
+ if (null === $fallback) {
+ $fallback = [$default];
+ } elseif (!is_array($fallback)) {
+ $fallback = is_string($fallback) && $fallback !== '' ? explode(',', $fallback) : [];
+ }
+ array_unshift($fallback, $active);
+ $fallback = array_unique($fallback);
+
+ foreach ($fallback as $code) {
+ // Default fallback list has active language followed by default language and extensionless file:
+ // active: 'fi', default: 'en', languages: ['sv', 'en', 'de', 'fi'] => ['fi', 'en', '']
+ $fallback_languages[$code] = $code;
+ if ($includeDefault && $code === $default) {
+ $fallback_languages[''] = '';
+ }
}
- $this->fallback_languages = $fallback_languages;
}
- // always add english in case a translation doesn't exist
- $this->fallback_languages[] = 'en';
+
+ $fallback_languages = array_values($fallback_languages);
+
+ $this->fallback_languages[$key] = $fallback_languages;
}
- return $this->fallback_languages;
+ return $this->fallback_languages[$key];
}
/**
* Ensures the language is valid and supported
*
* @param string $lang
- *
* @return bool
*/
public function validate($lang)
{
- return \in_array($lang, $this->languages, true);
+ return in_array($lang, $this->languages, true);
}
/**
@@ -378,45 +484,40 @@ public function validate($lang)
*
* @param string|array $args The first argument is the lookup key value
* Other arguments can be passed and replaced in the translation with sprintf syntax
- * @param array $languages
+ * @param array|null $languages
* @param bool $array_support
* @param bool $html_out
- *
- * @return string
+ * @return string|string[]
*/
public function translate($args, array $languages = null, $array_support = false, $html_out = false)
{
- if (\is_array($args)) {
+ if (is_array($args)) {
$lookup = array_shift($args);
} else {
$lookup = $args;
$args = [];
}
- if ($this->config->get('system.languages.translations', true)) {
- if ($this->enabled() && $lookup) {
- if (empty($languages)) {
- if ($this->config->get('system.languages.translations_fallback', true)) {
- $languages = $this->getFallbackLanguages();
- } else {
- $languages = (array)$this->getLanguage();
- }
- }
- } else {
- $languages = ['en'];
+ if (!$this->isDebug()) {
+ if ($lookup && $this->enabled() && empty($languages)) {
+ $languages = $this->getTranslatedLanguages();
}
+ $languages = $languages ?: ['en'];
+
foreach ((array)$languages as $lang) {
$translation = $this->getTranslation($lang, $lookup, $array_support);
if ($translation) {
- if (\count($args) >= 1) {
+ if (is_string($translation) && count($args) >= 1) {
return vsprintf($translation, $args);
}
return $translation;
}
}
+ } elseif ($array_support) {
+ return [$lookup];
}
if ($html_out) {
@@ -433,29 +534,24 @@ public function translate($args, array $languages = null, $array_support = false
* @param string $index
* @param array|null $languages
* @param bool $html_out
- *
* @return string
*/
public function translateArray($key, $index, $languages = null, $html_out = false)
{
- if ($this->config->get('system.languages.translations', true)) {
- if ($this->enabled() && $key) {
- if (empty($languages)) {
- if ($this->config->get('system.languages.translations_fallback', true)) {
- $languages = $this->getFallbackLanguages();
- } else {
- $languages = (array)$this->getDefault();
- }
- }
- } else {
- $languages = ['en'];
- }
+ if ($this->isDebug()) {
+ return $key . '[' . $index . ']';
+ }
- foreach ((array)$languages as $lang) {
- $translation_array = (array)Grav::instance()['languages']->get($lang . '.' . $key, null);
- if ($translation_array && array_key_exists($index, $translation_array)) {
- return $translation_array[$index];
- }
+ if ($key && empty($languages) && $this->enabled()) {
+ $languages = $this->getTranslatedLanguages();
+ }
+
+ $languages = $languages ?: ['en'];
+
+ foreach ((array)$languages as $lang) {
+ $translation_array = (array)Grav::instance()['languages']->get($lang . '.' . $key, null);
+ if ($translation_array && array_key_exists($index, $translation_array)) {
+ return $translation_array[$index];
}
}
@@ -472,11 +568,14 @@ public function translateArray($key, $index, $languages = null, $html_out = fals
* @param string $lang lang code
* @param string $key key to lookup with
* @param bool $array_support
- *
- * @return string
+ * @return string|string[]
*/
public function getTranslation($lang, $key, $array_support = false)
{
+ if ($this->isDebug()) {
+ return $key;
+ }
+
$translation = Grav::instance()['languages']->get($lang . '.' . $key, null);
if (!$array_support && is_array($translation)) {
return (string)array_shift($translation);
@@ -527,11 +626,38 @@ public function getBrowserLanguages($accept_langs = [])
*
* @param string $code
* @param string $type
- * @return bool
+ * @return string|false
*/
public function getLanguageCode($code, $type = 'name')
{
return LanguageCodes::get($code, $type);
}
+ /**
+ * @return array
+ */
+ #[\ReturnTypeWillChange]
+ public function __debugInfo()
+ {
+ $vars = get_object_vars($this);
+ unset($vars['grav'], $vars['config']);
+
+ return $vars;
+ }
+
+ /**
+ * @return array
+ */
+ protected function getTranslatedLanguages(): array
+ {
+ if ($this->config->get('system.languages.translations_fallback', true)) {
+ $languages = $this->getFallbackLanguages();
+ } else {
+ $languages = [$this->getLanguage()];
+ }
+
+ $languages[] = 'en';
+
+ return array_values(array_unique($languages));
+ }
}
diff --git a/system/src/Grav/Common/Language/LanguageCodes.php b/system/src/Grav/Common/Language/LanguageCodes.php
index 780df7c029..ddd37c9b88 100644
--- a/system/src/Grav/Common/Language/LanguageCodes.php
+++ b/system/src/Grav/Common/Language/LanguageCodes.php
@@ -3,14 +3,19 @@
/**
* @package Grav\Common\Language
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Language;
+/**
+ * Class LanguageCodes
+ * @package Grav\Common\Language
+ */
class LanguageCodes
{
+ /** @var array */
protected static $codes = [
'af' => [ 'name' => 'Afrikaans', 'nativeName' => 'Afrikaans' ],
'ak' => [ 'name' => 'Akan', 'nativeName' => 'Akan' ], // unverified native name
@@ -81,13 +86,15 @@ class LanguageCodes
'ja-JP' => [ 'name' => 'Japanese', 'nativeName' => '日本語' ], // not iso-639-1
'ka' => [ 'name' => 'Georgian', 'nativeName' => 'ქართული' ],
'kk' => [ 'name' => 'Kazakh', 'nativeName' => 'Қазақ' ],
+ 'km' => [ 'name' => 'Khmer', 'nativeName' => 'Khmer' ],
'kn' => [ 'name' => 'Kannada', 'nativeName' => 'ಕನ್ನಡ' ],
'ko' => [ 'name' => 'Korean', 'nativeName' => '한국어' ],
'ku' => [ 'name' => 'Kurdish', 'nativeName' => 'Kurdî' ],
'la' => [ 'name' => 'Latin', 'nativeName' => 'Latina' ],
'lb' => [ 'name' => 'Luxembourgish', 'nativeName' => 'Lëtzebuergesch' ],
'lg' => [ 'name' => 'Luganda', 'nativeName' => 'Luganda' ],
- 'lt' => [ 'name' => 'Lithuanian', 'nativeName' => 'Lietuvių kalba' ],
+ 'lo' => [ 'name' => 'Lao', 'nativeName' => 'Lao' ],
+ 'lt' => [ 'name' => 'Lithuanian', 'nativeName' => 'Lietuvių' ],
'lv' => [ 'name' => 'Latvian', 'nativeName' => 'Latviešu' ],
'mai' => [ 'name' => 'Maithili', 'nativeName' => 'मैथिली মৈথিলী' ],
'mg' => [ 'name' => 'Malagasy', 'nativeName' => 'Malagasy' ],
@@ -96,6 +103,7 @@ class LanguageCodes
'ml' => [ 'name' => 'Malayalam', 'nativeName' => 'മലയാളം' ],
'mn' => [ 'name' => 'Mongolian', 'nativeName' => 'Монгол' ],
'mr' => [ 'name' => 'Marathi', 'nativeName' => 'मराठी' ],
+ 'my' => [ 'name' => 'Myanmar (Burmese)', 'nativeName' => 'ဗမာी' ],
'no' => [ 'name' => 'Norwegian', 'nativeName' => 'Norsk' ],
'nb' => [ 'name' => 'Norwegian', 'nativeName' => 'Norsk' ],
'nb-NO' => [ 'name' => 'Norwegian (Bokmål)', 'nativeName' => 'Norsk bokmål' ],
@@ -127,6 +135,7 @@ class LanguageCodes
'st' => [ 'name' => 'Southern Sotho', 'nativeName' => 'Sesotho' ],
'sv' => [ 'name' => 'Swedish', 'nativeName' => 'Svenska' ],
'sv-SE' => [ 'name' => 'Swedish', 'nativeName' => 'Svenska' ],
+ 'sw' => [ 'name' => 'Swahili', 'nativeName' => 'Swahili' ],
'ta' => [ 'name' => 'Tamil', 'nativeName' => 'தமிழ்' ],
'ta-IN' => [ 'name' => 'Tamil (India)', 'nativeName' => 'தமிழ் (இந்தியா)' ],
'ta-LK' => [ 'name' => 'Tamil (Sri Lanka)', 'nativeName' => 'தமிழ் (இலங்கை)' ],
@@ -144,17 +153,27 @@ 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' => '正體中文 (繁體)' ],
'zu' => [ 'name' => 'Zulu', 'nativeName' => 'isiZulu' ]
];
+ /**
+ * @param string $code
+ * @return string|false
+ */
public static function getName($code)
{
return static::get($code, 'name');
}
+ /**
+ * @param string $code
+ * @return string|false
+ */
public static function getNativeName($code)
{
if (isset(static::$codes[$code])) {
@@ -168,21 +187,28 @@ public static function getNativeName($code)
return $code;
}
+ /**
+ * @param string $code
+ * @return string
+ */
public static function getOrientation($code)
{
- if (isset(static::$codes[$code])) {
- if (isset(static::$codes[$code]['orientation'])) {
- return static::get($code, 'orientation');
- }
- }
- return 'ltr';
+ return static::$codes[$code]['orientation'] ?? 'ltr';
}
+ /**
+ * @param string $code
+ * @return bool
+ */
public static function isRtl($code)
{
return static::getOrientation($code) === 'rtl';
}
+ /**
+ * @param array $keys
+ * @return array
+ */
public static function getNames(array $keys)
{
$results = [];
@@ -194,12 +220,27 @@ public static function getNames(array $keys)
return $results;
}
+ /**
+ * @param string $code
+ * @param string $type
+ * @return string|false
+ */
public static function get($code, $type)
{
- if (isset(static::$codes[$code][$type])) {
- return static::$codes[$code][$type];
+ return static::$codes[$code][$type] ?? false;
+ }
+
+ /**
+ * @param bool $native
+ * @return array
+ */
+ public static function getList($native = true)
+ {
+ $list = [];
+ foreach (static::$codes as $key => $names) {
+ $list[$key] = $native ? $names['nativeName'] : $names['name'];
}
- return false;
+ return $list;
}
}
diff --git a/system/src/Grav/Common/Markdown/Parsedown.php b/system/src/Grav/Common/Markdown/Parsedown.php
index 32033ddff0..f0d8a48ff8 100644
--- a/system/src/Grav/Common/Markdown/Parsedown.php
+++ b/system/src/Grav/Common/Markdown/Parsedown.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Markdown
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -12,6 +12,10 @@
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Markdown\Excerpts;
+/**
+ * Class Parsedown
+ * @package Grav\Common\Markdown
+ */
class Parsedown extends \Parsedown
{
use ParsedownGravTrait;
@@ -19,7 +23,7 @@ class Parsedown extends \Parsedown
/**
* Parsedown constructor.
*
- * @param Excerpts|null $excerpts
+ * @param Excerpts|PageInterface|null $excerpts
* @param array|null $defaults
*/
public function __construct($excerpts = null, $defaults = null)
@@ -35,5 +39,4 @@ public function __construct($excerpts = null, $defaults = null)
$this->init($excerpts, $defaults);
}
-
}
diff --git a/system/src/Grav/Common/Markdown/ParsedownExtra.php b/system/src/Grav/Common/Markdown/ParsedownExtra.php
index a562d8d4a8..ef450feaf4 100644
--- a/system/src/Grav/Common/Markdown/ParsedownExtra.php
+++ b/system/src/Grav/Common/Markdown/ParsedownExtra.php
@@ -3,15 +3,20 @@
/**
* @package Grav\Common\Markdown
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Markdown;
+use Exception;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Markdown\Excerpts;
+/**
+ * Class ParsedownExtra
+ * @package Grav\Common\Markdown
+ */
class ParsedownExtra extends \ParsedownExtra
{
use ParsedownGravTrait;
@@ -19,9 +24,9 @@ class ParsedownExtra extends \ParsedownExtra
/**
* ParsedownExtra constructor.
*
- * @param Excerpts|null $excerpts
+ * @param Excerpts|PageInterface|null $excerpts
* @param array|null $defaults
- * @throws \Exception
+ * @throws Exception
*/
public function __construct($excerpts = null, $defaults = null)
{
diff --git a/system/src/Grav/Common/Markdown/ParsedownGravTrait.php b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php
index aa76cdf0ce..a593dd749b 100644
--- a/system/src/Grav/Common/Markdown/ParsedownGravTrait.php
+++ b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Markdown
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,23 +11,34 @@
use Grav\Common\Page\Markdown\Excerpts;
use Grav\Common\Page\Interfaces\PageInterface;
+use function call_user_func_array;
+use function in_array;
+use function strlen;
+/**
+ * Trait ParsedownGravTrait
+ * @package Grav\Common\Markdown
+ */
trait ParsedownGravTrait
{
+ /** @var array */
+ public $completable_blocks = [];
+ /** @var array */
+ public $continuable_blocks = [];
+
/** @var Excerpts */
protected $excerpts;
-
+ /** @var array */
protected $special_chars;
+ /** @var string */
protected $twig_link_regex = '/\!*\[(?:.*)\]\((\{([\{%#])\s*(.*?)\s*(?:\2|\})\})\)/';
- public $completable_blocks = [];
- public $continuable_blocks = [];
-
/**
* Initialization function to setup key variables needed by the MarkdownGravLinkTrait
*
* @param PageInterface|Excerpts|null $excerpts
* @param array|null $defaults
+ * @return void
*/
protected function init($excerpts = null, $defaults = null)
{
@@ -79,6 +90,7 @@ public function getExcerpts()
* @param bool $continuable
* @param bool $completable
* @param int|null $index
+ * @return void
*/
public function addBlockType($type, $tag, $continuable = false, $completable = false, $index = null)
{
@@ -110,6 +122,7 @@ public function addBlockType($type, $tag, $continuable = false, $completable = f
* @param string $type
* @param string $tag
* @param int|null $index
+ * @return void
*/
public function addInlineType($type, $tag, $index = null)
{
@@ -128,12 +141,11 @@ public function addInlineType($type, $tag, $index = null)
* Overrides the default behavior to allow for plugin-provided blocks to be continuable
*
* @param string $Type
- *
* @return bool
*/
protected function isBlockContinuable($Type)
{
- $continuable = \in_array($Type, $this->continuable_blocks, true)
+ $continuable = in_array($Type, $this->continuable_blocks, true)
|| method_exists($this, 'block' . $Type . 'Continue');
return $continuable;
@@ -143,12 +155,11 @@ protected function isBlockContinuable($Type)
* Overrides the default behavior to allow for plugin-provided blocks to be completable
*
* @param string $Type
- *
* @return bool
*/
protected function isBlockCompletable($Type)
{
- $completable = \in_array($Type, $this->completable_blocks, true)
+ $completable = in_array($Type, $this->completable_blocks, true)
|| method_exists($this, 'block' . $Type . 'Complete');
return $completable;
@@ -159,7 +170,6 @@ protected function isBlockCompletable($Type)
* Make the element function publicly accessible, Medium uses this to render from Twig
*
* @param array $Element
- *
* @return string markup
*/
public function elementToHtml(array $Element)
@@ -171,7 +181,6 @@ public function elementToHtml(array $Element)
* Setter for special chars
*
* @param array $special_chars
- *
* @return $this
*/
public function setSpecialChars($special_chars)
@@ -196,6 +205,10 @@ protected function blockTwigTag($line)
return null;
}
+ /**
+ * @param array $excerpt
+ * @return array|null
+ */
protected function inlineSpecialCharacter($excerpt)
{
if ($excerpt['text'][0] === '&' && !preg_match('/^?\w+;/', $excerpt['text'])) {
@@ -215,6 +228,10 @@ protected function inlineSpecialCharacter($excerpt)
return null;
}
+ /**
+ * @param array $excerpt
+ * @return array
+ */
protected function inlineImage($excerpt)
{
if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) {
@@ -237,6 +254,10 @@ protected function inlineImage($excerpt)
return $excerpt;
}
+ /**
+ * @param array $excerpt
+ * @return array
+ */
protected function inlineLink($excerpt)
{
$type = $excerpt['type'] ?? 'link';
@@ -263,13 +284,18 @@ protected function inlineLink($excerpt)
/**
* For extending this class via plugins
+ *
+ * @param string $method
+ * @param array $args
+ * @return mixed|null
*/
+ #[\ReturnTypeWillChange]
public function __call($method, $args)
{
if (isset($this->{$method}) === true) {
$func = $this->{$method};
- return \call_user_func_array($func, $args);
+ return call_user_func_array($func, $args);
}
return null;
diff --git a/system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php b/system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php
new file mode 100644
index 0000000000..6465d6b9ca
--- /dev/null
+++ b/system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php
@@ -0,0 +1,25 @@
+set('this.is.my.nested.variable', $value);
+ *
+ * @param string $name Dot separated path to the requested value.
+ * @param mixed $value New value.
+ * @param string|null $separator Separator, defaults to '.'
+ * @return $this
+ */
+ public function set($name, $value, $separator = null);
}
diff --git a/system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php b/system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php
new file mode 100644
index 0000000000..fbe6f656fa
--- /dev/null
+++ b/system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php
@@ -0,0 +1,56 @@
+ 'user://pages/media']; // Settings from the form field.
+ * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);
+ * $media->copyUploadedFile($uploadedFile, $filename);
+
+ * @param UploadedFileInterface $uploadedFile
+ * @param string|null $filename
+ * @param array|null $settings
+ * @return string
+ * @throws RuntimeException
+ */
+ public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string;
+
+ /**
+ * Copy uploaded file to the media collection.
+ *
+ * WARNING: Always check uploaded file before copying it!
+ *
+ * @example
+ * $filename = null; // Override filename if needed (ignored if randomizing filenames).
+ * $settings = ['destination' => 'user://pages/media']; // Settings from the form field.
+ * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);
+ * $media->copyUploadedFile($uploadedFile, $filename);
+ *
+ * @param UploadedFileInterface $uploadedFile
+ * @param string $filename
+ * @param array|null $settings
+ * @return void
+ * @throws RuntimeException
+ */
+ public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename, array $settings = null): void;
+
+ /**
+ * Delete real file from the media collection.
+ *
+ * @param string $filename
+ * @param array|null $settings
+ * @return void
+ */
+ public function deleteFile(string $filename, array $settings = null): void;
+
+ /**
+ * Rename file inside the media collection.
+ *
+ * @param string $from
+ * @param string $to
+ * @param array|null $settings
+ */
+ public function renameFile(string $from, string $to, array $settings = null): void;
+}
diff --git a/system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php b/system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php
new file mode 100644
index 0000000000..6d42feff26
--- /dev/null
+++ b/system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php
@@ -0,0 +1,32 @@
+attributes['controlsList'] = $controlsList;
+
+ return $this;
+ }
+
+ /**
+ * Parsedown element for source display mode
+ *
+ * @param array $attributes
+ * @param bool $reset
+ * @return array
+ */
+ protected function sourceParsedownElement(array $attributes, $reset = true)
+ {
+ $location = $this->url($reset);
+
+ return [
+ 'name' => 'audio',
+ 'rawHtml' => 'Your browser does not support the audio tag.',
+ 'attributes' => $attributes
+ ];
+ }
+}
diff --git a/system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php b/system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php
new file mode 100644
index 0000000000..75e23cab8d
--- /dev/null
+++ b/system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php
@@ -0,0 +1,37 @@
+get('system.images.defaults.loading', 'auto');
+ }
+ if ($value && $value !== 'auto') {
+ $this->attributes['loading'] = $value;
+ }
+
+ return $this;
+ }
+}
diff --git a/system/src/Grav/Common/Media/Traits/ImageMediaTrait.php b/system/src/Grav/Common/Media/Traits/ImageMediaTrait.php
new file mode 100644
index 0000000000..c1c46dc94a
--- /dev/null
+++ b/system/src/Grav/Common/Media/Traits/ImageMediaTrait.php
@@ -0,0 +1,428 @@
+ [0, 1],
+ 'forceResize' => [0, 1],
+ 'cropResize' => [0, 1],
+ 'crop' => [0, 1, 2, 3],
+ 'zoomCrop' => [0, 1]
+ ];
+
+ /** @var string */
+ protected $sizes = '100vw';
+
+
+ /**
+ * Allows the ability to override the image's pretty name stored in cache
+ *
+ * @param string $name
+ */
+ public function setImagePrettyName($name)
+ {
+ $this->set('prettyname', $name);
+ if ($this->image) {
+ $this->image->setPrettyName($name);
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getImagePrettyName()
+ {
+ if ($this->get('prettyname')) {
+ return $this->get('prettyname');
+ }
+
+ $basename = $this->get('basename');
+ if (preg_match('/[a-z0-9]{40}-(.*)/', $basename, $matches)) {
+ $basename = $matches[1];
+ }
+ return $basename;
+ }
+
+ /**
+ * Simply processes with no extra methods. Useful for triggering events.
+ *
+ * @return $this
+ */
+ public function cache()
+ {
+ if (!$this->image) {
+ $this->image();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Generate alternative image widths, using either an array of integers, or
+ * a min width, a max width, and a step parameter to fill out the necessary
+ * widths. Existing image alternatives won't be overwritten.
+ *
+ * @param int|int[] $min_width
+ * @param int $max_width
+ * @param int $step
+ * @return $this
+ */
+ public function derivatives($min_width, $max_width = 2500, $step = 200)
+ {
+ if (!empty($this->alternatives)) {
+ $max = max(array_keys($this->alternatives));
+ $base = $this->alternatives[$max];
+ } else {
+ $base = $this;
+ }
+
+ $widths = [];
+
+ if (func_num_args() === 1) {
+ foreach ((array) func_get_arg(0) as $width) {
+ if ($width < $base->get('width')) {
+ $widths[] = $width;
+ }
+ }
+ } else {
+ $max_width = min($max_width, $base->get('width'));
+
+ for ($width = $min_width; $width < $max_width; $width += $step) {
+ $widths[] = $width;
+ }
+ }
+
+ foreach ($widths as $width) {
+ // Only generate image alternatives that don't already exist
+ if (array_key_exists((int) $width, $this->alternatives)) {
+ continue;
+ }
+
+ $derivative = MediumFactory::fromFile($base->get('filepath'));
+
+ // It's possible that MediumFactory::fromFile returns null if the
+ // original image file no longer exists and this class instance was
+ // retrieved from the page cache
+ if (null !== $derivative) {
+ $index = 2;
+ $alt_widths = array_keys($this->alternatives);
+ sort($alt_widths);
+
+ foreach ($alt_widths as $i => $key) {
+ if ($width > $key) {
+ $index += max($i, 1);
+ }
+ }
+
+ $basename = preg_replace('/(@\d+x)?$/', "@{$width}w", $base->get('basename'), 1);
+ $derivative->setImagePrettyName($basename);
+
+ $ratio = $base->get('width') / $width;
+ $height = $derivative->get('height') / $ratio;
+
+ $derivative->resize($width, $height);
+ $derivative->set('width', $width);
+ $derivative->set('height', $height);
+
+ $this->addAlternative($ratio, $derivative);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Clear out the alternatives.
+ */
+ public function clearAlternatives()
+ {
+ $this->alternatives = [];
+ }
+
+ /**
+ * Sets or gets the quality of the image
+ *
+ * @param int|null $quality 0-100 quality
+ * @return int|$this
+ */
+ public function quality($quality = null)
+ {
+ if ($quality) {
+ if (!$this->image) {
+ $this->image();
+ }
+
+ $this->quality = $quality;
+
+ return $this;
+ }
+
+ return $this->quality;
+ }
+
+ /**
+ * Sets image output format.
+ *
+ * @param string $format
+ * @return $this
+ */
+ public function format($format)
+ {
+ if (!$this->image) {
+ $this->image();
+ }
+
+ $this->format = $format;
+
+ return $this;
+ }
+
+ /**
+ * Set or get sizes parameter for srcset media action
+ *
+ * @param string|null $sizes
+ * @return string
+ */
+ public function sizes($sizes = null)
+ {
+ if ($sizes) {
+ $this->sizes = $sizes;
+
+ return $this;
+ }
+
+ return empty($this->sizes) ? '100vw' : $this->sizes;
+ }
+
+ /**
+ * Allows to set the width attribute from Markdown or Twig
+ * Examples: 
+ * 
+ * 
+ * 
+ * {{ page.media['myimg.png'].width().height().html }}
+ * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }}
+ *
+ * @param string|int $value A value or 'auto' or empty to use the width of the image
+ * @return $this
+ */
+ public function width($value = 'auto')
+ {
+ if (!$value || $value === 'auto') {
+ $this->attributes['width'] = $this->get('width');
+ } else {
+ $this->attributes['width'] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Allows to set the height attribute from Markdown or Twig
+ * Examples: 
+ * 
+ * 
+ * 
+ * {{ page.media['myimg.png'].width().height().html }}
+ * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }}
+ *
+ * @param string|int $value A value or 'auto' or empty to use the height of the image
+ * @return $this
+ */
+ public function height($value = 'auto')
+ {
+ if (!$value || $value === 'auto') {
+ $this->attributes['height'] = $this->get('height');
+ } else {
+ $this->attributes['height'] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Filter image by using user defined filter parameters.
+ *
+ * @param string $filter Filter to be used.
+ * @return $this
+ */
+ public function filter($filter = 'image.filters.default')
+ {
+ $filters = (array) $this->get($filter, []);
+ foreach ($filters as $params) {
+ $params = (array) $params;
+ $method = array_shift($params);
+ $this->__call($method, $params);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the image higher quality version
+ *
+ * @return ImageMediaInterface|$this the alternative version with higher quality
+ */
+ public function higherQualityAlternative()
+ {
+ if ($this->alternatives) {
+ /** @var ImageMedium $max */
+ $max = reset($this->alternatives);
+ /** @var ImageMedium $alternative */
+ foreach ($this->alternatives as $alternative) {
+ if ($alternative->quality() > $max->quality()) {
+ $max = $alternative;
+ }
+ }
+
+ return $max;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Gets medium image, resets image manipulation operations.
+ *
+ * @return $this
+ */
+ protected function image()
+ {
+ $locator = Grav::instance()['locator'];
+
+ // Use existing cache folder or if it doesn't exist, create it.
+ $cacheDir = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true);
+
+ // Make sure we free previous image.
+ unset($this->image);
+
+ /** @var MediaCollectionInterface $media */
+ $media = $this->get('media');
+ if ($media && method_exists($media, 'getImageFileObject')) {
+ $this->image = $media->getImageFileObject($this);
+ } else {
+ $this->image = ImageFile::open($this->get('filepath'));
+ }
+
+ $this->image
+ ->setCacheDir($cacheDir)
+ ->setActualCacheDir($cacheDir)
+ ->setPrettyName($this->getImagePrettyName());
+
+ // Fix orientation if enabled
+ $config = Grav::instance()['config'];
+ if ($config->get('system.images.auto_fix_orientation', false) &&
+ extension_loaded('exif') && function_exists('exif_read_data')) {
+ $this->image->fixOrientation();
+ }
+
+ // Set CLS configuration
+ $this->auto_sizes = $config->get('system.images.cls.auto_sizes', false);
+ $this->aspect_ratio = $config->get('system.images.cls.aspect_ratio', false);
+ $this->retina_scale = $config->get('system.images.cls.retina_scale', 1);
+
+ $this->watermark = $config->get('system.images.watermark.watermark_all', false);
+
+ return $this;
+ }
+
+ /**
+ * Save the image with cache.
+ *
+ * @return string
+ */
+ protected function saveImage()
+ {
+ if (!$this->image) {
+ return parent::path(false);
+ }
+
+ $this->filter();
+
+ if (isset($this->result)) {
+ return $this->result;
+ }
+
+ if ($this->format === 'guess') {
+ $extension = strtolower($this->get('extension'));
+ $this->format($extension);
+ }
+
+ if (!$this->debug_watermarked && $this->get('debug')) {
+ $ratio = $this->get('ratio');
+ if (!$ratio) {
+ $ratio = 1;
+ }
+
+ $locator = Grav::instance()['locator'];
+ $overlay = $locator->findResource("system://assets/responsive-overlays/{$ratio}x.png") ?: $locator->findResource('system://assets/responsive-overlays/unknown.png');
+ $this->image->merge(ImageFile::open($overlay));
+ }
+
+ if ($this->watermark) {
+ $this->watermark();
+ }
+
+ return $this->image->cacheFile($this->format, $this->quality, false, [$this->get('width'), $this->get('height'), $this->get('modified')]);
+ }
+}
diff --git a/system/src/Grav/Common/Media/Traits/MediaFileTrait.php b/system/src/Grav/Common/Media/Traits/MediaFileTrait.php
new file mode 100644
index 0000000000..63906fc4a3
--- /dev/null
+++ b/system/src/Grav/Common/Media/Traits/MediaFileTrait.php
@@ -0,0 +1,139 @@
+path(false);
+
+ return file_exists($path);
+ }
+
+ /**
+ * Get file modification time for the medium.
+ *
+ * @return int|null
+ */
+ public function modified()
+ {
+ $path = $this->path(false);
+ if (!file_exists($path)) {
+ return null;
+ }
+
+ return filemtime($path) ?: null;
+ }
+
+ /**
+ * Get size of the medium.
+ *
+ * @return int
+ */
+ public function size()
+ {
+ $path = $this->path(false);
+ if (!file_exists($path)) {
+ return 0;
+ }
+
+ return filesize($path) ?: 0;
+ }
+
+ /**
+ * Return PATH to file.
+ *
+ * @param bool $reset
+ * @return string path to file
+ */
+ public function path($reset = true)
+ {
+ if ($reset) {
+ $this->reset();
+ }
+
+ return $this->get('url') ?? $this->get('filepath');
+ }
+
+ /**
+ * Return the relative path to file
+ *
+ * @param bool $reset
+ * @return string
+ */
+ public function relativePath($reset = true)
+ {
+ if ($reset) {
+ $this->reset();
+ }
+
+ $path = $this->path(false);
+ $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $path) ?: $path;
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+ if ($locator->isStream($output)) {
+ $output = (string)($locator->findResource($output, false) ?: $locator->findResource($output, false, true));
+ }
+
+ return $output;
+ }
+
+ /**
+ * Return URL to file.
+ *
+ * @param bool $reset
+ * @return string
+ */
+ public function url($reset = true)
+ {
+ $url = $this->get('url');
+ if ($url) {
+ return $url;
+ }
+
+ $path = $this->relativePath($reset);
+
+ return trim($this->getGrav()['base_url'] . '/' . $this->urlQuerystring($path), '\\');
+ }
+
+ /**
+ * Get the URL with full querystring
+ *
+ * @param string $url
+ * @return string
+ */
+ abstract public function urlQuerystring($url);
+
+ /**
+ * Reset medium.
+ *
+ * @return $this
+ */
+ abstract public function reset();
+
+ /**
+ * @return Grav
+ */
+ abstract protected function getGrav(): Grav;
+}
diff --git a/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php b/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php
new file mode 100644
index 0000000000..85ed6d224f
--- /dev/null
+++ b/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php
@@ -0,0 +1,630 @@
+getItems());
+ }
+
+ /**
+ * Set querystring to file modification timestamp (or value provided as a parameter).
+ *
+ * @param string|int|null $timestamp
+ * @return $this
+ */
+ public function setTimestamp($timestamp = null)
+ {
+ if (null !== $timestamp) {
+ $this->timestamp = (string)($timestamp);
+ } elseif ($this instanceof MediaFileInterface) {
+ $this->timestamp = (string)$this->modified();
+ } else {
+ $this->timestamp = '';
+ }
+
+ return $this;
+ }
+
+ /**
+ * Returns an array containing just the metadata
+ *
+ * @return array
+ */
+ public function metadata()
+ {
+ return $this->metadata;
+ }
+
+ /**
+ * Add meta file for the medium.
+ *
+ * @param string $filepath
+ */
+ abstract public function addMetaFile($filepath);
+
+ /**
+ * Add alternative Medium to this Medium.
+ *
+ * @param int|float $ratio
+ * @param MediaObjectInterface $alternative
+ */
+ public function addAlternative($ratio, MediaObjectInterface $alternative)
+ {
+ if (!is_numeric($ratio) || $ratio === 0) {
+ return;
+ }
+
+ $alternative->set('ratio', $ratio);
+ $width = $alternative->get('width', 0);
+
+ $this->alternatives[$width] = $alternative;
+ }
+
+ /**
+ * @param bool $withDerived
+ * @return array
+ */
+ public function getAlternatives(bool $withDerived = true): array
+ {
+ $alternatives = [];
+ foreach ($this->alternatives + [$this->get('width', 0) => $this] as $size => $alternative) {
+ if ($withDerived || $alternative->filename === Utils::basename($alternative->filepath)) {
+ $alternatives[$size] = $alternative;
+ }
+ }
+
+ ksort($alternatives, SORT_NUMERIC);
+
+ return $alternatives;
+ }
+
+ /**
+ * Return string representation of the object (html).
+ *
+ * @return string
+ */
+ #[\ReturnTypeWillChange]
+ abstract public function __toString();
+
+ /**
+ * Get/set querystring for the file's url
+ *
+ * @param string|null $querystring
+ * @param bool $withQuestionmark
+ * @return string
+ */
+ public function querystring($querystring = null, $withQuestionmark = true)
+ {
+ if (null !== $querystring) {
+ $this->medium_querystring[] = ltrim($querystring, '?&');
+ foreach ($this->alternatives as $alt) {
+ $alt->querystring($querystring, $withQuestionmark);
+ }
+ }
+
+ if (empty($this->medium_querystring)) {
+ return '';
+ }
+
+ // join the strings
+ $querystring = implode('&', $this->medium_querystring);
+ // explode all strings
+ $query_parts = explode('&', $querystring);
+ // Join them again now ensure the elements are unique
+ $querystring = implode('&', array_unique($query_parts));
+
+ return $withQuestionmark ? ('?' . $querystring) : $querystring;
+ }
+
+ /**
+ * Get the URL with full querystring
+ *
+ * @param string $url
+ * @return string
+ */
+ public function urlQuerystring($url)
+ {
+ $querystring = $this->querystring();
+ if (isset($this->timestamp) && !Utils::contains($querystring, $this->timestamp)) {
+ $querystring = empty($querystring) ? ('?' . $this->timestamp) : ($querystring . '&' . $this->timestamp);
+ }
+
+ return ltrim($url . $querystring . $this->urlHash(), '/');
+ }
+
+ /**
+ * Get/set hash for the file's url
+ *
+ * @param string|null $hash
+ * @param bool $withHash
+ * @return string
+ */
+ public function urlHash($hash = null, $withHash = true)
+ {
+ if ($hash) {
+ $this->set('urlHash', ltrim($hash, '#'));
+ }
+
+ $hash = $this->get('urlHash', '');
+
+ return $withHash && !empty($hash) ? '#' . $hash : $hash;
+ }
+
+ /**
+ * Get an element (is array) that can be rendered by the Parsedown engine
+ *
+ * @param string|null $title
+ * @param string|null $alt
+ * @param string|null $class
+ * @param string|null $id
+ * @param bool $reset
+ * @return array
+ */
+ public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true)
+ {
+ $attributes = $this->attributes;
+ $items = $this->getItems();
+
+ $style = '';
+ foreach ($this->styleAttributes as $key => $value) {
+ if (is_numeric($key)) { // Special case for inline style attributes, refer to style() method
+ $style .= $value;
+ } else {
+ $style .= $key . ': ' . $value . ';';
+ }
+ }
+ if ($style) {
+ $attributes['style'] = $style;
+ }
+
+ if (empty($attributes['title'])) {
+ if (!empty($title)) {
+ $attributes['title'] = $title;
+ } elseif (!empty($items['title'])) {
+ $attributes['title'] = $items['title'];
+ }
+ }
+
+ if (empty($attributes['alt'])) {
+ if (!empty($alt)) {
+ $attributes['alt'] = $alt;
+ } elseif (!empty($items['alt'])) {
+ $attributes['alt'] = $items['alt'];
+ } elseif (!empty($items['alt_text'])) {
+ $attributes['alt'] = $items['alt_text'];
+ } else {
+ $attributes['alt'] = '';
+ }
+ }
+
+ if (empty($attributes['class'])) {
+ if (!empty($class)) {
+ $attributes['class'] = $class;
+ } elseif (!empty($items['class'])) {
+ $attributes['class'] = $items['class'];
+ }
+ }
+
+ if (empty($attributes['id'])) {
+ if (!empty($id)) {
+ $attributes['id'] = $id;
+ } elseif (!empty($items['id'])) {
+ $attributes['id'] = $items['id'];
+ }
+ }
+
+ switch ($this->mode) {
+ case 'text':
+ $element = $this->textParsedownElement($attributes, false);
+ break;
+ case 'thumbnail':
+ $thumbnail = $this->getThumbnail();
+ $element = $thumbnail ? $thumbnail->sourceParsedownElement($attributes, false) : [];
+ break;
+ case 'source':
+ $element = $this->sourceParsedownElement($attributes, false);
+ break;
+ default:
+ $element = [];
+ }
+
+ if ($reset) {
+ $this->reset();
+ }
+
+ $this->display('source');
+
+ return $element;
+ }
+
+ /**
+ * Reset medium.
+ *
+ * @return $this
+ */
+ public function reset()
+ {
+ $this->attributes = [];
+
+ return $this;
+ }
+
+ /**
+ * Add custom attribute to medium.
+ *
+ * @param string $attribute
+ * @param string $value
+ * @return $this
+ */
+ public function attribute($attribute = null, $value = '')
+ {
+ if (!empty($attribute)) {
+ $this->attributes[$attribute] = $value;
+ }
+ return $this;
+ }
+
+ /**
+ * Switch display mode.
+ *
+ * @param string $mode
+ *
+ * @return MediaObjectInterface|null
+ */
+ public function display($mode = 'source')
+ {
+ if ($this->mode === $mode) {
+ return $this;
+ }
+
+ $this->mode = $mode;
+ if ($mode === 'thumbnail') {
+ $thumbnail = $this->getThumbnail();
+
+ return $thumbnail ? $thumbnail->reset() : null;
+ }
+
+ return $this->reset();
+ }
+
+ /**
+ * Helper method to determine if this media item has a thumbnail or not
+ *
+ * @param string $type;
+ * @return bool
+ */
+ public function thumbnailExists($type = 'page')
+ {
+ $thumbs = $this->get('thumbnails');
+
+ return isset($thumbs[$type]);
+ }
+
+ /**
+ * Switch thumbnail.
+ *
+ * @param string $type
+ * @return $this
+ */
+ public function thumbnail($type = 'auto')
+ {
+ if ($type !== 'auto' && !in_array($type, $this->thumbnailTypes, true)) {
+ return $this;
+ }
+
+ if ($this->thumbnailType !== $type) {
+ $this->_thumbnail = null;
+ }
+
+ $this->thumbnailType = $type;
+
+ return $this;
+ }
+
+ /**
+ * Return URL to file.
+ *
+ * @param bool $reset
+ * @return string
+ */
+ abstract public function url($reset = true);
+
+ /**
+ * Turn the current Medium into a Link
+ *
+ * @param bool $reset
+ * @param array $attributes
+ * @return MediaLinkInterface
+ */
+ public function link($reset = true, array $attributes = [])
+ {
+ if ($this->mode !== 'source') {
+ $this->display('source');
+ }
+
+ foreach ($this->attributes as $key => $value) {
+ empty($attributes['data-' . $key]) && $attributes['data-' . $key] = $value;
+ }
+
+ empty($attributes['href']) && $attributes['href'] = $this->url();
+
+ return $this->createLink($attributes);
+ }
+
+ /**
+ * Turn the current Medium into a Link with lightbox enabled
+ *
+ * @param int|null $width
+ * @param int|null $height
+ * @param bool $reset
+ * @return MediaLinkInterface
+ */
+ public function lightbox($width = null, $height = null, $reset = true)
+ {
+ $attributes = ['rel' => 'lightbox'];
+
+ if ($width && $height) {
+ $attributes['data-width'] = $width;
+ $attributes['data-height'] = $height;
+ }
+
+ return $this->link($reset, $attributes);
+ }
+
+ /**
+ * Add a class to the element from Markdown or Twig
+ * Example:  or 
+ *
+ * @return $this
+ */
+ public function classes()
+ {
+ $classes = func_get_args();
+ if (!empty($classes)) {
+ $this->attributes['class'] = implode(',', $classes);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add an id to the element from Markdown or Twig
+ * Example: 
+ *
+ * @param string $id
+ * @return $this
+ */
+ public function id($id)
+ {
+ if (is_string($id)) {
+ $this->attributes['id'] = trim($id);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Allows to add an inline style attribute from Markdown or Twig
+ * Example: 
+ *
+ * @param string $style
+ * @return $this
+ */
+ public function style($style)
+ {
+ $this->styleAttributes[] = rtrim($style, ';') . ';';
+
+ return $this;
+ }
+
+ /**
+ * Allow any action to be called on this medium from twig or markdown
+ *
+ * @param string $method
+ * @param array $args
+ * @return $this
+ */
+ #[\ReturnTypeWillChange]
+ public function __call($method, $args)
+ {
+ $count = count($args);
+ if ($count > 1 || ($count === 1 && !empty($args[0]))) {
+ $method .= '=' . implode(',', array_map(static function ($a) {
+ if (is_array($a)) {
+ $a = '[' . implode(',', $a) . ']';
+ }
+
+ return rawurlencode($a);
+ }, $args));
+ }
+
+ if (!empty($method)) {
+ $this->querystring($this->querystring(null, false) . '&' . $method);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Parsedown element for source display mode
+ *
+ * @param array $attributes
+ * @param bool $reset
+ * @return array
+ */
+ protected function sourceParsedownElement(array $attributes, $reset = true)
+ {
+ return $this->textParsedownElement($attributes, $reset);
+ }
+
+ /**
+ * Parsedown element for text display mode
+ *
+ * @param array $attributes
+ * @param bool $reset
+ * @return array
+ */
+ protected function textParsedownElement(array $attributes, $reset = true)
+ {
+ if ($reset) {
+ $this->reset();
+ }
+
+ $text = $attributes['title'] ?? '';
+ if ($text === '') {
+ $text = $attributes['alt'] ?? '';
+ if ($text === '') {
+ $text = $this->get('filename');
+ }
+ }
+
+ return [
+ 'name' => 'p',
+ 'attributes' => $attributes,
+ 'text' => $text
+ ];
+ }
+
+ /**
+ * Get the thumbnail Medium object
+ *
+ * @return ThumbnailImageMedium|null
+ */
+ protected function getThumbnail()
+ {
+ if (null === $this->_thumbnail) {
+ $types = $this->thumbnailTypes;
+
+ if ($this->thumbnailType !== 'auto') {
+ array_unshift($types, $this->thumbnailType);
+ }
+
+ foreach ($types as $type) {
+ $thumb = $this->get("thumbnails.{$type}", false);
+ if ($thumb) {
+ $image = $thumb instanceof ThumbnailImageMedium ? $thumb : $this->createThumbnail($thumb);
+ if($image) {
+ $image->parent = $this;
+ $this->_thumbnail = $image;
+ }
+ break;
+ }
+ }
+ }
+
+ return $this->_thumbnail;
+ }
+
+ /**
+ * 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.
+ */
+ abstract public function get($name, $default = null, $separator = null);
+
+ /**
+ * Set value by using dot notation for nested arrays/objects.
+ *
+ * @example $data->set('this.is.my.nested.variable', $value);
+ *
+ * @param string $name Dot separated path to the requested value.
+ * @param mixed $value New value.
+ * @param string|null $separator Separator, defaults to '.'
+ * @return $this
+ */
+ abstract public function set($name, $value, $separator = null);
+
+ /**
+ * @param string $thumb
+ */
+ abstract protected function createThumbnail($thumb);
+
+ /**
+ * @param array $attributes
+ * @return MediaLinkInterface
+ */
+ abstract protected function createLink(array $attributes);
+
+ /**
+ * @return array
+ */
+ abstract protected function getItems(): array;
+}
diff --git a/system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php b/system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php
new file mode 100644
index 0000000000..7e59d64b06
--- /dev/null
+++ b/system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php
@@ -0,0 +1,113 @@
+attributes['controls'] = 'controls';
+ } else {
+ unset($this->attributes['controls']);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Allows to set the loop attribute
+ *
+ * @param bool $status
+ * @return $this
+ */
+ public function loop($status = false)
+ {
+ if ($status) {
+ $this->attributes['loop'] = 'loop';
+ } else {
+ unset($this->attributes['loop']);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Allows to set the autoplay attribute
+ *
+ * @param bool $status
+ * @return $this
+ */
+ public function autoplay($status = false)
+ {
+ if ($status) {
+ $this->attributes['autoplay'] = 'autoplay';
+ } else {
+ unset($this->attributes['autoplay']);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Allows to set the muted attribute
+ *
+ * @param bool $status
+ * @return $this
+ */
+ public function muted($status = false)
+ {
+ if ($status) {
+ $this->attributes['muted'] = 'muted';
+ } else {
+ unset($this->attributes['muted']);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Allows to set the preload behaviour
+ *
+ * @param string|null $preload
+ * @return $this
+ */
+ public function preload($preload = null)
+ {
+ $validPreloadAttrs = ['auto', 'metadata', 'none'];
+
+ if (null === $preload) {
+ unset($this->attributes['preload']);
+ } elseif (in_array($preload, $validPreloadAttrs, true)) {
+ $this->attributes['preload'] = $preload;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Reset player.
+ */
+ public function resetPlayer()
+ {
+ $this->attributes['controls'] = 'controls';
+ }
+}
diff --git a/system/src/Grav/Common/Media/Traits/MediaTrait.php b/system/src/Grav/Common/Media/Traits/MediaTrait.php
index c65f87da60..14bfa90725 100644
--- a/system/src/Grav/Common/Media/Traits/MediaTrait.php
+++ b/system/src/Grav/Common/Media/Traits/MediaTrait.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Media
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -15,10 +15,18 @@
use Grav\Common\Page\Media;
use Psr\SimpleCache\CacheInterface;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use function strlen;
+/**
+ * Trait MediaTrait
+ * @package Grav\Common\Media\Traits
+ */
trait MediaTrait
{
+ /** @var MediaCollectionInterface|null */
protected $media;
+ /** @var bool */
+ protected $_loadMedia = true;
/**
* Get filesystem path to the associated media.
@@ -40,52 +48,58 @@ public function getMediaOrder()
/**
* Get URI ot the associated media. Method will return null if path isn't URI.
*
- * @return null|string
+ * @return string|null
*/
public function getMediaUri()
{
- $folder = $this->getMediaFolder();
+ $folder = $this->getMediaFolder();
+ if (!$folder) {
+ return null;
+ }
- if (strpos($folder, '://')) {
- return $folder;
- }
+ if (strpos($folder, '://')) {
+ return $folder;
+ }
/** @var UniformResourceLocator $locator */
- $locator = Grav::instance()['locator'];
- $user = $locator->findResource('user://');
- if (strpos($folder, $user) === 0) {
- return 'user://' . substr($folder, \strlen($user)+1);
- }
+ $locator = Grav::instance()['locator'];
+ $user = $locator->findResource('user://');
+ if (strpos($folder, $user) === 0) {
+ return 'user://' . substr($folder, strlen($user)+1);
+ }
- return null;
+ return null;
}
/**
* Gets the associated media collection.
*
- * @return MediaCollectionInterface Representation of associated media.
+ * @return MediaCollectionInterface|Media Representation of associated media.
*/
public function getMedia()
{
- if ($this->media === null) {
+ $media = $this->media;
+ if (null === $media) {
$cache = $this->getMediaCache();
+ $cacheKey = md5('media' . $this->getCacheKey());
// Use cached media if possible.
- $cacheKey = md5('media' . $this->getCacheKey());
- if (!$media = $cache->get($cacheKey)) {
- $media = new Media($this->getMediaFolder(), $this->getMediaOrder());
+ $media = $cache->get($cacheKey);
+ if (!$media instanceof MediaCollectionInterface) {
+ $media = new Media($this->getMediaFolder(), $this->getMediaOrder(), $this->_loadMedia);
$cache->set($cacheKey, $media);
}
+
$this->media = $media;
}
- return $this->media;
+ return $media;
}
/**
* Sets the associated media collection.
*
- * @param MediaCollectionInterface $media Representation of associated media.
+ * @param MediaCollectionInterface|Media $media Representation of associated media.
* @return $this
*/
protected function setMedia(MediaCollectionInterface $media)
@@ -99,8 +113,18 @@ protected function setMedia(MediaCollectionInterface $media)
return $this;
}
+ /**
+ * @return void
+ */
+ protected function freeMedia()
+ {
+ $this->media = null;
+ }
+
/**
* Clear media cache.
+ *
+ * @return void
*/
protected function clearMediaCache()
{
@@ -108,7 +132,7 @@ protected function clearMediaCache()
$cacheKey = md5('media' . $this->getCacheKey());
$cache->delete($cacheKey);
- $this->media = null;
+ $this->freeMedia();
}
/**
@@ -125,5 +149,5 @@ protected function getMediaCache()
/**
* @return string
*/
- abstract protected function getCacheKey();
+ abstract protected function getCacheKey(): string;
}
diff --git a/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php b/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php
new file mode 100644
index 0000000000..88591f6aeb
--- /dev/null
+++ b/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php
@@ -0,0 +1,680 @@
+ true, // Whether path is in the media collection path itself.
+ 'avoid_overwriting' => false, // Do not override existing files (adds datetime postfix if conflict).
+ 'random_name' => false, // True if name needs to be randomized.
+ 'accept' => ['image/*'], // Accepted mime types or file extensions.
+ 'limit' => 10, // Maximum number of files.
+ 'filesize' => null, // Maximum filesize in MB.
+ 'destination' => null // Destination path, if empty, exception is thrown.
+ ];
+
+ /**
+ * Create Medium from an uploaded file.
+ *
+ * @param UploadedFileInterface $uploadedFile
+ * @param array $params
+ * @return Medium|null
+ */
+ public function createFromUploadedFile(UploadedFileInterface $uploadedFile, array $params = [])
+ {
+ return MediumFactory::fromUploadedFile($uploadedFile, $params);
+ }
+
+ /**
+ * Checks that uploaded file meets the requirements. Returns new filename.
+ *
+ * @example
+ * $filename = null; // Override filename if needed (ignored if randomizing filenames).
+ * $settings = ['destination' => 'user://pages/media']; // Settings from the form field.
+ * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);
+ * $media->copyUploadedFile($uploadedFile, $filename);
+ *
+ * @param UploadedFileInterface $uploadedFile
+ * @param string|null $filename
+ * @param array|null $settings
+ * @return string
+ * @throws RuntimeException
+ */
+ public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string
+ {
+ // Check if there is an upload error.
+ switch ($uploadedFile->getError()) {
+ case UPLOAD_ERR_OK:
+ break;
+ case UPLOAD_ERR_INI_SIZE:
+ case UPLOAD_ERR_FORM_SIZE:
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_FILESIZE_LIMIT'), 400);
+ case UPLOAD_ERR_PARTIAL:
+ case UPLOAD_ERR_NO_FILE:
+ if (!$uploadedFile instanceof FormFlashFile) {
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILES_SENT'), 400);
+ }
+ break;
+ case UPLOAD_ERR_NO_TMP_DIR:
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.UPLOAD_ERR_NO_TMP_DIR'), 400);
+ case UPLOAD_ERR_CANT_WRITE:
+ case UPLOAD_ERR_EXTENSION:
+ default:
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNKNOWN_ERRORS'), 400);
+ }
+
+ $metadata = [
+ 'filename' => $uploadedFile->getClientFilename(),
+ 'mime' => $uploadedFile->getClientMediaType(),
+ 'size' => $uploadedFile->getSize(),
+ ];
+
+ if ($uploadedFile instanceof FormFlashFile) {
+ $uploadedFile->checkXss();
+ }
+
+ return $this->checkFileMetadata($metadata, $filename, $settings);
+ }
+
+ /**
+ * Checks that file metadata meets the requirements. Returns new filename.
+ *
+ * @param array $metadata
+ * @param array|null $settings
+ * @return string
+ * @throws RuntimeException
+ */
+ public function checkFileMetadata(array $metadata, string $filename = null, array $settings = null): string
+ {
+ // Add the defaults to the settings.
+ $settings = $this->getUploadSettings($settings);
+
+ // Destination is always needed (but it can be set in defaults).
+ $self = $settings['self'] ?? false;
+ if (!isset($settings['destination']) && $self === false) {
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED'), 400);
+ }
+
+ if (null === $filename) {
+ // If no filename is given, use the filename from the uploaded file (path is not allowed).
+ $folder = '';
+ $filename = $metadata['filename'] ?? '';
+ } else {
+ // If caller sets the filename, we will accept any custom path.
+ $folder = dirname($filename);
+ if ($folder === '.') {
+ $folder = '';
+ }
+ $filename = Utils::basename($filename);
+ }
+ $extension = Utils::pathinfo($filename, PATHINFO_EXTENSION);
+
+ // Decide which filename to use.
+ if ($settings['random_name']) {
+ // Generate random filename if asked for.
+ $filename = mb_strtolower(Utils::generateRandomString(15) . '.' . $extension);
+ }
+
+ // Handle conflicting filename if needed.
+ if ($settings['avoid_overwriting']) {
+ $destination = $settings['destination'];
+ if ($destination && $this->fileExists($filename, $destination)) {
+ $filename = date('YmdHis') . '-' . $filename;
+ }
+ }
+ $filepath = $folder . $filename;
+
+ // Check if the filename is allowed.
+ if (!Utils::checkFilename($filename)) {
+ throw new RuntimeException(
+ sprintf($this->translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD'), $filepath, $this->translate('PLUGIN_ADMIN.BAD_FILENAME'))
+ );
+ }
+
+ // Check if the file extension is allowed.
+ $extension = mb_strtolower($extension);
+ if (!$extension || !$this->getConfig()->get("media.types.{$extension}")) {
+ // Not a supported type.
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400);
+ }
+
+ // Calculate maximum file size (from MB).
+ $filesize = $settings['filesize'];
+ if ($filesize) {
+ $max_filesize = $filesize * 1048576;
+ if ($metadata['size'] > $max_filesize) {
+ // TODO: use own language string
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400);
+ }
+ } elseif (null === $filesize) {
+ // Check size against the Grav upload limit.
+ $grav_limit = Utils::getUploadLimit();
+ if ($grav_limit > 0 && $metadata['size'] > $grav_limit) {
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400);
+ }
+ }
+
+ $grav = Grav::instance();
+ /** @var MimeTypes $mimeChecker */
+ $mimeChecker = $grav['mime'];
+
+ // Handle Accepted file types. Accept can only be mime types (image/png | image/*) or file extensions (.pdf | .jpg)
+ // Do not trust mime type sent by the browser.
+ $mime = $metadata['mime'] ?? $mimeChecker->getMimeType($extension);
+ $validExtensions = $mimeChecker->getExtensions($mime);
+ if (!in_array($extension, $validExtensions, true)) {
+ throw new RuntimeException('The mime type does not match to file extension', 400);
+ }
+
+ $accepted = false;
+ $errors = [];
+ foreach ((array)$settings['accept'] as $type) {
+ // Force acceptance of any file when star notation
+ if ($type === '*') {
+ $accepted = true;
+ break;
+ }
+
+ $isMime = strstr($type, '/');
+ $find = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type);
+
+ if ($isMime) {
+ $match = preg_match('#' . $find . '$#', $mime);
+ if (!$match) {
+ // TODO: translate
+ $errors[] = 'The MIME type "' . $mime . '" for the file "' . $filepath . '" is not an accepted.';
+ } else {
+ $accepted = true;
+ break;
+ }
+ } else {
+ $match = preg_match('#' . $find . '$#', $filename);
+ if (!$match) {
+ // TODO: translate
+ $errors[] = 'The File Extension for the file "' . $filepath . '" is not an accepted.';
+ } else {
+ $accepted = true;
+ break;
+ }
+ }
+ }
+ if (!$accepted) {
+ throw new RuntimeException(implode('
', $errors), 400);
+ }
+
+ return $filepath;
+ }
+
+ /**
+ * Copy uploaded file to the media collection.
+ *
+ * WARNING: Always check uploaded file before copying it!
+ *
+ * @example
+ * $settings = ['destination' => 'user://pages/media']; // Settings from the form field.
+ * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);
+ * $media->copyUploadedFile($uploadedFile, $filename, $settings);
+ *
+ * @param UploadedFileInterface $uploadedFile
+ * @param string $filename
+ * @param array|null $settings
+ * @return void
+ * @throws RuntimeException
+ */
+ public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename, array $settings = null): void
+ {
+ // Add the defaults to the settings.
+ $settings = $this->getUploadSettings($settings);
+
+ $path = $settings['destination'] ?? $this->getPath();
+ if (!$path || !$filename) {
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400);
+ }
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+
+ try {
+ // Clear locator cache to make sure we have up to date information from the filesystem.
+ $locator->clearCache();
+ $this->clearCache();
+
+ $filesystem = Filesystem::getInstance(false);
+
+ // Calculate path without the retina scaling factor.
+ $basename = $filesystem->basename($filename);
+ $pathname = $filesystem->pathname($filename);
+
+ // Get name for the uploaded file.
+ [$base, $ext,,] = $this->getFileParts($basename);
+ $name = "{$pathname}{$base}.{$ext}";
+
+ // Upload file.
+ if ($uploadedFile instanceof FormFlashFile) {
+ // FormFlashFile needs some additional logic.
+ if ($uploadedFile->getError() === \UPLOAD_ERR_OK) {
+ // Move uploaded file.
+ $this->doMoveUploadedFile($uploadedFile, $filename, $path);
+ } elseif (strpos($filename, 'original/') === 0 && !$this->fileExists($filename, $path) && $this->fileExists($basename, $path)) {
+ // Original image support: override original image if it's the same as the uploaded image.
+ $this->doCopy($basename, $filename, $path);
+ }
+
+ // FormFlashFile may also contain metadata.
+ $metadata = $uploadedFile->getMetaData();
+ if ($metadata) {
+ // TODO: This overrides metadata if used with multiple retina image sizes.
+ $this->doSaveMetadata(['upload' => $metadata], $name, $path);
+ }
+ } else {
+ // Not a FormFlashFile.
+ $this->doMoveUploadedFile($uploadedFile, $filename, $path);
+ }
+
+ // Post-processing: Special content sanitization for SVG.
+ $mime = Utils::getMimeByFilename($filename);
+ if (Utils::contains($mime, 'svg', false)) {
+ $this->doSanitizeSvg($filename, $path);
+ }
+
+ // Add the new file into the media.
+ // TODO: This overrides existing media sizes if used with multiple retina image sizes.
+ $this->doAddUploadedMedium($name, $filename, $path);
+
+ } catch (Exception $e) {
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE') . $e->getMessage(), 400);
+ } finally {
+ // Finally clear media cache.
+ $locator->clearCache();
+ $this->clearCache();
+ }
+ }
+
+ /**
+ * Delete real file from the media collection.
+ *
+ * @param string $filename
+ * @param array|null $settings
+ * @return void
+ * @throws RuntimeException
+ */
+ public function deleteFile(string $filename, array $settings = null): void
+ {
+ // Add the defaults to the settings.
+ $settings = $this->getUploadSettings($settings);
+ $filesystem = Filesystem::getInstance(false);
+
+ // First check for allowed filename.
+ $basename = $filesystem->basename($filename);
+ if (!Utils::checkFilename($basename)) {
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ": {$this->translate('PLUGIN_ADMIN.BAD_FILENAME')}: " . $filename, 400);
+ }
+
+ $path = $settings['destination'] ?? $this->getPath();
+ if (!$path) {
+ return;
+ }
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+ $locator->clearCache();
+
+ $pathname = $filesystem->pathname($filename);
+
+ // Get base name of the file.
+ [$base, $ext,,] = $this->getFileParts($basename);
+ $name = "{$pathname}{$base}.{$ext}";
+
+ // Remove file and all all the associated metadata.
+ $this->doRemove($name, $path);
+
+ // Finally clear media cache.
+ $locator->clearCache();
+ $this->clearCache();
+ }
+
+ /**
+ * Rename file inside the media collection.
+ *
+ * @param string $from
+ * @param string $to
+ * @param array|null $settings
+ */
+ public function renameFile(string $from, string $to, array $settings = null): void
+ {
+ // Add the defaults to the settings.
+ $settings = $this->getUploadSettings($settings);
+ $filesystem = Filesystem::getInstance(false);
+
+ $path = $settings['destination'] ?? $this->getPath();
+ if (!$path) {
+ // TODO: translate error message
+ throw new RuntimeException('Failed to rename file: Bad destination', 400);
+ }
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+ $locator->clearCache();
+
+ // Get base name of the file.
+ $pathname = $filesystem->pathname($from);
+
+ // Remove @2x, @3x and .meta.yaml
+ [$base, $ext,,] = $this->getFileParts($filesystem->basename($from));
+ $from = "{$pathname}{$base}.{$ext}";
+
+ [$base, $ext,,] = $this->getFileParts($filesystem->basename($to));
+ $to = "{$pathname}{$base}.{$ext}";
+
+ $this->doRename($from, $to, $path);
+
+ // Finally clear media cache.
+ $locator->clearCache();
+ $this->clearCache();
+ }
+
+ /**
+ * Internal logic to move uploaded file.
+ *
+ * @param UploadedFileInterface $uploadedFile
+ * @param string $filename
+ * @param string $path
+ */
+ protected function doMoveUploadedFile(UploadedFileInterface $uploadedFile, string $filename, string $path): void
+ {
+ $filepath = sprintf('%s/%s', $path, $filename);
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+
+ // Do not use streams internally.
+ if ($locator->isStream($filepath)) {
+ $filepath = (string)$locator->findResource($filepath, true, true);
+ }
+
+ Folder::create(dirname($filepath));
+
+ $uploadedFile->moveTo($filepath);
+ }
+
+ /**
+ * Get upload settings.
+ *
+ * @param array|null $settings Form field specific settings (override).
+ * @return array
+ */
+ public function getUploadSettings(?array $settings = null): array
+ {
+ return null !== $settings ? $settings + $this->_upload_defaults : $this->_upload_defaults;
+ }
+
+ /**
+ * Internal logic to copy file.
+ *
+ * @param string $src
+ * @param string $dst
+ * @param string $path
+ */
+ protected function doCopy(string $src, string $dst, string $path): void
+ {
+ $src = sprintf('%s/%s', $path, $src);
+ $dst = sprintf('%s/%s', $path, $dst);
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+
+ // Do not use streams internally.
+ if ($locator->isStream($dst)) {
+ $dst = (string)$locator->findResource($dst, true, true);
+ }
+
+ Folder::create(dirname($dst));
+
+ copy($src, $dst);
+ }
+
+ /**
+ * Internal logic to rename file.
+ *
+ * @param string $from
+ * @param string $to
+ * @param string $path
+ */
+ protected function doRename(string $from, string $to, string $path): void
+ {
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+
+ $fromPath = $path . '/' . $from;
+ if ($locator->isStream($fromPath)) {
+ $fromPath = $locator->findResource($fromPath, true, true);
+ }
+
+ if (!is_file($fromPath)) {
+ return;
+ }
+
+ $mediaPath = dirname($fromPath);
+ $toPath = $mediaPath . '/' . $to;
+ if ($locator->isStream($toPath)) {
+ $toPath = $locator->findResource($toPath, true, true);
+ }
+
+ if (is_file($toPath)) {
+ // TODO: translate error message
+ throw new RuntimeException(sprintf('File could not be renamed: %s already exists (%s)', $to, $mediaPath), 500);
+ }
+
+ $result = rename($fromPath, $toPath);
+ if (!$result) {
+ // TODO: translate error message
+ throw new RuntimeException(sprintf('File could not be renamed: %s -> %s (%s)', $from, $to, $mediaPath), 500);
+ }
+
+ // TODO: Add missing logic to handle retina files.
+ if (is_file($fromPath . '.meta.yaml')) {
+ $result = rename($fromPath . '.meta.yaml', $toPath . '.meta.yaml');
+ if (!$result) {
+ // TODO: translate error message
+ throw new RuntimeException(sprintf('Meta could not be renamed: %s -> %s (%s)', $from, $to, $mediaPath), 500);
+ }
+ }
+ }
+
+ /**
+ * Internal logic to remove file.
+ *
+ * @param string $filename
+ * @param string $path
+ */
+ protected function doRemove(string $filename, string $path): void
+ {
+ $filesystem = Filesystem::getInstance(false);
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+
+ // If path doesn't exist, there's nothing to do.
+ $pathname = $filesystem->pathname($filename);
+ if (!$this->fileExists($pathname, $path)) {
+ return;
+ }
+
+ $folder = $locator->isStream($path) ? (string)$locator->findResource($path, true, true) : $path;
+
+ // Remove requested media file.
+ if ($this->fileExists($filename, $path)) {
+ $result = unlink("{$folder}/{$filename}");
+ if (!$result) {
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500);
+ }
+ }
+
+ // Remove associated metadata.
+ $this->doRemoveMetadata($filename, $path);
+
+ // Remove associated 2x, 3x and their .meta.yaml files.
+ $targetPath = rtrim(sprintf('%s/%s', $folder, $pathname), '/');
+ $dir = scandir($targetPath, SCANDIR_SORT_NONE);
+ if (false === $dir) {
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500);
+ }
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+
+ $basename = $filesystem->basename($filename);
+ $fileParts = (array)$filesystem->pathinfo($filename);
+
+ foreach ($dir as $file) {
+ $preg_name = preg_quote($fileParts['filename'], '`');
+ $preg_ext = preg_quote($fileParts['extension'] ?? '.', '`');
+ $preg_filename = preg_quote($basename, '`');
+
+ if (preg_match("`({$preg_name}@\d+x\.{$preg_ext}(?:\.meta\.yaml)?$|{$preg_filename}\.meta\.yaml)$`", $file)) {
+ $testPath = $targetPath . '/' . $file;
+ if ($locator->isStream($testPath)) {
+ $testPath = (string)$locator->findResource($testPath, true, true);
+ $locator->clearCache($testPath);
+ }
+
+ if (is_file($testPath)) {
+ $result = unlink($testPath);
+ if (!$result) {
+ throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500);
+ }
+ }
+ }
+ }
+
+ $this->hide($filename);
+ }
+
+ /**
+ * @param array $metadata
+ * @param string $filename
+ * @param string $path
+ */
+ protected function doSaveMetadata(array $metadata, string $filename, string $path): void
+ {
+ $filepath = sprintf('%s/%s', $path, $filename);
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+
+ // Do not use streams internally.
+ if ($locator->isStream($filepath)) {
+ $filepath = (string)$locator->findResource($filepath, true, true);
+ }
+
+ $file = YamlFile::instance($filepath . '.meta.yaml');
+ $file->save($metadata);
+ }
+
+ /**
+ * @param string $filename
+ * @param string $path
+ */
+ protected function doRemoveMetadata(string $filename, string $path): void
+ {
+ $filepath = sprintf('%s/%s', $path, $filename);
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+
+ // Do not use streams internally.
+ if ($locator->isStream($filepath)) {
+ $filepath = (string)$locator->findResource($filepath, true);
+ if (!$filepath) {
+ return;
+ }
+ }
+
+ $file = YamlFile::instance($filepath . '.meta.yaml');
+ if ($file->exists()) {
+ $file->delete();
+ }
+ }
+
+ /**
+ * @param string $filename
+ * @param string $path
+ */
+ protected function doSanitizeSvg(string $filename, string $path): void
+ {
+ $filepath = sprintf('%s/%s', $path, $filename);
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+
+ // Do not use streams internally.
+ if ($locator->isStream($filepath)) {
+ $filepath = (string)$locator->findResource($filepath, true, true);
+ }
+
+ Security::sanitizeSVG($filepath);
+ }
+
+ /**
+ * @param string $name
+ * @param string $filename
+ * @param string $path
+ */
+ protected function doAddUploadedMedium(string $name, string $filename, string $path): void
+ {
+ $filepath = sprintf('%s/%s', $path, $filename);
+ $medium = $this->createFromFile($filepath);
+ $realpath = $path . '/' . $name;
+ $this->add($realpath, $medium);
+ }
+
+ /**
+ * @param string $string
+ * @return string
+ */
+ protected function translate(string $string): string
+ {
+ return $this->getLanguage()->translate($string);
+ }
+
+ abstract protected function getPath(): ?string;
+
+ abstract protected function getGrav(): Grav;
+
+ abstract protected function getConfig(): Config;
+
+ abstract protected function getLanguage(): Language;
+
+ abstract protected function clearCache(): void;
+}
diff --git a/system/src/Grav/Common/Media/Traits/StaticResizeTrait.php b/system/src/Grav/Common/Media/Traits/StaticResizeTrait.php
new file mode 100644
index 0000000000..d8a4b08315
--- /dev/null
+++ b/system/src/Grav/Common/Media/Traits/StaticResizeTrait.php
@@ -0,0 +1,40 @@
+styleAttributes['width'] = $width . 'px';
+ } else {
+ unset($this->styleAttributes['width']);
+ }
+ if ($height) {
+ $this->styleAttributes['height'] = $height . 'px';
+ } else {
+ unset($this->styleAttributes['height']);
+ }
+
+ return $this;
+ }
+}
diff --git a/system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php b/system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php
new file mode 100644
index 0000000000..100b5321cc
--- /dev/null
+++ b/system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php
@@ -0,0 +1,149 @@
+bubble('parsedownElement', [$title, $alt, $class, $id, $reset]);
+ }
+
+ /**
+ * Return HTML markup from the medium.
+ *
+ * @param string|null $title
+ * @param string|null $alt
+ * @param string|null $class
+ * @param string|null $id
+ * @param bool $reset
+ * @return string
+ */
+ public function html($title = null, $alt = null, $class = null, $id = null, $reset = true)
+ {
+ return $this->bubble('html', [$title, $alt, $class, $id, $reset]);
+ }
+
+ /**
+ * Switch display mode.
+ *
+ * @param string $mode
+ *
+ * @return MediaLinkInterface|MediaObjectInterface|null
+ */
+ public function display($mode = 'source')
+ {
+ return $this->bubble('display', [$mode], false);
+ }
+
+ /**
+ * Switch thumbnail.
+ *
+ * @param string $type
+ *
+ * @return MediaLinkInterface|MediaObjectInterface
+ */
+ public function thumbnail($type = 'auto')
+ {
+ $this->bubble('thumbnail', [$type], false);
+
+ return $this->bubble('getThumbnail', [], false);
+ }
+
+ /**
+ * Turn the current Medium into a Link
+ *
+ * @param bool $reset
+ * @param array $attributes
+ * @return MediaLinkInterface
+ */
+ public function link($reset = true, array $attributes = [])
+ {
+ return $this->bubble('link', [$reset, $attributes], false);
+ }
+
+ /**
+ * Turn the current Medium into a Link with lightbox enabled
+ *
+ * @param int|null $width
+ * @param int|null $height
+ * @param bool $reset
+ * @return MediaLinkInterface
+ */
+ public function lightbox($width = null, $height = null, $reset = true)
+ {
+ return $this->bubble('lightbox', [$width, $height, $reset], false);
+ }
+
+ /**
+ * Bubble a function call up to either the superclass function or the parent Medium instance
+ *
+ * @param string $method
+ * @param array $arguments
+ * @param bool $testLinked
+ * @return mixed
+ */
+ protected function bubble($method, array $arguments = [], $testLinked = true)
+ {
+ if (!$testLinked || $this->linked) {
+ $parent = $this->parent;
+ if (null === $parent) {
+ return $this;
+ }
+
+ $closure = [$parent, $method];
+
+ if (!is_callable($closure)) {
+ throw new BadMethodCallException(get_class($parent) . '::' . $method . '() not found.');
+ }
+
+ return $closure(...$arguments);
+ }
+
+ return parent::{$method}(...$arguments);
+ }
+}
diff --git a/system/src/Grav/Common/Media/Traits/VideoMediaTrait.php b/system/src/Grav/Common/Media/Traits/VideoMediaTrait.php
new file mode 100644
index 0000000000..07f0c3f12a
--- /dev/null
+++ b/system/src/Grav/Common/Media/Traits/VideoMediaTrait.php
@@ -0,0 +1,68 @@
+attributes['poster'] = $urlImage;
+
+ return $this;
+ }
+
+ /**
+ * Allows to set the playsinline attribute
+ *
+ * @param bool $status
+ * @return $this
+ */
+ public function playsinline($status = false)
+ {
+ if ($status) {
+ $this->attributes['playsinline'] = 'playsinline';
+ } else {
+ unset($this->attributes['playsinline']);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Parsedown element for source display mode
+ *
+ * @param array $attributes
+ * @param bool $reset
+ * @return array
+ */
+ protected function sourceParsedownElement(array $attributes, $reset = true)
+ {
+ $location = $this->url($reset);
+
+ return [
+ 'name' => 'video',
+ 'rawHtml' => 'Your browser does not support the video tag.',
+ 'attributes' => $attributes
+ ];
+ }
+}
diff --git a/system/src/Grav/Common/Page/Collection.php b/system/src/Grav/Common/Page/Collection.php
index 51de16b021..987b8d9317 100644
--- a/system/src/Grav/Common/Page/Collection.php
+++ b/system/src/Grav/Common/Page/Collection.php
@@ -3,27 +3,37 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page;
+use Exception;
use Grav\Common\Grav;
use Grav\Common\Iterator;
+use Grav\Common\Page\Interfaces\PageCollectionInterface;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Utils;
+use InvalidArgumentException;
+use function array_key_exists;
+use function array_keys;
+use function array_search;
+use function count;
+use function in_array;
+use function is_array;
+use function is_string;
-class Collection extends Iterator
+/**
+ * Class Collection
+ * @package Grav\Common\Page
+ * @implements PageCollectionInterface
+ */
+class Collection extends Iterator implements PageCollectionInterface
{
- /**
- * @var Pages
- */
+ /** @var Pages */
protected $pages;
-
- /**
- * @var array
- */
+ /** @var array */
protected $params;
/**
@@ -38,7 +48,7 @@ public function __construct($items = [], array $params = [], Pages $pages = null
parent::__construct($items);
$this->params = $params;
- $this->pages = $pages ? $pages : Grav::instance()->offsetGet('pages');
+ $this->pages = $pages ?: Grav::instance()->offsetGet('pages');
}
/**
@@ -51,11 +61,23 @@ public function params()
return $this->params;
}
+ /**
+ * Set parameters to the Collection
+ *
+ * @param array $params
+ * @return $this
+ */
+ public function setParams(array $params)
+ {
+ $this->params = array_merge($this->params, $params);
+
+ return $this;
+ }
+
/**
* Add a single page to a collection
*
* @param PageInterface $page
- *
* @return $this
*/
public function addPage(PageInterface $page)
@@ -94,12 +116,12 @@ public function copy()
*
* Merge another collection with the current collection
*
- * @param Collection $collection
+ * @param PageCollectionInterface $collection
* @return $this
*/
- public function merge(Collection $collection)
+ public function merge(PageCollectionInterface $collection)
{
- foreach($collection as $page) {
+ foreach ($collection as $page) {
$this->addPage($page);
}
@@ -109,15 +131,15 @@ public function merge(Collection $collection)
/**
* Intersect another collection with the current collection
*
- * @param Collection $collection
+ * @param PageCollectionInterface $collection
* @return $this
*/
- public function intersect(Collection $collection)
+ public function intersect(PageCollectionInterface $collection)
{
$array1 = $this->items;
$array2 = $collection->toArray();
- $this->items = array_uintersect($array1, $array2, function($val1, $val2) {
+ $this->items = array_uintersect($array1, $array2, function ($val1, $val2) {
return strcmp($val1['slug'], $val2['slug']);
});
@@ -125,17 +147,15 @@ public function intersect(Collection $collection)
}
/**
- * Set parameters to the Collection
- *
- * @param array $params
- *
- * @return $this
+ * Set current page.
*/
- public function setParams(array $params)
+ public function setCurrent(string $path): void
{
- $this->params = array_merge($this->params, $params);
+ reset($this->items);
- return $this;
+ while (($key = key($this->items)) !== null && $key !== $path) {
+ next($this->items);
+ }
}
/**
@@ -143,6 +163,7 @@ public function setParams(array $params)
*
* @return PageInterface
*/
+ #[\ReturnTypeWillChange]
public function current()
{
$current = parent::key();
@@ -155,6 +176,7 @@ public function current()
*
* @return mixed
*/
+ #[\ReturnTypeWillChange]
public function key()
{
$current = parent::current();
@@ -165,10 +187,10 @@ public function key()
/**
* Returns the value at specified offset.
*
- * @param mixed $offset The offset to retrieve.
- *
- * @return mixed Can return all value types.
+ * @param string $offset
+ * @return PageInterface|null
*/
+ #[\ReturnTypeWillChange]
public function offsetGet($offset)
{
return $this->pages->get($offset) ?: null;
@@ -178,7 +200,7 @@ public function offsetGet($offset)
* Split collection into array of smaller collections.
*
* @param int $size
- * @return array|Collection[]
+ * @return Collection[]
*/
public function batch($size)
{
@@ -196,9 +218,8 @@ public function batch($size)
* Remove item from the list.
*
* @param PageInterface|string|null $key
- *
* @return $this
- * @throws \InvalidArgumentException
+ * @throws InvalidArgumentException
*/
public function remove($key = null)
{
@@ -207,8 +228,8 @@ public function remove($key = null)
} elseif (null === $key) {
$key = (string)key($this->items);
}
- if (!\is_string($key)) {
- throw new \InvalidArgumentException('Invalid argument $key.');
+ if (!is_string($key)) {
+ throw new InvalidArgumentException('Invalid argument $key.');
}
parent::remove($key);
@@ -221,9 +242,8 @@ public function remove($key = null)
*
* @param string $by
* @param string $dir
- * @param array $manual
- * @param string $sort_flags
- *
+ * @param array|null $manual
+ * @param string|null $sort_flags
* @return $this
*/
public function order($by, $dir = 'asc', $manual = null, $sort_flags = null)
@@ -237,10 +257,9 @@ public function order($by, $dir = 'asc', $manual = null, $sort_flags = null)
* Check to see if this item is the first in the collection.
*
* @param string $path
- *
* @return bool True if item is first.
*/
- public function isFirst($path)
+ public function isFirst($path): bool
{
return $this->items && $path === array_keys($this->items)[0];
}
@@ -249,12 +268,11 @@ public function isFirst($path)
* Check to see if this item is the last in the collection.
*
* @param string $path
- *
* @return bool True if item is last.
*/
- public function isLast($path)
+ public function isLast($path): bool
{
- return $this->items && $path === array_keys($this->items)[\count($this->items) - 1];
+ return $this->items && $path === array_keys($this->items)[count($this->items) - 1];
}
/**
@@ -286,7 +304,6 @@ public function nextSibling($path)
*
* @param string $path
* @param int $direction either -1 or +1
- *
* @return PageInterface|Collection The sibling item.
*/
public function adjacentSibling($path, $direction = 1)
@@ -301,48 +318,49 @@ public function adjacentSibling($path, $direction = 1)
}
return $this;
-
}
/**
* Returns the item in the current position.
*
* @param string $path the path the item
- *
- * @return int the index of the current page.
+ * @return int|null The index of the current page, null if not found.
*/
- public function currentPosition($path)
+ public function currentPosition($path): ?int
{
- return \array_search($path, \array_keys($this->items), true);
+ $pos = array_search($path, array_keys($this->items), true);
+
+ return $pos !== false ? $pos : null;
}
/**
* Returns the items between a set of date ranges of either the page date field (default) or
- * an arbitrary datetime page field where end date is optional
- * Dates can be passed in as text that strtotime() can process
+ * an arbitrary datetime page field where start date and end date are optional
+ * Dates must be passed in as text that strtotime() can process
* http://php.net/manual/en/function.strtotime.php
*
- * @param string $startDate
- * @param bool $endDate
+ * @param string|null $startDate
+ * @param string|null $endDate
* @param string|null $field
- *
* @return $this
- * @throws \Exception
+ * @throws Exception
*/
- public function dateRange($startDate, $endDate = false, $field = null)
+ public function dateRange($startDate = null, $endDate = null, $field = null)
{
- $start = Utils::date2timestamp($startDate);
- $end = $endDate ? Utils::date2timestamp($endDate) : false;
+ $start = $startDate ? Utils::date2timestamp($startDate) : null;
+ $end = $endDate ? Utils::date2timestamp($endDate) : null;
$date_range = [];
foreach ($this->items as $path => $slug) {
$page = $this->pages->get($path);
- if ($page !== null) {
- $date = $field ? strtotime($page->value($field)) : $page->date();
+ if (!$page) {
+ continue;
+ }
- if ($date >= $start && (!$end || $date <= $end)) {
- $date_range[$path] = $slug;
- }
+ $date = $field ? strtotime($page->value($field)) : $page->date();
+
+ if ((!$start || $date >= $start) && (!$end || $date <= $end)) {
+ $date_range[$path] = $slug;
}
}
@@ -392,17 +410,17 @@ public function nonVisible()
}
/**
- * Creates new collection with only modular pages
+ * Creates new collection with only pages
*
- * @return Collection The collection with only modular pages
+ * @return Collection The collection with only pages
*/
- public function modular()
+ public function pages()
{
$modular = [];
foreach ($this->items as $path => $slug) {
$page = $this->pages->get($path);
- if ($page !== null && $page->modular()) {
+ if ($page !== null && !$page->isModule()) {
$modular[$path] = $slug;
}
}
@@ -412,17 +430,17 @@ public function modular()
}
/**
- * Creates new collection with only non-modular pages
+ * Creates new collection with only modules
*
- * @return Collection The collection with only non-modular pages
+ * @return Collection The collection with only modules
*/
- public function nonModular()
+ public function modules()
{
$modular = [];
foreach ($this->items as $path => $slug) {
$page = $this->pages->get($path);
- if ($page !== null && !$page->modular()) {
+ if ($page !== null && $page->isModule()) {
$modular[$path] = $slug;
}
}
@@ -431,6 +449,72 @@ public function nonModular()
return $this;
}
+ /**
+ * Alias of pages()
+ *
+ * @return Collection The collection with only non-module pages
+ */
+ public function nonModular()
+ {
+ $this->pages();
+
+ return $this;
+ }
+
+ /**
+ * Alias of modules()
+ *
+ * @return Collection The collection with only modules
+ */
+ public function modular()
+ {
+ $this->modules();
+
+ return $this;
+ }
+
+ /**
+ * Creates new collection with only translated pages
+ *
+ * @return Collection The collection with only published pages
+ * @internal
+ */
+ public function translated()
+ {
+ $published = [];
+
+ foreach ($this->items as $path => $slug) {
+ $page = $this->pages->get($path);
+ if ($page !== null && $page->translated()) {
+ $published[$path] = $slug;
+ }
+ }
+ $this->items = $published;
+
+ return $this;
+ }
+
+ /**
+ * Creates new collection with only untranslated pages
+ *
+ * @return Collection The collection with only non-published pages
+ * @internal
+ */
+ public function nonTranslated()
+ {
+ $published = [];
+
+ foreach ($this->items as $path => $slug) {
+ $page = $this->pages->get($path);
+ if ($page !== null && !$page->translated()) {
+ $published[$path] = $slug;
+ }
+ }
+ $this->items = $published;
+
+ return $this;
+ }
+
/**
* Creates new collection with only published pages
*
@@ -517,7 +601,6 @@ public function nonRoutable()
* Creates new collection with only pages of the specified type
*
* @param string $type
- *
* @return Collection The collection
*/
public function ofType($type)
@@ -540,7 +623,6 @@ public function ofType($type)
* Creates new collection with only pages of one of the specified types
*
* @param string[] $types
- *
* @return Collection The collection
*/
public function ofOneOfTheseTypes($types)
@@ -549,7 +631,7 @@ public function ofOneOfTheseTypes($types)
foreach ($this->items as $path => $slug) {
$page = $this->pages->get($path);
- if ($page !== null && \in_array($page->template(), $types, true)) {
+ if ($page !== null && in_array($page->template(), $types, true)) {
$items[$path] = $slug;
}
}
@@ -563,7 +645,6 @@ public function ofOneOfTheseTypes($types)
* Creates new collection with only pages of one of the specified access levels
*
* @param array $accessLevels
- *
* @return Collection The collection
*/
public function ofOneOfTheseAccessLevels($accessLevels)
@@ -574,19 +655,19 @@ public function ofOneOfTheseAccessLevels($accessLevels)
$page = $this->pages->get($path);
if ($page !== null && isset($page->header()->access)) {
- if (\is_array($page->header()->access)) {
+ if (is_array($page->header()->access)) {
//Multiple values for access
$valid = false;
foreach ($page->header()->access as $index => $accessLevel) {
- if (\is_array($accessLevel)) {
+ if (is_array($accessLevel)) {
foreach ($accessLevel as $innerIndex => $innerAccessLevel) {
- if (\in_array($innerAccessLevel, $accessLevels)) {
+ if (in_array($innerAccessLevel, $accessLevels, false)) {
$valid = true;
}
}
} else {
- if (\in_array($index, $accessLevels)) {
+ if (in_array($index, $accessLevels, false)) {
$valid = true;
}
}
@@ -596,11 +677,10 @@ public function ofOneOfTheseAccessLevels($accessLevels)
}
} else {
//Single value for access
- if (\in_array($page->header()->access, $accessLevels)) {
+ if (in_array($page->header()->access, $accessLevels, false)) {
$items[$path] = $slug;
}
}
-
}
}
@@ -613,7 +693,7 @@ public function ofOneOfTheseAccessLevels($accessLevels)
* Get the extended version of this Collection with each page keyed by route
*
* @return array
- * @throws \Exception
+ * @throws Exception
*/
public function toExtendedArray()
{
diff --git a/system/src/Grav/Common/Page/Header.php b/system/src/Grav/Common/Page/Header.php
index e3854aecfe..c18b58d378 100644
--- a/system/src/Grav/Common/Page/Header.php
+++ b/system/src/Grav/Common/Page/Header.php
@@ -3,16 +3,36 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page;
+use ArrayAccess;
+use JsonSerializable;
use RocketTheme\Toolbox\ArrayTraits\Constructor;
-use RocketTheme\Toolbox\ArrayTraits\NestedArrayAccess;
+use RocketTheme\Toolbox\ArrayTraits\Export;
+use RocketTheme\Toolbox\ArrayTraits\ExportInterface;
+use RocketTheme\Toolbox\ArrayTraits\NestedArrayAccessWithGetters;
-class Header implements \ArrayAccess
+/**
+ * Class Header
+ * @package Grav\Common\Page
+ */
+class Header implements ArrayAccess, ExportInterface, JsonSerializable
{
- use NestedArrayAccess, Constructor;
+ use NestedArrayAccessWithGetters, Constructor, Export;
+
+ /** @var array */
+ protected $items;
+
+ /**
+ * @return array
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize()
+ {
+ return $this->toArray();
+ }
}
diff --git a/system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php b/system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php
new file mode 100644
index 0000000000..493ab43985
--- /dev/null
+++ b/system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php
@@ -0,0 +1,310 @@
+
+ * @extends ArrayAccess
+ */
+interface PageCollectionInterface extends Traversable, ArrayAccess, Countable, Serializable
+{
+ /**
+ * Get the collection params
+ *
+ * @return array
+ */
+ public function params();
+
+ /**
+ * Set parameters to the Collection
+ *
+ * @param array $params
+ * @return $this
+ */
+ public function setParams(array $params);
+
+ /**
+ * Add a single page to a collection
+ *
+ * @param PageInterface $page
+ * @return $this
+ */
+ public function addPage(PageInterface $page);
+
+ /**
+ * Add a page with path and slug
+ *
+ * @param string $path
+ * @param string $slug
+ * @return $this
+ */
+ //public function add($path, $slug);
+
+ /**
+ *
+ * Create a copy of this collection
+ *
+ * @return static
+ */
+ public function copy();
+
+ /**
+ *
+ * Merge another collection with the current collection
+ *
+ * @param PageCollectionInterface $collection
+ * @return PageCollectionInterface
+ * @phpstan-return PageCollectionInterface
+ */
+ public function merge(PageCollectionInterface $collection);
+
+ /**
+ * Intersect another collection with the current collection
+ *
+ * @param PageCollectionInterface $collection
+ * @return PageCollectionInterface
+ * @phpstan-return PageCollectionInterface
+ */
+ public function intersect(PageCollectionInterface $collection);
+
+ /**
+ * Split collection into array of smaller collections.
+ *
+ * @param int $size
+ * @return PageCollectionInterface[]
+ * @phpstan-return array>
+ */
+ public function batch($size);
+
+ /**
+ * Remove item from the list.
+ *
+ * @param PageInterface|string|null $key
+ * @return PageCollectionInterface
+ * @phpstan-return PageCollectionInterface
+ * @throws InvalidArgumentException
+ */
+ //public function remove($key = null);
+
+ /**
+ * Reorder collection.
+ *
+ * @param string $by
+ * @param string $dir
+ * @param array|null $manual
+ * @param string|null $sort_flags
+ * @return PageCollectionInterface
+ * @phpstan-return PageCollectionInterface
+ */
+ public function order($by, $dir = 'asc', $manual = null, $sort_flags = null);
+
+ /**
+ * Check to see if this item is the first in the collection.
+ *
+ * @param string $path
+ * @return bool True if item is first.
+ */
+ public function isFirst($path): bool;
+
+ /**
+ * Check to see if this item is the last in the collection.
+ *
+ * @param string $path
+ * @return bool True if item is last.
+ */
+ public function isLast($path): bool;
+
+ /**
+ * Gets the previous sibling based on current position.
+ *
+ * @param string $path
+ * @return PageInterface The previous item.
+ * @phpstan-return T
+ */
+ public function prevSibling($path);
+
+ /**
+ * Gets the next sibling based on current position.
+ *
+ * @param string $path
+ * @return PageInterface The next item.
+ * @phpstan-return T
+ */
+ public function nextSibling($path);
+
+ /**
+ * Returns the adjacent sibling based on a direction.
+ *
+ * @param string $path
+ * @param int $direction either -1 or +1
+ * @return PageInterface|PageCollectionInterface|false The sibling item.
+ * @phpstan-return T|false
+ */
+ public function adjacentSibling($path, $direction = 1);
+
+ /**
+ * Returns the item in the current position.
+ *
+ * @param string $path the path the item
+ * @return int|null The index of the current page, null if not found.
+ */
+ public function currentPosition($path): ?int;
+
+ /**
+ * Returns the items between a set of date ranges of either the page date field (default) or
+ * an arbitrary datetime page field where start date and end date are optional
+ * Dates must be passed in as text that strtotime() can process
+ * http://php.net/manual/en/function.strtotime.php
+ *
+ * @param string|null $startDate
+ * @param string|null $endDate
+ * @param string|null $field
+ * @return PageCollectionInterface
+ * @phpstan-return PageCollectionInterface
+ * @throws Exception
+ */
+ public function dateRange($startDate = null, $endDate = null, $field = null);
+
+ /**
+ * Creates new collection with only visible pages
+ *
+ * @return PageCollectionInterface The collection with only visible pages
+ * @phpstan-return PageCollectionInterface
+ */
+ public function visible();
+
+ /**
+ * Creates new collection with only non-visible pages
+ *
+ * @return PageCollectionInterface The collection with only non-visible pages
+ * @phpstan-return PageCollectionInterface
+ */
+ public function nonVisible();
+
+ /**
+ * Creates new collection with only pages
+ *
+ * @return PageCollectionInterface The collection with only pages
+ * @phpstan-return PageCollectionInterface
+ */
+ public function pages();
+
+ /**
+ * Creates new collection with only modules
+ *
+ * @return PageCollectionInterface The collection with only modules
+ * @phpstan-return PageCollectionInterface
+ */
+ public function modules();
+
+ /**
+ * Creates new collection with only modules
+ *
+ * @return PageCollectionInterface The collection with only modules
+ * @phpstan-return PageCollectionInterface
+ * @deprecated 1.7 Use $this->modules() instead
+ */
+ public function modular();
+
+ /**
+ * Creates new collection with only non-module pages
+ *
+ * @return PageCollectionInterface The collection with only non-module pages
+ * @phpstan-return PageCollectionInterface
+ * @deprecated 1.7 Use $this->pages() instead
+ */
+ public function nonModular();
+
+ /**
+ * Creates new collection with only published pages
+ *
+ * @return PageCollectionInterface The collection with only published pages
+ * @phpstan-return PageCollectionInterface
+ */
+ public function published();
+
+ /**
+ * Creates new collection with only non-published pages
+ *
+ * @return PageCollectionInterface The collection with only non-published pages
+ * @phpstan-return PageCollectionInterface
+ */
+ public function nonPublished();
+
+ /**
+ * Creates new collection with only routable pages
+ *
+ * @return PageCollectionInterface The collection with only routable pages
+ * @phpstan-return PageCollectionInterface
+ */
+ public function routable();
+
+ /**
+ * Creates new collection with only non-routable pages
+ *
+ * @return PageCollectionInterface The collection with only non-routable pages
+ * @phpstan-return PageCollectionInterface
+ */
+ public function nonRoutable();
+
+ /**
+ * Creates new collection with only pages of the specified type
+ *
+ * @param string $type
+ * @return PageCollectionInterface The collection
+ * @phpstan-return PageCollectionInterface
+ */
+ public function ofType($type);
+
+ /**
+ * Creates new collection with only pages of one of the specified types
+ *
+ * @param string[] $types
+ * @return PageCollectionInterface The collection
+ * @phpstan-return PageCollectionInterface
+ */
+ public function ofOneOfTheseTypes($types);
+
+ /**
+ * Creates new collection with only pages of one of the specified access levels
+ *
+ * @param array $accessLevels
+ * @return PageCollectionInterface The collection
+ * @phpstan-return PageCollectionInterface
+ */
+ public function ofOneOfTheseAccessLevels($accessLevels);
+
+ /**
+ * Converts collection into an array.
+ *
+ * @return array
+ */
+ public function toArray();
+
+ /**
+ * Get the extended version of this Collection with each page keyed by route
+ *
+ * @return array
+ * @throws Exception
+ */
+ public function toExtendedArray();
+}
diff --git a/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php b/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php
index 8a3ee83be3..a80972b6bc 100644
--- a/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php
+++ b/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php
@@ -3,13 +3,15 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Interfaces;
-use Grav\Common\Page\Media;
+use Grav\Common\Data\Blueprint;
+use Grav\Common\Media\Interfaces\MediaCollectionInterface;
+use Grav\Common\Page\Header;
/**
* Methods currently implemented in Flex Page emulation layer.
@@ -19,28 +21,31 @@ interface PageContentInterface
/**
* Gets and Sets the header based on the YAML configuration at the top of the .md file
*
- * @param object|array $var a YAML object representing the configuration for the file
- *
- * @return object the current YAML configuration
+ * @param object|array|null $var a YAML object representing the configuration for the file
+ * @return \stdClass|Header The current YAML configuration
*/
public function header($var = null);
/**
* Get the summary.
*
- * @param int $size Max summary size.
- *
+ * @param int|null $size Max summary size.
* @param bool $textOnly Only count text size.
- *
* @return string
*/
public function summary($size = null, $textOnly = false);
/**
- * Gets and Sets the content based on content portion of the .md file
+ * Sets the summary of the page
*
- * @param string $var Content
+ * @param string $summary Summary
+ */
+ public function setSummary($summary);
+
+ /**
+ * Gets and Sets the content based on content portion of the .md file
*
+ * @param string|null $var Content
* @return string Content
*/
public function content($var = null);
@@ -55,7 +60,7 @@ public function getRawContent();
/**
* Needed by the onPageContentProcessed event to set the raw page content
*
- * @param string $content
+ * @param string|null $content
*/
public function setRawContent($content);
@@ -63,8 +68,7 @@ public function setRawContent($content);
* Gets and Sets the Page raw content
*
* @param string|null $var
- *
- * @return null
+ * @return string
*/
public function rawMarkdown($var = null);
@@ -72,8 +76,7 @@ public function rawMarkdown($var = null);
* Get value from a page variable (used mostly for creating edit forms).
*
* @param string $name Variable name.
- * @param mixed $default
- *
+ * @param mixed|null $default
* @return mixed
*/
public function value($name, $default = null);
@@ -81,18 +84,16 @@ public function value($name, $default = null);
/**
* Gets and sets the associated media as found in the page folder.
*
- * @param Media $var Representation of associated media.
- *
- * @return Media Representation of associated media.
+ * @param MediaCollectionInterface|null $var New media object.
+ * @return MediaCollectionInterface Representation of associated media.
*/
public function media($var = null);
/**
* Gets and sets the title for this Page. If no title is set, it will use the slug() to get a name
*
- * @param string $var the title of the Page
- *
- * @return string the title of the Page
+ * @param string|null $var New title of the Page
+ * @return string The title of the Page
*/
public function title($var = null);
@@ -100,45 +101,40 @@ public function title($var = null);
* Gets and sets the menu name for this Page. This is the text that can be used specifically for navigation.
* If no menu field is set, it will use the title()
*
- * @param string $var the menu field for the page
- *
- * @return string the menu field for the page
+ * @param string|null $var New menu field for the page
+ * @return string The menu field for the page
*/
public function menu($var = null);
/**
* Gets and Sets whether or not this Page is visible for navigation
*
- * @param bool $var true if the page is visible
- *
- * @return bool true if the page is visible
+ * @param bool|null $var New value
+ * @return bool True if the page is visible
*/
public function visible($var = null);
/**
* Gets and Sets whether or not this Page is considered published
*
- * @param bool $var true if the page is published
- *
- * @return bool true if the page is published
+ * @param bool|null $var New value
+ * @return bool True if the page is published
*/
public function published($var = null);
/**
* Gets and Sets the Page publish date
*
- * @param string $var string representation of a date
- *
- * @return int unix timestamp representation of the date
+ * @param string|null $var String representation of the new date
+ * @return int Unix timestamp representation of the date
*/
public function publishDate($var = null);
/**
* Gets and Sets the Page unpublish date
*
- * @param string $var string representation of a date
- *
- * @return int|null unix timestamp representation of the date
+ * @param string|null $var String representation of the new date
+ * @return int|null Unix timestamp representation of the date
*/
public function unpublishDate($var = null);
@@ -146,9 +142,8 @@ public function unpublishDate($var = null);
* Gets and Sets the process setup for this Page. This is multi-dimensional array that consists of
* a simple array of arrays with the form array("markdown"=>true) for example
*
- * @param array $var an Array of name value pairs where the name is the process and value is true or false
- *
- * @return array an Array of name value pairs where the name is the process and value is true or false
+ * @param array|null $var New array of name value pairs where the name is the process and value is true or false
+ * @return array Array of name value pairs where the name is the process and value is true or false
*/
public function process($var = null);
@@ -156,63 +151,56 @@ public function process($var = null);
* Gets and Sets the slug for the Page. The slug is used in the URL routing. If not set it uses
* the parent folder from the path
*
- * @param string $var the slug, e.g. 'my-blog'
- *
- * @return string the slug
+ * @param string|null $var New slug, e.g. 'my-blog'
+ * @return string The slug
*/
public function slug($var = null);
/**
* Get/set order number of this page.
*
- * @param int $var
- *
- * @return int|bool
+ * @param int|null $var New order as a number
+ * @return string|bool Order in a form of '02.' or false if not set
*/
public function order($var = null);
/**
* Gets and sets the identifier for this Page object.
*
- * @param string $var the identifier
- *
- * @return string the identifier
+ * @param string|null $var New identifier
+ * @return string The identifier
*/
public function id($var = null);
/**
* Gets and sets the modified timestamp.
*
- * @param int $var modified unix timestamp
- *
- * @return int modified unix timestamp
+ * @param int|null $var New modified unix timestamp
+ * @return int Modified unix timestamp
*/
public function modified($var = null);
/**
* Gets and sets the option to show the last_modified header for the page.
*
- * @param boolean $var show last_modified header
- *
- * @return boolean show last_modified header
+ * @param bool|null $var New last_modified header value
+ * @return bool Show last_modified header
*/
public function lastModified($var = null);
/**
* Get/set the folder.
*
- * @param string $var Optional path
- *
- * @return string|null
+ * @param string|null $var New folder
+ * @return string|null The folder
*/
public function folder($var = null);
/**
* Gets and sets the date for this Page object. This is typically passed in via the page headers
*
- * @param string $var string representation of a date
- *
- * @return int unix timestamp representation of the date
+ * @param string|null $var New string representation of a date
+ * @return int Unix timestamp representation of the date
*/
public function date($var = null);
@@ -220,30 +208,34 @@ public function date($var = null);
* Gets and sets the date format for this Page object. This is typically passed in via the page headers
* using typical PHP date string structure - http://php.net/manual/en/function.date.php
*
- * @param string $var string representation of a date format
- *
- * @return string string representation of a date format
+ * @param string|null $var New string representation of a date format
+ * @return string String representation of a date format
*/
public function dateformat($var = null);
/**
* Gets and sets the taxonomy array which defines which taxonomies this page identifies itself with.
*
- * @param array $var an array of taxonomies
- *
- * @return array an array of taxonomies
+ * @param array|null $var New array of taxonomies
+ * @return array An array of taxonomies
*/
public function taxonomy($var = null);
/**
* Gets the configured state of the processing method.
*
- * @param string $process the process, eg "twig" or "markdown"
- *
- * @return bool whether or not the processing method is enabled for this Page
+ * @param string $process The process name, eg "twig" or "markdown"
+ * @return bool Whether or not the processing method is enabled for this Page
*/
public function shouldProcess($process);
+ /**
+ * Returns true if page is a module.
+ *
+ * @return bool
+ */
+ public function isModule(): bool;
+
/**
* Returns whether or not this Page object has a .md file associated with it or if its just a directory.
*
@@ -264,4 +256,12 @@ public function isDir();
* @return bool
*/
public function exists();
+
+ /**
+ * Returns the blueprint from the page.
+ *
+ * @param string $name Name of the Blueprint form. Used by flex only.
+ * @return Blueprint Returns a Blueprint.
+ */
+ public function getBlueprint(string $name = '');
}
diff --git a/system/src/Grav/Common/Page/Interfaces/PageFormInterface.php b/system/src/Grav/Common/Page/Interfaces/PageFormInterface.php
new file mode 100644
index 0000000000..3c88ebf6a9
--- /dev/null
+++ b/system/src/Grav/Common/Page/Interfaces/PageFormInterface.php
@@ -0,0 +1,33 @@
+ blueprint, ...], where blueprint follows the regular form blueprint format.
+ *
+ * @return array
+ */
+ //public function getForms(): array;
+
+ /**
+ * Add forms to this page.
+ *
+ * @param array $new
+ * @return $this
+ */
+ public function addForms(array $new/*, $override = true*/);
+
+ /**
+ * Alias of $this->getForms();
+ *
+ * @return array
+ */
+ public function forms();//: array;
+}
diff --git a/system/src/Grav/Common/Page/Interfaces/PageInterface.php b/system/src/Grav/Common/Page/Interfaces/PageInterface.php
index 24bf50ce33..56fc891b98 100644
--- a/system/src/Grav/Common/Page/Interfaces/PageInterface.php
+++ b/system/src/Grav/Common/Page/Interfaces/PageInterface.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -14,6 +14,12 @@
/**
* Class implements page interface.
*/
-interface PageInterface extends PageContentInterface, PageRoutableInterface, PageTranslateInterface, MediaInterface, PageLegacyInterface
+interface PageInterface extends
+ PageContentInterface,
+ PageFormInterface,
+ PageRoutableInterface,
+ PageTranslateInterface,
+ MediaInterface,
+ PageLegacyInterface
{
}
diff --git a/system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php b/system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php
index 470d80d711..0d38c7d3ae 100644
--- a/system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php
+++ b/system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php
@@ -4,25 +4,29 @@
use Exception;
use Grav\Common\Data\Blueprint;
use Grav\Common\Page\Collection;
+use InvalidArgumentException;
use RocketTheme\Toolbox\File\MarkdownFile;
+use SplFileInfo;
+/**
+ * Interface PageLegacyInterface
+ * @package Grav\Common\Page\Interfaces
+ */
interface PageLegacyInterface
{
/**
* Initializes the page instance variables based on a file
*
- * @param \SplFileInfo $file The file information for the .md file that the page represents
- * @param string $extension
- *
+ * @param SplFileInfo $file The file information for the .md file that the page represents
+ * @param string|null $extension
* @return $this
*/
- public function init(\SplFileInfo $file, $extension = null);
+ public function init(SplFileInfo $file, $extension = null);
/**
* Gets and Sets the raw data
*
- * @param string $var Raw content string
- *
+ * @param string|null $var Raw content string
* @return string Raw content string
*/
public function raw($var = null);
@@ -31,7 +35,6 @@ public function raw($var = null);
* Gets and Sets the page frontmatter
*
* @param string|null $var
- *
* @return string
*/
public function frontmatter($var = null);
@@ -49,14 +52,10 @@ public function modifyHeader($key, $value);
*/
public function httpResponseCode();
- public function httpHeaders();
-
/**
- * Sets the summary of the page
- *
- * @param string $summary Summary
+ * @return array
*/
- public function setSummary($summary);
+ public function httpHeaders();
/**
* Get the contentMeta array and initialize content first if it's not already
@@ -69,7 +68,7 @@ public function contentMeta();
* Add an entry to the page's contentMeta array
*
* @param string $name
- * @param string $value
+ * @param mixed $value
*/
public function addContentMeta($name, $value);
@@ -77,7 +76,6 @@ public function addContentMeta($name, $value);
* Return the whole contentMeta array as it currently stands
*
* @param string|null $name
- *
* @return mixed
*/
public function getContentMeta($name = null);
@@ -86,7 +84,6 @@ public function getContentMeta($name = null);
* Sets the whole content meta array in one shot
*
* @param array $content_meta
- *
* @return array
*/
public function setContentMeta($content_meta);
@@ -116,7 +113,6 @@ public function save($reorder = true);
* You need to call $this->save() in order to perform the move.
*
* @param PageInterface $parent New parent page.
- *
* @return $this
*/
public function move(PageInterface $parent);
@@ -128,7 +124,6 @@ public function move(PageInterface $parent);
* You need to call $this->save() in order to perform the move.
*
* @param PageInterface $parent New parent page.
- *
* @return $this
*/
public function copy(PageInterface $parent);
@@ -202,8 +197,7 @@ public function addForms(array $new);
/**
* Gets and sets the name field. If no name field is set, it will return 'default.md'.
*
- * @param string $var The name of this page.
- *
+ * @param string|null $var The name of this page.
* @return string The name of this page.
*/
public function name($var = null);
@@ -219,8 +213,7 @@ public function childType();
* Gets and sets the template field. This is used to find the correct Twig template file to render.
* If no field is set, it will return the name without the .md extension
*
- * @param string $var the template name
- *
+ * @param string|null $var the template name
* @return string the template name
*/
public function template($var = null);
@@ -229,26 +222,23 @@ public function template($var = null);
* Allows a page to override the output render format, usually the extension provided
* in the URL. (e.g. `html`, `json`, `xml`, etc).
*
- * @param null $var
- *
- * @return null
+ * @param string|null $var
+ * @return string
*/
public function templateFormat($var = null);
/**
* Gets and sets the extension field.
*
- * @param null $var
- *
- * @return null|string
+ * @param string|null $var
+ * @return string|null
*/
public function extension($var = null);
/**
* Gets and sets the expires field. If not set will return the default
*
- * @param int $var The new expires value.
- *
+ * @param int|null $var The new expires value.
* @return int The expires value
*/
public function expires($var = null);
@@ -257,17 +247,21 @@ public function expires($var = null);
* Gets and sets the cache-control property. If not set it will return the default value (null)
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options
*
- * @param null $var
- * @return null
+ * @param string|null $var
+ * @return string|null
*/
public function cacheControl($var = null);
+ /**
+ * @param bool|null $var
+ * @return bool
+ */
public function ssl($var = null);
/**
* Returns the state of the debugger override etting for this page
*
- * @return mixed
+ * @return bool
*/
public function debugger();
@@ -275,8 +269,7 @@ public function debugger();
* Function to merge page metadata tags and build an array of Metadata objects
* that can then be rendered in the page.
*
- * @param array $var an Array of metadata values to set
- *
+ * @param array|null $var an Array of metadata values to set
* @return array an Array of metadata values for the page
*/
public function metadata($var = null);
@@ -284,17 +277,15 @@ public function metadata($var = null);
/**
* Gets and sets the option to show the etag header for the page.
*
- * @param bool $var show etag header
- *
+ * @param bool|null $var show etag header
* @return bool show etag header
*/
- public function eTag($var = null);
+ public function eTag($var = null): bool;
/**
* Gets and sets the path to the .md file for this Page object.
*
- * @param string $var the file path
- *
+ * @param string|null $var the file path
* @return string|null the file path
*/
public function filePath($var = null);
@@ -309,8 +300,7 @@ public function filePathClean();
/**
* Gets and sets the order by which any sub-pages should be sorted.
*
- * @param string $var the order, either "asc" or "desc"
- *
+ * @param string|null $var the order, either "asc" or "desc"
* @return string the order, either "asc" or "desc"
* @deprecated 1.6
*/
@@ -324,8 +314,7 @@ public function orderDir($var = null);
* date - is the order based on the date set in the pages
* folder - is the order based on the name of the folder with any numerics omitted
*
- * @param string $var supported options include "default", "title", "date", and "folder"
- *
+ * @param string|null $var supported options include "default", "title", "date", and "folder"
* @return string supported options include "default", "title", "date", and "folder"
* @deprecated 1.6
*/
@@ -334,8 +323,7 @@ public function orderBy($var = null);
/**
* Gets the manual order set in the header.
*
- * @param string $var supported options include "default", "title", "date", and "folder"
- *
+ * @param string|null $var supported options include "default", "title", "date", and "folder"
* @return array
* @deprecated 1.6
*/
@@ -345,8 +333,7 @@ public function orderManual($var = null);
* Gets and sets the maxCount field which describes how many sub-pages should be displayed if the
* sub_pages header property is set for this page object.
*
- * @param int $var the maximum number of sub-pages
- *
+ * @param int|null $var the maximum number of sub-pages
* @return int the maximum number of sub-pages
* @deprecated 1.6
*/
@@ -355,9 +342,9 @@ public function maxCount($var = null);
/**
* Gets and sets the modular var that helps identify this page is a modular child
*
- * @param bool $var true if modular_twig
- *
+ * @param bool|null $var true if modular_twig
* @return bool true if modular_twig
+ * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead.
*/
public function modular($var = null);
@@ -365,8 +352,7 @@ public function modular($var = null);
* Gets and sets the modular_twig var that helps identify this page as a modular child page that will need
* twig processing handled differently from a regular page.
*
- * @param bool $var true if modular_twig
- *
+ * @param bool|null $var true if modular_twig
* @return bool true if modular_twig
*/
public function modularTwig($var = null);
@@ -374,7 +360,7 @@ public function modularTwig($var = null);
/**
* Returns children of this page.
*
- * @return \Grav\Common\Page\Collection
+ * @return PageCollectionInterface|Collection
*/
public function children();
@@ -410,16 +396,14 @@ public function nextSibling();
* Returns the adjacent sibling based on a direction.
*
* @param int $direction either -1 or +1
- *
- * @return PageInterface|bool the sibling page
+ * @return PageInterface|false the sibling page
*/
public function adjacentSibling($direction = 1);
/**
* Helper method to return an ancestor page.
*
- * @param bool $lookup Name of the parent folder
- *
+ * @param bool|null $lookup Name of the parent folder
* @return PageInterface page you were looking for if it exists
*/
public function ancestor($lookup = null);
@@ -429,7 +413,6 @@ public function ancestor($lookup = null);
* page object is returned.
*
* @param string $field Name of the parent folder
- *
* @return PageInterface
*/
public function inherited($field);
@@ -439,7 +422,6 @@ public function inherited($field);
* first occurrence of an ancestor field will be returned if at all.
*
* @param string $field Name of the parent folder
- *
* @return array
*/
public function inheritedField($field);
@@ -449,7 +431,6 @@ public function inheritedField($field);
*
* @param string $url the url of the page
* @param bool $all
- *
* @return PageInterface page you were looking for if it exists
*/
public function find($url, $all = false);
@@ -459,17 +440,15 @@ public function find($url, $all = false);
*
* @param string|array $params
* @param bool $pagination
- *
* @return Collection
- * @throws \InvalidArgumentException
+ * @throws InvalidArgumentException
*/
public function collection($params = 'content', $pagination = true);
/**
* @param string|array $value
* @param bool $only_published
- * @return mixed
- * @internal
+ * @return PageCollectionInterface|Collection
*/
public function evaluate($value, $only_published = true);
diff --git a/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php b/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php
index c3afeab168..29002660c1 100644
--- a/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php
+++ b/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php
@@ -1,6 +1,10 @@
page = $page ?? Grav::instance()['page'] ?? null;
@@ -42,16 +59,26 @@ public function __construct(PageInterface $page = null, array $config = null)
$this->config = $config;
}
- public function getPage(): PageInterface
+ /**
+ * @return PageInterface|null
+ */
+ public function getPage(): ?PageInterface
{
return $this->page;
}
+ /**
+ * @return array
+ */
public function getConfig(): array
{
return $this->config;
}
+ /**
+ * @param object $markdown
+ * @return void
+ */
public function fireInitializedEvent($markdown): void
{
$grav = Grav::instance();
@@ -68,8 +95,8 @@ public function fireInitializedEvent($markdown): void
*/
public function processLinkExcerpt(array $excerpt, string $type = 'link'): array
{
+ $grav = Grav::instance();
$url = htmlspecialchars_decode(rawurldecode($excerpt['element']['attributes']['href']));
-
$url_parts = $this->parseUrl($url);
// If there is a query, then parse it and build action calls.
@@ -87,14 +114,18 @@ static function ($carry, $item) {
);
// Valid attributes supported.
- $valid_attributes = ['rel', 'target', 'id', 'class', 'classes'];
+ $valid_attributes = $grav['config']->get('system.pages.markdown.valid_link_attributes') ?? [];
+ $skip = [];
// Unless told to not process, go through actions.
if (array_key_exists('noprocess', $actions)) {
+ $skip = is_bool($actions['noprocess']) ? $actions : explode(',', $actions['noprocess']);
unset($actions['noprocess']);
- } else {
- // Loop through actions for the image and call them.
- foreach ($actions as $attrib => $value) {
+ }
+
+ // Loop through actions for the image and call them.
+ foreach ($actions as $attrib => $value) {
+ if (!in_array($attrib, $skip)) {
$key = $attrib;
if (in_array($attrib, $valid_attributes, true)) {
@@ -108,12 +139,12 @@ static function ($carry, $item) {
}
}
- $url_parts['query'] = http_build_query($actions, null, '&', PHP_QUERY_RFC3986);
+ $url_parts['query'] = http_build_query($actions, '', '&', PHP_QUERY_RFC3986);
}
// If no query elements left, unset query.
if (empty($url_parts['query'])) {
- unset ($url_parts['query']);
+ unset($url_parts['query']);
}
// Set path to / if not set.
@@ -124,9 +155,11 @@ static function ($carry, $item) {
// If scheme isn't http(s)..
if (!empty($url_parts['scheme']) && !in_array($url_parts['scheme'], ['http', 'https'])) {
// Handle custom streams.
- if ($type !== 'image' && !empty($url_parts['stream']) && !empty($url_parts['path'])) {
- $grav = Grav::instance();
- $url_parts['path'] = $grav['base_url_relative'] . '/' . $this->resolveStream("{$url_parts['scheme']}://{$url_parts['path']}");
+ /** @var UniformResourceLocator $locator */
+ $locator = $grav['locator'];
+ if ($type === 'link' && $locator->isStream($url)) {
+ $path = $locator->findResource($url, false) ?: $locator->findResource($url, false, true);
+ $url_parts['path'] = $grav['base_url_relative'] . '/' . $path;
unset($url_parts['stream'], $url_parts['scheme']);
}
@@ -162,9 +195,10 @@ public function processImageExcerpt(array $excerpt): array
$filename = $url_parts['scheme'] . '://' . ($url_parts['path'] ?? '');
$media = $this->page->getMedia();
-
} else {
$grav = Grav::instance();
+ /** @var Pages $pages */
+ $pages = $grav['pages'];
// File is also local if scheme is http(s) and host matches.
$local_file = isset($url_parts['path'])
@@ -172,7 +206,7 @@ public function processImageExcerpt(array $excerpt): array
&& (empty($url_parts['host']) || $url_parts['host'] === $grav['uri']->host());
if ($local_file) {
- $filename = basename($url_parts['path']);
+ $filename = Utils::basename($url_parts['path']);
$folder = dirname($url_parts['path']);
// Get the local path to page media if possible.
@@ -181,11 +215,10 @@ public function processImageExcerpt(array $excerpt): array
$media = $this->page->getMedia();
} else {
// see if this is an external page to this one
- $base_url = rtrim($grav['base_url_relative'] . $grav['pages']->base(), '/');
+ $base_url = rtrim($grav['base_url_relative'] . $pages->base(), '/');
$page_route = '/' . ltrim(str_replace($base_url, '', $folder), '/');
- /** @var PageInterface $ext_page */
- $ext_page = $grav['pages']->dispatch($page_route, true);
+ $ext_page = $pages->find($page_route, true);
if ($ext_page) {
$media = $ext_page->getMedia();
} else {
@@ -211,7 +244,6 @@ public function processImageExcerpt(array $excerpt): array
$id = $element_excerpt['id'] ?? '';
$excerpt['element'] = $medium->parsedownElement($title, $alt, $class, $id, true);
-
} else {
// Not a current page media file, see if it needs converting to relative.
$excerpt['element']['attributes']['src'] = Uri::buildUrl($url_parts);
@@ -232,6 +264,7 @@ public function processMediaActions($medium, $url)
$url_parts = is_string($url) ? $this->parseUrl($url) : $url;
$actions = [];
+
// if there is a query, then parse it and build action calls
if (isset($url_parts['query'])) {
$actions = array_reduce(
@@ -247,18 +280,15 @@ static function ($carry, $item) {
);
}
- $config = $this->getConfig();
- if (!empty($config['images']['auto_fix_orientation'])) {
- $actions[] = ['method' => 'fixOrientation', 'params' => ''];
- }
-
- $defaults = $config['images']['defaults'] ?? [];
+ $defaults = $this->config['images']['defaults'] ?? [];
if (count($defaults)) {
foreach ($defaults as $method => $params) {
- $actions[] = [
- 'method' => $method,
- 'params' => $params,
- ];
+ if (array_search($method, array_column($actions, 'method')) === false) {
+ $actions[] = [
+ 'method' => $method,
+ 'params' => $params,
+ ];
+ }
}
}
@@ -286,7 +316,7 @@ static function ($carry, $item) {
* Variation of parse_url() which works also with local streams.
*
* @param string $url
- * @return array|bool
+ * @return array
*/
protected function parseUrl(string $url)
{
@@ -310,20 +340,4 @@ protected function parseUrl(string $url)
return $url_parts;
}
-
- /**
- * @param string $url
- * @return bool|string
- */
- protected function resolveStream(string $url)
- {
- /** @var UniformResourceLocator $locator */
- $locator = Grav::instance()['locator'];
-
- if ($locator->isStream($url)) {
- return $locator->findResource($url, false) ?: $locator->findResource($url, false, true);
- }
-
- return $url;
- }
}
diff --git a/system/src/Grav/Common/Page/Media.php b/system/src/Grav/Common/Page/Media.php
index 88b2cede23..b80fd96861 100644
--- a/system/src/Grav/Common/Page/Media.php
+++ b/system/src/Grav/Common/Page/Media.php
@@ -3,29 +3,39 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page;
+use FilesystemIterator;
+use Grav\Common\Config\Config;
use Grav\Common\Grav;
+use Grav\Common\Media\Interfaces\MediaObjectInterface;
use Grav\Common\Yaml;
use Grav\Common\Page\Medium\AbstractMedia;
use Grav\Common\Page\Medium\GlobalMedia;
use Grav\Common\Page\Medium\MediumFactory;
use RocketTheme\Toolbox\File\File;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use function in_array;
+/**
+ * Class Media
+ * @package Grav\Common\Page
+ */
class Media extends AbstractMedia
{
+ /** @var GlobalMedia */
protected static $global;
+ /** @var array */
protected $standard_exif = ['FileSize', 'MimeType', 'height', 'width'];
/**
* @param string $path
- * @param array $media_order
+ * @param array|null $media_order
* @param bool $load
*/
public function __construct($path, array $media_order = null, $load = true)
@@ -44,27 +54,67 @@ public function __construct($path, array $media_order = null, $load = true)
*/
public function __wakeup()
{
- if (!isset(static::$global)) {
+ if (null === static::$global) {
// Add fallback to global media.
- static::$global = new GlobalMedia();
+ static::$global = GlobalMedia::getInstance();
}
}
/**
- * @param mixed $offset
+ * Return raw route to the page.
*
+ * @return string|null Route to the page or null if media isn't for a page.
+ */
+ public function getRawRoute(): ?string
+ {
+ $path = $this->getPath();
+ if ($path) {
+ /** @var Pages $pages */
+ $pages = $this->getGrav()['pages'];
+ $page = $pages->get($path);
+ if ($page) {
+ return $page->rawRoute();
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Return page route.
+ *
+ * @return string|null Route to the page or null if media isn't for a page.
+ */
+ public function getRoute(): ?string
+ {
+ $path = $this->getPath();
+ if ($path) {
+ /** @var Pages $pages */
+ $pages = $this->getGrav()['pages'];
+ $page = $pages->get($path);
+ if ($page) {
+ return $page->route();
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @param string $offset
* @return bool
*/
+ #[\ReturnTypeWillChange]
public function offsetExists($offset)
{
return parent::offsetExists($offset) ?: isset(static::$global[$offset]);
}
/**
- * @param mixed $offset
- *
- * @return mixed
+ * @param string $offset
+ * @return MediaObjectInterface|null
*/
+ #[\ReturnTypeWillChange]
public function offsetGet($offset)
{
return parent::offsetGet($offset) ?: static::$global[$offset];
@@ -72,58 +122,71 @@ public function offsetGet($offset)
/**
* Initialize class.
+ *
+ * @return void
*/
protected function init()
{
- /** @var UniformResourceLocator $locator */
- $locator = Grav::instance()['locator'];
- $config = Grav::instance()['config'];
- $locator = Grav::instance()['locator'];
- $exif_reader = isset(Grav::instance()['exif']) ? Grav::instance()['exif']->getReader() : false;
- $media_types = array_keys(Grav::instance()['config']->get('media.types'));
+ $path = $this->getPath();
// Handle special cases where page doesn't exist in filesystem.
- if (!is_dir($this->getPath())) {
+ if (!$path || !is_dir($path)) {
return;
}
- $iterator = new \FilesystemIterator($this->getPath(), \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::SKIP_DOTS);
+ $grav = Grav::instance();
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $grav['locator'];
+
+ /** @var Config $config */
+ $config = $grav['config'];
+
+ $exif_reader = isset($grav['exif']) ? $grav['exif']->getReader() : null;
+ $media_types = array_keys($config->get('media.types', []));
+
+ $iterator = new FilesystemIterator($path, FilesystemIterator::UNIX_PATHS | FilesystemIterator::SKIP_DOTS);
$media = [];
- /** @var \DirectoryIterator $info */
- foreach ($iterator as $path => $info) {
+ foreach ($iterator as $file => $info) {
// Ignore folders and Markdown files.
- if (!$info->isFile() || $info->getExtension() === 'md' || strpos($info->getFilename(), '.') === 0) {
+ $filename = $info->getFilename();
+ if (!$info->isFile() || $info->getExtension() === 'md' || $filename === 'frontmatter.yaml' || $filename === 'media.json' || strpos($filename, '.') === 0) {
continue;
}
// Find out what type we're dealing with
- list($basename, $ext, $type, $extra) = $this->getFileParts($info->getFilename());
+ [$basename, $ext, $type, $extra] = $this->getFileParts($filename);
- if (!\in_array(strtolower($ext), $media_types, true)) {
+ if (!in_array(strtolower($ext), $media_types, true)) {
continue;
}
if ($type === 'alternative') {
- $media["{$basename}.{$ext}"][$type][$extra] = ['file' => $path, 'size' => $info->getSize()];
+ $media["{$basename}.{$ext}"][$type][$extra] = ['file' => $file, 'size' => $info->getSize()];
} else {
- $media["{$basename}.{$ext}"][$type] = ['file' => $path, 'size' => $info->getSize()];
+ $media["{$basename}.{$ext}"][$type] = ['file' => $file, 'size' => $info->getSize()];
}
}
foreach ($media as $name => $types) {
// First prepare the alternatives in case there is no base medium
if (!empty($types['alternative'])) {
+ /**
+ * @var string|int $ratio
+ * @var array $alt
+ */
foreach ($types['alternative'] as $ratio => &$alt) {
- $alt['file'] = MediumFactory::fromFile($alt['file']);
+ $alt['file'] = $this->createFromFile($alt['file']);
- if (!$alt['file']) {
+ if (empty($alt['file'])) {
unset($types['alternative'][$ratio]);
} else {
$alt['file']->set('size', $alt['size']);
}
}
+ unset($alt);
}
$file_path = null;
@@ -139,9 +202,11 @@ protected function init()
$file_path = $medium->path();
$medium = MediumFactory::scaledFromMedium($medium, $max, 1)['file'];
} else {
- $medium = MediumFactory::fromFile($types['base']['file']);
- $medium && $medium->set('size', $types['base']['size']);
- $file_path = $medium->path();
+ $medium = $this->createFromFile($types['base']['file']);
+ if ($medium) {
+ $medium->set('size', $types['base']['size']);
+ $file_path = $medium->path();
+ }
}
if (empty($medium)) {
@@ -154,7 +219,6 @@ protected function init()
if (file_exists($meta_path)) {
$types['meta']['file'] = $meta_path;
} elseif ($file_path && $exif_reader && $medium->get('mime') === 'image/jpeg' && empty($types['meta']) && $config->get('system.media.auto_metadata_exif')) {
-
$meta = $exif_reader->read($file_path);
if ($meta) {
@@ -212,10 +276,10 @@ protected function init()
}
/**
- * @return string
+ * @return string|null
* @deprecated 1.6 Use $this->getPath() instead.
*/
- public function path()
+ public function path(): ?string
{
return $this->getPath();
}
diff --git a/system/src/Grav/Common/Page/Medium/AbstractMedia.php b/system/src/Grav/Common/Page/Medium/AbstractMedia.php
index f67cd650e8..9b3650b8dc 100644
--- a/system/src/Grav/Common/Page/Medium/AbstractMedia.php
+++ b/system/src/Grav/Common/Page/Medium/AbstractMedia.php
@@ -3,49 +3,72 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
+use Grav\Common\Config\Config;
+use Grav\Common\Data\Blueprint;
use Grav\Common\Grav;
+use Grav\Common\Language\Language;
use Grav\Common\Media\Interfaces\MediaCollectionInterface;
use Grav\Common\Media\Interfaces\MediaObjectInterface;
-use Grav\Common\Page\Page;
+use Grav\Common\Media\Interfaces\MediaUploadInterface;
+use Grav\Common\Media\Traits\MediaUploadTrait;
+use Grav\Common\Page\Pages;
use Grav\Common\Utils;
use RocketTheme\Toolbox\ArrayTraits\ArrayAccess;
use RocketTheme\Toolbox\ArrayTraits\Countable;
use RocketTheme\Toolbox\ArrayTraits\Export;
use RocketTheme\Toolbox\ArrayTraits\ExportInterface;
use RocketTheme\Toolbox\ArrayTraits\Iterator;
+use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use function is_array;
-abstract class AbstractMedia implements ExportInterface, MediaCollectionInterface
+/**
+ * Class AbstractMedia
+ * @package Grav\Common\Page\Medium
+ */
+abstract class AbstractMedia implements ExportInterface, MediaCollectionInterface, MediaUploadInterface
{
use ArrayAccess;
use Countable;
use Iterator;
use Export;
+ use MediaUploadTrait;
+ /** @var array */
protected $items = [];
+ /** @var string|null */
protected $path;
+ /** @var array */
protected $images = [];
+ /** @var array */
protected $videos = [];
+ /** @var array */
protected $audios = [];
+ /** @var array */
protected $files = [];
+ /** @var array|null */
protected $media_order;
/**
* Return media path.
*
- * @return string
+ * @return string|null
*/
- public function getPath()
+ public function getPath(): ?string
{
return $this->path;
}
- public function setPath(?string $path)
+ /**
+ * @param string|null $path
+ * @return void
+ */
+ public function setPath(?string $path): void
{
$this->path = $path;
}
@@ -54,7 +77,7 @@ public function setPath(?string $path)
* Get medium by filename.
*
* @param string $filename
- * @return Medium|null
+ * @return MediaObjectInterface|null
*/
public function get($filename)
{
@@ -67,6 +90,7 @@ public function get($filename)
* @param string $filename
* @return mixed
*/
+ #[\ReturnTypeWillChange]
public function __invoke($filename)
{
return $this->offsetGet($filename);
@@ -80,7 +104,6 @@ public function __invoke($filename)
*/
public function setTimestamps($timestamp = null)
{
- /** @var Medium $instance */
foreach ($this->items as $instance) {
$instance->setTimestamp($timestamp);
}
@@ -150,14 +173,17 @@ public function files()
/**
* @param string $name
- * @param MediaObjectInterface $file
+ * @param MediaObjectInterface|null $file
+ * @return void
*/
public function add($name, $file)
{
- if (!$file) {
+ if (null === $file) {
return;
}
+
$this->offsetSet($name, $file);
+
switch ($file->type) {
case 'image':
$this->images[$name] = $file;
@@ -173,6 +199,50 @@ public function add($name, $file)
}
}
+ /**
+ * @param string $name
+ * @return void
+ */
+ public function hide($name)
+ {
+ $this->offsetUnset($name);
+
+ unset($this->images[$name], $this->videos[$name], $this->audios[$name], $this->files[$name]);
+ }
+
+ /**
+ * Create Medium from a file.
+ *
+ * @param string $file
+ * @param array $params
+ * @return Medium|null
+ */
+ public function createFromFile($file, array $params = [])
+ {
+ return MediumFactory::fromFile($file, $params);
+ }
+
+ /**
+ * Create Medium from array of parameters
+ *
+ * @param array $items
+ * @param Blueprint|null $blueprint
+ * @return Medium|null
+ */
+ public function createFromArray(array $items = [], Blueprint $blueprint = null)
+ {
+ return MediumFactory::fromArray($items, $blueprint);
+ }
+
+ /**
+ * @param MediaObjectInterface $mediaObject
+ * @return ImageFile
+ */
+ public function getImageFileObject(MediaObjectInterface $mediaObject): ImageFile
+ {
+ return ImageFile::open($mediaObject->get('filepath'));
+ }
+
/**
* Order the media based on the page's media_order
*
@@ -182,11 +252,14 @@ public function add($name, $file)
protected function orderMedia($media)
{
if (null === $this->media_order) {
- /** @var Page $page */
- $page = Grav::instance()['pages']->get($this->getPath());
-
- if ($page && isset($page->header()->media_order)) {
- $this->media_order = array_map('trim', explode(',', $page->header()->media_order));
+ $path = $this->getPath();
+ if (null !== $path) {
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+ $page = $pages->get($path);
+ if ($page && isset($page->header()->media_order)) {
+ $this->media_order = array_map('trim', explode(',', $page->header()->media_order));
+ }
}
}
@@ -199,6 +272,11 @@ protected function orderMedia($media)
return $media;
}
+ protected function fileExists(string $filename, string $destination): bool
+ {
+ return file_exists("{$destination}/{$filename}");
+ }
+
/**
* Get filename, extension and meta part.
*
@@ -239,6 +317,28 @@ protected function getFileParts($filename)
}
}
- return array($name, $extension, $type, $extra);
+ return [$name, $extension, $type, $extra];
+ }
+
+ protected function getGrav(): Grav
+ {
+ return Grav::instance();
+ }
+
+ protected function getConfig(): Config
+ {
+ return $this->getGrav()['config'];
+ }
+
+ protected function getLanguage(): Language
+ {
+ return $this->getGrav()['language'];
+ }
+
+ protected function clearCache(): void
+ {
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->getGrav()['locator'];
+ $locator->clearCache();
}
}
diff --git a/system/src/Grav/Common/Page/Medium/AudioMedium.php b/system/src/Grav/Common/Page/Medium/AudioMedium.php
index 74ef746ab4..cc53f8198f 100644
--- a/system/src/Grav/Common/Page/Medium/AudioMedium.php
+++ b/system/src/Grav/Common/Page/Medium/AudioMedium.php
@@ -3,134 +3,22 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
-class AudioMedium extends Medium
-{
- use StaticResizeTrait;
-
- /**
- * Parsedown element for source display mode
- *
- * @param array $attributes
- * @param bool $reset
- * @return array
- */
- protected function sourceParsedownElement(array $attributes, $reset = true)
- {
- $location = $this->url($reset);
-
- return [
- 'name' => 'audio',
- 'text' => 'Your browser does not support the audio tag.',
- 'attributes' => $attributes
- ];
- }
-
- /**
- * Allows to set or remove the HTML5 default controls
- *
- * @param bool $display
- * @return $this
- */
- public function controls($display = true)
- {
- if($display) {
- $this->attributes['controls'] = true;
- } else {
- unset($this->attributes['controls']);
- }
-
- return $this;
- }
-
- /**
- * Allows to set the preload behaviour
- *
- * @param string $preload
- * @return $this
- */
- public function preload($preload)
- {
- $validPreloadAttrs = ['auto', 'metadata', 'none'];
-
- if (\in_array($preload, $validPreloadAttrs, true)) {
- $this->attributes['preload'] = $preload;
- }
-
- return $this;
- }
-
- /**
- * Allows to set the controlsList behaviour
- * Separate multiple values with a hyphen
- *
- * @param string $controlsList
- * @return $this
- */
- public function controlsList($controlsList)
- {
- $controlsList = str_replace('-', ' ', $controlsList);
- $this->attributes['controlsList'] = $controlsList;
-
- return $this;
- }
-
- /**
- * Allows to set the muted attribute
- *
- * @param bool $status
- * @return $this
- */
- public function muted($status = false)
- {
- if($status) {
- $this->attributes['muted'] = true;
- } else {
- unset($this->attributes['muted']);
- }
-
- return $this;
- }
-
- /**
- * Allows to set the loop attribute
- *
- * @param bool $status
- * @return $this
- */
- public function loop($status = false)
- {
- if($status) {
- $this->attributes['loop'] = true;
- } else {
- unset($this->attributes['loop']);
- }
-
- return $this;
- }
-
- /**
- * Allows to set the autoplay attribute
- *
- * @param bool $status
- * @return $this
- */
- public function autoplay($status = false)
- {
- if($status) {
- $this->attributes['autoplay'] = true;
- } else {
- unset($this->attributes['autoplay']);
- }
-
- return $this;
- }
+use Grav\Common\Media\Interfaces\AudioMediaInterface;
+use Grav\Common\Media\Traits\AudioMediaTrait;
+/**
+ * Class AudioMedium
+ * @package Grav\Common\Page\Medium
+ */
+class AudioMedium extends Medium implements AudioMediaInterface
+{
+ use AudioMediaTrait;
/**
* Reset medium.
@@ -141,7 +29,7 @@ public function reset()
{
parent::reset();
- $this->attributes['controls'] = true;
+ $this->resetPlayer();
return $this;
}
diff --git a/system/src/Grav/Common/Page/Medium/GlobalMedia.php b/system/src/Grav/Common/Page/Medium/GlobalMedia.php
index 9fb30e9824..20f63ad56c 100644
--- a/system/src/Grav/Common/Page/Medium/GlobalMedia.php
+++ b/system/src/Grav/Common/Page/Medium/GlobalMedia.php
@@ -3,42 +3,61 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
use Grav\Common\Grav;
+use Grav\Common\Media\Interfaces\MediaObjectInterface;
+use Grav\Common\Utils;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use function dirname;
+/**
+ * Class GlobalMedia
+ * @package Grav\Common\Page\Medium
+ */
class GlobalMedia extends AbstractMedia
{
+ /** @var self */
+ protected static $instance;
+
+ public static function getInstance(): self
+ {
+ if (null === self::$instance) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+
/**
* Return media path.
*
- * @return null
+ * @return string|null
*/
- public function getPath()
+ public function getPath(): ?string
{
return null;
}
/**
- * @param mixed $offset
- *
+ * @param string $offset
* @return bool
*/
+ #[\ReturnTypeWillChange]
public function offsetExists($offset)
{
return parent::offsetExists($offset) ?: !empty($this->resolveStream($offset));
}
/**
- * @param mixed $offset
- *
- * @return mixed
+ * @param string $offset
+ * @return MediaObjectInterface|null
*/
+ #[\ReturnTypeWillChange]
public function offsetGet($offset)
{
return parent::offsetGet($offset) ?: $this->addMedium($offset);
@@ -52,13 +71,16 @@ protected function resolveStream($filename)
{
/** @var UniformResourceLocator $locator */
$locator = Grav::instance()['locator'];
+ if (!$locator->isStream($filename)) {
+ return null;
+ }
- return $locator->isStream($filename) ? ($locator->findResource($filename) ?: null) : null;
+ return $locator->findResource($filename) ?: null;
}
/**
* @param string $stream
- * @return Medium|null
+ * @return MediaObjectInterface|null
*/
protected function addMedium($stream)
{
@@ -68,10 +90,10 @@ protected function addMedium($stream)
}
$path = dirname($filename);
- list($basename, $ext,, $extra) = $this->getFileParts(basename($filename));
+ [$basename, $ext,, $extra] = $this->getFileParts(Utils::basename($filename));
$medium = MediumFactory::fromFile($filename);
- if (empty($medium)) {
+ if (null === $medium) {
return null;
}
diff --git a/system/src/Grav/Common/Page/Medium/ImageFile.php b/system/src/Grav/Common/Page/Medium/ImageFile.php
index d56c342201..1aae7eeb67 100644
--- a/system/src/Grav/Common/Page/Medium/ImageFile.php
+++ b/system/src/Grav/Common/Page/Medium/ImageFile.php
@@ -3,27 +3,50 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
+use Exception;
+use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Gregwar\Image\Exceptions\GenerationError;
use Gregwar\Image\Image;
use Gregwar\Image\Source;
use RocketTheme\Toolbox\Event\Event;
+use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use RuntimeException;
+use function array_key_exists;
+use function count;
+use function extension_loaded;
+use function in_array;
+/**
+ * Class ImageFile
+ * @package Grav\Common\Page\Medium
+ *
+ * @method Image applyExifOrientation($exif_orienation)
+ */
class ImageFile extends Image
{
+ /**
+ * Destruct also image object.
+ */
+ #[\ReturnTypeWillChange]
public function __destruct()
{
- $this->getAdapter()->deinit();
+ $adapter = $this->adapter;
+ if ($adapter) {
+ $adapter->deinit();
+ }
}
/**
* Clear previously applied operations
+ *
+ * @return void
*/
public function clearOperations()
{
@@ -37,7 +60,6 @@ public function clearOperations()
* @param int $quality the quality (for JPEG)
* @param bool $actual
* @param array $extras
- *
* @return string
*/
public function cacheFile($type = 'jpg', $quality = 80, $actual = false, $extras = [])
@@ -53,8 +75,11 @@ public function cacheFile($type = 'jpg', $quality = 80, $actual = false, $extras
// Computes the hash
$this->hash = $this->getHash($type, $quality, $extras);
+ /** @var Config $config */
+ $config = Grav::instance()['config'];
+
// Seo friendly image names
- $seofriendly = Grav::instance()['config']->get('system.images.seofriendly', false);
+ $seofriendly = $config->get('system.images.seofriendly', false);
if ($seofriendly) {
$mini_hash = substr($this->hash, 0, 4) . substr($this->hash, -4);
@@ -87,7 +112,7 @@ public function cacheFile($type = 'jpg', $quality = 80, $actual = false, $extras
// Asking the cache for the cacheFile
try {
- $perms = Grav::instance()['config']->get('system.images.cache_perms', '0755');
+ $perms = $config->get('system.images.cache_perms', '0755');
$perms = octdec($perms);
$file = $this->getCacheSystem()->setDirectoryMode($perms)->getOrCreateFile($cacheFile, $conditions, $generate, $actual);
} catch (GenerationError $e) {
@@ -95,8 +120,9 @@ public function cacheFile($type = 'jpg', $quality = 80, $actual = false, $extras
}
// Nulling the resource
- $this->getAdapter()->setSource(new Source\File($file));
- $this->getAdapter()->deinit();
+ $adapter = $this->getAdapter();
+ $adapter->setSource(new Source\File($file));
+ $adapter->deinit();
if ($actual) {
return $file;
@@ -107,10 +133,11 @@ public function cacheFile($type = 'jpg', $quality = 80, $actual = false, $extras
/**
* Gets the hash.
+ *
* @param string $type
* @param int $quality
- * @param [] $extras
- * @return null
+ * @param array $extras
+ * @return string
*/
public function getHash($type = 'guess', $quality = 80, $extras = [])
{
@@ -123,6 +150,7 @@ public function getHash($type = 'guess', $quality = 80, $extras = [])
/**
* Generates the hash.
+ *
* @param string $type
* @param int $quality
* @param array $extras
@@ -131,15 +159,54 @@ public function generateHash($type = 'guess', $quality = 80, $extras = [])
{
$inputInfos = $this->source->getInfos();
- $datas = array(
+ $data = [
$inputInfos,
$this->serializeOperations(),
$type,
$quality,
$extras
- );
+ ];
- $this->hash = sha1(serialize($datas));
+ $this->hash = sha1(serialize($data));
}
+ /**
+ * Read exif rotation from file and apply it.
+ */
+ public function fixOrientation()
+ {
+ if (!extension_loaded('exif')) {
+ throw new RuntimeException('You need to EXIF PHP Extension to use this function');
+ }
+
+ if (!in_array(exif_imagetype($this->source->getInfos()), [IMAGETYPE_JPEG, IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM], true)) {
+ return $this;
+ }
+
+ // resolve any streams
+ /** @var UniformResourceLocator $locator */
+ $locator = Grav::instance()['locator'];
+ $filepath = $this->source->getInfos();
+ if ($locator->isStream($filepath)) {
+ $filepath = $locator->findResource($this->source->getInfos(), true, true);
+ }
+
+ // Make sure file exists
+ if (!file_exists($filepath)) {
+ return $this;
+ }
+
+ try {
+ $exif = @exif_read_data($filepath);
+ } catch (Exception $e) {
+ Grav::instance()['log']->error($filepath . ' - ' . $e->getMessage());
+ return $this;
+ }
+
+ if ($exif === false || !array_key_exists('Orientation', $exif)) {
+ return $this;
+ }
+
+ return $this->applyExifOrientation($exif['Orientation']);
+ }
}
diff --git a/system/src/Grav/Common/Page/Medium/ImageMedium.php b/system/src/Grav/Common/Page/Medium/ImageMedium.php
index 1925bef812..ab6ba4a90c 100644
--- a/system/src/Grav/Common/Page/Medium/ImageMedium.php
+++ b/system/src/Grav/Common/Page/Medium/ImageMedium.php
@@ -3,102 +3,70 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
+use BadFunctionCallException;
use Grav\Common\Data\Blueprint;
-use Grav\Common\Grav;
+use Grav\Common\Media\Interfaces\ImageManipulateInterface;
+use Grav\Common\Media\Interfaces\ImageMediaInterface;
+use Grav\Common\Media\Interfaces\MediaLinkInterface;
+use Grav\Common\Media\Traits\ImageLoadingTrait;
+use Grav\Common\Media\Traits\ImageMediaTrait;
use Grav\Common\Utils;
+use Gregwar\Image\Image;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use function func_get_args;
+use function in_array;
-class ImageMedium extends Medium
+/**
+ * Class ImageMedium
+ * @package Grav\Common\Page\Medium
+ */
+class ImageMedium extends Medium implements ImageMediaInterface, ImageManipulateInterface
{
- /**
- * @var array
- */
- protected $thumbnailTypes = ['page', 'media', 'default'];
-
- /**
- * @var ImageFile
- */
- protected $image;
-
- /**
- * @var string
- */
- protected $format = 'guess';
-
- /**
- * @var int
- */
- protected $quality;
-
- /**
- * @var int
- */
- protected $default_quality;
+ use ImageMediaTrait;
+ use ImageLoadingTrait;
/**
- * @var bool
+ * @var mixed|string
*/
- protected $debug_watermarked = false;
-
- /**
- * @var array
- */
- public static $magic_actions = [
- 'resize', 'forceResize', 'cropResize', 'crop', 'zoomCrop',
- 'negate', 'brightness', 'contrast', 'grayscale', 'emboss',
- 'smooth', 'sharp', 'edge', 'colorize', 'sepia', 'enableProgressive',
- 'rotate', 'flip', 'fixOrientation', 'gaussianBlur'
- ];
-
- /**
- * @var array
- */
- public static $magic_resize_actions = [
- 'resize' => [0, 1],
- 'forceResize' => [0, 1],
- 'cropResize' => [0, 1],
- 'crop' => [0, 1, 2, 3],
- 'zoomCrop' => [0, 1]
- ];
-
- /**
- * @var string
- */
- protected $sizes = '100vw';
+ private $saved_image_path;
/**
* Construct.
*
* @param array $items
- * @param Blueprint $blueprint
+ * @param Blueprint|null $blueprint
*/
public function __construct($items = [], Blueprint $blueprint = null)
{
parent::__construct($items, $blueprint);
- $config = Grav::instance()['config'];
+ $config = $this->getGrav()['config'];
+
+ $this->thumbnailTypes = ['page', 'media', 'default'];
+ $this->default_quality = $config->get('system.images.default_image_quality', 85);
+ $this->def('debug', $config->get('system.images.debug'));
$path = $this->get('filepath');
if (!$path || !file_exists($path) || !filesize($path)) {
return;
}
- $image_info = getimagesize($path);
+ $this->set('thumbnails.media', $path);
- $this->def('width', $image_info[0]);
- $this->def('height', $image_info[1]);
- $this->def('mime', $image_info['mime']);
- $this->def('debug', $config->get('system.images.debug'));
-
- $this->set('thumbnails.media', $this->get('filepath'));
-
- $this->default_quality = $config->get('system.images.default_image_quality', 85);
+ if (!($this->offsetExists('width') && $this->offsetExists('height') && $this->offsetExists('mime'))) {
+ $image_info = getimagesize($path);
+ if ($image_info) {
+ $this->def('width', $image_info[0]);
+ $this->def('height', $image_info[1]);
+ $this->def('mime', $image_info['mime']);
+ }
+ }
$this->reset();
@@ -107,18 +75,69 @@ public function __construct($items = [], Blueprint $blueprint = null)
}
}
+ /**
+ * @return array
+ */
+ public function getMeta(): array
+ {
+ return [
+ 'width' => $this->width,
+ 'height' => $this->height,
+ ] + parent::getMeta();
+ }
+
+ /**
+ * Also unset the image on destruct.
+ */
+ #[\ReturnTypeWillChange]
public function __destruct()
{
unset($this->image);
}
+ /**
+ * Also clone image.
+ */
+ #[\ReturnTypeWillChange]
public function __clone()
{
- $this->image = $this->image ? clone $this->image : null;
+ if ($this->image) {
+ $this->image = clone $this->image;
+ }
parent::__clone();
}
+ /**
+ * Reset image.
+ *
+ * @return $this
+ */
+ public function reset()
+ {
+ parent::reset();
+
+ if ($this->image) {
+ $this->image();
+ $this->medium_querystring = [];
+ $this->filter();
+ $this->clearAlternatives();
+ }
+
+ $this->format = 'guess';
+ $this->quality = $this->default_quality;
+
+ $this->debug_watermarked = false;
+
+ $config = $this->getGrav()['config'];
+ // Set CLS configuration
+ $this->auto_sizes = $config->get('system.images.cls.auto_sizes', false);
+ $this->aspect_ratio = $config->get('system.images.cls.aspect_ratio', false);
+ $this->retina_scale = $config->get('system.images.cls.retina_scale', 1);
+
+ return $this;
+ }
+
/**
* Add meta file for the medium.
*
@@ -135,14 +154,6 @@ public function addMetaFile($filepath)
return $this;
}
- /**
- * Clear out the alternatives
- */
- public function clearAlternatives()
- {
- $this->alternatives = [];
- }
-
/**
* Return PATH to image.
*
@@ -168,15 +179,17 @@ public function path($reset = true)
*/
public function url($reset = true)
{
+ $grav = $this->getGrav();
+
/** @var UniformResourceLocator $locator */
- $locator = Grav::instance()['locator'];
- $image_path = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true);
- $saved_image_path = $this->saveImage();
+ $locator = $grav['locator'];
+ $image_path = (string)($locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true));
+ $saved_image_path = $this->saved_image_path = $this->saveImage();
- $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $saved_image_path);
+ $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $saved_image_path) ?: $saved_image_path;
if ($locator->isStream($output)) {
- $output = $locator->findResource($output, false);
+ $output = (string)($locator->findResource($output, false) ?: $locator->findResource($output, false, true));
}
if (Utils::startsWith($output, $image_path)) {
@@ -188,24 +201,9 @@ public function url($reset = true)
$this->reset();
}
- return trim(Grav::instance()['base_url'] . '/' . $this->urlQuerystring($output), '\\');
- }
-
- /**
- * Simply processes with no extra methods. Useful for triggering events.
- *
- * @return $this
- */
- public function cache()
- {
- if (!$this->image) {
- $this->image();
- }
-
- return $this;
+ return trim($grav['base_url'] . '/' . $this->urlQuerystring($output), '\\');
}
-
/**
* Return srcset string for this Medium and its alternatives.
*
@@ -231,106 +229,6 @@ public function srcset($reset = true)
return implode(', ', $srcset);
}
- /**
- * Allows the ability to override the image's pretty name stored in cache
- *
- * @param string $name
- */
- public function setImagePrettyName($name)
- {
- $this->set('prettyname', $name);
- if ($this->image) {
- $this->image->setPrettyName($name);
- }
- }
-
- public function getImagePrettyName()
- {
- if ($this->get('prettyname')) {
- return $this->get('prettyname');
- }
-
- $basename = $this->get('basename');
- if (preg_match('/[a-z0-9]{40}-(.*)/', $basename, $matches)) {
- $basename = $matches[1];
- }
- return $basename;
- }
-
- /**
- * Generate alternative image widths, using either an array of integers, or
- * a min width, a max width, and a step parameter to fill out the necessary
- * widths. Existing image alternatives won't be overwritten.
- *
- * @param int|int[] $min_width
- * @param int $max_width
- * @param int $step
- * @return $this
- */
- public function derivatives($min_width, $max_width = 2500, $step = 200)
- {
- if (!empty($this->alternatives)) {
- $max = max(array_keys($this->alternatives));
- $base = $this->alternatives[$max];
- } else {
- $base = $this;
- }
-
- $widths = [];
-
- if (func_num_args() === 1) {
- foreach ((array) func_get_arg(0) as $width) {
- if ($width < $base->get('width')) {
- $widths[] = $width;
- }
- }
- } else {
- $max_width = min($max_width, $base->get('width'));
-
- for ($width = $min_width; $width < $max_width; $width = $width + $step) {
- $widths[] = $width;
- }
- }
-
- foreach ($widths as $width) {
- // Only generate image alternatives that don't already exist
- if (array_key_exists((int) $width, $this->alternatives)) {
- continue;
- }
-
- $derivative = MediumFactory::fromFile($base->get('filepath'));
-
- // It's possible that MediumFactory::fromFile returns null if the
- // original image file no longer exists and this class instance was
- // retrieved from the page cache
- if (null !== $derivative) {
- $index = 2;
- $alt_widths = array_keys($this->alternatives);
- sort($alt_widths);
-
- foreach ($alt_widths as $i => $key) {
- if ($width > $key) {
- $index += max($i, 1);
- }
- }
-
- $basename = preg_replace('/(@\d+x){0,1}$/', "@{$width}w", $base->get('basename'), 1);
- $derivative->setImagePrettyName($basename);
-
- $ratio = $base->get('width') / $width;
- $height = $derivative->get('height') / $ratio;
-
- $derivative->resize($width, $height);
- $derivative->set('width', $width);
- $derivative->set('height', $height);
-
- $this->addAlternative($ratio, $derivative);
- }
- }
-
- return $this;
- }
-
/**
* Parsedown element for source display mode
*
@@ -348,31 +246,24 @@ public function sourceParsedownElement(array $attributes, $reset = true)
$attributes['sizes'] = $this->sizes();
}
- return ['name' => 'img', 'attributes' => $attributes];
- }
+ if ($this->saved_image_path && $this->auto_sizes) {
+ if (!array_key_exists('height', $this->attributes) && !array_key_exists('width', $this->attributes)) {
+ $info = getimagesize($this->saved_image_path);
+ $width = (int)$info[0];
+ $height = (int)$info[1];
- /**
- * Reset image.
- *
- * @return $this
- */
- public function reset()
- {
- parent::reset();
+ $scaling_factor = $this->retina_scale > 0 ? $this->retina_scale : 1;
+ $attributes['width'] = (int)($width / $scaling_factor);
+ $attributes['height'] = (int)($height / $scaling_factor);
- if ($this->image) {
- $this->image();
- $this->medium_querystring = [];
- $this->filter();
- $this->clearAlternatives();
+ if ($this->aspect_ratio) {
+ $style = ($attributes['style'] ?? ' ') . "--aspect-ratio: $width/$height;";
+ $attributes['style'] = trim($style);
+ }
+ }
}
- $this->format = 'guess';
- $this->quality = $this->default_quality;
-
- $this->debug_watermarked = false;
-
- return $this;
+ return ['name' => 'img', 'attributes' => $attributes];
}
/**
@@ -380,7 +271,7 @@ public function reset()
*
* @param bool $reset
* @param array $attributes
- * @return Link
+ * @return MediaLinkInterface
*/
public function link($reset = true, array $attributes = [])
{
@@ -399,7 +290,7 @@ public function link($reset = true, array $attributes = [])
* @param int $width
* @param int $height
* @param bool $reset
- * @return Link
+ * @return MediaLinkInterface
*/
public function lightbox($width = null, $height = null, $reset = true)
{
@@ -415,105 +306,146 @@ public function lightbox($width = null, $height = null, $reset = true)
}
/**
- * Sets or gets the quality of the image
- *
- * @param int $quality 0-100 quality
- * @return int|$this
+ * @param string $enabled
+ * @return $this
*/
- public function quality($quality = null)
+ public function autoSizes($enabled = 'true')
{
- if ($quality) {
- if (!$this->image) {
- $this->image();
- }
+ $this->auto_sizes = $enabled === 'true' ?: false;
- $this->quality = $quality;
+ return $this;
+ }
- return $this;
- }
+ /**
+ * @param string $enabled
+ * @return $this
+ */
+ public function aspectRatio($enabled = 'true')
+ {
+ $this->aspect_ratio = $enabled === 'true' ?: false;
- return $this->quality;
+ return $this;
}
/**
- * Sets image output format.
- *
- * @param string $format
+ * @param int $scale
* @return $this
*/
- public function format($format)
+ public function retinaScale($scale = 1)
{
- if (!$this->image) {
- $this->image();
- }
-
- $this->format = $format;
+ $this->retina_scale = (int)$scale;
return $this;
}
/**
- * Set or get sizes parameter for srcset media action
- *
- * @param string $sizes
- * @return string
+ * @param string|null $image
+ * @param string|null $position
+ * @param int|float|null $scale
+ * @return $this
*/
- public function sizes($sizes = null)
+ public function watermark($image = null, $position = null, $scale = null)
{
+ $grav = $this->getGrav();
+
+ $locator = $grav['locator'];
+ $config = $grav['config'];
+
+ $args = func_get_args();
+
+ $file = $args[0] ?? '1'; // using '1' because of markdown. doing  returns $args[0]='1';
+ $file = $file === '1' ? $config->get('system.images.watermark.image') : $args[0];
+
+ $watermark = $locator->findResource($file);
+ $watermark = ImageFile::open($watermark);
+
+ // Scaling operations
+ $scale = ($scale ?? $config->get('system.images.watermark.scale', 100)) / 100;
+ $wwidth = (int)$this->get('width') * $scale;
+ $wheight = (int)$this->get('height') * $scale;
+ $watermark->resize($wwidth, $wheight);
+
+ // Position operations
+ $position = !empty($args[1]) ? explode('-', $args[1]) : ['center', 'center']; // todo change to config
+ $positionY = $position[0] ?? $config->get('system.images.watermark.position_y', 'center');
+ $positionX = $position[1] ?? $config->get('system.images.watermark.position_x', 'center');
+
+ switch ($positionY)
+ {
+ case 'top':
+ $positionY = 0;
+ break;
- if ($sizes) {
- $this->sizes = $sizes;
+ case 'bottom':
+ $positionY = (int)$this->get('height')-$wheight;
+ break;
- return $this;
+ case 'center':
+ $positionY = ((int)$this->get('height')/2) - ($wheight/2);
+ break;
}
- return empty($this->sizes) ? '100vw' : $this->sizes;
+ switch ($positionX)
+ {
+ case 'left':
+ $positionX = 0;
+ break;
+
+ case 'right':
+ $positionX = (int)$this->get('width')-$wwidth;
+ break;
+
+ case 'center':
+ $positionX = ((int)$this->get('width')/2) - ($wwidth/2);
+ break;
+ }
+
+ $this->__call('merge', [$watermark,$positionX, $positionY]);
+
+ return $this;
}
/**
- * Allows to set the width attribute from Markdown or Twig
- * Examples: 
- * 
- * 
- * 
- * {{ page.media['myimg.png'].width().height().html }}
- * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }}
+ * Handle this commonly used variant
*
- * @param mixed $value A value or 'auto' or empty to use the width of the image
* @return $this
*/
- public function width($value = 'auto')
+ public function cropZoom()
{
- if (!$value || $value === 'auto') {
- $this->attributes['width'] = $this->get('width');
- } else {
- $this->attributes['width'] = $value;
- }
+ $this->__call('zoomCrop', func_get_args());
return $this;
}
/**
- * Allows to set the height attribute from Markdown or Twig
- * Examples: 
- * 
- * 
- * 
- * {{ page.media['myimg.png'].width().height().html }}
- * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }}
+ * Add a frame to image
*
- * @param mixed $value A value or 'auto' or empty to use the height of the image
* @return $this
*/
- public function height($value = 'auto')
+ public function addFrame(int $border = 10, string $color = '0x000000')
{
- if (!$value || $value === 'auto') {
- $this->attributes['height'] = $this->get('height');
- } else {
- $this->attributes['height'] = $value;
- }
-
+ if($border > 0 && preg_match('/^0x[a-f0-9]{6}$/i', $color)) { // $border must be an integer and bigger than 0; $color must be formatted as an HEX value (0x??????).
+ $image = ImageFile::open($this->path());
+ }
+ else {
return $this;
+ }
+
+ $dst_width = $image->width()+2*$border;
+ $dst_height = $image->height()+2*$border;
+
+ $frame = ImageFile::create($dst_width, $dst_height);
+
+ $frame->__call('fill', [$color]);
+
+ $this->image = $frame;
+
+ $this->__call('merge', [$image, $border, $border]);
+
+ $this->saveImage();
+
+ return $this;
+
}
/**
@@ -523,13 +455,10 @@ public function height($value = 'auto')
* @param mixed $args
* @return $this|mixed
*/
+ #[\ReturnTypeWillChange]
public function __call($method, $args)
{
- if ($method === 'cropZoom') {
- $method = 'zoomCrop';
- }
-
- if (!\in_array($method, self::$magic_actions, true)) {
+ if (!in_array($method, static::$magic_actions, true)) {
return parent::__call($method, $args);
}
@@ -539,125 +468,28 @@ public function __call($method, $args)
}
try {
- call_user_func_array([$this->image, $method], $args);
+ $this->image->{$method}(...$args);
+ /** @var ImageMediaInterface $medium */
foreach ($this->alternatives as $medium) {
- if (!$medium->image) {
- $medium->image();
- }
-
$args_copy = $args;
// regular image: resize 400x400 -> 200x200
// --> @2x: resize 800x800->400x400
- if (isset(self::$magic_resize_actions[$method])) {
- foreach (self::$magic_resize_actions[$method] as $param) {
+ if (isset(static::$magic_resize_actions[$method])) {
+ foreach (static::$magic_resize_actions[$method] as $param) {
if (isset($args_copy[$param])) {
$args_copy[$param] *= $medium->get('ratio');
}
}
}
- call_user_func_array([$medium, $method], $args_copy);
- }
- } catch (\BadFunctionCallException $e) {
- }
-
- return $this;
- }
-
- /**
- * Gets medium image, resets image manipulation operations.
- *
- * @return $this
- */
- protected function image()
- {
- $locator = Grav::instance()['locator'];
-
- $file = $this->get('filepath');
-
- // Use existing cache folder or if it doesn't exist, create it.
- $cacheDir = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true);
-
- // Make sure we free previous image.
- unset($this->image);
-
- $this->image = ImageFile::open($file)
- ->setCacheDir($cacheDir)
- ->setActualCacheDir($cacheDir)
- ->setPrettyName($this->getImagePrettyName());
-
- return $this;
- }
-
- /**
- * Save the image with cache.
- *
- * @return string
- */
- protected function saveImage()
- {
- if (!$this->image) {
- return parent::path(false);
- }
-
- $this->filter();
-
- if (isset($this->result)) {
- return $this->result;
- }
-
- if (!$this->debug_watermarked && $this->get('debug')) {
- $ratio = $this->get('ratio');
- if (!$ratio) {
- $ratio = 1;
- }
-
- $locator = Grav::instance()['locator'];
- $overlay = $locator->findResource("system://assets/responsive-overlays/{$ratio}x.png") ?: $locator->findResource('system://assets/responsive-overlays/unknown.png');
- $this->image->merge(ImageFile::open($overlay));
- }
-
- return $this->image->cacheFile($this->format, $this->quality, false, [$this->get('width'), $this->get('height'), $this->get('modified')]);
- }
-
- /**
- * Filter image by using user defined filter parameters.
- *
- * @param string $filter Filter to be used.
- */
- public function filter($filter = 'image.filters.default')
- {
- $filters = (array) $this->get($filter, []);
- foreach ($filters as $params) {
- $params = (array) $params;
- $method = array_shift($params);
- $this->__call($method, $params);
- }
- }
-
- /**
- * Return the image higher quality version
- *
- * @return ImageMedium the alternative version with higher quality
- */
- public function higherQualityAlternative()
- {
- if ($this->alternatives) {
- $max = reset($this->alternatives);
- foreach($this->alternatives as $alternative)
- {
- if($alternative->quality() > $max->quality())
- {
- $max = $alternative;
- }
+ // Do the same call for alternative media.
+ $medium->__call($method, $args_copy);
}
-
- return $max;
+ } catch (BadFunctionCallException $e) {
}
return $this;
}
-
}
diff --git a/system/src/Grav/Common/Page/Medium/Link.php b/system/src/Grav/Common/Page/Medium/Link.php
index 6978635196..c76d8c191d 100644
--- a/system/src/Grav/Common/Page/Medium/Link.php
+++ b/system/src/Grav/Common/Page/Medium/Link.php
@@ -3,41 +3,60 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
-class Link implements RenderableInterface
+use BadMethodCallException;
+use Grav\Common\Media\Interfaces\MediaLinkInterface;
+use Grav\Common\Media\Interfaces\MediaObjectInterface;
+use RuntimeException;
+use function call_user_func_array;
+use function get_class;
+use function is_array;
+use function is_callable;
+
+/**
+ * Class Link
+ * @package Grav\Common\Page\Medium
+ */
+class Link implements RenderableInterface, MediaLinkInterface
{
use ParsedownHtmlTrait;
- /**
- * @var array
- */
+ /** @var array */
protected $attributes = [];
+ /** @var MediaObjectInterface|MediaLinkInterface */
protected $source;
/**
* Construct.
* @param array $attributes
- * @param Medium $medium
+ * @param MediaObjectInterface $medium
*/
- public function __construct(array $attributes, Medium $medium)
+ public function __construct(array $attributes, MediaObjectInterface $medium)
{
$this->attributes = $attributes;
- $this->source = $medium->reset()->thumbnail('auto')->display('thumbnail');
- $this->source->linked = true;
+
+ $source = $medium->reset()->thumbnail('auto')->display('thumbnail');
+ if (!$source instanceof MediaObjectInterface) {
+ throw new RuntimeException('Media has no thumbnail set');
+ }
+
+ $source->set('linked', true);
+
+ $this->source = $source;
}
/**
* Get an element (is array) that can be rendered by the Parsedown engine
*
- * @param string $title
- * @param string $alt
- * @param string $class
- * @param string $id
+ * @param string|null $title
+ * @param string|null $alt
+ * @param string|null $class
+ * @param string|null $id
* @param bool $reset
* @return array
*/
@@ -48,7 +67,7 @@ public function parsedownElement($title = null, $alt = null, $class = null, $id
return [
'name' => 'a',
'attributes' => $this->attributes,
- 'handler' => is_string($innerElement) ? 'line' : 'element',
+ 'handler' => is_array($innerElement) ? 'element' : 'line',
'text' => $innerElement
];
}
@@ -60,12 +79,24 @@ public function parsedownElement($title = null, $alt = null, $class = null, $id
* @param mixed $args
* @return mixed
*/
+ #[\ReturnTypeWillChange]
public function __call($method, $args)
{
- $this->source = call_user_func_array(array($this->source, $method), $args);
+ $object = $this->source;
+ $callable = [$object, $method];
+ if (!is_callable($callable)) {
+ throw new BadMethodCallException(get_class($object) . '::' . $method . '() not found.');
+ }
+
+ $object = call_user_func_array($callable, $args);
+ if (!$object instanceof MediaLinkInterface) {
+ // Don't start nesting links, if user has multiple link calls in his
+ // actions, we will drop the previous links.
+ return $this;
+ }
+
+ $this->source = $object;
- // Don't start nesting links, if user has multiple link calls in his
- // actions, we will drop the previous links.
- return $this->source instanceof Link ? $this->source : $this;
+ return $object;
}
}
diff --git a/system/src/Grav/Common/Page/Medium/Medium.php b/system/src/Grav/Common/Page/Medium/Medium.php
index bf0bb1fab3..0891c0c46a 100644
--- a/system/src/Grav/Common/Page/Medium/Medium.php
+++ b/system/src/Grav/Common/Page/Medium/Medium.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,68 +13,35 @@
use Grav\Common\Grav;
use Grav\Common\Data\Data;
use Grav\Common\Data\Blueprint;
-use Grav\Common\Media\Interfaces\MediaObjectInterface;
-use Grav\Common\Utils;
+use Grav\Common\Media\Interfaces\MediaFileInterface;
+use Grav\Common\Media\Interfaces\MediaLinkInterface;
+use Grav\Common\Media\Traits\MediaFileTrait;
+use Grav\Common\Media\Traits\MediaObjectTrait;
/**
* Class Medium
* @package Grav\Common\Page\Medium
*
+ * @property string $filepath
+ * @property string $filename
+ * @property string $basename
* @property string $mime
+ * @property int $size
+ * @property int $modified
+ * @property array $metadata
+ * @property int|string $timestamp
*/
-class Medium extends Data implements RenderableInterface, MediaObjectInterface
+class Medium extends Data implements RenderableInterface, MediaFileInterface
{
+ use MediaObjectTrait;
+ use MediaFileTrait;
use ParsedownHtmlTrait;
- /**
- * @var string
- */
- protected $mode = 'source';
-
- /**
- * @var Medium
- */
- protected $_thumbnail = null;
-
- /**
- * @var array
- */
- protected $thumbnailTypes = ['page', 'default'];
-
- protected $thumbnailType = null;
-
- /**
- * @var Medium[]
- */
- protected $alternatives = [];
-
- /**
- * @var array
- */
- protected $attributes = [];
-
- /**
- * @var array
- */
- protected $styleAttributes = [];
-
- /**
- * @var array
- */
- protected $metadata = [];
-
- /**
- * @var array
- */
- protected $medium_querystring = [];
-
- protected $timestamp;
-
/**
* Construct.
*
* @param array $items
- * @param Blueprint $blueprint
+ * @param Blueprint|null $blueprint
*/
public function __construct($items = [], Blueprint $blueprint = null)
{
@@ -85,99 +52,22 @@ public function __construct($items = [], Blueprint $blueprint = null)
}
$this->def('mime', 'application/octet-stream');
- $this->reset();
- }
-
- public function __clone()
- {
- // Allows future compatibility as parent::__clone() works.
- }
-
- /**
- * Create a copy of this media object
- *
- * @return Medium
- */
- public function copy()
- {
- return clone $this;
- }
-
- /**
- * Return just metadata from the Medium object
- *
- * @return Data
- */
- public function meta()
- {
- return new Data($this->items);
- }
-
- /**
- * Check if this medium exists or not
- *
- * @return bool
- */
- public function exists()
- {
- $path = $this->get('filepath');
- if (file_exists($path)) {
- return true;
- }
- return false;
- }
- /**
- * Get file modification time for the medium.
- *
- * @return int|null
- */
- public function modified()
- {
- $path = $this->get('filepath');
-
- if (!file_exists($path)) {
- return null;
+ if (!$this->offsetExists('size')) {
+ $path = $this->get('filepath');
+ $this->def('size', filesize($path));
}
- return filemtime($path) ?: null;
- }
-
- /**
- * @return int
- */
- public function size()
- {
- $path = $this->get('filepath');
-
- if (!file_exists($path)) {
- return 0;
- }
-
- return filesize($path) ?: 0;
- }
-
- /**
- * Set querystring to file modification timestamp (or value provided as a parameter).
- *
- * @param string|int|null $timestamp
- * @return $this
- */
- public function setTimestamp($timestamp = null)
- {
- $this->timestamp = (string)($timestamp ?? $this->modified());
-
- return $this;
+ $this->reset();
}
/**
- * Returns an array containing just the metadata
- *
- * @return array
+ * Clone medium.
*/
- public function metadata()
+ #[\ReturnTypeWillChange]
+ public function __clone()
{
- return $this->metadata;
+ // Allows future compatibility as parent::__clone() works.
}
/**
@@ -192,21 +82,15 @@ public function addMetaFile($filepath)
}
/**
- * Add alternative Medium to this Medium.
- *
- * @param int|float $ratio
- * @param Medium $alternative
+ * @return array
*/
- public function addAlternative($ratio, Medium $alternative)
+ public function getMeta(): array
{
- if (!is_numeric($ratio) || $ratio === 0) {
- return;
- }
-
- $alternative->set('ratio', $ratio);
- $width = $alternative->get('width');
-
- $this->alternatives[$width] = $alternative;
+ return [
+ 'mime' => $this->mime,
+ 'size' => $this->size,
+ 'modified' => $this->modified,
+ ];
}
/**
@@ -214,466 +98,43 @@ public function addAlternative($ratio, Medium $alternative)
*
* @return string
*/
+ #[\ReturnTypeWillChange]
public function __toString()
{
return $this->html();
}
/**
- * Return PATH to file.
- *
- * @param bool $reset
- * @return string path to file
- */
- public function path($reset = true)
- {
- if ($reset) {
- $this->reset();
- }
-
- return $this->get('filepath');
- }
-
- /**
- * Return the relative path to file
- *
- * @param bool $reset
- * @return mixed
- */
- public function relativePath($reset = true)
- {
- $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $this->get('filepath'));
-
- $locator = Grav::instance()['locator'];
- if ($locator->isStream($output)) {
- $output = $locator->findResource($output, false);
- }
-
- if ($reset) {
- $this->reset();
- }
-
- return str_replace(GRAV_ROOT, '', $output);
- }
-
- /**
- * Return URL to file.
- *
- * @param bool $reset
- * @return string
- */
- public function url($reset = true)
- {
- $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $this->get('filepath'));
-
- $locator = Grav::instance()['locator'];
- if ($locator->isStream($output)) {
- $output = $locator->findResource($output, false);
- }
-
- if ($reset) {
- $this->reset();
- }
-
- return trim(Grav::instance()['base_url'] . '/' . $this->urlQuerystring($output), '\\');
- }
-
- /**
- * Get/set querystring for the file's url
- *
- * @param string $querystring
- * @param bool $withQuestionmark
- * @return string
- */
- public function querystring($querystring = null, $withQuestionmark = true)
- {
- if (null !== $querystring) {
- $this->medium_querystring[] = ltrim($querystring, '?&');
- foreach ($this->alternatives as $alt) {
- $alt->querystring($querystring, $withQuestionmark);
- }
- }
-
- if (empty($this->medium_querystring)) {
- return '';
- }
-
- // join the strings
- $querystring = implode('&', $this->medium_querystring);
- // explode all strings
- $query_parts = explode('&', $querystring);
- // Join them again now ensure the elements are unique
- $querystring = implode('&', array_unique($query_parts));
-
- return $withQuestionmark ? ('?' . $querystring) : $querystring;
- }
-
- /**
- * Get the URL with full querystring
- *
- * @param string $url
- * @return string
- */
- public function urlQuerystring($url)
- {
- $querystring = $this->querystring();
- if (isset($this->timestamp) && !Utils::contains($querystring, $this->timestamp)) {
- $querystring = empty($querystring) ? ('?' . $this->timestamp) : ($querystring . '&' . $this->timestamp);
- }
-
- return ltrim($url . $querystring . $this->urlHash(), '/');
- }
-
- /**
- * Get/set hash for the file's url
- *
- * @param string $hash
- * @param bool $withHash
- * @return string
- */
- public function urlHash($hash = null, $withHash = true)
- {
- if ($hash) {
- $this->set('urlHash', ltrim($hash, '#'));
- }
-
- $hash = $this->get('urlHash', '');
-
- return $withHash && !empty($hash) ? '#' . $hash : $hash;
- }
-
- /**
- * Get an element (is array) that can be rendered by the Parsedown engine
- *
- * @param string $title
- * @param string $alt
- * @param string $class
- * @param string $id
- * @param bool $reset
- * @return array
- */
- public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true)
- {
- $attributes = $this->attributes;
-
- $style = '';
- foreach ($this->styleAttributes as $key => $value) {
- if (is_numeric($key)) // Special case for inline style attributes, refer to style() method
- $style .= $value;
- else
- $style .= $key . ': ' . $value . ';';
- }
- if ($style) {
- $attributes['style'] = $style;
- }
-
- if (empty($attributes['title'])) {
- if (!empty($title)) {
- $attributes['title'] = $title;
- } elseif (!empty($this->items['title'])) {
- $attributes['title'] = $this->items['title'];
- }
- }
-
- if (empty($attributes['alt'])) {
- if (!empty($alt)) {
- $attributes['alt'] = $alt;
- } elseif (!empty($this->items['alt'])) {
- $attributes['alt'] = $this->items['alt'];
- } elseif (!empty($this->items['alt_text'])) {
- $attributes['alt'] = $this->items['alt_text'];
- } else {
- $attributes['alt'] = '';
- }
- }
-
- if (empty($attributes['class'])) {
- if (!empty($class)) {
- $attributes['class'] = $class;
- } elseif (!empty($this->items['class'])) {
- $attributes['class'] = $this->items['class'];
- }
- }
-
- if (empty($attributes['id'])) {
- if (!empty($id)) {
- $attributes['id'] = $id;
- } elseif (!empty($this->items['id'])) {
- $attributes['id'] = $this->items['id'];
- }
- }
-
- switch ($this->mode) {
- case 'text':
- $element = $this->textParsedownElement($attributes, false);
- break;
- case 'thumbnail':
- $element = $this->getThumbnail()->sourceParsedownElement($attributes, false);
- break;
- case 'source':
- $element = $this->sourceParsedownElement($attributes, false);
- break;
- default:
- $element = [];
- }
-
- if ($reset) {
- $this->reset();
- }
-
- $this->display('source');
-
- return $element;
- }
-
- /**
- * Parsedown element for source display mode
- *
- * @param array $attributes
- * @param bool $reset
- * @return array
- */
- protected function sourceParsedownElement(array $attributes, $reset = true)
- {
- return $this->textParsedownElement($attributes, $reset);
- }
-
- /**
- * Parsedown element for text display mode
- *
- * @param array $attributes
- * @param bool $reset
- * @return array
- */
- protected function textParsedownElement(array $attributes, $reset = true)
- {
- $text = empty($attributes['title']) ? empty($attributes['alt']) ? $this->get('filename') : $attributes['alt'] : $attributes['title'];
-
- $element = [
- 'name' => 'p',
- 'attributes' => $attributes,
- 'text' => $text
- ];
-
- if ($reset) {
- $this->reset();
- }
-
- return $element;
- }
-
- /**
- * Reset medium.
- *
- * @return $this
- */
- public function reset()
- {
- $this->attributes = [];
- return $this;
- }
-
- /**
- * Switch display mode.
- *
- * @param string $mode
- *
- * @return $this
- */
- public function display($mode = 'source')
- {
- if ($this->mode === $mode) {
- return $this;
- }
-
-
- $this->mode = $mode;
-
- return $mode === 'thumbnail' ? ($this->getThumbnail() ? $this->getThumbnail()->reset() : null) : $this->reset();
- }
-
- /**
- * Helper method to determine if this media item has a thumbnail or not
- *
- * @param string $type;
- *
- * @return bool
+ * @param string $thumb
+ * @return Medium|null
*/
- public function thumbnailExists($type = 'page')
+ protected function createThumbnail($thumb)
{
- $thumbs = $this->get('thumbnails');
- if (isset($thumbs[$type])) {
- return true;
- }
- return false;
+ return MediumFactory::fromFile($thumb, ['type' => 'thumbnail']);
}
/**
- * Switch thumbnail.
- *
- * @param string $type
- *
- * @return $this
- */
- public function thumbnail($type = 'auto')
- {
- if ($type !== 'auto' && !\in_array($type, $this->thumbnailTypes, true)) {
- return $this;
- }
-
- if ($this->thumbnailType !== $type) {
- $this->_thumbnail = null;
- }
-
- $this->thumbnailType = $type;
-
- return $this;
- }
-
-
- /**
- * Turn the current Medium into a Link
- *
- * @param bool $reset
- * @param array $attributes
- * @return Link
+ * @param array $attributes
+ * @return MediaLinkInterface
*/
- public function link($reset = true, array $attributes = [])
+ protected function createLink(array $attributes)
{
- if ($this->mode !== 'source') {
- $this->display('source');
- }
-
- foreach ($this->attributes as $key => $value) {
- empty($attributes['data-' . $key]) && $attributes['data-' . $key] = $value;
- }
-
- empty($attributes['href']) && $attributes['href'] = $this->url();
-
return new Link($attributes, $this);
}
/**
- * Turn the current Medium into a Link with lightbox enabled
- *
- * @param int $width
- * @param int $height
- * @param bool $reset
- * @return Link
- */
- public function lightbox($width = null, $height = null, $reset = true)
- {
- $attributes = ['rel' => 'lightbox'];
-
- if ($width && $height) {
- $attributes['data-width'] = $width;
- $attributes['data-height'] = $height;
- }
-
- return $this->link($reset, $attributes);
- }
-
- /**
- * Add a class to the element from Markdown or Twig
- * Example:  or 
- *
- * @return $this
- */
- public function classes()
- {
- $classes = func_get_args();
- if (!empty($classes)) {
- $this->attributes['class'] = implode(',', $classes);
- }
-
- return $this;
- }
-
- /**
- * Add an id to the element from Markdown or Twig
- * Example: 
- *
- * @param string $id
- * @return $this
- */
- public function id($id)
- {
- if (is_string($id)) {
- $this->attributes['id'] = trim($id);
- }
-
- return $this;
- }
-
- /**
- * Allows to add an inline style attribute from Markdown or Twig
- * Example: 
- *
- * @param string $style
- * @return $this
- */
- public function style($style)
- {
- $this->styleAttributes[] = rtrim($style, ';') . ';';
- return $this;
- }
-
- /**
- * Allow any action to be called on this medium from twig or markdown
- *
- * @param string $method
- * @param mixed $args
- * @return $this
+ * @return Grav
*/
- public function __call($method, $args)
+ protected function getGrav(): Grav
{
- $qs = $method;
- if (\count($args) > 1 || (\count($args) === 1 && !empty($args[0]))) {
- $qs .= '=' . implode(',', array_map(function ($a) {
- if (is_array($a)) {
- $a = '[' . implode(',', $a) . ']';
- }
- return rawurlencode($a);
- }, $args));
- }
-
- if (!empty($qs)) {
- $this->querystring($this->querystring(null, false) . '&' . $qs);
- }
-
- return $this;
+ return Grav::instance();
}
/**
- * Get the thumbnail Medium object
- *
- * @return ThumbnailImageMedium
+ * @return array
*/
- protected function getThumbnail()
+ protected function getItems(): array
{
- if (!$this->_thumbnail) {
- $types = $this->thumbnailTypes;
-
- if ($this->thumbnailType !== 'auto') {
- array_unshift($types, $this->thumbnailType);
- }
-
- foreach ($types as $type) {
- $thumb = $this->get('thumbnails.' . $type, false);
-
- if ($thumb) {
- $thumb = $thumb instanceof ThumbnailImageMedium ? $thumb : MediumFactory::fromFile($thumb, ['type' => 'thumbnail']);
- $thumb->parent = $this;
- }
-
- if ($thumb) {
- $this->_thumbnail = $thumb;
- break;
- }
- }
- }
-
- return $this->_thumbnail;
+ return $this->items;
}
-
}
diff --git a/system/src/Grav/Common/Page/Medium/MediumFactory.php b/system/src/Grav/Common/Page/Medium/MediumFactory.php
index dc372d81db..913f198f17 100644
--- a/system/src/Grav/Common/Page/Medium/MediumFactory.php
+++ b/system/src/Grav/Common/Page/Medium/MediumFactory.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,8 +11,18 @@
use Grav\Common\Grav;
use Grav\Common\Data\Blueprint;
+use Grav\Common\Media\Interfaces\ImageMediaInterface;
+use Grav\Common\Media\Interfaces\MediaObjectInterface;
+use Grav\Common\Utils;
use Grav\Framework\Form\FormFlashFile;
+use Psr\Http\Message\UploadedFileInterface;
+use function dirname;
+use function is_array;
+/**
+ * Class MediumFactory
+ * @package Grav\Common\Page\Medium
+ */
class MediumFactory
{
/**
@@ -20,7 +30,7 @@ class MediumFactory
*
* @param string $file
* @param array $params
- * @return Medium
+ * @return Medium|null
*/
public static function fromFile($file, array $params = [])
{
@@ -28,19 +38,24 @@ public static function fromFile($file, array $params = [])
return null;
}
- $parts = pathinfo($file);
+ $parts = Utils::pathinfo($file);
$path = $parts['dirname'];
$filename = $parts['basename'];
- $ext = $parts['extension'];
+ $ext = $parts['extension'] ?? '';
$basename = $parts['filename'];
$config = Grav::instance()['config'];
- $media_params = $config->get('media.types.' . strtolower($ext));
- if (!\is_array($media_params)) {
+ $media_params = $ext ? $config->get('media.types.' . strtolower($ext)) : null;
+ if (!is_array($media_params)) {
return null;
}
+ // Remove empty 'image' attribute
+ if (isset($media_params['image']) && empty($media_params['image'])) {
+ unset($media_params['image']);
+ }
+
$params += $media_params;
// Add default settings for undefined variables.
@@ -71,23 +86,33 @@ public static function fromFile($file, array $params = [])
/**
* Create Medium from an uploaded file
*
- * @param FormFlashFile $uploadedFile
+ * @param UploadedFileInterface $uploadedFile
* @param array $params
- * @return Medium
+ * @return Medium|null
*/
- public static function fromUploadedFile(FormFlashFile $uploadedFile, array $params = [])
+ public static function fromUploadedFile(UploadedFileInterface $uploadedFile, array $params = [])
{
- $parts = pathinfo($uploadedFile->getClientFilename());
+ // For now support only FormFlashFiles, which exist over multiple requests. Also ignore errored and moved media.
+ if (!$uploadedFile instanceof FormFlashFile || $uploadedFile->getError() !== \UPLOAD_ERR_OK || $uploadedFile->isMoved()) {
+ return null;
+ }
+
+ $clientName = $uploadedFile->getClientFilename();
+ if (!$clientName) {
+ return null;
+ }
+
+ $parts = Utils::pathinfo($clientName);
$filename = $parts['basename'];
- $ext = $parts['extension'];
+ $ext = $parts['extension'] ?? '';
$basename = $parts['filename'];
$file = $uploadedFile->getTmpFile();
- $path = dirname($file);
+ $path = $file ? dirname($file) : '';
$config = Grav::instance()['config'];
- $media_params = $config->get('media.types.' . strtolower($ext));
- if (!\is_array($media_params)) {
+ $media_params = $ext ? $config->get('media.types.' . strtolower($ext)) : null;
+ if (!is_array($media_params)) {
return null;
}
@@ -104,7 +129,7 @@ public static function fromUploadedFile(FormFlashFile $uploadedFile, array $para
'basename' => $basename,
'extension' => $ext,
'path' => $path,
- 'modified' => filemtime($file),
+ 'modified' => $file ? filemtime($file) : 0,
'thumbnails' => []
];
@@ -134,8 +159,9 @@ public static function fromArray(array $items = [], Blueprint $blueprint = null)
return new ImageMedium($items, $blueprint);
case 'thumbnail':
return new ThumbnailImageMedium($items, $blueprint);
- case 'animated':
case 'vector':
+ return new VectorImageMedium($items, $blueprint);
+ case 'animated':
return new StaticImageMedium($items, $blueprint);
case 'video':
return new VideoMedium($items, $blueprint);
@@ -149,14 +175,14 @@ public static function fromArray(array $items = [], Blueprint $blueprint = null)
/**
* Create a new ImageMedium by scaling another ImageMedium object.
*
- * @param ImageMedium $medium
+ * @param ImageMediaInterface|MediaObjectInterface $medium
* @param int $from
* @param int $to
- * @return Medium|array
+ * @return ImageMediaInterface|MediaObjectInterface|array
*/
public static function scaledFromMedium($medium, $from, $to)
{
- if (! $medium instanceof ImageMedium) {
+ if (!$medium instanceof ImageMedium) {
return $medium;
}
@@ -169,7 +195,7 @@ public static function scaledFromMedium($medium, $from, $to)
$height = $medium->get('height') * $ratio;
$prev_basename = $medium->get('basename');
- $basename = str_replace('@'.$from.'x', '@'.$to.'x', $prev_basename);
+ $basename = str_replace('@' . $from . 'x', $to !== 1 ? '@' . $to . 'x' : '', $prev_basename);
$debug = $medium->get('debug');
$medium->set('debug', false);
@@ -184,6 +210,8 @@ public static function scaledFromMedium($medium, $from, $to)
$medium = self::fromFile($file);
if ($medium) {
+ $medium->set('basename', $basename);
+ $medium->set('filename', $basename . '.' . $medium->extension);
$medium->set('size', $size);
}
diff --git a/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php
index c6d75bc779..4ab2fb58f3 100644
--- a/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php
+++ b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -12,20 +12,22 @@
use Grav\Common\Markdown\Parsedown;
use Grav\Common\Page\Markdown\Excerpts;
+/**
+ * Trait ParsedownHtmlTrait
+ * @package Grav\Common\Page\Medium
+ */
trait ParsedownHtmlTrait
{
- /**
- * @var \Grav\Common\Markdown\Parsedown
- */
+ /** @var Parsedown|null */
protected $parsedown;
/**
* Return HTML markup from the medium.
*
- * @param string $title
- * @param string $alt
- * @param string $class
- * @param string $id
+ * @param string|null $title
+ * @param string|null $alt
+ * @param string|null $class
+ * @param string|null $id
* @param bool $reset
* @return string
*/
diff --git a/system/src/Grav/Common/Page/Medium/RenderableInterface.php b/system/src/Grav/Common/Page/Medium/RenderableInterface.php
index 50a6e67908..1ee6a96602 100644
--- a/system/src/Grav/Common/Page/Medium/RenderableInterface.php
+++ b/system/src/Grav/Common/Page/Medium/RenderableInterface.php
@@ -3,34 +3,39 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
+/**
+ * Interface RenderableInterface
+ * @package Grav\Common\Page\Medium
+ */
interface RenderableInterface
{
/**
* Return HTML markup from the medium.
*
- * @param string $title
- * @param string $alt
- * @param string $class
+ * @param string|null $title
+ * @param string|null $alt
+ * @param string|null $class
+ * @param string|null $id
* @param bool $reset
* @return string
*/
- public function html($title = null, $alt = null, $class = null, $reset = true);
+ public function html($title = null, $alt = null, $class = null, $id = null, $reset = true);
/**
* Return Parsedown Element from the medium.
*
- * @param string $title
- * @param string $alt
- * @param string $class
- * @param string $id
+ * @param string|null $title
+ * @param string|null $alt
+ * @param string|null $class
+ * @param string|null $id
* @param bool $reset
- * @return string
+ * @return array
*/
public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true);
}
diff --git a/system/src/Grav/Common/Page/Medium/StaticImageMedium.php b/system/src/Grav/Common/Page/Medium/StaticImageMedium.php
index 1fa543973c..97801000ae 100644
--- a/system/src/Grav/Common/Page/Medium/StaticImageMedium.php
+++ b/system/src/Grav/Common/Page/Medium/StaticImageMedium.php
@@ -3,15 +3,24 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
-class StaticImageMedium extends Medium
+use Grav\Common\Media\Interfaces\ImageMediaInterface;
+use Grav\Common\Media\Traits\ImageLoadingTrait;
+use Grav\Common\Media\Traits\StaticResizeTrait;
+
+/**
+ * Class StaticImageMedium
+ * @package Grav\Common\Page\Medium
+ */
+class StaticImageMedium extends Medium implements ImageMediaInterface
{
use StaticResizeTrait;
+ use ImageLoadingTrait;
/**
* Parsedown element for source display mode
@@ -22,8 +31,18 @@ class StaticImageMedium extends Medium
*/
protected function sourceParsedownElement(array $attributes, $reset = true)
{
- empty($attributes['src']) && $attributes['src'] = $this->url($reset);
+ if (empty($attributes['src'])) {
+ $attributes['src'] = $this->url($reset);
+ }
return ['name' => 'img', 'attributes' => $attributes];
}
+
+ /**
+ * @return $this
+ */
+ public function higherQualityAlternative()
+ {
+ return $this;
+ }
}
diff --git a/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php b/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php
index 8d6d43fb45..caaa7e2d6a 100644
--- a/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php
+++ b/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php
@@ -3,26 +3,22 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
+use Grav\Common\Media\Traits\StaticResizeTrait as NewResizeTrait;
+
+user_error('Grav\Common\Page\Medium\StaticResizeTrait is deprecated since Grav 1.7, use Grav\Common\Media\Traits\StaticResizeTrait instead', E_USER_DEPRECATED);
+
+/**
+ * Trait StaticResizeTrait
+ * @package Grav\Common\Page\Medium
+ * @deprecated 1.7 Use `Grav\Common\Media\Traits\StaticResizeTrait` instead
+ */
trait StaticResizeTrait
{
- /**
- * Resize media by setting attributes
- *
- * @param int $width
- * @param int $height
- * @return $this
- */
- public function resize($width = null, $height = null)
- {
- $this->styleAttributes['width'] = $width . 'px';
- $this->styleAttributes['height'] = $height . 'px';
-
- return $this;
- }
+ use NewResizeTrait;
}
diff --git a/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php b/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php
index 380baee64d..1e4d862f91 100644
--- a/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php
+++ b/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php
@@ -3,130 +3,19 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
+use Grav\Common\Media\Traits\ThumbnailMediaTrait;
+
+/**
+ * Class ThumbnailImageMedium
+ * @package Grav\Common\Page\Medium
+ */
class ThumbnailImageMedium extends ImageMedium
{
- /**
- * @var Medium
- */
- public $parent = null;
-
- /**
- * @var bool
- */
- public $linked = false;
-
- /**
- * Return srcset string for this Medium and its alternatives.
- *
- * @param bool $reset
- * @return string
- */
- public function srcset($reset = true)
- {
- return '';
- }
-
- /**
- * Get an element (is array) that can be rendered by the Parsedown engine
- *
- * @param string $title
- * @param string $alt
- * @param string $class
- * @param string $id
- * @param bool $reset
- * @return array
- */
- public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true)
- {
- return $this->bubble('parsedownElement', [$title, $alt, $class, $id, $reset]);
- }
-
- /**
- * Return HTML markup from the medium.
- *
- * @param string $title
- * @param string $alt
- * @param string $class
- * @param string $id
- * @param bool $reset
- * @return string
- */
- public function html($title = null, $alt = null, $class = null, $id = null, $reset = true)
- {
- return $this->bubble('html', [$title, $alt, $class, $id, $reset]);
- }
-
- /**
- * Switch display mode.
- *
- * @param string $mode
- *
- * @return $this
- */
- public function display($mode = 'source')
- {
- return $this->bubble('display', [$mode], false);
- }
-
- /**
- * Switch thumbnail.
- *
- * @param string $type
- *
- * @return $this
- */
- public function thumbnail($type = 'auto')
- {
- $this->bubble('thumbnail', [$type], false);
-
- return $this->bubble('getThumbnail', [], false);
- }
-
- /**
- * Turn the current Medium into a Link
- *
- * @param bool $reset
- * @param array $attributes
- * @return Link
- */
- public function link($reset = true, array $attributes = [])
- {
- return $this->bubble('link', [$reset, $attributes], false);
- }
-
- /**
- * Turn the current Medium into a Link with lightbox enabled
- *
- * @param int $width
- * @param int $height
- * @param bool $reset
- * @return Link
- */
- public function lightbox($width = null, $height = null, $reset = true)
- {
- return $this->bubble('lightbox', [$width, $height, $reset], false);
- }
-
- /**
- * Bubble a function call up to either the superclass function or the parent Medium instance
- *
- * @param string $method
- * @param array $arguments
- * @param bool $testLinked
- * @return Medium
- */
- protected function bubble($method, array $arguments = [], $testLinked = true)
- {
- if (!$testLinked || $this->linked) {
- return $this->parent ? call_user_func_array(array($this->parent, $method), $arguments) : $this;
- }
-
- return call_user_func_array(array($this, 'parent::' . $method), $arguments);
- }
+ use ThumbnailMediaTrait;
}
diff --git a/system/src/Grav/Common/Page/Medium/VectorImageMedium.php b/system/src/Grav/Common/Page/Medium/VectorImageMedium.php
new file mode 100644
index 0000000000..c44f35a77b
--- /dev/null
+++ b/system/src/Grav/Common/Page/Medium/VectorImageMedium.php
@@ -0,0 +1,68 @@
+get('width');
+ $height = $this->get('height');
+ if ($width && $height) {
+ return;
+ }
+
+ // Make sure that getting image size is supported.
+ if ($this->mime !== 'image/svg+xml' || !\extension_loaded('simplexml')) {
+ return;
+ }
+
+ // Make sure that the image exists.
+ $path = $this->get('filepath');
+ if (!$path || !file_exists($path) || !filesize($path)) {
+ return;
+ }
+
+ $xml = simplexml_load_string(file_get_contents($path));
+ $attr = $xml ? $xml->attributes() : null;
+ if (!$attr instanceof \SimpleXMLElement) {
+ return;
+ }
+
+ // Get the size from svg image.
+ if ($attr->width && $attr->height) {
+ $width = (string)$attr->width;
+ $height = (string)$attr->height;
+ } elseif ($attr->viewBox && \count($size = explode(' ', (string)$attr->viewBox)) === 4) {
+ [,$width,$height,] = $size;
+ }
+
+ if ($width && $height) {
+ $this->def('width', (int)$width);
+ $this->def('height', (int)$height);
+ }
+ }
+}
diff --git a/system/src/Grav/Common/Page/Medium/VideoMedium.php b/system/src/Grav/Common/Page/Medium/VideoMedium.php
index c0b488aa02..0e629dc89e 100644
--- a/system/src/Grav/Common/Page/Medium/VideoMedium.php
+++ b/system/src/Grav/Common/Page/Medium/VideoMedium.php
@@ -3,148 +3,22 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page\Medium;
-class VideoMedium extends Medium
-{
- use StaticResizeTrait;
-
- /**
- * Parsedown element for source display mode
- *
- * @param array $attributes
- * @param bool $reset
- * @return array
- */
- protected function sourceParsedownElement(array $attributes, $reset = true)
- {
- $location = $this->url($reset);
-
- return [
- 'name' => 'video',
- 'text' => 'Your browser does not support the video tag.',
- 'attributes' => $attributes
- ];
- }
+use Grav\Common\Media\Interfaces\VideoMediaInterface;
+use Grav\Common\Media\Traits\VideoMediaTrait;
- /**
- * Allows to set or remove the HTML5 default controls
- *
- * @param bool $display
- * @return $this
- */
- public function controls($display = true)
- {
- if($display) {
- $this->attributes['controls'] = true;
- } else {
- unset($this->attributes['controls']);
- }
-
- return $this;
- }
-
- /**
- * Allows to set the video's poster image
- *
- * @param string $urlImage
- * @return $this
- */
- public function poster($urlImage)
- {
- $this->attributes['poster'] = $urlImage;
-
- return $this;
- }
-
- /**
- * Allows to set the loop attribute
- *
- * @param bool $status
- * @return $this
- */
- public function loop($status = false)
- {
- if($status) {
- $this->attributes['loop'] = true;
- } else {
- unset($this->attributes['loop']);
- }
-
- return $this;
- }
-
- /**
- * Allows to set the autoplay attribute
- *
- * @param bool $status
- * @return $this
- */
- public function autoplay($status = false)
- {
- if ($status) {
- $this->attributes['autoplay'] = '';
- } else {
- unset($this->attributes['autoplay']);
- }
-
- return $this;
- }
-
- /**
- * Allows ability to set the preload option
- *
- * @param null $status
- * @return $this
- */
- public function preload($status = null)
- {
- if ($status) {
- $this->attributes['preload'] = $status;
- } else {
- unset($this->attributes['preload']);
- }
-
- return $this;
- }
-
- /**
- * Allows to set the playsinline attribute
- *
- * @param bool $status
- * @return $this
- */
- public function playsinline($status = false)
- {
- if($status) {
- $this->attributes['playsinline'] = true;
- } else {
- unset($this->attributes['playsinline']);
- }
-
- return $this;
- }
-
- /**
- * Allows to set the muted attribute
- *
- * @param bool $status
- * @return $this
- */
- public function muted($status = false)
- {
- if($status) {
- $this->attributes['muted'] = true;
- } else {
- unset($this->attributes['muted']);
- }
-
- return $this;
- }
+/**
+ * Class VideoMedium
+ * @package Grav\Common\Page\Medium
+ */
+class VideoMedium extends Medium implements VideoMediaInterface
+{
+ use VideoMediaTrait;
/**
* Reset medium.
@@ -155,7 +29,7 @@ public function reset()
{
parent::reset();
- $this->attributes['controls'] = true;
+ $this->resetPlayer();
return $this;
}
diff --git a/system/src/Grav/Common/Page/Page.php b/system/src/Grav/Common/Page/Page.php
index 6da3bbaed6..ccf676f6c4 100644
--- a/system/src/Grav/Common/Page/Page.php
+++ b/system/src/Grav/Common/Page/Page.php
@@ -3,107 +3,163 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page;
+use Exception;
use Grav\Common\Cache;
use Grav\Common\Config\Config;
use Grav\Common\Data\Blueprint;
use Grav\Common\File\CompiledYamlFile;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
+use Grav\Common\Language\Language;
use Grav\Common\Markdown\Parsedown;
use Grav\Common\Markdown\ParsedownExtra;
+use Grav\Common\Page\Interfaces\PageCollectionInterface;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Media\Traits\MediaTrait;
use Grav\Common\Page\Markdown\Excerpts;
-use Grav\Common\Taxonomy;
+use Grav\Common\Page\Traits\PageFormTrait;
+use Grav\Common\Twig\Twig;
use Grav\Common\Uri;
use Grav\Common\Utils;
use Grav\Common\Yaml;
-use Negotiation\Accept;
-use Negotiation\Negotiator;
+use Grav\Framework\Flex\Flex;
+use InvalidArgumentException;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\MarkdownFile;
+use RuntimeException;
+use SplFileInfo;
+use function dirname;
+use function in_array;
+use function is_array;
+use function is_object;
+use function is_string;
+use function strlen;
define('PAGE_ORDER_PREFIX_REGEX', '/^[0-9]+\./u');
+/**
+ * Class Page
+ * @package Grav\Common\Page
+ */
class Page implements PageInterface
{
+ use PageFormTrait;
use MediaTrait;
- /**
- * @var string Filename. Leave as null if page is folder.
- */
+ /** @var string|null Filename. Leave as null if page is folder. */
protected $name;
+ /** @var bool */
+ protected $initialized = false;
+ /** @var string */
protected $folder;
+ /** @var string */
protected $path;
+ /** @var string */
protected $extension;
+ /** @var string */
protected $url_extension;
-
+ /** @var string */
protected $id;
+ /** @var string */
protected $parent;
+ /** @var string */
protected $template;
+ /** @var int */
protected $expires;
+ /** @var string */
protected $cache_control;
+ /** @var bool */
protected $visible;
+ /** @var bool */
protected $published;
+ /** @var int */
protected $publish_date;
+ /** @var int|null */
protected $unpublish_date;
+ /** @var string */
protected $slug;
+ /** @var string|null */
protected $route;
+ /** @var string|null */
protected $raw_route;
+ /** @var string */
protected $url;
+ /** @var array */
protected $routes;
+ /** @var bool */
protected $routable;
+ /** @var int */
protected $modified;
+ /** @var string */
protected $redirect;
+ /** @var string */
protected $external_url;
- protected $items;
+ /** @var object|null */
protected $header;
+ /** @var string */
protected $frontmatter;
+ /** @var string */
protected $language;
+ /** @var string|null */
protected $content;
+ /** @var array */
protected $content_meta;
+ /** @var string|null */
protected $summary;
+ /** @var string */
protected $raw_content;
- protected $pagination;
+ /** @var array|null */
protected $metadata;
+ /** @var string */
protected $title;
+ /** @var int */
protected $max_count;
+ /** @var string */
protected $menu;
+ /** @var int */
protected $date;
+ /** @var string */
protected $dateformat;
+ /** @var array */
protected $taxonomy;
+ /** @var string */
protected $order_by;
+ /** @var string */
protected $order_dir;
+ /** @var array|string|null */
protected $order_manual;
- protected $modular;
+ /** @var bool */
protected $modular_twig;
+ /** @var array */
protected $process;
+ /** @var int|null */
protected $summary_size;
+ /** @var bool */
protected $markdown_extra;
+ /** @var bool */
protected $etag;
+ /** @var bool */
protected $last_modified;
+ /** @var string */
protected $home_route;
+ /** @var bool */
protected $hide_home_route;
+ /** @var bool */
protected $ssl;
+ /** @var string */
protected $template_format;
+ /** @var bool */
protected $debugger;
- /** @var array */
- protected $forms;
- /**
- * @var PageInterface Unmodified (original) version of the page. Used for copying and moving the page.
- */
+ /** @var PageInterface|null Unmodified (original) version of the page. Used for copying and moving the page. */
private $_original;
-
- /**
- * @var string Action
- */
+ /** @var string Action */
private $_action;
/**
@@ -122,15 +178,16 @@ public function __construct()
/**
* Initializes the page instance variables based on a file
*
- * @param \SplFileInfo $file The file information for the .md file that the page represents
- * @param string $extension
- *
+ * @param SplFileInfo $file The file information for the .md file that the page represents
+ * @param string|null $extension
* @return $this
*/
- public function init(\SplFileInfo $file, $extension = null)
+ public function init(SplFileInfo $file, $extension = null)
{
$config = Grav::instance()['config'];
+ $this->initialized = true;
+
// some extension logic
if (empty($extension)) {
$this->extension('.' . $file->getExtension());
@@ -139,7 +196,7 @@ public function init(\SplFileInfo $file, $extension = null)
}
// extract page language from page extension
- $language = trim(basename($this->extension(), 'md'), '.') ?: null;
+ $language = trim(Utils::basename($this->extension(), 'md'), '.') ?: null;
$this->language($language);
$this->hide_home_route = $config->get('system.home.hide_in_urls', false);
@@ -158,10 +215,32 @@ public function init(\SplFileInfo $file, $extension = null)
$this->published();
$this->urlExtension();
-
return $this;
}
+ #[\ReturnTypeWillChange]
+ public function __clone()
+ {
+ $this->initialized = false;
+ $this->header = $this->header ? clone $this->header : null;
+ }
+
+ /**
+ * @return void
+ */
+ public function initialize(): void
+ {
+ if (!$this->initialized) {
+ $this->initialized = true;
+ $this->route = null;
+ $this->raw_route = null;
+ $this->_forms = null;
+ }
+ }
+
+ /**
+ * @return void
+ */
protected function processFrontmatter()
{
// Quick check for twig output tags in frontmatter if enabled
@@ -183,22 +262,38 @@ protected function processFrontmatter()
* Return an array with the routes of other translated languages
*
* @param bool $onlyPublished only return published translations
- *
* @return array the page translated languages
*/
public function translatedLanguages($onlyPublished = false)
{
- $filename = substr($this->name, 0, -(strlen($this->extension())));
- $config = Grav::instance()['config'];
- $languages = $config->get('system.languages.supported', []);
+ $grav = Grav::instance();
+
+ /** @var Language $language */
+ $language = $grav['language'];
+
+ $languages = $language->getLanguages();
+ $defaultCode = $language->getDefault();
+
+ $name = substr($this->name, 0, -strlen($this->extension()));
$translatedLanguages = [];
- foreach ($languages as $language) {
- $path = $this->path . DS . $this->folder . DS . $filename . '.' . $language . '.md';
- if (file_exists($path)) {
- $aPage = new Page();
- $aPage->init(new \SplFileInfo($path), $language . '.md');
+ foreach ($languages as $languageCode) {
+ $languageExtension = ".{$languageCode}.md";
+ $path = $this->path . DS . $this->folder . DS . $name . $languageExtension;
+ $exists = file_exists($path);
+
+ // Default language may be saved without language file location.
+ if (!$exists && $languageCode === $defaultCode) {
+ $languageExtension = '.md';
+ $path = $this->path . DS . $this->folder . DS . $name . $languageExtension;
+ $exists = file_exists($path);
+ }
+ if ($exists) {
+ $aPage = new Page();
+ $aPage->init(new SplFileInfo($path), $languageExtension);
+ $aPage->route($this->route());
+ $aPage->rawRoute($this->rawRoute());
$route = $aPage->header()->routes['default'] ?? $aPage->rawRoute();
if (!$route) {
$route = $aPage->route();
@@ -208,7 +303,7 @@ public function translatedLanguages($onlyPublished = false)
continue;
}
- $translatedLanguages[$language] = $route;
+ $translatedLanguages[$languageCode] = $route;
}
}
@@ -219,37 +314,25 @@ public function translatedLanguages($onlyPublished = false)
* Return an array listing untranslated languages available
*
* @param bool $includeUnpublished also list unpublished translations
- *
* @return array the page untranslated languages
*/
public function untranslatedLanguages($includeUnpublished = false)
{
- $filename = substr($this->name, 0, -strlen($this->extension()));
- $config = Grav::instance()['config'];
- $languages = $config->get('system.languages.supported', []);
- $untranslatedLanguages = [];
+ $grav = Grav::instance();
- foreach ($languages as $language) {
- $path = $this->path . DS . $this->folder . DS . $filename . '.' . $language . '.md';
- if (file_exists($path)) {
- $aPage = new Page();
- $aPage->init(new \SplFileInfo($path), $language . '.md');
- if ($includeUnpublished && !$aPage->published()) {
- $untranslatedLanguages[] = $language;
- }
- } else {
- $untranslatedLanguages[] = $language;
- }
- }
+ /** @var Language $language */
+ $language = $grav['language'];
+
+ $languages = $language->getLanguages();
+ $translated = array_keys($this->translatedLanguages(!$includeUnpublished));
- return $untranslatedLanguages;
+ return array_values(array_diff($languages, $translated));
}
/**
* Gets and Sets the raw data
*
- * @param string $var Raw content string
- *
+ * @param string|null $var Raw content string
* @return string Raw content string
*/
public function raw($var = null)
@@ -282,7 +365,6 @@ public function raw($var = null)
*/
public function frontmatter($var = null)
{
-
if ($var) {
$this->frontmatter = (string)$var;
@@ -305,9 +387,8 @@ public function frontmatter($var = null)
/**
* Gets and Sets the header based on the YAML configuration at the top of the .md file
*
- * @param object|array $var a YAML object representing the configuration for the file
- *
- * @return object the current YAML configuration
+ * @param object|array|null $var a YAML object representing the configuration for the file
+ * @return \stdClass the current YAML configuration
*/
public function header($var = null)
{
@@ -337,8 +418,10 @@ public function header($var = null)
$frontmatterFile = CompiledYamlFile::instance($this->path . '/' . $this->folder . '/frontmatter.yaml');
if ($frontmatterFile->exists()) {
$frontmatter_data = (array)$frontmatterFile->content();
- $this->header = (object)array_replace_recursive($frontmatter_data,
- (array)$this->header);
+ $this->header = (object)array_replace_recursive(
+ $frontmatter_data,
+ (array)$this->header
+ );
$frontmatterFile->free();
}
// Process frontmatter with Twig if enabled
@@ -346,7 +429,7 @@ public function header($var = null)
$this->processFrontmatter();
}
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$file->raw(Grav::instance()['language']->translate([
'GRAV.FRONTMATTER_ERROR_PAGE',
$this->slug(),
@@ -360,8 +443,6 @@ public function header($var = null)
}
$var = true;
}
-
-
}
if ($var) {
@@ -465,8 +546,7 @@ public function header($var = null)
/**
* Get page language
*
- * @param string $var
- *
+ * @param string|null $var
* @return mixed
*/
public function language($var = null)
@@ -497,6 +577,9 @@ public function httpResponseCode()
return (int)($this->header()->http_response_code ?? 200);
}
+ /**
+ * @return array
+ */
public function httpHeaders()
{
$headers = [];
@@ -529,9 +612,9 @@ public function httpHeaders()
$headers['Last-Modified'] = $last_modified_date;
}
- // Calculate ETag based on the raw file
+ // Ask Grav to calculate ETag from the final content.
if ($this->eTag()) {
- $headers['ETag'] = '"' . md5($this->raw() . $this->modified()).'"';
+ $headers['ETag'] = '1';
}
// Set Vary: Accept-Encoding header
@@ -539,16 +622,19 @@ public function httpHeaders()
$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;
}
/**
* Get the summary.
*
- * @param int $size Max summary size.
- *
+ * @param int|null $size Max summary size.
* @param bool $textOnly Only count text size.
- *
* @return string
*/
public function summary($size = null, $textOnly = false)
@@ -568,8 +654,7 @@ public function summary($size = null, $textOnly = false)
$content = $textOnly ? strip_tags($this->content()) : $this->content();
$summary_size = $this->summary_size;
} else {
- $content = strip_tags($this->summary);
- // Use mb_strwidth to deal with the 2 character widths characters
+ $content = $textOnly ? strip_tags($this->summary) : $this->summary;
$summary_size = mb_strwidth($content, 'utf-8');
}
@@ -580,7 +665,7 @@ public function summary($size = null, $textOnly = false)
return $content;
}
if (($format === 'short') && isset($summary_size)) {
- // Use mb_strimwidth to slice the string
+ // Slice the string
if (mb_strwidth($content, 'utf8') > $summary_size) {
return mb_substr($content, 0, $summary_size);
}
@@ -608,12 +693,12 @@ public function summary($size = null, $textOnly = false)
return $content;
}
- return mb_strimwidth($content, 0, $size, '...', 'utf-8');
+ return mb_strimwidth($content, 0, $size, '…', 'UTF-8');
}
$summary = Utils::truncateHtml($content, $size);
- return html_entity_decode($summary);
+ return html_entity_decode($summary, ENT_COMPAT | ENT_HTML401, 'UTF-8');
}
/**
@@ -629,8 +714,7 @@ public function setSummary($summary)
/**
* Gets and Sets the content based on content portion of the .md file
*
- * @param string $var Content
- *
+ * @param string|null $var Content
* @return string Content
*/
public function content($var = null)
@@ -659,7 +743,7 @@ public function content($var = null)
// Load cached content
/** @var Cache $cache */
$cache = Grav::instance()['cache'];
- $cache_id = md5('page' . $this->id());
+ $cache_id = md5('page' . $this->getCacheKey());
$content_obj = $cache->fetch($cache_id);
if (is_array($content_obj)) {
@@ -673,14 +757,20 @@ public function content($var = null)
$process_markdown = $this->shouldProcess('markdown');
$process_twig = $this->shouldProcess('twig') || $this->modularTwig();
- $cache_enable = $this->header->cache_enable ?? $config->get('system.cache.enabled',
- true);
- $twig_first = $this->header->twig_first ?? $config->get('system.pages.twig_first',
- true);
+ $cache_enable = $this->header->cache_enable ?? $config->get(
+ 'system.cache.enabled',
+ true
+ );
+ $twig_first = $this->header->twig_first ?? $config->get(
+ 'system.pages.twig_first',
+ false
+ );
// never cache twig means it's always run after content
- $never_cache_twig = $this->header->never_cache_twig ?? $config->get('system.pages.never_cache_twig',
- false);
+ $never_cache_twig = $this->header->never_cache_twig ?? $config->get(
+ 'system.pages.never_cache_twig',
+ true
+ );
// if no cached-content run everything
if ($never_cache_twig) {
@@ -703,7 +793,6 @@ public function content($var = null)
if ($process_twig) {
$this->processTwig();
}
-
} else {
if ($this->content === false || $cache_enable === false) {
$this->content = $this->raw_content;
@@ -719,10 +808,9 @@ public function content($var = null)
// Content Processed but not cached yet
Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this]));
-
} else {
if ($process_markdown) {
- $this->processMarkdown();
+ $this->processMarkdown($process_twig);
}
// Content Processed but not cached yet
@@ -772,7 +860,7 @@ public function contentMeta()
* Add an entry to the page's contentMeta array
*
* @param string $name
- * @param string $value
+ * @param mixed $value
*/
public function addContentMeta($name, $value)
{
@@ -784,16 +872,12 @@ public function addContentMeta($name, $value)
*
* @param string|null $name
*
- * @return string
+ * @return mixed|null
*/
public function getContentMeta($name = null)
{
if ($name) {
- if (isset($this->content_meta[$name])) {
- return $this->content_meta[$name];
- }
-
- return null;
+ return $this->content_meta[$name] ?? null;
}
return $this->content_meta;
@@ -813,8 +897,11 @@ public function setContentMeta($content_meta)
/**
* Process the Markdown content. Uses Parsedown or Parsedown Extra depending on configuration
+ *
+ * @param bool $keepTwig If true, content between twig tags will not be processed.
+ * @return void
*/
- protected function processMarkdown()
+ protected function processMarkdown(bool $keepTwig = false)
{
/** @var Config $config */
$config = Grav::instance()['config'];
@@ -846,26 +933,57 @@ protected function processMarkdown()
$parsedown = new Parsedown($excerpts);
}
- $this->content = $parsedown->text($this->content);
+ $content = $this->content;
+ if ($keepTwig) {
+ $token = [
+ '/' . Utils::generateRandomString(3),
+ Utils::generateRandomString(3) . '/'
+ ];
+ // Base64 encode any twig.
+ $content = preg_replace_callback(
+ ['/({#.*?#})/mu', '/({{.*?}})/mu', '/({%.*?%})/mu'],
+ static function ($matches) use ($token) { return $token[0] . base64_encode($matches[1]) . $token[1]; },
+ $content
+ );
+ }
+
+ $content = $parsedown->text($content);
+
+ if ($keepTwig) {
+ // Base64 decode the encoded twig.
+ $content = preg_replace_callback(
+ ['`' . $token[0] . '([A-Za-z0-9+/]+={0,2})' . $token[1] . '`mu'],
+ static function ($matches) { return base64_decode($matches[1]); },
+ $content
+ );
+ }
+
+ $this->content = $content;
}
/**
* Process the Twig page content.
+ *
+ * @return void
*/
private function processTwig()
{
+ /** @var Twig $twig */
$twig = Grav::instance()['twig'];
$this->content = $twig->processPage($this, $this->content);
}
/**
* Fires the onPageContentProcessed event, and caches the page content using a unique ID for the page
+ *
+ * @return void
*/
public function cachePageContent()
{
+ /** @var Cache $cache */
$cache = Grav::instance()['cache'];
- $cache_id = md5('page' . $this->id());
+ $cache_id = md5('page' . $this->getCacheKey());
$cache->save($cache_id, ['content' => $this->content, 'content_meta' => $this->content_meta]);
}
@@ -882,7 +1000,8 @@ public function getRawContent()
/**
* Needed by the onPageContentProcessed event to set the raw page content
*
- * @param string $content
+ * @param string|null $content
+ * @return void
*/
public function setRawContent($content)
{
@@ -894,7 +1013,6 @@ public function setRawContent($content)
*
* @param string $name Variable name.
* @param mixed $default
- *
* @return mixed
*/
public function value($name, $default = null)
@@ -922,13 +1040,16 @@ public function value($name, $default = null)
return $this->slug();
}
if ($name === 'name') {
+ $name = $this->name();
$language = $this->language() ? '.' . $this->language() : '';
- $name_val = str_replace($language . '.md', '', $this->name());
- if ($this->modular()) {
- return 'modular/' . $name_val;
+ $pattern = '%(' . preg_quote($language, '%') . ')?\.md$%';
+ $name = preg_replace($pattern, '', $name);
+
+ if ($this->isModule()) {
+ return 'modular/' . $name;
}
- return $name_val;
+ return $name;
}
if ($name === 'media') {
return $this->media()->all();
@@ -975,7 +1096,6 @@ public function value($name, $default = null)
* Gets and Sets the Page raw content
*
* @param string|null $var
- *
* @return string
*/
public function rawMarkdown($var = null)
@@ -987,6 +1107,15 @@ public function rawMarkdown($var = null)
return $this->raw_content;
}
+ /**
+ * @return bool
+ * @internal
+ */
+ public function translated(): bool
+ {
+ return $this->initialized;
+ }
+
/**
* Get file object to the page.
*
@@ -1004,7 +1133,7 @@ public function file()
/**
* Save page if there's a file assigned to it.
*
- * @param bool|mixed $reorder Internal use.
+ * @param bool|array $reorder Internal use.
*/
public function save($reorder = true)
{
@@ -1024,6 +1153,14 @@ public function save($reorder = true)
$this->doReorder($reorder);
}
+ // We need to signal Flex Pages about the change.
+ /** @var Flex|null $flex */
+ $flex = Grav::instance()['flex'] ?? null;
+ $directory = $flex ? $flex->getDirectory('pages') : null;
+ if (null !== $directory) {
+ $directory->clearCache();
+ }
+
$this->_original = null;
}
@@ -1033,7 +1170,6 @@ public function save($reorder = true)
* You need to call $this->save() in order to perform the move.
*
* @param PageInterface $parent New parent page.
- *
* @return $this
*/
public function move(PageInterface $parent)
@@ -1046,10 +1182,10 @@ public function move(PageInterface $parent)
$this->_action = 'move';
if ($this->route() === $parent->route()) {
- throw new \RuntimeException('Failed: Cannot set page parent to self');
+ throw new RuntimeException('Failed: Cannot set page parent to self');
}
if (Utils::startsWith($parent->rawRoute(), $this->rawRoute())) {
- throw new \RuntimeException('Failed: Cannot set page parent to a child of current page');
+ throw new RuntimeException('Failed: Cannot set page parent to a child of current page');
}
$this->parent($parent);
@@ -1077,7 +1213,6 @@ public function move(PageInterface $parent)
* You need to call $this->save() in order to perform the move.
*
* @param PageInterface $parent New parent page.
- *
* @return $this
*/
public function copy(PageInterface $parent)
@@ -1117,6 +1252,17 @@ public function blueprints()
return $blueprint;
}
+ /**
+ * Returns the blueprint from the page.
+ *
+ * @param string $name Not used.
+ * @return Blueprint Returns a Blueprint.
+ */
+ public function getBlueprint(string $name = '')
+ {
+ return $this->blueprints();
+ }
+
/**
* Get the blueprint name for this page. Use the blueprint form field if set
*
@@ -1132,7 +1278,8 @@ public function blueprintName()
/**
* Validate page header.
*
- * @throws \Exception
+ * @return void
+ * @throws Exception
*/
public function validate()
{
@@ -1142,6 +1289,8 @@ public function validate()
/**
* Filter page header from illegal contents.
+ *
+ * @return void
*/
public function filter()
{
@@ -1200,97 +1349,15 @@ public function toJson()
/**
* @return string
*/
- protected function getCacheKey()
+ public function getCacheKey(): string
{
return $this->id();
}
- /**
- * Returns normalized list of name => form pairs.
- *
- * @return array
- */
- public function forms()
- {
- if (null === $this->forms) {
- $header = $this->header();
-
- // Call event to allow filling the page header form dynamically (e.g. use case: Comments plugin)
- $grav = Grav::instance();
- $grav->fireEvent('onFormPageHeaderProcessed', new Event(['page' => $this, 'header' => $header]));
-
- $rules = $header->rules ?? null;
- if (!\is_array($rules)) {
- $rules = [];
- }
-
- $forms = [];
-
- // First grab page.header.form
- $form = $this->normalizeForm($header->form ?? null, null, $rules);
- if ($form) {
- $forms[$form['name']] = $form;
- }
-
- // Append page.header.forms (override singular form if it clashes)
- $headerForms = $header->forms ?? null;
- if (\is_array($headerForms)) {
- foreach ($headerForms as $name => $form) {
- $form = $this->normalizeForm($form, $name, $rules);
- if ($form) {
- $forms[$form['name']] = $form;
- }
- }
- }
-
- $this->forms = $forms;
- }
-
- return $this->forms;
- }
-
- /**
- * @param array $new
- */
- public function addForms(array $new)
- {
- // Initialize forms.
- $this->forms();
-
- foreach ($new as $form) {
- $form = $this->normalizeForm($form);
- if ($form) {
- $this->forms[$form['name']] = $form;
- }
- }
- }
-
- protected function normalizeForm($form, $name = null, array $rules = [])
- {
- if (!\is_array($form)) {
- return null;
- }
-
- // Ignore numeric indexes on name.
- if (!$name || (string)(int)$name === (string)$name) {
- $name = null;
- }
-
- $name = $name ?? $form['name'] ?? $this->slug();
-
- $formRules = $form['rules'] ?? null;
- if (!\is_array($formRules)) {
- $formRules = [];
- }
-
- return ['name' => $name, 'rules' => $rules + $formRules] + $form;
- }
-
/**
* Gets and sets the associated media as found in the page folder.
*
- * @param Media $var Representation of associated media.
- *
+ * @param Media|null $var Representation of associated media.
* @return Media Representation of associated media.
*/
public function media($var = null)
@@ -1299,7 +1366,10 @@ public function media($var = null)
$this->setMedia($var);
}
- return $this->getMedia();
+ /** @var Media $media */
+ $media = $this->getMedia();
+
+ return $media;
}
/**
@@ -1327,8 +1397,7 @@ public function getMediaOrder()
/**
* Gets and sets the name field. If no name field is set, it will return 'default.md'.
*
- * @param string $var The name of this page.
- *
+ * @param string|null $var The name of this page.
* @return string The name of this page.
*/
public function name($var = null)
@@ -1354,8 +1423,7 @@ public function childType()
* Gets and sets the template field. This is used to find the correct Twig template file to render.
* If no field is set, it will return the name without the .md extension
*
- * @param string $var the template name
- *
+ * @param string|null $var the template name
* @return string the template name
*/
public function template($var = null)
@@ -1364,74 +1432,37 @@ public function template($var = null)
$this->template = $var;
}
if (empty($this->template)) {
- $this->template = ($this->modular() ? 'modular/' : '') . str_replace($this->extension(), '', $this->name());
+ $this->template = ($this->isModule() ? 'modular/' : '') . str_replace($this->extension(), '', $this->name());
}
return $this->template;
}
/**
- * Allows a page to override the output render format, usually the extension provided
- * in the URL. (e.g. `html`, `json`, `xml`, etc).
- *
- * @param null $var
+ * Allows a page to override the output render format, usually the extension provided in the URL.
+ * (e.g. `html`, `json`, `xml`, etc).
*
- * @return null
+ * @param string|null $var
+ * @return string
*/
public function templateFormat($var = null)
{
- if ($var !== null) {
- $this->template_format = $var;
- return $this->template_format;
- }
-
- if (isset($this->template_format)) {
- return $this->template_format;
- }
-
- // Set from URL extension set on page
- $page_extension = trim($this->header->append_url_extension ?? '' , '.');
- if (!empty($page_extension)) {
- $this->template_format = $page_extension;
-
- return $this->template_format;
+ if (null !== $var) {
+ $this->template_format = is_string($var) ? $var : null;
}
- // Set from uri extension
- $uri_extension = Grav::instance()['uri']->extension();
- if (is_string($uri_extension)) {
- $this->template_format = $uri_extension;
-
- return $this->template_format;
+ if (!isset($this->template_format)) {
+ $this->template_format = ltrim($this->header->append_url_extension ?? Utils::getPageFormat(), '.');
}
- // Use content negotiation via the `accept:` header
- $http_accept = $_SERVER['HTTP_ACCEPT'] ?? null;
- if (is_string($http_accept)) {
- $negotiator = new Negotiator();
-
- $supported_types = Utils::getSupportPageTypes(['html', 'json']);
- $priorities = Utils::getMimeTypes($supported_types);
-
- $media_type = $negotiator->getBest($http_accept, $priorities);
- $mimetype = $media_type instanceof Accept ? $media_type->getValue() : '';
-
- $this->template_format = Utils::getExtensionByMime($mimetype);
-
- return $this->template_format;
- }
-
- // Last chance set a default type
- $this->template_format = 'html';
return $this->template_format;
}
/**
* Gets and sets the extension field.
*
- * @param null $var
- *
- * @return null|string
+ * @param string|null $var
+ * @return string
*/
public function extension($var = null)
{
@@ -1439,7 +1470,7 @@ public function extension($var = null)
$this->extension = $var;
}
if (empty($this->extension)) {
- $this->extension = '.' . pathinfo($this->name(), PATHINFO_EXTENSION);
+ $this->extension = '.' . Utils::pathinfo($this->name(), PATHINFO_EXTENSION);
}
return $this->extension;
@@ -1468,8 +1499,7 @@ public function urlExtension()
/**
* Gets and sets the expires field. If not set will return the default
*
- * @param int $var The new expires value.
- *
+ * @param int|null $var The new expires value.
* @return int The expires value
*/
public function expires($var = null)
@@ -1485,8 +1515,8 @@ public function expires($var = null)
* Gets and sets the cache-control property. If not set it will return the default value (null)
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options
*
- * @param null $var
- * @return null
+ * @param string|null $var
+ * @return string|null
*/
public function cacheControl($var = null)
{
@@ -1500,8 +1530,7 @@ public function cacheControl($var = null)
/**
* Gets and sets the title for this Page. If no title is set, it will use the slug() to get a name
*
- * @param string $var the title of the Page
- *
+ * @param string|null $var the title of the Page
* @return string the title of the Page
*/
public function title($var = null)
@@ -1520,8 +1549,7 @@ public function title($var = null)
* Gets and sets the menu name for this Page. This is the text that can be used specifically for navigation.
* If no menu field is set, it will use the title()
*
- * @param string $var the menu field for the page
- *
+ * @param string|null $var the menu field for the page
* @return string the menu field for the page
*/
public function menu($var = null)
@@ -1539,8 +1567,7 @@ public function menu($var = null)
/**
* Gets and Sets whether or not this Page is visible for navigation
*
- * @param bool $var true if the page is visible
- *
+ * @param bool|null $var true if the page is visible
* @return bool true if the page is visible
*/
public function visible($var = null)
@@ -1565,8 +1592,7 @@ public function visible($var = null)
/**
* Gets and Sets whether or not this Page is considered published
*
- * @param bool $var true if the page is published
- *
+ * @param bool|null $var true if the page is published
* @return bool true if the page is published
*/
public function published($var = null)
@@ -1586,8 +1612,7 @@ public function published($var = null)
/**
* Gets and Sets the Page publish date
*
- * @param string $var string representation of a date
- *
+ * @param string|null $var string representation of a date
* @return int unix timestamp representation of the date
*/
public function publishDate($var = null)
@@ -1602,8 +1627,7 @@ public function publishDate($var = null)
/**
* Gets and Sets the Page unpublish date
*
- * @param string $var string representation of a date
- *
+ * @param string|null $var string representation of a date
* @return int|null unix timestamp representation of the date
*/
public function unpublishDate($var = null)
@@ -1620,8 +1644,7 @@ public function unpublishDate($var = null)
* via a URL.
* The page must be *routable* and *published*
*
- * @param bool $var true if the page is routable
- *
+ * @param bool|null $var true if the page is routable
* @return bool true if the page is routable
*/
public function routable($var = null)
@@ -1633,6 +1656,10 @@ public function routable($var = null)
return $this->routable && $this->published();
}
+ /**
+ * @param bool|null $var
+ * @return bool
+ */
public function ssl($var = null)
{
if ($var !== null) {
@@ -1646,8 +1673,7 @@ public function ssl($var = null)
* Gets and Sets the process setup for this Page. This is multi-dimensional array that consists of
* a simple array of arrays with the form array("markdown"=>true) for example
*
- * @param array $var an Array of name value pairs where the name is the process and value is true or false
- *
+ * @param array|null $var an Array of name value pairs where the name is the process and value is true or false
* @return array an Array of name value pairs where the name is the process and value is true or false
*/
public function process($var = null)
@@ -1660,9 +1686,9 @@ public function process($var = null)
}
/**
- * Returns the state of the debugger override etting for this page
+ * Returns the state of the debugger override setting for this page
*
- * @return mixed
+ * @return bool
*/
public function debugger()
{
@@ -1673,8 +1699,7 @@ public function debugger()
* Function to merge page metadata tags and build an array of Metadata objects
* that can then be rendered in the page.
*
- * @param array $var an Array of metadata values to set
- *
+ * @param array|null $var an Array of metadata values to set
* @return array an Array of metadata values for the page
*/
public function metadata($var = null)
@@ -1685,18 +1710,23 @@ public function metadata($var = null)
// if not metadata yet, process it.
if (null === $this->metadata) {
- $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible'];
+ $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible', 'content-security-policy'];
$this->metadata = [];
- $metadata = [];
// Set the Generator tag
- $metadata['generator'] = 'GravCMS';
+ $metadata = [
+ 'generator' => 'GravCMS'
+ ];
+
+ $config = Grav::instance()['config'];
+
+ $escape = !$config->get('system.strict_mode.twig_compat', false) || $config->get('system.twig.autoescape', true);
// Get initial metadata for the page
- $metadata = array_merge($metadata, Grav::instance()['config']->get('site.metadata'));
+ $metadata = array_merge($metadata, $config->get('site.metadata', []));
- if (isset($this->header->metadata)) {
+ if (isset($this->header->metadata) && is_array($this->header->metadata)) {
// Merge any site.metadata settings in with page metadata
$metadata = array_merge($metadata, $this->header->metadata);
}
@@ -1713,28 +1743,28 @@ public function metadata($var = null)
$this->metadata[$prop_key] = [
'name' => $prop_key,
'property' => $prop_key,
- 'content' => htmlspecialchars($prop_value, ENT_QUOTES, 'UTF-8')
+ 'content' => $escape ? htmlspecialchars($prop_value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $prop_value
];
}
} else {
// If it this is a standard meta data type
if ($value) {
- if (\in_array($key, $header_tag_http_equivs, true)) {
+ if (in_array($key, $header_tag_http_equivs, true)) {
$this->metadata[$key] = [
'http_equiv' => $key,
- 'content' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8')
+ 'content' => $escape ? htmlspecialchars($value, ENT_COMPAT, 'UTF-8') : $value
];
} elseif ($key === 'charset') {
- $this->metadata[$key] = ['charset' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8')];
+ $this->metadata[$key] = ['charset' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value];
} else {
// if it's a social metadata with separator, render as property
$separator = strpos($key, ':');
$hasSeparator = $separator && $separator < strlen($key) - 1;
$entry = [
- 'content' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8')
+ 'content' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value
];
- if ($hasSeparator && !Utils::startsWith($key, 'twitter')) {
+ if ($hasSeparator && !Utils::startsWith($key, ['twitter', 'flattr'])) {
$entry['property'] = $key;
} else {
$entry['name'] = $key;
@@ -1762,8 +1792,7 @@ public function resetMetadata()
* Gets and Sets the slug for the Page. The slug is used in the URL routing. If not set it uses
* the parent folder from the path
*
- * @param string $var the slug, e.g. 'my-blog'
- *
+ * @param string|null $var the slug, e.g. 'my-blog'
* @return string the slug
*/
public function slug($var = null)
@@ -1782,9 +1811,8 @@ public function slug($var = null)
/**
* Get/set order number of this page.
*
- * @param int $var
- *
- * @return int|bool
+ * @param int|null $var
+ * @return string|bool
*/
public function order($var = null)
{
@@ -1804,7 +1832,6 @@ public function order($var = null)
* Gets the URL for a page - alias of url().
*
* @param bool $include_host
- *
* @return string the permalink
*/
public function link($include_host = false)
@@ -1825,7 +1852,6 @@ public function permalink()
* Returns the canonical URL for a page
*
* @param bool $include_lang
- *
* @return string
*/
public function canonical($include_lang = true)
@@ -1840,7 +1866,6 @@ public function canonical($include_lang = true)
* @param bool $canonical True to return the canonical URL
* @param bool $include_base Include base url on multisite as well as language code
* @param bool $raw_route
- *
* @return string The url.
*/
public function url($include_host = false, $canonical = false, $include_base = true, $raw_route = false)
@@ -1878,11 +1903,6 @@ public function url($include_host = false, $canonical = false, $include_base = t
$uri = $grav['uri'];
$url = $uri->rootUrl($include_host) . '/' . trim($route, '/') . $this->urlExtension();
- // trim trailing / if not root
- if ($url !== '/') {
- $url = rtrim($url, '/');
- }
-
return Uri::filterPath($url);
}
@@ -1890,9 +1910,8 @@ public function url($include_host = false, $canonical = false, $include_base = t
* Gets the route for the page based on the route headers if available, else from
* the parents route and the current Page's slug.
*
- * @param string $var Set new default route.
- *
- * @return string The route for the Page.
+ * @param string|null $var Set new default route.
+ * @return string|null The route for the Page.
*/
public function route($var = null)
{
@@ -1937,8 +1956,7 @@ public function unsetRouteSlug()
/**
* Gets and Sets the page raw route
*
- * @param null $var
- *
+ * @param string|null $var
* @return null|string
*/
public function rawRoute($var = null)
@@ -1962,8 +1980,7 @@ public function rawRoute($var = null)
/**
* Gets the route aliases for the page based on page headers.
*
- * @param array $var list of route aliases
- *
+ * @param array|null $var list of route aliases
* @return array The route aliases for the Page.
*/
public function routeAliases($var = null)
@@ -1983,8 +2000,7 @@ public function routeAliases($var = null)
* Gets the canonical route for this page if its set. If provided it will use
* that value, else if it's `true` it will use the default route.
*
- * @param null $var
- *
+ * @param string|null $var
* @return bool|string
*/
public function routeCanonical($var = null)
@@ -2003,12 +2019,15 @@ public function routeCanonical($var = null)
/**
* Gets and sets the identifier for this Page object.
*
- * @param string $var the identifier
- *
+ * @param string|null $var the identifier
* @return string the identifier
*/
public function id($var = null)
{
+ if (null === $this->id) {
+ // We need to set unique id to avoid potential cache conflicts between pages.
+ $var = time() . md5($this->filePath());
+ }
if ($var !== null) {
// store unique per language
$active_lang = Grav::instance()['language']->getLanguage() ?: '';
@@ -2022,8 +2041,7 @@ public function id($var = null)
/**
* Gets and sets the modified timestamp.
*
- * @param int $var modified unix timestamp
- *
+ * @param int|null $var modified unix timestamp
* @return int modified unix timestamp
*/
public function modified($var = null)
@@ -2038,9 +2056,8 @@ public function modified($var = null)
/**
* Gets the redirect set in the header.
*
- * @param string $var redirect url
- *
- * @return string
+ * @param string|null $var redirect url
+ * @return string|null
*/
public function redirect($var = null)
{
@@ -2048,17 +2065,16 @@ public function redirect($var = null)
$this->redirect = $var;
}
- return $this->redirect;
+ return $this->redirect ?: null;
}
/**
* Gets and sets the option to show the etag header for the page.
*
- * @param bool $var show etag header
- *
+ * @param bool|null $var show etag header
* @return bool show etag header
*/
- public function eTag($var = null)
+ public function eTag($var = null): bool
{
if ($var !== null) {
$this->etag = $var;
@@ -2067,14 +2083,13 @@ public function eTag($var = null)
$this->etag = (bool)Grav::instance()['config']->get('system.pages.etag');
}
- return $this->etag;
+ return $this->etag ?? false;
}
/**
* Gets and sets the option to show the last_modified header for the page.
*
- * @param bool $var show last_modified header
- *
+ * @param bool|null $var show last_modified header
* @return bool show last_modified header
*/
public function lastModified($var = null)
@@ -2092,22 +2107,21 @@ public function lastModified($var = null)
/**
* Gets and sets the path to the .md file for this Page object.
*
- * @param string $var the file path
- *
+ * @param string|null $var the file path
* @return string|null the file path
*/
public function filePath($var = null)
{
if ($var !== null) {
// Filename of the page.
- $this->name = basename($var);
+ $this->name = Utils::basename($var);
// Folder of the page.
- $this->folder = basename(dirname($var));
+ $this->folder = Utils::basename(dirname($var));
// Path to the page.
$this->path = dirname($var, 2);
}
- return $this->path . '/' . $this->folder . '/' . ($this->name ?: '');
+ return rtrim($this->path . '/' . $this->folder . '/' . ($this->name() ?: ''), '/');
}
/**
@@ -2117,11 +2131,13 @@ public function filePath($var = null)
*/
public function filePathClean()
{
- return str_replace(ROOT_DIR, '', $this->filePath());
+ return str_replace(GRAV_ROOT . DS, '', $this->filePath());
}
/**
* Returns the clean path to the page file
+ *
+ * @return string
*/
public function relativePagePath()
{
@@ -2132,15 +2148,14 @@ public function relativePagePath()
* Gets and sets the path to the folder where the .md for this Page object resides.
* This is equivalent to the filePath but without the filename.
*
- * @param string $var the path
- *
+ * @param string|null $var the path
* @return string|null the path
*/
public function path($var = null)
{
if ($var !== null) {
// Folder of the page.
- $this->folder = basename($var);
+ $this->folder = Utils::basename($var);
// Path to the page.
$this->path = dirname($var);
}
@@ -2151,8 +2166,7 @@ public function path($var = null)
/**
* Get/set the folder.
*
- * @param string $var Optional path
- *
+ * @param string|null $var Optional path
* @return string|null
*/
public function folder($var = null)
@@ -2167,8 +2181,7 @@ public function folder($var = null)
/**
* Gets and sets the date for this Page object. This is typically passed in via the page headers
*
- * @param string $var string representation of a date
- *
+ * @param string|null $var string representation of a date
* @return int unix timestamp representation of the date
*/
public function date($var = null)
@@ -2188,8 +2201,7 @@ public function date($var = null)
* Gets and sets the date format for this Page object. This is typically passed in via the page headers
* using typical PHP date string structure - http://php.net/manual/en/function.date.php
*
- * @param string $var string representation of a date format
- *
+ * @param string|null $var string representation of a date format
* @return string string representation of a date format
*/
public function dateformat($var = null)
@@ -2204,8 +2216,7 @@ public function dateformat($var = null)
/**
* Gets and sets the order by which any sub-pages should be sorted.
*
- * @param string $var the order, either "asc" or "desc"
- *
+ * @param string|null $var the order, either "asc" or "desc"
* @return string the order, either "asc" or "desc"
* @deprecated 1.6
*/
@@ -2232,8 +2243,7 @@ public function orderDir($var = null)
* date - is the order based on the date set in the pages
* folder - is the order based on the name of the folder with any numerics omitted
*
- * @param string $var supported options include "default", "title", "date", and "folder"
- *
+ * @param string|null $var supported options include "default", "title", "date", and "folder"
* @return string supported options include "default", "title", "date", and "folder"
* @deprecated 1.6
*/
@@ -2251,8 +2261,7 @@ public function orderBy($var = null)
/**
* Gets the manual order set in the header.
*
- * @param string $var supported options include "default", "title", "date", and "folder"
- *
+ * @param string|null $var supported options include "default", "title", "date", and "folder"
* @return array
* @deprecated 1.6
*/
@@ -2271,8 +2280,7 @@ public function orderManual($var = null)
* Gets and sets the maxCount field which describes how many sub-pages should be displayed if the
* sub_pages header property is set for this page object.
*
- * @param int $var the maximum number of sub-pages
- *
+ * @param int|null $var the maximum number of sub-pages
* @return int the maximum number of sub-pages
* @deprecated 1.6
*/
@@ -2295,19 +2303,18 @@ public function maxCount($var = null)
/**
* Gets and sets the taxonomy array which defines which taxonomies this page identifies itself with.
*
- * @param array $var an array of taxonomies
- *
+ * @param array|null $var an array of taxonomies
* @return array an array of taxonomies
*/
public function taxonomy($var = null)
{
if ($var !== null) {
// make sure first level are arrays
- array_walk($var, function(&$value) {
+ array_walk($var, static function (&$value) {
$value = (array) $value;
});
// make sure all values are strings
- array_walk_recursive($var, function(&$value) {
+ array_walk_recursive($var, static function (&$value) {
$value = (string) $value;
});
$this->taxonomy = $var;
@@ -2319,12 +2326,14 @@ public function taxonomy($var = null)
/**
* Gets and sets the modular var that helps identify this page is a modular child
*
- * @param bool $var true if modular_twig
- *
+ * @param bool|null $var true if modular_twig
* @return bool true if modular_twig
+ * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead.
*/
public function modular($var = null)
{
+ user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->isModule() or ->modularTwig() method instead', E_USER_DEPRECATED);
+
return $this->modularTwig($var);
}
@@ -2332,8 +2341,7 @@ public function modular($var = null)
* Gets and sets the modular_twig var that helps identify this page as a modular child page that will need
* twig processing handled differently from a regular page.
*
- * @param bool $var true if modular_twig
- *
+ * @param bool|null $var true if modular_twig
* @return bool true if modular_twig
*/
public function modularTwig($var = null)
@@ -2349,14 +2357,13 @@ public function modularTwig($var = null)
}
}
- return $this->modular_twig;
+ return $this->modular_twig ?? false;
}
/**
* Gets the configured state of the processing method.
*
* @param string $process the process, eg "twig" or "markdown"
- *
* @return bool whether or not the processing method is enabled for this Page
*/
public function shouldProcess($process)
@@ -2367,8 +2374,7 @@ public function shouldProcess($process)
/**
* Gets and Sets the parent object for this page
*
- * @param PageInterface $var the parent page object
- *
+ * @param PageInterface|null $var the parent page object
* @return PageInterface|null the parent page object if it exists.
*/
public function parent(PageInterface $var = null)
@@ -2386,17 +2392,13 @@ public function parent(PageInterface $var = null)
}
/**
- * Gets the top parent object for this page
+ * Gets the top parent object for this page. Can return page itself.
*
- * @return PageInterface|null the top parent page object if it exists.
+ * @return PageInterface The top parent page object.
*/
public function topParent()
{
- $topParent = $this->parent();
-
- if (!$topParent) {
- return null;
- }
+ $topParent = $this;
while (true) {
$theParent = $topParent->parent();
@@ -2413,7 +2415,7 @@ public function topParent()
/**
* Returns children of this page.
*
- * @return Collection
+ * @return PageCollectionInterface|Collection
*/
public function children()
{
@@ -2480,8 +2482,7 @@ public function nextSibling()
* Returns the adjacent sibling based on a direction.
*
* @param int $direction either -1 or +1
- *
- * @return PageInterface|bool the sibling page
+ * @return PageInterface|false the sibling page
*/
public function adjacentSibling($direction = 1)
{
@@ -2497,7 +2498,7 @@ public function adjacentSibling($direction = 1)
/**
* Returns the item in the current position.
*
- * @return int the index of the current page.
+ * @return int|null The index of the current page.
*/
public function currentPosition()
{
@@ -2531,21 +2532,23 @@ public function active()
*/
public function activeChild()
{
- $uri = Grav::instance()['uri'];
- $pages = Grav::instance()['pages'];
+ $grav = Grav::instance();
+ /** @var Uri $uri */
+ $uri = $grav['uri'];
+ /** @var Pages $pages */
+ $pages = $grav['pages'];
$uri_path = rtrim(urldecode($uri->path()), '/');
- $routes = Grav::instance()['pages']->routes();
+ $routes = $pages->routes();
if (isset($routes[$uri_path])) {
- /** @var PageInterface $child_page */
- $child_page = $pages->dispatch($uri->route())->parent();
- if ($child_page) {
- while (!$child_page->root()) {
- if ($this->path() === $child_page->path()) {
- return true;
- }
- $child_page = $child_page->parent();
+ $page = $pages->find($uri->route());
+ /** @var PageInterface|null $child_page */
+ $child_page = $page ? $page->parent() : null;
+ while ($child_page && !$child_page->root()) {
+ if ($this->path() === $child_page->path()) {
+ return true;
}
+ $child_page = $child_page->parent();
}
}
@@ -2577,8 +2580,7 @@ public function root()
/**
* Helper method to return an ancestor page.
*
- * @param bool $lookup Name of the parent folder
- *
+ * @param bool|null $lookup Name of the parent folder
* @return PageInterface page you were looking for if it exists
*/
public function ancestor($lookup = null)
@@ -2594,12 +2596,11 @@ public function ancestor($lookup = null)
* page object is returned.
*
* @param string $field Name of the parent folder
- *
* @return PageInterface
*/
public function inherited($field)
{
- list($inherited, $currentParams) = $this->getInheritedParams($field);
+ [$inherited, $currentParams] = $this->getInheritedParams($field);
$this->modifyHeader($field, $currentParams);
@@ -2616,7 +2617,7 @@ public function inherited($field)
*/
public function inheritedField($field)
{
- list($inherited, $currentParams) = $this->getInheritedParams($field);
+ [$inherited, $currentParams] = $this->getInheritedParams($field);
return $currentParams;
}
@@ -2625,7 +2626,6 @@ public function inheritedField($field)
* Method that contains shared logic for inherited() and inheritedField()
*
* @param string $field Name of the parent folder
- *
* @return array
*/
protected function getInheritedParams($field)
@@ -2665,327 +2665,52 @@ public function find($url, $all = false)
* @param string|array $params
* @param bool $pagination
*
- * @return Collection
- * @throws \InvalidArgumentException
+ * @return PageCollectionInterface|Collection
+ * @throws InvalidArgumentException
*/
public function collection($params = 'content', $pagination = true)
{
if (is_string($params)) {
+ // Look into a page header field.
$params = (array)$this->value('header.' . $params);
} elseif (!is_array($params)) {
- throw new \InvalidArgumentException('Argument should be either header variable name or array of parameters');
- }
-
- if (!isset($params['items'])) {
- return new Collection();
- }
-
- // See if require published filter is set and use that, if assume published=true
- $only_published = true;
- if (isset($params['filter']['published']) && $params['filter']['published']) {
- $only_published = false;
- } elseif (isset($params['filter']['non-published']) && $params['filter']['non-published']) {
- $only_published = false;
- }
-
- $collection = $this->evaluate($params['items'], $only_published);
- if (!$collection instanceof Collection) {
- $collection = new Collection();
- }
- $collection->setParams($params);
-
- /** @var Uri $uri */
- $uri = Grav::instance()['uri'];
- /** @var Config $config */
- $config = Grav::instance()['config'];
-
- $process_taxonomy = $params['url_taxonomy_filters'] ?? $config->get('system.pages.url_taxonomy_filters');
-
- if ($process_taxonomy) {
- foreach ((array)$config->get('site.taxonomies') as $taxonomy) {
- if ($uri->param(rawurlencode($taxonomy))) {
- $items = explode(',', $uri->param($taxonomy));
- $collection->setParams(['taxonomies' => [$taxonomy => $items]]);
-
- foreach ($collection as $page) {
- // Don't filter modular pages
- if ($page->modular()) {
- continue;
- }
- foreach ($items as $item) {
- $item = rawurldecode($item);
- if (empty($page->taxonomy[$taxonomy]) || !\in_array(htmlspecialchars_decode($item, ENT_QUOTES), $page->taxonomy[$taxonomy], true)
- ) {
- $collection->remove($page->path());
- }
- }
- }
- }
- }
- }
-
- // If a filter or filters are set, filter the collection...
- if (isset($params['filter'])) {
-
- // remove any inclusive sets from filer:
- $sets = ['published', 'visible', 'modular', 'routable'];
- foreach ($sets as $type) {
- $var = "non-{$type}";
- if (isset($params['filter'][$type], $params['filter'][$var]) && $params['filter'][$type] && $params['filter'][$var]) {
- unset ($params['filter'][$type], $params['filter'][$var]);
- }
- }
-
- foreach ((array)$params['filter'] as $type => $filter) {
- switch ($type) {
- case 'published':
- if ((bool) $filter) {
- $collection->published();
- }
- break;
- case 'non-published':
- if ((bool) $filter) {
- $collection->nonPublished();
- }
- break;
- case 'visible':
- if ((bool) $filter) {
- $collection->visible();
- }
- break;
- case 'non-visible':
- if ((bool) $filter) {
- $collection->nonVisible();
- }
- break;
- case 'modular':
- if ((bool) $filter) {
- $collection->modular();
- }
- break;
- case 'non-modular':
- if ((bool) $filter) {
- $collection->nonModular();
- }
- break;
- case 'routable':
- if ((bool) $filter) {
- $collection->routable();
- }
- break;
- case 'non-routable':
- if ((bool) $filter) {
- $collection->nonRoutable();
- }
- break;
- case 'type':
- $collection->ofType($filter);
- break;
- case 'types':
- $collection->ofOneOfTheseTypes($filter);
- break;
- case 'access':
- $collection->ofOneOfTheseAccessLevels($filter);
- break;
- }
- }
- }
-
- if (isset($params['dateRange'])) {
- $start = $params['dateRange']['start'] ?? 0;
- $end = $params['dateRange']['end'] ?? false;
- $field = $params['dateRange']['field'] ?? false;
- $collection->dateRange($start, $end, $field);
+ throw new InvalidArgumentException('Argument should be either header variable name or array of parameters');
}
- if (isset($params['order'])) {
- $by = $params['order']['by'] ?? 'default';
- $dir = $params['order']['dir'] ?? 'asc';
- $custom = $params['order']['custom'] ?? null;
- $sort_flags = $params['order']['sort_flags'] ?? null;
-
- if (is_array($sort_flags)) {
- $sort_flags = array_map('constant', $sort_flags); //transform strings to constant value
- $sort_flags = array_reduce($sort_flags, function ($a, $b) {
- return $a | $b;
- }, 0); //merge constant values using bit or
- }
-
- $collection->order($by, $dir, $custom, $sort_flags);
- }
-
- /** @var Grav $grav */
- $grav = Grav::instance();
-
- // New Custom event to handle things like pagination.
- $grav->fireEvent('onCollectionProcessed', new Event(['collection' => $collection]));
-
- // Slice and dice the collection if pagination is required
- if ($pagination) {
- $params = $collection->params();
-
- $limit = $params['limit'] ?? 0;
- $start = !empty($params['pagination']) ? ($uri->currentPage() - 1) * $limit : 0;
+ $params['filter'] = ($params['filter'] ?? []) + ['translated' => true];
+ $context = [
+ 'pagination' => $pagination,
+ 'self' => $this
+ ];
- if ($limit && $collection->count() > $limit) {
- $collection->slice($start, $limit);
- }
- }
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
- return $collection;
+ return $pages->getCollection($params, $context);
}
/**
* @param string|array $value
* @param bool $only_published
- * @return mixed
+ * @return PageCollectionInterface|Collection
*/
public function evaluate($value, $only_published = true)
{
- // Parse command.
- if (is_string($value)) {
- // Format: @command.param
- $cmd = $value;
- $params = [];
- } elseif (is_array($value) && count($value) == 1 && !is_int(key($value))) {
- // Format: @command.param: { attr1: value1, attr2: value2 }
- $cmd = (string)key($value);
- $params = (array)current($value);
- } else {
- $result = [];
- foreach ((array)$value as $key => $val) {
- if (is_int($key)) {
- $result = $result + $this->evaluate($val)->toArray();
- } else {
- $result = $result + $this->evaluate([$key => $val])->toArray();
- }
-
- }
-
- return new Collection($result);
- }
+ $params = [
+ 'items' => $value,
+ 'published' => $only_published
+ ];
+ $context = [
+ 'event' => false,
+ 'pagination' => false,
+ 'url_taxonomy_filters' => false,
+ 'self' => $this
+ ];
/** @var Pages $pages */
$pages = Grav::instance()['pages'];
- $parts = explode('.', $cmd);
- $current = array_shift($parts);
-
- /** @var Collection $results */
- $results = new Collection();
-
- switch ($current) {
- case 'self@':
- case '@self':
- if (!empty($parts)) {
- switch ($parts[0]) {
- case 'modular':
- // @self.modular: false (alternative to @self.children)
- if (!empty($params) && $params[0] === false) {
- $results = $this->children()->nonModular();
- break;
- }
- $results = $this->children()->modular();
- break;
- case 'children':
- $results = $this->children()->nonModular();
- break;
- case 'all':
- $results = $this->children();
- break;
- case 'parent':
- $collection = new Collection();
- $results = $collection->addPage($this->parent());
- break;
- case 'siblings':
- if (!$this->parent()) {
- return new Collection();
- }
- $results = $this->parent()->children()->remove($this->path());
- break;
- case 'descendants':
- $results = $pages->all($this)->remove($this->path())->nonModular();
- break;
- }
- }
-
-
- break;
-
- case 'page@':
- case '@page':
- $page = null;
-
- if (!empty($params)) {
- $page = $this->find($params[0]);
- }
-
- // safety check in case page is not found
- if (!isset($page)) {
- return $results;
- }
-
- // Handle a @page.descendants
- if (!empty($parts)) {
- switch ($parts[0]) {
- case 'modular':
- $results = new Collection();
- foreach ($page->children() as $child) {
- $results = $results->addPage($child);
- }
- $results->modular();
- break;
- case 'page':
- case 'self':
- $results = new Collection();
- $results = $results->addPage($page);
- break;
-
- case 'descendants':
- $results = $pages->all($page)->remove($page->path())->nonModular();
- break;
-
- case 'children':
- $results = $page->children()->nonModular();
- break;
- }
- } else {
- $results = $page->children()->nonModular();
- }
-
- break;
-
- case 'root@':
- case '@root':
- if (!empty($parts) && $parts[0] === 'descendants') {
- $results = $pages->all($pages->root())->nonModular();
- } else {
- $results = $pages->root()->children()->nonModular();
- }
- break;
-
- case 'taxonomy@':
- case '@taxonomy':
- // Gets a collection of pages by using one of the following formats:
- // @taxonomy.category: blog
- // @taxonomy.category: [ blog, featured ]
- // @taxonomy: { category: [ blog, featured ], level: 1 }
-
- /** @var Taxonomy $taxonomy_map */
- $taxonomy_map = Grav::instance()['taxonomy'];
-
- if (!empty($parts)) {
- $params = [implode('.', $parts) => $params];
- }
- $results = $taxonomy_map->findTaxonomy($params);
- break;
- }
-
- if ($only_published) {
- $results = $results->published();
- }
-
- return $results;
+ return $pages->getCollection($params, $context);
}
/**
@@ -3012,6 +2737,14 @@ public function isDir()
return !$this->isPage();
}
+ /**
+ * @return bool
+ */
+ public function isModule(): bool
+ {
+ return $this->modularTwig();
+ }
+
/**
* Returns whether the page exists in the filesystem.
*
@@ -3038,7 +2771,6 @@ public function folderExists()
* Cleans the path.
*
* @param string $path the path
- *
* @return string the path
*/
protected function cleanPath($path)
@@ -3103,8 +2835,8 @@ protected function doReorder($new_order)
* Moves or copies the page in filesystem.
*
* @internal
- *
- * @throws \Exception
+ * @return void
+ * @throws Exception
*/
protected function doRelocation()
{
@@ -3126,9 +2858,11 @@ protected function doRelocation()
rename($path . '/' . $this->_original->name(), $path . '/' . $this->name());
}
}
-
}
+ /**
+ * @return void
+ */
protected function setPublishState()
{
// Handle publishing dates if no explicit published option set
@@ -3150,6 +2884,10 @@ protected function setPublishState()
}
}
+ /**
+ * @param string $route
+ * @return string
+ */
protected function adjustRouteCase($route)
{
$case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls');
@@ -3164,16 +2902,16 @@ protected function adjustRouteCase($route)
*/
public function getOriginal()
{
- return $this->_original;
+ return $this->_original;
}
/**
* Gets the action.
*
- * @return string The Action string.
+ * @return string|null The Action string.
*/
public function getAction()
{
- return $this->_action;
+ return $this->_action;
}
}
diff --git a/system/src/Grav/Common/Page/Pages.php b/system/src/Grav/Common/Page/Pages.php
index cd0dcc09de..6e2d14f298 100644
--- a/system/src/Grav/Common/Page/Pages.php
+++ b/system/src/Grav/Common/Page/Pages.php
@@ -3,130 +3,155 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page;
+use Exception;
+use FilesystemIterator;
use Grav\Common\Cache;
use Grav\Common\Config\Config;
use Grav\Common\Data\Blueprint;
use Grav\Common\Data\Blueprints;
+use Grav\Common\Debugger;
use Grav\Common\Filesystem\Folder;
+use Grav\Common\Flex\Types\Pages\PageCollection;
+use Grav\Common\Flex\Types\Pages\PageIndex;
use Grav\Common\Grav;
use Grav\Common\Language\Language;
+use Grav\Common\Page\Interfaces\PageCollectionInterface;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Taxonomy;
use Grav\Common\Uri;
use Grav\Common\Utils;
+use Grav\Framework\Flex\Flex;
+use Grav\Framework\Flex\FlexDirectory;
+use Grav\Framework\Flex\Interfaces\FlexTranslateInterface;
+use Grav\Framework\Flex\Pages\FlexPageObject;
use Grav\Plugin\Admin;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use RuntimeException;
+use SplFileInfo;
+use Symfony\Component\EventDispatcher\EventDispatcher;
use Whoops\Exception\ErrorException;
use Collator;
+use function array_key_exists;
+use function array_search;
+use function count;
+use function dirname;
+use function extension_loaded;
+use function in_array;
+use function is_array;
+use function is_int;
+use function is_string;
+/**
+ * Class Pages
+ * @package Grav\Common\Page
+ */
class Pages
{
- /**
- * @var Grav
- */
- protected $grav;
-
- /**
- * @var array|PageInterface[]
- */
- protected $instances;
+ /** @var FlexDirectory|null */
+ private $directory;
- /**
- * @var array|string[]
- */
+ /** @var Grav */
+ protected $grav;
+ /** @var array */
+ protected $instances = [];
+ /** @var array */
+ protected $index = [];
+ /** @var array */
protected $children;
-
- /**
- * @var string
- */
+ /** @var string */
protected $base = '';
-
- /**
- * @var array|string[]
- */
+ /** @var string[] */
protected $baseRoute = [];
-
- /**
- * @var array|string[]
- */
+ /** @var string[] */
protected $routes = [];
-
- /**
- * @var array
- */
+ /** @var array */
protected $sort;
-
- /**
- * @var Blueprints
- */
+ /** @var Blueprints */
protected $blueprints;
-
- /**
- * @var int
- */
+ /** @var bool */
+ protected $enable_pages = true;
+ /** @var int */
protected $last_modified;
-
- /**
- * @var array|string[]
- */
+ /** @var string[] */
protected $ignore_files;
-
- /**
- * @var array|string[]
- */
+ /** @var string[] */
protected $ignore_folders;
-
- /**
- * @var bool
- */
+ /** @var bool */
protected $ignore_hidden;
-
/** @var string */
protected $check_method;
-
+ /** @var string */
+ protected $simple_pages_hash;
+ /** @var string */
protected $pages_cache_id;
-
+ /** @var bool */
protected $initialized = false;
+ /** @var string */
+ protected $active_lang;
+ /** @var bool */
+ protected $fire_events = false;
+ /** @var Types|null */
+ protected static $types;
+ /** @var string|null */
+ protected static $home_route;
+
/**
- * @var Types
+ * Constructor
+ *
+ * @param Grav $grav
*/
- static protected $types;
+ public function __construct(Grav $grav)
+ {
+ $this->grav = $grav;
+ }
/**
- * @var string
+ * @return FlexDirectory|null
*/
- static protected $home_route;
+ public function getDirectory(): ?FlexDirectory
+ {
+ return $this->directory;
+ }
/**
- * Constructor
- *
- * @param Grav $c
+ * Method used in admin to disable frontend pages from being initialized.
*/
- public function __construct(Grav $c)
+ public function disablePages(): void
{
- $this->grav = $c;
+ $this->enable_pages = false;
+ }
+
+ /**
+ * Method used in admin to later load frontend pages.
+ */
+ public function enablePages(): void
+ {
+ if (!$this->enable_pages) {
+ $this->enable_pages = true;
+
+ $this->init();
+ }
}
/**
* Get or set base path for the pages.
*
- * @param string $path
- *
+ * @param string|null $path
* @return string
*/
public function base($path = null)
{
if ($path !== null) {
$path = trim($path, '/');
- $this->base = $path ? '/' . $path : null;
+ $this->base = $path ? '/' . $path : '';
$this->baseRoute = [];
}
@@ -137,13 +162,12 @@ public function base($path = null)
*
* Get base route for Grav pages.
*
- * @param string $lang Optional language code for multilingual routes.
- *
+ * @param string|null $lang Optional language code for multilingual routes.
* @return string
*/
public function baseRoute($lang = null)
{
- $key = $lang ?: 'default';
+ $key = $lang ?: $this->active_lang ?: 'default';
if (!isset($this->baseRoute[$key])) {
/** @var Language $language */
@@ -163,8 +187,7 @@ public function baseRoute($lang = null)
* Get route for Grav site.
*
* @param string $route Optional route to the page.
- * @param string $lang Optional language code for multilingual links.
- *
+ * @param string|null $lang Optional language code for multilingual links.
* @return string
*/
public function route($route = '/', $lang = null)
@@ -176,13 +199,64 @@ public function route($route = '/', $lang = null)
return $this->baseRoute($lang) . $route;
}
+ /**
+ * Get relative referrer route and language code. Returns null if the route isn't within the current base, language (if set) and route.
+ *
+ * @example `$langCode = null; $referrer = $pages->referrerRoute($langCode, '/admin');` returns relative referrer url within /admin and updates $langCode
+ * @example `$langCode = 'en'; $referrer = $pages->referrerRoute($langCode, '/admin');` returns relative referrer url within the /en/admin
+ *
+ * @param string|null $langCode Variable to store the language code. If already set, check only against that language.
+ * @param string $route Optional route within the site.
+ * @return string|null
+ * @since 1.7.23
+ */
+ public function referrerRoute(?string &$langCode, string $route = '/'): ?string
+ {
+ $referrer = $_SERVER['HTTP_REFERER'] ?? null;
+
+ // Start by checking that referrer came from our site.
+ $root = $this->grav['base_url_absolute'];
+ if (!is_string($referrer) || !str_starts_with($referrer, $root)) {
+ return null;
+ }
+
+ /** @var Language $language */
+ $language = $this->grav['language'];
+
+ // Get all language codes and append no language.
+ if (null === $langCode) {
+ $languages = $language->enabled() ? $language->getLanguages() : [];
+ $languages[] = '';
+ } else {
+ $languages[] = $langCode;
+ }
+
+ $path_base = rtrim($this->base(), '/');
+ $path_route = rtrim($route, '/');
+
+ // Try to figure out the language code.
+ foreach ($languages as $code) {
+ $path_lang = $code ? "/{$code}" : '';
+
+ $base = $path_base . $path_lang . $path_route;
+ if ($referrer === $base || str_starts_with($referrer, "{$base}/")) {
+ if (null === $langCode) {
+ $langCode = $code;
+ }
+
+ return substr($referrer, \strlen($base));
+ }
+ }
+
+ return null;
+ }
+
/**
*
* Get base URL for Grav pages.
*
- * @param string $lang Optional language code for multilingual links.
+ * @param string|null $lang Optional language code for multilingual links.
* @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
- *
* @return string
*/
public function baseUrl($lang = null, $absolute = null)
@@ -202,9 +276,8 @@ public function baseUrl($lang = null, $absolute = null)
*
* Get home URL for Grav site.
*
- * @param string $lang Optional language code for multilingual links.
- * @param bool $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
- *
+ * @param string|null $lang Optional language code for multilingual links.
+ * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
* @return string
*/
public function homeUrl($lang = null, $absolute = null)
@@ -217,9 +290,8 @@ public function homeUrl($lang = null, $absolute = null)
* Get URL for Grav site.
*
* @param string $route Optional route to the page.
- * @param string $lang Optional language code for multilingual links.
- * @param bool $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
- *
+ * @param string|null $lang Optional language code for multilingual links.
+ * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
* @return string
*/
public function url($route = '/', $lang = null, $absolute = null)
@@ -231,26 +303,56 @@ public function url($route = '/', $lang = null, $absolute = null)
return $this->baseUrl($lang, $absolute) . Uri::filterPath($route);
}
- public function setCheckMethod($method)
+ /**
+ * @param string $method
+ * @return void
+ */
+ public function setCheckMethod($method): void
{
$this->check_method = strtolower($method);
}
+ /**
+ * @return void
+ */
+ public function register(): void
+ {
+ $config = $this->grav['config'];
+ $type = $config->get('system.pages.type');
+ if ($type === 'flex') {
+ $this->initFlexPages();
+ }
+ }
+
+ /**
+ * Reset pages (used in search indexing etc).
+ *
+ * @return void
+ */
+ public function reset(): void
+ {
+ $this->initialized = false;
+
+ $this->init();
+ }
+
/**
* Class initialization. Must be called before using this class.
*/
- public function init()
+ public function init(): void
{
if ($this->initialized) {
return;
}
$config = $this->grav['config'];
- $this->ignore_files = $config->get('system.pages.ignore_files');
- $this->ignore_folders = $config->get('system.pages.ignore_folders');
- $this->ignore_hidden = $config->get('system.pages.ignore_hidden');
+ $this->ignore_files = (array)$config->get('system.pages.ignore_files');
+ $this->ignore_folders = (array)$config->get('system.pages.ignore_folders');
+ $this->ignore_hidden = (bool)$config->get('system.pages.ignore_hidden');
+ $this->fire_events = (bool)$config->get('system.pages.events.page');
$this->instances = [];
+ $this->index = [];
$this->children = [];
$this->routes = [];
@@ -258,14 +360,22 @@ public function init()
$this->setCheckMethod($config->get('system.cache.check.method', 'file'));
}
+ if ($this->enable_pages === false) {
+ $page = $this->buildRootPage();
+ $this->instances[$page->path()] = $page;
+
+ return;
+ }
+
$this->buildPages();
+
+ $this->initialized = true;
}
/**
* Get or set last modification time.
*
- * @param int $modified
- *
+ * @param int|null $modified
* @return int|null
*/
public function lastModified($modified = null)
@@ -280,11 +390,19 @@ public function lastModified($modified = null)
/**
* Returns a list of all pages.
*
- * @return array|PageInterface[]
+ * @return PageInterface[]
*/
public function instances()
{
- return $this->instances;
+ $instances = [];
+ foreach ($this->index as $path => $instance) {
+ $page = $this->get($path);
+ if ($page) {
+ $instances[$path] = $page;
+ }
+ }
+
+ return $instances;
}
/**
@@ -301,29 +419,352 @@ public function routes()
* Adds a page and assigns a route to it.
*
* @param PageInterface $page Page to be added.
- * @param string $route Optional route (uses route from the object if not set).
+ * @param string|null $route Optional route (uses route from the object if not set).
*/
- public function addPage(PageInterface $page, $route = null)
+ public function addPage(PageInterface $page, $route = null): void
{
- if (!isset($this->instances[$page->path()])) {
- $this->instances[$page->path()] = $page;
+ $path = $page->path() ?? '';
+ if (!isset($this->index[$path])) {
+ $this->index[$path] = $page;
+ $this->instances[$path] = $page;
}
$route = $page->route($route);
- if ($page->parent()) {
- $this->children[$page->parent()->path()][$page->path()] = ['slug' => $page->slug()];
+ $parent = $page->parent();
+ if ($parent) {
+ $this->children[$parent->path() ?? ''][$path] = ['slug' => $page->slug()];
}
- $this->routes[$route] = $page->path();
+ $this->routes[$route] = $path;
$this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));
}
+ /**
+ * Get a collection of pages in the given context.
+ *
+ * @param array $params
+ * @param array $context
+ * @return PageCollectionInterface|Collection
+ */
+ public function getCollection(array $params = [], array $context = [])
+ {
+ if (!isset($params['items'])) {
+ return new Collection();
+ }
+
+ /** @var Config $config */
+ $config = $this->grav['config'];
+
+ $context += [
+ 'event' => true,
+ 'pagination' => true,
+ 'url_taxonomy_filters' => $config->get('system.pages.url_taxonomy_filters'),
+ 'taxonomies' => (array)$config->get('site.taxonomies'),
+ 'pagination_page' => 1,
+ 'self' => null,
+ ];
+
+ // Include taxonomies from the URL if requested.
+ $process_taxonomy = $params['url_taxonomy_filters'] ?? $context['url_taxonomy_filters'];
+ if ($process_taxonomy) {
+ /** @var Uri $uri */
+ $uri = $this->grav['uri'];
+ foreach ($context['taxonomies'] as $taxonomy) {
+ $param = $uri->param(rawurlencode($taxonomy));
+ $items = is_string($param) ? explode(',', $param) : [];
+ foreach ($items as $item) {
+ $params['taxonomies'][$taxonomy][] = htmlspecialchars_decode(rawurldecode($item), ENT_QUOTES);
+ }
+ }
+ }
+
+ $pagination = $params['pagination'] ?? $context['pagination'];
+ if ($pagination && !isset($params['page'], $params['start'])) {
+ /** @var Uri $uri */
+ $uri = $this->grav['uri'];
+ $context['current_page'] = $uri->currentPage();
+ }
+
+ $collection = $this->evaluate($params['items'], $context['self']);
+ $collection->setParams($params);
+
+ // Filter by taxonomies.
+ foreach ($params['taxonomies'] ?? [] as $taxonomy => $items) {
+ foreach ($collection as $page) {
+ // Don't include modules
+ if ($page->isModule()) {
+ continue;
+ }
+
+ $test = $page->taxonomy()[$taxonomy] ?? [];
+ foreach ($items as $item) {
+ if (!$test || !in_array($item, $test, true)) {
+ $collection->remove($page->path());
+ }
+ }
+ }
+ }
+
+ $filters = $params['filter'] ?? [];
+
+ // Assume published=true if not set.
+ if (!isset($filters['published']) && !isset($filters['non-published'])) {
+ $filters['published'] = true;
+ }
+
+ // Remove any inclusive sets from filter.
+ $sets = ['published', 'visible', 'modular', 'routable'];
+ foreach ($sets as $type) {
+ $nonType = "non-{$type}";
+ if (isset($filters[$type], $filters[$nonType]) && $filters[$type] === $filters[$nonType]) {
+ if (!$filters[$type]) {
+ // Both options are false, return empty collection as nothing can match the filters.
+ return new Collection();
+ }
+
+ // Both options are true, remove opposite filters as all pages will match the filters.
+ unset($filters[$type], $filters[$nonType]);
+ }
+ }
+
+ // Filter the collection
+ foreach ($filters as $type => $filter) {
+ if (null === $filter) {
+ continue;
+ }
+
+ // Convert non-type to type.
+ if (str_starts_with($type, 'non-')) {
+ $type = substr($type, 4);
+ $filter = !$filter;
+ }
+
+ switch ($type) {
+ case 'translated':
+ if ($filter) {
+ $collection = $collection->translated();
+ } else {
+ $collection = $collection->nonTranslated();
+ }
+ break;
+ case 'published':
+ if ($filter) {
+ $collection = $collection->published();
+ } else {
+ $collection = $collection->nonPublished();
+ }
+ break;
+ case 'visible':
+ if ($filter) {
+ $collection = $collection->visible();
+ } else {
+ $collection = $collection->nonVisible();
+ }
+ break;
+ case 'page':
+ if ($filter) {
+ $collection = $collection->pages();
+ } else {
+ $collection = $collection->modules();
+ }
+ break;
+ case 'module':
+ case 'modular':
+ if ($filter) {
+ $collection = $collection->modules();
+ } else {
+ $collection = $collection->pages();
+ }
+ break;
+ case 'routable':
+ if ($filter) {
+ $collection = $collection->routable();
+ } else {
+ $collection = $collection->nonRoutable();
+ }
+ break;
+ case 'type':
+ $collection = $collection->ofType($filter);
+ break;
+ case 'types':
+ $collection = $collection->ofOneOfTheseTypes($filter);
+ break;
+ case 'access':
+ $collection = $collection->ofOneOfTheseAccessLevels($filter);
+ break;
+ }
+ }
+
+ if (isset($params['dateRange'])) {
+ $start = $params['dateRange']['start'] ?? null;
+ $end = $params['dateRange']['end'] ?? null;
+ $field = $params['dateRange']['field'] ?? null;
+ $collection = $collection->dateRange($start, $end, $field);
+ }
+
+ if (isset($params['order'])) {
+ $by = $params['order']['by'] ?? 'default';
+ $dir = $params['order']['dir'] ?? 'asc';
+ $custom = $params['order']['custom'] ?? null;
+ $sort_flags = $params['order']['sort_flags'] ?? null;
+
+ if (is_array($sort_flags)) {
+ $sort_flags = array_map('constant', $sort_flags); //transform strings to constant value
+ $sort_flags = array_reduce($sort_flags, static function ($a, $b) {
+ return $a | $b;
+ }, 0); //merge constant values using bit or
+ }
+
+ $collection = $collection->order($by, $dir, $custom, $sort_flags);
+ }
+
+ // New Custom event to handle things like pagination.
+ if ($context['event']) {
+ $this->grav->fireEvent('onCollectionProcessed', new Event(['collection' => $collection, 'context' => $context]));
+ }
+
+ if ($context['pagination']) {
+ // Slice and dice the collection if pagination is required
+ $params = $collection->params();
+
+ $limit = (int)($params['limit'] ?? 0);
+ $page = (int)($params['page'] ?? $context['current_page'] ?? 0);
+ $start = (int)($params['start'] ?? 0);
+ $start = $limit > 0 && $page > 0 ? ($page - 1) * $limit : max(0, $start);
+
+ if ($start || ($limit && $collection->count() > $limit)) {
+ $collection->slice($start, $limit ?: null);
+ }
+ }
+
+ return $collection;
+ }
+
+ /**
+ * @param array|string $value
+ * @param PageInterface|null $self
+ * @return Collection
+ */
+ protected function evaluate($value, PageInterface $self = null)
+ {
+ // Parse command.
+ if (is_string($value)) {
+ // Format: @command.param
+ $cmd = $value;
+ $params = [];
+ } elseif (is_array($value) && count($value) === 1 && !is_int(key($value))) {
+ // Format: @command.param: { attr1: value1, attr2: value2 }
+ $cmd = (string)key($value);
+ $params = (array)current($value);
+ } else {
+ $result = [];
+ foreach ((array)$value as $key => $val) {
+ if (is_int($key)) {
+ $result = $result + $this->evaluate($val, $self)->toArray();
+ } else {
+ $result = $result + $this->evaluate([$key => $val], $self)->toArray();
+ }
+ }
+
+ return new Collection($result);
+ }
+
+ $parts = explode('.', $cmd);
+ $scope = array_shift($parts);
+ $type = $parts[0] ?? null;
+
+ /** @var PageInterface|null $page */
+ $page = null;
+ switch ($scope) {
+ case 'self@':
+ case '@self':
+ $page = $self;
+ break;
+
+ case 'page@':
+ case '@page':
+ $page = isset($params[0]) ? $this->find($params[0]) : null;
+ break;
+
+ case 'root@':
+ case '@root':
+ $page = $this->root();
+ break;
+
+ case 'taxonomy@':
+ case '@taxonomy':
+ // Gets a collection of pages by using one of the following formats:
+ // @taxonomy.category: blog
+ // @taxonomy.category: [ blog, featured ]
+ // @taxonomy: { category: [ blog, featured ], level: 1 }
+
+ /** @var Taxonomy $taxonomy_map */
+ $taxonomy_map = Grav::instance()['taxonomy'];
+
+ if (!empty($parts)) {
+ $params = [implode('.', $parts) => $params];
+ }
+
+ return $taxonomy_map->findTaxonomy($params);
+ }
+
+ if (!$page) {
+ return new Collection();
+ }
+
+ // Handle '@page', '@page.modular: false', '@self' and '@self.modular: false'.
+ if (null === $type || (in_array($type, ['modular', 'modules']) && ($params[0] ?? null) === false)) {
+ $type = 'children';
+ }
+
+ switch ($type) {
+ case 'all':
+ $collection = $page->children();
+ break;
+ case 'modules':
+ case 'modular':
+ $collection = $page->children()->modules();
+ break;
+ case 'pages':
+ case 'children':
+ $collection = $page->children()->pages();
+ break;
+ case 'page':
+ case 'self':
+ $collection = !$page->root() ? (new Collection())->addPage($page) : new Collection();
+ break;
+ case 'parent':
+ $parent = $page->parent();
+ $collection = new Collection();
+ $collection = $parent ? $collection->addPage($parent) : $collection;
+ break;
+ case 'siblings':
+ $parent = $page->parent();
+ if ($parent) {
+ /** @var Collection $collection */
+ $collection = $parent->children();
+ $collection = $collection->remove($page->path());
+ } else {
+ $collection = new Collection();
+ }
+ break;
+ case 'descendants':
+ $collection = $this->all($page)->remove($page->path())->pages();
+ break;
+ default:
+ // Unknown type; return empty collection.
+ $collection = new Collection();
+ break;
+ }
+
+ return $collection;
+ }
+
/**
* Sort sub-pages in a page.
*
* @param PageInterface $page
- * @param string $order_by
- * @param string $order_dir
- *
+ * @param string|null $order_by
+ * @param string|null $order_dir
* @return array
*/
public function sort(PageInterface $page, $order_by = null, $order_dir = null, $sort_flags = null)
@@ -336,6 +777,10 @@ public function sort(PageInterface $page, $order_by = null, $order_dir = null, $
}
$path = $page->path();
+ if (null === $path) {
+ return [];
+ }
+
$children = $this->children[$path] ?? [];
if (!$children) {
@@ -357,11 +802,10 @@ public function sort(PageInterface $page, $order_by = null, $order_dir = null, $
/**
* @param Collection $collection
- * @param string|int $orderBy
+ * @param string $orderBy
* @param string $orderDir
* @param array|null $orderManual
* @param int|null $sort_flags
- *
* @return array
* @internal
*/
@@ -384,27 +828,70 @@ public function sortCollection(Collection $collection, $orderBy, $orderDir = 'as
}
return $sort;
-
}
/**
* Get a page instance.
*
* @param string $path The filesystem full path of the page
- *
- * @return PageInterface
- * @throws \Exception
+ * @return PageInterface|null
+ * @throws RuntimeException
*/
public function get($path)
{
- return $this->instances[(string)$path] ?? null;
+ $path = (string)$path;
+ if ($path === '') {
+ return null;
+ }
+
+ // Check for local instances first.
+ if (array_key_exists($path, $this->instances)) {
+ return $this->instances[$path];
+ }
+
+ $instance = $this->index[$path] ?? null;
+ if (is_string($instance)) {
+ if ($this->directory) {
+ /** @var Language $language */
+ $language = $this->grav['language'];
+ $lang = $language->getActive();
+ if ($lang) {
+ $languages = $language->getFallbackLanguages($lang, true);
+ $key = $instance;
+ $instance = null;
+ foreach ($languages as $code) {
+ $test = $code ? $key . ':' . $code : $key;
+ if (($instance = $this->directory->getObject($test, 'flex_key')) !== null) {
+ break;
+ }
+ }
+ } else {
+ $instance = $this->directory->getObject($instance, 'flex_key');
+ }
+ }
+
+ if ($instance instanceof PageInterface) {
+ if ($this->fire_events && method_exists($instance, 'initialize')) {
+ $instance->initialize();
+ }
+ } else {
+ /** @var Debugger $debugger */
+ $debugger = $this->grav['debugger'];
+ $debugger->addMessage(sprintf('Flex page %s is missing or broken!', $instance), 'debug');
+ }
+ }
+
+ if ($instance) {
+ $this->instances[$path] = $instance;
+ }
+
+ return $instance;
}
/**
* Get children of the path.
*
* @param string $path
- *
* @return Collection
*/
public function children($path)
@@ -418,14 +905,13 @@ public function children($path)
* Get a page ancestor.
*
* @param string $route The relative URL of the page
- * @param string $path The relative path of the ancestor folder
- *
+ * @param string|null $path The relative path of the ancestor folder
* @return PageInterface|null
*/
public function ancestor($route, $path = null)
{
if ($path !== null) {
- $page = $this->dispatch($route, true);
+ $page = $this->find($route, true);
if ($page && $page->path() === $path) {
return $page;
@@ -444,15 +930,13 @@ public function ancestor($route, $path = null)
* Get a page ancestor trait.
*
* @param string $route The relative route of the page
- * @param string $field The field name of the ancestor to query for
- *
+ * @param string|null $field The field name of the ancestor to query for
* @return PageInterface|null
*/
public function inherited($route, $field = null)
{
if ($field !== null) {
-
- $page = $this->dispatch($route, true);
+ $page = $this->find($route, true);
$parent = $page ? $page->parent() : null;
if ($parent && $parent->value('header.' . $field) !== null) {
@@ -467,97 +951,151 @@ public function inherited($route, $field = null)
}
/**
- * alias method to return find a page.
- *
- * @param string $route The relative URL of the page
- * @param bool $all
+ * Find a page based on route.
*
+ * @param string $route The route of the page
+ * @param bool $all If true, return also non-routable pages, otherwise return null if page isn't routable
* @return PageInterface|null
*/
public function find($route, $all = false)
{
- return $this->dispatch($route, $all, false);
+ $route = urldecode((string)$route);
+
+ // Fetch page if there's a defined route to it.
+ $path = $this->routes[$route] ?? null;
+ $page = null !== $path ? $this->get($path) : null;
+
+ // Try without trailing slash
+ if (null === $page && Utils::endsWith($route, '/')) {
+ $path = $this->routes[rtrim($route, '/')] ?? null;
+ $page = null !== $path ? $this->get($path) : null;
+ }
+
+ if (!$all && !isset($this->grav['admin'])) {
+ if (null === $page || !$page->routable()) {
+ // If the page cannot be accessed, look for the site wide routes and wildcards.
+ $page = $this->findSiteBasedRoute($route) ?? $page;
+ }
+ }
+
+ return $page;
+ }
+
+ /**
+ * Check site based routes.
+ *
+ * @param string $route
+ * @return PageInterface|null
+ */
+ protected function findSiteBasedRoute($route)
+ {
+ /** @var Config $config */
+ $config = $this->grav['config'];
+
+ $site_routes = $config->get('site.routes');
+ if (!is_array($site_routes)) {
+ return null;
+ }
+
+ $page = null;
+
+ // See if route matches one in the site configuration
+ $site_route = $site_routes[$route] ?? null;
+ if ($site_route) {
+ $page = $this->find($site_route);
+ } else {
+ // Use reverse order because of B/C (previously matched multiple and returned the last match).
+ foreach (array_reverse($site_routes, true) as $pattern => $replace) {
+ $pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#';
+ try {
+ $found = preg_replace($pattern, $replace, $route);
+ if ($found && $found !== $route) {
+ $page = $this->find($found);
+ if ($page) {
+ return $page;
+ }
+ }
+ } catch (ErrorException $e) {
+ $this->grav['log']->error('site.routes: ' . $pattern . '-> ' . $e->getMessage());
+ }
+ }
+ }
+
+ return $page;
}
/**
* Dispatch URI to a page.
*
* @param string $route The relative URL of the page
- * @param bool $all
- *
- * @param bool $redirect
+ * @param bool $all If true, return also non-routable pages, otherwise return null if page isn't routable
+ * @param bool $redirect If true, allow redirects
* @return PageInterface|null
- * @throws \Exception
+ * @throws Exception
*/
public function dispatch($route, $all = false, $redirect = true)
{
- $route = urldecode($route);
+ $page = $this->find($route, true);
- // Fetch page if there's a defined route to it.
- $page = isset($this->routes[$route]) ? $this->get($this->routes[$route]) : null;
- // Try without trailing slash
- if (!$page && Utils::endsWith($route, '/')) {
- $page = isset($this->routes[rtrim($route, '/')]) ? $this->get($this->routes[rtrim($route, '/')]) : null;
+ // If we want all pages or are in admin, return what we already have.
+ if ($all || isset($this->grav['admin'])) {
+ return $page;
}
- // Are we in the admin? this is important!
- $not_admin = !isset($this->grav['admin']);
+ if ($page) {
+ $routable = $page->routable();
+ if ($redirect) {
+ if ($page->redirect()) {
+ // Follow a redirect page.
+ $this->grav->redirectLangSafe($page->redirect());
+ }
- // If the page cannot be reached, look into site wide redirects, routes + wildcards
- if (!$all && $not_admin) {
+ if (!$routable) {
+ /** @var Collection $children */
+ $children = $page->children()->visible()->routable()->published();
+ $child = $children->first();
+ if ($child !== null) {
+ // Redirect to the first visible child as current page isn't routable.
+ $this->grav->redirectLangSafe($child->route());
+ }
+ }
+ }
- // If the page is a simple redirect, just do it.
- if ($redirect && $page && $page->redirect()) {
- $this->grav->redirectLangSafe($page->redirect());
+ if ($routable) {
+ return $page;
}
+ }
- // fall back and check site based redirects
- if (!$page || ($page && !$page->routable())) {
- /** @var Config $config */
- $config = $this->grav['config'];
+ $route = urldecode((string)$route);
- // See if route matches one in the site configuration
- $site_route = $config->get("site.routes.{$route}");
- if ($site_route) {
- $page = $this->dispatch($site_route, $all);
- } else {
+ // The page cannot be reached, look into site wide redirects, routes and wildcards.
+ $redirectedPage = $this->findSiteBasedRoute($route);
+ if ($redirectedPage) {
+ $page = $this->dispatch($redirectedPage->route(), false, $redirect);
+ }
- /** @var Uri $uri */
- $uri = $this->grav['uri'];
- /** @var \Grav\Framework\Uri\Uri $source_url */
- $source_url = $uri->uri(false);
-
- // Try Regex style redirects
- $site_redirects = $config->get('site.redirects');
- if (is_array($site_redirects)) {
- foreach ((array)$site_redirects as $pattern => $replace) {
- $pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#';
- try {
- $found = preg_replace($pattern, $replace, $source_url);
- if ($found !== $source_url) {
- $this->grav->redirectLangSafe($found);
- }
- } catch (ErrorException $e) {
- $this->grav['log']->error('site.redirects: ' . $pattern . '-> ' . $e->getMessage());
- }
- }
- }
+ /** @var Config $config */
+ $config = $this->grav['config'];
- // Try Regex style routes
- $site_routes = $config->get('site.routes');
- if (is_array($site_routes)) {
- foreach ((array)$site_routes as $pattern => $replace) {
- $pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#';
- try {
- $found = preg_replace($pattern, $replace, $source_url);
- if ($found !== $source_url) {
- $page = $this->dispatch($found, $all);
- }
- } catch (ErrorException $e) {
- $this->grav['log']->error('site.routes: ' . $pattern . '-> ' . $e->getMessage());
- }
- }
+ /** @var Uri $uri */
+ $uri = $this->grav['uri'];
+ /** @var \Grav\Framework\Uri\Uri $source_url */
+ $source_url = $uri->uri(false);
+
+ // Try Regex style redirects
+ $site_redirects = $config->get('site.redirects');
+ if (is_array($site_redirects)) {
+ foreach ((array)$site_redirects as $pattern => $replace) {
+ $pattern = ltrim($pattern, '^');
+ $pattern = '#^' . str_replace('/', '\/', $pattern) . '#';
+ try {
+ /** @var string $found */
+ $found = preg_replace($pattern, $replace, $source_url);
+ if ($found && $found !== $source_url) {
+ $this->grav->redirectLangSafe($found);
}
+ } catch (ErrorException $e) {
+ $this->grav['log']->error('site.redirects: ' . $pattern . '-> ' . $e->getMessage());
}
}
}
@@ -569,20 +1107,26 @@ public function dispatch($route, $all = false, $redirect = true)
* Get root page.
*
* @return PageInterface
+ * @throws RuntimeException
*/
public function root()
{
/** @var UniformResourceLocator $locator */
$locator = $this->grav['locator'];
- return $this->instances[rtrim($locator->findResource('page://'), DS)];
+ $path = $locator->findResource('page://');
+ $root = is_string($path) ? $this->get(rtrim($path, '/')) : null;
+ if (null === $root) {
+ throw new RuntimeException('Internal error');
+ }
+
+ return $root;
}
/**
* Get a blueprint for a page type.
*
* @param string $type
- *
* @return Blueprint
*/
public function blueprints($type)
@@ -593,13 +1137,13 @@ public function blueprints($type)
try {
$blueprint = $this->blueprints->get($type);
- } catch (\RuntimeException $e) {
+ } catch (RuntimeException $e) {
$blueprint = $this->blueprints->get('default');
}
if (empty($blueprint->initialized)) {
- $this->grav->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $type]));
$blueprint->initialized = true;
+ $this->grav->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $type]));
}
return $blueprint;
@@ -608,9 +1152,8 @@ public function blueprints($type)
/**
* Get all pages
*
- * @param PageInterface $current
- *
- * @return \Grav\Common\Page\Collection
+ * @param PageInterface|null $current
+ * @return Collection
*/
public function all(PageInterface $current = null)
{
@@ -646,7 +1189,6 @@ public static function parentsRawRoutes()
* Get available parents routes
*
* @param bool $rawRoutes get the raw route or the normal route
- *
* @return array
*/
private static function getParents($rawRoutes)
@@ -669,19 +1211,17 @@ private static function getParents($rawRoutes)
if (isset($parents[$page_route])) {
unset($parents[$page_route]);
}
-
}
return $parents;
}
/**
- * Get list of route/title of all pages.
+ * Get list of route/title of all pages. Title is in HTML.
*
- * @param PageInterface $current
+ * @param PageInterface|null $current
* @param int $level
* @param bool $rawRoutes
- *
* @param bool $showAll
* @param bool $showFullpath
* @param bool $showSlug
@@ -693,7 +1233,7 @@ public function getList(PageInterface $current = null, $level = 0, $rawRoutes =
{
if (!$current) {
if ($level) {
- throw new \RuntimeException('Internal error');
+ throw new RuntimeException('Internal error');
}
$current = $this->root();
@@ -709,22 +1249,18 @@ public function getList(PageInterface $current = null, $level = 0, $rawRoutes =
}
if ($showFullpath) {
- $option = $current->route();
+ $option = htmlspecialchars($current->route());
} else {
$extra = $showSlug ? '(' . $current->slug() . ') ' : '';
- $option = str_repeat('—-', $level). '▸ ' . $extra . $current->title();
-
-
+ $option = str_repeat('—-', $level). '▸ ' . $extra . htmlspecialchars($current->title());
}
$list[$route] = $option;
-
-
}
if ($limitLevels === false || ($level+1 < $limitLevels)) {
foreach ($current->children() as $next) {
- if ($showAll || $next->routable() || ($next->modular() && $showModular)) {
+ if ($showAll || $next->routable() || ($next->isModule() && $showModular)) {
$list = array_merge($list, $this->getList($next, $level + 1, $rawRoutes, $showAll, $showFullpath, $showSlug, $showModular, $limitLevels));
}
}
@@ -740,23 +1276,38 @@ public function getList(PageInterface $current = null, $level = 0, $rawRoutes =
*/
public static function getTypes()
{
- if (!self::$types) {
+ if (null === self::$types) {
$grav = Grav::instance();
- $scanBlueprintsAndTemplates = function () use ($grav) {
+ /** @var UniformResourceLocator $locator */
+ $locator = $grav['locator'];
+
+ // Prevent calls made before theme:// has been initialized (happens when upgrading old version of Admin plugin).
+ if (!$locator->isStream('theme://')) {
+ return new Types();
+ }
+
+ $scanBlueprintsAndTemplates = static function (Types $types) use ($grav) {
// Scan blueprints
$event = new Event();
- $event->types = self::$types;
+ $event->types = $types;
$grav->fireEvent('onGetPageBlueprints', $event);
- self::$types->scanBlueprints('theme://blueprints/');
+ $types->init();
+
+ // Try new location first.
+ $lookup = 'theme://blueprints/pages/';
+ if (!is_dir($lookup)) {
+ $lookup = 'theme://blueprints/';
+ }
+ $types->scanBlueprints($lookup);
// Scan templates
$event = new Event();
- $event->types = self::$types;
+ $event->types = $types;
$grav->fireEvent('onGetPageTemplates', $event);
- self::$types->scanTemplates('theme://templates/');
+ $types->scanTemplates('theme://templates/');
};
if ($grav['config']->get('system.cache.enabled')) {
@@ -765,22 +1316,21 @@ public static function getTypes()
// Use cached types if possible.
$types_cache_id = md5('types');
- self::$types = $cache->fetch($types_cache_id);
+ $types = $cache->fetch($types_cache_id);
- if (!self::$types) {
- self::$types = new Types();
- $scanBlueprintsAndTemplates();
- $cache->save($types_cache_id, self::$types);
+ if (!$types instanceof Types) {
+ $types = new Types();
+ $scanBlueprintsAndTemplates($types);
+ $cache->save($types_cache_id, $types);
}
-
} else {
- self::$types = new Types();
- $scanBlueprintsAndTemplates();
+ $types = new Types();
+ $scanBlueprintsAndTemplates($types);
}
// Register custom paths to the locator.
$locator = $grav['locator'];
- foreach (self::$types as $type => $paths) {
+ foreach ($types as $type => $paths) {
foreach ($paths as $k => $path) {
if (strpos($path, 'blueprints://') === 0) {
unset($paths[$k]);
@@ -790,6 +1340,8 @@ public static function getTypes()
$locator->addPath('blueprints', "pages/$type.yaml", $paths);
}
}
+
+ self::$types = $types;
}
return self::$types;
@@ -822,22 +1374,26 @@ public static function modularTypes()
/**
* Get template types based on page type (standard or modular)
*
+ * @param string|null $type
* @return array
*/
- public static function pageTypes()
+ public static function pageTypes($type = null)
{
- if (isset(Grav::instance()['admin'])) {
+ if (null === $type && isset(Grav::instance()['admin'])) {
/** @var Admin $admin */
$admin = Grav::instance()['admin'];
- /** @var PageInterface $page */
- $page = $admin->getPage($admin->route);
+ /** @var PageInterface|null $page */
+ $page = $admin->page();
- if ($page && $page->modular()) {
- return static::modularTypes();
- }
+ $type = $page && $page->isModule() ? 'modular' : 'standard';
+ }
- return static::types();
+ switch ($type) {
+ case 'standard':
+ return static::types();
+ case 'modular':
+ return static::modularTypes();
}
return [];
@@ -852,10 +1408,10 @@ public function accessLevels()
{
$accessLevels = [];
foreach ($this->all() as $page) {
- if (isset($page->header()->access)) {
- if (\is_array($page->header()->access)) {
+ if ($page instanceof PageInterface && isset($page->header()->access)) {
+ if (is_array($page->header()->access)) {
foreach ($page->header()->access as $index => $accessLevel) {
- if (\is_array($accessLevel)) {
+ if (is_array($accessLevel)) {
foreach ($accessLevel as $innerIndex => $innerAccessLevel) {
$accessLevels[] = $innerIndex;
}
@@ -884,8 +1440,6 @@ public static function parents()
return self::getParents($rawRoutes);
}
-
-
/**
* Gets the home route
*
@@ -919,7 +1473,6 @@ public static function getHomeRoute()
} catch (ErrorException $e) {
$home = $home_aliases[$default];
}
-
}
}
@@ -931,38 +1484,245 @@ public static function getHomeRoute()
/**
* Needed for testing where we change the home route via config
+ *
+ * @return string|null
*/
public static function resetHomeRoute()
{
self::$home_route = null;
+
return self::getHomeRoute();
}
+ protected function initFlexPages(): void
+ {
+ /** @var Debugger $debugger */
+ $debugger = $this->grav['debugger'];
+ $debugger->addMessage('Pages: Flex Directory');
+
+ /** @var Flex $flex */
+ $flex = $this->grav['flex'];
+ $directory = $flex->getDirectory('pages');
+
+ /** @var EventDispatcher $dispatcher */
+ $dispatcher = $this->grav['events'];
+
+ // Stop /admin/pages from working, display error instead.
+ $dispatcher->addListener(
+ 'onAdminPage',
+ static function (Event $event) use ($directory) {
+ $grav = Grav::instance();
+ $admin = $grav['admin'];
+ [$base,$location,] = $admin->getRouteDetails();
+ if ($location !== 'pages' || isset($grav['flex_objects'])) {
+ return;
+ }
+
+ /** @var PageInterface $page */
+ $page = $event['page'];
+ $page->init(new SplFileInfo('plugin://admin/pages/admin/error.md'));
+ $page->routable(true);
+ $header = $page->header();
+ $header->title = 'Please install missing plugin';
+ $page->content("## Please install and enable **[Flex Objects]({$base}/plugins/flex-objects)** plugin. It is required to edit **Flex Pages**.");
+
+ /** @var Header $header */
+ $header = $page->header();
+ $menu = $directory->getConfig('admin.menu.list');
+ $header->access = $menu['authorize'] ?? ['admin.super'];
+ },
+ 100000
+ );
+
+ $this->directory = $directory;
+ }
+
/**
* Builds pages.
*
* @internal
*/
- protected function buildPages()
+ protected function buildPages(): void
{
- $this->sort = [];
+ /** @var Debugger $debugger */
+ $debugger = $this->grav['debugger'];
+ $debugger->startTimer('build-pages', 'Init frontend routes');
+ if ($this->directory) {
+ $this->buildFlexPages($this->directory);
+ } else {
+ $this->buildRegularPages();
+ }
+ $debugger->stopTimer('build-pages');
+ }
+
+ protected function buildFlexPages(FlexDirectory $directory): void
+ {
/** @var Config $config */
$config = $this->grav['config'];
+ // TODO: right now we are just emulating normal pages, it is inefficient and bad... but works!
+ /** @var PageCollection|PageIndex $collection */
+ $collection = $directory->getIndex(null, 'storage_key');
+ $cache = $directory->getCache('index');
+
/** @var Language $language */
$language = $this->grav['language'];
+ $this->pages_cache_id = 'pages-' . md5($collection->getCacheChecksum() . $language->getActive() . $config->checksum());
+
+ $cached = $cache->get($this->pages_cache_id);
+
+ if ($cached && $this->getVersion() === $cached[0]) {
+ [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached;
+
+ /** @var Taxonomy $taxonomy */
+ $taxonomy = $this->grav['taxonomy'];
+ $taxonomy->taxonomy($taxonomy_map);
+
+ return;
+ }
+
+ /** @var Debugger $debugger */
+ $debugger = $this->grav['debugger'];
+ $debugger->addMessage('Page cache missed, rebuilding Flex Pages..');
+
+ $root = $collection->getRoot();
+ $root_path = $root->path();
+ $this->routes = [];
+ $this->instances = [$root_path => $root];
+ $this->index = [$root_path => $root];
+ $this->children = [];
+ $this->sort = [];
+
+ if ($this->fire_events) {
+ $this->grav->fireEvent('onBuildPagesInitialized');
+ }
+
+ /** @var PageInterface $page */
+ foreach ($collection as $page) {
+ $path = $page->path();
+ if (null === $path) {
+ throw new RuntimeException('Internal error');
+ }
+
+ if ($page instanceof FlexTranslateInterface) {
+ $page = $page->hasTranslation() ? $page->getTranslation() : null;
+ }
+
+ if (!$page instanceof FlexPageObject || $path === $root_path) {
+ continue;
+ }
+
+ if ($this->fire_events) {
+ if (method_exists($page, 'initialize')) {
+ $page->initialize();
+ } else {
+ // TODO: Deprecated, only used in 1.7 betas.
+ $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));
+ }
+ }
+
+ $parent = dirname($path);
+
+ $route = $page->rawRoute();
+
+ // Skip duplicated empty folders (git revert does not remove those).
+ // TODO: still not perfect, will only work if the page has been translated.
+ if (isset($this->routes[$route])) {
+ $oldPath = $this->routes[$route];
+ if ($page->isPage()) {
+ unset($this->index[$oldPath], $this->children[dirname($oldPath)][$oldPath]);
+ } else {
+ continue;
+ }
+ }
+
+ $this->routes[$route] = $path;
+ $this->instances[$path] = $page;
+ $this->index[$path] = $page->getFlexKey();
+ // FIXME: ... better...
+ $this->children[$parent][$path] = ['slug' => $page->slug()];
+ if (!isset($this->children[$path])) {
+ $this->children[$path] = [];
+ }
+ }
+
+ foreach ($this->children as $path => $list) {
+ $page = $this->instances[$path] ?? null;
+ if (null === $page) {
+ continue;
+ }
+ // Call onFolderProcessed event.
+ if ($this->fire_events) {
+ $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page]));
+ }
+ // Sort the children.
+ $this->children[$path] = $this->sort($page);
+ }
+
+ $this->routes = [];
+ $this->buildRoutes();
+
+ // cache if needed
+ if (null !== $cache) {
+ /** @var Taxonomy $taxonomy */
+ $taxonomy = $this->grav['taxonomy'];
+ $taxonomy_map = $taxonomy->taxonomy();
+
+ // save pages, routes, taxonomy, and sort to cache
+ $cache->set($this->pages_cache_id, [$this->getVersion(), $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort]);
+ }
+ }
+
+ /**
+ * @return Page
+ */
+ protected function buildRootPage()
+ {
+ $grav = Grav::instance();
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $grav['locator'];
+ $path = $locator->findResource('page://');
+ if (!is_string($path)) {
+ throw new RuntimeException('Internal Error');
+ }
+
+ /** @var Config $config */
+ $config = $grav['config'];
+
+ $page = new Page();
+ $page->path($path);
+ $page->orderDir($config->get('system.pages.order.dir'));
+ $page->orderBy($config->get('system.pages.order.by'));
+ $page->modified(0);
+ $page->routable(false);
+ $page->template('default');
+ $page->extension('.md');
+
+ return $page;
+ }
+
+ protected function buildRegularPages(): void
+ {
+ /** @var Config $config */
+ $config = $this->grav['config'];
+
/** @var UniformResourceLocator $locator */
$locator = $this->grav['locator'];
- $pages_dir = $locator->findResource('page://');
+ /** @var Language $language */
+ $language = $this->grav['language'];
+
+ $pages_dirs = $this->getPagesPaths();
+
+ // Set active language
+ $this->active_lang = $language->getActive();
if ($config->get('system.cache.enabled')) {
- /** @var Cache $cache */
- $cache = $this->grav['cache'];
- /** @var Taxonomy $taxonomy */
- $taxonomy = $this->grav['taxonomy'];
+ /** @var Language $language */
+ $language = $this->grav['language'];
// how should we check for last modified? Default is by file
switch ($this->check_method) {
@@ -971,43 +1731,69 @@ protected function buildPages()
$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());
-
- list($this->instances, $this->routes, $this->children, $taxonomy_map, $this->sort) = $cache->fetch($this->pages_cache_id);
- if (!$this->instances) {
- $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..');
+ $this->simple_pages_hash = json_encode($pages_dirs) . $hash . $config->checksum();
+ $this->pages_cache_id = md5($this->simple_pages_hash . $language->getActive());
- // recurse pages and cache result
- $this->resetPages($pages_dir);
+ /** @var Cache $cache */
+ $cache = $this->grav['cache'];
+ $cached = $cache->fetch($this->pages_cache_id);
+ if ($cached && $this->getVersion() === $cached[0]) {
+ [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached;
- } else {
- // If pages was found in cache, set the taxonomy
- $this->grav['debugger']->addMessage('Page cache hit.');
+ /** @var Taxonomy $taxonomy */
+ $taxonomy = $this->grav['taxonomy'];
$taxonomy->taxonomy($taxonomy_map);
+
+ return;
}
+
+ $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..');
} else {
- $this->recurse($pages_dir);
- $this->buildRoutes();
+ $this->grav['debugger']->addMessage('Page cache disabled, rebuilding pages..');
+ }
+
+ $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)
+ public function resetPages(array $pages_dirs): void
{
- $this->recurse($pages_dir);
+ $this->sort = [];
+
+ foreach ($pages_dirs as $dir) {
+ $this->recurse($dir);
+ }
+
$this->buildRoutes();
// cache if needed
@@ -1018,7 +1804,7 @@ public function resetPages($pages_dir)
$taxonomy = $this->grav['taxonomy'];
// save pages, routes, taxonomy, and sort to cache
- $cache->save($this->pages_cache_id, [$this->instances, $this->routes, $this->children, $taxonomy->taxonomy(), $this->sort]);
+ $cache->save($this->pages_cache_id, [$this->getVersion(), $this->index, $this->routes, $this->children, $taxonomy->taxonomy(), $this->sort]);
}
}
@@ -1027,12 +1813,11 @@ public function resetPages($pages_dir)
*
* @param string $directory
* @param PageInterface|null $parent
- *
* @return PageInterface
- * @throws \RuntimeException
+ * @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;
@@ -1045,7 +1830,7 @@ protected function recurse($directory, PageInterface $parent = null)
// Stuff to do at root page
// Fire event for memory and time consuming plugins...
- if ($parent === null && $config->get('system.pages.events.page')) {
+ if ($parent === null && $this->fire_events) {
$this->grav->fireEvent('onBuildPagesInitialized');
}
@@ -1058,19 +1843,20 @@ protected function recurse($directory, PageInterface $parent = null)
$page->orderBy($config->get('system.pages.order.by'));
// Add into instances
- if (!isset($this->instances[$page->path()])) {
+ if (!isset($this->index[$page->path()])) {
+ $this->index[$page->path()] = $page;
$this->instances[$page->path()] = $page;
if ($parent && $page->path()) {
$this->children[$parent->path()][$page->path()] = ['slug' => $page->slug()];
}
- } else {
- throw new \RuntimeException('Fatal error when creating page instances.');
+ } elseif ($parent !== null) {
+ throw new RuntimeException('Fatal error when creating page instances.');
}
// Build regular expression for all the allowed page extensions.
$page_extensions = $language->getFallbackPageExtensions();
$regex = '/^[^\.]*(' . implode('|', array_map(
- function ($str) {
+ static function ($str) {
return preg_quote($str, '/');
},
$page_extensions
@@ -1081,8 +1867,7 @@ function ($str) {
$page_extension = '.md';
$last_modified = 0;
- $iterator = new \FilesystemIterator($directory);
- /** @var \FilesystemIterator $file */
+ $iterator = new FilesystemIterator($directory);
foreach ($iterator as $file) {
$filename = $file->getFilename();
@@ -1094,14 +1879,14 @@ function ($str) {
// Handle folders later.
if ($file->isDir()) {
// But ignore all folders in ignore list.
- if (!\in_array($filename, $this->ignore_folders, true)) {
+ if (!in_array($filename, $this->ignore_folders, true)) {
$folders[] = $file;
}
continue;
}
// Ignore all files in ignore list.
- if (\in_array($filename, $this->ignore_files, true)) {
+ if (in_array($filename, $this->ignore_files, true)) {
continue;
}
@@ -1128,13 +1913,13 @@ function ($str) {
$content_exists = true;
- if ($config->get('system.pages.events.page')) {
+ if ($this->fire_events) {
$this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));
}
}
// Now handle all the folders under the page.
- /** @var \FilesystemIterator $file */
+ /** @var FilesystemIterator $file */
foreach ($folders as $file) {
$filename = $file->getFilename();
@@ -1150,20 +1935,20 @@ function ($str) {
$path = $directory . DS . $filename;
$child = $this->recurse($path, $page);
- if (Utils::startsWith($filename, '_')) {
+ if (preg_match('/^(\d+\.)_/', $filename)) {
$child->routable(false);
+ $child->modularTwig(true);
}
$this->children[$page->path()][$child->path()] = ['slug' => $child->slug()];
- if ($config->get('system.pages.events.page')) {
+ if ($this->fire_events) {
$this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page]));
}
}
-
if (!$content_exists) {
- // Set routability to false if no page found
+ // Set routable to false if no page found
$page->routable(false);
// Hide empty folders if option set
@@ -1185,7 +1970,7 @@ function ($str) {
// Override the modified and ID so that it takes the latest change into account
$page->modified($last_modified);
- $page->id($last_modified . md5($page->filePath()));
+ $page->id($last_modified . md5($page->filePath() ?? ''));
// Sort based on Defaults or Page Overridden sort order
$this->children[$page->path()] = $this->sort($page);
@@ -1196,54 +1981,68 @@ function ($str) {
/**
* @internal
*/
- protected function buildRoutes()
+ protected function buildRoutes(): void
{
/** @var Taxonomy $taxonomy */
$taxonomy = $this->grav['taxonomy'];
// Get the home route
$home = self::resetHomeRoute();
-
// Build routes and taxonomy map.
- /** @var PageInterface $page */
- foreach ($this->instances as $page) {
- if (!$page->root()) {
- // process taxonomy
- $taxonomy->addTaxonomy($page);
+ /** @var PageInterface|string $page */
+ foreach ($this->index as $path => $page) {
+ if (is_string($page)) {
+ $page = $this->get($path);
+ }
- $route = $page->route();
- $raw_route = $page->rawRoute();
- $page_path = $page->path();
+ if (!$page || $page->root()) {
+ continue;
+ }
- // add regular route
+ // process taxonomy
+ $taxonomy->addTaxonomy($page);
+
+ $page_path = $page->path();
+ if (null === $page_path) {
+ throw new RuntimeException('Internal Error');
+ }
+
+ $route = $page->route();
+ $raw_route = $page->rawRoute();
+
+ // add regular route
+ if ($route) {
$this->routes[$route] = $page_path;
+ }
- // add raw route
- if ($raw_route !== $route) {
- $this->routes[$raw_route] = $page_path;
- }
+ // add raw route
+ if ($raw_route && $raw_route !== $route) {
+ $this->routes[$raw_route] = $page_path;
+ }
- // add canonical route
- $route_canonical = $page->routeCanonical();
- if ($route_canonical && ($route !== $route_canonical)) {
- $this->routes[$route_canonical] = $page_path;
- }
+ // add canonical route
+ $route_canonical = $page->routeCanonical();
+ if ($route_canonical && $route !== $route_canonical) {
+ $this->routes[$route_canonical] = $page_path;
+ }
- // add aliases to routes list if they are provided
- $route_aliases = $page->routeAliases();
- if ($route_aliases) {
- foreach ($route_aliases as $alias) {
- $this->routes[$alias] = $page_path;
- }
+ // add aliases to routes list if they are provided
+ $route_aliases = $page->routeAliases();
+ if ($route_aliases) {
+ foreach ($route_aliases as $alias) {
+ $this->routes[$alias] = $page_path;
}
}
}
// Alias and set default route to home page.
- $homeRoute = '/' . $home;
+ $homeRoute = "/{$home}";
if ($home && isset($this->routes[$homeRoute])) {
- $this->routes['/'] = $this->routes[$homeRoute];
- $this->get($this->routes[$homeRoute])->route('/');
+ $home = $this->get($this->routes[$homeRoute]);
+ if ($home) {
+ $this->routes['/'] = $this->routes[$homeRoute];
+ $home->route('/');
+ }
}
}
@@ -1253,28 +2052,26 @@ protected function buildRoutes()
* @param string $order_by
* @param array|null $manual
* @param int|null $sort_flags
- *
- * @throws \RuntimeException
+ * @throws RuntimeException
* @internal
*/
- protected function buildSort($path, array $pages, $order_by = 'default', $manual = null, $sort_flags = null)
+ protected function buildSort($path, array $pages, $order_by = 'default', $manual = null, $sort_flags = null): void
{
$list = [];
- $header_default = null;
$header_query = null;
+ $header_default = null;
// do this header query work only once
if (strpos($order_by, 'header.') === 0) {
- $header_query = explode('|', str_replace('header.', '', $order_by));
- if (isset($header_query[1])) {
- $header_default = $header_query[1];
- }
+ $query = explode('|', str_replace('header.', '', $order_by), 2);
+ $header_query = array_shift($query) ?? '';
+ $header_default = array_shift($query);
}
foreach ($pages as $key => $info) {
- $child = $this->instances[$key] ?? null;
+ $child = $this->get($key);
if (!$child) {
- throw new \RuntimeException("Page does not exist: {$key}");
+ throw new RuntimeException("Page does not exist: {$key}");
}
switch ($order_by) {
@@ -1301,26 +2098,30 @@ protected function buildSort($path, array $pages, $order_by = 'default', $manual
$list[$key] = $child->slug();
break;
case 'basename':
- $list[$key] = basename($key);
+ $list[$key] = Utils::basename($key);
break;
case 'folder':
$list[$key] = $child->folder();
break;
- case (is_string($header_query[0])):
- $child_header = new Header((array)$child->header());
- $header_value = $child_header->get($header_query[0]);
- if (is_array($header_value)) {
- $list[$key] = implode(',',$header_value);
- } elseif ($header_value) {
- $list[$key] = $header_value;
- } else {
- $list[$key] = $header_default ?: $key;
- }
- $sort_flags = $sort_flags ?: SORT_REGULAR;
- break;
case 'manual':
case 'default':
default:
+ if (is_string($header_query)) {
+ $child_header = $child->header();
+ if (!$child_header instanceof Header) {
+ $child_header = new Header((array)$child_header);
+ }
+ $header_value = $child_header->get($header_query);
+ if (is_array($header_value)) {
+ $list[$key] = implode(',', $header_value);
+ } elseif ($header_value) {
+ $list[$key] = $header_value;
+ } else {
+ $list[$key] = $header_default ?: $key;
+ }
+ $sort_flags = $sort_flags ?: SORT_REGULAR;
+ break;
+ }
$list[$key] = $key;
$sort_flags = $sort_flags ?: SORT_REGULAR;
}
@@ -1336,13 +2137,17 @@ protected function buildSort($path, array $pages, $order_by = 'default', $manual
} else {
// else just sort the list according to specified key
if (extension_loaded('intl') && $this->grav['config']->get('system.intl_enabled')) {
- $locale = setlocale(LC_COLLATE, 0); //`setlocale` with a 0 param returns the current locale set
+ $locale = setlocale(LC_COLLATE, '0'); //`setlocale` with a '0' param returns the current locale set
$col = Collator::create($locale);
if ($col) {
+ $col->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON);
if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) {
- $list = preg_replace_callback('~([0-9]+)\.~', function($number) {
+ $list = preg_replace_callback('~([0-9]+)\.~', static function ($number) {
return sprintf('%032d.', $number[0]);
}, $list);
+ if (!is_array($list)) {
+ throw new RuntimeException('Internal Error');
+ }
$list_vals = array_values($list);
if (is_numeric(array_shift($list_vals))) {
@@ -1369,7 +2174,7 @@ protected function buildSort($path, array $pages, $order_by = 'default', $manual
foreach ($list as $key => $dummy) {
$info = $pages[$key];
- $order = \array_search($info['slug'], $manual, true);
+ $order = array_search($info['slug'], $manual, true);
if ($order === false) {
$order = $i++;
}
@@ -1379,7 +2184,7 @@ protected function buildSort($path, array $pages, $order_by = 'default', $manual
$list = $new_list;
// Apply manual ordering to the list.
- asort($list);
+ asort($list, SORT_NUMERIC);
}
foreach ($list as $key => $sort) {
@@ -1392,10 +2197,9 @@ protected function buildSort($path, array $pages, $order_by = 'default', $manual
* Shuffles an associative array
*
* @param array $list
- *
* @return array
*/
- protected function arrayShuffle($list)
+ protected function arrayShuffle(array $list): array
{
$keys = array_keys($list);
shuffle($keys);
@@ -1408,16 +2212,34 @@ protected function arrayShuffle($list)
return $new;
}
+ /**
+ * @return string
+ */
+ protected function getVersion(): string
+ {
+ return $this->directory ? 'flex' : 'regular';
+ }
+
/**
* Get the Pages cache ID
*
* 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 mixed
+ * @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;
+ }
}
diff --git a/system/src/Grav/Common/Page/Traits/PageFormTrait.php b/system/src/Grav/Common/Page/Traits/PageFormTrait.php
new file mode 100644
index 0000000000..b99e7b75c4
--- /dev/null
+++ b/system/src/Grav/Common/Page/Traits/PageFormTrait.php
@@ -0,0 +1,126 @@
+ blueprint, ...], where blueprint follows the regular form blueprint format.
+ *
+ * @return array
+ */
+ public function getForms(): array
+ {
+ if (null === $this->_forms) {
+ $header = $this->header();
+
+ // Call event to allow filling the page header form dynamically (e.g. use case: Comments plugin)
+ $grav = Grav::instance();
+ $grav->fireEvent('onFormPageHeaderProcessed', new Event(['page' => $this, 'header' => $header]));
+
+ $rules = $header->rules ?? null;
+ if (!is_array($rules)) {
+ $rules = [];
+ }
+
+ $forms = [];
+
+ // First grab page.header.form
+ $form = $this->normalizeForm($header->form ?? null, null, $rules);
+ if ($form) {
+ $forms[$form['name']] = $form;
+ }
+
+ // Append page.header.forms (override singular form if it clashes)
+ $headerForms = $header->forms ?? null;
+ if (is_array($headerForms)) {
+ foreach ($headerForms as $name => $form) {
+ $form = $this->normalizeForm($form, $name, $rules);
+ if ($form) {
+ $forms[$form['name']] = $form;
+ }
+ }
+ }
+
+ $this->_forms = $forms;
+ }
+
+ return $this->_forms;
+ }
+
+ /**
+ * Add forms to this page.
+ *
+ * @param array $new
+ * @param bool $override
+ * @return $this
+ */
+ public function addForms(array $new, $override = true)
+ {
+ // Initialize forms.
+ $this->forms();
+
+ foreach ($new as $name => $form) {
+ $form = $this->normalizeForm($form, $name);
+ $name = $form['name'] ?? null;
+ if ($name && ($override || !isset($this->_forms[$name]))) {
+ $this->_forms[$name] = $form;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Alias of $this->getForms();
+ *
+ * @return array
+ */
+ public function forms(): array
+ {
+ return $this->getForms();
+ }
+
+ /**
+ * @param array|null $form
+ * @param string|null $name
+ * @param array $rules
+ * @return array|null
+ */
+ protected function normalizeForm($form, $name = null, array $rules = []): ?array
+ {
+ if (!is_array($form)) {
+ return null;
+ }
+
+ // Ignore numeric indexes on name.
+ if (!$name || (string)(int)$name === (string)$name) {
+ $name = null;
+ }
+
+ $name = $name ?? $form['name'] ?? $this->slug();
+
+ $formRules = $form['rules'] ?? null;
+ if (!is_array($formRules)) {
+ $formRules = [];
+ }
+
+ return ['name' => $name, 'rules' => $rules + $formRules] + $form;
+ }
+
+ abstract public function header($var = null);
+ abstract public function slug($var = null);
+}
diff --git a/system/src/Grav/Common/Page/Types.php b/system/src/Grav/Common/Page/Types.php
index 03e3f6eb03..420c588607 100644
--- a/system/src/Grav/Common/Page/Types.php
+++ b/system/src/Grav/Common/Page/Types.php
@@ -3,38 +3,53 @@
/**
* @package Grav\Common\Page
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Page;
+use Grav\Common\Data\Blueprint;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
+use Grav\Common\Utils;
+use InvalidArgumentException;
use RocketTheme\Toolbox\ArrayTraits\ArrayAccess;
use RocketTheme\Toolbox\ArrayTraits\Constructor;
use RocketTheme\Toolbox\ArrayTraits\Countable;
use RocketTheme\Toolbox\ArrayTraits\Export;
use RocketTheme\Toolbox\ArrayTraits\Iterator;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use function is_string;
+/**
+ * Class Types
+ * @package Grav\Common\Page
+ */
class Types implements \ArrayAccess, \Iterator, \Countable
{
use ArrayAccess, Constructor, Iterator, Countable, Export;
+ /** @var array */
protected $items;
- protected $systemBlueprints;
-
+ /** @var array */
+ protected $systemBlueprints = [];
+
+ /**
+ * @param string $type
+ * @param Blueprint|null $blueprint
+ * @return void
+ */
public function register($type, $blueprint = null)
{
if (!isset($this->items[$type])) {
$this->items[$type] = [];
- } elseif (!$blueprint) {
+ } elseif (null === $blueprint) {
return;
}
- if (!$blueprint && $this->systemBlueprints) {
- $blueprint = $this->systemBlueprints[$type] ?? $this->systemBlueprints['default'];
+ if (null === $blueprint) {
+ $blueprint = $this->systemBlueprints[$type] ?? $this->systemBlueprints['default'] ?? null;
}
if ($blueprint) {
@@ -42,19 +57,28 @@ public function register($type, $blueprint = null)
}
}
- public function scanBlueprints($uri)
+ /**
+ * @return void
+ */
+ public function init()
{
- if (!\is_string($uri)) {
- throw new \InvalidArgumentException('First parameter must be URI');
- }
-
- if (!$this->systemBlueprints) {
+ if (empty($this->systemBlueprints)) {
+ // Register all blueprints from the blueprints stream.
$this->systemBlueprints = $this->findBlueprints('blueprints://pages');
+ foreach ($this->systemBlueprints as $type => $blueprint) {
+ $this->register($type);
+ }
+ }
+ }
- // Register default by default.
- $this->register('default');
-
- $this->register('external');
+ /**
+ * @param string $uri
+ * @return void
+ */
+ public function scanBlueprints($uri)
+ {
+ if (!is_string($uri)) {
+ throw new InvalidArgumentException('First parameter must be URI');
}
foreach ($this->findBlueprints($uri) as $type => $blueprint) {
@@ -62,10 +86,14 @@ public function scanBlueprints($uri)
}
}
+ /**
+ * @param string $uri
+ * @return void
+ */
public function scanTemplates($uri)
{
- if (!\is_string($uri)) {
- throw new \InvalidArgumentException('First parameter must be URI');
+ if (!is_string($uri)) {
+ throw new InvalidArgumentException('First parameter must be URI');
}
$options = [
@@ -90,6 +118,9 @@ public function scanTemplates($uri)
}
}
+ /**
+ * @return array
+ */
public function pageSelect()
{
$list = [];
@@ -104,6 +135,9 @@ public function pageSelect()
return $list;
}
+ /**
+ * @return array
+ */
public function modularSelect()
{
$list = [];
@@ -111,13 +145,17 @@ public function modularSelect()
if (strpos($name, 'modular/') !== 0) {
continue;
}
- $list[$name] = ucfirst(trim(str_replace('_', ' ', basename($name))));
+ $list[$name] = ucfirst(trim(str_replace('_', ' ', Utils::basename($name))));
}
ksort($list);
return $list;
}
+ /**
+ * @param string $uri
+ * @return array
+ */
private function findBlueprints($uri)
{
$options = [
diff --git a/system/src/Grav/Common/Plugin.php b/system/src/Grav/Common/Plugin.php
index 8729a519ff..30e5344f6a 100644
--- a/system/src/Grav/Common/Plugin.php
+++ b/system/src/Grav/Common/Plugin.php
@@ -3,44 +3,48 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
+use ArrayAccess;
+use Composer\Autoload\ClassLoader;
use Grav\Common\Data\Blueprint;
use Grav\Common\Data\Data;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Config\Config;
-use RocketTheme\Toolbox\Event\EventDispatcher;
-use RocketTheme\Toolbox\Event\EventSubscriberInterface;
+use LogicException;
use RocketTheme\Toolbox\File\YamlFile;
+use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use function defined;
+use function is_bool;
+use function is_string;
-class Plugin implements EventSubscriberInterface, \ArrayAccess
+/**
+ * Class Plugin
+ * @package Grav\Common
+ */
+class Plugin implements EventSubscriberInterface, ArrayAccess
{
- /**
- * @var string
- */
+ /** @var string */
public $name;
-
- /**
- * @var array
- */
+ /** @var array */
public $features = [];
- /**
- * @var Grav
- */
+ /** @var Grav */
protected $grav;
-
- /**
- * @var Config
- */
+ /** @var Config|null */
protected $config;
-
+ /** @var bool */
protected $active = true;
+ /** @var Blueprint|null */
protected $blueprint;
+ /** @var ClassLoader|null */
+ protected $loader;
/**
* By default assign all methods as listeners using the default priority.
@@ -49,7 +53,7 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess
*/
public static function getSubscribedEvents()
{
- $methods = get_class_methods(get_called_class());
+ $methods = get_class_methods(static::class);
$list = [];
foreach ($methods as $method) {
@@ -66,17 +70,36 @@ public static function getSubscribedEvents()
*
* @param string $name
* @param Grav $grav
- * @param Config $config
+ * @param Config|null $config
*/
public function __construct($name, Grav $grav, Config $config = null)
{
$this->name = $name;
$this->grav = $grav;
+
if ($config) {
$this->setConfig($config);
}
}
+ /**
+ * @return ClassLoader|null
+ * @internal
+ */
+ final public function getAutoloader(): ?ClassLoader
+ {
+ return $this->loader;
+ }
+
+ /**
+ * @param ClassLoader|null $loader
+ * @internal
+ */
+ final public function setAutoloader(?ClassLoader $loader): void
+ {
+ $this->loader = $loader;
+ }
+
/**
* @param Config $config
* @return $this
@@ -95,7 +118,7 @@ public function setConfig(Config $config)
*/
public function config()
{
- return $this->config["plugins.{$this->name}"];
+ return $this->config["plugins.{$this->name}"] ?? [];
}
/**
@@ -115,7 +138,7 @@ public function isAdmin()
*/
public function isCli()
{
- return \defined('GRAV_CLI');
+ return defined('GRAV_CLI');
}
/**
@@ -126,21 +149,25 @@ public function isCli()
*/
protected function isPluginActiveAdmin($plugin_route)
{
- $should_run = false;
+ $active = false;
+ /** @var Uri $uri */
$uri = $this->grav['uri'];
+ /** @var Config $config */
+ $config = $this->config ?? $this->grav['config'];
- if (strpos($uri->path(), $this->config->get('plugins.admin.route') . '/' . $plugin_route) === false) {
- $should_run = false;
+ if (strpos($uri->path(), $config->get('plugins.admin.route') . '/' . $plugin_route) === false) {
+ $active = false;
} elseif (isset($uri->paths()[1]) && $uri->paths()[1] === $plugin_route) {
- $should_run = true;
+ $active = true;
}
- return $should_run;
+ return $active;
}
/**
* @param array $events
+ * @return void
*/
protected function enable(array $events)
{
@@ -148,9 +175,9 @@ protected function enable(array $events)
$dispatcher = $this->grav['events'];
foreach ($events as $eventName => $params) {
- if (\is_string($params)) {
+ if (is_string($params)) {
$dispatcher->addListener($eventName, [$this, $params]);
- } elseif (\is_string($params[0])) {
+ } elseif (is_string($params[0])) {
$dispatcher->addListener($eventName, [$this, $params[0]], $this->getPriority($params, $eventName));
} else {
foreach ($params as $listener) {
@@ -163,22 +190,18 @@ protected function enable(array $events)
/**
* @param array $params
* @param string $eventName
+ * @return int
*/
private function getPriority($params, $eventName)
{
- $grav = Grav::instance();
- $override = implode('.', ["priorities", $this->name, $eventName, $params[0]]);
- if ($grav['config']->get($override) !== null)
- {
- return $grav['config']->get($override);
- } elseif (isset($params[1])) {
- return $params[1];
- }
- return 0;
+ $override = implode('.', ['priorities', $this->name, $eventName, $params[0]]);
+
+ return $this->grav['config']->get($override) ?? $params[1] ?? 0;
}
/**
* @param array $events
+ * @return void
*/
protected function disable(array $events)
{
@@ -186,9 +209,9 @@ protected function disable(array $events)
$dispatcher = $this->grav['events'];
foreach ($events as $eventName => $params) {
- if (\is_string($params)) {
+ if (is_string($params)) {
$dispatcher->removeListener($eventName, [$this, $params]);
- } elseif (\is_string($params[0])) {
+ } elseif (is_string($params[0])) {
$dispatcher->removeListener($eventName, [$this, $params[0]]);
} else {
foreach ($params as $listener) {
@@ -204,14 +227,16 @@ protected function disable(array $events)
* @param string $offset An offset to check for.
* @return bool Returns TRUE on success or FALSE on failure.
*/
+ #[\ReturnTypeWillChange]
public function offsetExists($offset)
{
- $this->loadBlueprint();
-
if ($offset === 'title') {
$offset = 'name';
}
- return isset($this->blueprint[$offset]);
+
+ $blueprint = $this->getBlueprint();
+
+ return isset($blueprint[$offset]);
}
/**
@@ -220,14 +245,16 @@ public function offsetExists($offset)
* @param string $offset The offset to retrieve.
* @return mixed Can return all value types.
*/
+ #[\ReturnTypeWillChange]
public function offsetGet($offset)
{
- $this->loadBlueprint();
-
if ($offset === 'title') {
$offset = 'name';
}
- return $this->blueprint[$offset] ?? null;
+
+ $blueprint = $this->getBlueprint();
+
+ return $blueprint[$offset] ?? null;
}
/**
@@ -235,22 +262,37 @@ public function offsetGet($offset)
*
* @param string $offset The offset to assign the value to.
* @param mixed $value The value to set.
- * @throws \LogicException
+ * @throws LogicException
*/
+ #[\ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
- throw new \LogicException(__CLASS__ . ' blueprints cannot be modified.');
+ throw new LogicException(__CLASS__ . ' blueprints cannot be modified.');
}
/**
* Unsets an offset.
*
* @param string $offset The offset to unset.
- * @throws \LogicException
+ * @throws LogicException
*/
+ #[\ReturnTypeWillChange]
public function offsetUnset($offset)
{
- throw new \LogicException(__CLASS__ . ' blueprints cannot be modified.');
+ throw new LogicException(__CLASS__ . ' blueprints cannot be modified.');
+ }
+
+ /**
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ $array = (array)$this;
+
+ unset($array["\0*\0grav"]);
+ $array["\0*\0config"] = $this->config();
+
+ return $array;
}
/**
@@ -263,40 +305,46 @@ public function offsetUnset($offset)
* @param string $content The string to perform operations upon
* @param callable $function The anonymous callback function
* @param string $internal_regex Optional internal regex to extra data from
- *
* @return string
*/
protected function parseLinks($content, $function, $internal_regex = '(.*)')
{
- $regex = '/\[plugin:(?:' . $this->name . ')\]\(' . $internal_regex . '\)/i';
+ $regex = '/\[plugin:(?:' . preg_quote($this->name, '/') . ')\]\(' . $internal_regex . '\)/i';
- return preg_replace_callback($regex, $function, $content);
+ $result = preg_replace_callback($regex, $function, $content);
+ \assert($result !== null);
+
+ return $result;
}
/**
* Merge global and page configurations.
*
+ * WARNING: This method modifies page header!
+ *
* @param PageInterface $page The page to merge the configurations with the
* plugin settings.
* @param mixed $deep false = shallow|true = recursive|merge = recursive+unique
* @param array $params Array of additional configuration options to
* merge with the plugin settings.
* @param string $type Is this 'plugins' or 'themes'
- *
* @return Data
*/
protected function mergeConfig(PageInterface $page, $deep = false, $params = [], $type = 'plugins')
{
+ /** @var Config $config */
+ $config = $this->config ?? $this->grav['config'];
+
$class_name = $this->name;
$class_name_merged = $class_name . '.merged';
- $defaults = $this->config->get($type . '.' . $class_name, []);
+ $defaults = $config->get($type . '.' . $class_name, []);
$page_header = $page->header();
$header = [];
if (!isset($page_header->{$class_name_merged}) && isset($page_header->{$class_name})) {
// Get default plugin configurations and retrieve page header configuration
$config = $page_header->{$class_name};
- if (\is_bool($config)) {
+ if (is_bool($config)) {
// Overwrite enabled option with boolean value in page header
$config = ['enabled' => $config];
}
@@ -305,7 +353,7 @@ protected function mergeConfig(PageInterface $page, $deep = false, $params = [],
// Create new config object and set it on the page object so it's cached for next time
$page->modifyHeader($class_name_merged, new Data($header));
- } else if (isset($page_header->{$class_name_merged})) {
+ } elseif (isset($page_header->{$class_name_merged})) {
$merged = $page_header->{$class_name_merged};
$header = $merged->toArray();
}
@@ -342,27 +390,54 @@ private function mergeArrays($deep, $array1, $array2)
/**
* Persists to disk the plugin parameters currently stored in the Grav Config object
*
- * @param string $plugin_name The name of the plugin whose config it should store.
- *
+ * @param string $name The name of the plugin whose config it should store.
* @return bool
*/
- public static function saveConfig($plugin_name)
+ public static function saveConfig($name)
{
- if (!$plugin_name) {
+ if (!$name) {
return false;
}
$grav = Grav::instance();
+
+ /** @var UniformResourceLocator $locator */
$locator = $grav['locator'];
- $filename = 'config://plugins/' . $plugin_name . '.yaml';
- $file = YamlFile::instance($locator->findResource($filename, true, true));
- $content = $grav['config']->get('plugins.' . $plugin_name);
+
+ $filename = 'config://plugins/' . $name . '.yaml';
+ $file = YamlFile::instance((string)$locator->findResource($filename, true, true));
+ $content = $grav['config']->get('plugins.' . $name);
$file->save($content);
$file->free();
+ unset($file);
return true;
}
+ public static function inheritedConfigOption(string $plugin, string $var, PageInterface $page = null, $default = null)
+ {
+ if (Utils::isAdminPlugin()) {
+ $page = Grav::instance()['admin']->page() ?? null;
+ } else {
+ $page = $page ?? Grav::instance()['page'] ?? null;
+ }
+
+ // Try to find var in the page headers
+ if ($page instanceof PageInterface && $page->exists()) {
+ // Loop over pages and look for header vars
+ while ($page && !$page->root()) {
+ $header = new Data((array)$page->header());
+ $value = $header->get("$plugin.$var");
+ if (isset($value)) {
+ return $value;
+ }
+ $page = $page->parent();
+ }
+ }
+
+ return Grav::instance()['config']->get("plugins.$plugin.$var", $default);
+ }
+
/**
* Simpler getter for the plugin blueprint
*
@@ -370,21 +445,28 @@ public static function saveConfig($plugin_name)
*/
public function getBlueprint()
{
- if (!$this->blueprint) {
+ if (null === $this->blueprint) {
$this->loadBlueprint();
+ \assert($this->blueprint instanceof Blueprint);
}
+
return $this->blueprint;
}
/**
* Load blueprints.
+ *
+ * @return void
*/
protected function loadBlueprint()
{
- if (!$this->blueprint) {
+ if (null === $this->blueprint) {
$grav = Grav::instance();
+ /** @var Plugins $plugins */
$plugins = $grav['plugins'];
- $this->blueprint = $plugins->get($this->name)->blueprints();
+ $data = $plugins->get($this->name);
+ \assert($data !== null);
+ $this->blueprint = $data->blueprints();
}
}
}
diff --git a/system/src/Grav/Common/Plugins.php b/system/src/Grav/Common/Plugins.php
index 358727f33f..42b1593c2d 100644
--- a/system/src/Grav/Common/Plugins.php
+++ b/system/src/Grav/Common/Plugins.php
@@ -3,23 +3,40 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
+use Exception;
use Grav\Common\Config\Config;
use Grav\Common\Data\Blueprints;
use Grav\Common\Data\Data;
use Grav\Common\File\CompiledYamlFile;
-use RocketTheme\Toolbox\Event\EventDispatcher;
+use Grav\Events\PluginsLoadedEvent;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use RuntimeException;
+use SplFileInfo;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use function get_class;
+use function is_object;
+/**
+ * Class Plugins
+ * @package Grav\Common
+ */
class Plugins extends Iterator
{
+ /** @var array|null */
public $formFieldTypes;
+ /** @var bool */
+ private $plugins_initialized = false;
+
+ /**
+ * Plugins constructor.
+ */
public function __construct()
{
parent::__construct();
@@ -30,17 +47,21 @@ public function __construct()
$iterator = $locator->getIterator('plugins://');
$plugins = [];
- foreach($iterator as $directory) {
+ /** @var SplFileInfo $directory */
+ foreach ($iterator as $directory) {
if (!$directory->isDir()) {
continue;
}
$plugins[] = $directory->getFilename();
}
- natsort($plugins);
+ sort($plugins, SORT_NATURAL | SORT_FLAG_CASE);
foreach ($plugins as $plugin) {
- $this->add($this->loadPlugin($plugin));
+ $object = $this->loadPlugin($plugin);
+ if ($object) {
+ $this->add($object);
+ }
}
}
@@ -52,28 +73,36 @@ public function setup()
$blueprints = [];
$formFields = [];
+ $grav = Grav::instance();
+
+ /** @var Config $config */
+ $config = $grav['config'];
+
/** @var Plugin $plugin */
foreach ($this->items as $plugin) {
- if (isset($plugin->features['blueprints'])) {
- $blueprints["plugin://{$plugin->name}/blueprints"] = $plugin->features['blueprints'];
- }
- if (method_exists($plugin, 'getFormFieldTypes')) {
- $formFields[get_class($plugin)] = isset($plugin->features['formfields']) ? $plugin->features['formfields'] : 0;
+ // Setup only enabled plugins.
+ if ($config["plugins.{$plugin->name}.enabled"] && $plugin instanceof Plugin) {
+ if (isset($plugin->features['blueprints'])) {
+ $blueprints["plugin://{$plugin->name}/blueprints"] = $plugin->features['blueprints'];
+ }
+ if (method_exists($plugin, 'getFormFieldTypes')) {
+ $formFields[get_class($plugin)] = $plugin->features['formfields'] ?? 0;
+ }
}
}
if ($blueprints) {
// Order by priority.
- arsort($blueprints);
+ arsort($blueprints, SORT_NUMERIC);
/** @var UniformResourceLocator $locator */
- $locator = Grav::instance()['locator'];
- $locator->addPath('blueprints', '', array_keys($blueprints), 'system/blueprints');
+ $locator = $grav['locator'];
+ $locator->addPath('blueprints', '', array_keys($blueprints), ['system', 'blueprints']);
}
if ($formFields) {
// Order by priority.
- arsort($formFields);
+ arsort($formFields, SORT_NUMERIC);
$list = [];
foreach ($formFields as $className => $priority) {
@@ -91,10 +120,14 @@ public function setup()
* Registers all plugins.
*
* @return Plugin[] array of Plugin objects
- * @throws \RuntimeException
+ * @throws RuntimeException
*/
public function init()
{
+ if ($this->plugins_initialized) {
+ return $this->items;
+ }
+
$grav = Grav::instance();
/** @var Config $config */
@@ -106,11 +139,23 @@ public function init()
foreach ($this->items as $instance) {
// Register only enabled plugins.
if ($config["plugins.{$instance->name}.enabled"] && $instance instanceof Plugin) {
+ // Set plugin configuration.
$instance->setConfig($config);
+ // Register autoloader.
+ if (method_exists($instance, 'autoload')) {
+ $instance->setAutoloader($instance->autoload());
+ }
+ // Register event listeners.
$events->addSubscriber($instance);
}
}
+ // Plugins Loaded Event
+ $event = new PluginsLoadedEvent($grav, $this);
+ $grav->dispatchEvent($event);
+
+ $this->plugins_initialized = true;
+
return $this->items;
}
@@ -118,6 +163,7 @@ public function init()
* Add a plugin
*
* @param Plugin $plugin
+ * @return void
*/
public function add($plugin)
{
@@ -126,14 +172,55 @@ public function add($plugin)
}
}
+ /**
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ $array = (array)$this;
+
+ unset($array["\0Grav\Common\Iterator\0iteratorUnset"]);
+
+ return $array;
+ }
+
+ /**
+ * @return Plugin[] Index of all plugins by plugin name.
+ */
+ public static function getPlugins(): array
+ {
+ /** @var Plugins $plugins */
+ $plugins = Grav::instance()['plugins'];
+
+ $list = [];
+ foreach ($plugins as $instance) {
+ $list[$instance->name] = $instance;
+ }
+
+ return $list;
+ }
+
+ /**
+ * @param string $name Plugin name
+ * @return Plugin|null Plugin object or null if plugin cannot be found.
+ */
+ public static function getPlugin(string $name)
+ {
+ $list = static::getPlugins();
+
+ return $list[$name] ?? null;
+ }
+
/**
* Return list of all plugin data with their blueprints.
*
- * @return array
+ * @return Data[]
*/
public static function all()
{
$grav = Grav::instance();
+
+ /** @var Plugins $plugins */
$plugins = $grav['plugins'];
$list = [];
@@ -142,8 +229,8 @@ public static function all()
try {
$result = self::get($name);
- } catch (\Exception $e) {
- $exception = new \RuntimeException(sprintf('Plugin %s: %s', $name, $e->getMessage()), $e->getCode(), $e);
+ } catch (Exception $e) {
+ $exception = new RuntimeException(sprintf('Plugin %s: %s', $name, $e->getMessage()), $e->getCode(), $e);
/** @var Debugger $debugger */
$debugger = $grav['debugger'];
@@ -165,7 +252,6 @@ public static function all()
* Get a plugin by name
*
* @param string $name
- *
* @return Data|null
*/
public static function get($name)
@@ -193,36 +279,52 @@ public static function get($name)
return $obj;
}
+ /**
+ * @param string $name
+ * @return Plugin|null
+ */
protected function loadPlugin($name)
{
+ // NOTE: ALL THE LOCAL VARIABLES ARE USED INSIDE INCLUDED FILE, DO NOT REMOVE THEM!
$grav = Grav::instance();
+ /** @var UniformResourceLocator $locator */
$locator = $grav['locator'];
+ $class = null;
+ // Start by attempting to load the plugin_name.php file.
$file = $locator->findResource('plugins://' . $name . DS . $name . PLUGIN_EXT);
-
if (is_file($file)) {
- // Local variables available in the file: $grav, $config, $name, $file
+ // Local variables available in the file: $grav, $name, $file
$class = include_once $file;
+ if (!is_object($class) || !is_subclass_of($class, Plugin::class, true)) {
+ $class = null;
+ }
+ }
+ // If the class hasn't been initialized yet, guess the class name and create a new instance.
+ if (null === $class) {
+ $className = Inflector::camelize($name);
$pluginClassFormat = [
'Grav\\Plugin\\' . ucfirst($name). 'Plugin',
- 'Grav\\Plugin\\' . Inflector::camelize($name) . 'Plugin'
+ 'Grav\\Plugin\\' . $className . 'Plugin',
+ 'Grav\\Plugin\\' . $className
];
foreach ($pluginClassFormat as $pluginClass) {
- if (class_exists($pluginClass)) {
+ if (is_subclass_of($pluginClass, Plugin::class, true)) {
$class = new $pluginClass($name, $grav);
break;
}
}
- } else {
+ }
+
+ // Log a warning if plugin cannot be found.
+ if (null === $class) {
$grav['log']->addWarning(
- sprintf("Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clear-cache`", $name)
+ sprintf("Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clearcache`", $name)
);
- return null;
}
return $class;
}
-
}
diff --git a/system/src/Grav/Common/Processors/AssetsProcessor.php b/system/src/Grav/Common/Processors/AssetsProcessor.php
index 8b4640138e..e460fb0023 100644
--- a/system/src/Grav/Common/Processors/AssetsProcessor.php
+++ b/system/src/Grav/Common/Processors/AssetsProcessor.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,12 +13,23 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+/**
+ * Class AssetsProcessor
+ * @package Grav\Common\Processors
+ */
class AssetsProcessor extends ProcessorBase
{
+ /** @var string */
public $id = '_assets';
+ /** @var string */
public $title = 'Assets';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
$this->container['assets']->init();
diff --git a/system/src/Grav/Common/Processors/BackupsProcessor.php b/system/src/Grav/Common/Processors/BackupsProcessor.php
index d2a822c913..83e8a0c212 100644
--- a/system/src/Grav/Common/Processors/BackupsProcessor.php
+++ b/system/src/Grav/Common/Processors/BackupsProcessor.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,12 +13,23 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+/**
+ * Class BackupsProcessor
+ * @package Grav\Common\Processors
+ */
class BackupsProcessor extends ProcessorBase
{
+ /** @var string */
public $id = '_backups';
+ /** @var string */
public $title = 'Backups';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
$backups = $this->container['backups'];
diff --git a/system/src/Grav/Common/Processors/ConfigurationProcessor.php b/system/src/Grav/Common/Processors/ConfigurationProcessor.php
deleted file mode 100644
index 83ceb8421d..0000000000
--- a/system/src/Grav/Common/Processors/ConfigurationProcessor.php
+++ /dev/null
@@ -1,30 +0,0 @@
-startTimer();
- $this->container['config']->init();
- $this->container['plugins']->setup();
- $this->stopTimer();
-
- return $handler->handle($request);
- }
-}
diff --git a/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php
index 11a39e894f..a3d5a51521 100644
--- a/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php
+++ b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php
@@ -3,29 +3,38 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Processors;
-use Grav\Framework\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+/**
+ * Class DebuggerAssetsProcessor
+ * @package Grav\Common\Processors
+ */
class DebuggerAssetsProcessor extends ProcessorBase
{
+ /** @var string */
public $id = 'debugger_assets';
+ /** @var string */
public $title = 'Debugger Assets';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
$this->container['debugger']->addAssets();
$this->stopTimer();
return $handler->handle($request);
-
}
}
diff --git a/system/src/Grav/Common/Processors/DebuggerProcessor.php b/system/src/Grav/Common/Processors/DebuggerProcessor.php
deleted file mode 100644
index 892af55c0b..0000000000
--- a/system/src/Grav/Common/Processors/DebuggerProcessor.php
+++ /dev/null
@@ -1,29 +0,0 @@
-startTimer();
- $this->container['debugger']->init();
- $this->stopTimer();
-
- return $handler->handle($request);
- }
-}
diff --git a/system/src/Grav/Common/Processors/ErrorsProcessor.php b/system/src/Grav/Common/Processors/ErrorsProcessor.php
deleted file mode 100644
index b0ef6cae87..0000000000
--- a/system/src/Grav/Common/Processors/ErrorsProcessor.php
+++ /dev/null
@@ -1,29 +0,0 @@
-startTimer();
- $this->container['errors']->resetHandlers();
- $this->stopTimer();
-
- return $handler->handle($request);
- }
-}
diff --git a/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php b/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php
index fd97e9e451..30f6195b6f 100644
--- a/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php
+++ b/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -16,6 +16,10 @@
use Psr\Http\Server\MiddlewareInterface;
use RocketTheme\Toolbox\Event\Event;
+/**
+ * Class RequestHandlerEvent
+ * @package Grav\Common\Processors\Events
+ */
class RequestHandlerEvent extends Event
{
/**
diff --git a/system/src/Grav/Common/Processors/InitializeProcessor.php b/system/src/Grav/Common/Processors/InitializeProcessor.php
index fe2869ce7a..24f4cf960d 100644
--- a/system/src/Grav/Common/Processors/InitializeProcessor.php
+++ b/system/src/Grav/Common/Processors/InitializeProcessor.php
@@ -3,32 +3,341 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Processors;
use Grav\Common\Config\Config;
+use Grav\Common\Debugger;
+use Grav\Common\Errors\Errors;
+use Grav\Common\Grav;
+use Grav\Common\Page\Pages;
+use Grav\Common\Plugins;
+use Grav\Common\Session;
use Grav\Common\Uri;
use Grav\Common\Utils;
+use Grav\Framework\File\Formatter\YamlFormatter;
+use Grav\Framework\File\YamlFile;
+use Grav\Framework\Psr7\Response;
use Grav\Framework\Session\Exceptions\SessionException;
+use Monolog\Formatter\LineFormatter;
+use Monolog\Handler\SyslogHandler;
+use Monolog\Logger;
+use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+use function defined;
+use function in_array;
+/**
+ * Class InitializeProcessor
+ * @package Grav\Common\Processors
+ */
class InitializeProcessor extends ProcessorBase
{
- public $id = 'init';
+ /** @var string */
+ public $id = '_init';
+ /** @var string */
public $title = 'Initialize';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /** @var bool */
+ protected static $cli_initialized = false;
+
+ /**
+ * @param Grav $grav
+ * @return void
+ */
+ public static function initializeCli(Grav $grav)
+ {
+ if (!static::$cli_initialized) {
+ static::$cli_initialized = true;
+
+ $instance = new static($grav);
+ $instance->processCli();
+ }
+ }
+
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ $this->startTimer('_init', 'Initialize');
+
+ // Load configuration.
+ $config = $this->initializeConfig();
+
+ // Initialize logger.
+ $this->initializeLogger($config);
+
+ // Initialize error handlers.
+ $this->initializeErrors();
+
+ // Initialize debugger.
+ $debugger = $this->initializeDebugger();
+
+ // Debugger can return response right away.
+ $response = $this->handleDebuggerRequest($debugger, $request);
+ if ($response) {
+ $this->stopTimer('_init');
+
+ return $response;
+ }
+
+ // Initialize output buffering.
+ $this->initializeOutputBuffering($config);
+
+ // Set timezone, locale.
+ $this->initializeLocale($config);
+
+ // Load plugins.
+ $this->initializePlugins();
+
+ // Load pages.
+ $this->initializePages($config);
+
+ // Load accounts (decides class to be used).
+ // TODO: remove in 2.0.
+ $this->container['accounts'];
+
+ // Initialize session (used by URI, see issue #3269).
+ $this->initializeSession($config);
+
+ // Initialize URI (uses session, see issue #3269).
+ $this->initializeUri($config);
+
+ // Grav may return redirect response right away.
+ $redirectCode = (int)$config->get('system.pages.redirect_trailing_slash', 1);
+ if ($redirectCode) {
+ $response = $this->handleRedirectRequest($request, $redirectCode > 300 ? $redirectCode : null);
+ if ($response) {
+ $this->stopTimer('_init');
+
+ return $response;
+ }
+ }
+
+ $this->stopTimer('_init');
+
+ // Wrap call to next handler so that debugger can profile it.
+ /** @var Response $response */
+ $response = $debugger->profile(static function () use ($handler, $request) {
+ return $handler->handle($request);
+ });
+
+ // Log both request and response and return the response.
+ return $debugger->logRequest($request, $response);
+ }
+
+ public function processCli(): void
{
- $this->startTimer();
+ // Load configuration.
+ $config = $this->initializeConfig();
+
+ // Initialize logger.
+ $this->initializeLogger($config);
+
+ // Disable debugger.
+ $this->container['debugger']->enabled(false);
+
+ // Set timezone, locale.
+ $this->initializeLocale($config);
+
+ // Load plugins.
+ $this->initializePlugins();
+
+ // Load pages.
+ $this->initializePages($config);
+
+ // Initialize URI.
+ $this->initializeUri($config);
+
+ // Load accounts (decides class to be used).
+ // TODO: remove in 2.0.
+ $this->container['accounts'];
+ }
+
+ /**
+ * @return Config
+ */
+ protected function initializeConfig(): Config
+ {
+ $this->startTimer('_init_config', 'Configuration');
+
+ // Initialize Configuration
+ $grav = $this->container;
/** @var Config $config */
- $config = $this->container['config'];
- $config->debug();
+ $config = $grav['config'];
+ $config->init();
+ $grav['plugins']->setup();
+
+ if (defined('GRAV_SCHEMA') && $config->get('versions') === null) {
+ $filename = USER_DIR . 'config/versions.yaml';
+ if (!is_file($filename)) {
+ $versions = [
+ 'core' => [
+ 'grav' => [
+ 'version' => GRAV_VERSION,
+ 'schema' => GRAV_SCHEMA
+ ]
+ ]
+ ];
+ $config->set('versions', $versions);
+
+ $file = new YamlFile($filename, new YamlFormatter(['inline' => 4]));
+ $file->save($versions);
+ }
+ }
+
+ // Override configuration using the environment.
+ $prefix = 'GRAV_CONFIG';
+ $env = getenv($prefix);
+ if ($env) {
+ $cPrefix = $prefix . '__';
+ $aPrefix = $prefix . '_ALIAS__';
+ $cLen = strlen($cPrefix);
+ $aLen = strlen($aPrefix);
+
+ $keys = $aliases = [];
+ $env = $_ENV + $_SERVER;
+ foreach ($env as $key => $value) {
+ if (!str_starts_with($key, $prefix)) {
+ continue;
+ }
+ if (str_starts_with($key, $cPrefix)) {
+ $key = str_replace('__', '.', substr($key, $cLen));
+ $keys[$key] = $value;
+ } elseif (str_starts_with($key, $aPrefix)) {
+ $key = substr($key, $aLen);
+ $aliases[$key] = $value;
+ }
+ }
+ $list = [];
+ foreach ($keys as $key => $value) {
+ foreach ($aliases as $alias => $real) {
+ $key = str_replace($alias, $real, $key);
+ }
+ $list[$key] = $value;
+ $config->set($key, $value);
+ }
+ }
+
+ $this->stopTimer('_init_config');
+
+ return $config;
+ }
+
+ /**
+ * @param Config $config
+ * @return Logger
+ */
+ protected function initializeLogger(Config $config): Logger
+ {
+ $this->startTimer('_init_logger', 'Logger');
+
+ $grav = $this->container;
+
+ // Initialize Logging
+ /** @var Logger $log */
+ $log = $grav['log'];
+
+ if ($config->get('system.log.handler', 'file') === 'syslog') {
+ $log->popHandler();
+
+ $facility = $config->get('system.log.syslog.facility', 'local6');
+ $tag = $config->get('system.log.syslog.tag', 'grav');
+ $logHandler = new SyslogHandler($tag, $facility);
+ $formatter = new LineFormatter("%channel%.%level_name%: %message% %extra%");
+ $logHandler->setFormatter($formatter);
+
+ $log->pushHandler($logHandler);
+ }
+
+ $this->stopTimer('_init_logger');
+
+ return $log;
+ }
+
+ /**
+ * @return Errors
+ */
+ protected function initializeErrors(): Errors
+ {
+ $this->startTimer('_init_errors', 'Error Handlers Reset');
+
+ $grav = $this->container;
+
+ // Initialize Error Handlers
+ /** @var Errors $errors */
+ $errors = $grav['errors'];
+ $errors->resetHandlers();
+
+ $this->stopTimer('_init_errors');
+
+ return $errors;
+ }
+
+ /**
+ * @return Debugger
+ */
+ protected function initializeDebugger(): Debugger
+ {
+ $this->startTimer('_init_debugger', 'Init Debugger');
+
+ $grav = $this->container;
+
+ /** @var Debugger $debugger */
+ $debugger = $grav['debugger'];
+ $debugger->init();
+
+ $this->stopTimer('_init_debugger');
+
+ return $debugger;
+ }
+
+ /**
+ * @param Debugger $debugger
+ * @param ServerRequestInterface $request
+ * @return ResponseInterface|null
+ */
+ protected function handleDebuggerRequest(Debugger $debugger, ServerRequestInterface $request): ?ResponseInterface
+ {
+ // Clockwork integration.
+ $clockwork = $debugger->getClockwork();
+ if ($clockwork) {
+ $server = $request->getServerParams();
+// $baseUri = str_replace('\\', '/', dirname(parse_url($server['SCRIPT_NAME'], PHP_URL_PATH)));
+// if ($baseUri === '/') {
+// $baseUri = '';
+// }
+ $requestTime = $server['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME;
+
+ $request = $request->withAttribute('request_time', $requestTime);
+
+ // Handle clockwork API calls.
+ $uri = $request->getUri();
+ if (Utils::contains($uri->getPath(), '/__clockwork/')) {
+ return $debugger->debuggerRequest($request);
+ }
+
+ $this->container['clockwork'] = $clockwork;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param Config $config
+ */
+ protected function initializeOutputBuffering(Config $config): void
+ {
+ $this->startTimer('_init_ob', 'Initialize Output Buffering');
// Use output buffering to prevent headers from being sent too early.
ob_start();
@@ -37,44 +346,116 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
ob_start();
}
+ $this->stopTimer('_init_ob');
+ }
+
+ /**
+ * @param Config $config
+ */
+ protected function initializeLocale(Config $config): void
+ {
+ $this->startTimer('_init_locale', 'Initialize Locale');
+
// Initialize the timezone.
$timezone = $config->get('system.timezone');
if ($timezone) {
date_default_timezone_set($timezone);
}
- // FIXME: Initialize session should happen later after plugins have been loaded. This is a workaround to fix session issues in AWS.
- if (isset($this->container['session']) && $config->get('system.session.initialize', true)) {
- // TODO: remove in 2.0.
- $this->container['accounts'];
+ $grav = $this->container;
+ $grav->setLocale();
- try {
- $this->container['session']->init();
- } catch (SessionException $e) {
- $this->container['session']->init();
- $message = 'Session corruption detected, restarting session...';
- $this->addMessage($message);
- $this->container['messages']->add($message, 'error');
- }
+ $this->stopTimer('_init_locale');
+ }
+
+ protected function initializePlugins(): Plugins
+ {
+ $this->startTimer('_init_plugins_load', 'Load Plugins');
+
+ $grav = $this->container;
+
+ /** @var Plugins $plugins */
+ $plugins = $grav['plugins'];
+ $plugins->init();
+
+ $this->stopTimer('_init_plugins_load');
+
+ return $plugins;
+ }
+
+ protected function initializePages(Config $config): Pages
+ {
+ $this->startTimer('_init_pages_register', 'Load Pages');
+
+ $grav = $this->container;
+
+ /** @var Pages $pages */
+ $pages = $grav['pages'];
+ // Upgrading from older Grav versions won't work without checking if the method exists.
+ if (method_exists($pages, 'register')) {
+ $pages->register();
}
+ $this->stopTimer('_init_pages_register');
+
+ return $pages;
+ }
+
+
+ protected function initializeUri(Config $config): void
+ {
+ $this->startTimer('_init_uri', 'Initialize URI');
+
+ $grav = $this->container;
+
/** @var Uri $uri */
- $uri = $this->container['uri'];
+ $uri = $grav['uri'];
$uri->init();
+ $this->stopTimer('_init_uri');
+ }
+
+ protected function handleRedirectRequest(RequestInterface $request, int $code = null): ?ResponseInterface
+ {
+ if (!in_array($request->getMethod(), ['GET', 'HEAD'])) {
+ return null;
+ }
+
// Redirect pages with trailing slash if configured to do so.
- $path = $uri->path() ?: '/';
- if ($path !== '/'
- && $config->get('system.pages.redirect_trailing_slash', false)
- && Utils::endsWith($path, '/')) {
+ $uri = $request->getUri();
+ $path = $uri->getPath() ?: '/';
+ $root = $this->container['uri']->rootUrl();
- $redirect = (string) $uri::getCurrentRoute()->toString();
- $this->container->redirect($redirect);
+ if ($path !== $root && $path !== $root . '/' && Utils::endsWith($path, '/')) {
+ // Use permanent redirect for SEO reasons.
+ return $this->container->getRedirectResponse((string)$uri->withPath(rtrim($path, '/')), $code);
}
- $this->container->setLocale();
- $this->stopTimer();
+ return null;
+ }
+
+ /**
+ * @param Config $config
+ */
+ protected function initializeSession(Config $config): void
+ {
+ // FIXME: Initialize session should happen later after plugins have been loaded. This is a workaround to fix session issues in AWS.
+ if (isset($this->container['session']) && $config->get('system.session.initialize', true)) {
+ $this->startTimer('_init_session', 'Start Session');
+
+ /** @var Session $session */
+ $session = $this->container['session'];
- return $handler->handle($request);
+ try {
+ $session->init();
+ } catch (SessionException $e) {
+ $session->init();
+ $message = 'Session corruption detected, restarting session...';
+ $this->addMessage($message);
+ $this->container['messages']->add($message, 'error');
+ }
+
+ $this->stopTimer('_init_session');
+ }
}
}
diff --git a/system/src/Grav/Common/Processors/LoggerProcessor.php b/system/src/Grav/Common/Processors/LoggerProcessor.php
deleted file mode 100644
index af3c169990..0000000000
--- a/system/src/Grav/Common/Processors/LoggerProcessor.php
+++ /dev/null
@@ -1,50 +0,0 @@
-startTimer();
-
- $grav = $this->container;
-
- /** @var Config $config */
- $config = $grav['config'];
-
- switch ($config->get('system.log.handler', 'file')) {
- case 'syslog':
- $log = $grav['log'];
- $log->popHandler();
-
- $facility = $config->get('system.log.syslog.facility', 'local6');
- $logHandler = new SyslogHandler('grav', $facility);
- $formatter = new LineFormatter("%channel%.%level_name%: %message% %extra%");
- $logHandler->setFormatter($formatter);
-
- $log->pushHandler($logHandler);
- break;
- }
- $this->stopTimer();
-
- return $handler->handle($request);
- }
-}
diff --git a/system/src/Grav/Common/Processors/PagesProcessor.php b/system/src/Grav/Common/Processors/PagesProcessor.php
index ede22e5c4d..3338e0c632 100644
--- a/system/src/Grav/Common/Processors/PagesProcessor.php
+++ b/system/src/Grav/Common/Processors/PagesProcessor.php
@@ -3,24 +3,38 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Processors;
use Grav\Common\Page\Interfaces\PageInterface;
+use Grav\Framework\RequestHandler\Exception\RequestException;
+use Grav\Plugin\Form\Forms;
use RocketTheme\Toolbox\Event\Event;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+use RuntimeException;
+/**
+ * Class PagesProcessor
+ * @package Grav\Common\Processors
+ */
class PagesProcessor extends ProcessorBase
{
+ /** @var string */
public $id = 'pages';
+ /** @var string */
public $title = 'Pages';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
@@ -28,23 +42,46 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
$this->container['debugger']->addMessage($this->container['cache']->getCacheStatus());
$this->container['pages']->init();
- $this->container->fireEvent('onPagesInitialized', new Event(['pages' => $this->container['pages']]));
- $this->container->fireEvent('onPageInitialized', new Event(['page' => $this->container['page']]));
+
+ $route = $this->container['route'];
+
+ $this->container->fireEvent('onPagesInitialized', new Event(
+ [
+ 'pages' => $this->container['pages'],
+ 'route' => $route,
+ 'request' => $request
+ ]
+ ));
+ $this->container->fireEvent('onPageInitialized', new Event(
+ [
+ 'page' => $this->container['page'],
+ 'route' => $route,
+ 'request' => $request
+ ]
+ ));
/** @var PageInterface $page */
$page = $this->container['page'];
if (!$page->routable()) {
+ $exception = new RequestException($request, 'Page Not Found', 404);
// If no page found, fire event
- $event = new Event(['page' => $page]);
+ $event = new Event([
+ 'page' => $page,
+ 'code' => $exception->getCode(),
+ 'message' => $exception->getMessage(),
+ 'exception' => $exception,
+ 'route' => $route,
+ 'request' => $request
+ ]);
$event->page = null;
$event = $this->container->fireEvent('onPageNotFound', $event);
if (isset($event->page)) {
- unset ($this->container['page']);
+ unset($this->container['page']);
$this->container['page'] = $page = $event->page;
} else {
- throw new \RuntimeException('Page Not Found', 404);
+ throw new RuntimeException('Page Not Found', 404);
}
$this->addMessage("Routed to page {$page->rawRoute()} (type: {$page->template()}) [Not Found fallback]");
@@ -53,12 +90,18 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
$task = $this->container['task'];
$action = $this->container['action'];
+
+ /** @var Forms $forms */
+ $forms = $this->container['forms'] ?? null;
+ $form = $forms ? $forms->getActiveForm() : null;
+
+ $options = ['page' => $page, 'form' => $form, 'request' => $request];
if ($task) {
- $event = new Event(['task' => $task, 'page' => $page]);
+ $event = new Event(['task' => $task] + $options);
$this->container->fireEvent('onPageTask', $event);
$this->container->fireEvent('onPageTask.' . $task, $event);
} elseif ($action) {
- $event = new Event(['action' => $action, 'page' => $page]);
+ $event = new Event(['action' => $action] + $options);
$this->container->fireEvent('onPageAction', $event);
$this->container->fireEvent('onPageAction.' . $action, $event);
}
diff --git a/system/src/Grav/Common/Processors/PluginsProcessor.php b/system/src/Grav/Common/Processors/PluginsProcessor.php
index 9d2943b67e..ae8790104c 100644
--- a/system/src/Grav/Common/Processors/PluginsProcessor.php
+++ b/system/src/Grav/Common/Processors/PluginsProcessor.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,18 +13,27 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+/**
+ * Class PluginsProcessor
+ * @package Grav\Common\Processors
+ */
class PluginsProcessor extends ProcessorBase
{
+ /** @var string */
public $id = 'plugins';
- public $title = 'Plugins';
+ /** @var string */
+ public $title = 'Initialize Plugins';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
- // TODO: remove in 2.0.
- $this->container['accounts'];
- $this->container['plugins']->init();
- $this->container->fireEvent('onPluginsInitialized');
+ $grav = $this->container;
+ $grav->fireEvent('onPluginsInitialized');
$this->stopTimer();
return $handler->handle($request);
diff --git a/system/src/Grav/Common/Processors/ProcessorBase.php b/system/src/Grav/Common/Processors/ProcessorBase.php
index 5ce7bfd299..9353391448 100644
--- a/system/src/Grav/Common/Processors/ProcessorBase.php
+++ b/system/src/Grav/Common/Processors/ProcessorBase.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -12,34 +12,56 @@
use Grav\Common\Debugger;
use Grav\Common\Grav;
+/**
+ * Class ProcessorBase
+ * @package Grav\Common\Processors
+ */
abstract class ProcessorBase implements ProcessorInterface
{
/** @var Grav */
protected $container;
+ /** @var string */
public $id = 'processorbase';
+ /** @var string */
public $title = 'ProcessorBase';
+ /**
+ * ProcessorBase constructor.
+ * @param Grav $container
+ */
public function __construct(Grav $container)
{
$this->container = $container;
}
- protected function startTimer($id = null, $title = null)
+ /**
+ * @param string|null $id
+ * @param string|null $title
+ */
+ protected function startTimer($id = null, $title = null): void
{
/** @var Debugger $debugger */
$debugger = $this->container['debugger'];
$debugger->startTimer($id ?? $this->id, $title ?? $this->title);
}
- protected function stopTimer($id = null)
+ /**
+ * @param string|null $id
+ */
+ protected function stopTimer($id = null): void
{
/** @var Debugger $debugger */
$debugger = $this->container['debugger'];
$debugger->stopTimer($id ?? $this->id);
}
- protected function addMessage($message, $label = 'info', $isString = true)
+ /**
+ * @param string $message
+ * @param string $label
+ * @param bool $isString
+ */
+ protected function addMessage($message, $label = 'info', $isString = true): void
{
/** @var Debugger $debugger */
$debugger = $this->container['debugger'];
diff --git a/system/src/Grav/Common/Processors/ProcessorInterface.php b/system/src/Grav/Common/Processors/ProcessorInterface.php
index a8e4aaaef8..0aed195044 100644
--- a/system/src/Grav/Common/Processors/ProcessorInterface.php
+++ b/system/src/Grav/Common/Processors/ProcessorInterface.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,6 +11,10 @@
use Psr\Http\Server\MiddlewareInterface;
+/**
+ * Interface ProcessorInterface
+ * @package Grav\Common\Processors
+ */
interface ProcessorInterface extends MiddlewareInterface
{
}
diff --git a/system/src/Grav/Common/Processors/RenderProcessor.php b/system/src/Grav/Common/Processors/RenderProcessor.php
index 06a6c79f36..e12cc45a37 100644
--- a/system/src/Grav/Common/Processors/RenderProcessor.php
+++ b/system/src/Grav/Common/Processors/RenderProcessor.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -14,13 +14,25 @@
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+use RocketTheme\Toolbox\Event\Event;
+/**
+ * Class RenderProcessor
+ * @package Grav\Common\Processors
+ */
class RenderProcessor extends ProcessorBase
{
+ /** @var string */
public $id = 'render';
+ /** @var string */
public $title = 'Render';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
@@ -31,23 +43,27 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
return $output;
}
- ob_start();
+ /** @var PageInterface $page */
+ $page = $this->container['page'];
// Use internal Grav output.
$container->output = $output;
- $container->fireEvent('onOutputGenerated');
+
+ ob_start();
+
+ $event = new Event(['page' => $page, 'output' => &$container->output]);
+ $container->fireEvent('onOutputGenerated', $event);
echo $container->output;
+ $html = ob_get_clean();
+
// remove any output
$container->output = '';
- $this->container->fireEvent('onOutputRendered');
+ $event = new Event(['page' => $page, 'output' => $html]);
+ $this->container->fireEvent('onOutputRendered', $event);
- $html = ob_get_clean();
-
- /** @var PageInterface $page */
- $page = $this->container['page'];
$this->stopTimer();
return new Response($page->httpResponseCode(), $page->httpHeaders(), $html);
diff --git a/system/src/Grav/Common/Processors/RequestProcessor.php b/system/src/Grav/Common/Processors/RequestProcessor.php
index 97564e69bc..bad410c110 100644
--- a/system/src/Grav/Common/Processors/RequestProcessor.php
+++ b/system/src/Grav/Common/Processors/RequestProcessor.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -11,16 +11,28 @@
use Grav\Common\Processors\Events\RequestHandlerEvent;
use Grav\Common\Uri;
+use Grav\Common\Utils;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+/**
+ * Class RequestProcessor
+ * @package Grav\Common\Processors
+ */
class RequestProcessor extends ProcessorBase
{
+ /** @var string */
public $id = 'request';
+ /** @var string */
public $title = 'Request';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
@@ -31,7 +43,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
}
$uri = $request->getUri();
- $ext = mb_strtolower(pathinfo($uri->getPath(), PATHINFO_EXTENSION));
+ $ext = mb_strtolower(Utils::pathinfo($uri->getPath(), PATHINFO_EXTENSION));
$request = $request
->withAttribute('grav', $this->container)
diff --git a/system/src/Grav/Common/Processors/SchedulerProcessor.php b/system/src/Grav/Common/Processors/SchedulerProcessor.php
index 64f3fba8f7..722bfcc92e 100644
--- a/system/src/Grav/Common/Processors/SchedulerProcessor.php
+++ b/system/src/Grav/Common/Processors/SchedulerProcessor.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -14,12 +14,23 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+/**
+ * Class SchedulerProcessor
+ * @package Grav\Common\Processors
+ */
class SchedulerProcessor extends ProcessorBase
{
+ /** @var string */
public $id = '_scheduler';
+ /** @var string */
public $title = 'Scheduler';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
$scheduler = $this->container['scheduler'];
diff --git a/system/src/Grav/Common/Processors/TasksProcessor.php b/system/src/Grav/Common/Processors/TasksProcessor.php
index aaf5cdf33a..29f3458eec 100644
--- a/system/src/Grav/Common/Processors/TasksProcessor.php
+++ b/system/src/Grav/Common/Processors/TasksProcessor.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -14,12 +14,23 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+/**
+ * Class TasksProcessor
+ * @package Grav\Common\Processors
+ */
class TasksProcessor extends ProcessorBase
{
+ /** @var string */
public $id = 'tasks';
+ /** @var string */
public $title = 'Tasks';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
@@ -42,7 +53,6 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
$this->stopTimer();
return $response;
-
} catch (NotFoundException $e) {
// Task not found: Let it pass through.
}
diff --git a/system/src/Grav/Common/Processors/ThemesProcessor.php b/system/src/Grav/Common/Processors/ThemesProcessor.php
index d1c6ae5644..60d089e8be 100644
--- a/system/src/Grav/Common/Processors/ThemesProcessor.php
+++ b/system/src/Grav/Common/Processors/ThemesProcessor.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,12 +13,23 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+/**
+ * Class ThemesProcessor
+ * @package Grav\Common\Processors
+ */
class ThemesProcessor extends ProcessorBase
{
+ /** @var string */
public $id = 'themes';
+ /** @var string */
public $title = 'Themes';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
$this->container['themes']->init();
diff --git a/system/src/Grav/Common/Processors/TwigProcessor.php b/system/src/Grav/Common/Processors/TwigProcessor.php
index 4ed247d013..ffc1032eac 100644
--- a/system/src/Grav/Common/Processors/TwigProcessor.php
+++ b/system/src/Grav/Common/Processors/TwigProcessor.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Processors
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,12 +13,23 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+/**
+ * Class TwigProcessor
+ * @package Grav\Common\Processors
+ */
class TwigProcessor extends ProcessorBase
{
+ /** @var string */
public $id = 'twig';
+ /** @var string */
public $title = 'Twig';
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
+ /**
+ * @param ServerRequestInterface $request
+ * @param RequestHandlerInterface $handler
+ * @return ResponseInterface
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
$this->container['twig']->init();
diff --git a/system/src/Grav/Common/Scheduler/Cron.php b/system/src/Grav/Common/Scheduler/Cron.php
index 3a42b29ea3..0103839d35 100644
--- a/system/src/Grav/Common/Scheduler/Cron.php
+++ b/system/src/Grav/Common/Scheduler/Cron.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Scheduler
* @author Originally based on jqCron by Arnaud Buathier modified for Grav integration
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -45,6 +45,15 @@
* var_dump($cron->matchWithMargin(new \DateTime('2012-07-01 12:32:50'), -3, 5));
* // bool(true)
*/
+
+use DateInterval;
+use DateTime;
+use RuntimeException;
+use function count;
+use function in_array;
+use function is_array;
+use function is_string;
+
class Cron
{
public const TYPE_UNDEFINED = '';
@@ -69,7 +78,7 @@ class Cron
'name_year' => 'année',
'text_period' => 'Chaque %s',
'text_mins' => 'à %s minutes',
- 'text_time' => 'à %s:%s',
+ 'text_time' => 'à %02s:%02s',
'text_dow' => 'le %s',
'text_month' => 'de %s',
'text_dom' => 'le %s',
@@ -86,7 +95,7 @@ class Cron
'name_year' => 'year',
'text_period' => 'Every %s',
'text_mins' => 'at %s minutes past the hour',
- 'text_time' => 'at %s:%s',
+ 'text_time' => 'at %02s:%02s',
'text_dow' => 'on %s',
'text_month' => 'of %s',
'text_dom' => 'on the %s',
@@ -127,7 +136,6 @@ class Cron
protected $dom = [];
/**
- *
* @param string|null $cron
*/
public function __construct($cron = null)
@@ -138,7 +146,6 @@ public function __construct($cron = null)
}
/**
- *
* @return string
*/
public function getCron()
@@ -153,7 +160,6 @@ public function getCron()
}
/**
- *
* @param string $lang 'fr' or 'en'
* @return string
*/
@@ -191,7 +197,7 @@ public function getText($lang)
}
// month + year
- if (\in_array($type, [self::TYPE_MONTH, self::TYPE_YEAR], true)) {
+ if (in_array($type, [self::TYPE_MONTH, self::TYPE_YEAR], true)) {
$elements[] = sprintf($texts['text_dom'], $this->getCronDaysOfMonth());
}
@@ -205,7 +211,7 @@ public function getText($lang)
}
// day + week + month + year
- if (\in_array($type, [self::TYPE_DAY, self::TYPE_WEEK, self::TYPE_MONTH, self::TYPE_YEAR], true)) {
+ if (in_array($type, [self::TYPE_DAY, self::TYPE_WEEK, self::TYPE_MONTH, self::TYPE_YEAR], true)) {
$elements[] = sprintf($texts['text_time'], $this->getCronHours(), $this->getCronMinutes());
}
@@ -213,7 +219,6 @@ public function getText($lang)
}
/**
- *
* @return string
*/
public function getType()
@@ -250,9 +255,8 @@ public function getType()
}
/**
- *
* @param string $cron
- * @return Cron
+ * @return $this
*/
public function setCron($cron)
{
@@ -261,8 +265,8 @@ public function setCron($cron)
$cron = preg_replace('/\s+/', ' ', $cron);
// explode
$elements = explode(' ', $cron);
- if (\count($elements) !== 5) {
- throw new \RuntimeException('Bad number of elements');
+ if (count($elements) !== 5) {
+ throw new RuntimeException('Bad number of elements');
}
$this->cron = $cron;
@@ -276,7 +280,6 @@ public function setCron($cron)
}
/**
- *
* @return string
*/
public function getCronMinutes()
@@ -285,7 +288,6 @@ public function getCronMinutes()
}
/**
- *
* @return string
*/
public function getCronHours()
@@ -294,7 +296,6 @@ public function getCronHours()
}
/**
- *
* @return string
*/
public function getCronDaysOfMonth()
@@ -303,7 +304,6 @@ public function getCronDaysOfMonth()
}
/**
- *
* @return string
*/
public function getCronMonths()
@@ -312,7 +312,6 @@ public function getCronMonths()
}
/**
- *
* @return string
*/
public function getCronDaysOfWeek()
@@ -321,7 +320,6 @@ public function getCronDaysOfWeek()
}
/**
- *
* @return array
*/
public function getMinutes()
@@ -330,7 +328,6 @@ public function getMinutes()
}
/**
- *
* @return array
*/
public function getHours()
@@ -339,7 +336,6 @@ public function getHours()
}
/**
- *
* @return array
*/
public function getDaysOfMonth()
@@ -348,7 +344,6 @@ public function getDaysOfMonth()
}
/**
- *
* @return array
*/
public function getMonths()
@@ -357,7 +352,6 @@ public function getMonths()
}
/**
- *
* @return array
*/
public function getDaysOfWeek()
@@ -366,9 +360,8 @@ public function getDaysOfWeek()
}
/**
- *
- * @param string|array $minutes
- * @return Cron
+ * @param string|string[] $minutes
+ * @return $this
*/
public function setMinutes($minutes)
{
@@ -378,9 +371,8 @@ public function setMinutes($minutes)
}
/**
- *
- * @param string|array $hours
- * @return Cron
+ * @param string|string[] $hours
+ * @return $this
*/
public function setHours($hours)
{
@@ -390,9 +382,8 @@ public function setHours($hours)
}
/**
- *
- * @param string|array $months
- * @return Cron
+ * @param string|string[] $months
+ * @return $this
*/
public function setMonths($months)
{
@@ -402,9 +393,8 @@ public function setMonths($months)
}
/**
- *
- * @param string|array $dow
- * @return Cron
+ * @param string|string[] $dow
+ * @return $this
*/
public function setDaysOfWeek($dow)
{
@@ -414,9 +404,8 @@ public function setDaysOfWeek($dow)
}
/**
- *
- * @param string|array $dom
- * @return Cron
+ * @param string|string[] $dom
+ * @return $this
*/
public function setDaysOfMonth($dom)
{
@@ -426,73 +415,68 @@ public function setDaysOfMonth($dom)
}
/**
- *
* @param mixed $date
* @param int $min
* @param int $hour
* @param int $day
* @param int $month
* @param int $weekday
- * @return \DateTime
+ * @return DateTime
*/
protected function parseDate($date, &$min, &$hour, &$day, &$month, &$weekday)
{
if (is_numeric($date) && (int)$date == $date) {
- $date = new \DateTime('@' . $date);
- }
- elseif (is_string($date)) {
- $date = new \DateTime('@' . strtotime($date));
+ $date = new DateTime('@' . $date);
+ } elseif (is_string($date)) {
+ $date = new DateTime('@' . strtotime($date));
}
- if ($date instanceof \DateTime) {
+ if ($date instanceof DateTime) {
$min = (int)$date->format('i');
$hour = (int)$date->format('H');
$day = (int)$date->format('d');
$month = (int)$date->format('m');
$weekday = (int)$date->format('w'); // 0-6
- }
- else {
- throw new \RuntimeException('Date format not supported');
+ } else {
+ throw new RuntimeException('Date format not supported');
}
- return new \DateTime($date->format('Y-m-d H:i:sP'));
+ return new DateTime($date->format('Y-m-d H:i:sP'));
}
/**
- *
- * @param int|string|\DateTime $date
+ * @param int|string|DateTime $date
*/
public function matchExact($date)
{
$date = $this->parseDate($date, $min, $hour, $day, $month, $weekday);
return
- (empty($this->minutes) || \in_array($min, $this->minutes, true)) &&
- (empty($this->hours) || \in_array($hour, $this->hours, true)) &&
- (empty($this->dom) || \in_array($day, $this->dom, true)) &&
- (empty($this->months) || \in_array($month, $this->months, true)) &&
- (empty($this->dow) || \in_array($weekday, $this->dow, true) || ($weekday == 0 && \in_array(7, $this->dow, true)) || ($weekday == 7 && \in_array(0, $this->dow, true))
+ (empty($this->minutes) || in_array($min, $this->minutes, true)) &&
+ (empty($this->hours) || in_array($hour, $this->hours, true)) &&
+ (empty($this->dom) || in_array($day, $this->dom, true)) &&
+ (empty($this->months) || in_array($month, $this->months, true)) &&
+ (empty($this->dow) || in_array($weekday, $this->dow, true) || ($weekday == 0 && in_array(7, $this->dow, true)) || ($weekday == 7 && in_array(0, $this->dow, true))
);
}
/**
- *
- * @param int|string|\DateTime $date
+ * @param int|string|DateTime $date
* @param int $minuteBefore
* @param int $minuteAfter
*/
public function matchWithMargin($date, $minuteBefore = 0, $minuteAfter = 0)
{
if ($minuteBefore > 0) {
- throw new \RuntimeException('MinuteBefore parameter cannot be positive !');
+ throw new RuntimeException('MinuteBefore parameter cannot be positive !');
}
if ($minuteAfter < 0) {
- throw new \RuntimeException('MinuteAfter parameter cannot be negative !');
+ throw new RuntimeException('MinuteAfter parameter cannot be negative !');
}
$date = $this->parseDate($date, $min, $hour, $day, $month, $weekday);
- $interval = new \DateInterval('PT1M'); // 1 min
+ $interval = new DateInterval('PT1M'); // 1 min
if ($minuteBefore !== 0) {
- $date->sub(new \DateInterval('PT' . abs($minuteBefore) . 'M'));
+ $date->sub(new DateInterval('PT' . abs($minuteBefore) . 'M'));
}
$n = $minuteAfter - $minuteBefore + 1;
for ($i = 0; $i < $n; $i++) {
@@ -506,14 +490,13 @@ public function matchWithMargin($date, $minuteBefore = 0, $minuteAfter = 0)
}
/**
- *
* @param array $array
* @return string
*/
protected function arrayToCron($array)
{
- $n = \count($array);
- if (!\is_array($array) || $n === 0) {
+ $n = count($array);
+ if (!is_array($array) || $n === 0) {
return '*';
}
@@ -522,9 +505,8 @@ protected function arrayToCron($array)
for ($i = 1; $i < $n; $i++) {
if ($array[$i] == $c + 1) {
$c = $array[$i];
- $cron[\count($cron) - 1] = $s . '-' . $c;
- }
- else {
+ $cron[count($cron) - 1] = $s . '-' . $c;
+ } else {
$s = $c = $array[$i];
$cron[] = $c;
}
@@ -543,7 +525,7 @@ protected function arrayToCron($array)
protected function cronToArray($string, $min, $max)
{
$array = [];
- if (\is_array($string)) {
+ if (is_array($string)) {
foreach ($string as $val) {
if (is_numeric($val) && (int)$val == $val && $val >= $min && $val <= $max) {
$array[] = (int)$val;
@@ -588,7 +570,7 @@ protected function cronToArray($string, $min, $max)
return [];
}
}
- sort($array);
+ sort($array, SORT_NUMERIC);
return $array;
}
diff --git a/system/src/Grav/Common/Scheduler/IntervalTrait.php b/system/src/Grav/Common/Scheduler/IntervalTrait.php
index b382c9d729..5f7406b8e6 100644
--- a/system/src/Grav/Common/Scheduler/IntervalTrait.php
+++ b/system/src/Grav/Common/Scheduler/IntervalTrait.php
@@ -3,14 +3,20 @@
/**
* @package Grav\Common\Scheduler
* @author Originally based on peppeocchi/php-cron-scheduler modified for Grav integration
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
use Cron\CronExpression;
+use InvalidArgumentException;
+use function is_string;
+/**
+ * Trait IntervalTrait
+ * @package Grav\Common\Scheduler
+ */
trait IntervalTrait
{
/**
@@ -59,7 +65,7 @@ public function hourly($minute = 0)
*/
public function daily($hour = 0, $minute = 0)
{
- if (\is_string($hour)) {
+ if (is_string($hour)) {
$parts = explode(':', $hour);
$hour = $parts[0];
$minute = $parts[1] ?? '0';
@@ -79,7 +85,7 @@ public function daily($hour = 0, $minute = 0)
*/
public function weekly($weekday = 0, $hour = 0, $minute = 0)
{
- if (\is_string($hour)) {
+ if (is_string($hour)) {
$parts = explode(':', $hour);
$hour = $parts[0];
$minute = $parts[1] ?? '0';
@@ -100,7 +106,7 @@ public function weekly($weekday = 0, $hour = 0, $minute = 0)
*/
public function monthly($month = '*', $day = 1, $hour = 0, $minute = 0)
{
- if (\is_string($hour)) {
+ if (is_string($hour)) {
$parts = explode(':', $hour);
$hour = $parts[0];
$minute = $parts[1] ?? '0';
@@ -353,11 +359,11 @@ public function december($day = 1, $hour = 0, $minute = 0)
/**
* Validate sequence of cron expression.
*
- * @param int|string $minute
- * @param int|string $hour
- * @param int|string $day
- * @param int|string $month
- * @param int|string $weekday
+ * @param int|string|null $minute
+ * @param int|string|null $hour
+ * @param int|string|null $day
+ * @param int|string|null $month
+ * @param int|string|null $weekday
* @return array
*/
private function validateCronSequence($minute = null, $hour = null, $day = null, $month = null, $weekday = null)
@@ -374,7 +380,7 @@ private function validateCronSequence($minute = null, $hour = null, $day = null,
/**
* Validate sequence of cron expression.
*
- * @param int|string $value
+ * @param int|string|null $value
* @param int $min
* @param int $max
* @return mixed
@@ -388,7 +394,7 @@ private function validateCronRange($value, $min, $max)
if (! is_numeric($value) ||
! ($value >= $min && $value <= $max)
) {
- throw new \InvalidArgumentException(
+ throw new InvalidArgumentException(
"Invalid value: it should be '*' or between {$min} and {$max}."
);
}
@@ -396,4 +402,3 @@ private function validateCronRange($value, $min, $max)
return $value;
}
}
-
diff --git a/system/src/Grav/Common/Scheduler/Job.php b/system/src/Grav/Common/Scheduler/Job.php
index 6250f1d62d..41561192ec 100644
--- a/system/src/Grav/Common/Scheduler/Job.php
+++ b/system/src/Grav/Common/Scheduler/Job.php
@@ -3,42 +3,79 @@
/**
* @package Grav\Common\Scheduler
* @author Originally based on peppeocchi/php-cron-scheduler modified for Grav integration
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
+use Closure;
use Cron\CronExpression;
+use DateTime;
use Grav\Common\Grav;
+use InvalidArgumentException;
+use RuntimeException;
use Symfony\Component\Process\Process;
+use function call_user_func;
+use function call_user_func_array;
+use function count;
+use function is_array;
+use function is_callable;
+use function is_string;
+/**
+ * Class Job
+ * @package Grav\Common\Scheduler
+ */
class Job
{
use IntervalTrait;
+ /** @var string */
private $id;
- private $enabled = true;
+ /** @var bool */
+ private $enabled;
+ /** @var callable|string */
private $command;
+ /** @var string */
private $at;
+ /** @var array */
private $args = [];
+ /** @var bool */
private $runInBackground = true;
+ /** @var DateTime */
private $creationTime;
+ /** @var CronExpression */
private $executionTime;
+ /** @var string */
private $tempDir;
+ /** @var string */
private $lockFile;
+ /** @var bool */
private $truthTest = true;
+ /** @var string */
private $output;
+ /** @var int */
private $returnCode = 0;
+ /** @var array */
private $outputTo = [];
+ /** @var array */
private $emailTo = [];
+ /** @var array */
private $emailConfig = [];
+ /** @var callable|null */
private $before;
+ /** @var callable|null */
private $after;
+ /** @var callable */
private $whenOverlapping;
+ /** @var string */
private $outputMode;
+ /** @var Process|null $process */
private $process;
+ /** @var bool */
private $successful = false;
+ /** @var string|null */
private $backlink;
/**
@@ -46,7 +83,7 @@ class Job
*
* @param string|callable $command
* @param array $args
- * @param string $id
+ * @param string|null $id
*/
public function __construct($command, $args = [], $id = null)
{
@@ -60,7 +97,7 @@ public function __construct($command, $args = [], $id = null)
$this->id = spl_object_hash($command);
}
}
- $this->creationTime = new \DateTime('now');
+ $this->creationTime = new DateTime('now');
// initialize the directory path for lock files
$this->tempDir = sys_get_temp_dir();
$this->command = $command;
@@ -73,7 +110,7 @@ public function __construct($command, $args = [], $id = null)
/**
* Get the command
*
- * @return string
+ * @return Closure|string
*/
public function getCommand()
{
@@ -107,13 +144,16 @@ public function getEnabled()
*/
public function getArguments()
{
- if (\is_string($this->args)) {
+ if (is_string($this->args)) {
return $this->args;
}
return null;
}
+ /**
+ * @return CronExpression
+ */
public function getCronExpression()
{
return CronExpression::factory($this->at);
@@ -145,10 +185,10 @@ public function getId()
* the job is due. Defaults to job creation time.
* It also default the execution time if not previously defined.
*
- * @param \DateTime $date
+ * @param DateTime|null $date
* @return bool
*/
- public function isDue(\DateTime $date = null)
+ public function isDue(DateTime $date = null)
{
// The execution time is being defaulted if not defined
if (!$this->executionTime) {
@@ -187,9 +227,8 @@ public function inForeground()
/**
* Sets/Gets an option backlink
*
- * @param string $link
- *
- * @return null|string
+ * @param string|null $link
+ * @return string|null
*/
public function backlink($link = null)
{
@@ -216,8 +255,8 @@ public function runInBackground()
* being executed if the previous is still running.
* The job id is used as a filename for the lock file.
*
- * @param string $tempDir The directory path for the lock files
- * @param callable $whenOverlapping A callback to ignore job overlapping
+ * @param string|null $tempDir The directory path for the lock files
+ * @param callable|null $whenOverlapping A callback to ignore job overlapping
* @return self
*/
public function onlyOne($tempDir = null, callable $whenOverlapping = null)
@@ -232,7 +271,7 @@ public function onlyOne($tempDir = null, callable $whenOverlapping = null)
if ($whenOverlapping) {
$this->whenOverlapping = $whenOverlapping;
} else {
- $this->whenOverlapping = function () {
+ $this->whenOverlapping = static function () {
return false;
};
}
@@ -298,8 +337,8 @@ public function run()
if (is_callable($this->command)) {
$this->output = $this->exec();
} else {
- $args = \is_string($this->args) ? $this->args : implode(' ', $this->args);
- $command = $this->command . ' ' . $args;
+ $args = is_string($this->args) ? explode(' ', $this->args) : $this->args;
+ $command = array_merge([$this->command], $args);
$process = new Process($command);
$this->process = $process;
@@ -322,7 +361,6 @@ public function run()
*/
public function finalize()
{
- /** @var Process $process */
$process = $this->process;
if ($process) {
@@ -344,13 +382,17 @@ public function finalize()
/**
* Things to run after job has run
+ *
+ * @return void
*/
private function postRun()
{
if (count($this->outputTo) > 0) {
foreach ($this->outputTo as $file) {
$output_mode = $this->outputMode === 'append' ? FILE_APPEND | LOCK_EX : LOCK_EX;
- file_put_contents($file, $this->output, $output_mode);
+ $timestamp = (new DateTime('now'))->format('c');
+ $output = $timestamp . "\n" . str_pad('', strlen($timestamp), '>') . "\n" . $this->output;
+ file_put_contents($file, $output, $output_mode);
}
}
@@ -374,7 +416,7 @@ private function postRun()
private function createLockFile($content = null)
{
if ($this->lockFile) {
- if ($content === null || !\is_string($content)) {
+ if ($content === null || !is_string($content)) {
$content = $this->getId();
}
file_put_contents($this->lockFile, $content);
@@ -396,8 +438,8 @@ private function removeLockFile()
/**
* Execute a callable job.
*
- * @throws \RuntimeException
* @return string
+ * @throws RuntimeException
*/
private function exec()
{
@@ -406,12 +448,15 @@ private function exec()
try {
$return_data = call_user_func_array($this->command, $this->args);
$this->successful = true;
- } catch (\RuntimeException $e) {
+ } catch (RuntimeException $e) {
+ $return_data = $e->getMessage();
$this->successful = false;
}
$this->output = ob_get_clean() . (is_string($return_data) ? $return_data : '');
$this->postRun();
+
+ return $this->output;
}
/**
@@ -450,7 +495,7 @@ public function getOutput()
public function email($email)
{
if (!is_string($email) && !is_array($email)) {
- throw new \InvalidArgumentException('The email can be only string or array');
+ throw new InvalidArgumentException('The email can be only string or array');
}
$this->emailTo = is_array($email) ? $email : [$email];
@@ -519,4 +564,3 @@ public function then(callable $fn, $runInBackground = false)
return $this;
}
}
-
diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php
index df5602ea1d..19eb9eac38 100644
--- a/system/src/Grav/Common/Scheduler/Scheduler.php
+++ b/system/src/Grav/Common/Scheduler/Scheduler.php
@@ -3,33 +3,51 @@
/**
* @package Grav\Common\Scheduler
* @author Originally based on peppeocchi/php-cron-scheduler modified for Grav integration
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
+use DateTime;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Utils;
+use InvalidArgumentException;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
use RocketTheme\Toolbox\File\YamlFile;
+use function is_callable;
+use function is_string;
+/**
+ * Class Scheduler
+ * @package Grav\Common\Scheduler
+ */
class Scheduler
{
- /**
- * The queued jobs.
- *
- * @var array
- */
+ /** @var Job[] The queued jobs. */
private $jobs = [];
+
+ /** @var Job[] */
private $saved_jobs = [];
+
+ /** @var Job[] */
private $executed_jobs = [];
+
+ /** @var Job[] */
private $failed_jobs = [];
+
+ /** @var Job[] */
private $jobs_run = [];
+
+ /** @var array */
private $output_schedule = [];
+
+ /** @var array */
private $config;
+
+ /** @var string */
private $status_path;
/**
@@ -44,11 +62,12 @@ public function __construct()
if (!file_exists($this->status_path)) {
Folder::create($this->status_path);
}
-
}
/**
* Load saved jobs from config/scheduler.yaml file
+ *
+ * @return $this
*/
public function loadSavedJobs()
{
@@ -65,7 +84,7 @@ public function loadSavedJobs()
}
if (isset($j['output'])) {
- $mode = isset($j['output_mode']) && $j['output_mode'] === 'append' ? true : false;
+ $mode = isset($j['output_mode']) && $j['output_mode'] === 'append';
$job->output($j['output'], $mode);
}
@@ -98,7 +117,6 @@ public function getQueuedJobs($all = false)
$foreground[] = $job;
}
}
-
}
return [$background, $foreground];
}
@@ -106,21 +124,38 @@ public function getQueuedJobs($all = false)
/**
* Get all jobs if they are disabled or not as one array
*
- * @return array
+ * @return Job[]
*/
public function getAllJobs()
{
- list($background, $foreground) = $this->loadSavedJobs()->getQueuedJobs(true);
+ [$background, $foreground] = $this->loadSavedJobs()->getQueuedJobs(true);
return array_merge($background, $foreground);
}
+ /**
+ * Get a specific Job based on id
+ *
+ * @param string $jobid
+ * @return Job|null
+ */
+ public function getJob($jobid)
+ {
+ $all = $this->getAllJobs();
+ foreach ($all as $job) {
+ if ($jobid == $job->getId()) {
+ return $job;
+ }
+ }
+ return null;
+ }
+
/**
* Queues a PHP function execution.
*
* @param callable $fn The function to execute
* @param array $args Optional arguments to pass to the php script
- * @param string $id Optional custom identifier
+ * @param string|null $id Optional custom identifier
* @return Job
*/
public function addFunction(callable $fn, $args = [], $id = null)
@@ -136,7 +171,7 @@ public function addFunction(callable $fn, $args = [], $id = null)
*
* @param string $command The command to execute
* @param array $args Optional arguments to pass to the command
- * @param string $id Optional custom identifier
+ * @param string|null $id Optional custom identifier
* @return Job
*/
public function addCommand($command, $args = [], $id = null)
@@ -150,29 +185,30 @@ public function addCommand($command, $args = [], $id = null)
/**
* Run the scheduler.
*
- * @param \DateTime|null $runTime Optional, run at specific moment
+ * @param DateTime|null $runTime Optional, run at specific moment
+ * @param bool $force force run even if not due
*/
- public function run(\DateTime $runTime = null)
+ public function run(DateTime $runTime = null, $force = false)
{
$this->loadSavedJobs();
- list($background, $foreground) = $this->getQueuedJobs(false);
+ [$background, $foreground] = $this->getQueuedJobs(false);
$alljobs = array_merge($background, $foreground);
if (null === $runTime) {
- $runTime = new \DateTime('now');
+ $runTime = new DateTime('now');
}
// Star processing jobs
foreach ($alljobs as $job) {
- if ($job->isDue($runTime)) {
+ if ($job->isDue($runTime) || $force) {
$job->run();
$this->jobs_run[] = $job;
}
}
// Finish handling any background jobs
- foreach($background as $job) {
+ foreach ($background as $job) {
$job->finalize();
}
@@ -184,6 +220,8 @@ public function run(\DateTime $runTime = null)
* Reset all collected data of last run.
*
* Call before run() if you call run() multiple times.
+ *
+ * @return $this
*/
public function resetRun()
{
@@ -199,7 +237,7 @@ public function resetRun()
* Get the scheduler verbose output.
*
* @param string $type Allowed: text, html, array
- * @return mixed The return depends on the requested $type
+ * @return string|array The return depends on the requested $type
*/
public function getVerboseOutput($type = 'text')
{
@@ -211,12 +249,14 @@ public function getVerboseOutput($type = 'text')
case 'array':
return $this->output_schedule;
default:
- throw new \InvalidArgumentException('Invalid output type');
+ throw new InvalidArgumentException('Invalid output type');
}
}
/**
* Remove all queued Jobs.
+ *
+ * @return $this
*/
public function clearJobs()
{
@@ -231,28 +271,44 @@ public function clearJobs()
* @return string
*/
public function getCronCommand()
+ {
+ $command = $this->getSchedulerCommand();
+
+ return "(crontab -l; echo \"* * * * * {$command} 1>> /dev/null 2>&1\") | crontab -";
+ }
+
+ /**
+ * @param string|null $php
+ * @return string
+ */
+ public function getSchedulerCommand($php = null)
{
$phpBinaryFinder = new PhpExecutableFinder();
- $php = $phpBinaryFinder->find();
+ $php = $php ?? $phpBinaryFinder->find();
$command = 'cd ' . str_replace(' ', '\ ', GRAV_ROOT) . ';' . $php . ' bin/grav scheduler';
- return "(crontab -l; echo \"* * * * * {$command} 1>> /dev/null 2>&1\") | crontab -";
+ return $command;
}
/**
* Helper to determine if cron job is setup
+ * 0 - Crontab Not found
+ * 1 - Crontab Found
+ * 2 - Error
*
* @return int
*/
public function isCrontabSetup()
{
- $process = new Process('crontab -l');
+ $process = new Process(['crontab', '-l']);
$process->run();
if ($process->isSuccessful()) {
$output = $process->getOutput();
+ $command = str_replace('/', '\/', $this->getSchedulerCommand('.*'));
+ $full_command = '/^(?!#).* .* .* .* .* ' . $command . '/m';
- return preg_match('$bin\/grav schedule$', $output) ? 1 : 0;
+ return preg_match($full_command, $output) ? 1 : 0;
}
$error = $process->getErrorOutput();
@@ -263,7 +319,7 @@ public function isCrontabSetup()
/**
* Get the Job states file
*
- * @return \RocketTheme\Toolbox\File\FileInterface|YamlFile
+ * @return YamlFile
*/
public function getJobStates()
{
@@ -272,6 +328,8 @@ public function getJobStates()
/**
* Save job states to statys file
+ *
+ * @return void
*/
private function saveJobStates()
{
@@ -292,6 +350,24 @@ private function saveJobStates()
$saved_states->save(array_merge($saved_states->content(), $new_states));
}
+ /**
+ * Try to determine who's running the process
+ *
+ * @return false|string
+ */
+ public function whoami()
+ {
+ $process = new Process('whoami');
+ $process->run();
+
+ if ($process->isSuccessful()) {
+ return trim($process->getOutput());
+ }
+
+ return $process->getErrorOutput();
+ }
+
+
/**
* Queue a job for execution in the correct queue.
*
@@ -313,7 +389,7 @@ private function queueJob(Job $job)
*/
private function addSchedulerVerboseOutput($string)
{
- $now = '[' . (new \DateTime('now'))->format('c') . '] ';
+ $now = '[' . (new DateTime('now'))->format('c') . '] ';
$this->output_schedule[] = $now . $string;
// Print to stdoutput in light gray
// echo "\033[37m{$string}\033[0m\n";
@@ -332,7 +408,7 @@ private function pushExecutedJob(Job $job)
$args = $job->getArguments();
// If callable, log the string Closure
if (is_callable($command)) {
- $command = \is_string($command) ? $command : 'Closure';
+ $command = is_string($command) ? $command : 'Closure';
}
$this->addSchedulerVerboseOutput("Success: {$command} {$args}");
@@ -351,7 +427,7 @@ private function pushFailedJob(Job $job)
$command = $job->getCommand();
// If callable, log the string Closure
if (is_callable($command)) {
- $command = \is_string($command) ? $command : 'Closure';
+ $command = is_string($command) ? $command : 'Closure';
}
$output = trim($job->getOutput());
$this->addSchedulerVerboseOutput("Error: {$command} → {$output}");
diff --git a/system/src/Grav/Common/Security.php b/system/src/Grav/Common/Security.php
index 0f61097956..982618544f 100644
--- a/system/src/Grav/Common/Security.php
+++ b/system/src/Grav/Common/Security.php
@@ -3,20 +3,101 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
+use Exception;
+use Grav\Common\Config\Config;
+use Grav\Common\Filesystem\Folder;
use Grav\Common\Page\Pages;
+use Rhukster\DomSanitizer\DOMSanitizer;
+use function chr;
+use function count;
+use function is_array;
+use function is_string;
+/**
+ * Class Security
+ * @package Grav\Common
+ */
class Security
{
+ /**
+ * @param string $filepath
+ * @param array|null $options
+ * @return string|null
+ */
+ public static function detectXssFromSvgFile(string $filepath, array $options = null): ?string
+ {
+ if (file_exists($filepath) && Grav::instance()['config']->get('security.sanitize_svg')) {
+ $content = file_get_contents($filepath);
+
+ return static::detectXss($content, $options);
+ }
+ return null;
+ }
+
+ /**
+ * Sanitize SVG string for XSS code
+ *
+ * @param string $svg
+ * @return string
+ */
+ public static function sanitizeSvgString(string $svg): string
+ {
+ if (Grav::instance()['config']->get('security.sanitize_svg')) {
+ $sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
+ $sanitized = $sanitizer->sanitize($svg);
+ if (is_string($sanitized)) {
+ $svg = $sanitized;
+ }
+ }
+
+ return $svg;
+ }
+
+ /**
+ * Sanitize SVG for XSS code
+ *
+ * @param string $file
+ * @return void
+ */
+ public static function sanitizeSVG(string $file): void
+ {
+ if (file_exists($file) && Grav::instance()['config']->get('security.sanitize_svg')) {
+ $sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
+ $original_svg = file_get_contents($file);
+ $clean_svg = $sanitizer->sanitize($original_svg);
+
+ // Quarantine bad SVG files and throw exception
+ if ($clean_svg !== false ) {
+ file_put_contents($file, $clean_svg);
+ } else {
+ $quarantine_file = Utils::basename($file);
+ $quarantine_dir = 'log://quarantine';
+ Folder::mkdir($quarantine_dir);
+ file_put_contents("$quarantine_dir/$quarantine_file", $original_svg);
+ unlink($file);
+ throw new Exception('SVG could not be sanitized, it has been moved to the logs/quarantine folder');
+ }
+ }
+ }
+
+ /**
+ * Detect XSS code in Grav pages
+ *
+ * @param Pages $pages
+ * @param bool $route
+ * @param callable|null $status
+ * @return array
+ */
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['/']);
@@ -29,32 +110,26 @@ public static function detectXssFromPages(Pages $pages, $route = true, callable
'steps' => count($routes),
]);
- foreach ($routes as $path) {
-
+ foreach (array_keys($routes) as $route) {
$status && $status([
'type' => 'progress',
]);
try {
- $page = $pages->get($path);
-
- // call the content to load/cache it
- $header = (array) $page->header();
- $content = $page->value('content');
+ $page = $pages->find($route);
+ if ($page->exists()) {
+ // call the content to load/cache it
+ $header = (array) $page->header();
+ $content = $page->value('content');
- $data = ['header' => $header, 'content' => $content];
- $results = Security::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) {
+ } catch (Exception $e) {
continue;
}
}
@@ -63,45 +138,67 @@ public static function detectXssFromPages(Pages $pages, $route = true, callable
}
/**
+ * Detect XSS in an array or strings such as $_POST or $_GET
+ *
* @param array $array Array such as $_POST or $_GET
+ * @param array|null $options Extra options to be passed.
* @param string $prefix Prefix for returned values.
* @return array Returns flatten list of potentially dangerous input values, such as 'data.content'.
*/
- public static function detectXssFromArray(array $array, $prefix = '')
+ public static function detectXssFromArray(array $array, string $prefix = '', array $options = null)
{
- $list = [];
+ if (null === $options) {
+ $options = static::getXssDefaults();
+ }
+ $list = [[]];
foreach ($array as $key => $value) {
- if (\is_array($value)) {
- $list[] = static::detectXssFromArray($value, $prefix . $key . '.');
+ if (is_array($value)) {
+ $list[] = static::detectXssFromArray($value, $prefix . $key . '.', $options);
}
- if ($result = static::detectXss($value)) {
+ if ($result = static::detectXss($value, $options)) {
$list[] = [$prefix . $key => $result];
}
}
- if (!empty($list)) {
- return array_merge(...$list);
- }
-
- return $list;
+ return array_merge(...$list);
}
/**
* Determine if string potentially has a XSS attack. This simple function does not catch all XSS and it is likely to
+ *
* return false positives because of it tags all potentially dangerous HTML tags and attributes without looking into
* their content.
*
- * @param string $string The string to run XSS detection logic on
- * @return bool|string Type of XSS vector if the given `$string` may contain XSS, false otherwise.
+ * @param string|null $string The string to run XSS detection logic on
+ * @param array|null $options
+ * @return string|null Type of XSS vector if the given `$string` may contain XSS, false otherwise.
*
* Copies the code from: https://github.com/symphonycms/xssfilter/blob/master/extension.driver.php#L138
*/
- public static function detectXss($string)
+ public static function detectXss($string, array $options = null): ?string
{
// Skip any null or non string values
- if (null === $string || !\is_string($string) || empty($string)) {
- return false;
+ if (null === $string || !is_string($string) || empty($string)) {
+ return null;
+ }
+
+ if (null === $options) {
+ $options = static::getXssDefaults();
+ }
+
+ $enabled_rules = (array)($options['enabled_rules'] ?? null);
+ $dangerous_tags = (array)($options['dangerous_tags'] ?? null);
+ if (!$dangerous_tags) {
+ $enabled_rules['dangerous_tags'] = false;
+ }
+ $invalid_protocols = (array)($options['invalid_protocols'] ?? null);
+ if (!$invalid_protocols) {
+ $enabled_rules['invalid_protocols'] = false;
+ }
+ $enabled_rules = array_filter($enabled_rules, static function ($val) { return !empty($val); });
+ if (!$enabled_rules) {
+ return null;
}
// Keep a copy of the original string before cleaning up
@@ -111,32 +208,27 @@ public static function detectXss($string)
$string = urldecode($string);
// Convert Hexadecimals
- $string = (string)preg_replace_callback('!(|\\\)[xX]([0-9a-fA-F]+);?!u', function($m) {
- return \chr(hexdec($m[2]));
+ $string = (string)preg_replace_callback('!(|\\\)[xX]([0-9a-fA-F]+);?!u', static function ($m) {
+ return chr(hexdec($m[2]));
}, $string);
// Clean up entities
- $string = preg_replace('!(+[0-9]+)!u','$1;', $string);
+ $string = preg_replace('!([0-9]+);?!u', '$1;', $string);
// Decode entities
- $string = html_entity_decode($string, ENT_NOQUOTES, 'UTF-8');
+ $string = html_entity_decode($string, ENT_NOQUOTES | ENT_HTML5, 'UTF-8');
// Strip whitespace characters
- $string = preg_replace('!\s!u','', $string);
-
- $config = Grav::instance()['config'];
-
- $dangerous_tags = array_map('preg_quote', array_map("trim", $config->get('security.xss_dangerous_tags')));
- $invalid_protocols = array_map('preg_quote', array_map("trim", $config->get('security.xss_invalid_protocols')));
- $enabled_rules = $config->get('security.xss_enabled');
+ $string = preg_replace('!\s!u', ' ', $string);
+ $stripped = preg_replace('!\s!u', '', $string);
// Set the patterns we'll test against
$patterns = [
// Match any attribute starting with "on" or xmlns
- 'on_events' => '#(<[^>]+[[a-z\x00-\x20\"\'\/])(\son|\sxmlns)[a-z].*=>?#iUu',
+ 'on_events' => '#(<[^>]+[[a-z\x00-\x20\"\'\/])([\s\/]on|\sxmlns)[a-z].*=>?#iUu',
// Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols
- 'invalid_protocols' => '#(' . implode('|', $invalid_protocols) . '):.*?#iUu',
+ 'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . ')(:|\&\#58)\S.*?#iUu',
// Match -moz-bindings
'moz_binding' => '#-moz-binding[a-z\x00-\x20]*:#u',
@@ -145,21 +237,30 @@ public static function detectXss($string)
'html_inline_styles' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(style=[^>]*(url\:|x\:expression).*)>?#iUu',
// Match potentially dangerous tags
- 'dangerous_tags' => '#*(' . implode('|', $dangerous_tags) . ')[^>]*>?#ui'
+ 'dangerous_tags' => '#*(' . implode('|', array_map('preg_quote', $dangerous_tags, ['#'])) . ')[^>]*>?#ui'
];
-
// Iterate over rules and return label if fail
- foreach ((array) $patterns as $name => $regex) {
- if ($enabled_rules[$name] === true) {
-
- if (preg_match($regex, $string) || preg_match($regex, $orig)) {
+ foreach ($patterns as $name => $regex) {
+ if (!empty($enabled_rules[$name])) {
+ if (preg_match($regex, $string) || preg_match($regex, $stripped) || preg_match($regex, $orig)) {
return $name;
}
-
}
}
- return false;
+ return null;
+ }
+
+ public static function getXssDefaults(): array
+ {
+ /** @var Config $config */
+ $config = Grav::instance()['config'];
+
+ return [
+ 'enabled_rules' => $config->get('security.xss_enabled'),
+ 'dangerous_tags' => array_map('trim', $config->get('security.xss_dangerous_tags')),
+ 'invalid_protocols' => array_map('trim', $config->get('security.xss_invalid_protocols')),
+ ];
}
}
diff --git a/system/src/Grav/Common/Service/AccountsServiceProvider.php b/system/src/Grav/Common/Service/AccountsServiceProvider.php
index e0778bebe9..60f26e5b02 100644
--- a/system/src/Grav/Common/Service/AccountsServiceProvider.php
+++ b/system/src/Grav/Common/Service/AccountsServiceProvider.php
@@ -3,127 +3,155 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Service;
use Grav\Common\Config\Config;
-use Grav\Common\Debugger;
+use Grav\Common\Grav;
+use Grav\Common\Page\Header;
+use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\User\DataUser;
-use Grav\Common\User\FlexUser;
use Grav\Common\User\User;
-use Grav\Framework\File\Formatter\YamlFormatter;
+use Grav\Events\PermissionsRegisterEvent;
+use Grav\Framework\Acl\Permissions;
+use Grav\Framework\Acl\PermissionsReader;
use Grav\Framework\Flex\Flex;
-use Grav\Framework\Flex\FlexDirectory;
+use Grav\Framework\Flex\Interfaces\FlexIndexInterface;
use Pimple\Container;
use Pimple\ServiceProviderInterface;
use RocketTheme\Toolbox\Event\Event;
-use RocketTheme\Toolbox\Event\EventDispatcher;
+use SplFileInfo;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use function define;
+use function defined;
+use function is_array;
+/**
+ * Class AccountsServiceProvider
+ * @package Grav\Common\Service
+ */
class AccountsServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
- $container['accounts'] = function (Container $container) {
- $type = strtolower(defined('GRAV_USER_INSTANCE') ? GRAV_USER_INSTANCE : $container['config']->get('system.accounts.type', 'data'));
- if ($type === 'flex') {
- /** @var Debugger $debugger */
- $debugger = $container['debugger'];
- $debugger->addMessage('User Accounts: Flex Directory');
- return $this->flexAccounts($container);
+ $container['permissions'] = static function (Grav $container) {
+ /** @var Config $config */
+ $config = $container['config'];
+
+ $permissions = new Permissions();
+ $permissions->addTypes($config->get('permissions.types', []));
+
+ $array = $config->get('permissions.actions');
+ if (is_array($array)) {
+ $actions = PermissionsReader::fromArray($array, $permissions->getTypes());
+ $permissions->addActions($actions);
}
- return $this->dataAccounts($container);
+ $event = new PermissionsRegisterEvent($permissions);
+ $container->dispatchEvent($event);
+
+ return $permissions;
+ };
+
+ $container['accounts'] = function (Container $container) {
+ $type = $this->initialize($container);
+
+ return $type === 'flex' ? $this->flexAccounts($container) : $this->regularAccounts($container);
+ };
+
+ $container['user_groups'] = static function (Container $container) {
+ /** @var Flex $flex */
+ $flex = $container['flex'];
+ $directory = $flex->getDirectory('user-groups');
+
+ return $directory ? $directory->getIndex() : null;
};
- $container['users'] = $container->factory(function (Container $container) {
+ $container['users'] = $container->factory(static function (Container $container) {
user_error('Grav::instance()[\'users\'] is deprecated since Grav 1.6, use Grav::instance()[\'accounts\'] instead', E_USER_DEPRECATED);
return $container['accounts'];
});
}
- protected function dataAccounts(Container $container)
+ /**
+ * @param Container $container
+ * @return string
+ */
+ protected function initialize(Container $container): string
{
- if (!defined('GRAV_USER_INSTANCE')) {
- define('GRAV_USER_INSTANCE', 'DATA');
- }
+ $isDefined = defined('GRAV_USER_INSTANCE');
+ $type = strtolower($isDefined ? GRAV_USER_INSTANCE : $container['config']->get('system.accounts.type', 'regular'));
- // Use User class for backwards compatibility.
- return new DataUser\UserCollection(User::class);
- }
+ if ($type === 'flex') {
+ if (!$isDefined) {
+ define('GRAV_USER_INSTANCE', 'FLEX');
+ }
- protected function flexAccounts(Container $container)
- {
- if (!defined('GRAV_USER_INSTANCE')) {
- define('GRAV_USER_INSTANCE', 'FLEX');
+ /** @var EventDispatcher $dispatcher */
+ $dispatcher = $container['events'];
+
+ // Stop /admin/user from working, display error instead.
+ $dispatcher->addListener(
+ 'onAdminPage',
+ static function (Event $event) {
+ $grav = Grav::instance();
+ $admin = $grav['admin'];
+ [$base,$location,] = $admin->getRouteDetails();
+ if ($location !== 'user' || isset($grav['flex_objects'])) {
+ return;
+ }
+
+ /** @var PageInterface $page */
+ $page = $event['page'];
+ $page->init(new SplFileInfo('plugin://admin/pages/admin/error.md'));
+ $page->routable(true);
+ $header = $page->header();
+ $header->title = 'Please install missing plugin';
+ $page->content("## Please install and enable **[Flex Objects]({$base}/plugins/flex-objects)** plugin. It is required to edit **Flex User Accounts**.");
+
+ /** @var Header $header */
+ $header = $page->header();
+ $directory = $grav['accounts']->getFlexDirectory();
+ $menu = $directory->getConfig('admin.menu.list');
+ $header->access = $menu['authorize'] ?? ['admin.super'];
+ },
+ 100000
+ );
+ } elseif (!$isDefined) {
+ define('GRAV_USER_INSTANCE', 'REGULAR');
}
- /** @var Config $config */
- $config = $container['config'];
-
- $options = [
- 'enabled' => true,
- 'data' => [
- 'object' => User::class, // Use User class for backwards compatibility.
- 'collection' => FlexUser\UserCollection::class,
- 'index' => FlexUser\UserIndex::class,
- 'storage' => $this->getFlexStorage($config->get('system.accounts.storage', 'file')),
- 'search' => [
- 'options' => [
- 'contains' => 1
- ],
- 'fields' => [
- 'key',
- 'email'
- ]
- ]
- ]
- ] + ($config->get('plugins.flex-objects.object') ?: []);
-
- $directory = new FlexDirectory('accounts', 'blueprints://user/accounts.yaml', $options);
-
- /** @var EventDispatcher $dispatcher */
- $dispatcher = $container['events'];
- $dispatcher->addListener('onFlexInit', function (Event $event) use ($directory) {
- /** @var Flex $flex */
- $flex = $event['flex'];
- $flex->addDirectory($directory);
- });
-
- return $directory->getIndex();
+ return $type;
}
- protected function getFlexStorage($config)
+ /**
+ * @param Container $container
+ * @return DataUser\UserCollection
+ */
+ protected function regularAccounts(Container $container)
{
- if (\is_array($config)) {
- return $config;
- }
+ // Use User class for backwards compatibility.
+ return new DataUser\UserCollection(User::class);
+ }
- if ($config === 'folder') {
- return [
- 'class' => FlexUser\Storage\UserFolderStorage::class,
- 'options' => [
- 'formatter' => ['class' => YamlFormatter::class],
- 'folder' => 'account://',
- 'pattern' => '{FOLDER}/{KEY:2}/{KEY}/user.yaml',
- 'key' => 'username',
- 'indexed' => true
- ],
- ];
- }
+ /**
+ * @param Container $container
+ * @return FlexIndexInterface|null
+ */
+ protected function flexAccounts(Container $container)
+ {
+ /** @var Flex $flex */
+ $flex = $container['flex'];
+ $directory = $flex->getDirectory('user-accounts');
- return [
- 'class' => FlexUser\Storage\UserFileStorage::class,
- 'options' => [
- 'formatter' => ['class' => YamlFormatter::class],
- 'folder' => 'account://',
- 'pattern' => '{FOLDER}/{KEY}.yaml',
- 'key' => 'storage_key',
- 'indexed' => true
- ],
- ];
+ return $directory ? $directory->getIndex() : null;
}
}
diff --git a/system/src/Grav/Common/Service/AssetsServiceProvider.php b/system/src/Grav/Common/Service/AssetsServiceProvider.php
index 004da9cac6..54a44a1c83 100644
--- a/system/src/Grav/Common/Service/AssetsServiceProvider.php
+++ b/system/src/Grav/Common/Service/AssetsServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,8 +13,16 @@
use Pimple\ServiceProviderInterface;
use Grav\Common\Assets;
+/**
+ * Class AssetsServiceProvider
+ * @package Grav\Common\Service
+ */
class AssetsServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['assets'] = function () {
diff --git a/system/src/Grav/Common/Service/BackupsServiceProvider.php b/system/src/Grav/Common/Service/BackupsServiceProvider.php
index 2c01036e81..58f5021a3a 100644
--- a/system/src/Grav/Common/Service/BackupsServiceProvider.php
+++ b/system/src/Grav/Common/Service/BackupsServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,8 +13,16 @@
use Pimple\Container;
use Pimple\ServiceProviderInterface;
+/**
+ * Class BackupsServiceProvider
+ * @package Grav\Common\Service
+ */
class BackupsServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['backups'] = function () {
diff --git a/system/src/Grav/Common/Service/ConfigServiceProvider.php b/system/src/Grav/Common/Service/ConfigServiceProvider.php
index c874441f1c..e65e228509 100644
--- a/system/src/Grav/Common/Service/ConfigServiceProvider.php
+++ b/system/src/Grav/Common/Service/ConfigServiceProvider.php
@@ -3,12 +3,13 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Service;
+use DirectoryIterator;
use Grav\Common\Config\CompiledBlueprints;
use Grav\Common\Config\CompiledConfig;
use Grav\Common\Config\CompiledLanguages;
@@ -16,13 +17,22 @@
use Grav\Common\Config\ConfigFileFinder;
use Grav\Common\Config\Setup;
use Grav\Common\Language\Language;
+use Grav\Framework\Mime\MimeTypes;
use Pimple\Container;
use Pimple\ServiceProviderInterface;
use RocketTheme\Toolbox\File\YamlFile;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+/**
+ * Class ConfigServiceProvider
+ * @package Grav\Common\Service
+ */
class ConfigServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['setup'] = function ($c) {
@@ -47,6 +57,19 @@ public function register(Container $container)
return $config;
};
+ $container['mime'] = function ($c) {
+ /** @var Config $config */
+ $config = $c['config'];
+ $mimes = $config->get('mime.types', []);
+ foreach ($config->get('media.types', []) as $ext => $media) {
+ if (!empty($media['mime'])) {
+ $mimes[$ext] = array_unique(array_merge([$media['mime']], $mimes[$ext] ?? []));
+ }
+ }
+
+ return MimeTypes::createFromMimes($mimes);
+ };
+
$container['languages'] = function ($c) {
return static::languages($c);
};
@@ -56,6 +79,10 @@ public function register(Container $container)
};
}
+ /**
+ * @param Container $container
+ * @return mixed
+ */
public static function blueprints(Container $container)
{
/** Setup $setup */
@@ -71,6 +98,8 @@ public static function blueprints(Container $container)
$files += (new ConfigFileFinder)->locateFiles($paths);
$paths = $locator->findResources('plugins://');
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'blueprints');
+ $paths = $locator->findResources('themes://');
+ $files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths, 'blueprints');
$blueprints = new CompiledBlueprints($cache, $files, GRAV_ROOT);
@@ -96,9 +125,11 @@ public static function load(Container $container)
$files += (new ConfigFileFinder)->locateFiles($paths);
$paths = $locator->findResources('plugins://');
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths);
+ $paths = $locator->findResources('themes://');
+ $files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths);
$compiled = new CompiledConfig($cache, $files, GRAV_ROOT);
- $compiled->setBlueprints(function() use ($container) {
+ $compiled->setBlueprints(function () use ($container) {
return $container['blueprints'];
});
@@ -108,6 +139,10 @@ public static function load(Container $container)
return $config;
}
+ /**
+ * @param Container $container
+ * @return mixed
+ */
public static function languages(Container $container)
{
/** @var Setup $setup */
@@ -144,14 +179,14 @@ public static function languages(Container $container)
* @param string $folder_path
* @return array
*/
- private static function pluginFolderPaths($plugins, $folder_path)
+ protected static function pluginFolderPaths($plugins, $folder_path)
{
$paths = [];
foreach ($plugins as $path) {
- $iterator = new \DirectoryIterator($path);
+ $iterator = new DirectoryIterator($path);
- /** @var \DirectoryIterator $directory */
+ /** @var DirectoryIterator $directory */
foreach ($iterator as $directory) {
if (!$directory->isDir() || $directory->isDot()) {
continue;
@@ -168,5 +203,4 @@ private static function pluginFolderPaths($plugins, $folder_path)
}
return $paths;
}
-
}
diff --git a/system/src/Grav/Common/Service/ErrorServiceProvider.php b/system/src/Grav/Common/Service/ErrorServiceProvider.php
index fce69ff52f..02d38c9306 100644
--- a/system/src/Grav/Common/Service/ErrorServiceProvider.php
+++ b/system/src/Grav/Common/Service/ErrorServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,8 +13,16 @@
use Pimple\Container;
use Pimple\ServiceProviderInterface;
+/**
+ * Class ErrorServiceProvider
+ * @package Grav\Common\Service
+ */
class ErrorServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['errors'] = new Errors;
diff --git a/system/src/Grav/Common/Service/FilesystemServiceProvider.php b/system/src/Grav/Common/Service/FilesystemServiceProvider.php
index f5547cb23f..1dde5b3e5f 100644
--- a/system/src/Grav/Common/Service/FilesystemServiceProvider.php
+++ b/system/src/Grav/Common/Service/FilesystemServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,8 +13,16 @@
use Pimple\Container;
use Pimple\ServiceProviderInterface;
+/**
+ * Class FilesystemServiceProvider
+ * @package Grav\Common\Service
+ */
class FilesystemServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['filesystem'] = function () {
diff --git a/system/src/Grav/Common/Service/FlexServiceProvider.php b/system/src/Grav/Common/Service/FlexServiceProvider.php
new file mode 100644
index 0000000000..21192926f9
--- /dev/null
+++ b/system/src/Grav/Common/Service/FlexServiceProvider.php
@@ -0,0 +1,121 @@
+ $config->get('system.flex', [])]);
+ FlexFormFlash::setFlex($flex);
+
+ $accountsEnabled = $config->get('system.accounts.type', 'regular') === 'flex';
+ $pagesEnabled = $config->get('system.pages.type', 'regular') === 'flex';
+
+ // Add built-in types from Grav.
+ if ($pagesEnabled) {
+ $flex->addDirectoryType(
+ 'pages',
+ 'blueprints://flex/pages.yaml',
+ [
+ 'enabled' => $pagesEnabled
+ ]
+ );
+ }
+ if ($accountsEnabled) {
+ $flex->addDirectoryType(
+ 'user-accounts',
+ 'blueprints://flex/user-accounts.yaml',
+ [
+ 'enabled' => $accountsEnabled,
+ 'data' => [
+ 'storage' => $this->getFlexAccountsStorage($config),
+ ]
+ ]
+ );
+ $flex->addDirectoryType(
+ 'user-groups',
+ 'blueprints://flex/user-groups.yaml',
+ [
+ 'enabled' => $accountsEnabled
+ ]
+ );
+ }
+
+ // Call event to register Flex Directories.
+ $event = new FlexRegisterEvent($flex);
+ $container->dispatchEvent($event);
+
+ return $flex;
+ };
+ }
+
+ /**
+ * @param Config $config
+ * @return array
+ */
+ private function getFlexAccountsStorage(Config $config): array
+ {
+ $value = $config->get('system.accounts.storage', 'file');
+ if (is_array($value)) {
+ return $value;
+ }
+
+ if ($value === 'folder') {
+ return [
+ 'class' => UserFolderStorage::class,
+ 'options' => [
+ 'file' => 'user',
+ 'pattern' => '{FOLDER}/{KEY:2}/{KEY}/{FILE}{EXT}',
+ 'key' => 'storage_key',
+ 'indexed' => true,
+ 'case_sensitive' => false
+ ],
+ ];
+ }
+
+ if ($value === 'file') {
+ return [
+ 'class' => UserFileStorage::class,
+ 'options' => [
+ 'pattern' => '{FOLDER}/{KEY}{EXT}',
+ 'key' => 'username',
+ 'indexed' => true,
+ 'case_sensitive' => false
+ ],
+ ];
+ }
+
+ return [];
+ }
+}
diff --git a/system/src/Grav/Common/Service/InflectorServiceProvider.php b/system/src/Grav/Common/Service/InflectorServiceProvider.php
index fd695474a4..f43a4461d1 100644
--- a/system/src/Grav/Common/Service/InflectorServiceProvider.php
+++ b/system/src/Grav/Common/Service/InflectorServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,8 +13,16 @@
use Pimple\Container;
use Pimple\ServiceProviderInterface;
+/**
+ * Class InflectorServiceProvider
+ * @package Grav\Common\Service
+ */
class InflectorServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['inflector'] = function () {
diff --git a/system/src/Grav/Common/Service/LoggerServiceProvider.php b/system/src/Grav/Common/Service/LoggerServiceProvider.php
index 85830dee61..43a67cadfb 100644
--- a/system/src/Grav/Common/Service/LoggerServiceProvider.php
+++ b/system/src/Grav/Common/Service/LoggerServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -15,8 +15,16 @@
use Pimple\ServiceProviderInterface;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+/**
+ * Class LoggerServiceProvider
+ * @package Grav\Common\Service
+ */
class LoggerServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['log'] = function ($c) {
diff --git a/system/src/Grav/Common/Service/OutputServiceProvider.php b/system/src/Grav/Common/Service/OutputServiceProvider.php
index a9b2877e96..2dbd3436f1 100644
--- a/system/src/Grav/Common/Service/OutputServiceProvider.php
+++ b/system/src/Grav/Common/Service/OutputServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -14,8 +14,16 @@
use Pimple\Container;
use Pimple\ServiceProviderInterface;
+/**
+ * Class OutputServiceProvider
+ * @package Grav\Common\Service
+ */
class OutputServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['output'] = function ($c) {
diff --git a/system/src/Grav/Common/Service/PagesServiceProvider.php b/system/src/Grav/Common/Service/PagesServiceProvider.php
index 0a37e831d7..fa6631eeec 100644
--- a/system/src/Grav/Common/Service/PagesServiceProvider.php
+++ b/system/src/Grav/Common/Service/PagesServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -17,26 +17,47 @@
use Grav\Common\Uri;
use Pimple\Container;
use Pimple\ServiceProviderInterface;
+use SplFileInfo;
+use function defined;
+/**
+ * Class PagesServiceProvider
+ * @package Grav\Common\Service
+ */
class PagesServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
- $container['pages'] = function ($c) {
- return new Pages($c);
+ $container['pages'] = function (Grav $grav) {
+ return new Pages($grav);
};
- $container['page'] = function ($c) {
- /** @var Grav $c */
+ if (defined('GRAV_CLI')) {
+ $container['page'] = static function (Grav $grav) {
+ $path = $grav['locator']->findResource('system://pages/notfound.md');
+ $page = new Page();
+ $page->init(new SplFileInfo($path));
+ $page->routable(false);
+
+ return $page;
+ };
+
+ return;
+ }
+ $container['page'] = static function (Grav $grav) {
/** @var Pages $pages */
- $pages = $c['pages'];
+ $pages = $grav['pages'];
/** @var Config $config */
- $config = $c['config'];
+ $config = $grav['config'];
/** @var Uri $uri */
- $uri = $c['uri'];
+ $uri = $grav['uri'];
$path = $uri->path() ?: '/'; // Don't trim to support trailing slash default routes
$page = $pages->dispatch($path);
@@ -45,55 +66,70 @@ public function register(Container $container)
if ($page) {
// some debugger override logic
if ($page->debugger() === false) {
- $c['debugger']->enabled(false);
+ $grav['debugger']->enabled(false);
}
if ($config->get('system.force_ssl')) {
- if (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') {
+ $scheme = $uri->scheme(true);
+ if ($scheme !== 'https') {
$url = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
- $c->redirect($url);
+ $grav->redirect($url);
}
}
- $url = $pages->route($page->route());
+ $route = $page->route();
+ if ($route && \in_array($uri->method(), ['GET', 'HEAD'], true)) {
+ $pageExtension = $page->urlExtension();
+ $url = $pages->route($route) . $pageExtension;
+
+ if ($uri->params()) {
+ if ($url === '/') { //Avoid double slash
+ $url = $uri->params();
+ } else {
+ $url .= $uri->params();
+ }
+ }
+ if ($uri->query()) {
+ $url .= '?' . $uri->query();
+ }
+ if ($uri->fragment()) {
+ $url .= '#' . $uri->fragment();
+ }
+
+ /** @var Language $language */
+ $language = $grav['language'];
- if ($uri->params()) {
- if ($url === '/') { //Avoid double slash
- $url = $uri->params();
- } else {
- $url .= $uri->params();
+ $redirect_default_route = $page->header()->redirect_default_route ?? $config->get('system.pages.redirect_default_route', 0);
+ $redirectCode = (int) $redirect_default_route;
+
+ // Language-specific redirection scenarios
+ if ($language->enabled() && ($language->isLanguageInUrl() xor $language->isIncludeDefaultLanguage())) {
+ $grav->redirect($url, $redirectCode);
}
- }
- if ($uri->query()) {
- $url .= '?' . $uri->query();
- }
- if ($uri->fragment()) {
- $url .= '#' . $uri->fragment();
- }
- /** @var Language $language */
- $language = $c['language'];
+ // Default route test and redirect
+ if ($redirectCode) {
+ $uriExtension = $uri->extension();
+ $uriExtension = null !== $uriExtension ? '.' . $uriExtension : '';
- // Language-specific redirection scenarios
- if ($language->enabled() && ($language->isLanguageInUrl() xor $language->isIncludeDefaultLanguage())) {
- $c->redirect($url);
- }
- // Default route test and redirect
- if ($config->get('system.pages.redirect_default_route') && $page->route() !== $path) {
- $c->redirect($url);
+ if ($route !== $path || ($pageExtension !== $uriExtension
+ && \in_array($pageExtension, ['', '.htm', '.html'], true)
+ && \in_array($uriExtension, ['', '.htm', '.html'], true))) {
+ $grav->redirect($url, $redirectCode);
+ }
+ }
}
}
// if page is not found, try some fallback stuff
if (!$page || !$page->routable()) {
-
// Try fallback URL stuff...
- $page = $c->fallbackUrl($path);
+ $page = $grav->fallbackUrl($path);
if (!$page) {
- $path = $c['locator']->findResource('system://pages/notfound.md');
+ $path = $grav['locator']->findResource('system://pages/notfound.md');
$page = new Page();
- $page->init(new \SplFileInfo($path));
+ $page->init(new SplFileInfo($path));
$page->routable(false);
}
}
diff --git a/system/src/Grav/Common/Service/RequestServiceProvider.php b/system/src/Grav/Common/Service/RequestServiceProvider.php
index 6410ef7254..a44c735640 100644
--- a/system/src/Grav/Common/Service/RequestServiceProvider.php
+++ b/system/src/Grav/Common/Service/RequestServiceProvider.php
@@ -3,20 +3,36 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Service;
use Grav\Common\Uri;
+use JsonException;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Pimple\Container;
use Pimple\ServiceProviderInterface;
+use function explode;
+use function fopen;
+use function function_exists;
+use function in_array;
+use function is_array;
+use function strtolower;
+use function trim;
+/**
+ * Class RequestServiceProvider
+ * @package Grav\Common\Service
+ */
class RequestServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['request'] = function () {
@@ -28,10 +44,59 @@ public function register(Container $container)
$psr17Factory // StreamFactory
);
- return $creator->fromGlobals();
+ $server = $_SERVER;
+ if (false === isset($server['REQUEST_METHOD'])) {
+ $server['REQUEST_METHOD'] = 'GET';
+ }
+ $method = $server['REQUEST_METHOD'];
+
+ $headers = function_exists('getallheaders') ? getallheaders() : $creator::getHeadersFromServer($_SERVER);
+
+ $post = null;
+ if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
+ foreach ($headers as $headerName => $headerValue) {
+ if ('content-type' !== strtolower($headerName)) {
+ continue;
+ }
+
+ $contentType = strtolower(trim(explode(';', $headerValue, 2)[0]));
+ switch ($contentType) {
+ case 'application/x-www-form-urlencoded':
+ case 'multipart/form-data':
+ $post = $_POST;
+ break 2;
+ case 'application/json':
+ case 'application/vnd.api+json':
+ try {
+ $json = file_get_contents('php://input');
+ $post = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+ if (!is_array($post)) {
+ $post = null;
+ }
+ } catch (JsonException $e) {
+ $post = null;
+ }
+ break 2;
+ }
+ }
+ }
+
+ // Remove _url from ngnix routes.
+ $get = $_GET;
+ unset($get['_url']);
+ if (isset($server['QUERY_STRING'])) {
+ $query = $server['QUERY_STRING'];
+ if (strpos($query, '_url=') !== false) {
+ parse_str($query, $query);
+ unset($query['_url']);
+ $server['QUERY_STRING'] = http_build_query($query);
+ }
+ }
+
+ return $creator->fromArrays($server, $headers, $_COOKIE, $get, $post, $_FILES, fopen('php://input', 'rb') ?: null);
};
- $container['route'] = $container->factory(function() {
+ $container['route'] = $container->factory(function () {
return clone Uri::getCurrentRoute();
});
}
diff --git a/system/src/Grav/Common/Service/SchedulerServiceProvider.php b/system/src/Grav/Common/Service/SchedulerServiceProvider.php
index 1df4bbcd69..13918fbc2e 100644
--- a/system/src/Grav/Common/Service/SchedulerServiceProvider.php
+++ b/system/src/Grav/Common/Service/SchedulerServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -13,8 +13,16 @@
use Pimple\Container;
use Pimple\ServiceProviderInterface;
+/**
+ * Class SchedulerServiceProvider
+ * @package Grav\Common\Service
+ */
class SchedulerServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['scheduler'] = function () {
diff --git a/system/src/Grav/Common/Service/SessionServiceProvider.php b/system/src/Grav/Common/Service/SessionServiceProvider.php
index 84d23d3547..80e24b4c78 100644
--- a/system/src/Grav/Common/Service/SessionServiceProvider.php
+++ b/system/src/Grav/Common/Service/SessionServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -14,16 +14,24 @@
use Grav\Common\Session;
use Grav\Common\Uri;
use Grav\Common\Utils;
+use Grav\Framework\Session\Messages;
use Pimple\Container;
use Pimple\ServiceProviderInterface;
-use RocketTheme\Toolbox\Session\Message;
+/**
+ * Class SessionServiceProvider
+ * @package Grav\Common\Service
+ */
class SessionServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
// Define session service.
- $container['session'] = function ($c) {
+ $container['session'] = static function ($c) {
/** @var Config $config */
$config = $c['config'];
@@ -32,21 +40,27 @@ public function register(Container $container)
// Get session options.
$enabled = (bool)$config->get('system.session.enabled', false);
- $cookie_secure = (bool)$config->get('system.session.secure', false);
+ $cookie_secure = $config->get('system.session.secure', false)
+ || ($config->get('system.session.secure_https', true) && $uri->scheme(true) === 'https');
$cookie_httponly = (bool)$config->get('system.session.httponly', true);
$cookie_lifetime = (int)$config->get('system.session.timeout', 1800);
+ $cookie_domain = $config->get('system.session.domain');
$cookie_path = $config->get('system.session.path');
+ $cookie_samesite = $config->get('system.session.samesite', 'Lax');
+
+ if (null === $cookie_domain) {
+ $cookie_domain = $uri->host();
+ if ($cookie_domain === 'localhost') {
+ $cookie_domain = '';
+ }
+ }
+
if (null === $cookie_path) {
$cookie_path = '/' . trim(Uri::filterPath($uri->rootUrl(false)), '/');
}
// Session cookie path requires trailing slash.
$cookie_path = rtrim($cookie_path, '/') . '/';
- $cookie_domain = $uri->host();
- if ($cookie_domain === 'localhost') {
- $cookie_domain = '';
- }
-
// Activate admin if we're inside the admin path.
$is_admin = false;
if ($config->get('plugins.admin.enabled')) {
@@ -87,7 +101,8 @@ public function register(Container $container)
'cookie_path' => $cookie_path,
'cookie_domain' => $cookie_domain,
'cookie_secure' => $cookie_secure,
- 'cookie_httponly' => $cookie_httponly
+ 'cookie_httponly' => $cookie_httponly,
+ 'cookie_samesite' => $cookie_samesite
] + (array) $config->get('system.session.options');
$session = new Session($options);
@@ -103,14 +118,14 @@ public function register(Container $container)
$debugger = $c['debugger'];
$debugger->addMessage('Inactive session: session messages may disappear', 'warming');
- return new Message;
+ return new Messages();
}
/** @var Session $session */
$session = $c['session'];
- if (!isset($session->messages)) {
- $session->messages = new Message;
+ if (!$session->messages instanceof Messages) {
+ $session->messages = new Messages();
}
return $session->messages;
diff --git a/system/src/Grav/Common/Service/StreamsServiceProvider.php b/system/src/Grav/Common/Service/StreamsServiceProvider.php
index 3bbbea1d36..f501b49038 100644
--- a/system/src/Grav/Common/Service/StreamsServiceProvider.php
+++ b/system/src/Grav/Common/Service/StreamsServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -17,12 +17,20 @@
use RocketTheme\Toolbox\StreamWrapper\Stream;
use RocketTheme\Toolbox\StreamWrapper\StreamBuilder;
+/**
+ * Class StreamsServiceProvider
+ * @package Grav\Common\Service
+ */
class StreamsServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
- $container['locator'] = function(Container $container) {
- $locator = new UniformResourceLocator(GRAV_ROOT);
+ $container['locator'] = function (Container $container) {
+ $locator = new UniformResourceLocator(GRAV_WEBROOT);
/** @var Setup $setup */
$setup = $container['setup'];
@@ -31,7 +39,7 @@ public function register(Container $container)
return $locator;
};
- $container['streams'] = function(Container $container) {
+ $container['streams'] = function (Container $container) {
/** @var Setup $setup */
$setup = $container['setup'];
diff --git a/system/src/Grav/Common/Service/TaskServiceProvider.php b/system/src/Grav/Common/Service/TaskServiceProvider.php
index c22121a960..2989e9d275 100644
--- a/system/src/Grav/Common/Service/TaskServiceProvider.php
+++ b/system/src/Grav/Common/Service/TaskServiceProvider.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common\Service
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -12,13 +12,26 @@
use Grav\Common\Grav;
use Pimple\Container;
use Pimple\ServiceProviderInterface;
+use Psr\Http\Message\ServerRequestInterface;
+/**
+ * Class TaskServiceProvider
+ * @package Grav\Common\Service
+ */
class TaskServiceProvider implements ServiceProviderInterface
{
+ /**
+ * @param Container $container
+ * @return void
+ */
public function register(Container $container)
{
$container['task'] = function (Grav $c) {
- $task = $_POST['task'] ?? $c['uri']->param('task');
+ /** @var ServerRequestInterface $request */
+ $request = $c['request'];
+ $body = $request->getParsedBody();
+
+ $task = $body['task'] ?? $c['uri']->param('task');
if (null !== $task) {
$task = filter_var($task, FILTER_SANITIZE_STRING);
}
@@ -27,7 +40,11 @@ public function register(Container $container)
};
$container['action'] = function (Grav $c) {
- $action = $_POST['action'] ?? $c['uri']->param('action');
+ /** @var ServerRequestInterface $request */
+ $request = $c['request'];
+ $body = $request->getParsedBody();
+
+ $action = $body['action'] ?? $c['uri']->param('action');
if (null !== $action) {
$action = filter_var($action, FILTER_SANITIZE_STRING);
}
diff --git a/system/src/Grav/Common/Session.php b/system/src/Grav/Common/Session.php
index cd9b2c175b..513143e097 100644
--- a/system/src/Grav/Common/Session.php
+++ b/system/src/Grav/Common/Session.php
@@ -3,14 +3,22 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
use Grav\Common\Form\FormFlash;
+use Grav\Events\SessionStartEvent;
+use Grav\Plugin\Form\Forms;
+use JsonException;
+use function is_string;
+/**
+ * Class Session
+ * @package Grav\Common
+ */
class Session extends \Grav\Framework\Session\Session
{
/** @var bool */
@@ -31,6 +39,8 @@ public static function instance()
* Initialize session.
*
* Code in this function has been moved into SessionServiceProvider class.
+ *
+ * @return void
*/
public function init()
{
@@ -68,7 +78,7 @@ public function all()
/**
* Checks if the session was started.
*
- * @return Boolean
+ * @return bool
* @deprecated 1.5 Use ->isStarted() method instead.
*/
public function started()
@@ -102,7 +112,7 @@ public function getFlashObject($name)
{
$serialized = $this->__get($name);
- $object = \is_string($serialized) ? unserialize($serialized, ['allowed_classes' => true]) : $serialized;
+ $object = is_string($serialized) ? unserialize($serialized, ['allowed_classes' => true]) : $serialized;
$this->__unset($name);
@@ -118,14 +128,14 @@ public function getFlashObject($name)
/** @var Uri $uri */
$uri = $grav['uri'];
- /** @var Grav\Plugin\Form\Forms $form */
- $form = $grav['forms']->getActiveForm();
+ /** @var Forms|null $form */
+ $form = $grav['forms']->getActiveForm(); // @phpstan-ignore-line (form plugin)
$sessionField = base64_encode($uri->url);
- /** @var FormFlash $flash */
- $flash = $form ? $form->getFlash() : null;
- $object = $flash ? [$sessionField => $flash->getLegacyFiles()] : null;
+ /** @var FormFlash|null $flash */
+ $flash = $form ? $form->getFlash() : null; // @phpstan-ignore-line (form plugin)
+ $object = $flash && method_exists($flash, 'getLegacyFiles') ? [$sessionField => $flash->getLegacyFiles()] : null;
}
}
@@ -139,10 +149,11 @@ public function getFlashObject($name)
* @param mixed $object
* @param int $time
* @return $this
+ * @throws JsonException
*/
public function setFlashCookieObject($name, $object, $time = 60)
{
- setcookie($name, json_encode($object), time() + $time, '/');
+ setcookie($name, json_encode($object, JSON_THROW_ON_ERROR), $this->getCookieOptions($time));
return $this;
}
@@ -152,15 +163,28 @@ public function setFlashCookieObject($name, $object, $time = 60)
*
* @param string $name
* @return mixed|null
+ * @throws JsonException
*/
public function getFlashCookieObject($name)
{
if (isset($_COOKIE[$name])) {
- $object = json_decode($_COOKIE[$name]);
- setcookie($name, '', time() - 3600, '/');
- return $object;
+ $cookie = $_COOKIE[$name];
+ setcookie($name, '', $this->getCookieOptions(-42000));
+
+ return json_decode($cookie, false, 512, JSON_THROW_ON_ERROR);
}
return null;
}
+
+ /**
+ * @return void
+ */
+ protected function onSessionStart(): void
+ {
+ $event = new SessionStartEvent($this);
+
+ $grav = Grav::instance();
+ $grav->dispatchEvent($event);
+ }
}
diff --git a/system/src/Grav/Common/Taxonomy.php b/system/src/Grav/Common/Taxonomy.php
index e3236472b7..5079a2572b 100644
--- a/system/src/Grav/Common/Taxonomy.php
+++ b/system/src/Grav/Common/Taxonomy.php
@@ -3,7 +3,7 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
@@ -12,6 +12,7 @@
use Grav\Common\Config\Config;
use Grav\Common\Page\Collection;
use Grav\Common\Page\Interfaces\PageInterface;
+use function is_string;
/**
* The Taxonomy object is a singleton that holds a reference to a 'taxonomy map'. This map is
@@ -32,7 +33,9 @@
*/
class Taxonomy
{
+ /** @var array */
protected $taxonomy_map;
+ /** @var Grav */
protected $grav;
/**
@@ -51,28 +54,60 @@ public function __construct(Grav $grav)
* then adds those taxonomies to the map
*
* @param PageInterface $page the page to process
- * @param array $page_taxonomy
+ * @param array|null $page_taxonomy
*/
public function addTaxonomy(PageInterface $page, $page_taxonomy = null)
{
+ if (!$page->published()) {
+ return;
+ }
+
if (!$page_taxonomy) {
$page_taxonomy = $page->taxonomy();
}
- if (empty($page_taxonomy) || !$page->published()) {
+ if (empty($page_taxonomy)) {
return;
}
/** @var Config $config */
$config = $this->grav['config'];
- if ($config->get('site.taxonomies')) {
- foreach ((array)$config->get('site.taxonomies') as $taxonomy) {
- if (isset($page_taxonomy[$taxonomy])) {
- foreach ((array)$page_taxonomy[$taxonomy] as $item) {
- $this->taxonomy_map[$taxonomy][(string)$item][$page->path()] = ['slug' => $page->slug()];
- }
- }
+ $taxonomies = (array)$config->get('site.taxonomies');
+ foreach ($taxonomies as $taxonomy) {
+ // Skip invalid taxonomies.
+ if (!\is_string($taxonomy)) {
+ continue;
+ }
+ $current = $page_taxonomy[$taxonomy] ?? null;
+ foreach ((array)$current as $item) {
+ $this->iterateTaxonomy($page, $taxonomy, '', $item);
+ }
+ }
+ }
+
+ /**
+ * Iterate through taxonomy fields
+ *
+ * Reduces [taxonomy_type] to dot-notation where necessary
+ *
+ * @param PageInterface $page The Page to process
+ * @param string $taxonomy Taxonomy type to add
+ * @param string $key Taxonomy type to concatenate
+ * @param iterable|string $value Taxonomy value to add or iterate
+ * @return void
+ */
+ public function iterateTaxonomy(PageInterface $page, string $taxonomy, string $key, $value)
+ {
+ if (is_iterable($value)) {
+ foreach ($value as $identifier => $item) {
+ $identifier = "{$key}.{$identifier}";
+ $this->iterateTaxonomy($page, $taxonomy, $identifier, $item);
}
+ } elseif (is_string($value)) {
+ if (!empty($key)) {
+ $taxonomy .= $key;
+ }
+ $this->taxonomy_map[$taxonomy][(string) $value][$page->path()] = ['slug' => $page->slug()];
}
}
@@ -82,7 +117,6 @@ public function addTaxonomy(PageInterface $page, $page_taxonomy = null)
*
* @param array $taxonomies taxonomies to search, eg ['tag'=>['animal','cat']]
* @param string $operator can be 'or' or 'and' (defaults to 'and')
- *
* @return Collection Collection object set to contain matches found in the taxonomy map
*/
public function findTaxonomy($taxonomies, $operator = 'and')
@@ -117,8 +151,7 @@ public function findTaxonomy($taxonomies, $operator = 'and')
/**
* Gets and Sets the taxonomy map
*
- * @param array $var the taxonomy map
- *
+ * @param array|null $var the taxonomy map
* @return array the taxonomy map
*/
public function taxonomy($var = null)
@@ -134,7 +167,6 @@ public function taxonomy($var = null)
* Gets item keys per taxonomy
*
* @param string $taxonomy taxonomy name
- *
* @return array keys of this taxonomy
*/
public function getTaxonomyItemKeys($taxonomy)
diff --git a/system/src/Grav/Common/Theme.php b/system/src/Grav/Common/Theme.php
index dbc589f082..60131292a9 100644
--- a/system/src/Grav/Common/Theme.php
+++ b/system/src/Grav/Common/Theme.php
@@ -3,16 +3,20 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
-use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Config\Config;
use RocketTheme\Toolbox\File\YamlFile;
+use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+/**
+ * Class Theme
+ * @package Grav\Common
+ */
class Theme extends Plugin
{
/**
@@ -30,67 +34,54 @@ public function __construct(Grav $grav, Config $config, $name)
/**
* Get configuration of the plugin.
*
- * @return Config
+ * @return array
*/
public function config()
{
- return $this->config["themes.{$this->name}"];
+ return $this->config["themes.{$this->name}"] ?? [];
}
/**
* Persists to disk the theme parameters currently stored in the Grav Config object
*
- * @param string $theme_name The name of the theme whose config it should store.
- *
- * @return true
+ * @param string $name The name of the theme whose config it should store.
+ * @return bool
*/
- public static function saveConfig($theme_name)
+ public static function saveConfig($name)
{
- if (!$theme_name) {
+ if (!$name) {
return false;
}
$grav = Grav::instance();
+
+ /** @var UniformResourceLocator $locator */
$locator = $grav['locator'];
- $filename = 'config://themes/' . $theme_name . '.yaml';
- $file = YamlFile::instance($locator->findResource($filename, true, true));
- $content = $grav['config']->get('themes.' . $theme_name);
+
+ $filename = 'config://themes/' . $name . '.yaml';
+ $file = YamlFile::instance((string)$locator->findResource($filename, true, true));
+ $content = $grav['config']->get('themes.' . $name);
$file->save($content);
$file->free();
+ unset($file);
return true;
}
- /**
- * Override the mergeConfig method to work for themes
- */
- protected function mergeConfig(PageInterface $page, $deep = 'merge', $params = [], $type = 'themes')
- {
- return parent::mergeConfig($page, $deep, $params, $type);
- }
-
- /**
- * Simpler getter for the theme blueprint
- *
- * @return mixed
- */
- public function getBlueprint()
- {
- if (!$this->blueprint) {
- $this->loadBlueprint();
- }
- return $this->blueprint;
- }
-
/**
* Load blueprints.
+ *
+ * @return void
*/
protected function loadBlueprint()
{
if (!$this->blueprint) {
$grav = Grav::instance();
+ /** @var Themes $themes */
$themes = $grav['themes'];
- $this->blueprint = $themes->get($this->name)->blueprints();
+ $data = $themes->get($this->name);
+ \assert($data !== null);
+ $this->blueprint = $data->blueprints();
}
}
}
diff --git a/system/src/Grav/Common/Themes.php b/system/src/Grav/Common/Themes.php
index 6720d462dc..3567ccadd6 100644
--- a/system/src/Grav/Common/Themes.php
+++ b/system/src/Grav/Common/Themes.php
@@ -3,28 +3,39 @@
/**
* @package Grav\Common
*
- * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common;
+use DirectoryIterator;
+use Exception;
use Grav\Common\Config\Config;
use Grav\Common\File\CompiledYamlFile;
use Grav\Common\Data\Blueprints;
use Grav\Common\Data\Data;
-use RocketTheme\Toolbox\Event\EventDispatcher;
-use RocketTheme\Toolbox\Event\EventSubscriberInterface;
+use Grav\Framework\Psr7\Response;
+use InvalidArgumentException;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use RuntimeException;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use function defined;
+use function in_array;
+use function strlen;
+/**
+ * Class Themes
+ * @package Grav\Common
+ */
class Themes extends Iterator
{
/** @var Grav */
protected $grav;
-
/** @var Config */
protected $config;
-
+ /** @var bool */
protected $inited = false;
/**
@@ -43,6 +54,9 @@ public function __construct(Grav $grav)
spl_autoload_register([$this, 'autoloadTheme']);
}
+ /**
+ * @return void
+ */
public function init()
{
/** @var Themes $themes */
@@ -52,6 +66,9 @@ public function init()
$this->initTheme();
}
+ /**
+ * @return void
+ */
public function initTheme()
{
if ($this->inited === false) {
@@ -60,17 +77,36 @@ public function initTheme()
try {
$instance = $themes->load();
- } catch (\InvalidArgumentException $e) {
- throw new \RuntimeException($this->current() . ' theme could not be found');
+ } catch (InvalidArgumentException $e) {
+ throw new RuntimeException($this->current() . ' theme could not be found');
}
+ // Register autoloader.
+ if (method_exists($instance, 'autoload')) {
+ $instance->autoload();
+ }
+
+ // Register event listeners.
if ($instance instanceof EventSubscriberInterface) {
/** @var EventDispatcher $events */
$events = $this->grav['events'];
-
$events->addSubscriber($instance);
}
+ // Register blueprints.
+ if (is_dir('theme://blueprints/pages')) {
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+ $locator->addPath('blueprints', '', ['theme://blueprints'], ['user', 'blueprints']);
+ }
+
+ // Register form fields.
+ if (method_exists($instance, 'getFormFieldTypes')) {
+ /** @var Plugins $plugins */
+ $plugins = $this->grav['plugins'];
+ $plugins->formFieldTypes = $instance->getFormFieldTypes() + $plugins->formFieldTypes;
+ }
+
$this->grav['theme'] = $instance;
$this->grav->fireEvent('onThemeInitialized');
@@ -93,7 +129,7 @@ public function all()
$iterator = $locator->getIterator('themes://');
- /** @var \DirectoryIterator $directory */
+ /** @var DirectoryIterator $directory */
foreach ($iterator as $directory) {
if (!$directory->isDir() || $directory->isDot()) {
continue;
@@ -103,8 +139,8 @@ public function all()
try {
$result = $this->get($theme);
- } catch (\Exception $e) {
- $exception = new \RuntimeException(sprintf('Theme %s: %s', $theme, $e->getMessage()), $e->getCode(), $e);
+ } catch (Exception $e) {
+ $exception = new RuntimeException(sprintf('Theme %s: %s', $theme, $e->getMessage()), $e->getCode(), $e);
/** @var Debugger $debugger */
$debugger = $this->grav['debugger'];
@@ -118,7 +154,7 @@ public function all()
$list[$theme] = $result;
}
}
- ksort($list);
+ ksort($list, SORT_NATURAL | SORT_FLAG_CASE);
return $list;
}
@@ -127,14 +163,13 @@ public function all()
* Get theme configuration or throw exception if it cannot be found.
*
* @param string $name
- *
- * @return Data
- * @throws \RuntimeException
+ * @return Data|null
+ * @throws RuntimeException
*/
public function get($name)
{
if (!$name) {
- throw new \RuntimeException('Theme name not provided.');
+ throw new RuntimeException('Theme name not provided.');
}
$blueprints = new Blueprints('themes://');
@@ -189,47 +224,55 @@ public function load()
$grav = $this->grav;
$config = $this->config;
$name = $this->current();
+ $class = null;
/** @var UniformResourceLocator $locator */
$locator = $grav['locator'];
- $file = $locator('theme://theme.php') ?: $locator("theme://{$name}.php");
-
- $inflector = $grav['inflector'];
+ // Start by attempting to load the theme.php file.
+ $file = $locator('theme://theme.php') ?: $locator("theme://{$name}.php");
if ($file) {
// Local variables available in the file: $grav, $config, $name, $file
$class = include $file;
+ if (!\is_object($class) || !is_subclass_of($class, Theme::class, true)) {
+ $class = null;
+ }
+ } elseif (!$locator('theme://') && !defined('GRAV_CLI')) {
+ $response = new Response(500, [], "Theme '$name' does not exist, unable to display page.");
- if (!is_object($class)) {
- $themeClassFormat = [
- 'Grav\\Theme\\' . ucfirst($name),
- 'Grav\\Theme\\' . $inflector->camelize($name)
- ];
+ $grav->close($response);
+ }
- foreach ($themeClassFormat as $themeClass) {
- if (class_exists($themeClass)) {
- $class = new $themeClass($grav, $config, $name);
- break;
- }
+ // If the class hasn't been initialized yet, guess the class name and create a new instance.
+ if (null === $class) {
+ $themeClassFormat = [
+ 'Grav\\Theme\\' . Inflector::camelize($name),
+ 'Grav\\Theme\\' . ucfirst($name)
+ ];
+
+ foreach ($themeClassFormat as $themeClass) {
+ if (is_subclass_of($themeClass, Theme::class, true)) {
+ $class = new $themeClass($grav, $config, $name);
+ break;
}
}
- } elseif (!$locator('theme://') && !defined('GRAV_CLI')) {
- exit("Theme '$name' does not exist, unable to display page.");
}
- $this->config->set('theme', $config->get('themes.' . $name));
-
- if (empty($class)) {
+ // Finally if everything else fails, just create a new instance from the default Theme class.
+ if (null === $class) {
$class = new Theme($grav, $config, $name);
}
+ $this->config->set('theme', $config->get('themes.' . $name));
+
return $class;
}
/**
* Configure and prepare streams for current template.
*
- * @throws \InvalidArgumentException
+ * @return void
+ * @throws InvalidArgumentException
*/
public function configure()
{
@@ -261,7 +304,7 @@ public function configure()
}
}
- if (\in_array($scheme, $registered, true)) {
+ if (in_array($scheme, $registered, true)) {
stream_wrapper_unregister($scheme);
}
$type = !empty($config['type']) ? $config['type'] : 'ReadOnlyStream';
@@ -270,7 +313,7 @@ public function configure()
}
if (!stream_wrapper_register($scheme, $type)) {
- throw new \InvalidArgumentException("Stream '{$type}' could not be initialized.");
+ throw new InvalidArgumentException("Stream '{$type}' could not be initialized.");
}
}
@@ -283,6 +326,7 @@ public function configure()
*
* @param string $name Theme name
* @param Config $config Configuration class
+ * @return void
*/
protected function loadConfiguration($name, Config $config)
{
@@ -292,8 +336,10 @@ protected function loadConfiguration($name, Config $config)
/**
* Load theme languages.
+ * Reads ALL language files from theme stream and merges them.
*
* @param Config $config Configuration class
+ * @return void
*/
protected function loadLanguages(Config $config)
{
@@ -301,17 +347,15 @@ protected function loadLanguages(Config $config)
$locator = $this->grav['locator'];
if ($config->get('system.languages.translations', true)) {
- $language_file = $locator->findResource('theme://languages' . YAML_EXT);
- if ($language_file) {
+ $language_files = array_reverse($locator->findResources('theme://languages' . YAML_EXT));
+ foreach ($language_files as $language_file) {
$language = CompiledYamlFile::instance($language_file)->content();
$this->grav['languages']->mergeRecursive($language);
}
- $languages_folder = $locator->findResource('theme://languages');
- if (file_exists($languages_folder)) {
+ $languages_folders = array_reverse($locator->findResources('theme://languages'));
+ foreach ($languages_folders as $languages_folder) {
$languages = [];
- $iterator = new \DirectoryIterator($languages_folder);
-
- /** @var \DirectoryIterator $directory */
+ $iterator = new DirectoryIterator($languages_folder);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'yaml') {
continue;
@@ -327,8 +371,7 @@ protected function loadLanguages(Config $config)
* Autoload theme classes for inheritance
*
* @param string $class Class name
- *
- * @return mixed false FALSE if unable to load $class; Class name if
+ * @return mixed|false FALSE if unable to load $class; Class name if
* $class is successfully loaded
*/
protected function autoloadTheme($class)
@@ -357,7 +400,10 @@ protected function autoloadTheme($class)
}
// Try Old style theme classes
- $path = strtolower(preg_replace('#\\\|_(?!.+\\\)#', '/', $class));
+ $path = preg_replace('#\\\|_(?!.+\\\)#', '/', $class);
+ \assert(null !== $path);
+
+ $path = strtolower($path);
$file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php");
// Load class
diff --git a/system/src/Grav/Common/Twig/Exception/TwigException.php b/system/src/Grav/Common/Twig/Exception/TwigException.php
new file mode 100644
index 0000000000..7605de4c47
--- /dev/null
+++ b/system/src/Grav/Common/Twig/Exception/TwigException.php
@@ -0,0 +1,21 @@
+locator = Grav::instance()['locator'];
+ }
+
+ /**
+ * @return TwigFilter[]
+ */
+ public function getFilters()
+ {
+ return [
+ new TwigFilter('file_exists', [$this, 'file_exists']),
+ new TwigFilter('fileatime', [$this, 'fileatime']),
+ new TwigFilter('filectime', [$this, 'filectime']),
+ new TwigFilter('filemtime', [$this, 'filemtime']),
+ new TwigFilter('filesize', [$this, 'filesize']),
+ new TwigFilter('filetype', [$this, 'filetype']),
+ new TwigFilter('is_dir', [$this, 'is_dir']),
+ new TwigFilter('is_file', [$this, 'is_file']),
+ new TwigFilter('is_link', [$this, 'is_link']),
+ new TwigFilter('is_readable', [$this, 'is_readable']),
+ new TwigFilter('is_writable', [$this, 'is_writable']),
+ new TwigFilter('is_writeable', [$this, 'is_writable']),
+ new TwigFilter('lstat', [$this, 'lstat']),
+ new TwigFilter('getimagesize', [$this, 'getimagesize']),
+ new TwigFilter('exif_read_data', [$this, 'exif_read_data']),
+ new TwigFilter('read_exif_data', [$this, 'exif_read_data']),
+ new TwigFilter('exif_imagetype', [$this, 'exif_imagetype']),
+ new TwigFilter('hash_file', [$this, 'hash_file']),
+ new TwigFilter('hash_hmac_file', [$this, 'hash_hmac_file']),
+ new TwigFilter('md5_file', [$this, 'md5_file']),
+ new TwigFilter('sha1_file', [$this, 'sha1_file']),
+ new TwigFilter('get_meta_tags', [$this, 'get_meta_tags']),
+ new TwigFilter('pathinfo', [$this, 'pathinfo']),
+ ];
+ }
+
+ /**
+ * Return a list of all functions.
+ *
+ * @return TwigFunction[]
+ */
+ public function getFunctions()
+ {
+ return [
+ new TwigFunction('file_exists', [$this, 'file_exists']),
+ new TwigFunction('fileatime', [$this, 'fileatime']),
+ new TwigFunction('filectime', [$this, 'filectime']),
+ new TwigFunction('filemtime', [$this, 'filemtime']),
+ new TwigFunction('filesize', [$this, 'filesize']),
+ new TwigFunction('filetype', [$this, 'filetype']),
+ new TwigFunction('is_dir', [$this, 'is_dir']),
+ new TwigFunction('is_file', [$this, 'is_file']),
+ new TwigFunction('is_link', [$this, 'is_link']),
+ new TwigFunction('is_readable', [$this, 'is_readable']),
+ new TwigFunction('is_writable', [$this, 'is_writable']),
+ new TwigFunction('is_writeable', [$this, 'is_writable']),
+ new TwigFunction('lstat', [$this, 'lstat']),
+ new TwigFunction('getimagesize', [$this, 'getimagesize']),
+ new TwigFunction('exif_read_data', [$this, 'exif_read_data']),
+ new TwigFunction('read_exif_data', [$this, 'exif_read_data']),
+ new TwigFunction('exif_imagetype', [$this, 'exif_imagetype']),
+ new TwigFunction('hash_file', [$this, 'hash_file']),
+ new TwigFunction('hash_hmac_file', [$this, 'hash_hmac_file']),
+ new TwigFunction('md5_file', [$this, 'md5_file']),
+ new TwigFunction('sha1_file', [$this, 'sha1_file']),
+ new TwigFunction('get_meta_tags', [$this, 'get_meta_tags']),
+ new TwigFunction('pathinfo', [$this, 'pathinfo']),
+ ];
+ }
+
+ /**
+ * @param string $filename
+ * @return bool
+ */
+ public function file_exists($filename): bool
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return file_exists($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return int|false
+ */
+ public function fileatime($filename)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return fileatime($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return int|false
+ */
+ public function filectime($filename)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return filectime($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return int|false
+ */
+ public function filemtime($filename)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return filemtime($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return int|false
+ */
+ public function filesize($filename)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return filesize($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return string|false
+ */
+ public function filetype($filename)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return filetype($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return bool
+ */
+ public function is_dir($filename): bool
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return is_dir($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return bool
+ */
+ public function is_file($filename): bool
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return is_file($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return bool
+ */
+ public function is_link($filename): bool
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return is_link($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return bool
+ */
+ public function is_readable($filename): bool
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return is_readable($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return bool
+ */
+ public function is_writable($filename): bool
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return is_writable($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return array|false
+ */
+ public function lstat($filename)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return lstat($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @return array|false
+ */
+ public function getimagesize($filename)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return getimagesize($filename);
+ }
+
+ /**
+ * @param string $filename
+ * @param string|null $required_sections
+ * @param bool $as_arrays
+ * @param bool $read_thumbnail
+ * @return array|false
+ */
+ public function exif_read_data($filename, ?string $required_sections, bool $as_arrays = false, bool $read_thumbnail = false)
+ {
+ if (!Utils::functionExists('exif_read_data') || !$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return exif_read_data($filename, $required_sections, $as_arrays, $read_thumbnail);
+ }
+
+ /**
+ * @param string $filename
+ * @return int|false
+ */
+ public function exif_imagetype($filename)
+ {
+ if (!Utils::functionExists('exif_imagetype') || !$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return @exif_imagetype($filename);
+ }
+
+ /**
+ * @param string $algo
+ * @param string $filename
+ * @param bool $binary
+ * @return string|false
+ */
+ public function hash_file(string $algo, string $filename, bool $binary = false)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return hash_file($algo, $filename, $binary);
+ }
+
+ /**
+ * @param string $algo
+ * @param string $filename
+ * @param string $key
+ * @param bool $binary
+ * @return string|false
+ */
+ public function hash_hmac_file(string $algo, string $filename, string $key, bool $binary = false)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return hash_hmac_file($algo, $filename, $key, $binary);
+ }
+
+ /**
+ * @param string $filename
+ * @param bool $binary
+ * @return string|false
+ */
+ public function md5_file($filename, bool $binary = false)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return md5_file($filename, $binary);
+ }
+
+ /**
+ * @param string $filename
+ * @param bool $binary
+ * @return string|false
+ */
+ public function sha1_file($filename, bool $binary = false)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return sha1_file($filename, $binary);
+ }
+
+ /**
+ * @param string $filename
+ * @return array|false
+ */
+ public function get_meta_tags($filename)
+ {
+ if (!$this->checkFilename($filename)) {
+ return false;
+ }
+
+ return get_meta_tags($filename);
+ }
+
+ /**
+ * @param string $path
+ * @param int|null $flags
+ * @return string|string[]
+ */
+ public function pathinfo($path, $flags = null)
+ {
+ return Utils::pathinfo($path, $flags);
+ }
+
+ /**
+ * @param string $filename
+ * @return bool
+ */
+ private function checkFilename($filename): bool
+ {
+ return is_string($filename) && (!str_contains($filename, '://') || $this->locator->isStream($filename));
+ }
+}
diff --git a/system/src/Grav/Common/Twig/Extension/GravExtension.php b/system/src/Grav/Common/Twig/Extension/GravExtension.php
new file mode 100644
index 0000000000..64fb315a34
--- /dev/null
+++ b/system/src/Grav/Common/Twig/Extension/GravExtension.php
@@ -0,0 +1,1700 @@
+grav = Grav::instance();
+ $this->debugger = $this->grav['debugger'] ?? null;
+ $this->config = $this->grav['config'];
+ }
+
+ /**
+ * Register some standard globals
+ *
+ * @return array
+ */
+ public function getGlobals(): array
+ {
+ return [
+ 'grav' => $this->grav,
+ ];
+ }
+
+ /**
+ * Return a list of all filters.
+ *
+ * @return array
+ */
+ public function getFilters(): array
+ {
+ return [
+ new TwigFilter('*ize', [$this, 'inflectorFilter']),
+ new TwigFilter('absolute_url', [$this, 'absoluteUrlFilter']),
+ new TwigFilter('contains', [$this, 'containsFilter']),
+ new TwigFilter('chunk_split', [$this, 'chunkSplitFilter']),
+ new TwigFilter('nicenumber', [$this, 'niceNumberFunc']),
+ new TwigFilter('nicefilesize', [$this, 'niceFilesizeFunc']),
+ new TwigFilter('nicetime', [$this, 'nicetimeFunc']),
+ new TwigFilter('defined', [$this, 'definedDefaultFilter']),
+ new TwigFilter('ends_with', [$this, 'endsWithFilter']),
+ new TwigFilter('fieldName', [$this, 'fieldNameFilter']),
+ new TwigFilter('parent_field', [$this, 'fieldParentFilter']),
+ new TwigFilter('ksort', [$this, 'ksortFilter']),
+ new TwigFilter('ltrim', [$this, 'ltrimFilter']),
+ new TwigFilter('markdown', [$this, 'markdownFunction'], ['needs_context' => true, 'is_safe' => ['html']]),
+ new TwigFilter('md5', [$this, 'md5Filter']),
+ new TwigFilter('base32_encode', [$this, 'base32EncodeFilter']),
+ new TwigFilter('base32_decode', [$this, 'base32DecodeFilter']),
+ new TwigFilter('base64_encode', [$this, 'base64EncodeFilter']),
+ new TwigFilter('base64_decode', [$this, 'base64DecodeFilter']),
+ new TwigFilter('randomize', [$this, 'randomizeFilter']),
+ new TwigFilter('modulus', [$this, 'modulusFilter']),
+ new TwigFilter('rtrim', [$this, 'rtrimFilter']),
+ new TwigFilter('pad', [$this, 'padFilter']),
+ new TwigFilter('regex_replace', [$this, 'regexReplace']),
+ new TwigFilter('safe_email', [$this, 'safeEmailFilter'], ['is_safe' => ['html']]),
+ new TwigFilter('safe_truncate', [Utils::class, 'safeTruncate']),
+ new TwigFilter('safe_truncate_html', [Utils::class, 'safeTruncateHTML']),
+ new TwigFilter('sort_by_key', [$this, 'sortByKeyFilter']),
+ new TwigFilter('starts_with', [$this, 'startsWithFilter']),
+ new TwigFilter('truncate', [Utils::class, 'truncate']),
+ new TwigFilter('truncate_html', [Utils::class, 'truncateHTML']),
+ new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']),
+ new TwigFilter('array_unique', 'array_unique'),
+ new TwigFilter('basename', 'basename'),
+ new TwigFilter('dirname', 'dirname'),
+ new TwigFilter('print_r', [$this, 'print_r']),
+ 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]),
+ new TwigFilter('tl', [$this, 'translateLanguage']),
+ new TwigFilter('ta', [$this, 'translateArray']),
+
+ // Casting values
+ new TwigFilter('string', [$this, 'stringFilter']),
+ new TwigFilter('int', [$this, 'intFilter'], ['is_safe' => ['all']]),
+ new TwigFilter('bool', [$this, 'boolFilter']),
+ new TwigFilter('float', [$this, 'floatFilter'], ['is_safe' => ['all']]),
+ new TwigFilter('array', [$this, 'arrayFilter']),
+ new TwigFilter('yaml', [$this, 'yamlFilter']),
+
+ // Object Types
+ new TwigFilter('get_type', [$this, 'getTypeFunc']),
+ new TwigFilter('of_type', [$this, 'ofTypeFunc']),
+
+ // PHP methods
+ new TwigFilter('count', 'count'),
+ new TwigFilter('array_diff', 'array_diff'),
+
+ // Security fix
+ new TwigFilter('filter', [$this, 'filterFilter'], ['needs_environment' => true]),
+ ];
+ }
+
+ /**
+ * Return a list of all functions.
+ *
+ * @return array
+ */
+ public function getFunctions(): array
+ {
+ return [
+ new TwigFunction('array', [$this, 'arrayFilter']),
+ new TwigFunction('array_key_value', [$this, 'arrayKeyValueFunc']),
+ new TwigFunction('array_key_exists', 'array_key_exists'),
+ new TwigFunction('array_unique', 'array_unique'),
+ new TwigFunction('array_intersect', [$this, 'arrayIntersectFunc']),
+ new TwigFunction('array_diff', 'array_diff'),
+ new TwigFunction('authorize', [$this, 'authorize']),
+ new TwigFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),
+ new TwigFunction('dump', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),
+ new TwigFunction('vardump', [$this, 'vardumpFunc']),
+ new TwigFunction('print_r', [$this, 'print_r']),
+ new TwigFunction('http_response_code', 'http_response_code'),
+ new TwigFunction('evaluate', [$this, 'evaluateStringFunc'], ['needs_context' => true]),
+ new TwigFunction('evaluate_twig', [$this, 'evaluateTwigFunc'], ['needs_context' => true]),
+ 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']),
+ new TwigFunction('regex_filter', [$this, 'regexFilter']),
+ new TwigFunction('regex_match', [$this, 'regexMatch']),
+ new TwigFunction('regex_split', [$this, 'regexSplit']),
+ new TwigFunction('string', [$this, 'stringFilter']),
+ new TwigFunction('url', [$this, 'urlFunc']),
+ new TwigFunction('json_decode', [$this, 'jsonDecodeFilter']),
+ new TwigFunction('get_cookie', [$this, 'getCookie']),
+ new TwigFunction('redirect_me', [$this, 'redirectFunc']),
+ new TwigFunction('range', [$this, 'rangeFunc']),
+ new TwigFunction('isajaxrequest', [$this, 'isAjaxFunc']),
+ new TwigFunction('exif', [$this, 'exifFunc']),
+ new TwigFunction('media_directory', [$this, 'mediaDirFunc']),
+ new TwigFunction('body_class', [$this, 'bodyClassFunc'], ['needs_context' => true]),
+ new TwigFunction('theme_var', [$this, 'themeVarFunc'], ['needs_context' => true]),
+ new TwigFunction('header_var', [$this, 'pageHeaderVarFunc'], ['needs_context' => true]),
+ new TwigFunction('read_file', [$this, 'readFileFunc']),
+ new TwigFunction('nicenumber', [$this, 'niceNumberFunc']),
+ new TwigFunction('nicefilesize', [$this, 'niceFilesizeFunc']),
+ new TwigFunction('nicetime', [$this, 'nicetimeFunc']),
+ new TwigFunction('cron', [$this, 'cronFunc']),
+ new TwigFunction('svg_image', [$this, 'svgImageFunction']),
+ new TwigFunction('xss', [$this, 'xssFunc']),
+ new TwigFunction('unique_id', [$this, 'uniqueId']),
+
+ // Translations
+ new TwigFunction('t', [$this, 'translate'], ['needs_environment' => true]),
+ new TwigFunction('tl', [$this, 'translateLanguage']),
+ new TwigFunction('ta', [$this, 'translateArray']),
+
+ // Object Types
+ new TwigFunction('get_type', [$this, 'getTypeFunc']),
+ new TwigFunction('of_type', [$this, 'ofTypeFunc']),
+
+ // PHP methods
+ new TwigFunction('is_numeric', 'is_numeric'),
+ new TwigFunction('is_iterable', 'is_iterable'),
+ new TwigFunction('is_countable', 'is_countable'),
+ new TwigFunction('is_null', 'is_null'),
+ new TwigFunction('is_string', 'is_string'),
+ new TwigFunction('is_array', 'is_array'),
+ new TwigFunction('is_object', 'is_object'),
+ new TwigFunction('count', 'count'),
+ new TwigFunction('array_diff', 'array_diff'),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function getTokenParsers(): array
+ {
+ return [
+ new TwigTokenParserRender(),
+ new TwigTokenParserThrow(),
+ new TwigTokenParserTryCatch(),
+ new TwigTokenParserScript(),
+ new TwigTokenParserStyle(),
+ new TwigTokenParserLink(),
+ new TwigTokenParserMarkdown(),
+ new TwigTokenParserSwitch(),
+ new TwigTokenParserCache(),
+ ];
+ }
+
+ /**
+ * @param mixed $var
+ * @return string
+ */
+ public function print_r($var)
+ {
+ return print_r($var, true);
+ }
+
+ /**
+ * Filters field name by changing dot notation into array notation.
+ *
+ * @param string $str
+ * @return string
+ */
+ public function fieldNameFilter($str)
+ {
+ $path = explode('.', rtrim($str, '.'));
+
+ return array_shift($path) . ($path ? '[' . implode('][', $path) . ']' : '');
+ }
+
+ /**
+ * Filters field name by changing dot notation into array notation.
+ *
+ * @param string $str
+ * @return string
+ */
+ public function fieldParentFilter($str)
+ {
+ $path = explode('.', rtrim($str, '.'));
+ array_pop($path);
+
+ return implode('.', $path);
+ }
+
+ /**
+ * Protects email address.
+ *
+ * @param string $str
+ * @return string
+ */
+ public function safeEmailFilter($str)
+ {
+ static $list = [
+ '"' => '"',
+ "'" => ''',
+ '&' => '&',
+ '<' => '<',
+ '>' => '>',
+ '@' => '@'
+ ];
+
+ $characters = mb_str_split($str, 1, 'UTF-8');
+
+ $encoded = '';
+ foreach ($characters as $chr) {
+ $encoded .= $list[$chr] ?? (random_int(0, 1) ? '' . mb_ord($chr) . ';' : $chr);
+ }
+
+ return $encoded;
+ }
+
+ /**
+ * Returns array in a random order.
+ *
+ * @param array|Traversable $original
+ * @param int $offset Can be used to return only slice of the array.
+ * @return array
+ */
+ public function randomizeFilter($original, $offset = 0)
+ {
+ if ($original instanceof Traversable) {
+ $original = iterator_to_array($original, false);
+ }
+
+ if (!is_array($original)) {
+ return $original;
+ }
+
+ $sorted = [];
+ $random = array_slice($original, $offset);
+ shuffle($random);
+
+ $sizeOf = count($original);
+ for ($x = 0; $x < $sizeOf; $x++) {
+ if ($x < $offset) {
+ $sorted[] = $original[$x];
+ } else {
+ $sorted[] = array_shift($random);
+ }
+ }
+
+ return $sorted;
+ }
+
+ /**
+ * Returns the modulus of an integer
+ *
+ * @param string|int $number
+ * @param int $divider
+ * @param array|null $items array of items to select from to return
+ * @return int
+ */
+ public function modulusFilter($number, $divider, $items = null)
+ {
+ if (is_string($number)) {
+ $number = strlen($number);
+ }
+
+ $remainder = $number % $divider;
+
+ if (is_array($items)) {
+ return $items[$remainder] ?? $items[0];
+ }
+
+ return $remainder;
+ }
+
+ /**
+ * Inflector supports following notations:
+ *
+ * `{{ 'person'|pluralize }} => people`
+ * `{{ 'shoes'|singularize }} => shoe`
+ * `{{ 'welcome page'|titleize }} => "Welcome Page"`
+ * `{{ 'send_email'|camelize }} => SendEmail`
+ * `{{ 'CamelCased'|underscorize }} => camel_cased`
+ * `{{ 'Something Text'|hyphenize }} => something-text`
+ * `{{ 'something_text_to_read'|humanize }} => "Something text to read"`
+ * `{{ '181'|monthize }} => 5`
+ * `{{ '10'|ordinalize }} => 10th`
+ *
+ * @param string $action
+ * @param string $data
+ * @param int|null $count
+ * @return string
+ */
+ public function inflectorFilter($action, $data, $count = null)
+ {
+ $action .= 'ize';
+
+ /** @var Inflector $inflector */
+ $inflector = $this->grav['inflector'];
+
+ if (in_array(
+ $action,
+ ['titleize', 'camelize', 'underscorize', 'hyphenize', 'humanize', 'ordinalize', 'monthize'],
+ true
+ )) {
+ return $inflector->{$action}($data);
+ }
+
+ if (in_array($action, ['pluralize', 'singularize'], true)) {
+ return $count ? $inflector->{$action}($data, $count) : $inflector->{$action}($data);
+ }
+
+ return $data;
+ }
+
+ /**
+ * Return MD5 hash from the input.
+ *
+ * @param string $str
+ * @return string
+ */
+ public function md5Filter($str)
+ {
+ return md5($str);
+ }
+
+ /**
+ * Return Base32 encoded string
+ *
+ * @param string $str
+ * @return string
+ */
+ public function base32EncodeFilter($str)
+ {
+ return Base32::encode($str);
+ }
+
+ /**
+ * Return Base32 decoded string
+ *
+ * @param string $str
+ * @return string
+ */
+ public function base32DecodeFilter($str)
+ {
+ return Base32::decode($str);
+ }
+
+ /**
+ * Return Base64 encoded string
+ *
+ * @param string $str
+ * @return string
+ */
+ public function base64EncodeFilter($str)
+ {
+ return base64_encode($str);
+ }
+
+ /**
+ * Return Base64 decoded string
+ *
+ * @param string $str
+ * @return string|false
+ */
+ public function base64DecodeFilter($str)
+ {
+ return base64_decode($str);
+ }
+
+ /**
+ * Sorts a collection by key
+ *
+ * @param array $input
+ * @param string $filter
+ * @param int $direction
+ * @param int $sort_flags
+ * @return array
+ */
+ public function sortByKeyFilter($input, $filter, $direction = SORT_ASC, $sort_flags = SORT_REGULAR)
+ {
+ return Utils::sortArrayByKey($input, $filter, $direction, $sort_flags);
+ }
+
+ /**
+ * Return ksorted collection.
+ *
+ * @param array|null $array
+ * @return array
+ */
+ public function ksortFilter($array)
+ {
+ if (null === $array) {
+ $array = [];
+ }
+ ksort($array);
+
+ return $array;
+ }
+
+ /**
+ * Wrapper for chunk_split() function
+ *
+ * @param string $value
+ * @param int $chars
+ * @param string $split
+ * @return string
+ */
+ public function chunkSplitFilter($value, $chars, $split = '-')
+ {
+ return chunk_split($value, $chars, $split);
+ }
+
+ /**
+ * determine if a string contains another
+ *
+ * @param string $haystack
+ * @param string $needle
+ * @return string|bool
+ * @todo returning $haystack here doesn't make much sense
+ */
+ public function containsFilter($haystack, $needle)
+ {
+ if (empty($needle)) {
+ return $haystack;
+ }
+
+ return (strpos($haystack, (string) $needle) !== false);
+ }
+
+ /**
+ * Gets a human readable output for cron syntax
+ *
+ * @param string $at
+ * @return string
+ */
+ public function niceCronFilter($at)
+ {
+ $cron = new Cron($at);
+ 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
+ *
+ * @param string $at
+ * @return CronExpression
+ */
+ public function cronFunc($at)
+ {
+ return CronExpression::factory($at);
+ }
+
+ /**
+ * displays a facebook style 'time ago' formatted date/time
+ *
+ * @param string $date
+ * @param bool $long_strings
+ * @param bool $show_tense
+ * @return string
+ */
+ public function nicetimeFunc($date, $long_strings = true, $show_tense = true)
+ {
+ if (empty($date)) {
+ return $this->grav['language']->translate('GRAV.NICETIME.NO_DATE_PROVIDED');
+ }
+
+ if ($long_strings) {
+ $periods = [
+ 'NICETIME.SECOND',
+ 'NICETIME.MINUTE',
+ 'NICETIME.HOUR',
+ 'NICETIME.DAY',
+ 'NICETIME.WEEK',
+ 'NICETIME.MONTH',
+ 'NICETIME.YEAR',
+ 'NICETIME.DECADE'
+ ];
+ } else {
+ $periods = [
+ 'NICETIME.SEC',
+ 'NICETIME.MIN',
+ 'NICETIME.HR',
+ 'NICETIME.DAY',
+ 'NICETIME.WK',
+ 'NICETIME.MO',
+ 'NICETIME.YR',
+ 'NICETIME.DEC'
+ ];
+ }
+
+ $lengths = ['60', '60', '24', '7', '4.35', '12', '10'];
+
+ $now = time();
+
+ // check if unix timestamp
+ if ((string)(int)$date === (string)$date) {
+ $unix_date = $date;
+ } else {
+ $unix_date = strtotime($date);
+ }
+
+ // check validity of date
+ if (empty($unix_date)) {
+ return $this->grav['language']->translate('GRAV.NICETIME.BAD_DATE');
+ }
+
+ // is it future date or past date
+ if ($now > $unix_date) {
+ $difference = $now - $unix_date;
+ $tense = $this->grav['language']->translate('GRAV.NICETIME.AGO');
+ } elseif ($now == $unix_date) {
+ $difference = $now - $unix_date;
+ $tense = $this->grav['language']->translate('GRAV.NICETIME.JUST_NOW');
+ } else {
+ $difference = $unix_date - $now;
+ $tense = $this->grav['language']->translate('GRAV.NICETIME.FROM_NOW');
+ }
+
+ for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths) - 1; $j++) {
+ $difference /= $lengths[$j];
+ }
+
+ $difference = round($difference);
+
+ if ($difference != 1) {
+ $periods[$j] .= '_PLURAL';
+ }
+
+ if ($this->grav['language']->getTranslation(
+ $this->grav['language']->getLanguage(),
+ $periods[$j] . '_MORE_THAN_TWO'
+ )
+ ) {
+ if ($difference > 2) {
+ $periods[$j] .= '_MORE_THAN_TWO';
+ }
+ }
+
+ $periods[$j] = $this->grav['language']->translate('GRAV.'.$periods[$j]);
+
+ if ($now == $unix_date) {
+ return $tense;
+ }
+
+ $time = "{$difference} {$periods[$j]}";
+ $time .= $show_tense ? " {$tense}" : '';
+
+ return $time;
+ }
+
+ /**
+ * Allow quick check of a string for XSS Vulnerabilities
+ *
+ * @param string|array $data
+ * @return bool|string|array
+ */
+ public function xssFunc($data)
+ {
+ if (!is_array($data)) {
+ return Security::detectXss($data);
+ }
+
+ $results = Security::detectXssFromArray($data);
+ $results_parts = array_map(static function ($value, $key) {
+ return $key.': \''.$value . '\'';
+ }, array_values($results), array_keys($results));
+
+ return implode(', ', $results_parts);
+ }
+
+ /**
+ * Generates a random string with configurable length, prefix and suffix.
+ * Unlike the built-in `uniqid()`, this string is non-conflicting and safe
+ *
+ * @param int $length
+ * @param array $options
+ * @return string
+ * @throws \Exception
+ */
+ public function uniqueId(int $length = 9, array $options = ['prefix' => '', 'suffix' => '']): string
+ {
+ return Utils::uniqueId($length, $options);
+ }
+
+ /**
+ * @param string $string
+ * @return string
+ */
+ public function absoluteUrlFilter($string)
+ {
+ $url = $this->grav['uri']->base();
+ $string = preg_replace('/((?:href|src) *= *[\'"](?!(http|ftp)))/i', "$1$url", $string);
+
+ return $string;
+ }
+
+ /**
+ * @param array $context
+ * @param string $string
+ * @param bool $block Block or Line processing
+ * @return string
+ */
+ public function markdownFunction($context, $string, $block = true)
+ {
+ $page = $context['page'] ?? null;
+ return Utils::processMarkdown($string, $block, $page);
+ }
+
+ /**
+ * @param string $haystack
+ * @param string $needle
+ * @return bool
+ */
+ public function startsWithFilter($haystack, $needle)
+ {
+ return Utils::startsWith($haystack, $needle);
+ }
+
+ /**
+ * @param string $haystack
+ * @param string $needle
+ * @return bool
+ */
+ public function endsWithFilter($haystack, $needle)
+ {
+ return Utils::endsWith($haystack, $needle);
+ }
+
+ /**
+ * @param mixed $value
+ * @param null $default
+ * @return mixed|null
+ */
+ public function definedDefaultFilter($value, $default = null)
+ {
+ return $value ?? $default;
+ }
+
+ /**
+ * @param string $value
+ * @param string|null $chars
+ * @return string
+ */
+ public function rtrimFilter($value, $chars = null)
+ {
+ return null !== $chars ? rtrim($value, $chars) : rtrim($value);
+ }
+
+ /**
+ * @param string $value
+ * @param string|null $chars
+ * @return string
+ */
+ public function ltrimFilter($value, $chars = null)
+ {
+ return null !== $chars ? ltrim($value, $chars) : ltrim($value);
+ }
+
+ /**
+ * Returns a string from a value. If the value is array, return it json encoded
+ *
+ * @param mixed $value
+ * @return string
+ */
+ public function stringFilter($value)
+ {
+ // Format the array as a string
+ if (is_array($value)) {
+ return json_encode($value);
+ }
+
+ // Boolean becomes '1' or '0'
+ if (is_bool($value)) {
+ $value = (int)$value;
+ }
+
+ // Cast the other values to string.
+ return (string)$value;
+ }
+
+ /**
+ * Casts input to int.
+ *
+ * @param mixed $input
+ * @return int
+ */
+ public function intFilter($input)
+ {
+ return (int) $input;
+ }
+
+ /**
+ * Casts input to bool.
+ *
+ * @param mixed $input
+ * @return bool
+ */
+ public function boolFilter($input)
+ {
+ return (bool) $input;
+ }
+
+ /**
+ * Casts input to float.
+ *
+ * @param mixed $input
+ * @return float
+ */
+ public function floatFilter($input)
+ {
+ return (float) $input;
+ }
+
+ /**
+ * Casts input to array.
+ *
+ * @param mixed $input
+ * @return array
+ */
+ public function arrayFilter($input)
+ {
+ if (is_array($input)) {
+ return $input;
+ }
+
+ if (is_object($input)) {
+ if (method_exists($input, 'toArray')) {
+ return $input->toArray();
+ }
+
+ if ($input instanceof Iterator) {
+ return iterator_to_array($input);
+ }
+ }
+
+ return (array)$input;
+ }
+
+ /**
+ * @param array|object $value
+ * @param int|null $inline
+ * @param int|null $indent
+ * @return string
+ */
+ public function yamlFilter($value, $inline = null, $indent = null): string
+ {
+ return Yaml::dump($value, $inline, $indent);
+ }
+
+ /**
+ * @param Environment $twig
+ * @return string
+ */
+ public function translate(Environment $twig, ...$args)
+ {
+ // If admin and tu filter provided, use it
+ if (isset($this->grav['admin'])) {
+ $numargs = count($args);
+ $lang = null;
+
+ if (($numargs === 3 && is_array($args[1])) || ($numargs === 2 && !is_array($args[1]))) {
+ $lang = array_pop($args);
+ /** @var Language $language */
+ $language = $this->grav['language'];
+ if (is_string($lang) && !$language->getLanguageCode($lang)) {
+ $args[] = $lang;
+ $lang = null;
+ }
+ } elseif ($numargs === 2 && is_array($args[1])) {
+ $subs = array_pop($args);
+ $args = array_merge($args, $subs);
+ }
+
+ return $this->grav['admin']->translate($args, $lang);
+ }
+
+ // else use the default grav translate functionality
+ return $this->grav['language']->translate($args);
+ }
+
+ /**
+ * Translate Strings
+ *
+ * @param string|array $args
+ * @param array|null $languages
+ * @param bool $array_support
+ * @param bool $html_out
+ * @return string
+ */
+ public function translateLanguage($args, array $languages = null, $array_support = false, $html_out = false)
+ {
+ /** @var Language $language */
+ $language = $this->grav['language'];
+
+ return $language->translate($args, $languages, $array_support, $html_out);
+ }
+
+ /**
+ * @param string $key
+ * @param string $index
+ * @param array|null $lang
+ * @return string
+ */
+ public function translateArray($key, $index, $lang = null)
+ {
+ /** @var Language $language */
+ $language = $this->grav['language'];
+
+ return $language->translateArray($key, $index, $lang);
+ }
+
+ /**
+ * Repeat given string x times.
+ *
+ * @param string $input
+ * @param int $multiplier
+ *
+ * @return string
+ */
+ public function repeatFunc($input, $multiplier)
+ {
+ return str_repeat($input, $multiplier);
+ }
+
+ /**
+ * Return URL to the resource.
+ *
+ * @example {{ url('theme://images/logo.png')|default('http://www.placehold.it/150x100/f4f4f4') }}
+ *
+ * @param string $input Resource to be located.
+ * @param bool $domain True to include domain name.
+ * @param bool $failGracefully If true, return URL even if the file does not exist.
+ * @return string|false Returns url to the resource or null if resource was not found.
+ */
+ public function urlFunc($input, $domain = false, $failGracefully = false)
+ {
+ return Utils::url($input, $domain, $failGracefully);
+ }
+
+ /**
+ * This function will evaluate Twig $twig through the $environment, and return its results.
+ *
+ * @param array $context
+ * @param string $twig
+ * @return mixed
+ */
+ public function evaluateTwigFunc($context, $twig)
+ {
+
+ $loader = new FilesystemLoader('.');
+ $env = new Environment($loader);
+ $env->addExtension($this);
+
+ $template = $env->createTemplate($twig);
+
+ return $template->render($context);
+ }
+
+ /**
+ * This function will evaluate a $string through the $environment, and return its results.
+ *
+ * @param array $context
+ * @param string $string
+ * @return mixed
+ */
+ public function evaluateStringFunc($context, $string)
+ {
+ return $this->evaluateTwigFunc($context, "{{ $string }}");
+ }
+
+ /**
+ * Based on Twig\Extension\Debug / twig_var_dump
+ * (c) 2011 Fabien Potencier
+ *
+ * @param Environment $env
+ * @param array $context
+ */
+ public function dump(Environment $env, $context)
+ {
+ if (!$env->isDebug() || !$this->debugger) {
+ return;
+ }
+
+ $count = func_num_args();
+ if (2 === $count) {
+ $data = [];
+ foreach ($context as $key => $value) {
+ if (is_object($value)) {
+ if (method_exists($value, 'toArray')) {
+ $data[$key] = $value->toArray();
+ } else {
+ $data[$key] = 'Object (' . get_class($value) . ')';
+ }
+ } else {
+ $data[$key] = $value;
+ }
+ }
+ $this->debugger->addMessage($data, 'debug');
+ } else {
+ for ($i = 2; $i < $count; $i++) {
+ $var = func_get_arg($i);
+ $this->debugger->addMessage($var, 'debug');
+ }
+ }
+ }
+
+ /**
+ * Output a Gist
+ *
+ * @param string $id
+ * @param string|false $file
+ * @return string
+ */
+ public function gistFunc($id, $file = false)
+ {
+ $url = 'https://gist.github.com/' . $id . '.js';
+ if ($file) {
+ $url .= '?file=' . $file;
+ }
+ return '';
+ }
+
+ /**
+ * Generate a random string
+ *
+ * @param int $count
+ * @return string
+ */
+ public function randomStringFunc($count = 5)
+ {
+ return Utils::generateRandomString($count);
+ }
+
+ /**
+ * Pad a string to a certain length with another string
+ *
+ * @param string $input
+ * @param int $pad_length
+ * @param string $pad_string
+ * @param int $pad_type
+ * @return string
+ */
+ public static function padFilter($input, $pad_length, $pad_string = ' ', $pad_type = STR_PAD_RIGHT)
+ {
+ return str_pad($input, (int)$pad_length, $pad_string, $pad_type);
+ }
+
+ /**
+ * Workaround for twig associative array initialization
+ * Returns a key => val array
+ *
+ * @param string $key key of item
+ * @param string $val value of item
+ * @param array|null $current_array optional array to add to
+ * @return array
+ */
+ public function arrayKeyValueFunc($key, $val, $current_array = null)
+ {
+ if (empty($current_array)) {
+ return array($key => $val);
+ }
+
+ $current_array[$key] = $val;
+
+ return $current_array;
+ }
+
+ /**
+ * Wrapper for array_intersect() method
+ *
+ * @param array|Collection $array1
+ * @param array|Collection $array2
+ * @return array|Collection
+ */
+ public function arrayIntersectFunc($array1, $array2)
+ {
+ if ($array1 instanceof Collection && $array2 instanceof Collection) {
+ return $array1->intersect($array2)->toArray();
+ }
+
+ return array_intersect($array1, $array2);
+ }
+
+ /**
+ * Translate a string
+ *
+ * @return string
+ */
+ public function translateFunc()
+ {
+ return $this->grav['language']->translate(func_get_args());
+ }
+
+ /**
+ * Authorize an action. Returns true if the user is logged in and
+ * has the right to execute $action.
+ *
+ * @param string|array $action An action or a list of actions. Each
+ * entry can be a string like 'group.action'
+ * or without dot notation an associative
+ * array.
+ * @return bool Returns TRUE if the user is authorized to
+ * perform the action, FALSE otherwise.
+ */
+ public function authorize($action)
+ {
+ // Admin can use Flex users even if the site does not; make sure we use the right version of the user.
+ $admin = $this->grav['admin'] ?? null;
+ if ($admin) {
+ $user = $admin->user;
+ } else {
+ /** @var UserInterface|null $user */
+ $user = $this->grav['user'] ?? null;
+ }
+
+ if (!$user) {
+ return false;
+ }
+
+ if (is_array($action)) {
+ if (Utils::isAssoc($action)) {
+ // Handle nested access structure.
+ $actions = Utils::arrayFlattenDotNotation($action);
+ } else {
+ // Handle simple access list.
+ $actions = array_combine($action, array_fill(0, count($action), true));
+ }
+ } else {
+ // Handle single action.
+ $actions = [(string)$action => true];
+ }
+
+ $count = count($actions);
+ foreach ($actions as $act => $authenticated) {
+ // Ignore 'admin.super' if it's not the only value to be checked.
+ if ($act === 'admin.super' && $count > 1 && $user instanceof FlexObjectInterface) {
+ continue;
+ }
+
+ $auth = $user->authorize($act) ?? false;
+ if (is_bool($auth) && $auth === Utils::isPositive($authenticated)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Used to add a nonce to a form. Call {{ nonce_field('action') }} specifying a string representing the action.
+ *
+ * For maximum protection, ensure that the string representing the action is as specific as possible
+ *
+ * @param string $action the action
+ * @param string $nonceParamName a custom nonce param name
+ * @return string the nonce input field
+ */
+ public function nonceFieldFunc($action, $nonceParamName = 'nonce')
+ {
+ $string = '';
+
+ return $string;
+ }
+
+ /**
+ * Decodes string from JSON.
+ *
+ * @param string $str
+ * @param bool $assoc
+ * @param int $depth
+ * @param int $options
+ * @return array
+ */
+ public function jsonDecodeFilter($str, $assoc = false, $depth = 512, $options = 0)
+ {
+ return json_decode(html_entity_decode($str, ENT_COMPAT | ENT_HTML401, 'UTF-8'), $assoc, $depth, $options);
+ }
+
+ /**
+ * Used to retrieve a cookie value
+ *
+ * @param string $key The cookie name to retrieve
+ * @return string
+ */
+ public function getCookie($key)
+ {
+ return filter_input(INPUT_COOKIE, $key, FILTER_SANITIZE_STRING);
+ }
+
+ /**
+ * Twig wrapper for PHP's preg_replace method
+ *
+ * @param string|string[] $subject the content to perform the replacement on
+ * @param string|string[] $pattern the regex pattern to use for matches
+ * @param string|string[] $replace the replacement value either as a string or an array of replacements
+ * @param int $limit the maximum possible replacements for each pattern in each subject
+ * @return string|string[]|null the resulting content
+ */
+ public function regexReplace($subject, $pattern, $replace, $limit = -1)
+ {
+ return preg_replace($pattern, $replace, $subject, $limit);
+ }
+
+ /**
+ * Twig wrapper for PHP's preg_grep method
+ *
+ * @param array $array
+ * @param string $regex
+ * @param int $flags
+ * @return array
+ */
+ public function regexFilter($array, $regex, $flags = 0)
+ {
+ return preg_grep($regex, $array, $flags);
+ }
+
+ /**
+ * Twig wrapper for PHP's preg_match method
+ *
+ * @param string $subject the content to perform the match on
+ * @param string $pattern the regex pattern to use for match
+ * @param int $flags
+ * @param int $offset
+ * @return array|false returns the matches if there is at least one match in the subject for a given pattern or null if not.
+ */
+ public function regexMatch($subject, $pattern, $flags = 0, $offset = 0)
+ {
+ if (preg_match($pattern, $subject, $matches, $flags, $offset) === false) {
+ return false;
+ }
+
+ return $matches;
+ }
+
+ /**
+ * Twig wrapper for PHP's preg_split method
+ *
+ * @param string $subject the content to perform the split on
+ * @param string $pattern the regex pattern to use for split
+ * @param int $limit the maximum possible splits for the given pattern
+ * @param int $flags
+ * @return array|false the resulting array after performing the split operation
+ */
+ public function regexSplit($subject, $pattern, $limit = -1, $flags = 0)
+ {
+ return preg_split($pattern, $subject, $limit, $flags);
+ }
+
+ /**
+ * redirect browser from twig
+ *
+ * @param string $url the url to redirect to
+ * @param int $statusCode statusCode, default 303
+ * @return void
+ */
+ public function redirectFunc($url, $statusCode = 303)
+ {
+ $response = new Response($statusCode, ['location' => $url]);
+
+ $this->grav->close($response);
+ }
+
+ /**
+ * Generates an array containing a range of elements, optionally stepped
+ *
+ * @param int $start Minimum number, default 0
+ * @param int $end Maximum number, default `getrandmax()`
+ * @param int $step Increment between elements in the sequence, default 1
+ * @return array
+ */
+ public function rangeFunc($start = 0, $end = 100, $step = 1)
+ {
+ return range($start, $end, $step);
+ }
+
+ /**
+ * Check if HTTP_X_REQUESTED_WITH has been set to xmlhttprequest,
+ * in which case we may unsafely assume ajax. Non critical use only.
+ *
+ * @return bool True if HTTP_X_REQUESTED_WITH exists and has been set to xmlhttprequest
+ */
+ public function isAjaxFunc()
+ {
+ return (
+ !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
+ && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest');
+ }
+
+ /**
+ * Get the Exif data for a file
+ *
+ * @param string $image
+ * @param bool $raw
+ * @return mixed
+ */
+ public function exifFunc($image, $raw = false)
+ {
+ if (isset($this->grav['exif'])) {
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ if ($locator->isStream($image)) {
+ $image = $locator->findResource($image);
+ }
+
+ $exif_reader = $this->grav['exif']->getReader();
+
+ if ($image && file_exists($image) && $this->config->get('system.media.auto_metadata_exif') && $exif_reader) {
+ $exif_data = $exif_reader->read($image);
+
+ if ($exif_data) {
+ if ($raw) {
+ return $exif_data->getRawData();
+ }
+
+ return $exif_data->getData();
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Simple function to read a file based on a filepath and output it
+ *
+ * @param string $filepath
+ * @return bool|string
+ */
+ public function readFileFunc($filepath)
+ {
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ if ($locator->isStream($filepath)) {
+ $filepath = $locator->findResource($filepath);
+ }
+
+ if ($filepath && file_exists($filepath)) {
+ return file_get_contents($filepath);
+ }
+
+ return false;
+ }
+
+ /**
+ * Process a folder as Media and return a media object
+ *
+ * @param string $media_dir
+ * @return Media|null
+ */
+ public function mediaDirFunc($media_dir)
+ {
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ if ($locator->isStream($media_dir)) {
+ $media_dir = $locator->findResource($media_dir);
+ }
+
+ if ($media_dir && file_exists($media_dir)) {
+ return new Media($media_dir);
+ }
+
+ return null;
+ }
+
+ /**
+ * Dump a variable to the browser
+ *
+ * @param mixed $var
+ * @return void
+ */
+ public function vardumpFunc($var)
+ {
+ dump($var);
+ }
+
+ /**
+ * Returns a nicer more readable filesize based on bytes
+ *
+ * @param int $bytes
+ * @return string
+ */
+ public function niceFilesizeFunc($bytes)
+ {
+ return Utils::prettySize($bytes);
+ }
+
+ /**
+ * Returns a nicer more readable number
+ *
+ * @param int|float|string $n
+ * @return string|bool
+ */
+ public function niceNumberFunc($n)
+ {
+ if (!is_float($n) && !is_int($n)) {
+ if (!is_string($n) || $n === '') {
+ return false;
+ }
+
+ // Strip any thousand formatting and find the first number.
+ $list = array_filter(preg_split("/\D+/", str_replace(',', '', $n)));
+ $n = reset($list);
+
+ if (!is_numeric($n)) {
+ return false;
+ }
+
+ $n = (float)$n;
+ }
+
+ // now filter it;
+ if ($n > 1000000000000) {
+ return round($n/1000000000000, 2).' t';
+ }
+ if ($n > 1000000000) {
+ return round($n/1000000000, 2).' b';
+ }
+ if ($n > 1000000) {
+ return round($n/1000000, 2).' m';
+ }
+ if ($n > 1000) {
+ return round($n/1000, 2).' k';
+ }
+
+ return number_format($n);
+ }
+
+ /**
+ * Get a theme variable
+ * Will try to get the variable for the current page, if not found, it tries it's parent page on up to root.
+ * If still not found, will use the theme's configuration value,
+ * If still not found, will use the $default value passed in
+ *
+ * @param array $context Twig Context
+ * @param string $var variable to be found (using dot notation)
+ * @param null $default the default value to be used as last resort
+ * @param PageInterface|null $page an optional page to use for the current page
+ * @param bool $exists toggle to simply return the page where the variable is set, else null
+ * @return mixed
+ */
+ public function themeVarFunc($context, $var, $default = null, $page = null, $exists = false)
+ {
+ $page = $page ?? $context['page'] ?? Grav::instance()['page'] ?? null;
+
+ // Try to find var in the page headers
+ if ($page instanceof PageInterface && $page->exists()) {
+ // Loop over pages and look for header vars
+ while ($page && !$page->root()) {
+ $header = new Data((array)$page->header());
+ $value = $header->get($var);
+ if (isset($value)) {
+ if ($exists) {
+ return $page;
+ }
+
+ return $value;
+ }
+ $page = $page->parent();
+ }
+ }
+
+ if ($exists) {
+ return false;
+ }
+
+ return Grav::instance()['config']->get('theme.' . $var, $default);
+ }
+
+ /**
+ * Look for a page header variable in an array of pages working its way through until a value is found
+ *
+ * @param array $context
+ * @param string $var the variable to look for in the page header
+ * @param string|string[]|null $pages array of pages to check (current page upwards if not null)
+ * @return mixed
+ * @deprecated 1.7 Use themeVarFunc() instead
+ */
+ public function pageHeaderVarFunc($context, $var, $pages = null)
+ {
+ if (is_array($pages)) {
+ $page = array_shift($pages);
+ } else {
+ $page = null;
+ }
+ return $this->themeVarFunc($context, $var, null, $page);
+ }
+
+ /**
+ * takes an array of classes, and if they are not set on body_classes
+ * look to see if they are set in theme config
+ *
+ * @param array $context
+ * @param string|string[] $classes
+ * @return string
+ */
+ public function bodyClassFunc($context, $classes)
+ {
+
+ $header = $context['page']->header();
+ $body_classes = $header->body_classes ?? '';
+
+ foreach ((array)$classes as $class) {
+ if (!empty($body_classes) && Utils::contains($body_classes, $class)) {
+ continue;
+ }
+
+ $val = $this->config->get('theme.' . $class, false) ? $class : false;
+ $body_classes .= $val ? ' ' . $val : '';
+ }
+
+ return $body_classes;
+ }
+
+ /**
+ * Returns the content of an SVG image and adds extra classes as needed
+ *
+ * @param string $path
+ * @param string|null $classes
+ * @return string|string[]|null
+ */
+ public static function svgImageFunction($path, $classes = null, $strip_style = false)
+ {
+ $path = Utils::fullPath($path);
+
+ $classes = $classes ?: '';
+
+ if (file_exists($path) && !is_dir($path)) {
+ $svg = file_get_contents($path);
+ $classes = " inline-block $classes";
+ $matched = false;
+
+ //Remove xml tag if it exists
+ $svg = preg_replace('/^<\?xml.*\?>/','', $svg);
+
+ //Strip style if needed
+ if ($strip_style) {
+ $svg = preg_replace('//s', '', $svg);
+ }
+
+ //Look for existing class
+ $svg = preg_replace_callback('/^