commit 449ca1d987def5e01190c26fc763eb8d9d4ef91f Author: Kevin Date: Thu Jun 11 16:42:43 2020 +0200 first commit diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e84f52b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,8 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: grav +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +custom: # Replace with a single custom sponsorship URL diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79d0570 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +log/* +*.sql +.DS_Store +.directory + +user/accounts/* +user/pages/* +user/themes/hehe/node_modules/* diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..9b61c48 --- /dev/null +++ b/.htaccess @@ -0,0 +1,75 @@ + + +RewriteEngine On + +## Begin RewriteBase +# If you are getting 500 or 404 errors on subpages, you may have to uncomment the RewriteBase entry +# You should change the '/' to your appropriate subfolder. For example if you have +# your Grav install at the root of your site '/' should work, else it might be something +# along the lines of: RewriteBase / +## + +RewriteBase / + +## End - RewriteBase + +## Begin - X-Forwarded-Proto +# In some hosted or load balanced environments, SSL negotiation happens upstream. +# In order for Grav to recognize the connection as secure, you need to uncomment +# the following lines. +# +# RewriteCond %{HTTP:X-Forwarded-Proto} https +# RewriteRule .* - [E=HTTPS:on] +# +## End - X-Forwarded-Proto + +## Begin - Exploits +# 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 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/Js.php b/system/src/Grav/Common/Assets/Js.php new file mode 100644 index 0000000..cce86d9 --- /dev/null +++ b/system/src/Grav/Common/Assets/Js.php @@ -0,0 +1,36 @@ + 'js', + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + public function render() + { + if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') { + $buffer = $this->gatherLinks( [$this], self::JS_ASSET); + return 'renderAttributes() . ">\n" . trim($buffer) . "\n\n"; + } + + return '\n"; + } +} diff --git a/system/src/Grav/Common/Assets/Pipeline.php b/system/src/Grav/Common/Assets/Pipeline.php new file mode 100644 index 0000000..b009585 --- /dev/null +++ b/system/src/Grav/Common/Assets/Pipeline.php @@ -0,0 +1,273 @@ +base_url = rtrim($uri->rootUrl($config->get('system.absolute_urls')), '/') . '/'; + $this->assets_dir = $locator->findResource('asset://') . DS; + $this->assets_url = $locator->findResource('asset://', false); + } + + /** + * Minify and concatenate CSS + * + * @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 = []) + { + // temporary list of assets to pipeline + $inline_group = false; + + if (array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline') { + $inline_group = true; + unset($attributes['loading']); + } + + // Store Attributes + $this->attributes = array_merge(['type' => 'text/css', 'rel' => 'stylesheet'], $attributes); + + // Compute uid based on assets and timestamp + $json_assets = json_encode($assets); + $uid = md5($json_assets . $this->css_minify . $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"; + } else { + //if nothing found get out of here! + if (empty($assets)) { + return false; + } + + // Concatenate files + $buffer = $this->gatherLinks($assets, self::CSS_ASSET); + + // Minify if required + if ($this->shouldMinify('css')) { + $minifier = new \MatthiasMullie\Minify\CSS(); + $minifier->add($buffer); + $buffer = $minifier->minify(); + } + + // Write file + if (trim($buffer) !== '') { + file_put_contents($this->assets_dir . $file, $buffer); + } + } + + if ($inline_group) { + $output = "\n"; + } else { + $this->asset = $relative_path; + $output = 'renderAttributes() . ">\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($assets, $group, $attributes = []) + { + // temporary list of assets to pipeline + $inline_group = false; + + if (array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline') { + $inline_group = true; + unset($attributes['loading']); + } + + // Store Attributes + $this->attributes = $attributes; + + // Compute uid based on assets and timestamp + $json_assets = json_encode($assets); + $uid = md5($json_assets . $this->js_minify . $group); + $file = $uid . '.js'; + $relative_path = "{$this->base_url}{$this->assets_url}/{$file}"; + + $buffer = null; + + if (file_exists($this->assets_dir . $file)) { + $buffer = file_get_contents($this->assets_dir . $file) . "\n"; + } else { + //if nothing found get out of here! + if (empty($assets)) { + return false; + } + + // Concatenate files + $buffer = $this->gatherLinks($assets, self::JS_ASSET); + + // Minify if required + if ($this->shouldMinify('js')) { + $minifier = new \MatthiasMullie\Minify\JS(); + $minifier->add($buffer); + $buffer = $minifier->minify(); + } + + // Write file + if (trim($buffer) !== '') { + file_put_contents($this->assets_dir . $file, $buffer); + } + } + + if ($inline_group) { + $output = 'renderAttributes(). ">\n" . $buffer . "\n\n"; + } else { + $this->asset = $relative_path; + $output = '\n"; + } + + return $output; + } + + + /** + * Finds relative CSS 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 mixed + */ + protected function cssRewrite($file, $dir, $local) + { + // Strip any sourcemap comments + $file = preg_replace(self::CSS_SOURCEMAP_REGEX, '', $file); + + // Find any css url() elements, grab the URLs and calculate an absolute path + // Then replace the old url with the new one + $file = (string)preg_replace_callback(self::CSS_URL_REGEX, function ($matches) use ($dir, $local) { + + $old_url = $matches[2]; + + // 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) || Utils::startsWith($old_url, 'data:') || $this->isRemoteLink($old_url)) { + return $matches[0]; + } + + // clean leading / + $old_url = Utils::normalizePath($dir . '/' . $old_url); + if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url)) { + $old_url = ltrim($old_url, '/'); + } + + $new_url = ($local ? $this->base_url: '') . $old_url; + + $fixed = str_replace($matches[2], $new_url, $matches[0]); + + return $fixed; + }, $file); + + return $file; + } + + + + private function shouldMinify($type = 'css') + { + $check = $type . '_minify'; + $win_check = $type . '_minify_windows'; + + $minify = (bool) $this->$check; + + // If this is a Windows server, and minify_windows is false (default value) skip the + // minification process because it will cause Apache to die/crash due to insufficient + // ThreadStackSize in httpd.conf - See: https://bugs.php.net/bug.php?id=47689 + if (stripos(php_uname('s'), 'WIN') === 0 && !$this->{$win_check}) { + $minify = false; + } + + return $minify; + } +} diff --git a/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php new file mode 100644 index 0000000..0a1d149 --- /dev/null +++ b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php @@ -0,0 +1,182 @@ +rootUrl(true); + + // Sanity check for local URLs with absolute URL's enabled + if (Utils::startsWith($link, $base)) { + return false; + } + + return (0 === strpos($link, 'http://') || 0 === strpos($link, 'https://') || 0 === strpos($link, '//')); + } + + /** + * Download and concatenate the content of several links. + * + * @param array $assets + * @param bool $css + * + * @return string + */ + protected function gatherLinks(array $assets, $css = true) + { + $buffer = ''; + + + foreach ($assets as $id => $asset) { + $local = true; + + $link = $asset->getAsset(); + $relative_path = $link; + + if (static::isRemoteLink($link)) { + $local = false; + if (0 === strpos($link, '//')) { + $link = 'http:' . $link; + } + $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)) { + $base_url = '#' . preg_quote($this->base_url, '#') . '#'; + $relative_path = ltrim(preg_replace($base_url, '/', $link, 1), '/'); + } + + $relative_dir = \dirname($relative_path); + $link = ROOT_DIR . $relative_path; + } + + $file = ($this->fetch_command instanceof \Closure) ? @$this->fetch_command->__invoke($link) : @file_get_contents($link); + + // No file found, skip it... + if ($file === false) { + continue; + } + + // Double check last character being + if (!$css) { + $file = rtrim($file, ' ;') . ';'; + } + + // If this is CSS + the file is local + rewrite enabled + if ($css && $this->css_rewrite) { + $file = $this->cssRewrite($file, $relative_dir, $local); + } + + $file = rtrim($file) . PHP_EOL; + $buffer .= $file; + } + + // Pull out @imports and move to top + if ($css) { + $buffer = $this->moveImports($buffer); + } + + return $buffer; + } + + /** + * 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) + { + $imports = []; + + $file = (string)preg_replace_callback(self::CSS_IMPORT_REGEX, function ($matches) use (&$imports) { + $imports[] = $matches[0]; + + return ''; + }, $file); + + return implode("\n", $imports) . "\n\n" . $file; + } + + /** + * + * Build an HTML attribute string from an array. + * + * @return string + */ + protected function renderAttributes() + { + $html = ''; + $no_key = ['loading']; + + foreach ($this->attributes as $key => $value) { + if (is_numeric($key)) { + $key = $value; + } + if (\is_array($value)) { + $value = implode(' ', $value); + } + + 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) . '"'; + } + + $html .= ' ' . $element; + } + + return $html; + } + + /** + * Render Querystring + * + * @param string $asset + * @return string + */ + protected function renderQueryString($asset = null) + { + $querystring = ''; + + $asset = $asset ?? $this->asset; + + if (!empty($this->query)) { + if (Utils::contains($asset, '?')) { + $querystring .= '&' . $this->query; + } else { + $querystring .= '?' . $this->query; + } + } + + if ($this->timestamp) { + if (Utils::contains($asset, '?') || $querystring) { + $querystring .= '&' . $this->timestamp; + } else { + $querystring .= '?' . $this->timestamp; + } + } + + return $querystring; + } +} diff --git a/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php b/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php new file mode 100644 index 0000000..7f91c6d --- /dev/null +++ b/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php @@ -0,0 +1,129 @@ + null, 'pipeline' => true, 'loading' => null, 'group' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + break; + + 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']; + 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): + $defaults = ['priority' => null, 'group' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + break; + + default: + case(Assets::CSS_TYPE): + $defaults = ['priority' => null, 'pipeline' => true, 'group' => null, 'loading' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + } + + return $arguments; + } + + protected function createArgumentsFromLegacy(array $args, array $defaults) + { + // Remove arguments with old default values. + $arguments = []; + foreach ($args as $arg) { + $default = current($defaults); + if ($arg !== $default) { + $arguments[key($defaults)] = $arg; + } + next($defaults); + } + + return $arguments; + } + + /** + * Convenience wrapper for async loading of JavaScript + * + * @param string|array $asset + * @param int $priority + * @param bool $pipeline + * @param string $group name of the group + * + * @return \Grav\Common\Assets + * @deprecated Please use dynamic method with ['loading' => 'async']. + */ + public function addAsyncJs($asset, $priority = 10, $pipeline = true, $group = 'head') + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use dynamic method with [\'loading\' => \'async\']', E_USER_DEPRECATED); + + return $this->addJs($asset, $priority, $pipeline, 'async', $group); + } + + /** + * Convenience wrapper for deferred loading of JavaScript + * + * @param string|array $asset + * @param int $priority + * @param bool $pipeline + * @param string $group name of the group + * + * @return \Grav\Common\Assets + * @deprecated Please use dynamic method with ['loading' => 'defer']. + */ + public function addDeferJs($asset, $priority = 10, $pipeline = true, $group = 'head') + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use dynamic method with [\'loading\' => \'defer\']', E_USER_DEPRECATED); + + 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 new file mode 100644 index 0000000..84b83e3 --- /dev/null +++ b/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php @@ -0,0 +1,343 @@ +collections[$asset]) || isset($this->assets_css[$asset]) || isset($this->assets_js[$asset]); + } + + /** + * Return the array of all the registered collections + * + * @return array + */ + public function getCollections() + { + return $this->collections; + } + + /** + * Set the array of collections explicitly + * + * @param array $collections + * + * @return $this + */ + public function setCollection($collections) + { + $this->collections = $collections; + + return $this; + } + + /** + * Return the array of all the registered CSS assets + * 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 + * @return array + */ + public function getCss($key = null) + { + if (null !== $key) { + $asset_key = md5($key); + + return $this->assets_css[$asset_key] ?? null; + } + + return $this->assets_css; + } + + /** + * Return the array of all the registered JS assets + * 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 + * @return array + */ + public function getJs($key = null) + { + if (null !== $key) { + $asset_key = md5($key); + + return $this->assets_js[$asset_key] ?? null; + } + + return $this->assets_js; + } + + /** + * Set the whole array of CSS assets + * + * @param array $css + * + * @return $this + */ + public function setCss($css) + { + $this->assets_css = $css; + + return $this; + } + + /** + * Set the whole array of JS assets + * + * @param array $js + * + * @return $this + */ + public function setJs($js) + { + $this->assets_js = $js; + + return $this; + } + + /** + * Removes an item from the CSS array if set + * + * @param string $key The asset key + * + * @return $this + */ + public function removeCss($key) + { + $asset_key = md5($key); + if (isset($this->assets_css[$asset_key])) { + unset($this->assets_css[$asset_key]); + } + + return $this; + } + + /** + * Removes an item from the JS array if set + * + * @param string $key The asset key + * + * @return $this + */ + public function removeJs($key) + { + $asset_key = md5($key); + if (isset($this->assets_js[$asset_key])) { + unset($this->assets_js[$asset_key]); + } + + return $this; + } + + /** + * Sets the state of CSS Pipeline + * + * @param bool $value + * + * @return $this + */ + public function setCssPipeline($value) + { + $this->css_pipeline = (bool)$value; + + return $this; + } + + /** + * Sets the state of JS Pipeline + * + * @param bool $value + * + * @return $this + */ + public function setJsPipeline($value) + { + $this->js_pipeline = (bool)$value; + + return $this; + } + + /** + * Reset all assets. + * + * @return $this + */ + public function reset() + { + $this->resetCss(); + $this->resetJs(); + $this->setCssPipeline(false); + $this->setJsPipeline(false); + + return $this; + } + + /** + * Reset JavaScript assets. + * + * @return $this + */ + public function resetJs() + { + $this->assets_js = []; + + return $this; + } + + /** + * Reset CSS assets. + * + * @return $this + */ + public function resetCss() + { + $this->assets_css = []; + + return $this; + } + + /** + * Explicitly set's a timestamp for assets + * + * @param string|int $value + */ + public function setTimestamp($value) + { + $this->timestamp = $value; + } + + /** + * Get the timestamp for assets + * + * @param bool $include_join + * @return string + */ + public function getTimestamp($include_join = true) + { + if ($this->timestamp) { + return $include_join ? '?' . $this->timestamp : $this->timestamp; + } + + return null; + } + + /** + * Add all assets matching $pattern within $directory. + * + * @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, '/'); + + // Check if $directory is a stream. + if (strpos($directory, '://')) { + $directory = Grav::instance()['locator']->findResource($directory, null); + } + + // Get files + $files = $this->rglob($root_dir . DIRECTORY_SEPARATOR . $directory, $pattern, $root_dir . '/'); + + // No luck? Nothing to do + if (!$files) { + return $this; + } + + // Add CSS files + if ($pattern === self::CSS_REGEX) { + foreach ($files as $file) { + $this->addCss($file); + } + + return $this; + } + + // Add JavaScript files + if ($pattern === self::JS_REGEX) { + foreach ($files as $file) { + $this->addJs($file); + } + + return $this; + } + + // Unknown pattern. + foreach ($files as $asset) { + $this->add($asset); + } + + return $this; + } + + /** + * 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) + { + return $this->addDir($directory, self::JS_REGEX); + } + + /** + * 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) + { + return $this->addDir($directory, self::CSS_REGEX); + } + + /** + * Recursively get files matching $pattern within $directory. + * + * @param string $directory + * @param string $pattern (regex) + * @param string $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); + $files = []; + + foreach ($iterator as $file) { + $files[] = substr($file->getPathname(), $offset); + } + + return $files; + } + + +} diff --git a/system/src/Grav/Common/Backup/Backups.php b/system/src/Grav/Common/Backup/Backups.php new file mode 100644 index 0000000..9582b9e --- /dev/null +++ b/system/src/Grav/Common/Backup/Backups.php @@ -0,0 +1,261 @@ +addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']); + Grav::instance()->fireEvent('onBackupsInitialized', new Event(['backups' => $this])); + } + + public function setup() + { + if (null === static::$backup_dir) { + static::$backup_dir = Grav::instance()['locator']->findResource('backup://', true, true); + Folder::create(static::$backup_dir); + } + } + + public function onSchedulerInitialized(Event $event) + { + /** @var Scheduler $scheduler */ + $scheduler = $event['scheduler']; + + /** @var Inflector $inflector */ + $inflector = Grav::instance()['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->at($at); + $job->output($logs); + $job->backlink('/tools/backups'); + } + } + + 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'); + + return $url; + } + + public static function getBackupProfiles() + { + return Grav::instance()['config']->get('backups.profiles'); + } + + public static function getPurgeConfig() + { + return Grav::instance()['config']->get('backups.purge'); + } + + public function getBackupNames() + { + return array_column(static::getBackupProfiles(), 'name'); + } + + public static function getTotalBackupsSize() + { + $backups = static::getAvailableBackups(); + $size = array_sum(array_column($backups, 'size')); + + return $size ?? 0; + } + + 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']; + $long_date_format = DATE_RFC2822; + + /** + * @var string $name + * @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]); + $timestamp = $date->getTimestamp(); + $backup = new \stdClass(); + $backup->title = $inflector->titleize($matches[1]); + $backup->time = $date; + $backup->date = $date->format($long_date_format); + $backup->filename = $name; + $backup->path = $file->getPathname(); + $backup->size = $file->getSize(); + static::$backups[$timestamp] = $backup; + } + } + // Reverse Key Sort to get in reverse date order + krsort(static::$backups); + } + + return static::$backups; + } + + /** + * Backup + * + * @param int $id + * @param callable|null $status + * + * @return null|string + */ + public static function backup($id = 0, callable $status = null) + { + $profiles = static::getBackupProfiles(); + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + if (isset($profiles[$id])) { + $backup = (object) $profiles[$id]; + } else { + throw new \RuntimeException('No backups defined...'); + } + + $name = Grav::instance()['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); + $backup_root = $backup->root; + + if ($locator->isStream($backup_root)) { + $backup_root = $locator->findResource($backup_root); + } else { + $backup_root = rtrim(GRAV_ROOT . $backup_root, '/'); + } + + if (!file_exists($backup_root)) { + throw new \RuntimeException("Backup location: {$backup_root} does not exist..."); + } + + $options = [ + 'exclude_files' => static::convertExclude($backup->exclude_files ?? ''), + '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); + + $status && $status([ + 'type' => 'message', + 'message' => 'Done...', + ]); + + $status && $status([ + 'type' => 'progress', + 'complete' => true + ]); + + if ($max_execution_time !== false) { + ini_set('max_execution_time', $max_execution_time); + } + + // Log the backup + Grav::instance()['log']->notice('Backup Created: ' . $destination); + + // Fire Finished event + Grav::instance()->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->content([ + 'time' => time(), + 'location' => $destination + ]); + $log->save(); + + return $destination; + } + + public static function purge() + { + $purge_config = static::getPurgeConfig(); + $trigger = $purge_config['trigger']; + $backups = static::getAvailableBackups(true); + + switch ($trigger) + { + case 'number': + $backups_count = count($backups); + if ($backups_count > $purge_config['max_backups_count']) { + $last = end($backups); + unlink ($last->path); + static::purge(); + } + break; + + case 'time': + $last = end($backups); + $now = new \DateTime(); + $interval = $now->diff($last->time); + if ($interval->days > $purge_config['max_backups_time']) { + unlink($last->path); + static::purge(); + } + break; + + default: + $used_space = static::getTotalBackupsSize(); + $max_space = $purge_config['max_backups_space'] * 1024 * 1024 * 1024; + if ($used_space > $max_space) { + $last = end($backups); + unlink($last->path); + static::purge(); + } + break; + } + } + + protected static function convertExclude($exclude) + { + $lines = preg_split("/[\s,]+/", $exclude); + 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 new file mode 100644 index 0000000..8d0d687 --- /dev/null +++ b/system/src/Grav/Common/Browser.php @@ -0,0 +1,151 @@ +useragent = parse_user_agent(); + } catch (\InvalidArgumentException $e) { + $this->useragent = parse_user_agent("Mozilla/5.0 (compatible; Unknown;)"); + } + } + + /** + * Get the current browser identifier + * + * Currently detected browsers: + * + * Android Browser + * BlackBerry Browser + * Camino + * Kindle / Silk + * Firefox / Iceweasel + * Safari + * Internet Explorer + * IEMobile + * Chrome + * Opera + * Midori + * Vivaldi + * TizenBrowser + * Lynx + * Wget + * Curl + * + * @return string the lowercase browser name + */ + public function getBrowser() + { + return strtolower($this->useragent['browser']); + } + + /** + * Get the current platform identifier + * + * Currently detected platforms: + * + * Desktop + * -> Windows + * -> Linux + * -> Macintosh + * -> Chrome OS + * Mobile + * -> Android + * -> iPhone + * -> iPad / iPod Touch + * -> Windows Phone OS + * -> Kindle + * -> Kindle Fire + * -> BlackBerry + * -> Playbook + * -> Tizen + * Console + * -> Nintendo 3DS + * -> New Nintendo 3DS + * -> Nintendo Wii + * -> Nintendo WiiU + * -> PlayStation 3 + * -> PlayStation 4 + * -> PlayStation Vita + * -> Xbox 360 + * -> Xbox One + * + * @return string the lowercase platform name + */ + public function getPlatform() + { + return strtolower($this->useragent['platform']); + } + + /** + * Get the current full version identifier + * + * @return string the browser full version identifier + */ + public function getLongVersion() + { + return $this->useragent['version']; + } + + /** + * Get the current major version identifier + * + * @return string the browser major version identifier + */ + public function getVersion() + { + $version = explode('.', $this->getLongVersion()); + + return (int)$version[0]; + } + + /** + * Determine if the request comes from a human, or from a bot/crawler + * + * @return bool + */ + public function isHuman() + { + $browser = $this->getBrowser(); + if (empty($browser)) { + return false; + } + + if (preg_match('~(bot|crawl)~i', $browser)) { + return false; + } + + return true; + } + + /** + * Determine if “Do Not Track” is set by browser + * @see https://www.w3.org/TR/tracking-dnt/ + * + * @return bool + */ + public function isTrackable(): bool + { + return !(isset($_SERVER['HTTP_DNT']) && $_SERVER['HTTP_DNT'] === '1'); + } +} diff --git a/system/src/Grav/Common/Cache.php b/system/src/Grav/Common/Cache.php new file mode 100644 index 0000000..94eae59 --- /dev/null +++ b/system/src/Grav/Common/Cache.php @@ -0,0 +1,656 @@ +init($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(); + + if (null === $this->enabled) { + $this->enabled = (bool)$this->config->get('system.cache.enabled'); + } + + /** @var Uri $uri */ + $uri = $grav['uri']; + + $prefix = $this->config->get('system.cache.prefix'); + $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->cache_dir = $grav['locator']->findResource('cache://doctrine/' . $uniqueness, true, true); + $this->driver_setting = $this->config->get('system.cache.driver'); + $this->driver = $this->getCacheDriver(); + $this->driver->setNamespace($this->key); + + /** @var EventDispatcher $dispatcher */ + $dispatcher = Grav::instance()['events']; + $dispatcher->addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']); + } + + /** + * @return CacheInterface + */ + public function getSimpleCache() + { + if (null === $this->simpleCache) { + $cache = new \Grav\Framework\Cache\Adapter\DoctrineCache($this->driver, '', $this->getLifetime()); + + // Disable cache key validation. + $cache->setValidation(false); + + $this->simpleCache = $cache; + } + + return $this->simpleCache; + } + + /** + * Deletes the old out of date file-based caches + * + * @return int + */ + public function purgeOldCache() + { + $cache_dir = dirname($this->cache_dir); + $current = basename($this->cache_dir); + $count = 0; + + foreach (new \DirectoryIterator($cache_dir) as $file) { + $dir = $file->getBasename(); + if ($dir === $current || $file->isDot() || $file->isFile()) { + continue; + } + + Folder::delete($file->getPathname()); + $count++; + } + + return $count; + } + + /** + * Public accessor to set the enabled state of the cache + * + * @param bool|int $enabled + */ + public function setEnabled($enabled) + { + $this->enabled = (bool)$enabled; + } + + /** + * Returns the current enabled state + * + * @return bool + */ + public function getEnabled() + { + return $this->enabled; + } + + /** + * Get cache state + * + * @return string + */ + public function getCacheStatus() + { + return 'Cache: [' . ($this->enabled ? 'true' : 'false') . '] Setting: [' . $this->driver_setting . '] Driver: [' . $this->driver_name . ']'; + } + + /** + * Automatically picks the cache mechanism to use. If you pick one manually it will use that + * If there is no config option for $driver in the config, or it's set to 'auto', it will + * pick the best option based on which cache extensions are installed. + * + * @return DoctrineCache\CacheProvider The cache driver to use + */ + public function getCacheDriver() + { + $setting = $this->driver_setting; + $driver_name = 'file'; + + // CLI compatibility requires a non-volatile cache driver + if ($this->config->get('system.cache.cli_compatibility') && ( + $setting === 'auto' || $this->isVolatileDriver($setting))) { + $setting = $driver_name; + } + + if (!$setting || $setting === 'auto') { + if (extension_loaded('apcu')) { + $driver_name = 'apcu'; + } elseif (extension_loaded('wincache')) { + $driver_name = 'wincache'; + } + } else { + $driver_name = $setting; + } + + $this->driver_name = $driver_name; + + switch ($driver_name) { + case 'apc': + case 'apcu': + $driver = new DoctrineCache\ApcuCache(); + break; + + case 'wincache': + $driver = new DoctrineCache\WinCacheCache(); + break; + + 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)); + $driver = new DoctrineCache\MemcacheCache(); + $driver->setMemcache($memcache); + } else { + 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)); + $driver = new DoctrineCache\MemcachedCache(); + $driver->setMemcached($memcached); + } else { + throw new \LogicException('Memcached PHP extension has not been installed'); + } + break; + + case 'redis': + if (extension_loaded('redis')) { + $redis = new \Redis(); + $socket = $this->config->get('system.cache.redis.socket', false); + $password = $this->config->get('system.cache.redis.password', false); + + if ($socket) { + $redis->connect($socket); + } else { + $redis->connect($this->config->get('system.cache.redis.server', 'localhost'), + $this->config->get('system.cache.redis.port', 6379)); + } + + // Authenticate with password if set + if ($password && !$redis->auth($password)) { + throw new \RedisException('Redis authentication failed'); + } + + $driver = new DoctrineCache\RedisCache(); + $driver->setRedis($redis); + } else { + throw new \LogicException('Redis PHP extension has not been installed'); + } + break; + + default: + $driver = new DoctrineCache\FilesystemCache($this->cache_dir); + break; + } + + return $driver; + } + + /** + * 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 + */ + public function fetch($id) + { + if ($this->enabled) { + return $this->driver->fetch($id); + } + + return false; + } + + /** + * 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 + */ + public function save($id, $data, $lifetime = null) + { + if ($this->enabled) { + if ($lifetime === null) { + $lifetime = $this->getLifetime(); + } + $this->driver->save($id, $data, $lifetime); + } + } + + /** + * Deletes an item in the cache based on the id + * + * @param string $id the id of the cached data entry + * @return bool true if the item was deleted successfully + */ + public function delete($id) + { + if ($this->enabled) { + return $this->driver->delete($id); + } + + return false; + } + + /** + * Deletes all cache + * + * @return bool + */ + public function deleteAll() + { + if ($this->enabled) { + return $this->driver->deleteAll(); + } + + return false; + } + + /** + * Returns a boolean state of whether or not the item exists in the cache based on id key + * + * @param string $id the id of the cached data entry + * @return bool true if the cached items exists + */ + public function contains($id) + { + if ($this->enabled) { + return $this->driver->contains(($id)); + } + + return false; + } + + /** + * Getter method to get the cache key + */ + public function getKey() + { + return $this->key; + } + + /** + * Setter method to set key (Advanced) + */ + public function setKey($key) + { + $this->key = $key; + $this->driver->setNamespace($this->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') + { + $locator = Grav::instance()['locator']; + $output = []; + $user_config = USER_DIR . 'config/system.yaml'; + + switch ($remove) { + case 'all': + $remove_paths = self::$all_remove; + break; + case 'assets-only': + $remove_paths = self::$assets_remove; + break; + case 'images-only': + $remove_paths = self::$images_remove; + break; + case 'cache-only': + $remove_paths = self::$cache_remove; + break; + case 'tmp-only': + $remove_paths = self::$tmp_remove; + break; + case 'invalidate': + $remove_paths = []; + break; + default: + if (Grav::instance()['config']->get('system.cache.clear_images_by_default')) { + $remove_paths = self::$standard_remove; + } else { + $remove_paths = self::$standard_remove_no_images; + } + + } + + // Delete entries in the doctrine cache if required + if (in_array($remove, ['all', 'standard'])) { + $cache = Grav::instance()['cache']; + $cache->driver->deleteAll(); + } + + // Clearing cache event to add paths to clear + 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; + + $anything = false; + $files = glob($path . '/*'); + + if (is_array($files)) { + foreach ($files as $file) { + if (is_link($file)) { + $output[] = 'Skipping symlink: ' . $file; + } elseif (is_file($file)) { + if (@unlink($file)) { + $anything = true; + } + } elseif (is_dir($file)) { + if (Folder::delete($file)) { + $anything = true; + } + } + } + } + + if ($anything) { + $output[] = 'Cleared: ' . $path . '/*'; + } + } catch (\Exception $e) { + // stream not found or another error while deleting files. + $output[] = 'ERROR: ' . $e->getMessage(); + } + } + + $output[] = ''; + + if (($remove === 'all' || $remove === 'standard') && file_exists($user_config)) { + touch($user_config); + + $output[] = 'Touched: ' . $user_config; + $output[] = ''; + } + + // Clear stat cache + @clearstatcache(); + + // Clear opcache + if (function_exists('opcache_reset')) { + @opcache_reset(); + } + + return $output; + } + + public static function invalidateCache() + { + $user_config = USER_DIR . 'config/system.yaml'; + + if (file_exists($user_config)) { + touch($user_config); + } + + // Clear stat cache + @clearstatcache(); + + // Clear opcache + if (function_exists('opcache_reset')) { + @opcache_reset(); + } + + } + + /** + * Set the cache lifetime programmatically + * + * @param int $future timestamp + */ + public function setLifetime($future) + { + if (!$future) { + return; + } + + $interval = (int)($future - $this->now); + if ($interval > 0 && $interval < $this->getLifetime()) { + $this->lifetime = $interval; + } + } + + + /** + * Retrieve the cache lifetime (in seconds) + * + * @return mixed + */ + public function getLifetime() + { + if ($this->lifetime === null) { + $this->lifetime = (int)($this->config->get('system.cache.lifetime') ?: 604800); // 1 week default + } + + return $this->lifetime; + } + + /** + * Returns the current driver name + * + * @return mixed + */ + public function getDriverName() + { + return $this->driver_name; + } + + /** + * Returns the current driver setting + * + * @return mixed + */ + public function getDriverSetting() + { + return $this->driver_setting; + } + + /** + * is this driver a volatile driver in that it resides in PHP process memory + * + * @param string $setting + * @return bool + */ + public function isVolatileDriver($setting) + { + if (in_array($setting, ['apc', 'apcu', 'xcache', 'wincache'])) { + return true; + } + + return false; + } + + /** + * Static function to call as a scheduled Job to purge old Doctrine files + */ + public static function purgeJob() + { + /** @var Cache $cache */ + $cache = Grav::instance()['cache']; + $deleted_folders = $cache->purgeOldCache(); + + echo 'Purged ' . $deleted_folders . ' old cache folders...'; + } + + /** + * Static function to call as a scheduled Job to clear Grav cache + * + * @param string $type + */ + public static function clearJob($type) + { + $result = static::clearCache($type); + static::invalidateCache(); + + echo strip_tags(implode("\n", $result)); + } + + public function onSchedulerInitialized(Event $event) + { + /** @var Scheduler $scheduler */ + $scheduler = $event['scheduler']; + $config = Grav::instance()['config']; + + // File Cache Purge + $at = $config->get('system.cache.purge_at'); + $name = 'cache-purge'; + $logs = 'logs/' . $name . '.out'; + + $job = $scheduler->addFunction('Grav\Common\Cache::purgeJob', [], $name ); + $job->at($at); + $job->output($logs); + $job->backlink('/config/system#caching'); + + // Cache Clear + $at = $config->get('system.cache.clear_at'); + $clear_type = $config->get('system.cache.clear_job_type'); + $name = 'cache-clear'; + $logs = 'logs/' . $name . '.out'; + + $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 new file mode 100644 index 0000000..c6abb2c --- /dev/null +++ b/system/src/Grav/Common/Composer.php @@ -0,0 +1,61 @@ +path = $path ? rtrim($path, '\\/') . '/' : ''; + $this->cacheFolder = $cacheFolder; + $this->files = $files; + $this->timestamp = 0; + } + + /** + * Get filename for the compiled PHP file. + * + * @param string $name + * @return $this + */ + public function name($name = null) + { + if (!$this->name) { + $this->name = $name ?: md5(json_encode(array_keys($this->files))); + } + + return $this; + } + + /** + * Function gets called when cached configuration is saved. + */ + public function modified() {} + + /** + * Get timestamp of compiled configuration + * + * @return int Timestamp of compiled configuration + */ + public function timestamp() + { + return $this->timestamp ?: time(); + } + + /** + * Load the configuration. + * + * @return mixed + */ + public function load() + { + if ($this->object) { + return $this->object; + } + + $filename = $this->createFilename(); + if (!$this->loadCompiledFile($filename) && $this->loadFiles()) { + $this->saveCompiledFile($filename); + } + + return $this->object; + } + + /** + * Returns checksum from the configuration files. + * + * You can set $this->checksum = false to disable this check. + * + * @return bool|string + */ + public function checksum() + { + if (null === $this->checksum) { + $this->checksum = md5(json_encode($this->files) . $this->version); + } + + return $this->checksum; + } + + protected function createFilename() + { + return "{$this->cacheFolder}/{$this->name()->name}.php"; + } + + /** + * Create configuration object. + * + * @param array $data + */ + abstract protected function createObject(array $data = []); + + /** + * Finalize configuration object. + */ + 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. + */ + abstract protected function loadFile($name, $filename); + + /** + * Load and join all configuration files. + * + * @return bool + * @internal + */ + protected function loadFiles() + { + $this->createObject(); + + $list = array_reverse($this->files); + foreach ($list as $files) { + foreach ($files as $name => $item) { + $this->loadFile($name, $this->path . $item['file']); + } + } + + $this->finalizeObject(); + + return true; + } + + /** + * Load compiled file. + * + * @param string $filename + * @return bool + * @internal + */ + protected function loadCompiledFile($filename) + { + if (!file_exists($filename)) { + return false; + } + + $cache = include $filename; + if ( + !\is_array($cache) + || !isset($cache['checksum'], $cache['data'], $cache['@class']) + || $cache['@class'] !== \get_class($this) + ) { + return false; + } + + // Load real file if cache isn't up to date (or is invalid). + if ($cache['checksum'] !== $this->checksum()) { + return false; + } + + $this->createObject($cache['data']); + $this->timestamp = $cache['timestamp'] ?? 0; + + $this->finalizeObject(); + + return true; + } + + /** + * Save compiled file. + * + * @param string $filename + * @throws \RuntimeException + * @internal + */ + protected function saveCompiledFile($filename) + { + $file = PhpFile::instance($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. + } + + if ($file->locked() === false) { + // File was already locked by another process. + return; + } + + $cache = [ + '@class' => \get_class($this), + 'timestamp' => time(), + 'checksum' => $this->checksum(), + 'files' => $this->files, + 'data' => $this->getState() + ]; + + $file->save($cache); + $file->unlock(); + $file->free(); + + $this->modified(); + } + + 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 new file mode 100644 index 0000000..9ad7be6 --- /dev/null +++ b/system/src/Grav/Common/Config/CompiledBlueprints.php @@ -0,0 +1,119 @@ +version = 2; + } + + /** + * Returns checksum from the configuration files. + * + * You can set $this->checksum = false to disable this check. + * + * @return bool|string + */ + public function checksum() + { + if (null === $this->checksum) { + $this->checksum = md5(json_encode($this->files) . json_encode($this->getTypes()) . $this->version); + } + + return $this->checksum; + } + + /** + * Create configuration object. + * + * @param array $data + */ + protected function createObject(array $data = []) + { + $this->object = (new BlueprintSchema($data))->setTypes($this->getTypes()); + } + + /** + * Get list of form field types. + * + * @return array + */ + protected function getTypes() + { + return Grav::instance()['plugins']->formFieldTypes ?: []; + } + + /** + * Finalize configuration object. + */ + protected function finalizeObject() + { + } + + /** + * Load single configuration file and append it to the correct position. + * + * @param string $name Name of the position. + * @param array $files Files to be loaded. + */ + protected function loadFile($name, $files) + { + // Load blueprint file. + $blueprint = new Blueprint($files); + + $this->object->embed($name, $blueprint->load()->toArray(), '/', true); + } + + /** + * Load and join all configuration files. + * + * @return bool + * @internal + */ + protected function loadFiles() + { + $this->createObject(); + + // Convert file list into parent list. + $list = []; + /** @var array $files */ + foreach ($this->files as $files) { + foreach ($files as $name => $item) { + $list[$name][] = $this->path . $item['file']; + } + } + + // Load files. + foreach ($list as $name => $files) { + $this->loadFile($name, $files); + } + + $this->finalizeObject(); + + return true; + } + + 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 new file mode 100644 index 0000000..1c92edd --- /dev/null +++ b/system/src/Grav/Common/Config/CompiledConfig.php @@ -0,0 +1,101 @@ +version = 1; + } + + /** + * Set blueprints for the configuration. + * + * @param callable $blueprints + * @return $this + */ + public function setBlueprints(callable $blueprints) + { + $this->callable = $blueprints; + + return $this; + } + + /** + * @param bool $withDefaults + * @return mixed + */ + public function load($withDefaults = false) + { + $this->withDefaults = $withDefaults; + + return parent::load(); + } + + /** + * Create configuration object. + * + * @param array $data + */ + protected function createObject(array $data = []) + { + if ($this->withDefaults && empty($data) && \is_callable($this->callable)) { + $blueprints = $this->callable; + $data = $blueprints()->getDefaults(); + } + + $this->object = new Config($data, $this->callable); + } + + /** + * Finalize configuration object. + */ + protected function finalizeObject() + { + $this->object->checksum($this->checksum()); + $this->object->timestamp($this->timestamp()); + } + + /** + * Function gets called when cached configuration is saved. + */ + public function modified() + { + $this->object->modified(true); + } + + /** + * 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. + */ + protected function loadFile($name, $filename) + { + $file = CompiledYamlFile::instance($filename); + $this->object->join($name, $file->content(), '/'); + $file->free(); + } +} diff --git a/system/src/Grav/Common/Config/CompiledLanguages.php b/system/src/Grav/Common/Config/CompiledLanguages.php new file mode 100644 index 0000000..ef18145 --- /dev/null +++ b/system/src/Grav/Common/Config/CompiledLanguages.php @@ -0,0 +1,67 @@ +version = 1; + } + + /** + * Create configuration object. + * + * @param array $data + */ + protected function createObject(array $data = []) + { + $this->object = new Languages($data); + } + + /** + * Finalize configuration object. + */ + protected function finalizeObject() + { + $this->object->checksum($this->checksum()); + $this->object->timestamp($this->timestamp()); + } + + + /** + * Function gets called when cached configuration is saved. + */ + public function modified() + { + $this->object->modified(true); + } + + /** + * 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. + */ + protected function loadFile($name, $filename) + { + $file = CompiledYamlFile::instance($filename); + if (preg_match('|languages\.yaml$|', $filename)) { + $this->object->mergeRecursive((array) $file->content()); + } else { + $this->object->mergeRecursive([$name => $file->content()]); + } + $file->free(); + } +} diff --git a/system/src/Grav/Common/Config/Config.php b/system/src/Grav/Common/Config/Config.php new file mode 100644 index 0000000..7b80846 --- /dev/null +++ b/system/src/Grav/Common/Config/Config.php @@ -0,0 +1,126 @@ +key) { + $this->key = md5($this->checksum . $this->timestamp); + } + + return $this->key; + } + + public function checksum($checksum = null) + { + if ($checksum !== null) { + $this->checksum = $checksum; + } + + return $this->checksum; + } + + public function modified($modified = null) + { + if ($modified !== null) { + $this->modified = $modified; + } + + return $this->modified; + } + + public function timestamp($timestamp = null) + { + if ($timestamp !== null) { + $this->timestamp = $timestamp; + } + + return $this->timestamp; + } + + public function reload() + { + $grav = Grav::instance(); + + // Load new configuration. + $config = ConfigServiceProvider::load($grav); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + + if ($config->modified()) { + // Update current configuration. + $this->items = $config->toArray(); + $this->checksum($config->checksum()); + $this->modified(true); + + $debugger->addMessage('Configuration was changed and saved.'); + } + + return $this; + } + + public function debug() + { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + + $debugger->addMessage('Environment Name: ' . $this->environment); + if ($this->modified()) { + $debugger->addMessage('Configuration reloaded and cached.'); + } + } + + public function init() + { + $setup = Grav::instance()['setup']->toArray(); + foreach ($setup as $key => $value) { + if ($key === 'streams' || !\is_array($value)) { + // Optimized as streams and simple values are fully defined in setup. + $this->items[$key] = $value; + } else { + $this->joinDefaults($key, $value); + } + } + + // Legacy value - Override the media.upload_limit based on PHP values + $this->items['system']['media']['upload_limit'] = Utils::getUploadLimit(); + } + + /** + * @return mixed + * @deprecated 1.5 Use Grav::instance()['languages'] instead. + */ + public function getLanguages() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use Grav::instance()[\'languages\'] instead', E_USER_DEPRECATED); + + return Grav::instance()['languages']; + } +} diff --git a/system/src/Grav/Common/Config/ConfigFileFinder.php b/system/src/Grav/Common/Config/ConfigFileFinder.php new file mode 100644 index 0000000..1e7adca --- /dev/null +++ b/system/src/Grav/Common/Config/ConfigFileFinder.php @@ -0,0 +1,263 @@ +base = $base ? "{$base}/" : ''; + + return $this; + } + + /** + * Return all locations for all the files with a timestamp. + * + * @param array $paths List of folders to look from. + * @param string $pattern Pattern to match the file. Pattern will also be removed from the key. + * @param int $levels Maximum number of recursive directories. + * @return array + */ + public function locateFiles(array $paths, $pattern = '|\.yaml$|', $levels = -1) + { + $list = []; + foreach ($paths as $folder) { + $list += $this->detectRecursive($folder, $pattern, $levels); + } + return $list; + } + + /** + * Return all locations for all the files with a timestamp. + * + * @param array $paths List of folders to look from. + * @param string $pattern Pattern to match the file. Pattern will also be removed from the key. + * @param int $levels Maximum number of recursive directories. + * @return array + */ + public function getFiles(array $paths, $pattern = '|\.yaml$|', $levels = -1) + { + $list = []; + foreach ($paths as $folder) { + $path = trim(Folder::getRelativePath($folder), '/'); + + $files = $this->detectRecursive($folder, $pattern, $levels); + + $list += $files[trim($path, '/')]; + } + return $list; + } + + /** + * Return all paths for all the files with a timestamp. + * + * @param array $paths List of folders to look from. + * @param string $pattern Pattern to match the file. Pattern will also be removed from the key. + * @param int $levels Maximum number of recursive directories. + * @return array + */ + public function listFiles(array $paths, $pattern = '|\.yaml$|', $levels = -1) + { + $list = []; + foreach ($paths as $folder) { + $list = array_merge_recursive($list, $this->detectAll($folder, $pattern, $levels)); + } + return $list; + } + + /** + * Find filename from a list of folders. + * + * Note: Only finds the last override. + * + * @param string $filename + * @param array $folders + * @return array + */ + public function locateFileInFolder($filename, array $folders) + { + $list = []; + foreach ($folders as $folder) { + $list += $this->detectInFolder($folder, $filename); + } + return $list; + } + + /** + * Find filename from a list of folders. + * + * @param array $folders + * @param string $filename + * @return array + */ + public function locateInFolders(array $folders, $filename = null) + { + $list = []; + foreach ($folders as $folder) { + $path = trim(Folder::getRelativePath($folder), '/'); + $list[$path] = $this->detectInFolder($folder, $filename); + } + return $list; + } + + /** + * Return all existing locations for a single file with a timestamp. + * + * @param array $paths Filesystem paths to look up from. + * @param string $name Configuration file to be located. + * @param string $ext File extension (optional, defaults to .yaml). + * @return array + */ + public function locateFile(array $paths, $name, $ext = '.yaml') + { + $filename = preg_replace('|[.\/]+|', '/', $name) . $ext; + + $list = []; + foreach ($paths as $folder) { + $path = trim(Folder::getRelativePath($folder), '/'); + + if (is_file("{$folder}/{$filename}")) { + $modified = filemtime("{$folder}/{$filename}"); + } else { + $modified = 0; + } + $basename = $this->base . $name; + $list[$path] = [$basename => ['file' => "{$path}/{$filename}", 'modified' => $modified]]; + } + + return $list; + } + + /** + * Detects all directories with a configuration file and returns them with last modification time. + * + * @param string $folder Location to look up from. + * @param string $pattern Pattern to match the file. Pattern will also be removed from the key. + * @param int $levels Maximum number of recursive directories. + * @return array + * @internal + */ + protected function detectRecursive($folder, $pattern, $levels) + { + $path = trim(Folder::getRelativePath($folder), '/'); + + if (is_dir($folder)) { + // Find all system and user configuration files. + $options = [ + 'levels' => $levels, + 'compare' => 'Filename', + 'pattern' => $pattern, + 'filters' => [ + 'pre-key' => $this->base, + 'key' => $pattern, + 'value' => function (\RecursiveDirectoryIterator $file) use ($path) { + return ['file' => "{$path}/{$file->getSubPathname()}", 'modified' => $file->getMTime()]; + } + ], + 'key' => 'SubPathname' + ]; + + $list = Folder::all($folder, $options); + + ksort($list); + } else { + $list = []; + } + + return [$path => $list]; + } + + /** + * 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). + * @return array + * @internal + */ + protected function detectInFolder($folder, $lookup = null) + { + $folder = rtrim($folder, '/'); + $path = trim(Folder::getRelativePath($folder), '/'); + $base = $path === $folder ? '' : ($path ? substr($folder, 0, -strlen($path)) : $folder . '/'); + + $list = []; + + if (is_dir($folder)) { + $iterator = new \DirectoryIterator($folder); + + /** @var \DirectoryIterator $directory */ + foreach ($iterator as $directory) { + if (!$directory->isDir() || $directory->isDot()) { + continue; + } + + $name = $directory->getFilename(); + $find = ($lookup ?: $name) . '.yaml'; + $filename = "{$path}/{$name}/{$find}"; + + if (file_exists($base . $filename)) { + $basename = $this->base . $name; + $list[$basename] = ['file' => $filename, 'modified' => filemtime($base . $filename)]; + } + } + } + + return $list; + } + + /** + * Detects all plugins with a configuration file and returns them with last modification time. + * + * @param string $folder Location to look up from. + * @param string $pattern Pattern to match the file. Pattern will also be removed from the key. + * @param int $levels Maximum number of recursive directories. + * @return array + * @internal + */ + protected function detectAll($folder, $pattern, $levels) + { + $path = trim(Folder::getRelativePath($folder), '/'); + + if (is_dir($folder)) { + // Find all system and user configuration files. + $options = [ + 'levels' => $levels, + 'compare' => 'Filename', + 'pattern' => $pattern, + 'filters' => [ + 'pre-key' => $this->base, + 'key' => $pattern, + 'value' => function (\RecursiveDirectoryIterator $file) use ($path) { + return ["{$path}/{$file->getSubPathname()}" => $file->getMTime()]; + } + ], + 'key' => 'SubPathname' + ]; + + $list = Folder::all($folder, $options); + + ksort($list); + } else { + $list = []; + } + + return $list; + } +} diff --git a/system/src/Grav/Common/Config/Languages.php b/system/src/Grav/Common/Config/Languages.php new file mode 100644 index 0000000..9c76dc5 --- /dev/null +++ b/system/src/Grav/Common/Config/Languages.php @@ -0,0 +1,83 @@ +checksum = $checksum; + } + + return $this->checksum; + } + + public function modified($modified = null) + { + if ($modified !== null) { + $this->modified = $modified; + } + + return $this->modified; + } + + public function timestamp($timestamp = null) + { + if ($timestamp !== null) { + $this->timestamp = $timestamp; + } + + return $this->timestamp; + } + + public function reformat() + { + if (isset($this->items['plugins'])) { + $this->items = array_merge_recursive($this->items, $this->items['plugins']); + unset($this->items['plugins']); + } + } + + public function mergeRecursive(array $data) + { + $this->items = Utils::arrayMergeRecursiveUnique($this->items, $data); + } + + public function flattenByLang($lang) + { + $language = $this->items[$lang]; + return Utils::arrayFlattenDotNotation($language); + } + + 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 new file mode 100644 index 0000000..6a4d336 --- /dev/null +++ b/system/src/Grav/Common/Config/Setup.php @@ -0,0 +1,321 @@ + 'unknown', + '127.0.0.1' => 'localhost', + '::1' => 'localhost' + ]; + + /** + * @var string Current environment normalized to lower case. + */ + public static $environment; + + protected $streams = [ + 'system' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['system'], + ] + ], + 'user' => [ + 'type' => 'ReadOnlyStream', + 'force' => true, + 'prefixes' => [ + '' => ['user'], + ] + ], + 'environment' => [ + 'type' => 'ReadOnlyStream' + // If not defined, environment will be set up in the constructor. + ], + 'asset' => [ + 'type' => 'Stream', + 'prefixes' => [ + '' => ['assets'], + ] + ], + 'blueprints' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['environment://blueprints', 'user://blueprints', 'system/blueprints'], + ] + ], + 'config' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['environment://config', 'user://config', 'system/config'], + ] + ], + 'plugins' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['user://plugins'], + ] + ], + 'plugin' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['user://plugins'], + ] + ], + 'themes' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['user://themes'], + ] + ], + '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'] + ] + ], + 'image' => [ + 'type' => 'Stream', + 'prefixes' => [ + '' => ['user://images', 'system://images'] + ] + ], + 'page' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['user://pages'] + ] + ], + 'user-data' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => ['user://data'] + ] + ], + 'account' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['user://accounts'] + ] + ], + ]; + + /** + * @param Container|array $container + */ + public function __construct($container) + { + // If no environment is set, make sure we get one (CLI or hostname). + if (!static::$environment) { + if (\defined('GRAV_CLI')) { + static::$environment = 'cli'; + } else { + /** @var ServerRequestInterface $request */ + $request = $container['request']; + $host = $request->getUri()->getHost(); + + static::$environment = Utils::substrToString($host, ':'); + } + } + + // Resolve server aliases to the proper environment. + $environment = $this->environments[static::$environment] ?? static::$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 : []; + + // Add default streams defined in beginning of the class. + if (!isset($setup['streams']['schemes'])) { + $setup['streams']['schemes'] = []; + } + $setup['streams']['schemes'] += $this->streams; + + // Initialize class. + parent::__construct($setup); + + // Set up environment. + $this->def('environment', $environment); + $this->def('streams.schemes.environment.prefixes', ['' => ["user://{$this->get('environment')}"]]); + } + + /** + * @return $this + * @throws \RuntimeException + * @throws \InvalidArgumentException + */ + public function init() + { + $locator = new UniformResourceLocator(GRAV_ROOT); + $files = []; + + $guard = 5; + do { + $check = $files; + $this->initializeLocator($locator); + $files = $locator->findResources('config://streams.yaml'); + + if ($check === $files) { + break; + } + + // Update streams. + foreach (array_reverse($files) as $path) { + $file = CompiledYamlFile::instance($path); + $content = (array)$file->content(); + if (!empty($content['schemes'])) { + $this->items['streams']['schemes'] = $content['schemes'] + $this->items['streams']['schemes']; + } + } + } while (--$guard); + + if (!$guard) { + throw new \RuntimeException('Setup: Configuration reload loop detected!'); + } + + // Make sure we have valid setup. + $this->check($locator); + + return $this; + } + + /** + * Initialize resource locator by using the configuration. + * + * @param UniformResourceLocator $locator + * @throws \BadMethodCallException + */ + public function initializeLocator(UniformResourceLocator $locator) + { + $locator->reset(); + + $schemes = (array) $this->get('streams.schemes', []); + + foreach ($schemes as $scheme => $config) { + if (isset($config['paths'])) { + $locator->addPath($scheme, '', $config['paths']); + } + + $override = $config['override'] ?? false; + $force = $config['force'] ?? false; + + if (isset($config['prefixes'])) { + foreach ((array)$config['prefixes'] as $prefix => $paths) { + $locator->addPath($scheme, $prefix, $paths, $override, $force); + } + } + } + } + + /** + * Get available streams and their types from the configuration. + * + * @return array + */ + public function getStreams() + { + $schemes = []; + foreach ((array) $this->get('streams.schemes') as $scheme => $config) { + $type = $config['type'] ?? 'ReadOnlyStream'; + if ($type[0] !== '\\') { + $type = '\\RocketTheme\\Toolbox\\StreamWrapper\\' . $type; + } + + $schemes[$scheme] = $type; + } + + return $schemes; + } + + /** + * @param UniformResourceLocator $locator + * @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!'); + } + $diff = array_keys(array_diff_key($this->streams, $streams)); + if ($diff) { + throw new \InvalidArgumentException( + sprintf('Configuration is missing keys %s from streams.schemes!', implode(', ', $diff)) + ); + } + + try { + 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' => []]); + $this->initializeLocator($locator); + } + + // Create security.yaml if it doesn't exist. + $filename = $locator->findResource('config://security.yaml', true, true); + $security_file = CompiledYamlFile::instance($filename); + $security_content = (array)$security_file->content(); + + if (!isset($security_content['salt'])) { + $security_content = array_merge($security_content, ['salt' => Utils::generateRandomString(14)]); + $security_file->content($security_content); + $security_file->save(); + $security_file->free(); + } + } 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 new file mode 100644 index 0000000..e703777 --- /dev/null +++ b/system/src/Grav/Common/Data/Blueprint.php @@ -0,0 +1,428 @@ +blueprintSchema) { + $this->blueprintSchema = clone $this->blueprintSchema; + } + } + + public function setScope($scope) + { + $this->scope = $scope; + } + + /** + * Set default values for field types. + * + * @param array $types + * @return $this + */ + public function setTypes(array $types) + { + $this->initInternals(); + + $this->blueprintSchema->setTypes($types); + + 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() + { + $this->initInternals(); + + if (null === $this->defaults) { + $this->defaults = $this->blueprintSchema->getDefaults(); + } + + return $this->defaults; + } + + /** + * Initialize blueprints with its dynamic fields. + * + * @return $this + */ + public function init() + { + foreach ($this->dynamic as $key => $data) { + // Locate field. + $path = explode('/', $key); + $current = &$this->items; + + foreach ($path as $field) { + if (\is_object($current)) { + // Handle objects. + if (!isset($current->{$field})) { + $current->{$field} = []; + } + + $current = &$current->{$field}; + } else { + // Handle arrays and scalars. + if (!\is_array($current)) { + $current = [$field => []]; + } elseif (!isset($current[$field])) { + $current[$field] = []; + } + + $current = &$current[$field]; + } + } + + // Set dynamic property. + foreach ($data as $property => $call) { + $action = $call['action']; + $method = 'dynamic' . ucfirst($action); + + if (isset($this->handlers[$action])) { + $callable = $this->handlers[$action]; + $callable($current, $property, $call); + } elseif (method_exists($this, $method)) { + $this->{$method}($current, $property, $call); + } + } + } + + return $this; + } + + /** + * Merge two arrays by using blueprints. + * + * @param array $data1 + * @param array $data2 + * @param string $name Optional + * @param string $separator Optional + * @return array + */ + public function mergeData(array $data1, array $data2, $name = null, $separator = '.') + { + $this->initInternals(); + + return $this->blueprintSchema->mergeData($data1, $data2, $name, $separator); + } + + /** + * Process data coming from a form. + * + * @param array $data + * @param array $toggles + * @return array + */ + public function processForm(array $data, array $toggles = []) + { + $this->initInternals(); + + return $this->blueprintSchema->processForm($data, $toggles); + } + + /** + * Return data fields that do not exist in blueprints. + * + * @param array $data + * @param string $prefix + * @return array + */ + public function extra(array $data, $prefix = '') + { + $this->initInternals(); + + return $this->blueprintSchema->extra($data, $prefix); + } + + /** + * Validate data against blueprints. + * + * @param array $data + * @throws \RuntimeException + */ + public function validate(array $data) + { + $this->initInternals(); + + $this->blueprintSchema->validate($data); + } + + /** + * Filter data by using blueprints. + * + * @param array $data + * @param bool $missingValuesAsNull + * @param bool $keepEmptyValues + * @return array + */ + public function filter(array $data, bool $missingValuesAsNull = false, bool $keepEmptyValues = false) + { + $this->initInternals(); + + return $this->blueprintSchema->filter($data, $missingValuesAsNull, $keepEmptyValues); + } + + + /** + * Flatten data by using blueprints. + * + * @param array $data + * @return array + */ + public function flattenData(array $data) + { + $this->initInternals(); + + return $this->blueprintSchema->flattenData($data); + } + + + /** + * Return blueprint data schema. + * + * @return BlueprintSchema + */ + public function schema() + { + $this->initInternals(); + + return $this->blueprintSchema; + } + + public function addDynamicHandler(string $name, callable $callable): void + { + $this->handlers[$name] = $callable; + } + + /** + * Initialize validator. + */ + protected function initInternals() + { + if (null === $this->blueprintSchema) { + $types = Grav::instance()['plugins']->formFieldTypes; + + $this->blueprintSchema = new BlueprintSchema; + + if ($types) { + $this->blueprintSchema->setTypes($types); + } + + $this->blueprintSchema->embed('', $this->items); + $this->blueprintSchema->init(); + $this->defaults = null; + } + } + + /** + * @param string $filename + * @return string + */ + protected function loadFile($filename) + { + $file = CompiledYamlFile::instance($filename); + $content = $file->content(); + $file->free(); + + return $content; + } + + /** + * @param string|array $path + * @param string $context + * @return array + */ + protected function getFiles($path, $context = null) + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + if (\is_string($path) && !$locator->isStream($path)) { + // Find path overrides. + $paths = (array) ($this->overrides[$path] ?? null); + + // Add path pointing to default context. + if ($context === null) { + $context = $this->context; + } + + if ($context && $context[\strlen($context)-1] !== '/') { + $context .= '/'; + } + + $path = $context . $path; + + if (!preg_match('/\.yaml$/', $path)) { + $path .= '.yaml'; + } + + $paths[] = $path; + } else { + $paths = (array) $path; + } + + $files = []; + foreach ($paths as $lookup) { + if (\is_string($lookup) && strpos($lookup, '://')) { + $files = array_merge($files, $locator->findResources($lookup)); + } else { + $files[] = $lookup; + } + } + + return array_values(array_unique($files)); + } + + /** + * @param array $field + * @param string $property + * @param array $call + */ + protected function dynamicData(array &$field, $property, array &$call) + { + $params = $call['params']; + + if (\is_array($params)) { + $function = array_shift($params); + } else { + $function = $params; + $params = []; + } + + [$o, $f] = explode('::', $function, 2); + + $data = null; + if (!$f) { + 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); + } + } + + // If function returns a value, + if (null !== $data) { + if (\is_array($data) && isset($field[$property]) && \is_array($field[$property])) { + // Combine field and @data-field together. + $field[$property] += $data; + } else { + // Or create/replace field with @data-field. + $field[$property] = $data; + } + } + } + + /** + * @param array $field + * @param string $property + * @param array $call + */ + protected function dynamicConfig(array &$field, $property, array &$call) + { + $value = $call['params']; + $default = $field[$property] ?? null; + $config = Grav::instance()['config']->get($value, $default); + + if (null !== $config) { + $field[$property] = $config; + } + } + + /** + * @param array $field + * @param string $property + * @param array $call + */ + protected function dynamicSecurity(array &$field, $property, array &$call) + { + if ($property || !empty($field['validate']['ignore'])) { + return; + } + + $grav = Grav::instance(); + $actions = (array)$call['params']; + + /** @var UserInterface|null $user */ + $user = $grav['user'] ?? null; + foreach ($actions as $action) { + if (!$user || !$user->authorize($action)) { + $this->addPropertyRecursive($field, 'validate', ['ignore' => true]); + return; + } + } + } + + /** + * @param array $field + * @param string $property + * @param array $call + */ + protected function dynamicScope(array &$field, $property, array &$call) + { + if ($property && $property !== 'ignore') { + return; + } + + $scopes = (array)$call['params']; + $matches = \in_array($this->scope, $scopes, true); + if ($this->scope && $property !== 'ignore') { + $matches = !$matches; + } + + if ($matches) { + $this->addPropertyRecursive($field, 'validate', ['ignore' => true]); + return; + } + } + + protected function addPropertyRecursive(array &$field, $property, $value) + { + if (\is_array($value) && isset($field[$property]) && \is_array($field[$property])) { + $field[$property] = array_merge_recursive($field[$property], $value); + } else { + $field[$property] = $value; + } + + if (!empty($field['fields'])) { + foreach ($field['fields'] as $key => &$child) { + $this->addPropertyRecursive($child, $property, $value); + } + } + } +} diff --git a/system/src/Grav/Common/Data/BlueprintSchema.php b/system/src/Grav/Common/Data/BlueprintSchema.php new file mode 100644 index 0000000..c79b3fd --- /dev/null +++ b/system/src/Grav/Common/Data/BlueprintSchema.php @@ -0,0 +1,319 @@ + true, + 'help' => true, + 'placeholder' => true, + 'placeholder_key' => true, + 'placeholder_value' => true, + 'fields' => true + ]; + + /** + * @return array + */ + public function getTypes() + { + return $this->types; + } + + /** + * @param string $name + * @return array + */ + public function getType($name) + { + return $this->types[$name] ?? []; + } + + /** + * Validate data against blueprints. + * + * @param array $data + * @throws \RuntimeException + */ + public function validate(array $data) + { + try { + $messages = $this->validateArray($data, $this->nested); + + } catch (\RuntimeException $e) { + throw (new ValidationException($e->getMessage(), $e->getCode(), $e))->setMessages(); + } + + if (!empty($messages)) { + throw (new ValidationException())->setMessages($messages); + } + } + + /** + * @param array $data + * @param array $toggles + * @return array + */ + public function processForm(array $data, array $toggles = []) + { + return $this->processFormRecursive($data, $toggles, $this->nested); + } + + /** + * Filter data by using blueprints. + * + * @param array $data Incoming data, for example from a form. + * @param bool $missingValuesAsNull Include missing values as nulls. + * @param bool $keepEmptyValues Include empty values. + * @return array + */ + public function filter(array $data, $missingValuesAsNull = false, $keepEmptyValues = false) + { + return $this->filterArray($data, $this->nested, $missingValuesAsNull, $keepEmptyValues); + } + + /** + * Flatten data by using blueprints. + * + * @param array $data Data to be flattened. + * @return array + */ + public function flattenData(array $data) + { + return $this->flattenArray($data, $this->nested, ''); + } + + /** + * @param array $data + * @param array $rules + * @param string $prefix + * @return array + */ + protected function flattenArray(array $data, array $rules, string $prefix) + { + $array = []; + + foreach ($data as $key => $field) { + $val = $rules[$key] ?? $rules['*'] ?? null; + $rule = is_string($val) ? $this->items[$val] : null; + + if ($rule || isset($val['*'])) { + // Item has been defined in blueprints. + $array[$prefix.$key] = $field; + } elseif (is_array($field) && is_array($val)) { + // Array has been defined in blueprints. + $array += $this->flattenArray($field, $val, $prefix . $key . '.'); + } else { + // Undefined/extra item. + $array[$prefix.$key] = $field; + } + } + return $array; + } + + /** + * @param array $data + * @param array $rules + * @return array + * @throws \RuntimeException + */ + protected function validateArray(array $data, array $rules) + { + $messages = $this->checkRequired($data, $rules); + + foreach ($data as $key => $child) { + $val = $rules[$key] ?? $rules['*'] ?? null; + $rule = \is_string($val) ? $this->items[$val] : null; + + if ($rule) { + // Item has been defined in blueprints. + if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) { + // Skip validation in the ignored field. + continue; + } + + $messages += Validation::validate($child, $rule); + } elseif (\is_array($child) && \is_array($val)) { + // Array has been defined in blueprints. + $messages += $this->validateArray($child, $val); + } elseif (isset($rules['validation']) && $rules['validation'] === 'strict') { + // Undefined/extra item. + throw new \RuntimeException(sprintf('%s is not defined in blueprints', $key)); + } + } + + return $messages; + } + + /** + * @param array $data + * @param array $rules + * @param bool $missingValuesAsNull + * @param bool $keepEmptyValues + * @return array + */ + protected function filterArray(array $data, array $rules, $missingValuesAsNull, $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; + + if ($rule) { + // Item has been defined in blueprints. + if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) { + // Skip any data in the ignored field. + unset($results[$key]); + continue; + } + + $field = Validation::filter($field, $rule); + } elseif (\is_array($field) && \is_array($val)) { + // Array has been defined in blueprints. + $field = $this->filterArray($field, $val, $missingValuesAsNull, $keepEmptyValues); + + } elseif (isset($rules['validation']) && $rules['validation'] === 'strict') { + // Skip any extra data. + continue; + } + + if ($keepEmptyValues || (null !== $field && (!\is_array($field) || !empty($field)))) { + $results[$key] = $field; + } + } + + return $results ?: null; + } + + /** + * @param array|null $data + * @param array $toggles + * @param array $nested + * @return array|null + */ + protected function processFormRecursive(?array $data, array $toggles, array $nested) + { + foreach ($nested as $key => $value) { + if ($key === '') { + continue; + } + if ($key === '*') { + // TODO: Add support to collections. + continue; + } + if (is_array($value)) { + // Recursively fetch the items. + $data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggles[$key] ?? [], $value); + } else { + $field = $this->get($value); + // Do not add the field if: + if ( + // Not an input field + !$field + // Field has been disabled + || !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])) + ) { + continue; + } + if (!isset($data[$key])) { + $data[$key] = null; + } + } + } + + return $data; + } + + /** + * @param array $data + * @param array $fields + * @return array + */ + protected function checkRequired(array $data, array $fields) + { + $messages = []; + + foreach ($fields as $name => $field) { + if (!\is_string($field)) { + continue; + } + + $field = $this->items[$field]; + + // Skip ignored field, it will not be required. + if (!empty($field['disabled']) || !empty($field['validate']['ignore'])) { + continue; + } + + // Check if required. + if (isset($field['validate']['required']) + && $field['validate']['required'] === true) { + + if (isset($data[$name])) { + continue; + } + if ($field['type'] === 'file' && isset($data['data']['name'][$name])) { //handle case of file input fields required + continue; + } + + $value = $field['label'] ?? $field['name']; + $language = Grav::instance()['language']; + $message = sprintf($language->translate('GRAV.FORM.MISSING_REQUIRED_FIELD', null, true) . ' %s', $language->translate($value)); + $messages[$field['name']][] = $message; + } + } + + return $messages; + } + + /** + * @param array $field + * @param string $property + * @param array $call + */ + protected function dynamicConfig(array &$field, $property, array &$call) + { + $value = $call['params']; + + $default = $field[$property] ?? null; + $config = Grav::instance()['config']->get($value, $default); + + if (null !== $config) { + $field[$property] = $config; + } + } +} diff --git a/system/src/Grav/Common/Data/Blueprints.php b/system/src/Grav/Common/Data/Blueprints.php new file mode 100644 index 0000000..1d0fcc6 --- /dev/null +++ b/system/src/Grav/Common/Data/Blueprints.php @@ -0,0 +1,114 @@ +search = $search; + } + + /** + * Get blueprint. + * + * @param string $type Blueprint type. + * @return Blueprint + * @throws \RuntimeException + */ + public function get($type) + { + if (!isset($this->instances[$type])) { + $blueprint = $this->loadFile($type); + $this->instances[$type] = $blueprint; + } + + return $this->instances[$type]; + } + + /** + * Get all available blueprint types. + * + * @return array List of type=>name + */ + public function types() + { + if ($this->types === null) { + $this->types = []; + + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + // Get stream / directory iterator. + if ($locator->isStream($this->search)) { + $iterator = $locator->getIterator($this->search); + } else { + $iterator = new \DirectoryIterator($this->search); + } + + /** @var \DirectoryIterator $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || '.' . $file->getExtension() !== YAML_EXT) { + continue; + } + $name = $file->getBasename(YAML_EXT); + $this->types[$name] = ucfirst(str_replace('_', ' ', $name)); + } + } + + return $this->types; + } + + + /** + * Load blueprint file. + * + * @param string $name Name of the blueprint. + * @return Blueprint + */ + protected function loadFile($name) + { + $blueprint = new Blueprint($name); + + if (\is_array($this->search) || \is_object($this->search)) { + // Page types. + $blueprint->setOverrides($this->search); + $blueprint->setContext('blueprints://pages'); + } else { + $blueprint->setContext($this->search); + } + + try { + $blueprint->load()->init(); + } catch (\RuntimeException $e) { + $log = Grav::instance()['log']; + $log->error(sprintf('Blueprint %s cannot be loaded: %s', $name, $e->getMessage())); + + throw $e; + } + + return $blueprint; + } +} diff --git a/system/src/Grav/Common/Data/Data.php b/system/src/Grav/Common/Data/Data.php new file mode 100644 index 0000000..97b5530 --- /dev/null +++ b/system/src/Grav/Common/Data/Data.php @@ -0,0 +1,327 @@ +items = $items; + $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; + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @example $value = $data->value('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string $separator Separator, defaults to '.' + * @return mixed Value. + */ + public function value($name, $default = null, $separator = '.') + { + return $this->get($name, $default, $separator); + } + + /** + * 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 $separator Separator, defaults to '.' + * @return $this + * @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_object($value)) { + $value = (array) $value; + } elseif (!\is_array($value)) { + throw new \RuntimeException('Value ' . $value); + } + + $value = $this->blueprints()->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->blueprints()->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 $separator Separator, defaults to '.' + * @return $this + */ + public function joinDefaults($name, $value, $separator = '.') + { + if (\is_object($value)) { + $value = (array) $value; + } + + $old = $this->get($name, null, $separator); + if ($old !== null) { + $value = $this->blueprints()->mergeData($value, $old, $name, $separator); + } + + $this->set($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 = '.') + { + 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->blueprints()->mergeData($old, $value, $name, $separator); + } + + + /** + * Merge two configurations together. + * + * @param array $data + * @return $this + */ + public function merge(array $data) + { + $this->items = $this->blueprints()->mergeData($this->items, $data); + + return $this; + } + + /** + * Set default values to the configuration if variables were not set. + * + * @param array $data + * @return $this + */ + public function setDefaults(array $data) + { + $this->items = $this->blueprints()->mergeData($data, $this->items); + + return $this; + } + + /** + * Validate by blueprints. + * + * @return $this + * @throws \Exception + */ + public function validate() + { + $this->blueprints()->validate($this->items); + + return $this; + } + + /** + * @return $this + */ + public function filter() + { + $args = func_get_args(); + $missingValuesAsNull = (bool)(array_shift($args) ?? $this->missingValuesAsNull); + $keepEmptyValues = (bool)(array_shift($args) ?? $this->keepEmptyValues); + + $this->items = $this->blueprints()->filter($this->items, $missingValuesAsNull, $keepEmptyValues); + + return $this; + } + + /** + * Get extra items which haven't been defined in blueprints. + * + * @return array + */ + public function extra() + { + return $this->blueprints()->extra($this->items); + } + + /** + * Return blueprints. + * + * @return Blueprint + */ + public function blueprints() + { + if (!$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 + */ + public function save() + { + $file = $this->file(); + if ($file) { + $file->save($this->items); + } + } + + /** + * Returns whether the data already exists in the storage. + * + * NOTE: This method does not check if the data is current. + * + * @return bool + */ + public function exists() + { + $file = $this->file(); + + return $file && $file->exists(); + } + + /** + * 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 $storage Optionally enter a new storage. + * @return FileInterface + */ + public function file(FileInterface $storage = null) + { + if ($storage) { + $this->storage = $storage; + } + + return $this->storage; + } + + public function jsonSerialize() + { + return $this->items; + } +} diff --git a/system/src/Grav/Common/Data/DataInterface.php b/system/src/Grav/Common/Data/DataInterface.php new file mode 100644 index 0000000..91279b2 --- /dev/null +++ b/system/src/Grav/Common/Data/DataInterface.php @@ -0,0 +1,70 @@ +value('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string $separator Separator, defaults to '.' + * @return mixed Value. + */ + public function value($name, $default = null, $separator = '.'); + + /** + * Merge external data. + * + * @param array $data + * @return mixed + */ + public function merge(array $data); + + /** + * Return blueprints. + */ + public function blueprints(); + + /** + * Validate by blueprints. + * + * @throws \Exception + */ + public function validate(); + + /** + * Filter all items by using blueprints. + */ + public function filter(); + + /** + * Get extra items which haven't been defined in blueprints. + */ + public function extra(); + + /** + * Save data into the file. + */ + public function save(); + + /** + * Set or get the data storage. + * + * @param FileInterface $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 new file mode 100644 index 0000000..a2bf0c5 --- /dev/null +++ b/system/src/Grav/Common/Data/Validation.php @@ -0,0 +1,813 @@ +translate($field['validate']['message']) + : $language->translate('GRAV.FORM.INVALID_INPUT', null, true) . ' "' . $language->translate($name) . '"'; + + + // Validate type with fallback type text. + $method = 'type' . str_replace('-', '_', $type); + + // If this is a YAML field validate/filter as such + if (isset($field['yaml']) && $field['yaml'] === true) { + $method = 'typeYaml'; + } + + $messages = []; + + $success = method_exists(__CLASS__, $method) ? self::$method($value, $validate, $field) : true; + if (!$success) { + $messages[$field['name']][] = $message; + } + + // Check individual rules. + foreach ($validate as $rule => $params) { + $method = 'validate' . ucfirst(str_replace('-', '_', $rule)); + + if (method_exists(__CLASS__, $method)) { + $success = self::$method($value, $params); + + if (!$success) { + $messages[$field['name']][] = $message; + } + } + } + + return $messages; + } + + /** + * Filter value against a blueprint field definition. + * + * @param mixed $value + * @param array $field + * @return mixed Filtered value. + */ + public static function filter($value, array $field) + { + $validate = (array)($field['filter'] ?? $field['validate'] ?? null); + + // If value isn't required, we will return null if empty value is given. + if (($value === null || $value === '') && empty($validate['required'])) { + return null; + } + + if (!isset($field['type'])) { + $field['type'] = 'text'; + } + $type = $field['filter']['type'] ?? $field['validate']['type'] ?? $field['type']; + + $method = 'filter' . ucfirst(str_replace('-', '_', $type)); + + // If this is a YAML field validate/filter as such + if (isset($field['yaml']) && $field['yaml'] === true) { + $method = 'filterYaml'; + } + + if (!method_exists(__CLASS__, $method)) { + $method = isset($field['array']) && $field['array'] === true ? 'filterArray' : 'filterText'; + } + + return self::$method($value, $validate, $field); + } + + /** + * HTML5 input: text + * + * @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 typeText($value, array $params, array $field) + { + if (!\is_string($value) && !is_numeric($value)) { + return false; + } + + $value = (string)$value; + + if (!empty($params['trim'])) { + $value = trim($value); + } + + if (isset($params['min']) && \strlen($value) < $params['min']) { + return false; + } + + if (isset($params['max']) && \strlen($value) > $params['max']) { + return false; + } + + $min = $params['min'] ?? 0; + if (isset($params['step']) && (\strlen($value) - $min) % $params['step'] === 0) { + return false; + } + + if ((!isset($params['multiline']) || !$params['multiline']) && preg_match('/\R/um', $value)) { + return false; + } + + return true; + } + + protected static function filterText($value, array $params, array $field) + { + if (!\is_string($value) && !is_numeric($value)) { + return ''; + } + + if (!empty($params['trim'])) { + $value = trim($value); + } + + return (string) $value; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return string|null + */ + protected static function filterCheckbox($value, array $params, array $field) + { + $value = (string)$value; + $field_value = (string)($field['value'] ?? '1'); + + return $value === $field_value ? $value : null; + } + + protected static function filterCommaList($value, array $params, array $field) + { + return \is_array($value) ? $value : preg_split('/\s*,\s*/', $value, -1, PREG_SPLIT_NO_EMPTY); + } + + public static function typeCommaList($value, array $params, array $field) + { + return \is_array($value) ? true : self::typeText($value, $params, $field); + } + + protected static function filterLower($value, array $params) + { + return strtolower($value); + } + + protected static function filterUpper($value, array $params) + { + return strtoupper($value); + } + + + /** + * HTML5 input: textarea + * + * @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 typeTextarea($value, array $params, array $field) + { + if (!isset($params['multiline'])) { + $params['multiline'] = true; + } + + return self::typeText($value, $params, $field); + } + + /** + * HTML5 input: password + * + * @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 typePassword($value, array $params, array $field) + { + return self::typeText($value, $params, $field); + } + + /** + * HTML5 input: hidden + * + * @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 typeHidden($value, array $params, array $field) + { + return self::typeText($value, $params, $field); + } + + /** + * Custom input: checkbox list + * + * @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 typeCheckboxes($value, array $params, array $field) + { + // Set multiple: true so checkboxes can easily use min/max counts to control number of options required + $field['multiple'] = true; + + return self::typeArray((array) $value, $params, $field); + } + + protected static function filterCheckboxes($value, array $params, array $field) + { + return self::filterArray($value, $params, $field); + } + + /** + * HTML5 input: checkbox + * + * @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 typeCheckbox($value, array $params, array $field) + { + $value = (string)$value; + $field_value = (string)($field['value'] ?? '1'); + + return $value === $field_value; + } + + /** + * HTML5 input: radio + * + * @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 typeRadio($value, array $params, array $field) + { + return self::typeArray((array) $value, $params, $field); + } + + /** + * Custom input: toggle + * + * @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 typeToggle($value, array $params, array $field) + { + if (\is_bool($value)) { + $value = (int)$value; + } + + return self::typeArray((array) $value, $params, $field); + } + + /** + * Custom input: file + * + * @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 typeFile($value, array $params, array $field) + { + return self::typeArray((array)$value, $params, $field); + } + + protected static function filterFile($value, array $params, array $field) + { + return (array)$value; + } + + /** + * HTML5 input: select + * + * @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 typeSelect($value, array $params, array $field) + { + return self::typeArray((array) $value, $params, $field); + } + + /** + * HTML5 input: number + * + * @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 typeNumber($value, array $params, array $field) + { + if (!is_numeric($value)) { + return false; + } + + if (isset($params['min']) && $value < $params['min']) { + return false; + } + + if (isset($params['max']) && $value > $params['max']) { + return false; + } + + $min = $params['min'] ?? 0; + + return !(isset($params['step']) && fmod($value - $min, $params['step']) === 0); + } + + protected static function filterNumber($value, array $params, array $field) + { + return (string)(int)$value !== (string)(float)$value ? (float) $value : (int) $value; + } + + protected static function filterDateTime($value, array $params, array $field) + { + $format = Grav::instance()['config']->get('system.pages.dateformat.default'); + if ($format) { + $converted = new \DateTime($value); + return $converted->format($format); + } + return $value; + } + + + /** + * HTML5 input: range + * + * @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 typeRange($value, array $params, array $field) + { + return self::typeNumber($value, $params, $field); + } + + protected static function filterRange($value, array $params, array $field) + { + return self::filterNumber($value, $params, $field); + } + + /** + * HTML5 input: color + * + * @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 typeColor($value, array $params, array $field) + { + return preg_match('/^\#[0-9a-fA-F]{3}[0-9a-fA-F]{3}?$/u', $value); + } + + /** + * HTML5 input: email + * + * @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 typeEmail($value, array $params, array $field) + { + $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))) { + return false; + } + } + + return true; + } + + /** + * HTML5 input: url + * + * @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 typeUrl($value, array $params, array $field) + { + return self::typeText($value, $params, $field) && filter_var($value, FILTER_VALIDATE_URL); + } + + /** + * HTML5 input: datetime + * + * @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 typeDatetime($value, array $params, array $field) + { + if ($value instanceof \DateTime) { + return true; + } + if (!\is_string($value)) { + return false; + } + if (!isset($params['format'])) { + return false !== strtotime($value); + } + + $dateFromFormat = \DateTime::createFromFormat($params['format'], $value); + + return $dateFromFormat && $value === date($params['format'], $dateFromFormat->getTimestamp()); + } + + /** + * HTML5 input: datetime-local + * + * @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 typeDatetimeLocal($value, array $params, array $field) + { + return self::typeDatetime($value, $params, $field); + } + + /** + * HTML5 input: date + * + * @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 typeDate($value, array $params, array $field) + { + if (!isset($params['format'])) { + $params['format'] = 'Y-m-d'; + } + + return self::typeDatetime($value, $params, $field); + } + + /** + * HTML5 input: time + * + * @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 typeTime($value, array $params, array $field) + { + if (!isset($params['format'])) { + $params['format'] = 'H:i'; + } + + return self::typeDatetime($value, $params, $field); + } + + /** + * HTML5 input: month + * + * @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 typeMonth($value, array $params, array $field) + { + if (!isset($params['format'])) { + $params['format'] = 'Y-m'; + } + + return self::typeDatetime($value, $params, $field); + } + + /** + * HTML5 input: week + * + * @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 typeWeek($value, array $params, array $field) + { + if (!isset($params['format']) && !preg_match('/^\d{4}-W\d{2}$/u', $value)) { + return false; + } + + return self::typeDatetime($value, $params, $field); + } + + /** + * Custom input: array + * + * @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 typeArray($value, array $params, array $field) + { + if (!\is_array($value)) { + return false; + } + + if (isset($field['multiple'])) { + if (isset($params['min']) && \count($value) < $params['min']) { + return false; + } + + if (isset($params['max']) && \count($value) > $params['max']) { + return false; + } + + $min = $params['min'] ?? 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'])) { + return true; + } + + $options = $field['options'] ?? []; + $use = $field['use'] ?? 'values'; + + if (empty($field['selectize']) || empty($field['multiple'])) { + $options = array_keys($options); + } + if ($use === 'keys') { + $value = array_keys($value); + } + + return !($options && array_diff($value, $options)); + } + + 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] === '') { + return null; + } + + + if ($options) { + $useKey = isset($field['use']) && $field['use'] === 'keys'; + foreach ($values as $key => $val) { + $values[$key] = $useKey ? (bool) $val : $val; + } + } + + if ($multi) { + foreach ($values as $key => $val) { + if (\is_array($val)) { + $val = implode(',', $val); + $values[$key] = array_map('trim', explode(',', $val)); + } else { + $values[$key] = trim($val); + } + } + } + + if (isset($field['ignore_empty']) && Utils::isPositive($field['ignore_empty'])) { + foreach ($values as $key => $val) { + if ($val === '') { + unset($values[$key]); + } elseif (\is_array($val)) { + foreach ($val as $inner_key => $inner_value) { + if ($inner_value === '') { + unset($val[$inner_key]); + } + } + } + + $values[$key] = $val; + } + } + + return $values; + } + + public static function typeList($value, array $params, array $field) + { + if (!\is_array($value)) { + return false; + } + + if (isset($field['fields'])) { + foreach ($value as $key => $item) { + foreach ($field['fields'] as $subKey => $subField) { + $subKey = trim($subKey, '.'); + $subValue = $item[$subKey] ?? null; + self::validate($subValue, $subField); + } + } + } + + return true; + } + + protected static function filterList($value, array $params, array $field) + { + return (array) $value; + } + + public static function filterYaml($value, $params) + { + if (!\is_string($value)) { + return $value; + } + + return (array) Yaml::parse($value); + + } + + /** + * Custom input: ignore (will not validate) + * + * @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 typeIgnore($value, array $params, array $field) + { + return true; + } + + public static function filterIgnore($value, array $params, array $field) + { + return $value; + } + + /** + * Input value which can be ignored. + * + * @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 typeUnset($value, array $params, array $field) + { + return true; + } + + public static function filterUnset($value, array $params, array $field) + { + return null; + } + + // HTML5 attributes (min, max and range are handled inside the types) + + public static function validateRequired($value, $params) + { + if (is_scalar($value)) { + return (bool) $params !== true || $value !== ''; + } + + return (bool) $params !== true || !empty($value); + } + + public static function validatePattern($value, $params) + { + return (bool) preg_match("`^{$params}$`u", $value); + } + + + // Internal types + + public static function validateAlpha($value, $params) + { + return ctype_alpha($value); + } + + public static function validateAlnum($value, $params) + { + return ctype_alnum($value); + } + + public static function typeBool($value, $params) + { + return \is_bool($value) || $value == 1 || $value == 0; + } + + public static function validateBool($value, $params) + { + return \is_bool($value) || $value == 1 || $value == 0; + } + + protected static function filterBool($value, $params) + { + return (bool) $value; + } + + public static function validateDigit($value, $params) + { + return ctype_digit($value); + } + + public static function validateFloat($value, $params) + { + return \is_float(filter_var($value, FILTER_VALIDATE_FLOAT)); + } + + protected static function filterFloat($value, $params) + { + return (float) $value; + } + + public static function validateHex($value, $params) + { + return ctype_xdigit($value); + } + + public static function validateInt($value, $params) + { + return is_numeric($value) && (int)$value == $value; + } + + protected static function filterInt($value, $params) + { + return (int)$value; + } + + public static function validateArray($value, $params) + { + return \is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable && $value instanceof \Countable); + } + + public static function filterItem_List($value, $params) + { + return array_values(array_filter($value, function($v) { return !empty($v); } )); + } + + 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 new file mode 100644 index 0000000..8bdb057 --- /dev/null +++ b/system/src/Grav/Common/Data/ValidationException.php @@ -0,0 +1,38 @@ +messages = $messages; + + $language = Grav::instance()['language']; + $this->message = $language->translate('GRAV.FORM.VALIDATION_FAIL', null, true) . ' ' . $this->message; + + foreach ($messages as $variable => &$list) { + $list = array_unique($list); + foreach ($list as $message) { + $this->message .= "
$message"; + } + } + + return $this; + } + + public function getMessages() + { + return $this->messages; + } +} diff --git a/system/src/Grav/Common/Debugger.php b/system/src/Grav/Common/Debugger.php new file mode 100644 index 0000000..0737209 --- /dev/null +++ b/system/src/Grav/Common/Debugger.php @@ -0,0 +1,606 @@ +init() gets called. + $this->enabled = true; + + $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)); + + $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)); + + $this->debugbar = $debugbar; + + // Set deprecation collector. + $this->setErrorHandler(); + } + + /** + * Initialize the debugger + * + * @return $this + * @throws \DebugBar\DebugBarException + */ + public function init() + { + if ($this->initialized) { + return $this; + } + + $this->grav = Grav::instance(); + $this->config = $this->grav['config']; + + // Enable/disable debugger based on configuration. + $this->enabled = (bool)$this->config->get('system.debugger.enabled'); + + if ($this->enabled()) { + $this->initialized = true; + + $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); + } + + return $this; + } + + /** + * 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 + * + * @return bool + */ + public function enabled($state = null) + { + if ($state !== null) { + $this->enabled = (bool)$state; + } + + return $this->enabled; + } + + /** + * Add the debugger assets to the Grav Assets + * + * @return $this + */ + public function addAssets() + { + if ($this->enabled()) { + + // Only add assets if Page is HTML + $page = $this->grav['page']; + if ($page->templateFormat() !== 'html') { + return $this; + } + + /** @var Assets $assets */ + $assets = $this->grav['assets']; + + // Add jquery library + $assets->add('jquery', 101); + + $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); + } + + $assets->addCss('/system/assets/debugger.css'); + + foreach ((array)$js_files as $js) { + $assets->addJs($js); + } + } + + return $this; + } + + public function getCaller($limit = 2) + { + $trace = debug_backtrace(false, $limit); + + return array_pop($trace); + } + + /** + * Adds a data collector + * + * @param DataCollectorInterface $collector + * + * @return $this + * @throws \DebugBar\DebugBarException + */ + public function addCollector($collector) + { + $this->debugbar->addCollector($collector); + + return $this; + } + + /** + * Returns a data collector + * + * @param DataCollectorInterface $collector + * + * @return DataCollectorInterface + * @throws \DebugBar\DebugBarException + */ + public function getCollector($collector) + { + return $this->debugbar->getCollector($collector); + } + + /** + * Displays the debug bar + * + * @return $this + */ + public function render() + { + if ($this->enabled()) { + // Only add assets if Page is HTML + $page = $this->grav['page']; + if (!$this->renderer || $page->templateFormat() !== 'html') { + return $this; + } + + $this->addDeprecations(); + + echo $this->renderer->render(); + } + + return $this; + } + + /** + * Sends the data through the HTTP headers + * + * @return $this + */ + public function sendDataInHeaders() + { + if ($this->enabled()) { + $this->addDeprecations(); + $this->debugbar->sendDataInHeaders(); + } + + return $this; + } + + /** + * Returns collected debugger data. + * + * @return array + */ + public function getData() + { + if (!$this->enabled()) { + return null; + } + + $this->addDeprecations(); + $this->timers = []; + + return $this->debugbar->getData(); + } + + /** + * 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; + } + + return $this; + } + + /** + * 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); + } + + return $this; + } + + /** + * Dump variables into the Messages tab of the Debug Bar + * + * @param mixed $message + * @param string $label + * @param bool $isString + * + * @return $this + */ + public function addMessage($message, $label = 'info', $isString = true) + { + if ($this->enabled()) { + $this->debugbar['messages']->addMessage($message, $label, $isString); + } + + return $this; + } + + /** + * Dump exception into the Messages tab of the Debug Bar + * + * @param \Exception $e + * @return Debugger + */ + public function addException(\Exception $e) + { + if ($this->initialized && $this->enabled()) { + $this->debugbar['exceptions']->addException($e); + } + + return $this; + } + + public function setErrorHandler() + { + $this->errorHandler = set_error_handler( + [$this, 'deprecatedErrorHandler'] + ); + } + + /** + * @param int $errno + * @param string $errstr + * @param string $errfile + * @param int $errline + * @return bool + */ + public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline) + { + if ($errno !== E_USER_DEPRECATED && $errno !== E_DEPRECATED) { + if ($this->errorHandler) { + return \call_user_func($this->errorHandler, $errno, $errstr, $errfile, $errline); + } + + return true; + } + + if (!$this->enabled()) { + return true; + } + + // Figure out error scope from the error. + $scope = 'unknown'; + if (stripos($errstr, 'grav') !== false) { + $scope = 'grav'; + } elseif (strpos($errfile, '/twig/') !== false) { + $scope = 'twig'; + } elseif (stripos($errfile, '/yaml/') !== false) { + $scope = 'yaml'; + } elseif (strpos($errfile, '/vendor/') !== false) { + $scope = 'vendor'; + } + + // Clean up backtrace to make it more useful. + $backtrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT); + + // Skip current call. + array_shift($backtrace); + + // Find yaml file where the error happened. + if ($scope === 'yaml') { + foreach ($backtrace as $current) { + if (isset($current['args'])) { + foreach ($current['args'] as $arg) { + if ($arg instanceof \SplFileInfo) { + $arg = $arg->getPathname(); + } + if (\is_string($arg) && preg_match('/.+\.(yaml|md)$/i', $arg)) { + $errfile = $arg; + $errline = 0; + + break 2; + } + } + } + } + } + + // Filter arguments. + $cut = 0; + $previous = null; + foreach ($backtrace as $i => &$current) { + if (isset($current['args'])) { + $args = []; + foreach ($current['args'] as $arg) { + if (\is_string($arg)) { + $arg = "'" . $arg . "'"; + if (mb_strlen($arg) > 100) { + $arg = 'string'; + } + } elseif (\is_bool($arg)) { + $arg = $arg ? 'true' : 'false'; + } elseif (\is_scalar($arg)) { + $arg = $arg; + } elseif (\is_object($arg)) { + $arg = get_class($arg) . ' $object'; + } elseif (\is_array($arg)) { + $arg = '$array'; + } else { + $arg = '$object'; + } + + $args[] = $arg; + } + $current['args'] = $args; + } + + $object = $current['object'] ?? null; + unset($current['object']); + + $reflection = null; + if ($object instanceof TemplateWrapper) { + $reflection = new \ReflectionObject($object); + $property = $reflection->getProperty('template'); + $property->setAccessible(true); + $object = $property->getValue($object); + } + + if ($object instanceof Template) { + $file = $current['file'] ?? null; + + if (preg_match('`(Template.php|TemplateWrapper.php)$`', $file)) { + $current = null; + continue; + } + + $debugInfo = $object->getDebugInfo(); + + $line = 1; + if (!$reflection) { + foreach ($debugInfo as $codeLine => $templateLine) { + if ($codeLine <= $current['line']) { + $line = $templateLine; + break; + } + } + } + + $src = $object->getSourceContext(); + //$code = preg_split('/\r\n|\r|\n/', $src->getCode()); + //$current['twig']['twig'] = trim($code[$line - 1]); + $current['twig']['file'] = $src->getPath(); + $current['twig']['line'] = $line; + + $prevFile = $previous['file'] ?? null; + if ($prevFile && $file === $prevFile) { + $prevLine = $previous['line']; + + $line = 1; + foreach ($debugInfo as $codeLine => $templateLine) { + if ($codeLine <= $prevLine) { + $line = $templateLine; + break; + } + } + + //$previous['twig']['twig'] = trim($code[$line - 1]); + $previous['twig']['file'] = $src->getPath(); + $previous['twig']['line'] = $line; + } + + $cut = $i; + } elseif ($object instanceof ProcessorInterface) { + $cut = $cut ?: $i; + break; + } + + $previous = &$backtrace[$i]; + } + unset($current); + + if ($cut) { + $backtrace = array_slice($backtrace, 0, $cut + 1); + } + $backtrace = array_values(array_filter($backtrace)); + + // Skip vendor libraries and the method where error was triggered. + foreach ($backtrace as $i => $current) { + if (!isset($current['file'])) { + continue; + } + if (strpos($current['file'], '/vendor/') !== false) { + $cut = $i + 1; + continue; + } + if (isset($current['function']) && ($current['function'] === 'user_error' || $current['function'] === 'trigger_error')) { + $cut = $i + 1; + continue; + } + + break; + } + + if ($cut) { + $backtrace = array_slice($backtrace, $cut); + } + $backtrace = array_values(array_filter($backtrace)); + + $current = reset($backtrace); + + // If the issue happened inside twig file, change the file and line to match that file. + $file = $current['twig']['file'] ?? ''; + if ($file) { + $errfile = $file; + $errline = $current['twig']['line'] ?? 0; + } + + $deprecation = [ + 'scope' => $scope, + 'message' => $errstr, + 'file' => $errfile, + 'line' => $errline, + 'trace' => $backtrace, + 'count' => 1 + ]; + + $this->deprecations[] = $deprecation; + + // Do not pass forward. + return true; + } + + protected function addDeprecations() + { + if (!$this->deprecations) { + return; + } + + $collector = new MessagesCollector('deprecated'); + $this->addCollector($collector); + $collector->addMessage('Your site is using following deprecated features:'); + + /** @var array $deprecated */ + foreach ($this->deprecations as $deprecated) { + list($message, $scope) = $this->getDepracatedMessage($deprecated); + + $collector->addMessage($message, $scope); + } + } + + protected function getDepracatedMessage($deprecated) + { + $scope = $deprecated['scope']; + + $trace = []; + if (isset($deprecated['trace'])) { + foreach ($deprecated['trace'] as $current) { + $class = $current['class'] ?? ''; + $type = $current['type'] ?? ''; + $function = $this->getFunction($current); + if (isset($current['file'])) { + $current['file'] = str_replace(GRAV_ROOT . '/', '', $current['file']); + } + + unset($current['class'], $current['type'], $current['function'], $current['args']); + + if (isset($current['twig'])) { + $trace[] = $current['twig']; + } else { + $trace[] = ['call' => $class . $type . $function] + $current; + } + } + } + + $array = [ + 'message' => $deprecated['message'], + 'file' => $deprecated['file'], + 'line' => $deprecated['line'], + 'trace' => $trace + ]; + + return [ + array_filter($array), + $scope + ]; + } + + protected function getFunction($trace) + { + if (!isset($trace['function'])) { + return ''; + } + + return $trace['function'] . '(' . implode(', ', $trace['args'] ?? []) . ')'; + } +} diff --git a/system/src/Grav/Common/Errors/BareHandler.php b/system/src/Grav/Common/Errors/BareHandler.php new file mode 100644 index 0000000..52effc6 --- /dev/null +++ b/system/src/Grav/Common/Errors/BareHandler.php @@ -0,0 +1,32 @@ +getInspector(); + $code = $inspector->getException()->getCode(); + 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 new file mode 100644 index 0000000..dbc955e --- /dev/null +++ b/system/src/Grav/Common/Errors/Errors.php @@ -0,0 +1,73 @@ +get('system.errors'); + $jsonRequest = $_SERVER && isset($_SERVER['HTTP_ACCEPT']) && $_SERVER['HTTP_ACCEPT'] === 'application/json'; + + // Setup Whoops-based error handler + $system = new SystemFacade; + $whoops = new Whoops\Run($system); + + $verbosity = 1; + + if (isset($config['display'])) { + if (is_int($config['display'])) { + $verbosity = $config['display']; + } else { + $verbosity = $config['display'] ? 1 : 0; + } + } + + switch ($verbosity) { + case 1: + $error_page = new Whoops\Handler\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); + break; + case -1: + $whoops->pushHandler(new BareHandler); + break; + default: + $whoops->pushHandler(new SimplePageHandler); + break; + } + + if (Whoops\Util\Misc::isAjaxRequest() || $jsonRequest) { + $whoops->pushHandler(new Whoops\Handler\JsonResponseHandler); + } + + if (isset($config['log']) && $config['log']) { + $logger = $grav['log']; + $whoops->pushHandler(function($exception, $inspector, $run) use ($logger) { + try { + $logger->addCritical($exception->getMessage() . ' - Trace: ' . $exception->getTraceAsString()); + } catch (\Exception $e) { + echo $e; + } + }); + } + + $whoops->register(); + + // Re-register deprecation handler. + $grav['debugger']->setErrorHandler(); + } +} diff --git a/system/src/Grav/Common/Errors/Resources/error.css b/system/src/Grav/Common/Errors/Resources/error.css new file mode 100644 index 0000000..11ce3fd --- /dev/null +++ b/system/src/Grav/Common/Errors/Resources/error.css @@ -0,0 +1,52 @@ +html, body { + height: 100% +} +body { + margin:0 3rem; + padding:0; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 1.5rem; + line-height: 1.4; + display: -webkit-box; /* OLD - iOS 6-, Safari 3.1-6 */ + display: -moz-box; /* OLD - Firefox 19- (buggy but mostly works) */ + display: -ms-flexbox; /* TWEENER - IE 10 */ + display: -webkit-flex; /* NEW - Chrome */ + display: flex; + -webkit-align-items: center; + align-items: center; + -webkit-justify-content: center; + justify-content: center; +} +.container { + margin: 0rem; + max-width: 600px; + padding-bottom:5rem; +} + +header { + color: #000; + font-size: 4rem; + letter-spacing: 2px; + line-height: 1.1; + margin-bottom: 2rem; +} +p { + font-family: Optima, Segoe, "Segoe UI", Candara, Calibri, Arial, sans-serif; + color: #666; +} + +h5 { + font-weight: normal; + color: #999; + font-size: 1rem; +} + +h6 { + font-weight: normal; + color: #999; +} + +code { + font-weight: bold; + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; +} diff --git a/system/src/Grav/Common/Errors/Resources/layout.html.php b/system/src/Grav/Common/Errors/Resources/layout.html.php new file mode 100644 index 0000000..6699959 --- /dev/null +++ b/system/src/Grav/Common/Errors/Resources/layout.html.php @@ -0,0 +1,30 @@ + + + + + + Whoops there was an error! + + + +
+
+
+ Server Error +
+ + + +

Sorry, something went terribly wrong!

+ +

-

+ +
For further details please review your logs/ folder, or enable displaying of errors in your system configuration.
+
+
+ + diff --git a/system/src/Grav/Common/Errors/SimplePageHandler.php b/system/src/Grav/Common/Errors/SimplePageHandler.php new file mode 100644 index 0000000..311d722 --- /dev/null +++ b/system/src/Grav/Common/Errors/SimplePageHandler.php @@ -0,0 +1,108 @@ +searchPaths[] = __DIR__ . '/Resources'; + } + + /** + * @return int|null + */ + public function handle() + { + $inspector = $this->getInspector(); + + $helper = new TemplateHelper(); + $templateFile = $this->getResource('layout.html.php'); + $cssFile = $this->getResource('error.css'); + + $code = $inspector->getException()->getCode(); + if ( ($code >= 400) && ($code < 600) ) + { + $this->getRun()->sendHttpCode($code); + } + $message = $inspector->getException()->getMessage(); + + if ($inspector->getException() instanceof \ErrorException) { + $code = Misc::translateErrorCode($code); + } + + $vars = array( + 'stylesheet' => file_get_contents($cssFile), + 'code' => $code, + 'message' => filter_var(rawurldecode($message), FILTER_SANITIZE_STRING), + ); + + $helper->setVariables($vars); + $helper->render($templateFile); + + return Handler::QUIT; + } + + /** + * @param string $resource + * + * @return string + * @throws \RuntimeException + */ + protected function getResource($resource) + { + // If the resource was found before, we can speed things up + // by caching its absolute, resolved path: + if (isset($this->resourceCache[$resource])) { + return $this->resourceCache[$resource]; + } + + // Search through available search paths, until we find the + // resource we're after: + foreach ($this->searchPaths as $path) { + $fullPath = "{$path}/{$resource}"; + + if (is_file($fullPath)) { + // Cache the result: + $this->resourceCache[$resource] = $fullPath; + return $fullPath; + } + } + + // If we got this far, nothing was found. + throw new \RuntimeException( + "Could not find resource '{$resource}' in any resource paths (searched: " . implode(', ', $this->searchPaths). ')' + ); + } + + public function addResourcePath($path) + { + if (!is_dir($path)) { + throw new \InvalidArgumentException( + "'{$path}' is not a valid directory" + ); + } + + array_unshift($this->searchPaths, $path); + } + + public function getResourcePaths() + { + return $this->searchPaths; + } +} diff --git a/system/src/Grav/Common/Errors/SystemFacade.php b/system/src/Grav/Common/Errors/SystemFacade.php new file mode 100644 index 0000000..02ef0cf --- /dev/null +++ b/system/src/Grav/Common/Errors/SystemFacade.php @@ -0,0 +1,40 @@ +whoopsShutdownHandler = $function; + register_shutdown_function([$this, 'handleShutdown']); + } + + /** + * Special case to deal with Fatal errors and the like. + */ + public function handleShutdown() + { + $error = $this->getLastError(); + + // Ignore core warnings and errors. + if ($error && !($error['type'] & (E_CORE_WARNING | E_CORE_ERROR))) { + $handler = $this->whoopsShutdownHandler; + $handler(); + } + } +} diff --git a/system/src/Grav/Common/File/CompiledFile.php b/system/src/Grav/Common/File/CompiledFile.php new file mode 100644 index 0000000..f4b1e8d --- /dev/null +++ b/system/src/Grav/Common/File/CompiledFile.php @@ -0,0 +1,110 @@ +raw === null && $this->content === null) { + $key = md5($this->filename); + $file = PhpFile::instance(CACHE_DIR . "compiled/files/{$key}{$this->extension}.php"); + + $modified = $this->modified(); + + if (!$modified) { + return $this->decode($this->raw()); + } + + $class = get_class($this); + + $cache = $file->exists() ? $file->content() : null; + + // Load real file if cache isn't up to date (or is invalid). + if ( + !isset($cache['@class']) + || $cache['@class'] !== $class + || $cache['modified'] !== $modified + || $cache['filename'] !== $this->filename + ) { + // 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. + } + + // Decode RAW file into compiled array. + $data = (array)$this->decode($this->raw()); + $cache = [ + '@class' => $class, + 'filename' => $this->filename, + 'modified' => $modified, + 'data' => $data + ]; + + // If compiled file wasn't already locked by another process, save it. + if ($file->locked() !== false) { + $file->save($cache); + $file->unlock(); + + // Compile cached file into bytecode cache + if (function_exists('opcache_invalidate')) { + // Silence error if function exists, but is restricted. + @opcache_invalidate($file->filename(), true); + } + } + } + $file->free(); + + $this->content = $cache['data']; + } + + } catch (\Exception $e) { + throw new \RuntimeException(sprintf('Failed to read %s: %s', basename($this->filename), $e->getMessage()), 500, $e); + } + + return parent::content($var); + } + + /** + * Serialize file. + */ + public function __sleep() + { + return [ + 'filename', + 'extension', + 'raw', + 'content', + 'settings' + ]; + } + + /** + * Unserialize file. + */ + public function __wakeup() + { + if (!isset(static::$instances[$this->filename])) { + static::$instances[$this->filename] = $this; + } + } +} diff --git a/system/src/Grav/Common/File/CompiledJsonFile.php b/system/src/Grav/Common/File/CompiledJsonFile.php new file mode 100644 index 0000000..d73e817 --- /dev/null +++ b/system/src/Grav/Common/File/CompiledJsonFile.php @@ -0,0 +1,29 @@ + ['.DS_Store'], + 'exclude_paths' => [] + ]; + + protected $archive_file; + + public static function create($compression) + { + if ($compression === 'zip') { + return new ZipArchiver(); + } + + return new ZipArchiver(); + } + + public function setArchive($archive_file) + { + $this->archive_file = $archive_file; + 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); + } + + $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); + + 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); + $filterItr = new RecursiveDirectoryFilterIterator($dirItr, $rootPath, $exclude_paths, $exclude_files); + $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 new file mode 100644 index 0000000..3196b65 --- /dev/null +++ b/system/src/Grav/Common/Filesystem/Folder.php @@ -0,0 +1,511 @@ +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; + } + } + + return $last_modified; + } + + /** + * Recursively find the last modified time under given path by file. + * + * @param string $path + * @param string $extensions which files to search for specifically + * + * @return int + */ + public static function lastModifiedFile($path, $extensions = 'md|yaml') + { + if (!file_exists($path)) { + return 0; + } + + $last_modified = 0; + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $flags = \RecursiveDirectoryIterator::SKIP_DOTS; + if ($locator->isStream($path)) { + $directory = $locator->getRecursiveIterator($path, $flags); + } else { + $directory = new \RecursiveDirectoryIterator($path, $flags); + } + $recursive = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST); + $iterator = new \RegexIterator($recursive, '/^.+\.'.$extensions.'$/i'); + + /** @var \RecursiveDirectoryIterator $file */ + foreach ($iterator as $filepath => $file) { + try { + $file_modified = $file->getMTime(); + if ($file_modified > $last_modified) { + $last_modified = $file_modified; + } + } catch (\Exception $e) { + Grav::instance()['log']->error('Could not process file: ' . $e->getMessage()); + } + } + + return $last_modified; + } + + /** + * Recursively md5 hash all files in a path + * + * @param string $path + * @return string + */ + public static function hashAllFiles($path) + { + $files = []; + + if (file_exists($path)) { + $flags = \RecursiveDirectoryIterator::SKIP_DOTS; + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($path)) { + $directory = $locator->getRecursiveIterator($path, $flags); + } else { + $directory = new \RecursiveDirectoryIterator($path, $flags); + } + + $iterator = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST); + + foreach ($iterator as $file) { + $files[] = $file->getPathname() . '?'. $file->getMTime(); + } + } + + return md5(serialize($files)); + } + + /** + * Get relative path between target and base path. If path isn't relative, return full path. + * + * @param string $path + * @param mixed|string $base + * + * @return string + */ + public static function getRelativePath($path, $base = GRAV_ROOT) + { + if ($base) { + $base = preg_replace('![\\\/]+!', '/', $base); + $path = preg_replace('![\\\/]+!', '/', $path); + if (strpos($path, $base) === 0) { + $path = ltrim(substr($path, strlen($base)), '/'); + } + } + + return $path; + } + + /** + * Get relative path between target and base path. If path isn't relative, return full path. + * + * @param string $path + * @param string $base + * @return string + */ + public static function getRelativePathDotDot($path, $base) + { + // Normalize paths. + $base = preg_replace('![\\\/]+!', '/', $base); + $path = preg_replace('![\\\/]+!', '/', $path); + + if ($path === $base) { + return ''; + } + + $baseParts = explode('/', ltrim($base, '/')); + $pathParts = explode('/', ltrim($path, '/')); + + array_pop($baseParts); + $lastPart = array_pop($pathParts); + foreach ($baseParts as $i => $directory) { + if (isset($pathParts[$i]) && $pathParts[$i] === $directory) { + unset($baseParts[$i], $pathParts[$i]); + } else { + break; + } + } + $pathParts[] = $lastPart; + $path = str_repeat('../', count($baseParts)) . implode('/', $pathParts); + + return '' === $path + || strpos($path, '/') === 0 + || false !== ($colonPos = strpos($path, ':')) && ($colonPos < ($slashPos = strpos($path, '/')) || false === $slashPos) + ? "./$path" : $path; + } + + /** + * Shift first directory out of the path. + * + * @param string $path + * @return string + */ + public static function shift(&$path) + { + $parts = explode('/', trim($path, '/'), 2); + $result = array_shift($parts); + $path = array_shift($parts); + + return $result ?: null; + } + + /** + * Return recursive list of all files and directories under given path. + * + * @param string $path + * @param array $params + * @return array + * @throws \RuntimeException + */ + public static function all($path, array $params = []) + { + if ($path === false) { + throw new \RuntimeException("Path doesn't exist."); + } + if (!file_exists($path)) { + return []; + } + + $compare = isset($params['compare']) ? 'get' . $params['compare'] : null; + $pattern = $params['pattern'] ?? null; + $filters = $params['filters'] ?? null; + $recursive = $params['recursive'] ?? true; + $levels = $params['levels'] ?? -1; + $key = isset($params['key']) ? 'get' . $params['key'] : null; + $value = 'get' . ($params['value'] ?? ($recursive ? 'SubPathname' : 'Filename')); + $folders = $params['folders'] ?? true; + $files = $params['files'] ?? true; + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($recursive) { + $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); + } + $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); + } + } + + $results = []; + + /** @var \RecursiveDirectoryIterator $file */ + foreach ($iterator as $file) { + // Ignore hidden files. + if (strpos($file->getFilename(), '.') === 0 && $file->isFile()) { + continue; + } + if (!$folders && $file->isDir()) { + continue; + } + if (!$files && $file->isFile()) { + continue; + } + if ($compare && $pattern && !preg_match($pattern, $file->{$compare}())) { + continue; + } + $fileKey = $key ? $file->{$key}() : null; + $filePath = $file->{$value}(); + if ($filters) { + if (isset($filters['key'])) { + $pre = !empty($filters['pre-key']) ? $filters['pre-key'] : ''; + $fileKey = $pre . preg_replace($filters['key'], '', $fileKey); + } + if (isset($filters['value'])) { + $filter = $filters['value']; + if (is_callable($filter)) { + $filePath = $filter($file); + } else { + $filePath = preg_replace($filter, '', $filePath); + } + } + } + + if ($fileKey !== null) { + $results[$fileKey] = $filePath; + } else { + $results[] = $filePath; + } + } + + return $results; + } + + /** + * Recursively copy directory in filesystem. + * + * @param string $source + * @param string $target + * @param string $ignore Ignore files matching pattern (regular expression). + * @throws \RuntimeException + */ + public static function copy($source, $target, $ignore = null) + { + $source = rtrim($source, '\\/'); + $target = rtrim($target, '\\/'); + + if (!is_dir($source)) { + throw new \RuntimeException('Cannot copy non-existing folder.'); + } + + // Make sure that path to the target exists before copying. + self::create($target); + + $success = true; + + // Go through all sub-directories and copy everything. + $files = self::all($source); + foreach ($files as $file) { + if ($ignore && preg_match($ignore, $file)) { + continue; + } + $src = $source .'/'. $file; + $dst = $target .'/'. $file; + + if (is_dir($src)) { + // Create current directory (if it doesn't exist). + if (!is_dir($dst)) { + $success &= @mkdir($dst, 0777, true); + } + } else { + // Or copy current file. + $success &= @copy($src, $dst); + } + } + + if (!$success) { + $error = error_get_last(); + throw new \RuntimeException($error['message'] ?? 'Unknown error'); + } + + // Make sure that the change will be detected when caching. + @touch(dirname($target)); + } + + /** + * Move directory in filesystem. + * + * @param string $source + * @param string $target + * @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.'); + } + + // Don't do anything if the source is the same as the new target + if ($source === $target) { + return; + } + + if (file_exists($target)) { + // Rename fails if target folder exists. + throw new \RuntimeException('Cannot move files to existing folder/file.'); + } + + // Make sure that path to the target exists before moving. + self::create(dirname($target)); + + // Silence warnings (chmod failed etc). + @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); + } + // Rename doesn't support moving folders across filesystems. Use copy instead. + self::copy($source, $target); + self::delete($source); + } + + // Make sure that the change will be detected when caching. + @touch(dirname($source)); + @touch(dirname($target)); + @touch($target); + } + + /** + * Recursively delete directory from filesystem. + * + * @param string $target + * @param bool $include_target + * @return bool + * @throws \RuntimeException + */ + public static function delete($target, $include_target = true) + { + if (!is_dir($target)) { + return false; + } + + $success = self::doDelete($target, $include_target); + + if (!$success) { + $error = error_get_last(); + throw new \RuntimeException($error['message']); + } + + // Make sure that the change will be detected when caching. + if ($include_target) { + @touch(dirname($target)); + } else { + @touch($target); + } + + return $success; + } + + /** + * @param string $folder + * @throws \RuntimeException + */ + public static function mkdir($folder) + { + self::create($folder); + } + + /** + * @param string $folder + * @throws \RuntimeException + */ + public static function create($folder) + { + // Silence error for open_basedir; should fail in mkdir instead. + if (@is_dir($folder)) { + return; + } + + $success = @mkdir($folder, 0777, true); + + if (!$success) { + // 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)); + } + } + } + + /** + * Recursive copy of one directory to another + * + * @param string $src + * @param string $dest + * + * @return bool + * @throws \RuntimeException + */ + public static function rcopy($src, $dest) + { + + // If the src is not a directory do a simple file copy + if (!is_dir($src)) { + copy($src, $dest); + return true; + } + + // If the destination directory does not exist create it + if (!is_dir($dest)) { + static::create($dest); + } + + // Open the source directory to read in files + $i = new \DirectoryIterator($src); + /** @var \DirectoryIterator $f */ + foreach ($i as $f) { + if ($f->isFile()) { + copy($f->getRealPath(), "{$dest}/" . $f->getFilename()); + } else { + if (!$f->isDot() && $f->isDir()) { + static::rcopy($f->getRealPath(), "{$dest}/{$f}"); + } + } + } + return true; + } + + /** + * @param string $folder + * @param bool $include_target + * @return bool + * @internal + */ + protected static function doDelete($folder, $include_target = true) + { + // Special case for symbolic links. + if ($include_target && is_link($folder)) { + return @unlink($folder); + } + + // Go through all items in filesystem and recursively remove everything. + $files = array_diff(scandir($folder, SCANDIR_SORT_NONE), array('.', '..')); + foreach ($files as $file) { + $path = "{$folder}/{$file}"; + is_dir($path) ? self::doDelete($path) : @unlink($path); + } + + return $include_target ? @rmdir($folder) : true; + } +} diff --git a/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php new file mode 100644 index 0000000..c662fc4 --- /dev/null +++ b/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php @@ -0,0 +1,67 @@ +current(); + $filename = $file->getFilename(); + $relative_filename = str_replace($this::$root . '/', '', $file->getPathname()); + + if ($file->isDir()) { + if (in_array($relative_filename, $this::$ignore_folders, true)) { + return false; + } + if (!in_array($filename, $this::$ignore_files, true)) { + return true; + } + } elseif ($file->isFile() && !in_array($filename, $this::$ignore_files, true)) { + return true; + } + return false; + } + + public function getChildren() + { + /** @var RecursiveDirectoryFilterIterator $iterator */ + $iterator = $this->getInnerIterator(); + + return new self($iterator->getChildren(), $this::$root, $this::$ignore_folders, $this::$ignore_files); + } +} diff --git a/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php new file mode 100644 index 0000000..eb493db --- /dev/null +++ b/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php @@ -0,0 +1,47 @@ +get('system.pages.ignore_folders'); + } + + $this::$ignore_folders = $ignore_folders; + } + + /** + * Check whether the current element of the iterator is acceptable + * + * @return bool true if the current element is acceptable, otherwise false. + */ + public function accept() + { + /** @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 new file mode 100644 index 0000000..91212f7 --- /dev/null +++ b/system/src/Grav/Common/Filesystem/ZipArchiver.php @@ -0,0 +1,111 @@ +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); + } + + $zip->close(); + return $this; + } + + throw new \RuntimeException('ZipArchiver: Failed to open ' . $this->archive_file); + } + + public function compress($source, callable $status = null) + { + if (!extension_loaded('zip')) { + throw new \InvalidArgumentException('ZipArchiver: Zip PHP module not installed...'); + } + + if (!file_exists($source)) { + 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...'); + } + + // Get real path for our folder + $rootPath = realpath($source); + + $files = $this->getArchiveFiles($rootPath); + + $status && $status([ + 'type' => 'count', + 'steps' => iterator_count($files), + ]); + + foreach ($files as $file) { + $filePath = $file->getPathname(); + $relativePath = ltrim(substr($filePath, strlen($rootPath)), '/'); + + if ($file->isDir()) { + $zip->addEmptyDir($relativePath); + } else { + $zip->addFile($filePath, $relativePath); + } + + $status && $status([ + 'type' => 'progress', + ]); + } + + $status && $status([ + 'type' => 'message', + 'message' => 'Compressing...' + ]); + + $zip->close(); + + return $this; + } + + public function addEmptyFolders($folders, callable $status = null) + { + if (!extension_loaded('zip')) { + throw new \InvalidArgumentException('ZipArchiver: Zip PHP module not installed...'); + } + + $zip = new \ZipArchive(); + if (!$zip->open($this->archive_file)) { + throw new \InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be opened...'); + } + + $status && $status([ + 'type' => 'message', + 'message' => 'Adding empty folders...' + ]); + + foreach($folders as $folder) { + $zip->addEmptyDir($folder); + $status && $status([ + 'type' => 'progress', + ]); + } + + $zip->close(); + + return $this; + } +} diff --git a/system/src/Grav/Common/Form/FormFlash.php b/system/src/Grav/Common/Form/FormFlash.php new file mode 100644 index 0000000..b67313b --- /dev/null +++ b/system/src/Grav/Common/Form/FormFlash.php @@ -0,0 +1,101 @@ +files as $field => $files) { + if (strpos($field, '/')) { + continue; + } + foreach ($files as $file) { + if (\is_array($file)) { + $file['tmp_name'] = $this->getTmpDir() . '/' . $file['tmp_name']; + $fields[$field][$file['path'] ?? $file['name']] = $file; + } + } + } + + return $fields; + } + + /** + * @param string $field + * @param string $filename + * @param array $upload + * @return bool + * @deprecated 1.6 For backwards compatibility only, do not use + */ + public function uploadFile(string $field, string $filename, array $upload): bool + { + if (!$this->uniqueId) { + return false; + } + + $tmp_dir = $this->getTmpDir(); + Folder::create($tmp_dir); + + $tmp_file = $upload['file']['tmp_name']; + $basename = basename($tmp_file); + + if (!move_uploaded_file($tmp_file, $tmp_dir . '/' . $basename)) { + return false; + } + + $upload['file']['tmp_name'] = $basename; + $upload['file']['name'] = $filename; + + $this->addFileInternal($field, $filename, $upload['file']); + + return true; + } + + /** + * @param string $field + * @param string $filename + * @param array $upload + * @param array $crop + * @return bool + * @deprecated 1.6 For backwards compatibility only, do not use + */ + public function cropFile(string $field, string $filename, array $upload, array $crop): bool + { + if (!$this->uniqueId) { + return false; + } + + $tmp_dir = $this->getTmpDir(); + Folder::create($tmp_dir); + + $tmp_file = $upload['file']['tmp_name']; + $basename = basename($tmp_file); + + if (!move_uploaded_file($tmp_file, $tmp_dir . '/' . $basename)) { + return false; + } + + $upload['file']['tmp_name'] = $basename; + $upload['file']['name'] = $filename; + + $this->addFileInternal($field, $filename, $upload['file'], $crop); + + return true; + } +} diff --git a/system/src/Grav/Common/GPM/AbstractCollection.php b/system/src/Grav/Common/GPM/AbstractCollection.php new file mode 100644 index 0000000..a9e1ac9 --- /dev/null +++ b/system/src/Grav/Common/GPM/AbstractCollection.php @@ -0,0 +1,31 @@ +toArray()); + } + + public function toArray() + { + $items = []; + + foreach ($this->items as $name => $package) { + $items[$name] = $package->toArray(); + } + + return $items; + } +} diff --git a/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php new file mode 100644 index 0000000..bc4ecab --- /dev/null +++ b/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php @@ -0,0 +1,39 @@ +items as $name => $package) { + $items[$name] = $package->toArray(); + } + + return json_encode($items); + } + + public function toArray() + { + $items = []; + + foreach ($this->items as $name => $package) { + $items[$name] = $package->toArray(); + } + + return $items; + } +} diff --git a/system/src/Grav/Common/GPM/Common/CachedCollection.php b/system/src/Grav/Common/GPM/Common/CachedCollection.php new file mode 100644 index 0000000..a6fca4d --- /dev/null +++ b/system/src/Grav/Common/GPM/Common/CachedCollection.php @@ -0,0 +1,33 @@ + $item) { + $this->append([$name => $item]); + } + } +} diff --git a/system/src/Grav/Common/GPM/Common/Package.php b/system/src/Grav/Common/GPM/Common/Package.php new file mode 100644 index 0000000..40d3759 --- /dev/null +++ b/system/src/Grav/Common/GPM/Common/Package.php @@ -0,0 +1,70 @@ +data = $package; + + if ($type) { + $this->data->set('package_type', $type); + } + } + + /** + * @return Data + */ + public function getData() + { + return $this->data; + } + + public function __get($key) + { + return $this->data->get($key); + } + + public function __set($key, $value) + { + return $this->data->set($key, $value); + } + + public function __isset($key) + { + return isset($this->data->{$key}); + } + + public function __toString() + { + return $this->toJson(); + } + + public function toJson() + { + return $this->data->toJson(); + } + + /** + * @return array + */ + public function toArray() + { + return $this->data->toArray(); + } +} diff --git a/system/src/Grav/Common/GPM/GPM.php b/system/src/Grav/Common/GPM/GPM.php new file mode 100644 index 0000000..6c9e7b0 --- /dev/null +++ b/system/src/Grav/Common/GPM/GPM.php @@ -0,0 +1,1160 @@ + 'user/plugins/%name%', + 'themes' => 'user/themes/%name%', + 'skeletons' => 'user/' + ]; + + /** + * 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 + */ + public function __construct($refresh = false, $callback = null) + { + parent::__construct(); + $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) { + } + } + + /** + * Return the locally installed packages + * + * @return Local\Packages + */ + public function getInstalled() + { + return $this->installed; + } + + /** + * Returns the Locally installable packages + * + * @param array $list_type_installed + * @return array The installed packages + */ + public function getInstallable($list_type_installed = ['plugins' => true, 'themes' => true]) + { + $items = ['total' => 0]; + foreach ($list_type_installed as $type => $type_installed) { + if ($type_installed === false) { + continue; + } + $methodInstallableType = 'getInstalled' . ucfirst($type); + $to_install = $this->$methodInstallableType(); + $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() + { + $installed = $this->getInstalled(); + + return count($installed['plugins']) + count($installed['themes']); + } + + /** + * Return the instance of a specific Package + * + * @param string $slug The slug of the Package + * @return Local\Package 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 the instance of a specific Plugin + * @param string $slug The slug of the Plugin + * @return Local\Package The instance of the Plugin + */ + public function getInstalledPlugin($slug) + { + return $this->installed['plugins'][$slug]; + } + + /** + * Returns the Locally installed plugins + * @return Iterator The installed plugins + */ + public function getInstalledPlugins() + { + return $this->installed['plugins']; + } + + /** + * 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) + { + return isset($this->installed['plugins'][$slug]); + } + + public function isPluginInstalledAsSymlink($slug) + { + return $this->installed['plugins'][$slug]->symlink; + } + + /** + * Return the instance of a specific Theme + * @param string $slug The slug of the Theme + * @return Local\Package The instance of the Theme + */ + public function getInstalledTheme($slug) + { + return $this->installed['themes'][$slug]; + } + + /** + * Returns the Locally installed themes + * @return Iterator The installed themes + */ + public function getInstalledThemes() + { + return $this->installed['themes']; + } + + /** + * 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) + { + 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; + } + + /** + * 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) { + continue; + } + $methodUpdatableType = 'getUpdatable' . ucfirst($type); + $to_update = $this->$methodUpdatableType(); + $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']; + + // local cache to speed things up + if (isset($this->cache[__METHOD__])) { + return $this->cache[__METHOD__]; + } + + foreach ($this->installed['plugins'] as $slug => $plugin) { + if (!isset($repository[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) { + continue; + } + + $local_version = $plugin->version ?: 'Unknown'; + $remote_version = $repository[$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]; + } + } + + $this->cache[__METHOD__] = $items; + + return $items; + } + + /** + * 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; + } + + //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; + } + + return null; + } + + /** + * 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 + */ + public function isUpdatable($slug) + { + return $this->isPluginUpdatable($slug) || $this->isThemeUpdatable($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 + */ + public function isPluginUpdatable($plugin) + { + return array_key_exists($plugin, (array)$this->getUpdatablePlugins()); + } + + /** + * 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']; + + // local cache to speed things up + if (isset($this->cache[__METHOD__])) { + return $this->cache[__METHOD__]; + } + + foreach ($this->installed['themes'] as $slug => $plugin) { + if (!isset($repository[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) { + continue; + } + + $local_version = $plugin->version ?: 'Unknown'; + $remote_version = $repository[$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]; + } + } + + $this->cache[__METHOD__] = $items; + + return $items; + } + + /** + * Checks if a Theme is Updatable + * @param string $theme The slug of the Theme + * @return bool True if the Theme is updatable. False otherwise + */ + public function isThemeUpdatable($theme) + { + return array_key_exists($theme, (array)$this->getUpdatableThemes()); + } + + /** + * 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; + } + + //Not a plugin, it's a theme? + $repository = $this->repository['themes']; + if (isset($repository[$package_name])) { + return $repository[$package_name]->release_type; + } + + return null; + } + + /** + * Returns true if the package latest release is stable + * + * @param string $package_name + * + * @return bool + */ + public function isStableRelease($package_name) + { + return $this->getReleaseType($package_name) === 'stable'; + } + + /** + * 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; + + 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 + */ + public function getRepositoryPlugin($slug) + { + return @$this->repository['plugins'][$slug]; + } + + /** + * Returns the list of Plugins available in the repository + * @return Iterator The Plugins remotely available + */ + public function getRepositoryPlugins() + { + return $this->repository['plugins']; + } + + /** + * Returns a Theme from the repository + * @param string $slug The slug of the Theme + * @return mixed Package if found, NULL if not + */ + public function getRepositoryTheme($slug) + { + return @$this->repository['themes'][$slug]; + } + + /** + * Returns the list of Themes available in the repository + * @return Iterator The Themes remotely available + */ + public function getRepositoryThemes() + { + return $this->repository['themes']; + } + + /** + * Returns the list of Plugins and Themes available in the repository + * @return Remote\Packages Available Plugins and Themes + * Format: ['plugins' => array, 'themes' => array] + */ + public function getRepository() + { + return $this->repository; + } + + /** + * 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 + */ + public function findPackage($search, $ignore_exception = false) + { + $search = strtolower($search); + + $found = $this->getRepositoryTheme($search); + if ($found) { + return $found; + } + + $found = $this->getRepositoryPlugin($search); + if ($found) { + return $found; + } + + $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 ($ignore_exception) { + return false; + } + + 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; + } + } + } + + if ($plugins) { + foreach ($plugins as $slug => $plugin) { + if ($search == $slug || $search == $plugin->name) { + return $plugin; + } + } + } + + return false; + } + + /** + * Download the zip package via the URL + * + * @param string $package_file + * @param string $tmp + * @return null|string + */ + public static function downloadPackage($package_file, $tmp) + { + $package = parse_url($package_file); + $filename = 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."); + } + + $output = Response::get($package_file, []); + + if ($output) { + Folder::create($tmp); + file_put_contents($tmp . DS . $filename, $output); + return $tmp . DS . $filename; + } + + return null; + } + + /** + * Copy the local zip package to tmp + * + * @param string $package_file + * @param string $tmp + * @return null|string + */ + public static function copyPackage($package_file, $tmp) + { + $package_file = realpath($package_file); + + if (file_exists($package_file)) { + $filename = basename($package_file); + Folder::create($tmp); + copy(realpath($package_file), $tmp . DS . $filename); + return $tmp . DS . $filename; + } + + return null; + } + + /** + * Try to guess the package type from the source files + * + * @param string $source + * @return bool|string + */ + 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') && + file_exists($source . 'system/config/system.yaml') + ) { + return 'grav'; + } + + // must have a blueprint + if (!file_exists($source . 'blueprints.yaml')) { + return false; + } + + // either theme or plugin + $name = basename($source); + if (Utils::contains($name, 'theme')) { + return 'theme'; + } + if (Utils::contains($name, 'plugin')) { + return 'plugin'; + } + foreach (glob($source . '*.php') as $filename) { + $contents = file_get_contents($filename); + if (preg_match($theme_regex, $contents)) { + return 'theme'; + } + if (preg_match($plugin_regex, $contents)) { + return 'plugin'; + } + } + + // Assume it's a theme + return 'theme'; + } + + /** + * Try to guess the package name from the source files + * + * @param string $source + * @return bool|string + */ + public static function getPackageName($source) + { + $ignore_yaml_files = ['blueprints', 'languages']; + + foreach (glob($source . '*.yaml') as $filename) { + $name = strtolower(basename($filename, '.yaml')); + if (in_array($name, $ignore_yaml_files)) { + continue; + } + return $name; + } + return false; + } + + /** + * Find/Parse the blueprint file + * + * @param string $source + * @return array|bool + */ + public static function getBlueprints($source) + { + $blueprint_file = $source . 'blueprints.yaml'; + if (!file_exists($blueprint_file)) { + return false; + } + + $file = YamlFile::instance($blueprint_file); + $blueprint = (array)$file->content(); + $file->free(); + + return $blueprint; + } + + /** + * Get the install path for a name and a particular type of package + * + * @param string $type + * @param string $name + * @return string + */ + public static function getInstallPath($type, $name) + { + $locator = Grav::instance()['locator']; + + if ($type === 'theme') { + $install_path = $locator->findResource('themes://', false) . DS . $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, ] + */ + public function findPackages($searches = []) + { + $packages = ['total' => 0, 'not_found' => []]; + $inflector = new Inflector(); + + foreach ($searches as $search) { + $repository = ''; + // if this is an object, get the search data from the key + if (is_object($search)) { + $search = (array)$search; + $key = key($search); + $repository = $search[$key]; + $search = $key; + } + + $found = $this->findPackage($search); + if ($found) { + // set override repository if provided + if ($repository) { + $found->override_repository = $repository; + } + if (!isset($packages[$found->package_type])) { + $packages[$found->package_type] = []; + } + + $packages[$found->package_type][$found->slug] = $found; + $packages['total']++; + } else { + // make a best guess at the type based on the repo URL + if (Utils::contains($repository, '-theme')) { + $type = 'themes'; + } else { + $type = 'plugins'; + } + + $not_found = new \stdClass(); + $not_found->name = $inflector::camelize($search); + $not_found->slug = $search; + $not_found->package_type = $type; + $not_found->install_path = str_replace('%name%', $search, $this->install_paths[$type]); + $not_found->override_repository = $repository; + $packages['not_found'][$search] = $not_found; + } + } + + return $packages; + } + + /** + * 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) + { + $plugins = $this->getInstalledPlugins(); + $themes = $this->getInstalledThemes(); + $packages = array_merge($plugins->toArray(), $themes->toArray()); + + $dependent_packages = []; + + 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']; + } + + if ($dependency === $slug) { + $dependent_packages[] = $package_name; + } + } + } + } + + return $dependent_packages; + } + + + /** + * Get the required version of a dependency of a package + * + * @param string $package_slug + * @param string $dependency_slug + * + * @return mixed + */ + public function getVersionOfDependencyRequiredByPackage($package_slug, $dependency_slug) + { + $dependencies = $this->getInstalledPackage($package_slug)->dependencies; + foreach ($dependencies as $dependency) { + if (isset($dependency[$dependency_slug])) { + return $dependency[$dependency_slug]; + } + } + + return null; + } + + /** + * Check the package identified by $slug can be updated to the version passed as argument. + * Thrown an exception if it cannot be updated because another package installed requires it to be at an older version. + * + * @param string $slug + * @param string $version_with_operator + * @param array $ignore_packages_list + * + * @return bool + * @throws \RuntimeException + */ + 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 = $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); + } + } + } + } + } + + return true; + } + + /** + * Check the passed packages list can be updated + * + * @param array $packages_names_list + * + * @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); + } + } + + /** + * Fetch the dependencies, check the installed packages and return an array with + * the list of packages with associated an information on what to do: install, update or ignore. + * + * `ignore` means the package is already installed and can be safely left as-is. + * `install` means the package is not installed and must be installed. + * `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 + */ + public function getDependencies($packages) + { + $dependencies = $this->calculateMergedDependenciesOfPackages($packages); + foreach ($dependencies as $dependency_slug => $dependencyVersionWithOperator) { + 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 + ) { + //Needs a Grav update first + throw new \RuntimeException("One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this"); + } + + unset($dependencies[$dependency_slug]); + continue; + } + + //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 + ) { + //Needs a Grav update first + throw new \RuntimeException("One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release."); + } + + unset($dependencies[$dependency_slug]); + continue; + } + + if ($this->isPluginInstalled($dependency_slug)) { + if ($this->isPluginInstalledAsSymlink($dependency_slug)) { + unset($dependencies[$dependency_slug]); + continue; + } + + $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); + + // get currently installed version + $locator = Grav::instance()['locator']; + $blueprints_path = $locator->findResource('plugins://' . $dependency_slug . DS . 'blueprints.yaml'); + $file = YamlFile::instance($blueprints_path); + $package_yaml = $file->content(); + $file->free(); + $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 (!$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 I already have the latest release, remove the dependency + $latestRelease = $this->getLatestVersionOfPackage($dependency_slug); + + 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); + } + + if ($this->firstVersionIsLower($currentlyInstalledVersion, $dependencyVersion)) { + $dependencies[$dependency_slug] = 'update'; + } else { + if ($currentlyInstalledVersion == $latestRelease) { + unset($dependencies[$dependency_slug]); + } else { + // an update is not strictly required mark as 'ignore' + $dependencies[$dependency_slug] = 'ignore'; + } + } + } else { + $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); + + // if requirement is next significant release, check is compatible with latest available version, might not be + if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) { + $latestVersionOfPackage = $this->getLatestVersionOfPackage($dependency_slug); + if ($this->firstVersionIsLower($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); + } + } + } + + $dependencies[$dependency_slug] = 'install'; + } + } + + $dependencies_slugs = array_keys($dependencies); + $this->checkNoOtherPackageNeedsTheseDependenciesInALowerVersion(array_merge($packages, $dependencies_slugs)); + + return $dependencies; + } + + public function checkNoOtherPackageNeedsTheseDependenciesInALowerVersion($dependencies_slugs) + { + foreach ($dependencies_slugs as $dependency_slug) { + $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($dependency_slug, + $this->getLatestVersionOfPackage($dependency_slug), $dependencies_slugs); + } + } + + private function firstVersionIsLower($firstVersion, $secondVersion) + { + return version_compare($firstVersion, $secondVersion) === -1; + } + + /** + * 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 (!isset($dependencies[$current_package_name])) { + // Dependency added for the first time + + if (!isset($current_package_version_information)) { + $dependencies[$current_package_name] = '*'; + } else { + $dependencies[$current_package_name] = $current_package_version_information; + } + + //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 !== '*') { + + $currently_stored_version_information = $dependencies[$current_package_name]; + $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currently_stored_version_information); + + $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; + } + + 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_is_in_next_significant_release_format = false; + if ($this->versionFormatIsNextSignificantRelease($current_package_version_information)) { + $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); + } + } + } + } + } + } + } + + return $dependencies; + } + + /** + * Calculates and merges the dependencies of the passed packages + * + * @param array $packages + * + * @return mixed + * @throws \Exception + */ + public function calculateMergedDependenciesOfPackages($packages) + { + $dependencies = []; + + foreach ($packages as $package) { + $dependencies = $this->calculateMergedDependenciesOfPackage($package, $dependencies); + } + + return $dependencies; + } + + /** + * Returns the actual version from a dependency version string. + * Examples: + * $versionInformation == '~2.0' => returns '2.0' + * $versionInformation == '>=2.0.2' => returns '2.0.2' + * $versionInformation == '2.0.2' => returns '2.0.2' + * $versionInformation == '*' => returns null + * $versionInformation == '' => returns null + * + * @param string $version + * + * @return null|string + */ + public function calculateVersionNumberFromDependencyVersion($version) + { + if ($version === '*') { + return null; + } + if ($version === '') { + return null; + } + if ($this->versionFormatIsNextSignificantRelease($version)) { + return trim(substr($version, 1)); + } + if ($this->versionFormatIsEqualOrHigher($version)) { + return trim(substr($version, 2)); + } + + return $version; + } + + /** + * Check if the passed version information contains next significant release (tilde) operator + * + * Example: returns true for $version: '~2.0' + * + * @param string $version + * + * @return bool + */ + public function versionFormatIsNextSignificantRelease($version): bool + { + return strpos($version, '~') === 0; + } + + /** + * Check if the passed version information contains equal or higher operator + * + * Example: returns true for $version: '>=2.0' + * + * @param string $version + * + * @return bool + */ + public function versionFormatIsEqualOrHigher($version): bool + { + return strpos($version, '>=') === 0; + } + + /** + * Check if two releases are compatible by next significant release + * + * ~1.2 is equivalent to >=1.2 <2.0.0 + * ~1.2.3 is equivalent to >=1.2.3 <1.3.0 + * + * In short, allows the last digit specified to go up + * + * @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 + { + $version1array = explode('.', $version1); + $version2array = explode('.', $version2); + + if (\count($version1array) > \count($version2array)) { + list($version1array, $version2array) = [$version2array, $version1array]; + } + + $i = 0; + while ($i < \count($version1array) - 1) { + if ($version1array[$i] != $version2array[$i]) { + return false; + } + $i++; + } + + return true; + } + +} diff --git a/system/src/Grav/Common/GPM/Installer.php b/system/src/Grav/Common/GPM/Installer.php new file mode 100644 index 0000000..cd51163 --- /dev/null +++ b/system/src/Grav/Common/GPM/Installer.php @@ -0,0 +1,543 @@ + true, + 'ignore_symlinks' => true, + 'sophisticated' => false, + 'theme' => false, + 'install_path' => '', + 'ignores' => [], + 'exclude_checks' => [self::EXISTS, self::NOT_FOUND, self::IS_LINK] + ]; + + /** + * Installs a given package to a given destination. + * + * @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 bool $keepExtracted True if you want to keep the original files + * @return bool True if everything went fine, False otherwise. + */ + public static function install($zip, $destination, $options = [], $extracted = null, $keepExtracted = false) + { + $destination = rtrim($destination, DS); + $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']) + ) { + return false; + } + + if ((self::lastErrorCode() === self::IS_LINK && $options['ignore_symlinks']) || + (self::lastErrorCode() === self::EXISTS && !$options['overwrite']) + ) { + return false; + } + + // Create a tmp location + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); + $tmp = $tmp_dir . '/Grav-' . uniqid('', false); + + if (!$extracted) { + $extracted = self::unZip($zip, $tmp); + if (!$extracted) { + Folder::delete($tmp); + return false; + } + } + + if (!file_exists($extracted)) { + self::$error = self::INVALID_SOURCE; + return false; + } + + $is_install = true; + $installer = self::loadInstaller($extracted, $is_install); + + if (isset($options['is_update']) && $options['is_update'] === true) { + $method = 'preUpdate'; + } else { + $method = 'preInstall'; + } + + if ($installer && method_exists($installer, $method)) { + $method_result = $installer::$method(); + if ($method_result !== true) { + self::$error = 'An error occurred'; + if (is_string($method_result)) { + self::$error = $method_result; + } + + return false; + } + } + + if (!$options['sophisticated']) { + if ($options['theme']) { + self::copyInstall($extracted, $install_path); + } else { + self::moveInstall($extracted, $install_path); + } + } else { + self::sophisticatedInstall($extracted, $install_path, $options['ignores'], $keepExtracted); + } + + Folder::delete($tmp); + + if (isset($options['is_update']) && $options['is_update'] === true) { + $method = 'postUpdate'; + } else { + $method = 'postInstall'; + } + + self::$message = ''; + if ($installer && method_exists($installer, $method)) { + self::$message = $installer::$method(); + } + + self::$error = self::OK; + + return true; + + } + + /** + * Unzip a file to somewhere + * + * @param string $zip_file + * @param string $destination + * @return bool|string + */ + public static function unZip($zip_file, $destination) + { + $zip = new \ZipArchive(); + $archive = $zip->open($zip_file); + + if ($archive === true) { + Folder::create($destination); + + $unzip = $zip->extractTo($destination); + + + if (!$unzip) { + self::$error = self::ZIP_EXTRACT_ERROR; + Folder::delete($destination); + $zip->close(); + return false; + } + + $package_folder_name = preg_replace('#\./$#', '', $zip->getNameIndex(0)); + $zip->close(); + + return $destination . '/' . $package_folder_name; + } + + self::$error = self::ZIP_EXTRACT_ERROR; + self::$error_zip = $archive; + return false; + } + + /** + * Instantiates and returns the package installer class + * + * @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 + */ + 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 { + return null; + } + + if ($is_install) { + $slug = ''; + if (($pos = strpos($installer_file_folder, 'grav-plugin-')) !== false) { + $slug = substr($installer_file_folder, $pos + strlen('grav-plugin-')); + } elseif (($pos = strpos($installer_file_folder, 'grav-theme-')) !== false) { + $slug = substr($installer_file_folder, $pos + strlen('grav-theme-')); + } + } else { + $path_elements = explode('/', $installer_file_folder); + $slug = end($path_elements); + } + + if (!$slug) { + return null; + } + + $class_name = ucfirst($slug) . 'Install'; + + if (class_exists($class_name)) { + return $class_name; + } + + $class_name_alphanumeric = preg_replace('/[^a-zA-Z0-9]+/', '', $class_name); + + if (class_exists($class_name_alphanumeric)) { + return $class_name_alphanumeric; + } + + return $installer; + } + + /** + * @param string $source_path + * @param string $install_path + * + * @return bool + */ + public static function moveInstall($source_path, $install_path) + { + if (file_exists($install_path)) { + Folder::delete($install_path); + } + + Folder::move($source_path, $install_path); + + return true; + } + + /** + * @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"); + } + + Folder::rcopy($source_path, $install_path); + + return true; + } + + /** + * @param string $source_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)) { + continue; + } + + $path = $install_path . DS . $file->getFilename(); + + if ($file->isDir()) { + Folder::delete($path); + if ($keep_source) { + Folder::copy($file->getPathname(), $path); + } else { + Folder::move($file->getPathname(), $path); + } + + if ($file->getFilename() === 'bin') { + foreach (glob($path . DS . '*') as $bin_file) { + @chmod($bin_file, 0755); + } + } + } else { + @unlink($path); + @copy($file->getPathname(), $path); + } + } + + return true; + } + + /** + * Uninstalls one or more given package + * + * @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 = []) + { + $options = array_merge(self::$options, $options); + if (!self::isValidDestination($path, $options['exclude_checks']) + ) { + return false; + } + + $installer_file_folder = $path; + $is_install = false; + $installer = self::loadInstaller($installer_file_folder, $is_install); + + if ($installer && method_exists($installer, 'preUninstall')) { + $method_result = $installer::preUninstall(); + if ($method_result !== true) { + self::$error = 'An error occurred'; + if (is_string($method_result)) { + self::$error = $method_result; + } + + return false; + } + } + + $result = Folder::delete($path); + + self::$message = ''; + if ($result && $installer && method_exists($installer, 'postUninstall')) { + self::$message = $installer::postUninstall(); + } + + return $result; + } + + /** + * Runs a set of checks on the destination and sets the Error if any + * + * @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 = []) + { + self::$error = 0; + self::$target = $destination; + + if (is_link($destination)) { + self::$error = self::IS_LINK; + } elseif (file_exists($destination)) { + self::$error = self::EXISTS; + } elseif (!file_exists($destination)) { + self::$error = self::NOT_FOUND; + } elseif (!is_dir($destination)) { + self::$error = self::NOT_DIRECTORY; + } + + if (\count($exclude) && \in_array(self::$error, $exclude, true)) { + return true; + } + + return !self::$error; + } + + /** + * 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) + { + self::$error = 0; + self::$target = $target; + + 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') + ) { + self::$error = self::NOT_GRAV_ROOT; + } + + return !self::$error; + } + + /** + * Returns the last message added by the installer + * @return string The message + */ + public static function getMessage() + { + return self::$message; + } + + /** + * Returns the last error occurred in a string message format + * @return string The message of the last error + */ + public static function lastErrorMsg() + { + if (is_string(self::$error)) { + return self::$error; + } + + switch (self::$error) { + case 0: + $msg = 'No Error'; + break; + + case self::EXISTS: + $msg = 'The target path "' . self::$target . '" already exists'; + break; + + case self::IS_LINK: + $msg = 'The target path "' . self::$target . '" is a symbolic link'; + break; + + case self::NOT_FOUND: + $msg = 'The target path "' . self::$target . '" does not appear to exist'; + break; + + case self::NOT_DIRECTORY: + $msg = 'The target path "' . self::$target . '" does not appear to be a folder'; + break; + + case self::NOT_GRAV_ROOT: + $msg = 'The target path "' . self::$target . '" does not appear to be a Grav instance'; + break; + + case self::ZIP_OPEN_ERROR: + $msg = 'Unable to open the package file'; + break; + + case self::ZIP_EXTRACT_ERROR: + $msg = 'Unable to extract the package. '; + if (self::$error_zip) { + switch(self::$error_zip) { + case \ZipArchive::ER_EXISTS: + $msg .= 'File already exists.'; + break; + + case \ZipArchive::ER_INCONS: + $msg .= 'Zip archive inconsistent.'; + break; + + case \ZipArchive::ER_MEMORY: + $msg .= 'Memory allocation failure.'; + break; + + case \ZipArchive::ER_NOENT: + $msg .= 'No such file.'; + break; + + case \ZipArchive::ER_NOZIP: + $msg .= 'Not a zip archive.'; + break; + + case \ZipArchive::ER_OPEN: + $msg .= "Can't open file."; + break; + + case \ZipArchive::ER_READ: + $msg .= 'Read error.'; + break; + + case \ZipArchive::ER_SEEK: + $msg .= 'Seek error.'; + break; + } + } + break; + + case self::INVALID_SOURCE: + $msg = 'Invalid source file'; + break; + + default: + $msg = 'Unknown Error'; + break; + } + + return $msg; + } + + /** + * Returns the last error code of the occurred error + * @return int|string The code of the last error + */ + public static function lastErrorCode() + { + return self::$error; + } + + /** + * Allows to manually set an error + * + * @param int|string $error the Error code + */ + + 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 new file mode 100644 index 0000000..14c9258 --- /dev/null +++ b/system/src/Grav/Common/GPM/Licenses.php @@ -0,0 +1,123 @@ +content(); + $slug = strtolower($slug); + + if ($license && !self::validate($license)) { + return false; + } + + if (!\is_string($license)) { + if (isset($data['licenses'][$slug])) { + unset($data['licenses'][$slug]); + } else { + return false; + } + } else { + $data['licenses'][$slug] = $license; + } + + $licenses->save($data); + $licenses->free(); + + return true; + } + + /** + * Returns the license for a Premium package + * + * @param string $slug + * + * @return array|string + */ + public static function get($slug = null) + { + $licenses = self::getLicenseFile(); + $data = (array)$licenses->content(); + $licenses->free(); + $slug = strtolower($slug); + + if (!$slug) { + return $data['licenses'] ?? []; + } + + return $data['licenses'][$slug] ?? ''; + } + + + /** + * Validates the License format + * + * @param string $license + * + * @return bool + */ + public static function validate($license = null) + { + if (!is_string($license)) { + return false; + } + + return preg_match('#' . self::$regex. '#', $license); + } + + /** + * Get the License File object + * + * @return \RocketTheme\Toolbox\File\FileInterface + */ + public static function getLicenseFile() + + { + if (!isset(self::$file)) { + $path = Grav::instance()['locator']->findResource('user-data://') . '/licenses.yaml'; + if (!file_exists($path)) { + touch($path); + } + self::$file = CompiledYamlFile::instance($path); + } + + return self::$file; + } +} diff --git a/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php new file mode 100644 index 0000000..be565c9 --- /dev/null +++ b/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php @@ -0,0 +1,25 @@ + $data) { + $data->set('slug', $name); + $this->items[$name] = new Package($data, $this->type); + } + } +} diff --git a/system/src/Grav/Common/GPM/Local/Package.php b/system/src/Grav/Common/GPM/Local/Package.php new file mode 100644 index 0000000..88241a4 --- /dev/null +++ b/system/src/Grav/Common/GPM/Local/Package.php @@ -0,0 +1,41 @@ +blueprints()->toArray()); + parent::__construct($data, $package_type); + + $this->settings = $package->toArray(); + + $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)); + $this->data->set('symlink', is_link(USER_DIR . $package_type . DS . $this->__get('slug'))); + } + + /** + * @return mixed + */ + public function isEnabled() + { + return $this->settings['enabled']; + } +} diff --git a/system/src/Grav/Common/GPM/Local/Packages.php b/system/src/Grav/Common/GPM/Local/Packages.php new file mode 100644 index 0000000..14a0b0b --- /dev/null +++ b/system/src/Grav/Common/GPM/Local/Packages.php @@ -0,0 +1,25 @@ + new Plugins(), + 'themes' => new Themes() + ]; + + parent::__construct($items); + } +} diff --git a/system/src/Grav/Common/GPM/Local/Plugins.php b/system/src/Grav/Common/GPM/Local/Plugins.php new file mode 100644 index 0000000..19adfb8 --- /dev/null +++ b/system/src/Grav/Common/GPM/Local/Plugins.php @@ -0,0 +1,31 @@ +all()); + } +} diff --git a/system/src/Grav/Common/GPM/Local/Themes.php b/system/src/Grav/Common/GPM/Local/Themes.php new file mode 100644 index 0000000..8607e6b --- /dev/null +++ b/system/src/Grav/Common/GPM/Local/Themes.php @@ -0,0 +1,28 @@ +all()); + } +} diff --git a/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php new file mode 100644 index 0000000..429c5b1 --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php @@ -0,0 +1,77 @@ +get('system.gpm.releases', 'stable'); + $cache_dir = Grav::instance()['locator']->findResource('cache://gpm', true, true); + $this->cache = new FilesystemCache($cache_dir); + + $this->repository = $repository . '?v=' . GRAV_VERSION . '&' . $channel . '=1'; + $this->raw = $this->cache->fetch(md5($this->repository)); + + $this->fetch($refresh, $callback); + foreach (json_decode($this->raw, true) as $slug => $data) { + // Temporarily fix for using multisites + if (isset($data['install_path'])) { + $path = preg_replace('~^user/~i', 'user://', $data['install_path']); + $data['install_path'] = Grav::instance()['locator']->findResource($path, false, true); + } + $this->items[$slug] = new Package($data, $this->type); + } + } + + public function fetch($refresh = false, $callback = null) + { + if (!$this->raw || $refresh) { + $response = Response::get($this->repository, [], $callback); + $this->raw = $response; + $this->cache->save(md5($this->repository), $this->raw, $this->lifetime); + } + + return $this->raw; + } +} diff --git a/system/src/Grav/Common/GPM/Remote/GravCore.php b/system/src/Grav/Common/GPM/Remote/GravCore.php new file mode 100644 index 0000000..1d30120 --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/GravCore.php @@ -0,0 +1,141 @@ +get('system.gpm.releases', 'stable'); + $cache_dir = Grav::instance()['locator']->findResource('cache://gpm', true, true); + $this->cache = new FilesystemCache($cache_dir); + $this->repository .= '?v=' . GRAV_VERSION . '&' . $channel . '=1'; + $this->raw = $this->cache->fetch(md5($this->repository)); + + $this->fetch($refresh, $callback); + + $this->data = json_decode($this->raw, true); + $this->version = $this->data['version'] ?? '-'; + $this->date = $this->data['date'] ?? '-'; + $this->min_php = $this->data['min_php'] ?? null; + + if (isset($this->data['assets'])) { + foreach ((array)$this->data['assets'] as $slug => $data) { + $this->items[$slug] = new Package($data); + } + } + } + + /** + * Returns the list of assets associated to the latest version of Grav + * + * @return array list of assets + */ + public function getAssets() + { + return $this->data['assets']; + } + + /** + * Returns the changelog list for each version of Grav + * + * @param string $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; + } + + /** + * Return the release date of the latest Grav + * + * @return string + */ + public function getDate() + { + return $this->date; + } + + /** + * Determine if this version of Grav is eligible to be updated + * + * @return mixed + */ + public function isUpdatable() + { + return version_compare(GRAV_VERSION, $this->getVersion(), '<'); + } + + /** + * Returns the latest version of Grav available remotely + * + * @return string + */ + public function getVersion() + { + return $this->version; + } + + /** + * Returns the minimum PHP version + * + * @return null|string + */ + public function getMinPHPVersion() + { + // If non min set, assume current PHP version + if (null === $this->min_php) { + $this->min_php = phpversion(); + } + return $this->min_php; + } + + /** + * Is this installation symlinked? + * + * @return bool + */ + public function isSymlink() + { + return is_link(GRAV_ROOT . DS . 'index.php'); + } +} diff --git a/system/src/Grav/Common/GPM/Remote/Package.php b/system/src/Grav/Common/GPM/Remote/Package.php new file mode 100644 index 0000000..196e92b --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/Package.php @@ -0,0 +1,27 @@ +data; + } +} diff --git a/system/src/Grav/Common/GPM/Remote/Packages.php b/system/src/Grav/Common/GPM/Remote/Packages.php new file mode 100644 index 0000000..46bc31f --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/Packages.php @@ -0,0 +1,25 @@ + new Plugins($refresh, $callback), + 'themes' => new Themes($refresh, $callback) + ]; + + parent::__construct($items); + } +} diff --git a/system/src/Grav/Common/GPM/Remote/Plugins.php b/system/src/Grav/Common/GPM/Remote/Plugins.php new file mode 100644 index 0000000..1d905e3 --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/Plugins.php @@ -0,0 +1,30 @@ +repository, $refresh, $callback); + } +} diff --git a/system/src/Grav/Common/GPM/Remote/Themes.php b/system/src/Grav/Common/GPM/Remote/Themes.php new file mode 100644 index 0000000..8024b40 --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/Themes.php @@ -0,0 +1,30 @@ +repository, $refresh, $callback); + } +} diff --git a/system/src/Grav/Common/GPM/Response.php b/system/src/Grav/Common/GPM/Response.php new file mode 100644 index 0000000..221ecf0 --- /dev/null +++ b/system/src/Grav/Common/GPM/Response.php @@ -0,0 +1,434 @@ + [ + 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('/(?:^|\n)Location:(.*?)\n/i', $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); + } +} diff --git a/system/src/Grav/Common/GPM/Upgrader.php b/system/src/Grav/Common/GPM/Upgrader.php new file mode 100644 index 0000000..5d9406d --- /dev/null +++ b/system/src/Grav/Common/GPM/Upgrader.php @@ -0,0 +1,142 @@ +remote = new Remote\GravCore($refresh, $callback); + } + + /** + * Returns the release date of the latest version of Grav + * + * @return string + */ + public function getReleaseDate() + { + return $this->remote->getDate(); + } + + /** + * Returns the version of the installed Grav + * + * @return string + */ + public function getLocalVersion() + { + return GRAV_VERSION; + } + + /** + * Returns the version of the remotely available Grav + * + * @return string + */ + public function getRemoteVersion() + { + return $this->remote->getVersion(); + } + + /** + * Returns an array of assets available to download remotely + * + * @return array + */ + public function getAssets() + { + return $this->remote->getAssets(); + } + + /** + * Returns the changelog list for each version of Grav + * + * @param string $diff the version number to start the diff from + * + * @return array return the changelog list for each version + */ + public function getChangelog($diff = null) + { + return $this->remote->getChangelog($diff); + } + + /** + * Make sure this meets minimum PHP requirements + * + * @return bool + */ + public function meetsRequirements() + { + $current_php_version = phpversion(); + if (version_compare($current_php_version, $this->minPHPVersion(), '<')) { + return false; + } + + return true; + } + + /** + * Get minimum PHP version from remote + * + * @return null + */ + 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. + */ + public function isUpgradable() + { + return version_compare($this->getLocalVersion(), $this->getRemoteVersion(), '<'); + } + + /** + * Checks if Grav is currently symbolically linked + * + * @return boolean 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 new file mode 100644 index 0000000..e69116e --- /dev/null +++ b/system/src/Grav/Common/Getters.php @@ -0,0 +1,161 @@ +offsetSet($offset, $value); + } + + /** + * Magic getter method + * + * @param mixed $offset Medium name value + * + * @return mixed Medium value + */ + public function __get($offset) + { + return $this->offsetGet($offset); + } + + /** + * Magic method to determine if the attribute is set + * + * @param mixed $offset Medium name value + * + * @return boolean True if the value is set + */ + public function __isset($offset) + { + return $this->offsetExists($offset); + } + + /** + * Magic method to unset the attribute + * + * @param mixed $offset The name value to unset + */ + public function __unset($offset) + { + $this->offsetUnset($offset); + } + + /** + * @param mixed $offset + * + * @return bool + */ + public function offsetExists($offset) + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + + return isset($this->{$var}[$offset]); + } + + return isset($this->{$offset}); + } + + /** + * @param mixed $offset + * + * @return mixed + */ + public function offsetGet($offset) + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + + return $this->{$var}[$offset] ?? null; + } + + return $this->{$offset} ?? null; + } + + /** + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value) + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + $this->{$var}[$offset] = $value; + } else { + $this->{$offset} = $value; + } + } + + /** + * @param mixed $offset + */ + public function offsetUnset($offset) + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + unset($this->{$var}[$offset]); + } else { + unset($this->{$offset}); + } + } + + /** + * @return int + */ + public function count() + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + return \count($this->{$var}); + } + + return \count($this->toArray()); + } + + /** + * Returns an associative array of object properties. + * + * @return array + */ + public function toArray() + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + + return $this->{$var}; + } + + $properties = (array)$this; + $list = []; + foreach ($properties as $property => $value) { + if ($property[0] !== "\0") { + $list[$property] = $value; + } + } + + return $list; + } +} diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php new file mode 100644 index 0000000..c7287db --- /dev/null +++ b/system/src/Grav/Common/Grav.php @@ -0,0 +1,618 @@ + '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', + ]; + + /** + * @var array All middleware processors that are processed in $this->process() + */ + protected $middleware = [ + 'configurationProcessor', + 'loggerProcessor', + 'errorsProcessor', + 'debuggerProcessor', + 'initializeProcessor', + 'pluginsProcessor', + 'themesProcessor', + 'requestProcessor', + 'tasksProcessor', + 'backupsProcessor', + 'schedulerProcessor', + 'assetsProcessor', + 'twigProcessor', + 'pagesProcessor', + 'debuggerAssetsProcessor', + 'renderProcessor', + ]; + + protected $initialized = []; + + /** + * Reset the Grav instance. + */ + public static function resetInstance() + { + if (self::$instance) { + self::$instance = null; + } + } + + /** + * 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) { + self::$instance = static::load($values); + } elseif ($values) { + $instance = self::$instance; + foreach ($values as $key => $value) { + $instance->offsetSet($key, $value); + } + } + + return self::$instance; + } + + /** + * Setup Grav instance using specific environment. + * + * Initializes Grav streams by + * + * @param string|null $environment + * @return $this + */ + public function setup(string $environment = null) + { + if (isset($this->initialized['setup'])) { + return $this; + } + + $this->initialized['setup'] = true; + + $this->measureTime('_setup', 'Site Setup', function () use ($environment) { + // Force environment if passed to the method. + if ($environment) { + Setup::$environment = $environment; + } + + $this['setup']; + $this['streams']; + }); + + return $this; + } + + /** + * Initialize CLI environment. + * + * Call after `$grav->setup($environment)` + * + * - Load configuration + * - Disable debugger + * - Set timezone, locale + * - Load plugins + * - Set Users type to be used in the site + * + * This method WILL NOT initialize assets, twig or pages. + * + * @param string|null $environment + * @return $this + */ + public function initializeCli() + { + InitializeProcessor::initializeCli($this); + + return $this; + } + + /** + * Process a request + */ + public function process() + { + if (isset($this->initialized['process'])) { + return; + } + + // Initialize Grav if needed. + $this->setup(); + + $this->initialized['process'] = true; + + $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); + }, + 'initializeProcessor' => function () { + return new InitializeProcessor($this); + }, + 'backupsProcessor' => function () { + return new BackupsProcessor($this); + }, + 'pluginsProcessor' => function () { + return new PluginsProcessor($this); + }, + 'themesProcessor' => function () { + return new ThemesProcessor($this); + }, + 'schedulerProcessor' => function () { + return new SchedulerProcessor($this); + }, + 'requestProcessor' => function () { + return new RequestProcessor($this); + }, + 'tasksProcessor' => function () { + return new TasksProcessor($this); + }, + 'assetsProcessor' => function () { + return new AssetsProcessor($this); + }, + 'twigProcessor' => function () { + return new TwigProcessor($this); + }, + 'pagesProcessor' => function () { + return new PagesProcessor($this); + }, + 'debuggerAssetsProcessor' => function () { + return new DebuggerAssetsProcessor($this); + }, + 'renderProcessor' => function () { + return new RenderProcessor($this); + }, + ] + ); + + $default = function (ServerRequestInterface $request) { + return new Response(404); + }; + + /** @var Debugger $debugger */ + $debugger = $this['debugger']; + + $collection = new RequestHandler($this->middleware, $default, $container); + + $response = $collection->handle($this['request']); + $body = $response->getBody(); + + // Handle ETag and If-None-Match headers. + if ($response->getHeaderLine('ETag') === '1') { + $etag = md5($body); + $response = $response->withHeader('ETag', $etag); + + if ($this['request']->getHeaderLine('If-None-Match') === $etag) { + $response = $response->withStatus(304); + $body = ''; + } + } + + $this->header($response); + echo $body; + + $debugger->render(); + + register_shutdown_function([$this, 'shutdown']); + } + + /** + * Set the system locale based on the language and configuration + */ + public function setLocale() + { + // 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')); + } + } + + /** + * Redirect browser to another location. + * + * @param string $route Internal route. + * @param int $code Redirection code (30x) + */ + public function redirect($route, $code = null) + { + /** @var Uri $uri */ + $uri = $this['uri']; + + // Clean route for redirect + $route = preg_replace("#^\/[\\\/]+\/#", '/', $route); + + // 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]; + } + + if ($code === null) { + $code = $this['config']->get('system.pages.redirect_default_code', 302); + } + + if (isset($this['session'])) { + $this['session']->close(); + } + + if ($uri->isExternal($route)) { + $url = $route; + } else { + $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 + } + } + + header("Location: {$url}", true, $code); + exit(); + } + + /** + * Redirect browser to another location taking language into account (preferred) + * + * @param string $route Internal route. + * @param int $code Redirection code (30x) + */ + public function redirectLangSafe($route, $code = null) + { + if (!$this['uri']->isExternal($route)) { + $this->redirect($this['pages']->route($route), $code); + } else { + $this->redirect($route, $code); + } + } + + /** + * Set response header. + * + * @param ResponseInterface|null $response + */ + public function header(ResponseInterface $response = null) + { + if (null === $response) { + /** @var PageInterface $page */ + $page = $this['page']; + $response = new Response($page->httpResponseCode(), $page->httpHeaders(), ''); + } + + header("HTTP/{$response->getProtocolVersion()} {$response->getStatusCode()} {$response->getReasonPhrase()}"); + foreach ($response->getHeaders() as $key => $values) { + foreach ($values as $i => $value) { + header($key . ': ' . $value, $i === 0); + } + } + } + + /** + * Fires an event with optional parameters. + * + * @param string $eventName + * @param Event $event + * + * @return Event + */ + public function fireEvent($eventName, Event $event = null) + { + /** @var EventDispatcher $events */ + $events = $this['events']; + + return $events->dispatch($eventName, $event); + } + + /** + * Set the final content length for the page and flush the buffer + * + */ + public function shutdown() + { + // Prevent user abort allowing onShutdown event to run without interruptions. + if (\function_exists('ignore_user_abort')) { + @ignore_user_abort(true); + } + + // Close the session allowing new requests to be handled. + if (isset($this['session'])) { + $this['session']->close(); + } + + if ($this['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; + + 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 { + // 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'); + } + + } + + + // Get length and close the connection. + header('Content-Length: ' . ob_get_length()); + header('Connection: close'); + + ob_end_flush(); + @ob_flush(); + flush(); + } + } + + // Run any time consuming tasks. + $this->fireEvent('onShutdown'); + } + + /** + * Magic Catch All Function + * + * Used to call closures. + * + * Source: http://stackoverflow.com/questions/419804/closures-as-class-members + * + * @param string $method + * @param array $args + * @return + */ + public function __call($method, $args) + { + $closure = $this->{$method} ?? null; + + return is_callable($closure) ? $closure(...$args) : null; + } + + /** + * Measure how long it takes to do an action. + * + * @param string $timerId + * @param string $timerTitle + * @param callable $callback + * @return mixed Returns value returned by the callable. + */ + public function measureTime(string $timerId, string $timerTitle, callable $callback) + { + $debugger = $this['debugger']; + $debugger->startTimer($timerId, $timerTitle); + $result = $callback(); + $debugger->stopTimer($timerId); + + return $result; + } + + /** + * Initialize and return a Grav instance + * + * @param array $values + * + * @return static + */ + protected static function load(array $values) + { + $container = new static($values); + + $container['debugger'] = new Debugger(); + $container['grav'] = function (Container $container) { + user_error('Calling $grav[\'grav\'] or {{ grav.grav }} is deprecated since Grav 1.6, just use $grav or {{ grav }}', E_USER_DEPRECATED); + + return $container; + }; + + $container->measureTime('_services', 'Services', function () use ($container) { + $container->registerServices(); + }); + + return $container; + } + + /** + * Register all services + * Services are defined in the diMap. They can either only the class + * of a Service Provider or a pair of serviceKey => serviceClass that + * gets directly mapped into the container. + * + * @return void + */ + protected function registerServices() + { + foreach (self::$diMap as $serviceKey => $serviceClass) { + if (\is_int($serviceKey)) { + $this->register(new $serviceClass); + } else { + $this[$serviceKey] = function ($c) use ($serviceClass) { + return new $serviceClass($c); + }; + } + } + } + + /** + * This attempts to find media, other files, and download them + * + * @param string $path + */ + public function fallbackUrl($path) + { + $this->fireEvent('onPageFallBackUrl'); + + /** @var Uri $uri */ + $uri = $this['uri']; + + /** @var Config $config */ + $config = $this['config']; + + $uri_extension = strtolower($uri->extension()); + $fallback_types = $config->get('system.media.allowed_fallback_types', null); + $supported_types = $config->get('media.types'); + + // Check whitelist first, then ensure extension is a valid media type + 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)); + } + } + Utils::download($medium->path(), false); + } + + // unsupported media type, try to download it... + if ($uri_extension) { + $extension = $uri_extension; + } else { + if (isset($path_parts['extension'])) { + $extension = $path_parts['extension']; + } else { + $extension = null; + } + } + + if ($extension) { + $download = 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; + } +} diff --git a/system/src/Grav/Common/GravTrait.php b/system/src/Grav/Common/GravTrait.php new file mode 100644 index 0000000..a82c23a --- /dev/null +++ b/system/src/Grav/Common/GravTrait.php @@ -0,0 +1,33 @@ +', '?' + 0xFF,0x00,0x01,0x02,0x03,0x04,0x05,0x06, // '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G' + 0x07,0x08,0x09,0x0A,0x0B,0x0C,0x0D,0x0E, // 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O' + 0x0F,0x10,0x11,0x12,0x13,0x14,0x15,0x16, // 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W' + 0x17,0x18,0x19,0xFF,0xFF,0xFF,0xFF,0xFF, // 'X', 'Y', 'Z', '[', '\', ']', '^', '_' + 0xFF,0x00,0x01,0x02,0x03,0x04,0x05,0x06, // '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g' + 0x07,0x08,0x09,0x0A,0x0B,0x0C,0x0D,0x0E, // 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o' + 0x0F,0x10,0x11,0x12,0x13,0x14,0x15,0x16, // 'p', 'q', 'r', 's', 't', 'u', 'v', 'w' + 0x17,0x18,0x19,0xFF,0xFF,0xFF,0xFF,0xFF // 'x', 'y', 'z', '{', '|', '}', '~', 'DEL' + ]; + + /** + * Encode in Base32 + * + * @param string $bytes + * @return string + */ + public static function encode($bytes) + { + $i = 0; $index = 0; + $base32 = ''; + $bytesLen = \strlen($bytes); + + while ($i < $bytesLen) { + $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]); + } else { + $nextByte = 0; + } + + $digit = $currByte & (0xFF >> $index); + $index = ($index + 5) % 8; + $digit <<= $index; + $digit |= $nextByte >> (8 - $index); + $i++; + } else { + $digit = ($currByte >> (8 - ($index + 5))) & 0x1F; + $index = ($index + 5) % 8; + if ($index === 0) { + $i++; + } + } + + $base32 .= self::$base32Chars[$digit]; + } + return $base32; + } + + /** + * Decode in Base32 + * + * @param string $base32 + * @return string + */ + public static function decode($base32) + { + $bytes = []; + $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'); + + /* Skip chars outside the lookup table */ + if ($lookup < 0 || $lookup >= $base32LookupLen) { + continue; + } + + $digit = self::$base32Lookup[$lookup]; + + /* If this digit is not in the table, ignore it */ + if ($digit === 0xFF) { + continue; + } + + if ($index <= 3) { + $index = ($index + 5) % 8; + if ($index === 0) { + $bytes[$offset] |= $digit; + $offset++; + if ($offset >= \count($bytes)) { + break; + } + } else { + $bytes[$offset] |= $digit << (8 - $index); + } + } else { + $index = ($index + 5) % 8; + $bytes[$offset] |= ($digit >> $index); + $offset++; + if ($offset >= \count($bytes)) { + break; + } + $bytes[$offset] |= $digit << (8 - $index); + } + } + + $bites = ''; + foreach ($bytes as $byte) { + $bites .= \chr($byte); + } + + return $bites; + } +} diff --git a/system/src/Grav/Common/Helpers/Excerpts.php b/system/src/Grav/Common/Helpers/Excerpts.php new file mode 100644 index 0000000..f07c3aa --- /dev/null +++ b/system/src/Grav/Common/Helpers/Excerpts.php @@ -0,0 +1,150 @@ +` + * @param PageInterface|null $page Page, defaults to the current page object + * @return string Returns final HTML string + */ + public static function processImageHtml($html, PageInterface $page = null) + { + $excerpt = static::getExcerptFromHtml($html, 'img'); + + $original_src = $excerpt['element']['attributes']['src']; + $excerpt['element']['attributes']['href'] = $original_src; + + $excerpt = static::processLinkExcerpt($excerpt, $page, 'image'); + + $excerpt['element']['attributes']['src'] = $excerpt['element']['attributes']['href']; + unset ($excerpt['element']['attributes']['href']); + + $excerpt = static::processImageExcerpt($excerpt, $page); + + $excerpt['element']['attributes']['data-src'] = $original_src; + + $html = static::getHtmlFromExcerpt($excerpt); + + return $html; + } + + /** + * Get an Excerpt array from a chunk of HTML + * + * @param string $html Chunk of HTML + * @param string $tag A tag, for example `img` + * @return array|null returns nested array excerpt + */ + public static function getExcerptFromHtml($html, $tag) + { + $doc = new \DOMDocument(); + $doc->loadHTML($html); + $images = $doc->getElementsByTagName($tag); + $excerpt = null; + + foreach ($images as $image) { + $attributes = []; + foreach ($image->attributes as $name => $value) { + $attributes[$name] = $value->value; + } + $excerpt = [ + 'element' => [ + 'name' => $image->tagName, + 'attributes' => $attributes + ] + ]; + } + + return $excerpt; + } + + /** + * Rebuild HTML tag from an excerpt array + * + * @param array $excerpt + * @return string + */ + public static function getHtmlFromExcerpt($excerpt) + { + $element = $excerpt['element']; + $html = '<'.$element['name']; + + if (isset($element['attributes'])) { + foreach ($element['attributes'] as $name => $value) { + if ($value === null) { + continue; + } + $html .= ' '.$name.'="'.$value.'"'; + } + } + + if (isset($element['text'])) { + $html .= '>'; + $html .= $element['text']; + $html .= ''; + } else { + $html .= ' />'; + } + + return $html; + } + + /** + * Process a Link excerpt + * + * @param array $excerpt + * @param PageInterface|null $page Page, defaults to the current page object + * @param string $type + * @return mixed + */ + public static function processLinkExcerpt($excerpt, PageInterface $page = null, $type = 'link') + { + $excerpts = new ExcerptsObject($page); + + return $excerpts->processLinkExcerpt($excerpt, $type); + } + + /** + * Process an image excerpt + * + * @param array $excerpt + * @param PageInterface|null $page Page, defaults to the current page object + * @return array + */ + public static function processImageExcerpt(array $excerpt, PageInterface $page = null) + { + $excerpts = new ExcerptsObject($page); + + return $excerpts->processImageExcerpt($excerpt); + } + + /** + * Process media actions + * + * @param Medium $medium + * @param string|array $url + * @param PageInterface|null $page Page, defaults to the current page object + * @return Medium + */ + public static function processMediaActions($medium, $url, PageInterface $page = null) + { + $excerpts = new ExcerptsObject($page); + + return $excerpts->processMediaActions($medium, $url); + } +} diff --git a/system/src/Grav/Common/Helpers/Exif.php b/system/src/Grav/Common/Helpers/Exif.php new file mode 100644 index 0000000..15aff66 --- /dev/null +++ b/system/src/Grav/Common/Helpers/Exif.php @@ -0,0 +1,41 @@ +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); + } else { + throw new \RuntimeException('Please enable the Exif extension for PHP or disable Exif support in Grav system configuration'); + } + } + } + + public function getReader() + { + if ($this->reader) { + return $this->reader; + } + + return false; + } +} diff --git a/system/src/Grav/Common/Helpers/LogViewer.php b/system/src/Grav/Common/Helpers/LogViewer.php new file mode 100644 index 0000000..397fb17 --- /dev/null +++ b/system/src/Grav/Common/Helpers/LogViewer.php @@ -0,0 +1,149 @@ +.*)\] (?P\w+).(?P\w+): (?P.*[^ ]+) (?P[^ ]+) (?P[^ ]+)/'; + + /** + * Get the objects of a tailed file + * + * @param string $filepath + * @param int $lines + * @param bool $desc + * @return array + */ + public function objectTail($filepath, $lines = 1, $desc = true) + { + $data = $this->tail($filepath, $lines); + $tailed_log = explode(PHP_EOL, $data); + $line_objects = []; + + foreach ($tailed_log as $line) { + $line_objects[] = $this->parse($line); + } + + return $desc ? $line_objects : array_reverse($line_objects); + } + + /** + * Optimized way to get just the last few entries of a log file + * + * @param string $filepath + * @param int $lines + * @return bool|string + */ + public function tail($filepath, $lines = 1) { + + $f = @fopen($filepath, "rb"); + if ($f === false) return false; + + else $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096)); + + fseek($f, -1, SEEK_END); + if (fread($f, 1) != "\n") $lines -= 1; + + // Start reading + $output = ''; + $chunk = ''; + // While we would like more + while (ftell($f) > 0 && $lines >= 0) { + // Figure out how far back we should jump + $seek = min(ftell($f), $buffer); + // 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; + // Jump back to where we started reading + fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR); + // Decrease our line counter + $lines -= substr_count($chunk, "\n"); + } + // While we have too many lines + // (Because of buffer size we might have read too many) + while ($lines++ < 0) { + // Find first newline and remove all text before that + $output = substr($output, strpos($output, "\n") + 1); + } + // Close file and return + fclose($f); + + return trim($output); + } + + /** + * Helper class to get level color + * + * @param string $level + * @return mixed|string + */ + public static function levelColor($level) + { + $colors = [ + 'DEBUG' => 'green', + 'INFO' => 'cyan', + 'NOTICE' => 'yellow', + 'WARNING' => 'yellow', + 'ERROR' => 'red', + 'CRITICAL' => 'red', + 'ALERT' => 'red', + 'EMERGENCY' => 'magenta' + ]; + return $colors[$level] ?? 'white'; + } + + /** + * Parse a monolog row into array bits + * + * @param string $line + * @return array + */ + public function parse($line) + { + if( !is_string($line) || strlen($line) === 0) { + return array(); + } + preg_match($this->pattern, $line, $data); + if (!isset($data['date'])) { + return array(); + } + + preg_match('/(.*)- Trace:(.*)/', $data['message'], $matches); + if (is_array($matches) && isset($matches[1])) { + $data['message'] = trim($matches[1]); + $data['trace'] = trim($matches[2]); + } + + return array( + '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, + 'context' => json_decode($data['context'], true), + 'extra' => json_decode($data['extra'], true) + ); + } + + /** + * Parse text of trace into an array of lines + * + * @param string $trace + * @param int $rows + * @return array + */ + 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 new file mode 100644 index 0000000..d116bb3 --- /dev/null +++ b/system/src/Grav/Common/Helpers/Truncator.php @@ -0,0 +1,335 @@ +getElementsByTagName('div')->item(0); + $container = $container->parentNode->removeChild($container); + + // Iterate over words. + $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]; + $offset = $currentWordPosition[1]; + $words = $currentWordPosition[2]; + + $curNode->nodeValue = substr( + $curNode->nodeValue, + 0, + $words[$offset][1] + strlen($words[$offset][0]) + ); + + self::removeProceedingNodes($curNode, $container); + + if (!empty($ellipsis)) { + self::insertEllipsis($curNode, $ellipsis); + } + + $truncated = true; + + break; + } + + } + + // Return original HTML if not truncated. + if ($truncated) { + $html = self::getCleanedHtml($doc, $container); + } + + return $html; + } + + /** + * 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). + * @return string Safe truncated HTML. + */ + public static function truncateLetters($html, $limit = 0, $ellipsis = '') + { + if ($limit <= 0) { + return $html; + } + + $doc = self::htmlToDomDocument($html); + $container = $doc->getElementsByTagName('div')->item(0); + $container = $container->parentNode->removeChild($container); + + // Iterate over letters. + $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); + + if (!empty($ellipsis)) { + self::insertEllipsis($currentText[0], $ellipsis); + } + + $truncated = true; + + break; + } + } + + // Return original HTML if not truncated. + if ($truncated) { + $html = self::getCleanedHtml($doc, $container); + } + + return $html; + } + + /** + * Builds a DOMDocument object from a string containing HTML. + * @param string $html HTML to load + * @returns DOMDocument Returns a DOMDocument object. + */ + public static function htmlToDomDocument($html) + { + if (!$html) { + $html = ''; + } + + // Transform multibyte entities which otherwise display incorrectly. + $html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); + + // Internal errors enabled as HTML5 not fully supported. + libxml_use_internal_errors(true); + + // Instantiate new DOMDocument object, and then load in UTF-8 HTML. + $dom = new DOMDocument(); + $dom->encoding = 'UTF-8'; + $dom->loadHTML("
$html
"); + + return $dom; + } + + /** + * Removes all nodes after the current node. + * @param DOMNode|DOMElement $domNode + * @param DOMNode|DOMElement $topNode + * @return void + */ + private static function removeProceedingNodes($domNode, $topNode) + { + $nextNode = $domNode->nextSibling; + + if ($nextNode !== null) { + self::removeProceedingNodes($nextNode, $topNode); + $domNode->parentNode->removeChild($nextNode); + } else { + //scan upwards till we find a sibling + $curNode = $domNode->parentNode; + while ($curNode !== $topNode) { + if ($curNode->nextSibling !== null) { + $curNode = $curNode->nextSibling; + self::removeProceedingNodes($curNode, $topNode); + $curNode->parentNode->removeChild($curNode); + break; + } + $curNode = $curNode->parentNode; + } + } + } + + /** + * Clean extra code + * + * @param DOMDocument $doc + * @param $container + * @return string + */ + private static function getCleanedHTML(DOMDocument $doc, $container) + { + while ($doc->firstChild) { + $doc->removeChild($doc->firstChild); + } + + while ($container->firstChild ) { + $doc->appendChild($container->firstChild); + } + + $html = trim($doc->saveHTML()); + return $html; + } + + /** + * Inserts an ellipsis + * @param DOMNode|DOMElement $domNode Element to insert after. + * @param string $ellipsis Text used to suffix our document. + * @return void + */ + private static function insertEllipsis($domNode, $ellipsis) + { + $avoid = array('a', 'strong', 'em', 'h1', 'h2', 'h3', 'h4', 'h5'); //html tags to avoid appending the ellipsis to + + if ($domNode->parentNode->parentNode !== null && in_array($domNode->parentNode->nodeName, $avoid, true)) { + // Append as text node to parent instead + $textNode = new DOMText($ellipsis); + + if ($domNode->parentNode->parentNode->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; + } + } + + /** + * + */ + public function truncate( + $text, + $length = 100, + $ending = '...', + $exact = false, + $considerHtml = true + ) { + if ($considerHtml) { + // if the plain text is shorter than the maximum length, return the whole text + 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 = ''; + 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])) { + // if it's an "empty element" with or without xhtml-conform closing slash + 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)) { + // 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)) { + // add tag to the beginning of $open_tags list + array_unshift($open_tags, strtolower($tag_matchings[1])); + } + // add html-tag to $truncate'd text + $truncate .= $line_matchings[1]; + } + // calculate the length of the plain text part of the line; handle entities as one character + $content_length = strlen(preg_replace('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|[0-9a-f]{1,6};/i', ' ', $line_matchings[2])); + if ($total_length+$content_length> $length) { + // the number of characters which are left + $left = $length - $total_length; + $entities_length = 0; + // search for html entities + if (preg_match_all('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|[0-9a-f]{1,6};/i', $line_matchings[2], $entities, PREG_OFFSET_CAPTURE)) { + // calculate the real length of all entities in the legal range + foreach ($entities[0] as $entity) { + if ($entity[1]+1-$entities_length <= $left) { + $left--; + $entities_length += strlen($entity[0]); + } else { + // no more characters left + break; + } + } + } + $truncate .= substr($line_matchings[2], 0, $left+$entities_length); + // maximum lenght is reached, so get off the loop + break; + } else { + $truncate .= $line_matchings[2]; + $total_length += $content_length; + } + // if the maximum length is reached, get off the loop + if($total_length>= $length) { + break; + } + } + } else { + if (strlen($text) <= $length) { + return $text; + } else { + $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)) { + // ...and cut the text in this position + $truncate = substr($truncate, 0, $spacepos); + } + } + // add the defined ending to the text + $truncate .= $ending; + if($considerHtml) { + // close all unclosed html-tags + foreach ($open_tags as $tag) { + $truncate .= ''; + } + } + return $truncate; + } + +} diff --git a/system/src/Grav/Common/Helpers/YamlLinter.php b/system/src/Grav/Common/Helpers/YamlLinter.php new file mode 100644 index 0000000..b7e608d --- /dev/null +++ b/system/src/Grav/Common/Helpers/YamlLinter.php @@ -0,0 +1,89 @@ +get('system.pages.theme'); + $theme_path = 'themes://' . $current_theme . '/blueprints'; + + $locator->addPath('blueprints', '', [$theme_path]); + return static::recurseFolder('blueprints://'); + } + + public static function recurseFolder($path, $extensions = 'md|yaml') + { + $lint_errors = []; + + /** @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 { + Yaml::parse(static::extractYaml($filepath)); + } catch (\Exception $e) { + $lint_errors[str_replace(GRAV_ROOT, '', $filepath)] = $e->getMessage(); + } + } + + return $lint_errors; + } + + protected static function extractYaml($path) + { + $extension = pathinfo($path, PATHINFO_EXTENSION); + if ($extension === 'md') { + $file = MarkdownFile::instance($path); + $contents = $file->frontmatter(); + } else { + $contents = file_get_contents($path); + } + return $contents; + } + +} diff --git a/system/src/Grav/Common/Inflector.php b/system/src/Grav/Common/Inflector.php new file mode 100644 index 0000000..eca93c1 --- /dev/null +++ b/system/src/Grav/Common/Inflector.php @@ -0,0 +1,330 @@ +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) ?: []; + } + } + + /** + * Pluralizes English nouns. + * + * @param string $word English noun to pluralize + * @param int $count The count + * + * @return string Plural noun + */ + public static function pluralize($word, $count = 2) + { + static::init(); + + if ((int)$count === 1) { + return $word; + } + + $lowercased_word = strtolower($word); + + 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); + } + } + + foreach (static::$plural as $rule => $replacement) { + if (preg_match($rule, $word)) { + return preg_replace($rule, $replacement, $word); + } + } + + return false; + + } + + /** + * Singularizes English nouns. + * + * @param string $word English noun to singularize + * @param int $count + * + * @return string Singular noun. + */ + public static function singularize($word, $count = 1) + { + static::init(); + + if ((int)$count !== 1) { + return $word; + } + + $lowercased_word = strtolower($word); + 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); + } + } + + foreach (static::$singular as $rule => $replacement) { + if (preg_match($rule, $word)) { + return preg_replace($rule, $replacement, $word); + } + } + + return $word; + } + + /** + * Converts an underscored or CamelCase word into a English + * sentence. + * + * The titleize public function converts text like "WelcomePage", + * "welcome_page" or "welcome page" to this "Welcome + * Page". + * If second parameter is set to 'first' it will only + * capitalize the first character of the title. + * + * @param string $word Word to format as tile + * @param string $uppercase If set to 'first' it will only uppercase the + * first character. Otherwise it will uppercase all + * the words in the title. + * + * @return string Text formatted as title + */ + public static function titleize($word, $uppercase = '') + { + $uppercase = $uppercase === 'first' ? 'ucfirst' : 'ucwords'; + + return $uppercase(static::humanize(static::underscorize($word))); + } + + /** + * Returns given word as CamelCased + * + * Converts a word like "send_email" to "SendEmail". It + * will remove non alphanumeric character from the word, so + * "who's online" will be converted to "WhoSOnline" + * + * @see variablize + * + * @param string $word Word to convert to camel case + * + * @return string UpperCamelCasedWord + */ + public static function camelize($word) + { + return str_replace(' ', '', ucwords(preg_replace('/[^A-Z^a-z^0-9]+/', ' ', $word))); + } + + /** + * Converts a word "into_it_s_underscored_version" + * + * Convert any "CamelCased" or "ordinary Word" into an + * "underscored_word". + * + * This can be really useful for creating friendly URLs. + * + * @param string $word Word to underscore + * + * @return string Underscored word + */ + public static function underscorize($word) + { + $regex1 = preg_replace('/([A-Z]+)([A-Z][a-z])/', '\1_\2', $word); + $regex2 = preg_replace('/([a-zd])([A-Z])/', '\1_\2', $regex1); + $regex3 = preg_replace('/[^A-Z^a-z^0-9]+/', '_', $regex2); + + return strtolower($regex3); + } + + /** + * Converts a word "into-it-s-hyphenated-version" + * + * Convert any "CamelCased" or "ordinary Word" into an + * "hyphenated-word". + * + * This can be really useful for creating friendly URLs. + * + * @param string $word Word to hyphenate + * + * @return string hyphenized word + */ + public static function hyphenize($word) + { + $regex1 = preg_replace('/([A-Z]+)([A-Z][a-z])/', '\1-\2', $word); + $regex2 = preg_replace('/([a-z])([A-Z])/', '\1-\2', $regex1); + $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); + } + + /** + * Returns a human-readable string from $word + * + * Returns a human-readable string from $word, by replacing + * underscores with a space, and by upper-casing the initial + * character by default. + * + * If you need to uppercase all the words you just have to + * pass 'all' as a second parameter. + * + * @param string $word String to "humanize" + * @param string $uppercase If set to 'all' it will uppercase all the words + * instead of just the first one. + * + * @return string Human-readable word + */ + public static function humanize($word, $uppercase = '') + { + $uppercase = $uppercase === 'all' ? 'ucwords' : 'ucfirst'; + + return $uppercase(str_replace('_', ' ', preg_replace('/_id$/', '', $word))); + } + + /** + * Same as camelize but first char is underscored + * + * Converts a word like "send_email" to "sendEmail". It + * will remove non alphanumeric character from the word, so + * "who's online" will be converted to "whoSOnline" + * + * @see camelize + * + * @param string $word Word to lowerCamelCase + * + * @return string Returns a lowerCamelCasedWord + */ + public static function variablize($word) + { + $word = static::camelize($word); + + return strtolower($word[0]) . substr($word, 1); + } + + /** + * Converts a class name to its table name according to rails + * naming conventions. + * + * Converts "Person" to "people" + * + * @see classify + * + * @param string $class_name Class name for getting related table_name. + * + * @return string plural_table_name + */ + public static function tableize($class_name) + { + return static::pluralize(static::underscorize($class_name)); + } + + /** + * Converts a table name to its class name according to rails + * naming conventions. + * + * Converts "people" to "Person" + * + * @see tableize + * + * @param string $table_name Table name for getting related ClassName. + * + * @return string SingularClassName + */ + public static function classify($table_name) + { + return static::camelize(static::singularize($table_name)); + } + + /** + * Converts number to its ordinal English form. + * + * This method converts 13 to 13th, 2 to 2nd ... + * + * @param int $number Number to get its ordinal value + * + * @return string Ordinal representation of given string. + */ + public static function ordinalize($number) + { + static::init(); + + if (\in_array($number % 100, range(11, 13), true)) { + return $number . static::$ordinals['default']; + } + + switch ($number % 10) { + case 1: + return $number . static::$ordinals['first']; + case 2: + return $number . static::$ordinals['second']; + case 3: + return $number . static::$ordinals['third']; + default: + return $number . static::$ordinals['default']; + } + } + + /** + * 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(); + + $duration = new \DateInterval("P{$days}D"); + + $diff = $end->add($duration)->diff($now); + + // handle years + if ($diff->y > 0) { + $diff->m += 12 * $diff->y; + } + + return $diff->m; + } +} diff --git a/system/src/Grav/Common/Iterator.php b/system/src/Grav/Common/Iterator.php new file mode 100644 index 0000000..5f6dc7e --- /dev/null +++ b/system/src/Grav/Common/Iterator.php @@ -0,0 +1,265 @@ +items[$key] ?? null; + } + + /** + * Clone the iterator. + */ + public function __clone() + { + foreach ($this as $key => $value) { + if (\is_object($value)) { + $this->{$key} = clone $this->{$key}; + } + } + } + + /** + * Convents iterator to a comma separated list. + * + * @return string + */ + public function __toString() + { + return implode(',', $this->items); + } + + /** + * Remove item from the list. + * + * @param string $key + */ + public function remove($key) + { + $this->offsetUnset($key); + } + + /** + * Return previous item. + * + * @return mixed + */ + public function prev() + { + return prev($this->items); + } + + /** + * Return nth item. + * + * @param int $key + * + * @return mixed|bool + */ + public function nth($key) + { + $items = array_keys($this->items); + + return isset($items[$key]) ? $this->offsetGet($items[$key]) : false; + } + + /** + * Get the first item + * + * @return mixed + */ + public function first() + { + $items = array_keys($this->items); + + return $this->offsetGet(array_shift($items)); + } + + /** + * Get the last item + * + * @return mixed + */ + public function last() + { + $items = array_keys($this->items); + + return $this->offsetGet(array_pop($items)); + } + + /** + * Reverse the Iterator + * + * @return $this + */ + public function reverse() + { + $this->items = array_reverse($this->items); + + return $this; + } + + /** + * @param mixed $needle Searched value. + * + * @return string|bool Key if found, otherwise false. + */ + public function indexOf($needle) + { + foreach (array_values($this->items) as $key => $value) { + if ($value === $needle) { + return $key; + } + } + + return false; + } + + /** + * Shuffle items. + * + * @return $this + */ + public function shuffle() + { + $keys = array_keys($this->items); + shuffle($keys); + + $new = []; + foreach ($keys as $key) { + $new[$key] = $this->items[$key]; + } + + $this->items = $new; + + return $this; + } + + /** + * Slice the list. + * + * @param int $offset + * @param int $length + * + * @return $this + */ + public function slice($offset, $length = null) + { + $this->items = \array_slice($this->items, $offset, $length); + + return $this; + } + + /** + * 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); + if ($num > $count) { + $num = $count; + } + + $this->items = array_intersect_key($this->items, array_flip((array)array_rand($this->items, $num))); + + return $this; + } + + /** + * 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) + { + if ($items instanceof static) { + $items = $items->toArray(); + } + $this->items = array_merge($this->items, (array)$items); + + return $this; + } + + /** + * Filter elements from the list + * + * @param callable|null $callback A function the receives ($value, $key) and must return a boolean to indicate + * filter status + * + * @return $this + */ + public function filter(callable $callback = null) + { + foreach ($this->items as $key => $value) { + if ( + (!$callback && !(bool)$value) || + ($callback && !$callback($value, $key)) + ) { + unset($this->items[$key]); + } + } + + return $this; + } + + + /** + * 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)) { + return $this; + } + + $items = $this->items; + uasort($items, $callback); + + return !$desc ? $items : array_reverse($items, true); + } +} diff --git a/system/src/Grav/Common/Language/Language.php b/system/src/Grav/Common/Language/Language.php new file mode 100644 index 0000000..a625146 --- /dev/null +++ b/system/src/Grav/Common/Language/Language.php @@ -0,0 +1,537 @@ +grav = $grav; + $this->config = $grav['config']; + $this->languages = $this->config->get('system.languages.supported', []); + $this->init(); + } + + /** + * Initialize the default and enabled languages + */ + 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); + } + + $this->page_extensions = null; + + if (empty($this->languages)) { + $this->enabled = false; + } + } + + /** + * Ensure that languages are enabled + * + * @return bool + */ + public function enabled() + { + return $this->enabled; + } + + /** + * Gets the array of supported languages + * + * @return array + */ + public function getLanguages() + { + return $this->languages; + } + + /** + * Sets the current supported languages manually + * + * @param array $langs + */ + public function setLanguages($langs) + { + $this->languages = $langs; + $this->init(); + } + + /** + * Gets a pipe-separated string of available languages + * + * @return string + */ + public function getAvailable() + { + $languagesArray = $this->languages; //Make local copy + + $languagesArray = array_map(function($value) { + return preg_quote($value); + }, $languagesArray); + + sort($languagesArray); + + return implode('|', array_reverse($languagesArray)); + } + + /** + * Gets language, active if set, else default + * + * @return string + */ + public function getLanguage() + { + return $this->active ?: $this->default; + } + + /** + * Gets current default language + * + * @return mixed + */ + public function getDefault() + { + return $this->default; + } + + /** + * Sets default language manually + * + * @param string $lang + * + * @return bool + */ + public function setDefault($lang) + { + if ($this->validate($lang)) { + $this->default = $lang; + + return $lang; + } + + return false; + } + + /** + * Gets current active language + * + * @return string + */ + public function getActive() + { + return $this->active; + } + + /** + * Sets active language manually + * + * @param string $lang + * + * @return string|bool + */ + public function setActive($lang) + { + if ($this->validate($lang)) { + $this->active = $lang; + + return $lang; + } + + return false; + } + + /** + * Sets the active language based on the first part of the URL + * + * @param string $uri + * + * @return string + */ + public function setActiveFromUri($uri) + { + $regex = '/(^\/(' . $this->getAvailable() . '))(?:\/|\?|$)/i'; + + // if languages set + if ($this->enabled()) { + // 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]; + $uri = preg_replace("/\\" . $matches[1] . '/', '', $uri, 1); + + // Store in session if language is different. + if (isset($this->grav['session']) && $this->grav['session']->isStarted() + && $this->config->get('system.languages.session_store_active', true) + && $this->grav['session']->active_language != $this->active + ) { + $this->grav['session']->active_language = $this->active; + } + } else { + // 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; + } + // 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(); + } else { + $this->active = $this->getDefault(); + } + + } + } + } + + return $uri; + } + + /** + * Get a URL prefix based on configuration + * + * @param string|null $lang + * @return string + */ + public function getLanguageURLPrefix($lang = null) + { + // if active lang is not passed in, use current active + if (!$lang) { + $lang = $this->getLanguage(); + } + + return $this->isIncludeDefaultLanguage($lang) ? '/' . $lang : ''; + } + + /** + * Test to see if language is default and language should be included in the URL + * + * @param string|null $lang + * @return bool + */ + public function isIncludeDefaultLanguage($lang = null) + { + // if active lang is not passed in, use current active + if (!$lang) { + $lang = $this->getLanguage(); + } + + return !($this->default === $lang && $this->config->get('system.languages.include_default_lang') === false); + } + + /** + * Simple getter to tell if a language was found in the URL + * + * @return bool + */ + 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 + * + * @return array + */ + public function getFallbackPageExtensions($file_ext = null) + { + if (empty($this->page_extensions)) { + if (!$file_ext) { + $file_ext = CONTENT_EXT; + } + + if ($this->enabled()) { + $valid_lang_extensions = []; + foreach ($this->languages as $lang) { + $valid_lang_extensions[] = '.' . $lang . $file_ext; + } + + if ($this->active) { + $active_extension = '.' . $this->active . $file_ext; + $key = \array_search($active_extension, $valid_lang_extensions, true); + + // 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); + } + } + $valid_lang_extensions[] = $file_ext; + $this->page_extensions = $valid_lang_extensions; + } else { + $this->page_extensions = (array)$file_ext; + } + } + + return $this->page_extensions; + } + + /** + * Resets the page_extensions value. + * + * Useful to re-initialize the pages and change site language at runtime, example: + * + * ``` + * $this->grav['language']->setActive('it'); + * $this->grav['language']->resetFallbackPageExtensions(); + * $this->grav['pages']->init(); + * ``` + */ + public function resetFallbackPageExtensions() + { + $this->page_extensions = null; + } + + /** + * Gets an array of languages with active first, then fallback languages + * + * @return array + */ + public function getFallbackLanguages() + { + 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); + } + $this->fallback_languages = $fallback_languages; + } + // always add english in case a translation doesn't exist + $this->fallback_languages[] = 'en'; + } + + return $this->fallback_languages; + } + + /** + * Ensures the language is valid and supported + * + * @param string $lang + * + * @return bool + */ + public function validate($lang) + { + return \in_array($lang, $this->languages, true); + } + + /** + * Translate a key and possibly arguments into a string using current lang and fallbacks + * + * @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 bool $array_support + * @param bool $html_out + * + * @return string + */ + public function translate($args, array $languages = null, $array_support = false, $html_out = false) + { + 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']; + } + + foreach ((array)$languages as $lang) { + $translation = $this->getTranslation($lang, $lookup, $array_support); + + if ($translation) { + if (\count($args) >= 1) { + return vsprintf($translation, $args); + } + + return $translation; + } + } + } + + if ($html_out) { + return '' . $lookup . ''; + } + + return $lookup; + } + + /** + * Translate Array + * + * @param string $key + * @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']; + } + + 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 ($html_out) { + return '' . $key . '[' . $index . ']'; + } + + return $key . '[' . $index . ']'; + } + + /** + * Lookup the translation text for a given lang and key + * + * @param string $lang lang code + * @param string $key key to lookup with + * @param bool $array_support + * + * @return string + */ + public function getTranslation($lang, $key, $array_support = false) + { + $translation = Grav::instance()['languages']->get($lang . '.' . $key, null); + if (!$array_support && is_array($translation)) { + return (string)array_shift($translation); + } + + return $translation; + } + + /** + * Get the browser accepted languages + * + * @param array $accept_langs + * @return array + * @deprecated 1.6 No longer used - using content negotiation. + */ + public function getBrowserLanguages($accept_langs = []) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, no longer used', E_USER_DEPRECATED); + + if (empty($this->http_accept_language)) { + if (empty($accept_langs) && isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { + $accept_langs = $_SERVER['HTTP_ACCEPT_LANGUAGE']; + } else { + return $accept_langs; + } + + $langs = []; + + foreach (explode(',', $accept_langs) as $k => $pref) { + // split $pref again by ';q=' + // and decorate the language entries by inverted position + if (false !== ($i = strpos($pref, ';q='))) { + $langs[substr($pref, 0, $i)] = [(float)substr($pref, $i + 3), -$k]; + } else { + $langs[$pref] = [1, -$k]; + } + } + arsort($langs); + + // no need to undecorate, because we're only interested in the keys + $this->http_accept_language = array_keys($langs); + } + return $this->http_accept_language; + } + + /** + * Accessible wrapper to LanguageCodes + * + * @param string $code + * @param string $type + * @return bool + */ + public function getLanguageCode($code, $type = 'name') + { + return LanguageCodes::get($code, $type); + } + +} diff --git a/system/src/Grav/Common/Language/LanguageCodes.php b/system/src/Grav/Common/Language/LanguageCodes.php new file mode 100644 index 0000000..780df7c --- /dev/null +++ b/system/src/Grav/Common/Language/LanguageCodes.php @@ -0,0 +1,205 @@ + [ 'name' => 'Afrikaans', 'nativeName' => 'Afrikaans' ], + 'ak' => [ 'name' => 'Akan', 'nativeName' => 'Akan' ], // unverified native name + 'ast' => [ 'name' => 'Asturian', 'nativeName' => 'Asturianu' ], + 'ar' => [ 'name' => 'Arabic', 'nativeName' => 'عربي', 'orientation' => 'rtl'], + 'as' => [ 'name' => 'Assamese', 'nativeName' => 'অসমীয়া' ], + 'be' => [ 'name' => 'Belarusian', 'nativeName' => 'Беларуская' ], + 'bg' => [ 'name' => 'Bulgarian', 'nativeName' => 'Български' ], + 'bn' => [ 'name' => 'Bengali', 'nativeName' => 'বাংলা' ], + 'bn-BD' => [ 'name' => 'Bengali (Bangladesh)', 'nativeName' => 'বাংলা (বাংলাদেশ)' ], + 'bn-IN' => [ 'name' => 'Bengali (India)', 'nativeName' => 'বাংলা (ভারত)' ], + 'br' => [ 'name' => 'Breton', 'nativeName' => 'Brezhoneg' ], + 'bs' => [ 'name' => 'Bosnian', 'nativeName' => 'Bosanski' ], + 'ca' => [ 'name' => 'Catalan', 'nativeName' => 'Català' ], + 'ca-valencia'=> [ 'name' => 'Catalan (Valencian)', 'nativeName' => 'Català (valencià)' ], // not iso-639-1. a=l10n-drivers + 'cs' => [ 'name' => 'Czech', 'nativeName' => 'Čeština' ], + 'cy' => [ 'name' => 'Welsh', 'nativeName' => 'Cymraeg' ], + 'da' => [ 'name' => 'Danish', 'nativeName' => 'Dansk' ], + 'de' => [ 'name' => 'German', 'nativeName' => 'Deutsch' ], + 'de-AT' => [ 'name' => 'German (Austria)', 'nativeName' => 'Deutsch (Österreich)' ], + 'de-CH' => [ 'name' => 'German (Switzerland)', 'nativeName' => 'Deutsch (Schweiz)' ], + 'de-DE' => [ 'name' => 'German (Germany)', 'nativeName' => 'Deutsch (Deutschland)' ], + 'dsb' => [ 'name' => 'Lower Sorbian', 'nativeName' => 'Dolnoserbšćina' ], // iso-639-2 + 'el' => [ 'name' => 'Greek', 'nativeName' => 'Ελληνικά' ], + 'en' => [ 'name' => 'English', 'nativeName' => 'English' ], + 'en-AU' => [ 'name' => 'English (Australian)', 'nativeName' => 'English (Australian)' ], + 'en-CA' => [ 'name' => 'English (Canadian)', 'nativeName' => 'English (Canadian)' ], + 'en-GB' => [ 'name' => 'English (British)', 'nativeName' => 'English (British)' ], + 'en-NZ' => [ 'name' => 'English (New Zealand)', 'nativeName' => 'English (New Zealand)' ], + 'en-US' => [ 'name' => 'English (US)', 'nativeName' => 'English (US)' ], + 'en-ZA' => [ 'name' => 'English (South African)', 'nativeName' => 'English (South African)' ], + 'eo' => [ 'name' => 'Esperanto', 'nativeName' => 'Esperanto' ], + 'es' => [ 'name' => 'Spanish', 'nativeName' => 'Español' ], + 'es-AR' => [ 'name' => 'Spanish (Argentina)', 'nativeName' => 'Español (de Argentina)' ], + 'es-CL' => [ 'name' => 'Spanish (Chile)', 'nativeName' => 'Español (de Chile)' ], + 'es-ES' => [ 'name' => 'Spanish (Spain)', 'nativeName' => 'Español (de España)' ], + 'es-MX' => [ 'name' => 'Spanish (Mexico)', 'nativeName' => 'Español (de México)' ], + 'et' => [ 'name' => 'Estonian', 'nativeName' => 'Eesti keel' ], + 'eu' => [ 'name' => 'Basque', 'nativeName' => 'Euskara' ], + 'fa' => [ 'name' => 'Persian', 'nativeName' => 'فارسی' , 'orientation' => 'rtl' ], + 'fi' => [ 'name' => 'Finnish', 'nativeName' => 'Suomi' ], + 'fj-FJ' => [ 'name' => 'Fijian', 'nativeName' => 'Vosa vaka-Viti' ], + 'fr' => [ 'name' => 'French', 'nativeName' => 'Français' ], + 'fr-CA' => [ 'name' => 'French (Canada)', 'nativeName' => 'Français (Canada)' ], + 'fr-FR' => [ 'name' => 'French (France)', 'nativeName' => 'Français (France)' ], + 'fur' => [ 'name' => 'Friulian', 'nativeName' => 'Furlan' ], + 'fur-IT' => [ 'name' => 'Friulian', 'nativeName' => 'Furlan' ], + 'fy' => [ 'name' => 'Frisian', 'nativeName' => 'Frysk' ], + 'fy-NL' => [ 'name' => 'Frisian', 'nativeName' => 'Frysk' ], + 'ga' => [ 'name' => 'Irish', 'nativeName' => 'Gaeilge' ], + 'ga-IE' => [ 'name' => 'Irish (Ireland)', 'nativeName' => 'Gaeilge (Éire)' ], + 'gd' => [ 'name' => 'Gaelic (Scotland)', 'nativeName' => 'Gàidhlig' ], + 'gl' => [ 'name' => 'Galician', 'nativeName' => 'Galego' ], + 'gu' => [ 'name' => 'Gujarati', 'nativeName' => 'ગુજરાતી' ], + 'gu-IN' => [ 'name' => 'Gujarati', 'nativeName' => 'ગુજરાતી' ], + 'he' => [ 'name' => 'Hebrew', 'nativeName' => 'עברית', 'orientation' => 'rtl' ], + 'hi' => [ 'name' => 'Hindi', 'nativeName' => 'हिन्दी' ], + 'hi-IN' => [ 'name' => 'Hindi (India)', 'nativeName' => 'हिन्दी (भारत)' ], + 'hr' => [ 'name' => 'Croatian', 'nativeName' => 'Hrvatski' ], + 'hsb' => [ 'name' => 'Upper Sorbian', 'nativeName' => 'Hornjoserbsce' ], + 'hu' => [ 'name' => 'Hungarian', 'nativeName' => 'Magyar' ], + 'hy' => [ 'name' => 'Armenian', 'nativeName' => 'Հայերեն' ], + 'hy-AM' => [ 'name' => 'Armenian', 'nativeName' => 'Հայերեն' ], + 'id' => [ 'name' => 'Indonesian', 'nativeName' => 'Bahasa Indonesia' ], + 'is' => [ 'name' => 'Icelandic', 'nativeName' => 'íslenska' ], + 'it' => [ 'name' => 'Italian', 'nativeName' => 'Italiano' ], + 'ja' => [ 'name' => 'Japanese', 'nativeName' => '日本語' ], + 'ja-JP' => [ 'name' => 'Japanese', 'nativeName' => '日本語' ], // not iso-639-1 + 'ka' => [ 'name' => 'Georgian', 'nativeName' => 'ქართული' ], + 'kk' => [ 'name' => 'Kazakh', 'nativeName' => 'Қазақ' ], + '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' ], + 'lv' => [ 'name' => 'Latvian', 'nativeName' => 'Latviešu' ], + 'mai' => [ 'name' => 'Maithili', 'nativeName' => 'मैथिली মৈথিলী' ], + 'mg' => [ 'name' => 'Malagasy', 'nativeName' => 'Malagasy' ], + 'mi' => [ 'name' => 'Maori (Aotearoa)', 'nativeName' => 'Māori (Aotearoa)' ], + 'mk' => [ 'name' => 'Macedonian', 'nativeName' => 'Македонски' ], + 'ml' => [ 'name' => 'Malayalam', 'nativeName' => 'മലയാളം' ], + 'mn' => [ 'name' => 'Mongolian', 'nativeName' => 'Монгол' ], + 'mr' => [ 'name' => 'Marathi', 'nativeName' => 'मराठी' ], + 'no' => [ 'name' => 'Norwegian', 'nativeName' => 'Norsk' ], + 'nb' => [ 'name' => 'Norwegian', 'nativeName' => 'Norsk' ], + 'nb-NO' => [ 'name' => 'Norwegian (Bokmål)', 'nativeName' => 'Norsk bokmål' ], + 'ne-NP' => [ 'name' => 'Nepali', 'nativeName' => 'नेपाली' ], + 'nn-NO' => [ 'name' => 'Norwegian (Nynorsk)', 'nativeName' => 'Norsk nynorsk' ], + 'nl' => [ 'name' => 'Dutch', 'nativeName' => 'Nederlands' ], + 'nr' => [ 'name' => 'Ndebele, South', 'nativeName' => 'IsiNdebele' ], + 'nso' => [ 'name' => 'Northern Sotho', 'nativeName' => 'Sepedi' ], + 'oc' => [ 'name' => 'Occitan (Lengadocian)', 'nativeName' => 'Occitan (lengadocian)' ], + 'or' => [ 'name' => 'Oriya', 'nativeName' => 'ଓଡ଼ିଆ' ], + 'pa' => [ 'name' => 'Punjabi', 'nativeName' => 'ਪੰਜਾਬੀ' ], + 'pa-IN' => [ 'name' => 'Punjabi', 'nativeName' => 'ਪੰਜਾਬੀ' ], + 'pl' => [ 'name' => 'Polish', 'nativeName' => 'Polski' ], + 'pt' => [ 'name' => 'Portuguese', 'nativeName' => 'Português' ], + 'pt-BR' => [ 'name' => 'Portuguese (Brazilian)', 'nativeName' => 'Português (do Brasil)' ], + 'pt-PT' => [ 'name' => 'Portuguese (Portugal)', 'nativeName' => 'Português (Europeu)' ], + 'ro' => [ 'name' => 'Romanian', 'nativeName' => 'Română' ], + 'rm' => [ 'name' => 'Romansh', 'nativeName' => 'Rumantsch' ], + 'ru' => [ 'name' => 'Russian', 'nativeName' => 'Русский' ], + 'rw' => [ 'name' => 'Kinyarwanda', 'nativeName' => 'Ikinyarwanda' ], + 'si' => [ 'name' => 'Sinhala', 'nativeName' => 'සිංහල' ], + 'sk' => [ 'name' => 'Slovak', 'nativeName' => 'Slovenčina' ], + 'sl' => [ 'name' => 'Slovenian', 'nativeName' => 'Slovensko' ], + 'son' => [ 'name' => 'Songhai', 'nativeName' => 'Soŋay' ], + 'sq' => [ 'name' => 'Albanian', 'nativeName' => 'Shqip' ], + 'sr' => [ 'name' => 'Serbian', 'nativeName' => 'Српски' ], + 'sr-Latn' => [ 'name' => 'Serbian', 'nativeName' => 'Srpski' ], // follows RFC 4646 + 'ss' => [ 'name' => 'Siswati', 'nativeName' => 'siSwati' ], + 'st' => [ 'name' => 'Southern Sotho', 'nativeName' => 'Sesotho' ], + 'sv' => [ 'name' => 'Swedish', 'nativeName' => 'Svenska' ], + 'sv-SE' => [ 'name' => 'Swedish', 'nativeName' => 'Svenska' ], + 'ta' => [ 'name' => 'Tamil', 'nativeName' => 'தமிழ்' ], + 'ta-IN' => [ 'name' => 'Tamil (India)', 'nativeName' => 'தமிழ் (இந்தியா)' ], + 'ta-LK' => [ 'name' => 'Tamil (Sri Lanka)', 'nativeName' => 'தமிழ் (இலங்கை)' ], + 'te' => [ 'name' => 'Telugu', 'nativeName' => 'తెలుగు' ], + 'th' => [ 'name' => 'Thai', 'nativeName' => 'ไทย' ], + 'tlh' => [ 'name' => 'Klingon', 'nativeName' => 'Klingon' ], + 'tn' => [ 'name' => 'Tswana', 'nativeName' => 'Setswana' ], + 'tr' => [ 'name' => 'Turkish', 'nativeName' => 'Türkçe' ], + 'ts' => [ 'name' => 'Tsonga', 'nativeName' => 'Xitsonga' ], + 'tt' => [ 'name' => 'Tatar', 'nativeName' => 'Tatarça' ], + 'tt-RU' => [ 'name' => 'Tatar', 'nativeName' => 'Tatarça' ], + 'uk' => [ 'name' => 'Ukrainian', 'nativeName' => 'Українська' ], + 'ur' => [ 'name' => 'Urdu', 'nativeName' => 'اُردو', 'orientation' => 'rtl' ], + 've' => [ 'name' => 'Venda', 'nativeName' => 'Tshivenḓa' ], + 'vi' => [ 'name' => 'Vietnamese', 'nativeName' => 'Tiếng Việt' ], + 'wo' => [ 'name' => 'Wolof', 'nativeName' => 'Wolof' ], + 'xh' => [ 'name' => 'Xhosa', 'nativeName' => 'isiXhosa' ], + 'zh' => [ 'name' => 'Chinese (Simplified)', 'nativeName' => '中文 (简体)' ], + 'zh-CN' => [ 'name' => 'Chinese (Simplified)', 'nativeName' => '中文 (简体)' ], + 'zh-TW' => [ 'name' => 'Chinese (Traditional)', 'nativeName' => '正體中文 (繁體)' ], + 'zu' => [ 'name' => 'Zulu', 'nativeName' => 'isiZulu' ] + ]; + + public static function getName($code) + { + return static::get($code, 'name'); + } + + public static function getNativeName($code) + { + if (isset(static::$codes[$code])) { + return static::get($code, 'nativeName'); + } + + if (preg_match('/[a-zA-Z]{2}-[a-zA-Z]{2}/', $code)) { + return static::get(substr($code, 0, 2), 'nativeName') . ' (' . substr($code, -2) . ')'; + } + + return $code; + } + + public static function getOrientation($code) + { + if (isset(static::$codes[$code])) { + if (isset(static::$codes[$code]['orientation'])) { + return static::get($code, 'orientation'); + } + } + return 'ltr'; + } + + public static function isRtl($code) + { + return static::getOrientation($code) === 'rtl'; + } + + public static function getNames(array $keys) + { + $results = []; + foreach ($keys as $key) { + if (isset(static::$codes[$key])) { + $results[$key] = static::$codes[$key]; + } + } + return $results; + } + + public static function get($code, $type) + { + if (isset(static::$codes[$code][$type])) { + return static::$codes[$code][$type]; + } + + return false; + } +} diff --git a/system/src/Grav/Common/Markdown/Parsedown.php b/system/src/Grav/Common/Markdown/Parsedown.php new file mode 100644 index 0000000..8b8565f --- /dev/null +++ b/system/src/Grav/Common/Markdown/Parsedown.php @@ -0,0 +1,40 @@ + $defaults]; + } + $excerpts = new Excerpts($excerpts, $defaults); + user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use new ' . __CLASS__ . '(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED); + } + + $this->init($excerpts, $defaults); + } + +} diff --git a/system/src/Grav/Common/Markdown/ParsedownExtra.php b/system/src/Grav/Common/Markdown/ParsedownExtra.php new file mode 100644 index 0000000..70c8596 --- /dev/null +++ b/system/src/Grav/Common/Markdown/ParsedownExtra.php @@ -0,0 +1,42 @@ + $defaults]; + } + $excerpts = new Excerpts($excerpts, $defaults); + user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use new ' . __CLASS__ . '(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED); + } + + parent::__construct(); + + $this->init($excerpts, $defaults); + } +} diff --git a/system/src/Grav/Common/Markdown/ParsedownGravTrait.php b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php new file mode 100644 index 0000000..aa76cdf --- /dev/null +++ b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php @@ -0,0 +1,277 @@ + $defaults]; + } + $this->excerpts = new Excerpts($excerpts, $defaults); + user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use ->init(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED); + } else { + $this->excerpts = $excerpts; + } + + $this->BlockTypes['{'][] = 'TwigTag'; + $this->special_chars = ['>' => 'gt', '<' => 'lt', '"' => 'quot']; + + $defaults = $this->excerpts->getConfig(); + + if (isset($defaults['markdown']['auto_line_breaks'])) { + $this->setBreaksEnabled($defaults['markdown']['auto_line_breaks']); + } + if (isset($defaults['markdown']['auto_url_links'])) { + $this->setUrlsLinked($defaults['markdown']['auto_url_links']); + } + if (isset($defaults['markdown']['escape_markup'])) { + $this->setMarkupEscaped($defaults['markdown']['escape_markup']); + } + if (isset($defaults['markdown']['special_chars'])) { + $this->setSpecialChars($defaults['markdown']['special_chars']); + } + + $this->excerpts->fireInitializedEvent($this); + } + + /** + * @return Excerpts + */ + public function getExcerpts() + { + return $this->excerpts; + } + + /** + * Be able to define a new Block type or override an existing one + * + * @param string $type + * @param string $tag + * @param bool $continuable + * @param bool $completable + * @param int|null $index + */ + public function addBlockType($type, $tag, $continuable = false, $completable = false, $index = null) + { + $block = &$this->unmarkedBlockTypes; + if ($type) { + if (!isset($this->BlockTypes[$type])) { + $this->BlockTypes[$type] = []; + } + $block = &$this->BlockTypes[$type]; + } + + if (null === $index) { + $block[] = $tag; + } else { + array_splice($block, $index, 0, [$tag]); + } + + if ($continuable) { + $this->continuable_blocks[] = $tag; + } + if ($completable) { + $this->completable_blocks[] = $tag; + } + } + + /** + * Be able to define a new Inline type or override an existing one + * + * @param string $type + * @param string $tag + * @param int|null $index + */ + public function addInlineType($type, $tag, $index = null) + { + if (null === $index || !isset($this->InlineTypes[$type])) { + $this->InlineTypes[$type] [] = $tag; + } else { + array_splice($this->InlineTypes[$type], $index, 0, [$tag]); + } + + if (strpos($this->inlineMarkerList, $type) === false) { + $this->inlineMarkerList .= $type; + } + } + + /** + * 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) + || method_exists($this, 'block' . $Type . 'Continue'); + + return $continuable; + } + + /** + * 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) + || method_exists($this, 'block' . $Type . 'Complete'); + + return $completable; + } + + + /** + * Make the element function publicly accessible, Medium uses this to render from Twig + * + * @param array $Element + * + * @return string markup + */ + public function elementToHtml(array $Element) + { + return $this->element($Element); + } + + /** + * Setter for special chars + * + * @param array $special_chars + * + * @return $this + */ + public function setSpecialChars($special_chars) + { + $this->special_chars = $special_chars; + + return $this; + } + + /** + * Ensure Twig tags are treated as block level items with no

tags + * + * @param array $line + * @return array|null + */ + protected function blockTwigTag($line) + { + if (preg_match('/(?:{{|{%|{#)(.*)(?:}}|%}|#})/', $line['body'], $matches)) { + return ['markup' => $line['body']]; + } + + return null; + } + + protected function inlineSpecialCharacter($excerpt) + { + if ($excerpt['text'][0] === '&' && !preg_match('/^&#?\w+;/', $excerpt['text'])) { + return [ + 'markup' => '&', + 'extent' => 1, + ]; + } + + if (isset($this->special_chars[$excerpt['text'][0]])) { + return [ + 'markup' => '&' . $this->special_chars[$excerpt['text'][0]] . ';', + 'extent' => 1, + ]; + } + + return null; + } + + protected function inlineImage($excerpt) + { + if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) { + $excerpt['text'] = str_replace($matches[1], '/', $excerpt['text']); + $excerpt = parent::inlineImage($excerpt); + $excerpt['element']['attributes']['src'] = $matches[1]; + $excerpt['extent'] = $excerpt['extent'] + strlen($matches[1]) - 1; + + return $excerpt; + } + + $excerpt['type'] = 'image'; + $excerpt = parent::inlineImage($excerpt); + + // if this is an image process it + if (isset($excerpt['element']['attributes']['src'])) { + $excerpt = $this->excerpts->processImageExcerpt($excerpt); + } + + return $excerpt; + } + + protected function inlineLink($excerpt) + { + $type = $excerpt['type'] ?? 'link'; + + // do some trickery to get around Parsedown requirement for valid URL if its Twig in there + if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) { + $excerpt['text'] = str_replace($matches[1], '/', $excerpt['text']); + $excerpt = parent::inlineLink($excerpt); + $excerpt['element']['attributes']['href'] = $matches[1]; + $excerpt['extent'] = $excerpt['extent'] + strlen($matches[1]) - 1; + + return $excerpt; + } + + $excerpt = parent::inlineLink($excerpt); + + // if this is a link + if (isset($excerpt['element']['attributes']['href'])) { + $excerpt = $this->excerpts->processLinkExcerpt($excerpt, $type); + } + + return $excerpt; + } + + /** + * For extending this class via plugins + */ + public function __call($method, $args) + { + if (isset($this->{$method}) === true) { + $func = $this->{$method}; + + return \call_user_func_array($func, $args); + } + + return null; + } +} diff --git a/system/src/Grav/Common/Media/Interfaces/MediaCollectionInterface.php b/system/src/Grav/Common/Media/Interfaces/MediaCollectionInterface.php new file mode 100644 index 0000000..9f91663 --- /dev/null +++ b/system/src/Grav/Common/Media/Interfaces/MediaCollectionInterface.php @@ -0,0 +1,23 @@ +getMediaFolder(); + + 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); + } + + return null; + } + + /** + * Gets the associated media collection. + * + * @return MediaCollectionInterface Representation of associated media. + */ + public function getMedia() + { + if ($this->media === null) { + $cache = $this->getMediaCache(); + + // Use cached media if possible. + $cacheKey = md5('media' . $this->getCacheKey()); + if (!$media = $cache->get($cacheKey)) { + $media = new Media($this->getMediaFolder(), $this->getMediaOrder()); + $cache->set($cacheKey, $media); + } + $this->media = $media; + } + + return $this->media; + } + + /** + * Sets the associated media collection. + * + * @param MediaCollectionInterface $media Representation of associated media. + * @return $this + */ + protected function setMedia(MediaCollectionInterface $media) + { + $cache = $this->getMediaCache(); + $cacheKey = md5('media' . $this->getCacheKey()); + $cache->set($cacheKey, $media); + + $this->media = $media; + + return $this; + } + + /** + * Clear media cache. + */ + protected function clearMediaCache() + { + $cache = $this->getMediaCache(); + $cacheKey = md5('media' . $this->getCacheKey()); + $cache->delete($cacheKey); + + $this->media = null; + } + + /** + * @return CacheInterface + */ + protected function getMediaCache() + { + /** @var Cache $cache */ + $cache = Grav::instance()['cache']; + + return $cache->getSimpleCache(); + } + + /** + * @return string + */ + abstract protected function getCacheKey(): string; +} diff --git a/system/src/Grav/Common/Page/Collection.php b/system/src/Grav/Common/Page/Collection.php new file mode 100644 index 0000000..51de16b --- /dev/null +++ b/system/src/Grav/Common/Page/Collection.php @@ -0,0 +1,630 @@ +params = $params; + $this->pages = $pages ? $pages : Grav::instance()->offsetGet('pages'); + } + + /** + * Get the collection params + * + * @return array + */ + public function params() + { + return $this->params; + } + + /** + * Add a single page to a collection + * + * @param PageInterface $page + * + * @return $this + */ + public function addPage(PageInterface $page) + { + $this->items[$page->path()] = ['slug' => $page->slug()]; + + return $this; + } + + /** + * Add a page with path and slug + * + * @param string $path + * @param string $slug + * @return $this + */ + public function add($path, $slug) + { + $this->items[$path] = ['slug' => $slug]; + + return $this; + } + + /** + * + * Create a copy of this collection + * + * @return static + */ + public function copy() + { + return new static($this->items, $this->params, $this->pages); + } + + /** + * + * Merge another collection with the current collection + * + * @param Collection $collection + * @return $this + */ + public function merge(Collection $collection) + { + foreach($collection as $page) { + $this->addPage($page); + } + + return $this; + } + + /** + * Intersect another collection with the current collection + * + * @param Collection $collection + * @return $this + */ + public function intersect(Collection $collection) + { + $array1 = $this->items; + $array2 = $collection->toArray(); + + $this->items = array_uintersect($array1, $array2, function($val1, $val2) { + return strcmp($val1['slug'], $val2['slug']); + }); + + return $this; + } + + /** + * Set parameters to the Collection + * + * @param array $params + * + * @return $this + */ + public function setParams(array $params) + { + $this->params = array_merge($this->params, $params); + + return $this; + } + + /** + * Returns current page. + * + * @return PageInterface + */ + public function current() + { + $current = parent::key(); + + return $this->pages->get($current); + } + + /** + * Returns current slug. + * + * @return mixed + */ + public function key() + { + $current = parent::current(); + + return $current['slug']; + } + + /** + * Returns the value at specified offset. + * + * @param mixed $offset The offset to retrieve. + * + * @return mixed Can return all value types. + */ + public function offsetGet($offset) + { + return $this->pages->get($offset) ?: null; + } + + /** + * Split collection into array of smaller collections. + * + * @param int $size + * @return array|Collection[] + */ + public function batch($size) + { + $chunks = array_chunk($this->items, $size, true); + + $list = []; + foreach ($chunks as $chunk) { + $list[] = new static($chunk, $this->params, $this->pages); + } + + return $list; + } + + /** + * Remove item from the list. + * + * @param PageInterface|string|null $key + * + * @return $this + * @throws \InvalidArgumentException + */ + public function remove($key = null) + { + if ($key instanceof PageInterface) { + $key = $key->path(); + } elseif (null === $key) { + $key = (string)key($this->items); + } + if (!\is_string($key)) { + throw new \InvalidArgumentException('Invalid argument $key.'); + } + + parent::remove($key); + + return $this; + } + + /** + * Reorder collection. + * + * @param string $by + * @param string $dir + * @param array $manual + * @param string $sort_flags + * + * @return $this + */ + public function order($by, $dir = 'asc', $manual = null, $sort_flags = null) + { + $this->items = $this->pages->sortCollection($this, $by, $dir, $manual, $sort_flags); + + return $this; + } + + /** + * 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) + { + return $this->items && $path === array_keys($this->items)[0]; + } + + /** + * 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) + { + return $this->items && $path === array_keys($this->items)[\count($this->items) - 1]; + } + + /** + * Gets the previous sibling based on current position. + * + * @param string $path + * + * @return PageInterface The previous item. + */ + public function prevSibling($path) + { + return $this->adjacentSibling($path, -1); + } + + /** + * Gets the next sibling based on current position. + * + * @param string $path + * + * @return PageInterface The next item. + */ + public function nextSibling($path) + { + return $this->adjacentSibling($path, 1); + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param string $path + * @param int $direction either -1 or +1 + * + * @return PageInterface|Collection The sibling item. + */ + public function adjacentSibling($path, $direction = 1) + { + $values = array_keys($this->items); + $keys = array_flip($values); + + if (array_key_exists($path, $keys)) { + $index = $keys[$path] - $direction; + + return isset($values[$index]) ? $this->offsetGet($values[$index]) : $this; + } + + return $this; + + } + + /** + * Returns the item in the current position. + * + * @param string $path the path the item + * + * @return int the index of the current page. + */ + public function currentPosition($path) + { + return \array_search($path, \array_keys($this->items), true); + } + + /** + * 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 + * http://php.net/manual/en/function.strtotime.php + * + * @param string $startDate + * @param bool $endDate + * @param string|null $field + * + * @return $this + * @throws \Exception + */ + public function dateRange($startDate, $endDate = false, $field = null) + { + $start = Utils::date2timestamp($startDate); + $end = $endDate ? Utils::date2timestamp($endDate) : false; + + $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 ($date >= $start && (!$end || $date <= $end)) { + $date_range[$path] = $slug; + } + } + } + + $this->items = $date_range; + + return $this; + } + + /** + * Creates new collection with only visible pages + * + * @return Collection The collection with only visible pages + */ + public function visible() + { + $visible = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && $page->visible()) { + $visible[$path] = $slug; + } + } + $this->items = $visible; + + return $this; + } + + /** + * Creates new collection with only non-visible pages + * + * @return Collection The collection with only non-visible pages + */ + public function nonVisible() + { + $visible = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && !$page->visible()) { + $visible[$path] = $slug; + } + } + $this->items = $visible; + + return $this; + } + + /** + * Creates new collection with only modular pages + * + * @return Collection The collection with only modular pages + */ + public function modular() + { + $modular = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && $page->modular()) { + $modular[$path] = $slug; + } + } + $this->items = $modular; + + return $this; + } + + /** + * Creates new collection with only non-modular pages + * + * @return Collection The collection with only non-modular pages + */ + public function nonModular() + { + $modular = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && !$page->modular()) { + $modular[$path] = $slug; + } + } + $this->items = $modular; + + return $this; + } + + /** + * Creates new collection with only published pages + * + * @return Collection The collection with only published pages + */ + public function published() + { + $published = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && $page->published()) { + $published[$path] = $slug; + } + } + $this->items = $published; + + return $this; + } + + /** + * Creates new collection with only non-published pages + * + * @return Collection The collection with only non-published pages + */ + public function nonPublished() + { + $published = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && !$page->published()) { + $published[$path] = $slug; + } + } + $this->items = $published; + + return $this; + } + + /** + * Creates new collection with only routable pages + * + * @return Collection The collection with only routable pages + */ + public function routable() + { + $routable = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + + if ($page !== null && $page->routable()) { + $routable[$path] = $slug; + } + } + + $this->items = $routable; + + return $this; + } + + /** + * Creates new collection with only non-routable pages + * + * @return Collection The collection with only non-routable pages + */ + public function nonRoutable() + { + $routable = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && !$page->routable()) { + $routable[$path] = $slug; + } + } + $this->items = $routable; + + return $this; + } + + /** + * Creates new collection with only pages of the specified type + * + * @param string $type + * + * @return Collection The collection + */ + public function ofType($type) + { + $items = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && $page->template() === $type) { + $items[$path] = $slug; + } + } + + $this->items = $items; + + return $this; + } + + /** + * Creates new collection with only pages of one of the specified types + * + * @param string[] $types + * + * @return Collection The collection + */ + public function ofOneOfTheseTypes($types) + { + $items = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && \in_array($page->template(), $types, true)) { + $items[$path] = $slug; + } + } + + $this->items = $items; + + return $this; + } + + /** + * Creates new collection with only pages of one of the specified access levels + * + * @param array $accessLevels + * + * @return Collection The collection + */ + public function ofOneOfTheseAccessLevels($accessLevels) + { + $items = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + + if ($page !== null && isset($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)) { + foreach ($accessLevel as $innerIndex => $innerAccessLevel) { + if (\in_array($innerAccessLevel, $accessLevels)) { + $valid = true; + } + } + } else { + if (\in_array($index, $accessLevels)) { + $valid = true; + } + } + } + if ($valid) { + $items[$path] = $slug; + } + } else { + //Single value for access + if (\in_array($page->header()->access, $accessLevels)) { + $items[$path] = $slug; + } + } + + } + } + + $this->items = $items; + + return $this; + } + + /** + * Get the extended version of this Collection with each page keyed by route + * + * @return array + * @throws \Exception + */ + public function toExtendedArray() + { + $items = []; + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + + if ($page !== null) { + $items[$page->route()] = $page->toArray(); + } + } + return $items; + } +} diff --git a/system/src/Grav/Common/Page/Header.php b/system/src/Grav/Common/Page/Header.php new file mode 100644 index 0000000..e3854ae --- /dev/null +++ b/system/src/Grav/Common/Page/Header.php @@ -0,0 +1,18 @@ +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 + */ + 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 + */ + public function slug($var = null); + + /** + * Get/set order number of this page. + * + * @param int $var + * + * @return int|bool + */ + public function order($var = null); + + /** + * Gets and sets the identifier for this Page object. + * + * @param string $var the 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 + */ + 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 + */ + public function lastModified($var = null); + + /** + * Get/set the folder. + * + * @param string $var Optional path + * + * @return string|null + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + public function shouldProcess($process); + + /** + * Returns whether or not this Page object has a .md file associated with it or if its just a directory. + * + * @return bool True if its a page with a .md file associated + */ + public function isPage(); + + /** + * Returns whether or not this Page object is a directory or a page. + * + * @return bool True if its a directory + */ + public function isDir(); + + /** + * Returns whether the page exists in the filesystem. + * + * @return bool + */ + public function exists(); +} diff --git a/system/src/Grav/Common/Page/Interfaces/PageInterface.php b/system/src/Grav/Common/Page/Interfaces/PageInterface.php new file mode 100644 index 0000000..24bf50c --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageInterface.php @@ -0,0 +1,19 @@ +save() in order to perform the move. + * + * @param PageInterface $parent New parent page. + * + * @return $this + */ + public function move(PageInterface $parent); + + /** + * Prepare a copy from the page. Copies also everything that's under the current page. + * + * Returns a new Page object for the copy. + * 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); + + /** + * Get blueprints for the page. + * + * @return Blueprint + */ + public function blueprints(); + + /** + * Get the blueprint name for this page. Use the blueprint form field if set + * + * @return string + */ + public function blueprintName(); + + /** + * Validate page header. + * + * @throws Exception + */ + public function validate(); + + /** + * Filter page header from illegal contents. + */ + public function filter(); + + /** + * Get unknown header variables. + * + * @return array + */ + public function extra(); + + /** + * Convert page to an array. + * + * @return array + */ + public function toArray(); + + /** + * Convert page to YAML encoded string. + * + * @return string + */ + public function toYaml(); + + /** + * Convert page to JSON encoded string. + * + * @return string + */ + public function toJson(); + + /** + * Returns normalized list of name => form pairs. + * + * @return array + */ + public function forms(); + + /** + * @param array $new + */ + 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. + * + * @return string The name of this page. + */ + public function name($var = null); + + /** + * Returns child page type. + * + * @return string + */ + 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 + * + * @return string the template name + */ + 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 + */ + public function templateFormat($var = null); + + /** + * Gets and sets the extension field. + * + * @param null $var + * + * @return null|string + */ + 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. + * + * @return int The expires value + */ + 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 + */ + public function cacheControl($var = null); + + public function ssl($var = null); + + /** + * Returns the state of the debugger override etting for this page + * + * @return mixed + */ + 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 + * + * @return array an Array of metadata values for the page + */ + public function metadata($var = null); + + /** + * Gets and sets the option to show the etag header for the page. + * + * @param bool $var show etag header + * + * @return bool show etag header + */ + public function eTag($var = null); + + /** + * Gets and sets the path to the .md file for this Page object. + * + * @param string $var the file path + * + * @return string|null the file path + */ + public function filePath($var = null); + + /** + * Gets the relative path to the .md file + * + * @return string The relative file path + */ + 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" + * + * @return string the order, either "asc" or "desc" + * @deprecated 1.6 + */ + public function orderDir($var = null); + + /** + * Gets and sets the order by which the sub-pages should be sorted. + * + * default - is the order based on the file system, ie 01.Home before 02.Advark + * title - is the order based on the title set in the pages + * 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" + * + * @return string supported options include "default", "title", "date", and "folder" + * @deprecated 1.6 + */ + public function orderBy($var = null); + + /** + * Gets the manual order set in the header. + * + * @param string $var supported options include "default", "title", "date", and "folder" + * + * @return array + * @deprecated 1.6 + */ + 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 + * + * @return int the maximum number of sub-pages + * @deprecated 1.6 + */ + 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 + * + * @return bool true if modular_twig + */ + 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 + * + * @return bool true if modular_twig + */ + public function modularTwig($var = null); + + /** + * Returns children of this page. + * + * @return \Grav\Common\Page\Collection + */ + public function children(); + + /** + * 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(); + + /** + * 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(); + + /** + * Gets the previous sibling based on current position. + * + * @return PageInterface the previous Page item + */ + public function prevSibling(); + + /** + * Gets the next sibling based on current position. + * + * @return PageInterface the next Page item + */ + public function nextSibling(); + + /** + * Returns the adjacent sibling based on a direction. + * + * @param int $direction either -1 or +1 + * + * @return PageInterface|bool the sibling page + */ + public function adjacentSibling($direction = 1); + + /** + * Helper method to return an ancestor page. + * + * @param bool $lookup Name of the parent folder + * + * @return PageInterface page you were looking for if it exists + */ + public function ancestor($lookup = null); + + /** + * Helper method to return an ancestor page to inherit from. The current + * page object is returned. + * + * @param string $field Name of the parent folder + * + * @return PageInterface + */ + public function inherited($field); + + /** + * Helper method to return an ancestor field only to inherit from. The + * 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); + + /** + * Helper method to return a page. + * + * @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); + + /** + * Get a collection of pages in the current context. + * + * @param string|array $params + * @param bool $pagination + * + * @return Collection + * @throws \InvalidArgumentException + */ + public function collection($params = 'content', $pagination = true); + + /** + * @param string|array $value + * @param bool $only_published + * @return mixed + * @internal + */ + public function evaluate($value, $only_published = true); + + /** + * Returns whether or not the current folder exists + * + * @return bool + */ + public function folderExists(); + + /** + * Gets the Page Unmodified (original) version of the page. + * + * @return PageInterface The original version of the page. + */ + public function getOriginal(); + + /** + * Gets the action. + * + * @return string The Action string. + */ + public function getAction(); +} diff --git a/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php b/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php new file mode 100644 index 0000000..c3afeab --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php @@ -0,0 +1,188 @@ +page = $page ?? Grav::instance()['page'] ?? null; + + // Add defaults to the configuration. + if (null === $config || !isset($config['markdown'], $config['images'])) { + $c = Grav::instance()['config']; + $config = $config ?? []; + $config += [ + 'markdown' => $c->get('system.pages.markdown', []), + 'images' => $c->get('system.images', []) + ]; + } + + $this->config = $config; + } + + public function getPage(): PageInterface + { + return $this->page; + } + + public function getConfig(): array + { + return $this->config; + } + + public function fireInitializedEvent($markdown): void + { + $grav = Grav::instance(); + + $grav->fireEvent('onMarkdownInitialized', new Event(['markdown' => $markdown, 'page' => $this->page])); + } + + /** + * Process a Link excerpt + * + * @param array $excerpt + * @param string $type + * @return array + */ + public function processLinkExcerpt(array $excerpt, string $type = 'link'): array + { + $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. + if (isset($url_parts['query'])) { + $actions = array_reduce( + explode('&', $url_parts['query']), + static function ($carry, $item) { + $parts = explode('=', $item, 2); + $value = isset($parts[1]) ? rawurldecode($parts[1]) : true; + $carry[$parts[0]] = $value; + + return $carry; + }, + [] + ); + + // Valid attributes supported. + $valid_attributes = Grav::instance()['config']->get('system.pages.markdown.valid_link_attributes'); + + // Unless told to not process, go through actions. + if (array_key_exists('noprocess', $actions)) { + unset($actions['noprocess']); + } else { + // Loop through actions for the image and call them. + foreach ($actions as $attrib => $value) { + $key = $attrib; + + if (in_array($attrib, $valid_attributes, true)) { + // support both class and classes. + if ($attrib === 'classes') { + $attrib = 'class'; + } + $excerpt['element']['attributes'][$attrib] = str_replace(',', ' ', $value); + unset($actions[$key]); + } + } + } + + $url_parts['query'] = http_build_query($actions, null, '&', PHP_QUERY_RFC3986); + } + + // If no query elements left, unset query. + if (empty($url_parts['query'])) { + unset ($url_parts['query']); + } + + // Set path to / if not set. + if (empty($url_parts['path'])) { + $url_parts['path'] = ''; + } + + // 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']}"); + unset($url_parts['stream'], $url_parts['scheme']); + } + + $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts); + + return $excerpt; + } + + // Handle paths and such. + $url_parts = Uri::convertUrl($this->page, $url_parts, $type); + + // Build the URL from the component parts and set it on the element. + $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts); + + return $excerpt; + } + + /** + * Process an image excerpt + * + * @param array $excerpt + * @return array + */ + public function processImageExcerpt(array $excerpt): array + { + $url = htmlspecialchars_decode(urldecode($excerpt['element']['attributes']['src'])); + $url_parts = $this->parseUrl($url); + + $media = null; + $filename = null; + + if (!empty($url_parts['stream'])) { + $filename = $url_parts['scheme'] . '://' . ($url_parts['path'] ?? ''); + + $media = $this->page->getMedia(); + + } else { + $grav = Grav::instance(); + + // File is also local if scheme is http(s) and host matches. + $local_file = isset($url_parts['path']) + && (empty($url_parts['scheme']) || in_array($url_parts['scheme'], ['http', 'https'], true)) + && (empty($url_parts['host']) || $url_parts['host'] === $grav['uri']->host()); + + if ($local_file) { + $filename = basename($url_parts['path']); + $folder = dirname($url_parts['path']); + + // Get the local path to page media if possible. + if ($this->page && $folder === $this->page->url(false, false, false)) { + // Get the media objects for this page. + $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(), '/'); + $page_route = '/' . ltrim(str_replace($base_url, '', $folder), '/'); + + /** @var PageInterface $ext_page */ + $ext_page = $grav['pages']->dispatch($page_route, true); + if ($ext_page) { + $media = $ext_page->getMedia(); + } else { + $grav->fireEvent('onMediaLocate', new Event(['route' => $page_route, 'media' => &$media])); + } + } + } + } + + // If there is a media file that matches the path referenced.. + if ($media && $filename && isset($media[$filename])) { + // Get the medium object. + /** @var Medium $medium */ + $medium = $media[$filename]; + + // Process operations + $medium = $this->processMediaActions($medium, $url_parts); + $element_excerpt = $excerpt['element']['attributes']; + + $alt = $element_excerpt['alt'] ?? ''; + $title = $element_excerpt['title'] ?? ''; + $class = $element_excerpt['class'] ?? ''; + $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); + } + + return $excerpt; + } + + /** + * Process media actions + * + * @param Medium $medium + * @param string|array $url + * @return Medium|Link + */ + 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( + explode('&', $url_parts['query']), + static function ($carry, $item) { + $parts = explode('=', $item, 2); + $value = $parts[1] ?? null; + $carry[] = ['method' => $parts[0], 'params' => $value]; + + return $carry; + }, + [] + ); + } + + $config = $this->getConfig(); + if (!empty($config['images']['auto_fix_orientation'])) { + $actions[] = ['method' => 'fixOrientation', 'params' => '']; + } + + $defaults = $config['images']['defaults'] ?? []; + if (count($defaults)) { + foreach ($defaults as $method => $params) { + $actions[] = [ + 'method' => $method, + 'params' => $params, + ]; + } + } + + // loop through actions for the image and call them + foreach ($actions as $action) { + $matches = []; + + if (preg_match('/\[(.*)\]/', $action['params'], $matches)) { + $args = [explode(',', $matches[1])]; + } else { + $args = explode(',', $action['params']); + } + + $medium = call_user_func_array([$medium, $action['method']], $args); + } + + if (isset($url_parts['fragment'])) { + $medium->urlHash($url_parts['fragment']); + } + + return $medium; + } + + /** + * Variation of parse_url() which works also with local streams. + * + * @param string $url + * @return array|bool + */ + protected function parseUrl(string $url) + { + $url_parts = Utils::multibyteParseUrl($url); + + if (isset($url_parts['scheme'])) { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + // Special handling for the streams. + if ($locator->schemeExists($url_parts['scheme'])) { + if (isset($url_parts['host'])) { + // Merge host and path into a path. + $url_parts['path'] = $url_parts['host'] . (isset($url_parts['path']) ? '/' . $url_parts['path'] : ''); + unset($url_parts['host']); + } + + $url_parts['stream'] = true; + } + } + + 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 new file mode 100644 index 0000000..88b2ced --- /dev/null +++ b/system/src/Grav/Common/Page/Media.php @@ -0,0 +1,222 @@ +setPath($path); + $this->media_order = $media_order; + + $this->__wakeup(); + if ($load) { + $this->init(); + } + } + + /** + * Initialize static variables on unserialize. + */ + public function __wakeup() + { + if (!isset(static::$global)) { + // Add fallback to global media. + static::$global = new GlobalMedia(); + } + } + + /** + * @param mixed $offset + * + * @return bool + */ + public function offsetExists($offset) + { + return parent::offsetExists($offset) ?: isset(static::$global[$offset]); + } + + /** + * @param mixed $offset + * + * @return mixed + */ + public function offsetGet($offset) + { + return parent::offsetGet($offset) ?: static::$global[$offset]; + } + + /** + * Initialize class. + */ + 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')); + + // Handle special cases where page doesn't exist in filesystem. + if (!is_dir($this->getPath())) { + return; + } + + $iterator = new \FilesystemIterator($this->getPath(), \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::SKIP_DOTS); + + $media = []; + + /** @var \DirectoryIterator $info */ + foreach ($iterator as $path => $info) { + // Ignore folders and Markdown files. + if (!$info->isFile() || $info->getExtension() === 'md' || strpos($info->getFilename(), '.') === 0) { + continue; + } + + // Find out what type we're dealing with + list($basename, $ext, $type, $extra) = $this->getFileParts($info->getFilename()); + + if (!\in_array(strtolower($ext), $media_types, true)) { + continue; + } + + if ($type === 'alternative') { + $media["{$basename}.{$ext}"][$type][$extra] = ['file' => $path, 'size' => $info->getSize()]; + } else { + $media["{$basename}.{$ext}"][$type] = ['file' => $path, 'size' => $info->getSize()]; + } + } + + foreach ($media as $name => $types) { + // First prepare the alternatives in case there is no base medium + if (!empty($types['alternative'])) { + foreach ($types['alternative'] as $ratio => &$alt) { + $alt['file'] = MediumFactory::fromFile($alt['file']); + + if (!$alt['file']) { + unset($types['alternative'][$ratio]); + } else { + $alt['file']->set('size', $alt['size']); + } + } + } + + $file_path = null; + + // Create the base medium + if (empty($types['base'])) { + if (!isset($types['alternative'])) { + continue; + } + + $max = max(array_keys($types['alternative'])); + $medium = $types['alternative'][$max]['file']; + $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(); + } + + if (empty($medium)) { + continue; + } + + // metadata file + $meta_path = $file_path . '.meta.yaml'; + + 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) { + $meta_data = $meta->getData(); + $meta_trimmed = array_diff_key($meta_data, array_flip($this->standard_exif)); + if ($meta_trimmed) { + if ($locator->isStream($meta_path)) { + $file = File::instance($locator->findResource($meta_path, true, true)); + } else { + $file = File::instance($meta_path); + } + $file->save(Yaml::dump($meta_trimmed)); + $types['meta']['file'] = $meta_path; + } + } + } + + if (!empty($types['meta'])) { + $medium->addMetaFile($types['meta']['file']); + } + + if (!empty($types['thumb'])) { + // We will not turn it into medium yet because user might never request the thumbnail + // not wasting any resources on that, maybe we should do this for medium in general? + $medium->set('thumbnails.page', $types['thumb']['file']); + } + + // Build missing alternatives + if (!empty($types['alternative'])) { + $alternatives = $types['alternative']; + $max = max(array_keys($alternatives)); + + for ($i=$max; $i > 1; $i--) { + if (isset($alternatives[$i])) { + continue; + } + + $types['alternative'][$i] = MediumFactory::scaledFromMedium($alternatives[$max]['file'], $max, $i); + } + + foreach ($types['alternative'] as $altMedium) { + if ($altMedium['file'] != $medium) { + $altWidth = $altMedium['file']->get('width'); + $medWidth = $medium->get('width'); + if ($altWidth && $medWidth) { + $ratio = $altWidth / $medWidth; + $medium->addAlternative($ratio, $altMedium['file']); + } + } + } + } + + $this->add($name, $medium); + } + } + + /** + * @return string + * @deprecated 1.6 Use $this->getPath() instead. + */ + public function path() + { + return $this->getPath(); + } +} diff --git a/system/src/Grav/Common/Page/Medium/AbstractMedia.php b/system/src/Grav/Common/Page/Medium/AbstractMedia.php new file mode 100644 index 0000000..f67cd65 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/AbstractMedia.php @@ -0,0 +1,244 @@ +path; + } + + public function setPath(?string $path) + { + $this->path = $path; + } + + /** + * Get medium by filename. + * + * @param string $filename + * @return Medium|null + */ + public function get($filename) + { + return $this->offsetGet($filename); + } + + /** + * Call object as function to get medium by filename. + * + * @param string $filename + * @return mixed + */ + public function __invoke($filename) + { + return $this->offsetGet($filename); + } + + /** + * Set file modification timestamps (query params) for all the media files. + * + * @param string|int|null $timestamp + * @return $this + */ + public function setTimestamps($timestamp = null) + { + /** @var Medium $instance */ + foreach ($this->items as $instance) { + $instance->setTimestamp($timestamp); + } + + return $this; + } + + /** + * Get a list of all media. + * + * @return MediaObjectInterface[] + */ + public function all() + { + $this->items = $this->orderMedia($this->items); + + return $this->items; + } + + /** + * Get a list of all image media. + * + * @return MediaObjectInterface[] + */ + public function images() + { + $this->images = $this->orderMedia($this->images); + + return $this->images; + } + + /** + * Get a list of all video media. + * + * @return MediaObjectInterface[] + */ + public function videos() + { + $this->videos = $this->orderMedia($this->videos); + + return $this->videos; + } + + /** + * Get a list of all audio media. + * + * @return MediaObjectInterface[] + */ + public function audios() + { + $this->audios = $this->orderMedia($this->audios); + + return $this->audios; + } + + /** + * Get a list of all file media. + * + * @return MediaObjectInterface[] + */ + public function files() + { + $this->files = $this->orderMedia($this->files); + + return $this->files; + } + + /** + * @param string $name + * @param MediaObjectInterface $file + */ + public function add($name, $file) + { + if (!$file) { + return; + } + $this->offsetSet($name, $file); + switch ($file->type) { + case 'image': + $this->images[$name] = $file; + break; + case 'video': + $this->videos[$name] = $file; + break; + case 'audio': + $this->audios[$name] = $file; + break; + default: + $this->files[$name] = $file; + } + } + + /** + * Order the media based on the page's media_order + * + * @param array $media + * @return array + */ + 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)); + } + } + + if (!empty($this->media_order) && is_array($this->media_order)) { + $media = Utils::sortArrayByArray($media, $this->media_order); + } else { + ksort($media, SORT_NATURAL | SORT_FLAG_CASE); + } + + return $media; + } + + /** + * Get filename, extension and meta part. + * + * @param string $filename + * @return array + */ + protected function getFileParts($filename) + { + if (preg_match('/(.*)@(\d+)x\.(.*)$/', $filename, $matches)) { + $name = $matches[1]; + $extension = $matches[3]; + $extra = (int) $matches[2]; + $type = 'alternative'; + + if ($extra === 1) { + $type = 'base'; + $extra = null; + } + } else { + $fileParts = explode('.', $filename); + + $name = array_shift($fileParts); + $extension = null; + $extra = null; + $type = 'base'; + + while (($part = array_shift($fileParts)) !== null) { + if ($part !== 'meta' && $part !== 'thumb') { + if (null !== $extension) { + $name .= '.' . $extension; + } + $extension = $part; + } else { + $type = $part; + $extra = '.' . $part . '.' . implode('.', $fileParts); + break; + } + } + } + + return array($name, $extension, $type, $extra); + } +} diff --git a/system/src/Grav/Common/Page/Medium/AudioMedium.php b/system/src/Grav/Common/Page/Medium/AudioMedium.php new file mode 100644 index 0000000..74ef746 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/AudioMedium.php @@ -0,0 +1,148 @@ +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; + } + + + /** + * Reset medium. + * + * @return $this + */ + public function reset() + { + parent::reset(); + + $this->attributes['controls'] = true; + + return $this; + } +} diff --git a/system/src/Grav/Common/Page/Medium/GlobalMedia.php b/system/src/Grav/Common/Page/Medium/GlobalMedia.php new file mode 100644 index 0000000..9fb30e9 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/GlobalMedia.php @@ -0,0 +1,128 @@ +resolveStream($offset)); + } + + /** + * @param mixed $offset + * + * @return mixed + */ + public function offsetGet($offset) + { + return parent::offsetGet($offset) ?: $this->addMedium($offset); + } + + /** + * @param string $filename + * @return string|null + */ + protected function resolveStream($filename) + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + return $locator->isStream($filename) ? ($locator->findResource($filename) ?: null) : null; + } + + /** + * @param string $stream + * @return Medium|null + */ + protected function addMedium($stream) + { + $filename = $this->resolveStream($stream); + if (!$filename) { + return null; + } + + $path = dirname($filename); + list($basename, $ext,, $extra) = $this->getFileParts(basename($filename)); + $medium = MediumFactory::fromFile($filename); + + if (empty($medium)) { + return null; + } + + $medium->set('size', filesize($filename)); + $scale = (int) ($extra ?: 1); + + if ($scale !== 1) { + $altMedium = $medium; + + // Create scaled down regular sized image. + $medium = MediumFactory::scaledFromMedium($altMedium, $scale, 1)['file']; + + if (empty($medium)) { + return null; + } + + // Add original sized image as alternative. + $medium->addAlternative($scale, $altMedium['file']); + + // Locate or generate smaller retina images. + for ($i = $scale-1; $i > 1; $i--) { + $altFilename = "{$path}/{$basename}@{$i}x.{$ext}"; + + if (file_exists($altFilename)) { + $scaled = MediumFactory::fromFile($altFilename); + } else { + $scaled = MediumFactory::scaledFromMedium($altMedium, $scale, $i)['file']; + } + + if ($scaled) { + $medium->addAlternative($i, $scaled); + } + } + } + + $meta = "{$path}/{$basename}.{$ext}.yaml"; + if (file_exists($meta)) { + $medium->addMetaFile($meta); + } + $meta = "{$path}/{$basename}.{$ext}.meta.yaml"; + if (file_exists($meta)) { + $medium->addMetaFile($meta); + } + + $thumb = "{$path}/{$basename}.thumb.{$ext}"; + if (file_exists($thumb)) { + $medium->set('thumbnails.page', $thumb); + } + + $this->add($stream, $medium); + + return $medium; + } +} diff --git a/system/src/Grav/Common/Page/Medium/ImageFile.php b/system/src/Grav/Common/Page/Medium/ImageFile.php new file mode 100644 index 0000000..d56c342 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/ImageFile.php @@ -0,0 +1,145 @@ +getAdapter()->deinit(); + } + + /** + * Clear previously applied operations + */ + public function clearOperations() + { + $this->operations = []; + } + + /** + * This is the same as the Gregwar Image class except this one fires a Grav Event on creation of new cached file + * + * @param string $type the image type + * @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 = []) + { + if ($type === 'guess') { + $type = $this->guessType(); + } + + if (!$this->forceCache && !count($this->operations) && $type === $this->guessType()) { + return $this->getFilename($this->getFilePath()); + } + + // Computes the hash + $this->hash = $this->getHash($type, $quality, $extras); + + // Seo friendly image names + $seofriendly = Grav::instance()['config']->get('system.images.seofriendly', false); + + if ($seofriendly) { + $mini_hash = substr($this->hash, 0, 4) . substr($this->hash, -4); + $cacheFile = "{$this->prettyName}-{$mini_hash}"; + } else { + $cacheFile = "{$this->hash}-{$this->prettyName}"; + } + + $cacheFile .= '.' . $type; + + // If the files does not exists, save it + $image = $this; + + // Target file should be younger than all the current image + // dependencies + $conditions = array( + 'younger-than' => $this->getDependencies() + ); + + // The generating function + $generate = function ($target) use ($image, $type, $quality) { + $result = $image->save($target, $type, $quality); + + if ($result !== $target) { + throw new GenerationError($result); + } + + Grav::instance()->fireEvent('onImageMediumSaved', new Event(['image' => $target])); + }; + + // Asking the cache for the cacheFile + try { + $perms = Grav::instance()['config']->get('system.images.cache_perms', '0755'); + $perms = octdec($perms); + $file = $this->getCacheSystem()->setDirectoryMode($perms)->getOrCreateFile($cacheFile, $conditions, $generate, $actual); + } catch (GenerationError $e) { + $file = $e->getNewFile(); + } + + // Nulling the resource + $this->getAdapter()->setSource(new Source\File($file)); + $this->getAdapter()->deinit(); + + if ($actual) { + return $file; + } + + return $this->getFilename($file); + } + + /** + * Gets the hash. + * @param string $type + * @param int $quality + * @param [] $extras + * @return null + */ + public function getHash($type = 'guess', $quality = 80, $extras = []) + { + if (null === $this->hash) { + $this->generateHash($type, $quality, $extras); + } + + return $this->hash; + } + + /** + * Generates the hash. + * @param string $type + * @param int $quality + * @param array $extras + */ + public function generateHash($type = 'guess', $quality = 80, $extras = []) + { + $inputInfos = $this->source->getInfos(); + + $datas = array( + $inputInfos, + $this->serializeOperations(), + $type, + $quality, + $extras + ); + + $this->hash = sha1(serialize($datas)); + } + +} diff --git a/system/src/Grav/Common/Page/Medium/ImageMedium.php b/system/src/Grav/Common/Page/Medium/ImageMedium.php new file mode 100644 index 0000000..13c142a --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/ImageMedium.php @@ -0,0 +1,668 @@ + [0, 1], + 'forceResize' => [0, 1], + 'cropResize' => [0, 1], + 'crop' => [0, 1, 2, 3], + 'zoomCrop' => [0, 1] + ]; + + /** + * @var string + */ + protected $sizes = '100vw'; + + /** + * Construct. + * + * @param array $items + * @param Blueprint $blueprint + */ + public function __construct($items = [], Blueprint $blueprint = null) + { + parent::__construct($items, $blueprint); + + $config = Grav::instance()['config']; + + $path = $this->get('filepath'); + if (!$path || !file_exists($path) || !filesize($path)) { + return; + } + + $image_info = getimagesize($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); + + $this->reset(); + + if ($config->get('system.images.cache_all', false)) { + $this->cache(); + } + } + + public function __destruct() + { + unset($this->image); + } + + public function __clone() + { + $this->image = $this->image ? clone $this->image : null; + + parent::__clone(); + } + + /** + * Add meta file for the medium. + * + * @param string $filepath + * @return $this + */ + public function addMetaFile($filepath) + { + parent::addMetaFile($filepath); + + // Apply filters in meta file + $this->reset(); + + return $this; + } + + /** + * Clear out the alternatives + */ + public function clearAlternatives() + { + $this->alternatives = []; + } + + /** + * Return PATH to image. + * + * @param bool $reset + * @return string path to image + */ + public function path($reset = true) + { + $output = $this->saveImage(); + + if ($reset) { + $this->reset(); + } + + return $output; + } + + /** + * Return URL to image. + * + * @param bool $reset + * @return string + */ + public function url($reset = true) + { + /** @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(); + + $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $saved_image_path); + + if ($locator->isStream($output)) { + $output = $locator->findResource($output, false); + } + + if (Utils::startsWith($output, $image_path)) { + $image_dir = $locator->findResource('cache://images', false); + $output = '/' . $image_dir . preg_replace('|^' . preg_quote($image_path, '|') . '|', '', $output); + } + + if ($reset) { + $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 srcset string for this Medium and its alternatives. + * + * @param bool $reset + * @return string + */ + public function srcset($reset = true) + { + if (empty($this->alternatives)) { + if ($reset) { + $this->reset(); + } + + return ''; + } + + $srcset = []; + foreach ($this->alternatives as $ratio => $medium) { + $srcset[] = $medium->url($reset) . ' ' . $medium->get('width') . 'w'; + } + $srcset[] = str_replace(' ', '%20', $this->url($reset)) . ' ' . $this->get('width') . 'w'; + + 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 + * + * @param array $attributes + * @param bool $reset + * @return array + */ + public function sourceParsedownElement(array $attributes, $reset = true) + { + empty($attributes['src']) && $attributes['src'] = $this->url(false); + + $srcset = $this->srcset($reset); + if ($srcset) { + empty($attributes['srcset']) && $attributes['srcset'] = $srcset; + $attributes['sizes'] = $this->sizes(); + } + + return ['name' => 'img', 'attributes' => $attributes]; + } + + /** + * 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; + + return $this; + } + + /** + * Turn the current Medium into a Link + * + * @param bool $reset + * @param array $attributes + * @return Link + */ + public function link($reset = true, array $attributes = []) + { + $attributes['href'] = $this->url(false); + $srcset = $this->srcset(false); + if ($srcset) { + $attributes['data-srcset'] = $srcset; + } + + return parent::link($reset, $attributes); + } + + /** + * 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) + { + if ($this->mode !== 'source') { + $this->display('source'); + } + + if ($width && $height) { + $this->__call('cropResize', [$width, $height]); + } + + return parent::lightbox($width, $height, $reset); + } + + /** + * Sets or gets the quality of the image + * + * @param int $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 $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: ![Example](myimg.png?width=200&height=400) + * ![Example](myimg.png?resize=100,200&width=100&height=200) + * ![Example](myimg.png?width=auto&height=auto) + * ![Example](myimg.png?width&height) + * {{ page.media['myimg.png'].width().height().html }} + * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }} + * + * @param mixed $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: ![Example](myimg.png?width=200&height=400) + * ![Example](myimg.png?resize=100,200&width=100&height=200) + * ![Example](myimg.png?width=auto&height=auto) + * ![Example](myimg.png?width&height) + * {{ page.media['myimg.png'].width().height().html }} + * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }} + * + * @param mixed $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; + } + + /** + * Handle this commonly used variant + */ + public function cropZoom() + { + $this->__call('zoomCrop', func_get_args()); + return $this; + } + + /** + * Forward the call to the image processing method. + * + * @param string $method + * @param mixed $args + * @return $this|mixed + */ + public function __call($method, $args) + { + if (!\in_array($method, self::$magic_actions, true)) { + return parent::__call($method, $args); + } + + // Always initialize image. + if (!$this->image) { + $this->image(); + } + + try { + call_user_func_array([$this->image, $method], $args); + + 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($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; + } + } + + return $max; + } + + return $this; + } + +} diff --git a/system/src/Grav/Common/Page/Medium/Link.php b/system/src/Grav/Common/Page/Medium/Link.php new file mode 100644 index 0000000..6978635 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/Link.php @@ -0,0 +1,71 @@ +attributes = $attributes; + $this->source = $medium->reset()->thumbnail('auto')->display('thumbnail'); + $this->source->linked = true; + } + + /** + * 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) + { + $innerElement = $this->source->parsedownElement($title, $alt, $class, $id, $reset); + + return [ + 'name' => 'a', + 'attributes' => $this->attributes, + 'handler' => is_string($innerElement) ? 'line' : 'element', + 'text' => $innerElement + ]; + } + + /** + * Forward the call to the source element + * + * @param string $method + * @param mixed $args + * @return mixed + */ + public function __call($method, $args) + { + $this->source = call_user_func_array(array($this->source, $method), $args); + + // 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; + } +} diff --git a/system/src/Grav/Common/Page/Medium/Medium.php b/system/src/Grav/Common/Page/Medium/Medium.php new file mode 100644 index 0000000..bf0bb1f --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/Medium.php @@ -0,0 +1,679 @@ +get('system.media.enable_media_timestamp', true)) { + $this->timestamp = Grav::instance()['cache']->getKey(); + } + + $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; + } + + 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; + } + + /** + * Returns an array containing just the metadata + * + * @return array + */ + public function metadata() + { + return $this->metadata; + } + + /** + * Add meta file for the medium. + * + * @param string $filepath + */ + public function addMetaFile($filepath) + { + $this->metadata = (array)CompiledYamlFile::instance($filepath)->content(); + $this->merge($this->metadata); + } + + /** + * Add alternative Medium to this Medium. + * + * @param int|float $ratio + * @param Medium $alternative + */ + public function addAlternative($ratio, Medium $alternative) + { + if (!is_numeric($ratio) || $ratio === 0) { + return; + } + + $alternative->set('ratio', $ratio); + $width = $alternative->get('width'); + + $this->alternatives[$width] = $alternative; + } + + /** + * Return string representation of the object (html). + * + * @return string + */ + 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 + */ + public function thumbnailExists($type = 'page') + { + $thumbs = $this->get('thumbnails'); + if (isset($thumbs[$type])) { + return true; + } + return false; + } + + /** + * 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 + */ + 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 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: ![Example](myimg.png?classes=float-left) or ![Example](myimg.png?classes=myclass1,myclass2) + * + * @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: ![Example](myimg.png?id=primary-img) + * + * @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: ![Example](myimg.png?style=float:left) + * + * @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 + */ + public function __call($method, $args) + { + $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; + } + + /** + * Get the thumbnail Medium object + * + * @return ThumbnailImageMedium + */ + protected function getThumbnail() + { + 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; + } + +} diff --git a/system/src/Grav/Common/Page/Medium/MediumFactory.php b/system/src/Grav/Common/Page/Medium/MediumFactory.php new file mode 100644 index 0000000..dc372d8 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/MediumFactory.php @@ -0,0 +1,192 @@ +get('media.types.' . strtolower($ext)); + if (!\is_array($media_params)) { + return null; + } + + $params += $media_params; + + // Add default settings for undefined variables. + $params += (array)$config->get('media.types.defaults'); + $params += [ + 'type' => 'file', + 'thumb' => 'media/thumb.png', + 'mime' => 'application/octet-stream', + 'filepath' => $file, + 'filename' => $filename, + 'basename' => $basename, + 'extension' => $ext, + 'path' => $path, + 'modified' => filemtime($file), + 'thumbnails' => [] + ]; + + $locator = Grav::instance()['locator']; + + $file = $locator->findResource("image://{$params['thumb']}"); + if ($file) { + $params['thumbnails']['default'] = $file; + } + + return static::fromArray($params); + } + + /** + * Create Medium from an uploaded file + * + * @param FormFlashFile $uploadedFile + * @param array $params + * @return Medium + */ + public static function fromUploadedFile(FormFlashFile $uploadedFile, array $params = []) + { + $parts = pathinfo($uploadedFile->getClientFilename()); + $filename = $parts['basename']; + $ext = $parts['extension']; + $basename = $parts['filename']; + $file = $uploadedFile->getTmpFile(); + $path = dirname($file); + + $config = Grav::instance()['config']; + + $media_params = $config->get('media.types.' . strtolower($ext)); + if (!\is_array($media_params)) { + return null; + } + + $params += $media_params; + + // Add default settings for undefined variables. + $params += (array)$config->get('media.types.defaults'); + $params += [ + 'type' => 'file', + 'thumb' => 'media/thumb.png', + 'mime' => 'application/octet-stream', + 'filepath' => $file, + 'filename' => $filename, + 'basename' => $basename, + 'extension' => $ext, + 'path' => $path, + 'modified' => filemtime($file), + 'thumbnails' => [] + ]; + + $locator = Grav::instance()['locator']; + + $file = $locator->findResource("image://{$params['thumb']}"); + if ($file) { + $params['thumbnails']['default'] = $file; + } + + return static::fromArray($params); + } + + /** + * Create Medium from array of parameters + * + * @param array $items + * @param Blueprint|null $blueprint + * @return Medium + */ + public static function fromArray(array $items = [], Blueprint $blueprint = null) + { + $type = $items['type'] ?? null; + + switch ($type) { + case 'image': + return new ImageMedium($items, $blueprint); + case 'thumbnail': + return new ThumbnailImageMedium($items, $blueprint); + case 'animated': + case 'vector': + return new StaticImageMedium($items, $blueprint); + case 'video': + return new VideoMedium($items, $blueprint); + case 'audio': + return new AudioMedium($items, $blueprint); + default: + return new Medium($items, $blueprint); + } + } + + /** + * Create a new ImageMedium by scaling another ImageMedium object. + * + * @param ImageMedium $medium + * @param int $from + * @param int $to + * @return Medium|array + */ + public static function scaledFromMedium($medium, $from, $to) + { + if (! $medium instanceof ImageMedium) { + return $medium; + } + + if ($to > $from) { + return $medium; + } + + $ratio = $to / $from; + $width = $medium->get('width') * $ratio; + $height = $medium->get('height') * $ratio; + + $prev_basename = $medium->get('basename'); + $basename = str_replace('@'.$from.'x', '@'.$to.'x', $prev_basename); + + $debug = $medium->get('debug'); + $medium->set('debug', false); + $medium->setImagePrettyName($basename); + + $file = $medium->resize($width, $height)->path(); + + $medium->set('debug', $debug); + $medium->setImagePrettyName($prev_basename); + + $size = filesize($file); + + $medium = self::fromFile($file); + if ($medium) { + $medium->set('size', $size); + } + + return ['file' => $medium, 'size' => $size]; + } +} diff --git a/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php new file mode 100644 index 0000000..c6d75bc --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php @@ -0,0 +1,42 @@ +parsedownElement($title, $alt, $class, $id, $reset); + + if (!$this->parsedown) { + $this->parsedown = new Parsedown(new Excerpts()); + } + + return $this->parsedown->elementToHtml($element); + } +} diff --git a/system/src/Grav/Common/Page/Medium/RenderableInterface.php b/system/src/Grav/Common/Page/Medium/RenderableInterface.php new file mode 100644 index 0000000..50a6e67 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/RenderableInterface.php @@ -0,0 +1,36 @@ +url($reset); + + return ['name' => 'img', 'attributes' => $attributes]; + } +} diff --git a/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php b/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php new file mode 100644 index 0000000..8d6d43f --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php @@ -0,0 +1,28 @@ +styleAttributes['width'] = $width . 'px'; + $this->styleAttributes['height'] = $height . 'px'; + + return $this; + } +} diff --git a/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php b/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php new file mode 100644 index 0000000..380baee --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php @@ -0,0 +1,132 @@ +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); + } +} diff --git a/system/src/Grav/Common/Page/Medium/VideoMedium.php b/system/src/Grav/Common/Page/Medium/VideoMedium.php new file mode 100644 index 0000000..c0b488a --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/VideoMedium.php @@ -0,0 +1,162 @@ +url($reset); + + return [ + 'name' => 'video', + 'text' => 'Your browser does not support the video 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 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; + } + + /** + * Reset medium. + * + * @return $this + */ + public function reset() + { + parent::reset(); + + $this->attributes['controls'] = true; + + return $this; + } +} diff --git a/system/src/Grav/Common/Page/Page.php b/system/src/Grav/Common/Page/Page.php new file mode 100644 index 0000000..176a278 --- /dev/null +++ b/system/src/Grav/Common/Page/Page.php @@ -0,0 +1,3183 @@ +taxonomy = []; + $this->process = $config->get('system.pages.process'); + $this->published = true; + } + + /** + * 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 + * + * @return $this + */ + public function init(\SplFileInfo $file, $extension = null) + { + $config = Grav::instance()['config']; + + // some extension logic + if (empty($extension)) { + $this->extension('.' . $file->getExtension()); + } else { + $this->extension($extension); + } + + // extract page language from page extension + $language = trim(basename($this->extension(), 'md'), '.') ?: null; + $this->language($language); + + $this->hide_home_route = $config->get('system.home.hide_in_urls', false); + $this->home_route = $this->adjustRouteCase($config->get('system.home.alias')); + $this->filePath($file->getPathname()); + $this->modified($file->getMTime()); + $this->id($this->modified() . md5($this->filePath())); + $this->routable(true); + $this->header(); + $this->date(); + $this->metadata(); + $this->url(); + $this->visible(); + $this->modularTwig(strpos($this->slug(), '_') === 0); + $this->setPublishState(); + $this->published(); + $this->urlExtension(); + + + return $this; + } + + protected function processFrontmatter() + { + // Quick check for twig output tags in frontmatter if enabled + $process_fields = (array)$this->header(); + if (Utils::contains(json_encode(array_values($process_fields)), '{{')) { + $ignored_fields = []; + foreach ((array)Grav::instance()['config']->get('system.pages.frontmatter.ignore_fields') as $field) { + if (isset($process_fields[$field])) { + $ignored_fields[$field] = $process_fields[$field]; + unset($process_fields[$field]); + } + } + $text_header = Grav::instance()['twig']->processString(json_encode($process_fields, JSON_UNESCAPED_UNICODE), ['page' => $this]); + $this->header((object)(json_decode($text_header, true) + $ignored_fields)); + } + } + + /** + * 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', []); + $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'); + + $route = $aPage->header()->routes['default'] ?? $aPage->rawRoute(); + if (!$route) { + $route = $aPage->route(); + } + + if ($onlyPublished && !$aPage->published()) { + continue; + } + + $translatedLanguages[$language] = $route; + } + } + + return $translatedLanguages; + } + + /** + * 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 = []; + + 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; + } + } + + return $untranslatedLanguages; + } + + /** + * Gets and Sets the raw data + * + * @param string $var Raw content string + * + * @return string Raw content string + */ + public function raw($var = null) + { + $file = $this->file(); + + if ($var) { + // First update file object. + if ($file) { + $file->raw($var); + } + + // Reset header and content. + $this->modified = time(); + $this->id($this->modified() . md5($this->filePath())); + $this->header = null; + $this->content = null; + $this->summary = null; + } + + return $file ? $file->raw() : ''; + } + + /** + * Gets and Sets the page frontmatter + * + * @param string|null $var + * + * @return string + */ + public function frontmatter($var = null) + { + + if ($var) { + $this->frontmatter = (string)$var; + + // Update also file object. + $file = $this->file(); + if ($file) { + $file->frontmatter((string)$var); + } + + // Force content re-processing. + $this->id(time() . md5($this->filePath())); + } + if (!$this->frontmatter) { + $this->header(); + } + + return $this->frontmatter; + } + + /** + * 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 + */ + public function header($var = null) + { + if ($var) { + $this->header = (object)$var; + + // Update also file object. + $file = $this->file(); + if ($file) { + $file->header((array)$var); + } + + // Force content re-processing. + $this->id(time() . md5($this->filePath())); + } + if (!$this->header) { + $file = $this->file(); + if ($file) { + try { + $this->raw_content = $file->markdown(); + $this->frontmatter = $file->frontmatter(); + $this->header = (object)$file->header(); + + if (!Utils::isAdminPlugin()) { + // If there's a `frontmatter.yaml` file merge that in with the page header + // note page's own frontmatter has precedence and will overwrite any defaults + $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); + $frontmatterFile->free(); + } + // Process frontmatter with Twig if enabled + if (Grav::instance()['config']->get('system.pages.frontmatter.process_twig') === true) { + $this->processFrontmatter(); + } + } + } catch (\Exception $e) { + $file->raw(Grav::instance()['language']->translate([ + 'GRAV.FRONTMATTER_ERROR_PAGE', + $this->slug(), + $file->filename(), + $e->getMessage(), + $file->raw() + ])); + $this->raw_content = $file->markdown(); + $this->frontmatter = $file->frontmatter(); + $this->header = (object)$file->header(); + } + $var = true; + } + + + } + + if ($var) { + if (isset($this->header->slug)) { + $this->slug($this->header->slug); + } + if (isset($this->header->routes)) { + $this->routes = (array)$this->header->routes; + } + if (isset($this->header->title)) { + $this->title = trim($this->header->title); + } + if (isset($this->header->language)) { + $this->language = trim($this->header->language); + } + if (isset($this->header->template)) { + $this->template = trim($this->header->template); + } + if (isset($this->header->menu)) { + $this->menu = trim($this->header->menu); + } + if (isset($this->header->routable)) { + $this->routable = (bool)$this->header->routable; + } + if (isset($this->header->visible)) { + $this->visible = (bool)$this->header->visible; + } + if (isset($this->header->redirect)) { + $this->redirect = trim($this->header->redirect); + } + if (isset($this->header->external_url)) { + $this->external_url = trim($this->header->external_url); + } + if (isset($this->header->order_dir)) { + $this->order_dir = trim($this->header->order_dir); + } + if (isset($this->header->order_by)) { + $this->order_by = trim($this->header->order_by); + } + if (isset($this->header->order_manual)) { + $this->order_manual = (array)$this->header->order_manual; + } + if (isset($this->header->dateformat)) { + $this->dateformat($this->header->dateformat); + } + if (isset($this->header->date)) { + $this->date($this->header->date); + } + if (isset($this->header->markdown_extra)) { + $this->markdown_extra = (bool)$this->header->markdown_extra; + } + if (isset($this->header->taxonomy)) { + $this->taxonomy($this->header->taxonomy); + } + if (isset($this->header->max_count)) { + $this->max_count = (int)$this->header->max_count; + } + if (isset($this->header->process)) { + foreach ((array)$this->header->process as $process => $status) { + $this->process[$process] = (bool)$status; + } + } + if (isset($this->header->published)) { + $this->published = (bool)$this->header->published; + } + if (isset($this->header->publish_date)) { + $this->publishDate($this->header->publish_date); + } + if (isset($this->header->unpublish_date)) { + $this->unpublishDate($this->header->unpublish_date); + } + if (isset($this->header->expires)) { + $this->expires = (int)$this->header->expires; + } + if (isset($this->header->cache_control)) { + $this->cache_control = $this->header->cache_control; + } + if (isset($this->header->etag)) { + $this->etag = (bool)$this->header->etag; + } + if (isset($this->header->last_modified)) { + $this->last_modified = (bool)$this->header->last_modified; + } + if (isset($this->header->ssl)) { + $this->ssl = (bool)$this->header->ssl; + } + if (isset($this->header->template_format)) { + $this->template_format = $this->header->template_format; + } + if (isset($this->header->debugger)) { + $this->debugger = (bool)$this->header->debugger; + } + if (isset($this->header->append_url_extension)) { + $this->url_extension = $this->header->append_url_extension; + } + } + + return $this->header; + } + + /** + * Get page language + * + * @param string $var + * + * @return mixed + */ + public function language($var = null) + { + if ($var !== null) { + $this->language = $var; + } + + return $this->language; + } + + /** + * Modify a header value directly + * + * @param string $key + * @param mixed $value + */ + public function modifyHeader($key, $value) + { + $this->header->{$key} = $value; + } + + /** + * @return int + */ + public function httpResponseCode() + { + return (int)($this->header()->http_response_code ?? 200); + } + + public function httpHeaders() + { + $headers = []; + + $grav = Grav::instance(); + $format = $this->templateFormat(); + $cache_control = $this->cacheControl(); + $expires = $this->expires(); + + // Set Content-Type header + $headers['Content-Type'] = Utils::getMimeByExtension($format, 'text/html'); + + // Calculate Expires Headers if set to > 0 + if ($expires > 0) { + $expires_date = gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT'; + if (!$cache_control) { + $headers['Cache-Control'] = 'max-age=' . $expires; + } + $headers['Expires'] = $expires_date; + } + + // Set Cache-Control header + if ($cache_control) { + $headers['Cache-Control'] = strtolower($cache_control); + } + + // Set Last-Modified header + if ($this->lastModified()) { + $last_modified_date = gmdate('D, d M Y H:i:s', $this->modified()) . ' GMT'; + $headers['Last-Modified'] = $last_modified_date; + } + + // Ask Grav to calculate ETag from the final content. + if ($this->eTag()) { + $headers['ETag'] = '1'; + } + + // Set Vary: Accept-Encoding header + if ($grav['config']->get('system.pages.vary_accept_encoding', false)) { + $headers['Vary'] = 'Accept-Encoding'; + } + + return $headers; + } + + /** + * Get the summary. + * + * @param int $size Max summary size. + * + * @param bool $textOnly Only count text size. + * + * @return string + */ + public function summary($size = null, $textOnly = false) + { + $config = (array)Grav::instance()['config']->get('site.summary'); + if (isset($this->header->summary)) { + $config = array_merge($config, $this->header->summary); + } + + // Return summary based on settings in site config file + if (!$config['enabled']) { + return $this->content(); + } + + // Set up variables to process summary from page or from custom summary + if ($this->summary === null) { + $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 + $summary_size = mb_strwidth($content, 'utf-8'); + } + + // Return calculated summary based on summary divider's position + $format = $config['format']; + // Return entire page content on wrong/ unknown format + if (!in_array($format, ['short', 'long'])) { + return $content; + } + if (($format === 'short') && isset($summary_size)) { + // Use mb_strimwidth to slice the string + if (mb_strwidth($content, 'utf8') > $summary_size) { + return mb_substr($content, 0, $summary_size); + } + + return $content; + } + + // Get summary size from site config's file + if ($size === null) { + $size = $config['size']; + } + + // If the size is zero, return the entire page content + if ($size === 0) { + return $content; + // Return calculated summary based on defaults + } + if (!is_numeric($size) || ($size < 0)) { + $size = 300; + } + + // Only return string but not html, wrap whatever html tag you want when using + if ($textOnly) { + if (mb_strwidth($content, 'utf-8') <= $size) { + return $content; + } + + return mb_strimwidth($content, 0, $size, '...', 'UTF-8'); + } + + $summary = Utils::truncateHtml($content, $size); + + return html_entity_decode($summary, ENT_COMPAT | ENT_HTML401, 'UTF-8'); + } + + /** + * Sets the summary of the page + * + * @param string $summary Summary + */ + public function setSummary($summary) + { + $this->summary = $summary; + } + + /** + * Gets and Sets the content based on content portion of the .md file + * + * @param string $var Content + * + * @return string Content + */ + public function content($var = null) + { + if ($var !== null) { + $this->raw_content = $var; + + // Update file object. + $file = $this->file(); + if ($file) { + $file->markdown($var); + } + + // Force re-processing. + $this->id(time() . md5($this->filePath())); + $this->content = null; + } + // If no content, process it + if ($this->content === null) { + // Get media + $this->media(); + + /** @var Config $config */ + $config = Grav::instance()['config']; + + // Load cached content + /** @var Cache $cache */ + $cache = Grav::instance()['cache']; + $cache_id = md5('page' . $this->getCacheKey()); + $content_obj = $cache->fetch($cache_id); + + if (is_array($content_obj)) { + $this->content = $content_obj['content']; + $this->content_meta = $content_obj['content_meta']; + } else { + $this->content = $content_obj; + } + + + $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); + + // 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); + + // if no cached-content run everything + if ($never_cache_twig) { + if ($this->content === false || $cache_enable === false) { + $this->content = $this->raw_content; + Grav::instance()->fireEvent('onPageContentRaw', new Event(['page' => $this])); + + if ($process_markdown) { + $this->processMarkdown(); + } + + // Content Processed but not cached yet + Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + + if ($cache_enable) { + $this->cachePageContent(); + } + } + + if ($process_twig) { + $this->processTwig(); + } + + } else { + if ($this->content === false || $cache_enable === false) { + $this->content = $this->raw_content; + Grav::instance()->fireEvent('onPageContentRaw', new Event(['page' => $this])); + + if ($twig_first) { + if ($process_twig) { + $this->processTwig(); + } + if ($process_markdown) { + $this->processMarkdown(); + } + + // Content Processed but not cached yet + Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + + } else { + if ($process_markdown) { + $this->processMarkdown(); + } + + // Content Processed but not cached yet + Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + + if ($process_twig) { + $this->processTwig(); + } + } + + if ($cache_enable) { + $this->cachePageContent(); + } + } + } + + // Handle summary divider + $delimiter = $config->get('site.summary.delimiter', '==='); + $divider_pos = mb_strpos($this->content, "

{$delimiter}

"); + if ($divider_pos !== false) { + $this->summary_size = $divider_pos; + $this->content = str_replace("

{$delimiter}

", '', $this->content); + } + + // Fire event when Page::content() is called + Grav::instance()->fireEvent('onPageContent', new Event(['page' => $this])); + } + + return $this->content; + } + + /** + * Get the contentMeta array and initialize content first if it's not already + * + * @return mixed + */ + public function contentMeta() + { + if ($this->content === null) { + $this->content(); + } + + return $this->getContentMeta(); + } + + /** + * Add an entry to the page's contentMeta array + * + * @param string $name + * @param string $value + */ + public function addContentMeta($name, $value) + { + $this->content_meta[$name] = $value; + } + + /** + * Return the whole contentMeta array as it currently stands + * + * @param string|null $name + * + * @return string + */ + public function getContentMeta($name = null) + { + if ($name) { + if (isset($this->content_meta[$name])) { + return $this->content_meta[$name]; + } + + return null; + } + + return $this->content_meta; + } + + /** + * Sets the whole content meta array in one shot + * + * @param array $content_meta + * + * @return array + */ + public function setContentMeta($content_meta) + { + return $this->content_meta = $content_meta; + } + + /** + * Process the Markdown content. Uses Parsedown or Parsedown Extra depending on configuration + */ + protected function processMarkdown() + { + /** @var Config $config */ + $config = Grav::instance()['config']; + + $markdownDefaults = (array)$config->get('system.pages.markdown'); + if (isset($this->header()->markdown)) { + $markdownDefaults = array_merge($markdownDefaults, $this->header()->markdown); + } + + // pages.markdown_extra is deprecated, but still check it... + if (!isset($markdownDefaults['extra']) && (isset($this->markdown_extra) || $config->get('system.pages.markdown_extra') !== null)) { + user_error('Configuration option \'system.pages.markdown_extra\' is deprecated since Grav 1.5, use \'system.pages.markdown.extra\' instead', E_USER_DEPRECATED); + + $markdownDefaults['extra'] = $this->markdown_extra ?: $config->get('system.pages.markdown_extra'); + } + + $extra = $markdownDefaults['extra'] ?? false; + $defaults = [ + 'markdown' => $markdownDefaults, + 'images' => $config->get('system.images', []) + ]; + + $excerpts = new Excerpts($this, $defaults); + + // Initialize the preferred variant of Parsedown + if ($extra) { + $parsedown = new ParsedownExtra($excerpts); + } else { + $parsedown = new Parsedown($excerpts); + } + + $this->content = $parsedown->text($this->content); + } + + + /** + * Process the Twig page content. + */ + private function processTwig() + { + $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 + */ + public function cachePageContent() + { + $cache = Grav::instance()['cache']; + $cache_id = md5('page' . $this->getCacheKey()); + $cache->save($cache_id, ['content' => $this->content, 'content_meta' => $this->content_meta]); + } + + /** + * Needed by the onPageContentProcessed event to get the raw page content + * + * @return string the current page content + */ + public function getRawContent() + { + return $this->content; + } + + /** + * Needed by the onPageContentProcessed event to set the raw page content + * + * @param string $content + */ + public function setRawContent($content) + { + $this->content = $content ?? ''; + } + + /** + * Get value from a page variable (used mostly for creating edit forms). + * + * @param string $name Variable name. + * @param mixed $default + * + * @return mixed + */ + public function value($name, $default = null) + { + if ($name === 'content') { + return $this->raw_content; + } + if ($name === 'route') { + $parent = $this->parent(); + + return $parent ? $parent->rawRoute() : ''; + } + if ($name === 'order') { + $order = $this->order(); + + return $order ? (int)$this->order() : ''; + } + if ($name === 'ordering') { + return (bool)$this->order(); + } + if ($name === 'folder') { + return preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder); + } + if ($name === 'slug') { + return $this->slug(); + } + if ($name === 'name') { + $language = $this->language() ? '.' . $this->language() : ''; + $name_val = str_replace($language . '.md', '', $this->name()); + if ($this->modular()) { + return 'modular/' . $name_val; + } + + return $name_val; + } + if ($name === 'media') { + return $this->media()->all(); + } + if ($name === 'media.file') { + return $this->media()->files(); + } + if ($name === 'media.video') { + return $this->media()->videos(); + } + if ($name === 'media.image') { + return $this->media()->images(); + } + if ($name === 'media.audio') { + return $this->media()->audios(); + } + + $path = explode('.', $name); + $scope = array_shift($path); + + if ($name === 'frontmatter') { + return $this->frontmatter; + } + + if ($scope === 'header') { + $current = $this->header(); + 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 $default; + } + } + + return $current; + } + + return $default; + } + + /** + * Gets and Sets the Page raw content + * + * @param string|null $var + * + * @return string + */ + public function rawMarkdown($var = null) + { + if ($var !== null) { + $this->raw_content = $var; + } + + return $this->raw_content; + } + + /** + * Get file object to the page. + * + * @return MarkdownFile|null + */ + public function file() + { + if ($this->name) { + return MarkdownFile::instance($this->filePath()); + } + + return null; + } + + /** + * Save page if there's a file assigned to it. + * + * @param bool|mixed $reorder Internal use. + */ + public function save($reorder = true) + { + // Perform move, copy [or reordering] if needed. + $this->doRelocation(); + + $file = $this->file(); + if ($file) { + $file->filename($this->filePath()); + $file->header((array)$this->header()); + $file->markdown($this->raw_content); + $file->save(); + } + + // Perform reorder if required + if ($reorder && is_array($reorder)) { + $this->doReorder($reorder); + } + + $this->_original = null; + } + + /** + * 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 (!$this->_original) { + $clone = clone $this; + $this->_original = $clone; + } + + $this->_action = 'move'; + + if ($this->route() === $parent->route()) { + 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'); + } + + $this->parent($parent); + $this->id(time() . md5($this->filePath())); + + if ($parent->path()) { + $this->path($parent->path() . '/' . $this->folder()); + } + + if ($parent->route()) { + $this->route($parent->route() . '/' . $this->slug()); + } else { + $this->route(Grav::instance()['pages']->root()->route() . '/' . $this->slug()); + } + + $this->raw_route = null; + + return $this; + } + + /** + * Prepare a copy from the page. Copies also everything that's under the current page. + * + * Returns a new Page object for the copy. + * 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) + { + $this->move($parent); + $this->_action = 'copy'; + + return $this; + } + + /** + * Get blueprints for the page. + * + * @return Blueprint + */ + public function blueprints() + { + $grav = Grav::instance(); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + $blueprint = $pages->blueprints($this->blueprintName()); + $fields = $blueprint->fields(); + $edit_mode = isset($grav['admin']) ? $grav['config']->get('plugins.admin.edit_mode') : null; + + // override if you only want 'normal' mode + if (empty($fields) && ($edit_mode === 'auto' || $edit_mode === 'normal')) { + $blueprint = $pages->blueprints('default'); + } + + // override if you only want 'expert' mode + if (!empty($fields) && $edit_mode === 'expert') { + $blueprint = $pages->blueprints(''); + } + + return $blueprint; + } + + /** + * Get the blueprint name for this page. Use the blueprint form field if set + * + * @return string + */ + public function blueprintName() + { + $blueprint_name = filter_input(INPUT_POST, 'blueprint', FILTER_SANITIZE_STRING) ?: $this->template(); + + return $blueprint_name; + } + + /** + * Validate page header. + * + * @throws \Exception + */ + public function validate() + { + $blueprints = $this->blueprints(); + $blueprints->validate($this->toArray()); + } + + /** + * Filter page header from illegal contents. + */ + public function filter() + { + $blueprints = $this->blueprints(); + $values = $blueprints->filter($this->toArray()); + if ($values && isset($values['header'])) { + $this->header($values['header']); + } + } + + /** + * Get unknown header variables. + * + * @return array + */ + public function extra() + { + $blueprints = $this->blueprints(); + + return $blueprints->extra($this->toArray()['header'], 'header.'); + } + + /** + * Convert page to an array. + * + * @return array + */ + public function toArray() + { + return [ + 'header' => (array)$this->header(), + 'content' => (string)$this->value('content') + ]; + } + + /** + * Convert page to YAML encoded string. + * + * @return string + */ + public function toYaml() + { + return Yaml::dump($this->toArray(), 20); + } + + /** + * Convert page to JSON encoded string. + * + * @return string + */ + public function toJson() + { + return json_encode($this->toArray()); + } + + /** + * @return string + */ + 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. + * + * @return Media Representation of associated media. + */ + public function media($var = null) + { + if ($var) { + $this->setMedia($var); + } + + return $this->getMedia(); + } + + /** + * Get filesystem path to the associated media. + * + * @return string|null + */ + public function getMediaFolder() + { + return $this->path(); + } + + /** + * Get display order for the associated media. + * + * @return array Empty array means default ordering. + */ + public function getMediaOrder() + { + $header = $this->header(); + + return isset($header->media_order) ? array_map('trim', explode(',', (string)$header->media_order)) : []; + } + + /** + * 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. + * + * @return string The name of this page. + */ + public function name($var = null) + { + if ($var !== null) { + $this->name = $var; + } + + return $this->name ?: 'default.md'; + } + + /** + * Returns child page type. + * + * @return string + */ + public function childType() + { + return isset($this->header->child_type) ? (string)$this->header->child_type : ''; + } + + /** + * 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 + * + * @return string the template name + */ + public function template($var = null) + { + if ($var !== null) { + $this->template = $var; + } + if (empty($this->template)) { + $this->template = ($this->modular() ? '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 + * + * @return null + */ + 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; + } + + // Set from uri extension + $uri_extension = Grav::instance()['uri']->extension(); + if (is_string($uri_extension)) { + $this->template_format = $uri_extension; + + return $this->template_format; + } + + // 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 + */ + public function extension($var = null) + { + if ($var !== null) { + $this->extension = $var; + } + if (empty($this->extension)) { + $this->extension = '.' . pathinfo($this->name(), PATHINFO_EXTENSION); + } + + return $this->extension; + } + + /** + * Returns the page extension, got from the page `url_extension` config and falls back to the + * system config `system.pages.append_url_extension`. + * + * @return string The extension of this page. For example `.html` + */ + public function urlExtension() + { + if ($this->home()) { + return ''; + } + + // if not set in the page get the value from system config + if (null === $this->url_extension) { + $this->url_extension = Grav::instance()['config']->get('system.pages.append_url_extension', ''); + } + + return $this->url_extension; + } + + /** + * Gets and sets the expires field. If not set will return the default + * + * @param int $var The new expires value. + * + * @return int The expires value + */ + public function expires($var = null) + { + if ($var !== null) { + $this->expires = $var; + } + + return $this->expires ?? Grav::instance()['config']->get('system.pages.expires'); + } + + /** + * 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 + */ + public function cacheControl($var = null) + { + if ($var !== null) { + $this->cache_control = $var; + } + + return $this->cache_control ?? Grav::instance()['config']->get('system.pages.cache_control'); + } + + /** + * 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 + */ + public function title($var = null) + { + if ($var !== null) { + $this->title = $var; + } + if (empty($this->title)) { + $this->title = ucfirst($this->slug()); + } + + return $this->title; + } + + /** + * 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 + */ + public function menu($var = null) + { + if ($var !== null) { + $this->menu = $var; + } + if (empty($this->menu)) { + $this->menu = $this->title(); + } + + return $this->menu; + } + + /** + * 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 + */ + public function visible($var = null) + { + if ($var !== null) { + $this->visible = (bool)$var; + } + + if ($this->visible === null) { + // Set item visibility in menu if folder is different from slug + // eg folder = 01.Home and slug = Home + if (preg_match(PAGE_ORDER_PREFIX_REGEX, $this->folder)) { + $this->visible = true; + } else { + $this->visible = false; + } + } + + return $this->visible; + } + + /** + * 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 + */ + public function published($var = null) + { + if ($var !== null) { + $this->published = (bool)$var; + } + + // If not published, should not be visible in menus either + if ($this->published === false) { + $this->visible = false; + } + + return $this->published; + } + + /** + * Gets and Sets the Page publish date + * + * @param string $var string representation of a date + * + * @return int unix timestamp representation of the date + */ + public function publishDate($var = null) + { + if ($var !== null) { + $this->publish_date = Utils::date2timestamp($var, $this->dateformat); + } + + return $this->publish_date; + } + + /** + * Gets and Sets the Page unpublish date + * + * @param string $var string representation of a date + * + * @return int|null unix timestamp representation of the date + */ + public function unpublishDate($var = null) + { + if ($var !== null) { + $this->unpublish_date = Utils::date2timestamp($var, $this->dateformat); + } + + return $this->unpublish_date; + } + + /** + * Gets and Sets whether or not this Page is routable, ie you can reach it + * via a URL. + * The page must be *routable* and *published* + * + * @param bool $var true if the page is routable + * + * @return bool true if the page is routable + */ + public function routable($var = null) + { + if ($var !== null) { + $this->routable = (bool)$var; + } + + return $this->routable && $this->published(); + } + + public function ssl($var = null) + { + if ($var !== null) { + $this->ssl = (bool)$var; + } + + return $this->ssl; + } + + /** + * 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 + */ + public function process($var = null) + { + if ($var !== null) { + $this->process = (array)$var; + } + + return $this->process; + } + + /** + * Returns the state of the debugger override etting for this page + * + * @return mixed + */ + public function debugger() + { + return !(isset($this->debugger) && $this->debugger === false); + } + + /** + * 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 + * + * @return array an Array of metadata values for the page + */ + public function metadata($var = null) + { + if ($var !== null) { + $this->metadata = (array)$var; + } + + // if not metadata yet, process it. + if (null === $this->metadata) { + $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible']; + + $this->metadata = []; + + $metadata = []; + // Set the Generator tag + $metadata['generator'] = 'GravCMS'; + + // Get initial metadata for the page + $metadata = array_merge($metadata, Grav::instance()['config']->get('site.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); + } + + // Build an array of meta objects.. + foreach ((array)$metadata as $key => $value) { + // Lowercase the key + $key = strtolower($key); + // If this is a property type metadata: "og", "twitter", "facebook" etc + // Backward compatibility for nested arrays in metas + if (is_array($value)) { + foreach ($value as $property => $prop_value) { + $prop_key = $key . ':' . $property; + $this->metadata[$prop_key] = [ + 'name' => $prop_key, + 'property' => $prop_key, + 'content' => htmlspecialchars($prop_value, ENT_QUOTES, 'UTF-8') + ]; + } + } else { + // If it this is a standard meta data type + if ($value) { + if (\in_array($key, $header_tag_http_equivs, true)) { + $this->metadata[$key] = [ + 'http_equiv' => $key, + 'content' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8') + ]; + } elseif ($key === 'charset') { + $this->metadata[$key] = ['charset' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8')]; + } 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') + ]; + + if ($hasSeparator && !Utils::startsWith($key, 'twitter')) { + $entry['property'] = $key; + } else { + $entry['name'] = $key; + } + + $this->metadata[$key] = $entry; + } + } + } + } + } + + return $this->metadata; + } + + /** + * Reset the metadata and pull from header again + */ + public function resetMetadata() + { + $this->metadata = 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 + */ + public function slug($var = null) + { + if ($var !== null && $var !== '') { + $this->slug = $var; + } + + if (empty($this->slug)) { + $this->slug = $this->adjustRouteCase(preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder)) ?: null; + } + + return $this->slug; + } + + /** + * Get/set order number of this page. + * + * @param int $var + * + * @return int|bool + */ + public function order($var = null) + { + if ($var !== null) { + $order = $var ? sprintf('%02d.', (int)$var) : ''; + $this->folder($order . preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder)); + + return $order; + } + + preg_match(PAGE_ORDER_PREFIX_REGEX, $this->folder, $order); + + return $order[0] ?? false; + } + + /** + * Gets the URL for a page - alias of url(). + * + * @param bool $include_host + * + * @return string the permalink + */ + public function link($include_host = false) + { + return $this->url($include_host); + } + + /** + * Gets the URL with host information, aka Permalink. + * @return string The permalink. + */ + public function permalink() + { + return $this->url(true, false, true, true); + } + + /** + * Returns the canonical URL for a page + * + * @param bool $include_lang + * + * @return string + */ + public function canonical($include_lang = true) + { + return $this->url(true, true, $include_lang); + } + + /** + * Gets the url for the Page. + * + * @param bool $include_host Defaults false, but true would include http://yourhost.com + * @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) + { + // Override any URL when external_url is set + if (isset($this->external_url)) { + return $this->external_url; + } + + $grav = Grav::instance(); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + /** @var Config $config */ + $config = $grav['config']; + + // get base route (multi-site base and language) + $route = $include_base ? $pages->baseRoute() : ''; + + // add full route if configured to do so + if (!$include_host && $config->get('system.absolute_urls', false)) { + $include_host = true; + } + + if ($canonical) { + $route .= $this->routeCanonical(); + } elseif ($raw_route) { + $route .= $this->rawRoute(); + } else { + $route .= $this->route(); + } + + /** @var Uri $uri */ + $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); + } + + /** + * 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. + */ + public function route($var = null) + { + if ($var !== null) { + $this->route = $var; + } + + if (empty($this->route)) { + $baseRoute = null; + + // calculate route based on parent slugs + $parent = $this->parent(); + if (isset($parent)) { + if ($this->hide_home_route && $parent->route() === $this->home_route) { + $baseRoute = ''; + } else { + $baseRoute = (string)$parent->route(); + } + } + + $this->route = isset($baseRoute) ? $baseRoute . '/' . $this->slug() : null; + + if (!empty($this->routes) && isset($this->routes['default'])) { + $this->routes['aliases'][] = $this->route; + $this->route = $this->routes['default']; + + return $this->route; + } + } + + return $this->route; + } + + /** + * Helper method to clear the route out so it regenerates next time you use it + */ + public function unsetRouteSlug() + { + unset($this->route, $this->slug); + } + + /** + * Gets and Sets the page raw route + * + * @param null $var + * + * @return null|string + */ + public function rawRoute($var = null) + { + if ($var !== null) { + $this->raw_route = $var; + } + + if (empty($this->raw_route)) { + $parent = $this->parent(); + $baseRoute = $parent ? (string)$parent->rawRoute() : null; + + $slug = $this->adjustRouteCase(preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder)); + + $this->raw_route = isset($baseRoute) ? $baseRoute . '/' . $slug : null; + } + + return $this->raw_route; + } + + /** + * Gets the route aliases for the page based on page headers. + * + * @param array $var list of route aliases + * + * @return array The route aliases for the Page. + */ + public function routeAliases($var = null) + { + if ($var !== null) { + $this->routes['aliases'] = (array)$var; + } + + if (!empty($this->routes) && isset($this->routes['aliases'])) { + return $this->routes['aliases']; + } + + return []; + } + + /** + * 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 + * + * @return bool|string + */ + public function routeCanonical($var = null) + { + if ($var !== null) { + $this->routes['canonical'] = $var; + } + + if (!empty($this->routes) && isset($this->routes['canonical'])) { + return $this->routes['canonical']; + } + + return $this->route(); + } + + /** + * Gets and sets the identifier for this Page object. + * + * @param string $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() ?: ''; + $id = $active_lang . $var; + $this->id = $id; + } + + return $this->id; + } + + /** + * Gets and sets the modified timestamp. + * + * @param int $var modified unix timestamp + * + * @return int modified unix timestamp + */ + public function modified($var = null) + { + if ($var !== null) { + $this->modified = $var; + } + + return $this->modified; + } + + /** + * Gets the redirect set in the header. + * + * @param string $var redirect url + * + * @return string + */ + public function redirect($var = null) + { + if ($var !== null) { + $this->redirect = $var; + } + + return $this->redirect; + } + + /** + * Gets and sets the option to show the etag header for the page. + * + * @param bool $var show etag header + * + * @return bool show etag header + */ + public function eTag($var = null) + { + if ($var !== null) { + $this->etag = $var; + } + if (!isset($this->etag)) { + $this->etag = (bool)Grav::instance()['config']->get('system.pages.etag'); + } + + return $this->etag; + } + + /** + * Gets and sets the option to show the last_modified header for the page. + * + * @param bool $var show last_modified header + * + * @return bool show last_modified header + */ + public function lastModified($var = null) + { + if ($var !== null) { + $this->last_modified = $var; + } + if (!isset($this->last_modified)) { + $this->last_modified = (bool)Grav::instance()['config']->get('system.pages.last_modified'); + } + + return $this->last_modified; + } + + /** + * Gets and sets the path to the .md file for this Page object. + * + * @param string $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); + // Folder of the page. + $this->folder = basename(dirname($var)); + // Path to the page. + $this->path = dirname($var, 2); + } + + return $this->path . '/' . $this->folder . '/' . ($this->name ?: ''); + } + + /** + * Gets the relative path to the .md file + * + * @return string The relative file path + */ + public function filePathClean() + { + return str_replace(ROOT_DIR, '', $this->filePath()); + } + + /** + * Returns the clean path to the page file + */ + public function relativePagePath() + { + return str_replace('/' . $this->name(), '', $this->filePathClean()); + } + + /** + * 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 + * + * @return string|null the path + */ + public function path($var = null) + { + if ($var !== null) { + // Folder of the page. + $this->folder = basename($var); + // Path to the page. + $this->path = dirname($var); + } + + return $this->path ? $this->path . '/' . $this->folder : null; + } + + /** + * Get/set the folder. + * + * @param string $var Optional path + * + * @return string|null + */ + public function folder($var = null) + { + if ($var !== null) { + $this->folder = $var; + } + + return $this->folder; + } + + /** + * 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 + */ + public function date($var = null) + { + if ($var !== null) { + $this->date = Utils::date2timestamp($var, $this->dateformat); + } + + if (!$this->date) { + $this->date = $this->modified; + } + + return $this->date; + } + + /** + * 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 + */ + public function dateformat($var = null) + { + if ($var !== null) { + $this->dateformat = $var; + } + + return $this->dateformat; + } + + /** + * Gets and sets the order by which any sub-pages should be sorted. + * + * @param string $var the order, either "asc" or "desc" + * + * @return string the order, either "asc" or "desc" + * @deprecated 1.6 + */ + public function orderDir($var = null) + { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + if ($var !== null) { + $this->order_dir = $var; + } + + if (empty($this->order_dir)) { + $this->order_dir = 'asc'; + } + + return $this->order_dir; + } + + /** + * Gets and sets the order by which the sub-pages should be sorted. + * + * default - is the order based on the file system, ie 01.Home before 02.Advark + * title - is the order based on the title set in the pages + * 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" + * + * @return string supported options include "default", "title", "date", and "folder" + * @deprecated 1.6 + */ + public function orderBy($var = null) + { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + if ($var !== null) { + $this->order_by = $var; + } + + return $this->order_by; + } + + /** + * Gets the manual order set in the header. + * + * @param string $var supported options include "default", "title", "date", and "folder" + * + * @return array + * @deprecated 1.6 + */ + public function orderManual($var = null) + { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + if ($var !== null) { + $this->order_manual = $var; + } + + return (array)$this->order_manual; + } + + /** + * 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 + * + * @return int the maximum number of sub-pages + * @deprecated 1.6 + */ + public function maxCount($var = null) + { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + if ($var !== null) { + $this->max_count = (int)$var; + } + if (empty($this->max_count)) { + /** @var Config $config */ + $config = Grav::instance()['config']; + $this->max_count = (int)$config->get('system.pages.list.count'); + } + + return $this->max_count; + } + + /** + * 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 + */ + public function taxonomy($var = null) + { + if ($var !== null) { + // make sure first level are arrays + array_walk($var, function(&$value) { + $value = (array) $value; + }); + // make sure all values are strings + array_walk_recursive($var, function(&$value) { + $value = (string) $value; + }); + $this->taxonomy = $var; + } + + return $this->taxonomy; + } + + /** + * Gets and sets the modular var that helps identify this page is a modular child + * + * @param bool $var true if modular_twig + * + * @return bool true if modular_twig + */ + public function modular($var = null) + { + return $this->modularTwig($var); + } + + /** + * 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 + * + * @return bool true if modular_twig + */ + public function modularTwig($var = null) + { + if ($var !== null) { + $this->modular_twig = (bool)$var; + if ($var) { + $this->visible(false); + // some routable logic + if (empty($this->header->routable)) { + $this->routable = false; + } + } + } + + return $this->modular_twig; + } + + /** + * 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) + { + return (bool)($this->process[$process] ?? false); + } + + /** + * Gets and Sets the parent object for this page + * + * @param PageInterface $var the parent page object + * + * @return PageInterface|null the parent page object if it exists. + */ + public function parent(PageInterface $var = null) + { + if ($var) { + $this->parent = $var->path(); + + return $var; + } + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->get($this->parent); + } + + /** + * Gets the top parent object for this page + * + * @return PageInterface|null the top parent page object if it exists. + */ + public function topParent() + { + $topParent = $this->parent(); + + if (!$topParent) { + return null; + } + + while (true) { + $theParent = $topParent->parent(); + if ($theParent !== null && $theParent->parent() !== null) { + $topParent = $theParent; + } else { + break; + } + } + + return $topParent; + } + + /** + * Returns children of this page. + * + * @return Collection + */ + public function children() + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->children($this->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() + { + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if ($collection instanceof Collection) { + return $collection->isFirst($this->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() + { + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if ($collection instanceof Collection) { + return $collection->isLast($this->path()); + } + + return true; + } + + /** + * Gets the previous sibling based on current position. + * + * @return PageInterface the previous Page item + */ + public function prevSibling() + { + return $this->adjacentSibling(-1); + } + + /** + * Gets the next sibling based on current position. + * + * @return PageInterface the next Page item + */ + public function nextSibling() + { + return $this->adjacentSibling(1); + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param int $direction either -1 or +1 + * + * @return PageInterface|bool the sibling page + */ + public function adjacentSibling($direction = 1) + { + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if ($collection instanceof Collection) { + return $collection->adjacentSibling($this->path(), $direction); + } + + return false; + } + + /** + * Returns the item in the current position. + * + * @return int the index of the current page. + */ + public function currentPosition() + { + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if ($collection instanceof Collection) { + return $collection->currentPosition($this->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() + { + $uri_path = rtrim(urldecode(Grav::instance()['uri']->path()), '/') ?: '/'; + $routes = Grav::instance()['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() + { + $uri = Grav::instance()['uri']; + $pages = Grav::instance()['pages']; + $uri_path = rtrim(urldecode($uri->path()), '/'); + $routes = Grav::instance()['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(); + } + } + } + + return false; + } + + /** + * Returns whether or not this page is the currently configured home page. + * + * @return bool True if it is the homepage + */ + public function home() + { + $home = Grav::instance()['config']->get('system.home.alias'); + + return $this->route() === $home || $this->rawRoute() === $home; + } + + /** + * Returns whether or not this page is the root node of the pages tree. + * + * @return bool True if it is the root + */ + public function root() + { + return !$this->parent && !$this->name && !$this->visible; + } + + /** + * Helper method to return an ancestor page. + * + * @param bool $lookup Name of the parent folder + * + * @return PageInterface page you were looking for if it exists + */ + public function ancestor($lookup = null) + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->ancestor($this->route, $lookup); + } + + /** + * Helper method to return an ancestor page to inherit from. The current + * page object is returned. + * + * @param string $field Name of the parent folder + * + * @return PageInterface + */ + public function inherited($field) + { + list($inherited, $currentParams) = $this->getInheritedParams($field); + + $this->modifyHeader($field, $currentParams); + + return $inherited; + } + + /** + * Helper method to return an ancestor field only to inherit from. The + * 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) + { + list($inherited, $currentParams) = $this->getInheritedParams($field); + + return $currentParams; + } + + /** + * Method that contains shared logic for inherited() and inheritedField() + * + * @param string $field Name of the parent folder + * + * @return array + */ + protected function getInheritedParams($field) + { + $pages = Grav::instance()['pages']; + + /** @var Pages $pages */ + $inherited = $pages->inherited($this->route, $field); + $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : []; + $currentParams = (array)$this->value('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 page you were looking for if it exists + */ + public function find($url, $all = false) + { + /** @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 Collection + * @throws \InvalidArgumentException + */ + public function collection($params = 'content', $pagination = true) + { + if (is_string($params)) { + $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); + } + + 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 = (int)($params['limit'] ?? 0); + $start = !empty($params['pagination']) ? ($uri->currentPage() - 1) * $limit : 0; + + if ($limit && $collection->count() > $limit) { + $collection->slice($start, $limit); + } + } + + return $collection; + } + + /** + * @param string|array $value + * @param bool $only_published + * @return mixed + */ + 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, $only_published)->toArray(); + } else { + $result = $result + $this->evaluate([$key => $val], $only_published)->toArray(); + } + + } + + return new Collection($result); + } + + /** @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; + } + + /** + * Returns whether or not this Page object has a .md file associated with it or if its just a directory. + * + * @return bool True if its a page with a .md file associated + */ + public function isPage() + { + if ($this->name) { + return true; + } + + return false; + } + + /** + * Returns whether or not this Page object is a directory or a page. + * + * @return bool True if its a directory + */ + public function isDir() + { + return !$this->isPage(); + } + + /** + * Returns whether the page exists in the filesystem. + * + * @return bool + */ + public function exists() + { + $file = $this->file(); + + return $file && $file->exists(); + } + + /** + * Returns whether or not the current folder exists + * + * @return bool + */ + public function folderExists() + { + return file_exists($this->path()); + } + + /** + * Cleans the path. + * + * @param string $path the path + * + * @return string the path + */ + protected function cleanPath($path) + { + $lastchunk = strrchr($path, DS); + if (strpos($lastchunk, ':') !== false) { + $path = str_replace($lastchunk, '', $path); + } + + return $path; + } + + /** + * Reorders all siblings according to a defined order + * + * @param array|null $new_order + */ + protected function doReorder($new_order) + { + if (!$this->_original) { + return; + } + + $pages = Grav::instance()['pages']; + $pages->init(); + + $this->_original->path($this->path()); + + $parent = $this->parent(); + $siblings = $parent ? $parent->children() : null; + + if ($siblings) { + $siblings->order('slug', 'asc', $new_order); + + $counter = 0; + + // Reorder all moved pages. + foreach ($siblings as $slug => $page) { + $order = (int)trim($page->order(), '.'); + $counter++; + + if ($order) { + if ($page->path() === $this->path() && $this->folderExists()) { + // Handle current page; we do want to change ordering number, but nothing else. + $this->order($counter); + $this->save(false); + } else { + // Handle all the other pages. + $page = $pages->get($page->path()); + if ($page && $page->folderExists() && !$page->_action) { + $page = $page->move($this->parent()); + $page->order($counter); + $page->save(false); + } + } + } + } + } + } + + /** + * Moves or copies the page in filesystem. + * + * @internal + * + * @throws \Exception + */ + protected function doRelocation() + { + if (!$this->_original) { + return; + } + + if (is_dir($this->_original->path())) { + if ($this->_action === 'move') { + Folder::move($this->_original->path(), $this->path()); + } elseif ($this->_action === 'copy') { + Folder::copy($this->_original->path(), $this->path()); + } + } + + if ($this->name() !== $this->_original->name()) { + $path = $this->path(); + if (is_file($path . '/' . $this->_original->name())) { + rename($path . '/' . $this->_original->name(), $path . '/' . $this->name()); + } + } + + } + + protected function setPublishState() + { + // Handle publishing dates if no explicit published option set + if (Grav::instance()['config']->get('system.pages.publish_dates') && !isset($this->header->published)) { + // unpublish if required, if not clear cache right before page should be unpublished + if ($this->unpublishDate()) { + if ($this->unpublishDate() < time()) { + $this->published(false); + } else { + $this->published(); + Grav::instance()['cache']->setLifeTime($this->unpublishDate()); + } + } + // publish if required, if not clear cache right before page is published + if ($this->publishDate() && $this->publishDate() > time()) { + $this->published(false); + Grav::instance()['cache']->setLifeTime($this->publishDate()); + } + } + } + + protected function adjustRouteCase($route) + { + $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls'); + + return $case_insensitive ? mb_strtolower($route) : $route; + } + + /** + * Gets the Page Unmodified (original) version of the page. + * + * @return PageInterface The original version of the page. + */ + public function getOriginal() + { + return $this->_original; + } + + /** + * Gets the action. + * + * @return string The Action string. + */ + public function getAction() + { + return $this->_action; + } +} diff --git a/system/src/Grav/Common/Page/Pages.php b/system/src/Grav/Common/Page/Pages.php new file mode 100644 index 0000000..8f31afb --- /dev/null +++ b/system/src/Grav/Common/Page/Pages.php @@ -0,0 +1,1443 @@ +grav = $c; + } + + /** + * Get or set base path for the pages. + * + * @param string $path + * + * @return string + */ + public function base($path = null) + { + if ($path !== null) { + $path = trim($path, '/'); + $this->base = $path ? '/' . $path : null; + $this->baseRoute = []; + } + + return $this->base; + } + + /** + * + * Get base route for Grav pages. + * + * @param string $lang Optional language code for multilingual routes. + * + * @return string + */ + public function baseRoute($lang = null) + { + $key = $lang ?: $this->active_lang ?: 'default'; + + if (!isset($this->baseRoute[$key])) { + /** @var Language $language */ + $language = $this->grav['language']; + + $path_base = rtrim($this->base(), '/'); + $path_lang = $language->enabled() ? $language->getLanguageURLPrefix($lang) : ''; + + $this->baseRoute[$key] = $path_base . $path_lang; + } + + return $this->baseRoute[$key]; + } + + /** + * + * Get route for Grav site. + * + * @param string $route Optional route to the page. + * @param string $lang Optional language code for multilingual links. + * + * @return string + */ + public function route($route = '/', $lang = null) + { + if (!$route || $route === '/') { + return $this->baseRoute($lang) ?: '/'; + } + + return $this->baseRoute($lang) . $route; + } + + /** + * + * Get base URL for Grav pages. + * + * @param string $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) + { + if ($absolute === null) { + $type = 'base_url'; + } elseif ($absolute) { + $type = 'base_url_absolute'; + } else { + $type = 'base_url_relative'; + } + + return $this->grav[$type] . $this->baseRoute($lang); + } + + /** + * + * 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. + * + * @return string + */ + public function homeUrl($lang = null, $absolute = null) + { + return $this->baseUrl($lang, $absolute) ?: '/'; + } + + /** + * + * 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. + * + * @return string + */ + public function url($route = '/', $lang = null, $absolute = null) + { + if (!$route || $route === '/') { + return $this->homeUrl($lang, $absolute); + } + + return $this->baseUrl($lang, $absolute) . Uri::filterPath($route); + } + + public function setCheckMethod($method) + { + $this->check_method = strtolower($method); + } + + /** + * Reset pages (used in search indexing etc). + */ + public function reset() + { + $this->initialized = false; + + $this->init(); + } + + /** + * Class initialization. Must be called before using this class. + */ + public function init() + { + 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->instances = []; + $this->children = []; + $this->routes = []; + + if (!$this->check_method) { + $this->setCheckMethod($config->get('system.cache.check.method', 'file')); + } + + $this->buildPages(); + } + + /** + * Get or set last modification time. + * + * @param int $modified + * + * @return int|null + */ + public function lastModified($modified = null) + { + if ($modified && $modified > $this->last_modified) { + $this->last_modified = $modified; + } + + return $this->last_modified; + } + + /** + * Returns a list of all pages. + * + * @return array|PageInterface[] + */ + public function instances() + { + return $this->instances; + } + + /** + * Returns a list of all routes. + * + * @return array + */ + public function routes() + { + return $this->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). + */ + public function addPage(PageInterface $page, $route = null) + { + if (!isset($this->instances[$page->path()])) { + $this->instances[$page->path()] = $page; + } + $route = $page->route($route); + if ($page->parent()) { + $this->children[$page->parent()->path()][$page->path()] = ['slug' => $page->slug()]; + } + $this->routes[$route] = $page->path(); + + $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page])); + } + + /** + * Sort sub-pages in a page. + * + * @param PageInterface $page + * @param string $order_by + * @param string $order_dir + * + * @return array + */ + public function sort(PageInterface $page, $order_by = null, $order_dir = null, $sort_flags = null) + { + if ($order_by === null) { + $order_by = $page->orderBy(); + } + if ($order_dir === null) { + $order_dir = $page->orderDir(); + } + + $path = $page->path(); + $children = $this->children[$path] ?? []; + + if (!$children) { + return $children; + } + + if (!isset($this->sort[$path][$order_by])) { + $this->buildSort($path, $children, $order_by, $page->orderManual(), $sort_flags); + } + + $sort = $this->sort[$path][$order_by]; + + if ($order_dir !== 'asc') { + $sort = array_reverse($sort); + } + + return $sort; + } + + /** + * @param Collection $collection + * @param string|int $orderBy + * @param string $orderDir + * @param array|null $orderManual + * @param int|null $sort_flags + * + * @return array + * @internal + */ + public function sortCollection(Collection $collection, $orderBy, $orderDir = 'asc', $orderManual = null, $sort_flags = null) + { + $items = $collection->toArray(); + if (!$items) { + return []; + } + + $lookup = md5(json_encode($items) . json_encode($orderManual) . $orderBy . $orderDir); + if (!isset($this->sort[$lookup][$orderBy])) { + $this->buildSort($lookup, $items, $orderBy, $orderManual, $sort_flags); + } + + $sort = $this->sort[$lookup][$orderBy]; + + if ($orderDir !== 'asc') { + $sort = array_reverse($sort); + } + + return $sort; + + } + + /** + * Get a page instance. + * + * @param string $path The filesystem full path of the page + * + * @return PageInterface + * @throws \Exception + */ + public function get($path) + { + return $this->instances[(string)$path] ?? null; + } + + /** + * Get children of the path. + * + * @param string $path + * + * @return Collection + */ + public function children($path) + { + $children = $this->children[(string)$path] ?? []; + + return new Collection($children, [], $this); + } + + /** + * Get a page ancestor. + * + * @param string $route The relative URL of the page + * @param string $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); + + if ($page && $page->path() === $path) { + return $page; + } + + $parent = $page ? $page->parent() : null; + if ($parent && !$parent->root()) { + return $this->ancestor($parent->route(), $path); + } + } + + return 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 + * + * @return PageInterface|null + */ + public function inherited($route, $field = null) + { + if ($field !== null) { + + $page = $this->dispatch($route, true); + + $parent = $page ? $page->parent() : null; + if ($parent && $parent->value('header.' . $field) !== null) { + return $parent; + } + if ($parent && !$parent->root()) { + return $this->inherited($parent->route(), $field); + } + } + + return null; + } + + /** + * alias method to return find a page. + * + * @param string $route The relative URL of the page + * @param bool $all + * + * @return PageInterface|null + */ + public function find($route, $all = false) + { + return $this->dispatch($route, $all, false); + } + + /** + * Dispatch URI to a page. + * + * @param string $route The relative URL of the page + * @param bool $all + * + * @param bool $redirect + * @return PageInterface|null + * @throws \Exception + */ + public function dispatch($route, $all = false, $redirect = true) + { + $route = urldecode($route); + + // 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; + } + + // Are we in the admin? this is important! + $not_admin = !isset($this->grav['admin']); + + // If the page cannot be reached, look into site wide redirects, routes + wildcards + if (!$all && $not_admin) { + + // If the page is a simple redirect, just do it. + if ($redirect && $page && $page->redirect()) { + $this->grav->redirectLangSafe($page->redirect()); + } + + // fall back and check site based redirects + if (!$page || ($page && !$page->routable())) { + /** @var Config $config */ + $config = $this->grav['config']; + + // 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 { + + /** @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()); + } + } + } + + // 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()); + } + } + } + } + } + } + + return $page; + } + + /** + * Get root page. + * + * @return PageInterface + */ + public function root() + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + return $this->instances[rtrim($locator->findResource('page://'), DS)]; + } + + /** + * Get a blueprint for a page type. + * + * @param string $type + * + * @return Blueprint + */ + public function blueprints($type) + { + if ($this->blueprints === null) { + $this->blueprints = new Blueprints(self::getTypes()); + } + + try { + $blueprint = $this->blueprints->get($type); + } 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; + } + + return $blueprint; + } + + /** + * Get all pages + * + * @param PageInterface $current + * + * @return \Grav\Common\Page\Collection + */ + public function all(PageInterface $current = null) + { + $all = new Collection(); + + /** @var PageInterface $current */ + $current = $current ?: $this->root(); + + if (!$current->root()) { + $all[$current->path()] = ['slug' => $current->slug()]; + } + + foreach ($current->children() as $next) { + $all->append($this->all($next)); + } + + return $all; + } + + /** + * Get available parents raw routes. + * + * @return array + */ + public static function parentsRawRoutes() + { + $rawRoutes = true; + + return self::getParents($rawRoutes); + } + + /** + * Get available parents routes + * + * @param bool $rawRoutes get the raw route or the normal route + * + * @return array + */ + private static function getParents($rawRoutes) + { + $grav = Grav::instance(); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + $parents = $pages->getList(null, 0, $rawRoutes); + + if (isset($grav['admin'])) { + // Remove current route from parents + + /** @var Admin $admin */ + $admin = $grav['admin']; + + $page = $admin->getPage($admin->route); + $page_route = $page->route(); + if (isset($parents[$page_route])) { + unset($parents[$page_route]); + } + + } + + return $parents; + } + + /** + * Get list of route/title of all pages. + * + * @param PageInterface $current + * @param int $level + * @param bool $rawRoutes + * + * @param bool $showAll + * @param bool $showFullpath + * @param bool $showSlug + * @param bool $showModular + * @param bool $limitLevels + * @return array + */ + public function getList(PageInterface $current = null, $level = 0, $rawRoutes = false, $showAll = true, $showFullpath = false, $showSlug = false, $showModular = false, $limitLevels = false) + { + if (!$current) { + if ($level) { + throw new \RuntimeException('Internal error'); + } + + $current = $this->root(); + } + + $list = []; + + if (!$current->root()) { + if ($rawRoutes) { + $route = $current->rawRoute(); + } else { + $route = $current->route(); + } + + if ($showFullpath) { + $option = $current->route(); + } else { + $extra = $showSlug ? '(' . $current->slug() . ') ' : ''; + $option = str_repeat('—-', $level). '▸ ' . $extra . $current->title(); + + + } + + $list[$route] = $option; + + + } + + if ($limitLevels === false || ($level+1 < $limitLevels)) { + foreach ($current->children() as $next) { + if ($showAll || $next->routable() || ($next->modular() && $showModular)) { + $list = array_merge($list, $this->getList($next, $level + 1, $rawRoutes, $showAll, $showFullpath, $showSlug, $showModular, $limitLevels)); + } + } + } + + return $list; + } + + /** + * Get available page types. + * + * @return Types + */ + public static function getTypes() + { + if (!self::$types) { + $grav = Grav::instance(); + + $scanBlueprintsAndTemplates = function () use ($grav) { + // Scan blueprints + $event = new Event(); + $event->types = self::$types; + $grav->fireEvent('onGetPageBlueprints', $event); + + self::$types->scanBlueprints('theme://blueprints/'); + + // Scan templates + $event = new Event(); + $event->types = self::$types; + $grav->fireEvent('onGetPageTemplates', $event); + + self::$types->scanTemplates('theme://templates/'); + }; + + if ($grav['config']->get('system.cache.enabled')) { + /** @var Cache $cache */ + $cache = $grav['cache']; + + // Use cached types if possible. + $types_cache_id = md5('types'); + self::$types = $cache->fetch($types_cache_id); + + if (!self::$types) { + self::$types = new Types(); + $scanBlueprintsAndTemplates(); + $cache->save($types_cache_id, self::$types); + } + + } else { + self::$types = new Types(); + $scanBlueprintsAndTemplates(); + } + + // Register custom paths to the locator. + $locator = $grav['locator']; + foreach (self::$types as $type => $paths) { + foreach ($paths as $k => $path) { + if (strpos($path, 'blueprints://') === 0) { + unset($paths[$k]); + } + } + if ($paths) { + $locator->addPath('blueprints', "pages/$type.yaml", $paths); + } + } + } + + return self::$types; + } + + /** + * Get available page types. + * + * @return array + */ + public static function types() + { + $types = self::getTypes(); + + return $types->pageSelect(); + } + + /** + * Get available page types. + * + * @return array + */ + public static function modularTypes() + { + $types = self::getTypes(); + + return $types->modularSelect(); + } + + /** + * Get template types based on page type (standard or modular) + * + * @return array + */ + public static function pageTypes() + { + if (isset(Grav::instance()['admin'])) { + /** @var Admin $admin */ + $admin = Grav::instance()['admin']; + + /** @var PageInterface $page */ + $page = $admin->getPage($admin->route); + + if ($page && $page->modular()) { + return static::modularTypes(); + } + + return static::types(); + } + + return []; + } + + /** + * Get access levels of the site pages + * + * @return array + */ + public function accessLevels() + { + $accessLevels = []; + foreach ($this->all() as $page) { + if (isset($page->header()->access)) { + if (\is_array($page->header()->access)) { + foreach ($page->header()->access as $index => $accessLevel) { + if (\is_array($accessLevel)) { + foreach ($accessLevel as $innerIndex => $innerAccessLevel) { + $accessLevels[] = $innerIndex; + } + } else { + $accessLevels[] = $index; + } + } + } else { + $accessLevels[] = $page->header()->access; + } + } + } + + return array_unique($accessLevels); + } + + /** + * Get available parents routes + * + * @return array + */ + public static function parents() + { + $rawRoutes = false; + + return self::getParents($rawRoutes); + } + + + + /** + * Gets the home route + * + * @return string + */ + public static function getHomeRoute() + { + if (empty(self::$home_route)) { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + /** @var Language $language */ + $language = $grav['language']; + + $home = $config->get('system.home.alias'); + + if ($language->enabled()) { + $home_aliases = $config->get('system.home.aliases'); + if ($home_aliases) { + $active = $language->getActive(); + $default = $language->getDefault(); + + try { + if ($active) { + $home = $home_aliases[$active]; + } else { + $home = $home_aliases[$default]; + } + } catch (ErrorException $e) { + $home = $home_aliases[$default]; + } + + } + } + + self::$home_route = trim($home, '/'); + } + + return self::$home_route; + } + + /** + * Needed for testing where we change the home route via config + */ + public static function resetHomeRoute() + { + self::$home_route = null; + return self::getHomeRoute(); + } + + /** + * Builds pages. + * + * @internal + */ + protected function buildPages() + { + $this->sort = []; + + /** @var Config $config */ + $config = $this->grav['config']; + + /** @var Language $language */ + $language = $this->grav['language']; + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + $pages_dir = $locator->findResource('page://'); + + // 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']; + + // how should we check for last modified? Default is by file + switch ($this->check_method) { + case 'none': + case 'off': + $hash = 0; + break; + case 'folder': + $hash = Folder::lastModifiedFolder($pages_dir); + break; + case 'hash': + $hash = Folder::hashAllFiles($pages_dir); + break; + default: + $hash = Folder::lastModifiedFile($pages_dir); + } + + $this->pages_cache_id = md5($pages_dir . $hash . $language->getActive() . $config->checksum()); + + $cached = $cache->fetch($this->pages_cache_id); + if ($cached) { + $this->grav['debugger']->addMessage('Page cache hit.'); + + list($this->instances, $this->routes, $this->children, $taxonomy_map, $this->sort) = $cached; + + // If pages was found in cache, set the taxonomy + $taxonomy->taxonomy($taxonomy_map); + } else { + $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..'); + + // recurse pages and cache result + $this->resetPages($pages_dir); + } + } else { + $this->recurse($pages_dir); + $this->buildRoutes(); + } + } + + /** + * Accessible method to manually reset the pages cache + * + * @param string $pages_dir + */ + public function resetPages($pages_dir) + { + $this->recurse($pages_dir); + $this->buildRoutes(); + + // cache if needed + if ($this->grav['config']->get('system.cache.enabled')) { + /** @var Cache $cache */ + $cache = $this->grav['cache']; + /** @var Taxonomy $taxonomy */ + $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]); + } + } + + /** + * Recursive function to load & build page relationships. + * + * @param string $directory + * @param PageInterface|null $parent + * + * @return PageInterface + * @throws \RuntimeException + * @internal + */ + protected function recurse($directory, PageInterface $parent = null) + { + $directory = rtrim($directory, DS); + $page = new Page; + + /** @var Config $config */ + $config = $this->grav['config']; + + /** @var Language $language */ + $language = $this->grav['language']; + + // Stuff to do at root page + // Fire event for memory and time consuming plugins... + if ($parent === null && $config->get('system.pages.events.page')) { + $this->grav->fireEvent('onBuildPagesInitialized'); + } + + $page->path($directory); + if ($parent) { + $page->parent($parent); + } + + $page->orderDir($config->get('system.pages.order.dir')); + $page->orderBy($config->get('system.pages.order.by')); + + // Add into instances + if (!isset($this->instances[$page->path()])) { + $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.'); + } + + // Build regular expression for all the allowed page extensions. + $page_extensions = $language->getFallbackPageExtensions(); + $regex = '/^[^\.]*(' . implode('|', array_map( + function ($str) { + return preg_quote($str, '/'); + }, + $page_extensions + )) . ')$/'; + + $folders = []; + $page_found = null; + $page_extension = '.md'; + $last_modified = 0; + + $iterator = new \FilesystemIterator($directory); + /** @var \FilesystemIterator $file */ + foreach ($iterator as $file) { + $filename = $file->getFilename(); + + // Ignore all hidden files if set. + if ($this->ignore_hidden && $filename && strpos($filename, '.') === 0) { + continue; + } + + // Handle folders later. + if ($file->isDir()) { + // But ignore all folders in ignore list. + 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)) { + continue; + } + + // Update last modified date to match the last updated file in the folder. + $modified = $file->getMTime(); + if ($modified > $last_modified) { + $last_modified = $modified; + } + + // Page is the one that matches to $page_extensions list with the lowest index number. + if (preg_match($regex, $filename, $matches, PREG_OFFSET_CAPTURE)) { + $ext = $matches[1][0]; + + if ($page_found === null || array_search($ext, $page_extensions, true) < array_search($page_extension, $page_extensions, true)) { + $page_found = $file; + $page_extension = $ext; + } + } + } + + $content_exists = false; + if ($parent && $page_found) { + $page->init($page_found, $page_extension); + + $content_exists = true; + + if ($config->get('system.pages.events.page')) { + $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page])); + } + } + + // Now handle all the folders under the page. + /** @var \FilesystemIterator $file */ + foreach ($folders as $file) { + $filename = $file->getFilename(); + + // if folder contains separator, continue + if (Utils::contains($file->getFilename(), $config->get('system.param_sep', ':'))) { + continue; + } + + if (!$page->path()) { + $page->path($file->getPath()); + } + + $path = $directory . DS . $filename; + $child = $this->recurse($path, $page); + + if (Utils::startsWith($filename, '_')) { + $child->routable(false); + } + + $this->children[$page->path()][$child->path()] = ['slug' => $child->slug()]; + + if ($config->get('system.pages.events.page')) { + $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page])); + } + } + + + if (!$content_exists) { + // Set routability to false if no page found + $page->routable(false); + + // Hide empty folders if option set + if ($config->get('system.pages.hide_empty_folders')) { + $page->visible(false); + } + } + + // Override the modified time if modular + if ($page->template() === 'modular') { + foreach ($page->collection() as $child) { + $modified = $child->modified(); + + if ($modified > $last_modified) { + $last_modified = $modified; + } + } + } + + // 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())); + + // Sort based on Defaults or Page Overridden sort order + $this->children[$page->path()] = $this->sort($page); + + return $page; + } + + /** + * @internal + */ + protected function buildRoutes() + { + /** @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); + + $route = $page->route(); + $raw_route = $page->rawRoute(); + $page_path = $page->path(); + + // add regular route + $this->routes[$route] = $page_path; + + // add raw route + if ($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 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; + if ($home && isset($this->routes[$homeRoute])) { + $this->routes['/'] = $this->routes[$homeRoute]; + $this->get($this->routes[$homeRoute])->route('/'); + } + } + + /** + * @param string $path + * @param array $pages + * @param string $order_by + * @param array|null $manual + * @param int|null $sort_flags + * + * @throws \RuntimeException + * @internal + */ + protected function buildSort($path, array $pages, $order_by = 'default', $manual = null, $sort_flags = null) + { + $list = []; + $header_default = null; + $header_query = []; + + // do this header query work only once + if (strpos($order_by, 'header.') === 0) { + $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; + if (!$child) { + throw new \RuntimeException("Page does not exist: {$key}"); + } + + 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] = basename($key); + break; + case 'folder': + $list[$key] = $child->folder(); + 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; + } + } + + if (!$sort_flags) { + $sort_flags = SORT_NATURAL | SORT_FLAG_CASE; + } + + // handle special case when order_by is random + if ($order_by === 'random') { + $list = $this->arrayShuffle($list); + } 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 + $col = Collator::create($locale); + if ($col) { + if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) { + $list = preg_replace_callback('~([0-9]+)\.~', function($number) { + return sprintf('%032d.', $number[0]); + }, $list); + + $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)) { + $new_list = []; + $i = count($manual); + + foreach ($list as $key => $dummy) { + $info = $pages[$key]; + $order = \array_search($info['slug'], $manual, true); + if ($order === false) { + $order = $i++; + } + $new_list[$key] = (int)$order; + } + + $list = $new_list; + + // Apply manual ordering to the list. + asort($list); + } + + foreach ($list as $key => $sort) { + $info = $pages[$key]; + $this->sort[$path][$order_by][$key] = $info; + } + } + + /** + * Shuffles an associative array + * + * @param array $list + * + * @return array + */ + protected function arrayShuffle($list) + { + $keys = array_keys($list); + shuffle($keys); + + $new = []; + foreach ($keys as $key) { + $new[$key] = $list[$key]; + } + + return $new; + } + + /** + * 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 + */ + public function getPagesCacheId() + { + return $this->pages_cache_id; + } +} diff --git a/system/src/Grav/Common/Page/Types.php b/system/src/Grav/Common/Page/Types.php new file mode 100644 index 0000000..03e3f6e --- /dev/null +++ b/system/src/Grav/Common/Page/Types.php @@ -0,0 +1,141 @@ +items[$type])) { + $this->items[$type] = []; + } elseif (!$blueprint) { + return; + } + + if (!$blueprint && $this->systemBlueprints) { + $blueprint = $this->systemBlueprints[$type] ?? $this->systemBlueprints['default']; + } + + if ($blueprint) { + array_unshift($this->items[$type], $blueprint); + } + } + + public function scanBlueprints($uri) + { + if (!\is_string($uri)) { + throw new \InvalidArgumentException('First parameter must be URI'); + } + + if (!$this->systemBlueprints) { + $this->systemBlueprints = $this->findBlueprints('blueprints://pages'); + + // Register default by default. + $this->register('default'); + + $this->register('external'); + } + + foreach ($this->findBlueprints($uri) as $type => $blueprint) { + $this->register($type, $blueprint); + } + } + + public function scanTemplates($uri) + { + if (!\is_string($uri)) { + throw new \InvalidArgumentException('First parameter must be URI'); + } + + $options = [ + 'compare' => 'Filename', + 'pattern' => '|\.html\.twig$|', + 'filters' => [ + 'value' => '|\.html\.twig$|' + ], + 'value' => 'Filename', + 'recursive' => false + ]; + + foreach (Folder::all($uri, $options) as $type) { + $this->register($type); + } + + $modular_uri = rtrim($uri, '/') . '/modular'; + if (is_dir($modular_uri)) { + foreach (Folder::all($modular_uri, $options) as $type) { + $this->register('modular/' . $type); + } + } + } + + public function pageSelect() + { + $list = []; + foreach ($this->items as $name => $file) { + if (strpos($name, '/')) { + continue; + } + $list[$name] = ucfirst(str_replace('_', ' ', $name)); + } + ksort($list); + + return $list; + } + + public function modularSelect() + { + $list = []; + foreach ($this->items as $name => $file) { + if (strpos($name, 'modular/') !== 0) { + continue; + } + $list[$name] = ucfirst(trim(str_replace('_', ' ', basename($name)))); + } + ksort($list); + + return $list; + } + + private function findBlueprints($uri) + { + $options = [ + 'compare' => 'Filename', + 'pattern' => '|\.yaml$|', + 'filters' => [ + 'key' => '|\.yaml$|' + ], + 'key' => 'SubPathName', + 'value' => 'PathName', + ]; + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($uri)) { + $options['value'] = 'Url'; + } + + return Folder::all($uri, $options); + } +} diff --git a/system/src/Grav/Common/Plugin.php b/system/src/Grav/Common/Plugin.php new file mode 100644 index 0000000..727fd95 --- /dev/null +++ b/system/src/Grav/Common/Plugin.php @@ -0,0 +1,390 @@ +name = $name; + $this->grav = $grav; + if ($config) { + $this->setConfig($config); + } + } + + /** + * @param Config $config + * @return $this + */ + public function setConfig(Config $config) + { + $this->config = $config; + + return $this; + } + + /** + * Get configuration of the plugin. + * + * @return array + */ + public function config() + { + return $this->config["plugins.{$this->name}"]; + } + + /** + * Determine if plugin is running under the admin + * + * @return bool + */ + public function isAdmin() + { + return Utils::isAdminPlugin(); + } + + /** + * Determine if plugin is running under the CLI + * + * @return bool + */ + public function isCli() + { + return \defined('GRAV_CLI'); + } + + /** + * Determine if this route is in Admin and active for the plugin + * + * @param string $plugin_route + * @return bool + */ + protected function isPluginActiveAdmin($plugin_route) + { + $should_run = false; + + $uri = $this->grav['uri']; + + if (strpos($uri->path(), $this->config->get('plugins.admin.route') . '/' . $plugin_route) === false) { + $should_run = false; + } elseif (isset($uri->paths()[1]) && $uri->paths()[1] === $plugin_route) { + $should_run = true; + } + + return $should_run; + } + + /** + * @param array $events + */ + protected function enable(array $events) + { + /** @var EventDispatcher $dispatcher */ + $dispatcher = $this->grav['events']; + + foreach ($events as $eventName => $params) { + if (\is_string($params)) { + $dispatcher->addListener($eventName, [$this, $params]); + } elseif (\is_string($params[0])) { + $dispatcher->addListener($eventName, [$this, $params[0]], $this->getPriority($params, $eventName)); + } else { + foreach ($params as $listener) { + $dispatcher->addListener($eventName, [$this, $listener[0]], $this->getPriority($listener, $eventName)); + } + } + } + } + + /** + * @param array $params + * @param string $eventName + */ + 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; + } + + /** + * @param array $events + */ + protected function disable(array $events) + { + /** @var EventDispatcher $dispatcher */ + $dispatcher = $this->grav['events']; + + foreach ($events as $eventName => $params) { + if (\is_string($params)) { + $dispatcher->removeListener($eventName, [$this, $params]); + } elseif (\is_string($params[0])) { + $dispatcher->removeListener($eventName, [$this, $params[0]]); + } else { + foreach ($params as $listener) { + $dispatcher->removeListener($eventName, [$this, $listener[0]]); + } + } + } + } + + /** + * Whether or not an offset exists. + * + * @param string $offset An offset to check for. + * @return bool Returns TRUE on success or FALSE on failure. + */ + public function offsetExists($offset) + { + $this->loadBlueprint(); + + if ($offset === 'title') { + $offset = 'name'; + } + return isset($this->blueprint[$offset]); + } + + /** + * Returns the value at specified offset. + * + * @param string $offset The offset to retrieve. + * @return mixed Can return all value types. + */ + public function offsetGet($offset) + { + $this->loadBlueprint(); + + if ($offset === 'title') { + $offset = 'name'; + } + return $this->blueprint[$offset] ?? null; + } + + /** + * Assigns a value to the specified offset. + * + * @param string $offset The offset to assign the value to. + * @param mixed $value The value to set. + * @throws \LogicException + */ + public function offsetSet($offset, $value) + { + throw new \LogicException(__CLASS__ . ' blueprints cannot be modified.'); + } + + /** + * Unsets an offset. + * + * @param string $offset The offset to unset. + * @throws \LogicException + */ + public function offsetUnset($offset) + { + throw new \LogicException(__CLASS__ . ' blueprints cannot be modified.'); + } + + /** + * This function will search a string for markdown links in a specific format. The link value can be + * optionally compared against via the $internal_regex and operated on by the callback $function + * provided. + * + * format: [plugin:myplugin_name](function_data) + * + * @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'; + + return preg_replace_callback($regex, $function, $content); + } + + /** + * Merge global and page configurations. + * + * @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') + { + $class_name = $this->name; + $class_name_merged = $class_name . '.merged'; + $defaults = $this->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)) { + // Overwrite enabled option with boolean value in page header + $config = ['enabled' => $config]; + } + // Merge page header settings using deep or shallow merging technique + $header = $this->mergeArrays($deep, $defaults, $config); + + // 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)); + } elseif (isset($page_header->{$class_name_merged})) { + $merged = $page_header->{$class_name_merged}; + $header = $merged->toArray(); + } + if (empty($header)) { + $header = $defaults; + } + // Merge additional parameter with configuration options + $header = $this->mergeArrays($deep, $header, $params); + + // Return configurations as a new data config class + return new Data($header); + } + + /** + * Merge arrays based on deepness + * + * @param string|bool $deep + * @param array $array1 + * @param array $array2 + * @return array + */ + private function mergeArrays($deep, $array1, $array2) + { + if ($deep === 'merge') { + return Utils::arrayMergeRecursiveUnique($array1, $array2); + } + if ($deep === true) { + return array_replace_recursive($array1, $array2); + } + + return array_merge($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. + * + * @return bool + */ + public static function saveConfig($plugin_name) + { + if (!$plugin_name) { + return false; + } + + $grav = Grav::instance(); + $locator = $grav['locator']; + $filename = 'config://plugins/' . $plugin_name . '.yaml'; + $file = YamlFile::instance($locator->findResource($filename, true, true)); + $content = $grav['config']->get('plugins.' . $plugin_name); + $file->save($content); + $file->free(); + + return true; + } + + /** + * Simpler getter for the plugin blueprint + * + * @return Blueprint + */ + public function getBlueprint() + { + if (!$this->blueprint) { + $this->loadBlueprint(); + } + return $this->blueprint; + } + + /** + * Load blueprints. + */ + protected function loadBlueprint() + { + if (!$this->blueprint) { + $grav = Grav::instance(); + $plugins = $grav['plugins']; + $this->blueprint = $plugins->get($this->name)->blueprints(); + } + } +} diff --git a/system/src/Grav/Common/Plugins.php b/system/src/Grav/Common/Plugins.php new file mode 100644 index 0000000..358727f --- /dev/null +++ b/system/src/Grav/Common/Plugins.php @@ -0,0 +1,228 @@ +getIterator('plugins://'); + + $plugins = []; + foreach($iterator as $directory) { + if (!$directory->isDir()) { + continue; + } + $plugins[] = $directory->getFilename(); + } + + natsort($plugins); + + foreach ($plugins as $plugin) { + $this->add($this->loadPlugin($plugin)); + } + } + + /** + * @return $this + */ + public function setup() + { + $blueprints = []; + $formFields = []; + + /** @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; + } + } + + if ($blueprints) { + // Order by priority. + arsort($blueprints); + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->addPath('blueprints', '', array_keys($blueprints), 'system/blueprints'); + } + + if ($formFields) { + // Order by priority. + arsort($formFields); + + $list = []; + foreach ($formFields as $className => $priority) { + $plugin = $this->items[$className]; + $list += $plugin->getFormFieldTypes(); + } + + $this->formFieldTypes = $list; + } + + return $this; + } + + /** + * Registers all plugins. + * + * @return Plugin[] array of Plugin objects + * @throws \RuntimeException + */ + public function init() + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + /** @var EventDispatcher $events */ + $events = $grav['events']; + + foreach ($this->items as $instance) { + // Register only enabled plugins. + if ($config["plugins.{$instance->name}.enabled"] && $instance instanceof Plugin) { + $instance->setConfig($config); + $events->addSubscriber($instance); + } + } + + return $this->items; + } + + /** + * Add a plugin + * + * @param Plugin $plugin + */ + public function add($plugin) + { + if (is_object($plugin)) { + $this->items[get_class($plugin)] = $plugin; + } + } + + /** + * Return list of all plugin data with their blueprints. + * + * @return array + */ + public static function all() + { + $grav = Grav::instance(); + $plugins = $grav['plugins']; + $list = []; + + foreach ($plugins as $instance) { + $name = $instance->name; + + try { + $result = self::get($name); + } catch (\Exception $e) { + $exception = new \RuntimeException(sprintf('Plugin %s: %s', $name, $e->getMessage()), $e->getCode(), $e); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage("Plugin {$name} cannot be loaded, please check Exceptions tab", 'error'); + $debugger->addException($exception); + + continue; + } + + if ($result) { + $list[$name] = $result; + } + } + + return $list; + } + + /** + * Get a plugin by name + * + * @param string $name + * + * @return Data|null + */ + public static function get($name) + { + $blueprints = new Blueprints('plugins://'); + $blueprint = $blueprints->get("{$name}/blueprints"); + + // Load default configuration. + $file = CompiledYamlFile::instance("plugins://{$name}/{$name}" . YAML_EXT); + + // ensure this is a valid plugin + if (!$file->exists()) { + return null; + } + + $obj = new Data((array)$file->content(), $blueprint); + + // Override with user configuration. + $obj->merge(Grav::instance()['config']->get('plugins.' . $name) ?: []); + + // Save configuration always to user/config. + $file = CompiledYamlFile::instance("config://plugins/{$name}.yaml"); + $obj->file($file); + + return $obj; + } + + protected function loadPlugin($name) + { + $grav = Grav::instance(); + $locator = $grav['locator']; + + $file = $locator->findResource('plugins://' . $name . DS . $name . PLUGIN_EXT); + + if (is_file($file)) { + // Local variables available in the file: $grav, $config, $name, $file + $class = include_once $file; + + $pluginClassFormat = [ + 'Grav\\Plugin\\' . ucfirst($name). 'Plugin', + 'Grav\\Plugin\\' . Inflector::camelize($name) . 'Plugin' + ]; + + foreach ($pluginClassFormat as $pluginClass) { + if (class_exists($pluginClass)) { + $class = new $pluginClass($name, $grav); + break; + } + } + } else { + $grav['log']->addWarning( + sprintf("Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clear-cache`", $name) + ); + return null; + } + + return $class; + } + +} diff --git a/system/src/Grav/Common/Processors/AssetsProcessor.php b/system/src/Grav/Common/Processors/AssetsProcessor.php new file mode 100644 index 0000000..8b46401 --- /dev/null +++ b/system/src/Grav/Common/Processors/AssetsProcessor.php @@ -0,0 +1,30 @@ +startTimer(); + $this->container['assets']->init(); + $this->container->fireEvent('onAssetsInitialized'); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/BackupsProcessor.php b/system/src/Grav/Common/Processors/BackupsProcessor.php new file mode 100644 index 0000000..d2a822c --- /dev/null +++ b/system/src/Grav/Common/Processors/BackupsProcessor.php @@ -0,0 +1,30 @@ +startTimer(); + $backups = $this->container['backups']; + $backups->init(); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/ConfigurationProcessor.php b/system/src/Grav/Common/Processors/ConfigurationProcessor.php new file mode 100644 index 0000000..83ceb84 --- /dev/null +++ b/system/src/Grav/Common/Processors/ConfigurationProcessor.php @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..11a39e8 --- /dev/null +++ b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..892af55 --- /dev/null +++ b/system/src/Grav/Common/Processors/DebuggerProcessor.php @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..b0ef6ca --- /dev/null +++ b/system/src/Grav/Common/Processors/ErrorsProcessor.php @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..fd97e9e --- /dev/null +++ b/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php @@ -0,0 +1,78 @@ +offsetGet('request'); + } + + /** + * @return Route + */ + public function getRoute(): Route + { + return $this->getRequest()->getAttribute('route'); + } + + /** + * @return RequestHandler + */ + public function getHandler(): RequestHandler + { + return $this->offsetGet('handler'); + } + + /** + * @return ResponseInterface|null + */ + public function getResponse(): ?ResponseInterface + { + return $this->offsetGet('response'); + } + + /** + * @param ResponseInterface $response + * @return $this + */ + public function setResponse(ResponseInterface $response): self + { + $this->offsetSet('response', $response); + $this->stopPropagation(); + + return $this; + } + + /** + * @param string $name + * @param MiddlewareInterface $middleware + * @return RequestHandlerEvent + */ + public function addMiddleware(string $name, MiddlewareInterface $middleware): self + { + /** @var RequestHandler $handler */ + $handler = $this['handler']; + $handler->addMiddleware($name, $middleware); + + return $this; + } +} diff --git a/system/src/Grav/Common/Processors/InitializeProcessor.php b/system/src/Grav/Common/Processors/InitializeProcessor.php new file mode 100644 index 0000000..eca7c54 --- /dev/null +++ b/system/src/Grav/Common/Processors/InitializeProcessor.php @@ -0,0 +1,128 @@ +processCli(); + } + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + { + $this->startTimer(); + + /** @var Config $config */ + $config = $this->container['config']; + $config->debug(); + + // Use output buffering to prevent headers from being sent too early. + ob_start(); + if ($config->get('system.cache.gzip') && !@ob_start('ob_gzhandler')) { + // Enable zip/deflate with a fallback in case of if browser does not support compressing. + ob_start(); + } + + // 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']; + + 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'); + } + } + + /** @var Uri $uri */ + $uri = $this->container['uri']; + $uri->init(); + + // 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, '/')) { + + $redirect = (string) $uri::getCurrentRoute()->toString(); + $this->container->redirect($redirect); + } + + $this->container->setLocale(); + $this->stopTimer(); + + return $handler->handle($request); + } + + public function processCli(): void + { + // Load configuration. + $this->container['config']->init(); + $this->container['plugins']->setup(); + + // Disable debugger. + $this->container['debugger']->enabled(false); + + // Set timezone, locale. + /** @var Config $config */ + $config = $this->container['config']; + $timezone = $config->get('system.timezone'); + if ($timezone) { + date_default_timezone_set($timezone); + } + $this->container->setLocale(); + + // Load plugins. + $this->container['plugins']->init(); + + // Initialize URI. + /** @var Uri $uri */ + $uri = $this->container['uri']; + $uri->init(); + + // Load accounts. + // TODO: remove in 2.0. + $this->container['accounts']; + } +} diff --git a/system/src/Grav/Common/Processors/LoggerProcessor.php b/system/src/Grav/Common/Processors/LoggerProcessor.php new file mode 100644 index 0000000..af3c169 --- /dev/null +++ b/system/src/Grav/Common/Processors/LoggerProcessor.php @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..ede22e5 --- /dev/null +++ b/system/src/Grav/Common/Processors/PagesProcessor.php @@ -0,0 +1,71 @@ +startTimer(); + + // Dump Cache state + $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']])); + + /** @var PageInterface $page */ + $page = $this->container['page']; + + if (!$page->routable()) { + // If no page found, fire event + $event = new Event(['page' => $page]); + $event->page = null; + $event = $this->container->fireEvent('onPageNotFound', $event); + + if (isset($event->page)) { + unset ($this->container['page']); + $this->container['page'] = $page = $event->page; + } else { + throw new \RuntimeException('Page Not Found', 404); + } + + $this->addMessage("Routed to page {$page->rawRoute()} (type: {$page->template()}) [Not Found fallback]"); + } else { + $this->addMessage("Routed to page {$page->rawRoute()} (type: {$page->template()})"); + + $task = $this->container['task']; + $action = $this->container['action']; + if ($task) { + $event = new Event(['task' => $task, 'page' => $page]); + $this->container->fireEvent('onPageTask', $event); + $this->container->fireEvent('onPageTask.' . $task, $event); + } elseif ($action) { + $event = new Event(['action' => $action, 'page' => $page]); + $this->container->fireEvent('onPageAction', $event); + $this->container->fireEvent('onPageAction.' . $action, $event); + } + } + + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/PluginsProcessor.php b/system/src/Grav/Common/Processors/PluginsProcessor.php new file mode 100644 index 0000000..9d2943b --- /dev/null +++ b/system/src/Grav/Common/Processors/PluginsProcessor.php @@ -0,0 +1,32 @@ +startTimer(); + // TODO: remove in 2.0. + $this->container['accounts']; + $this->container['plugins']->init(); + $this->container->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 new file mode 100644 index 0000000..5ce7bfd --- /dev/null +++ b/system/src/Grav/Common/Processors/ProcessorBase.php @@ -0,0 +1,48 @@ +container = $container; + } + + protected function startTimer($id = null, $title = null) + { + /** @var Debugger $debugger */ + $debugger = $this->container['debugger']; + $debugger->startTimer($id ?? $this->id, $title ?? $this->title); + } + + protected function stopTimer($id = null) + { + /** @var Debugger $debugger */ + $debugger = $this->container['debugger']; + $debugger->stopTimer($id ?? $this->id); + } + + protected function addMessage($message, $label = 'info', $isString = true) + { + /** @var Debugger $debugger */ + $debugger = $this->container['debugger']; + $debugger->addMessage($message, $label, $isString); + } +} diff --git a/system/src/Grav/Common/Processors/ProcessorInterface.php b/system/src/Grav/Common/Processors/ProcessorInterface.php new file mode 100644 index 0000000..a8e4aaa --- /dev/null +++ b/system/src/Grav/Common/Processors/ProcessorInterface.php @@ -0,0 +1,16 @@ +startTimer(); + + $container = $this->container; + $output = $container['output']; + + if ($output instanceof ResponseInterface) { + return $output; + } + + ob_start(); + + // Use internal Grav output. + $container->output = $output; + $container->fireEvent('onOutputGenerated'); + + echo $container->output; + + // remove any output + $container->output = ''; + + $this->container->fireEvent('onOutputRendered'); + + $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 new file mode 100644 index 0000000..97564e6 --- /dev/null +++ b/system/src/Grav/Common/Processors/RequestProcessor.php @@ -0,0 +1,54 @@ +startTimer(); + + $header = $request->getHeaderLine('Content-Type'); + $type = trim(strstr($header, ';', true) ?: $header); + if ($type === 'application/json') { + $request = $request->withParsedBody(json_decode($request->getBody()->getContents(), true)); + } + + $uri = $request->getUri(); + $ext = mb_strtolower(pathinfo($uri->getPath(), PATHINFO_EXTENSION)); + + $request = $request + ->withAttribute('grav', $this->container) + ->withAttribute('time', $_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME) + ->withAttribute('route', Uri::getCurrentRoute()->withExtension($ext)) + ->withAttribute('referrer', $this->container['uri']->referrer()); + + $event = new RequestHandlerEvent(['request' => $request, 'handler' => $handler]); + /** @var RequestHandlerEvent $event */ + $event = $this->container->fireEvent('onRequestHandlerInit', $event); + $response = $event->getResponse(); + $this->stopTimer(); + + if ($response) { + return $response; + } + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/SchedulerProcessor.php b/system/src/Grav/Common/Processors/SchedulerProcessor.php new file mode 100644 index 0000000..64f3fba --- /dev/null +++ b/system/src/Grav/Common/Processors/SchedulerProcessor.php @@ -0,0 +1,31 @@ +startTimer(); + $scheduler = $this->container['scheduler']; + $this->container->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler])); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/TasksProcessor.php b/system/src/Grav/Common/Processors/TasksProcessor.php new file mode 100644 index 0000000..aaf5cdf --- /dev/null +++ b/system/src/Grav/Common/Processors/TasksProcessor.php @@ -0,0 +1,61 @@ +startTimer(); + + $task = $this->container['task']; + $action = $this->container['action']; + if ($task || $action) { + $attributes = $request->getAttribute('controller'); + + $controllerClass = $attributes['class'] ?? null; + if ($controllerClass) { + /** @var RequestHandlerInterface $controller */ + $controller = new $controllerClass($attributes['path'] ?? '', $attributes['params'] ?? []); + try { + $response = $controller->handle($request); + + if ($response->getStatusCode() === 418) { + $response = $handler->handle($request); + } + + $this->stopTimer(); + + return $response; + + } catch (NotFoundException $e) { + // Task not found: Let it pass through. + } + } + + if ($task) { + $this->container->fireEvent('onTask.' . $task); + } elseif ($action) { + $this->container->fireEvent('onAction.' . $action); + } + } + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/ThemesProcessor.php b/system/src/Grav/Common/Processors/ThemesProcessor.php new file mode 100644 index 0000000..d1c6ae5 --- /dev/null +++ b/system/src/Grav/Common/Processors/ThemesProcessor.php @@ -0,0 +1,29 @@ +startTimer(); + $this->container['themes']->init(); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/TwigProcessor.php b/system/src/Grav/Common/Processors/TwigProcessor.php new file mode 100644 index 0000000..4ed247d --- /dev/null +++ b/system/src/Grav/Common/Processors/TwigProcessor.php @@ -0,0 +1,29 @@ +startTimer(); + $this->container['twig']->init(); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Scheduler/Cron.php b/system/src/Grav/Common/Scheduler/Cron.php new file mode 100644 index 0000000..3a42b29 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Cron.php @@ -0,0 +1,595 @@ + modified for Grav integration + * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @license MIT License; see LICENSE file for details. + */ + +namespace Grav\Common\Scheduler; + +/* + * Usage examples : + * ---------------- + * + * $cron = new Cron('10-30/5 12 * * *'); + * + * var_dump($cron->getMinutes()); + * // array(5) { + * // [0]=> int(10) + * // [1]=> int(15) + * // [2]=> int(20) + * // [3]=> int(25) + * // [4]=> int(30) + * // } + * + * var_dump($cron->getText('fr')); + * // string(32) "Chaque jour à 12:10,15,20,25,30" + * + * var_dump($cron->getText('en')); + * // string(30) "Every day at 12:10,15,20,25,30" + * + * var_dump($cron->getType()); + * // string(3) "day" + * + * var_dump($cron->getCronHours()); + * // string(2) "12" + * + * var_dump($cron->matchExact(new \DateTime('2012-07-01 13:25:10'))); + * // bool(false) + * + * var_dump($cron->matchExact(new \DateTime('2012-07-01 12:15:20'))); + * // bool(true) + * + * var_dump($cron->matchWithMargin(new \DateTime('2012-07-01 12:32:50'), -3, 5)); + * // bool(true) + */ +class Cron +{ + public const TYPE_UNDEFINED = ''; + public const TYPE_MINUTE = 'minute'; + public const TYPE_HOUR = 'hour'; + public const TYPE_DAY = 'day'; + public const TYPE_WEEK = 'week'; + public const TYPE_MONTH = 'month'; + public const TYPE_YEAR = 'year'; + /** + * + * @var array + */ + protected $texts = [ + 'fr' => [ + 'empty' => '-tout-', + 'name_minute' => 'minute', + 'name_hour' => 'heure', + 'name_day' => 'jour', + 'name_week' => 'semaine', + 'name_month' => 'mois', + 'name_year' => 'année', + 'text_period' => 'Chaque %s', + 'text_mins' => 'à %s minutes', + 'text_time' => 'à %s:%s', + 'text_dow' => 'le %s', + 'text_month' => 'de %s', + 'text_dom' => 'le %s', + 'weekdays' => ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'], + 'months' => ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'], + ], + 'en' => [ + 'empty' => '-all-', + 'name_minute' => 'minute', + 'name_hour' => 'hour', + 'name_day' => 'day', + 'name_week' => 'week', + 'name_month' => 'month', + 'name_year' => 'year', + 'text_period' => 'Every %s', + 'text_mins' => 'at %s minutes past the hour', + 'text_time' => 'at %s:%s', + 'text_dow' => 'on %s', + 'text_month' => 'of %s', + 'text_dom' => 'on the %s', + 'weekdays' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + 'months' => ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'], + ], + ]; + + /** + * min hour dom month dow + * @var string + */ + protected $cron = ''; + /** + * + * @var array + */ + protected $minutes = []; + /** + * + * @var array + */ + protected $hours = []; + /** + * + * @var array + */ + protected $months = []; + /** + * 0-7 : sunday, monday, ... saturday, sunday + * @var array + */ + protected $dow = []; + /** + * + * @var array + */ + protected $dom = []; + + /** + * + * @param string|null $cron + */ + public function __construct($cron = null) + { + if (null !== $cron) { + $this->setCron($cron); + } + } + + /** + * + * @return string + */ + public function getCron() + { + return implode(' ', [ + $this->getCronMinutes(), + $this->getCronHours(), + $this->getCronDaysOfMonth(), + $this->getCronMonths(), + $this->getCronDaysOfWeek(), + ]); + } + + /** + * + * @param string $lang 'fr' or 'en' + * @return string + */ + public function getText($lang) + { + // check lang + if (!isset($this->texts[$lang])) { + return $this->getCron(); + } + + $texts = $this->texts[$lang]; + // check type + + $type = $this->getType(); + if ($type === self::TYPE_UNDEFINED) { + return $this->getCron(); + } + + // init + $elements = []; + $elements[] = sprintf($texts['text_period'], $texts['name_' . $type]); + + // hour + if ($type === self::TYPE_HOUR) { + $elements[] = sprintf($texts['text_mins'], $this->getCronMinutes()); + } + + // week + if ($type === self::TYPE_WEEK) { + $dow = $this->getCronDaysOfWeek(); + foreach ($texts['weekdays'] as $i => $wd) { + $dow = str_replace((string) ($i + 1), $wd, $dow); + } + $elements[] = sprintf($texts['text_dow'], $dow); + } + + // month + year + if (\in_array($type, [self::TYPE_MONTH, self::TYPE_YEAR], true)) { + $elements[] = sprintf($texts['text_dom'], $this->getCronDaysOfMonth()); + } + + // year + if ($type === self::TYPE_YEAR) { + $months = $this->getCronMonths(); + for ($i = count($texts['months']) - 1; $i >= 0; $i--) { + $months = str_replace((string) ($i + 1), $texts['months'][$i], $months); + } + $elements[] = sprintf($texts['text_month'], $months); + } + + // day + week + month + year + 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()); + } + + return str_replace('*', $texts['empty'], implode(' ', $elements)); + } + + /** + * + * @return string + */ + public function getType() + { + $mask = preg_replace('/[^\* ]/', '-', $this->getCron()); + $mask = preg_replace('/-+/', '-', $mask); + $mask = preg_replace('/[^-\*]/', '', $mask); + + if ($mask === '*****') { + return self::TYPE_MINUTE; + } + + if ($mask === '-****') { + return self::TYPE_HOUR; + } + + if (substr($mask, -3) === '***') { + return self::TYPE_DAY; + } + + if (substr($mask, -3) === '-**') { + return self::TYPE_MONTH; + } + + if (substr($mask, -3) === '**-') { + return self::TYPE_WEEK; + } + + if (substr($mask, -2) === '-*') { + return self::TYPE_YEAR; + } + + return self::TYPE_UNDEFINED; + } + + /** + * + * @param string $cron + * @return Cron + */ + public function setCron($cron) + { + // sanitize + $cron = trim($cron); + $cron = preg_replace('/\s+/', ' ', $cron); + // explode + $elements = explode(' ', $cron); + if (\count($elements) !== 5) { + throw new \RuntimeException('Bad number of elements'); + } + + $this->cron = $cron; + $this->setMinutes($elements[0]); + $this->setHours($elements[1]); + $this->setDaysOfMonth($elements[2]); + $this->setMonths($elements[3]); + $this->setDaysOfWeek($elements[4]); + + return $this; + } + + /** + * + * @return string + */ + public function getCronMinutes() + { + return $this->arrayToCron($this->minutes); + } + + /** + * + * @return string + */ + public function getCronHours() + { + return $this->arrayToCron($this->hours); + } + + /** + * + * @return string + */ + public function getCronDaysOfMonth() + { + return $this->arrayToCron($this->dom); + } + + /** + * + * @return string + */ + public function getCronMonths() + { + return $this->arrayToCron($this->months); + } + + /** + * + * @return string + */ + public function getCronDaysOfWeek() + { + return $this->arrayToCron($this->dow); + } + + /** + * + * @return array + */ + public function getMinutes() + { + return $this->minutes; + } + + /** + * + * @return array + */ + public function getHours() + { + return $this->hours; + } + + /** + * + * @return array + */ + public function getDaysOfMonth() + { + return $this->dom; + } + + /** + * + * @return array + */ + public function getMonths() + { + return $this->months; + } + + /** + * + * @return array + */ + public function getDaysOfWeek() + { + return $this->dow; + } + + /** + * + * @param string|array $minutes + * @return Cron + */ + public function setMinutes($minutes) + { + $this->minutes = $this->cronToArray($minutes, 0, 59); + + return $this; + } + + /** + * + * @param string|array $hours + * @return Cron + */ + public function setHours($hours) + { + $this->hours = $this->cronToArray($hours, 0, 23); + + return $this; + } + + /** + * + * @param string|array $months + * @return Cron + */ + public function setMonths($months) + { + $this->months = $this->cronToArray($months, 1, 12); + + return $this; + } + + /** + * + * @param string|array $dow + * @return Cron + */ + public function setDaysOfWeek($dow) + { + $this->dow = $this->cronToArray($dow, 0, 7); + + return $this; + } + + /** + * + * @param string|array $dom + * @return Cron + */ + public function setDaysOfMonth($dom) + { + $this->dom = $this->cronToArray($dom, 1, 31); + + return $this; + } + + /** + * + * @param mixed $date + * @param int $min + * @param int $hour + * @param int $day + * @param int $month + * @param int $weekday + * @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)); + } + 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'); + } + + return new \DateTime($date->format('Y-m-d H:i:sP')); + } + + /** + * + * @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)) + ); + } + + /** + * + * @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 !'); + } + if ($minuteAfter < 0) { + throw new \RuntimeException('MinuteAfter parameter cannot be negative !'); + } + + $date = $this->parseDate($date, $min, $hour, $day, $month, $weekday); + $interval = new \DateInterval('PT1M'); // 1 min + if ($minuteBefore !== 0) { + $date->sub(new \DateInterval('PT' . abs($minuteBefore) . 'M')); + } + $n = $minuteAfter - $minuteBefore + 1; + for ($i = 0; $i < $n; $i++) { + if ($this->matchExact($date)) { + return true; + } + $date->add($interval); + } + + return false; + } + + /** + * + * @param array $array + * @return string + */ + protected function arrayToCron($array) + { + $n = \count($array); + if (!\is_array($array) || $n === 0) { + return '*'; + } + + $cron = [$array[0]]; + $s = $c = $array[0]; + for ($i = 1; $i < $n; $i++) { + if ($array[$i] == $c + 1) { + $c = $array[$i]; + $cron[\count($cron) - 1] = $s . '-' . $c; + } + else { + $s = $c = $array[$i]; + $cron[] = $c; + } + } + + return implode(',', $cron); + } + + /** + * + * @param array|string $string + * @param int $min + * @param int $max + * @return array + */ + protected function cronToArray($string, $min, $max) + { + $array = []; + if (\is_array($string)) { + foreach ($string as $val) { + if (is_numeric($val) && (int)$val == $val && $val >= $min && $val <= $max) { + $array[] = (int)$val; + } + } + } elseif ($string !== '*') { + while ($string !== '') { + // test "*/n" expression + if (preg_match('/^\*\/([0-9]+),?/', $string, $m)) { + for ($i = max(0, $min); $i <= min(59, $max); $i += $m[1]) { + $array[] = (int)$i; + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "a-b/n" expression + if (preg_match('/^([0-9]+)-([0-9]+)\/([0-9]+),?/', $string, $m)) { + for ($i = max($m[1], $min); $i <= min($m[2], $max); $i += $m[3]) { + $array[] = (int)$i; + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "a-b" expression + if (preg_match('/^([0-9]+)-([0-9]+),?/', $string, $m)) { + for ($i = max($m[1], $min); $i <= min($m[2], $max); $i++) { + $array[] = (int)$i; + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "c" expression + if (preg_match('/^([0-9]+),?/', $string, $m)) { + if ($m[1] >= $min && $m[1] <= $max) { + $array[] = (int)$m[1]; + } + $string = substr($string, strlen($m[0])); + continue; + } + + // something goes wrong in the expression + return []; + } + } + sort($array); + + return $array; + } +} diff --git a/system/src/Grav/Common/Scheduler/IntervalTrait.php b/system/src/Grav/Common/Scheduler/IntervalTrait.php new file mode 100644 index 0000000..b382c9d --- /dev/null +++ b/system/src/Grav/Common/Scheduler/IntervalTrait.php @@ -0,0 +1,399 @@ +at = $expression; + $this->executionTime = CronExpression::factory($expression); + + return $this; + } + + /** + * Set the execution time to every minute. + * + * @return self + */ + public function everyMinute() + { + return $this->at('* * * * *'); + } + + /** + * Set the execution time to every hour. + * + * @param int|string $minute + * @return self + */ + public function hourly($minute = 0) + { + $c = $this->validateCronSequence($minute); + + return $this->at("{$c['minute']} * * * *"); + } + + /** + * Set the execution time to once a day. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function daily($hour = 0, $minute = 0) + { + if (\is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = $parts[1] ?? '0'; + } + $c = $this->validateCronSequence($minute, $hour); + + return $this->at("{$c['minute']} {$c['hour']} * * *"); + } + + /** + * Set the execution time to once a week. + * + * @param int|string $weekday + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function weekly($weekday = 0, $hour = 0, $minute = 0) + { + if (\is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = $parts[1] ?? '0'; + } + $c = $this->validateCronSequence($minute, $hour, null, null, $weekday); + + return $this->at("{$c['minute']} {$c['hour']} * * {$c['weekday']}"); + } + + /** + * Set the execution time to once a month. + * + * @param int|string $month + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function monthly($month = '*', $day = 1, $hour = 0, $minute = 0) + { + if (\is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = $parts[1] ?? '0'; + } + $c = $this->validateCronSequence($minute, $hour, $day, $month); + + return $this->at("{$c['minute']} {$c['hour']} {$c['day']} {$c['month']} *"); + } + + /** + * Set the execution time to every Sunday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function sunday($hour = 0, $minute = 0) + { + return $this->weekly(0, $hour, $minute); + } + + /** + * Set the execution time to every Monday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function monday($hour = 0, $minute = 0) + { + return $this->weekly(1, $hour, $minute); + } + + /** + * Set the execution time to every Tuesday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function tuesday($hour = 0, $minute = 0) + { + return $this->weekly(2, $hour, $minute); + } + + /** + * Set the execution time to every Wednesday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function wednesday($hour = 0, $minute = 0) + { + return $this->weekly(3, $hour, $minute); + } + + /** + * Set the execution time to every Thursday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function thursday($hour = 0, $minute = 0) + { + return $this->weekly(4, $hour, $minute); + } + + /** + * Set the execution time to every Friday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function friday($hour = 0, $minute = 0) + { + return $this->weekly(5, $hour, $minute); + } + + /** + * Set the execution time to every Saturday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function saturday($hour = 0, $minute = 0) + { + return $this->weekly(6, $hour, $minute); + } + + /** + * Set the execution time to every January. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function january($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(1, $day, $hour, $minute); + } + + /** + * Set the execution time to every February. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function february($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(2, $day, $hour, $minute); + } + + /** + * Set the execution time to every March. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function march($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(3, $day, $hour, $minute); + } + + /** + * Set the execution time to every April. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function april($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(4, $day, $hour, $minute); + } + + /** + * Set the execution time to every May. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function may($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(5, $day, $hour, $minute); + } + + /** + * Set the execution time to every June. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function june($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(6, $day, $hour, $minute); + } + + /** + * Set the execution time to every July. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function july($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(7, $day, $hour, $minute); + } + + /** + * Set the execution time to every August. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function august($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(8, $day, $hour, $minute); + } + + /** + * Set the execution time to every September. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function september($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(9, $day, $hour, $minute); + } + + /** + * Set the execution time to every October. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function october($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(10, $day, $hour, $minute); + } + + /** + * Set the execution time to every November. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function november($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(11, $day, $hour, $minute); + } + + /** + * Set the execution time to every December. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function december($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(12, $day, $hour, $minute); + } + + /** + * 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 + * @return array + */ + private function validateCronSequence($minute = null, $hour = null, $day = null, $month = null, $weekday = null) + { + return [ + 'minute' => $this->validateCronRange($minute, 0, 59), + 'hour' => $this->validateCronRange($hour, 0, 23), + 'day' => $this->validateCronRange($day, 1, 31), + 'month' => $this->validateCronRange($month, 1, 12), + 'weekday' => $this->validateCronRange($weekday, 0, 6), + ]; + } + + /** + * Validate sequence of cron expression. + * + * @param int|string $value + * @param int $min + * @param int $max + * @return mixed + */ + private function validateCronRange($value, $min, $max) + { + if ($value === null || $value === '*') { + return '*'; + } + + if (! is_numeric($value) || + ! ($value >= $min && $value <= $max) + ) { + throw new \InvalidArgumentException( + "Invalid value: it should be '*' or between {$min} and {$max}." + ); + } + + return $value; + } +} + diff --git a/system/src/Grav/Common/Scheduler/Job.php b/system/src/Grav/Common/Scheduler/Job.php new file mode 100644 index 0000000..6250f1d --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Job.php @@ -0,0 +1,522 @@ +id = Grav::instance()['inflector']->hyphenize($id); + } else { + if (is_string($command)) { + $this->id = md5($command); + } else { + /* @var object $command */ + $this->id = spl_object_hash($command); + } + } + $this->creationTime = new \DateTime('now'); + // initialize the directory path for lock files + $this->tempDir = sys_get_temp_dir(); + $this->command = $command; + $this->args = $args; + // Set enabled state + $status = Grav::instance()['config']->get('scheduler.status'); + $this->enabled = !(isset($status[$id]) && $status[$id] === 'disabled'); + } + + /** + * Get the command + * + * @return string + */ + public function getCommand() + { + return $this->command; + } + + /** + * Get the cron 'at' syntax for this job + * + * @return string + */ + public function getAt() + { + return $this->at; + } + + /** + * Get the status of this job + * + * @return bool + */ + public function getEnabled() + { + return $this->enabled; + } + + /** + * Get optional arguments + * + * @return string|null + */ + public function getArguments() + { + if (\is_string($this->args)) { + return $this->args; + } + + return null; + } + + public function getCronExpression() + { + return CronExpression::factory($this->at); + } + + /** + * Get the status of the last run for this job + * + * @return bool + */ + public function isSuccessful() + { + return $this->successful; + } + + /** + * Get the Job id. + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * Check if the Job is due to run. + * It accepts as input a DateTime used to check if + * the job is due. Defaults to job creation time. + * It also default the execution time if not previously defined. + * + * @param \DateTime $date + * @return bool + */ + public function isDue(\DateTime $date = null) + { + // The execution time is being defaulted if not defined + if (!$this->executionTime) { + $this->at('* * * * *'); + } + + $date = $date ?? $this->creationTime; + + return $this->executionTime->isDue($date); + } + + /** + * Check if the Job is overlapping. + * + * @return bool + */ + public function isOverlapping() + { + return $this->lockFile && + file_exists($this->lockFile) && + call_user_func($this->whenOverlapping, filemtime($this->lockFile)) === false; + } + + /** + * Force the Job to run in foreground. + * + * @return $this + */ + public function inForeground() + { + $this->runInBackground = false; + + return $this; + } + + /** + * Sets/Gets an option backlink + * + * @param string $link + * + * @return null|string + */ + public function backlink($link = null) + { + if ($link) { + $this->backlink = $link; + } + return $this->backlink; + } + + + /** + * Check if the Job can run in background. + * + * @return bool + */ + public function runInBackground() + { + return !(is_callable($this->command) || $this->runInBackground === false); + } + + /** + * This will prevent the Job from overlapping. + * It prevents another instance of the same Job of + * 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 + * @return self + */ + public function onlyOne($tempDir = null, callable $whenOverlapping = null) + { + if ($tempDir === null || !is_dir($tempDir)) { + $tempDir = $this->tempDir; + } + $this->lockFile = implode('/', [ + trim($tempDir), + trim($this->id) . '.lock', + ]); + if ($whenOverlapping) { + $this->whenOverlapping = $whenOverlapping; + } else { + $this->whenOverlapping = function () { + return false; + }; + } + + return $this; + } + + /** + * Configure the job. + * + * @param array $config + * @return self + */ + public function configure(array $config = []) + { + // Check if config has defined a tempDir + if (isset($config['tempDir']) && is_dir($config['tempDir'])) { + $this->tempDir = $config['tempDir']; + } + + return $this; + } + + /** + * Truth test to define if the job should run if due. + * + * @param callable $fn + * @return self + */ + public function when(callable $fn) + { + $this->truthTest = $fn(); + + return $this; + } + + /** + * Run the job. + * + * @return bool + */ + public function run() + { + // If the truthTest failed, don't run + if ($this->truthTest !== true) { + return false; + } + + // If overlapping, don't run + if ($this->isOverlapping()) { + return false; + } + + // Write lock file if necessary + $this->createLockFile(); + + // Call before if required + if (is_callable($this->before)) { + call_user_func($this->before); + } + + // If command is callable... + if (is_callable($this->command)) { + $this->output = $this->exec(); + } else { + $args = \is_string($this->args) ? $this->args : implode(' ', $this->args); + $command = $this->command . ' ' . $args; + $process = new Process($command); + + $this->process = $process; + + if ($this->runInBackground()) { + $process->start(); + } else { + $process->run(); + $this->finalize(); + } + } + + return true; + } + + /** + * Finish up processing the job + * + * @return void + */ + public function finalize() + { + /** @var Process $process */ + $process = $this->process; + + if ($process) { + $process->wait(); + + if ($process->isSuccessful()) { + $this->successful = true; + $this->output = $process->getOutput(); + } else { + $this->successful = false; + $this->output = $process->getErrorOutput(); + } + + $this->postRun(); + + unset($this->process); + } + } + + /** + * Things to run after job has run + */ + 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); + } + } + + // Send output to email + $this->emailOutput(); + + // Call any callback defined + if (is_callable($this->after)) { + call_user_func($this->after, $this->output, $this->returnCode); + } + + $this->removeLockFile(); + } + + /** + * Create the job lock file. + * + * @param mixed $content + * @return void + */ + private function createLockFile($content = null) + { + if ($this->lockFile) { + if ($content === null || !\is_string($content)) { + $content = $this->getId(); + } + file_put_contents($this->lockFile, $content); + } + } + + /** + * Remove the job lock file. + * + * @return void + */ + private function removeLockFile() + { + if ($this->lockFile && file_exists($this->lockFile)) { + unlink($this->lockFile); + } + } + + /** + * Execute a callable job. + * + * @throws \RuntimeException + * @return string + */ + private function exec() + { + $return_data = ''; + ob_start(); + try { + $return_data = call_user_func_array($this->command, $this->args); + $this->successful = true; + } catch (\RuntimeException $e) { + $this->successful = false; + } + $this->output = ob_get_clean() . (is_string($return_data) ? $return_data : ''); + + $this->postRun(); + } + + /** + * Set the file/s where to write the output of the job. + * + * @param string|array $filename + * @param bool $append + * @return self + */ + public function output($filename, $append = false) + { + $this->outputTo = is_array($filename) ? $filename : [$filename]; + $this->outputMode = $append === false ? 'overwrite' : 'append'; + + return $this; + } + + /** + * Get the job output. + * + * @return mixed + */ + public function getOutput() + { + return $this->output; + } + + /** + * Set the emails where the output should be sent to. + * The Job should be set to write output to a file + * for this to work. + * + * @param string|array $email + * @return self + */ + public function email($email) + { + if (!is_string($email) && !is_array($email)) { + throw new \InvalidArgumentException('The email can be only string or array'); + } + + $this->emailTo = is_array($email) ? $email : [$email]; + // Force the job to run in foreground + $this->inForeground(); + + return $this; + } + + /** + * Email the output of the job, if any. + * + * @return bool + */ + private function emailOutput() + { + if (!count($this->outputTo) || !count($this->emailTo)) { + return false; + } + + if (is_callable('Grav\Plugin\Email\Utils::sendEmail')) { + $subject ='Grav Scheduled Job [' . $this->getId() . ']'; + $content = "

Output from Job ID: {$this->getId()}

\n

Command: {$this->getCommand()}


\n".$this->getOutput()."\n
"; + $to = $this->emailTo; + + \Grav\Plugin\Email\Utils::sendEmail($subject, $content, $to); + } + + return true; + } + + /** + * Set function to be called before job execution + * Job object is injected as a parameter to callable function. + * + * @param callable $fn + * @return self + */ + public function before(callable $fn) + { + $this->before = $fn; + + return $this; + } + + /** + * Set a function to be called after job execution. + * By default this will force the job to run in foreground + * because the output is injected as a parameter of this + * function, but it could be avoided by passing true as a + * second parameter. The job will run in background if it + * meets all the other criteria. + * + * @param callable $fn + * @param bool $runInBackground + * @return self + */ + public function then(callable $fn, $runInBackground = false) + { + $this->after = $fn; + // Force the job to run in foreground + if ($runInBackground === false) { + $this->inForeground(); + } + + return $this; + } +} + diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php new file mode 100644 index 0000000..df5602e --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -0,0 +1,361 @@ +get('scheduler.defaults', []); + $this->config = $config; + + $this->status_path = Grav::instance()['locator']->findResource('user-data://scheduler', true, true); + if (!file_exists($this->status_path)) { + Folder::create($this->status_path); + } + + } + + /** + * Load saved jobs from config/scheduler.yaml file + */ + public function loadSavedJobs() + { + $this->saved_jobs = []; + $saved_jobs = (array) Grav::instance()['config']->get('scheduler.custom_jobs', []); + + foreach ($saved_jobs as $id => $j) { + $args = $j['args'] ?? []; + $id = Grav::instance()['inflector']->hyphenize($id); + $job = $this->addCommand($j['command'], $args, $id); + + if (isset($j['at'])) { + $job->at($j['at']); + } + + if (isset($j['output'])) { + $mode = isset($j['output_mode']) && $j['output_mode'] === 'append' ? true : false; + $job->output($j['output'], $mode); + } + + if (isset($j['email'])) { + $job->email($j['email']); + } + + // store in saved_jobs + $this->saved_jobs[] = $job; + } + + return $this; + } + + /** + * Get the queued jobs as background/foreground + * + * @param bool $all + * @return array + */ + public function getQueuedJobs($all = false) + { + $background = []; + $foreground = []; + foreach ($this->jobs as $job) { + if ($all || $job->getEnabled()) { + if ($job->runInBackground()) { + $background[] = $job; + } else { + $foreground[] = $job; + } + } + + } + return [$background, $foreground]; + } + + /** + * Get all jobs if they are disabled or not as one array + * + * @return array + */ + public function getAllJobs() + { + list($background, $foreground) = $this->loadSavedJobs()->getQueuedJobs(true); + + return array_merge($background, $foreground); + } + + /** + * 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 + * @return Job + */ + public function addFunction(callable $fn, $args = [], $id = null) + { + $job = new Job($fn, $args, $id); + $this->queueJob($job->configure($this->config)); + + return $job; + } + + /** + * Queue a raw shell command. + * + * @param string $command The command to execute + * @param array $args Optional arguments to pass to the command + * @param string $id Optional custom identifier + * @return Job + */ + public function addCommand($command, $args = [], $id = null) + { + $job = new Job($command, $args, $id); + $this->queueJob($job->configure($this->config)); + + return $job; + } + + /** + * Run the scheduler. + * + * @param \DateTime|null $runTime Optional, run at specific moment + */ + public function run(\DateTime $runTime = null) + { + $this->loadSavedJobs(); + + list($background, $foreground) = $this->getQueuedJobs(false); + $alljobs = array_merge($background, $foreground); + + if (null === $runTime) { + $runTime = new \DateTime('now'); + } + + // Star processing jobs + foreach ($alljobs as $job) { + if ($job->isDue($runTime)) { + $job->run(); + $this->jobs_run[] = $job; + } + } + + // Finish handling any background jobs + foreach($background as $job) { + $job->finalize(); + } + + // Store states + $this->saveJobStates(); + } + + /** + * Reset all collected data of last run. + * + * Call before run() if you call run() multiple times. + */ + public function resetRun() + { + // Reset collected data of last run + $this->executed_jobs = []; + $this->failed_jobs = []; + $this->output_schedule = []; + + return $this; + } + + /** + * Get the scheduler verbose output. + * + * @param string $type Allowed: text, html, array + * @return mixed The return depends on the requested $type + */ + public function getVerboseOutput($type = 'text') + { + switch ($type) { + case 'text': + return implode("\n", $this->output_schedule); + case 'html': + return implode('
', $this->output_schedule); + case 'array': + return $this->output_schedule; + default: + throw new \InvalidArgumentException('Invalid output type'); + } + } + + /** + * Remove all queued Jobs. + */ + public function clearJobs() + { + $this->jobs = []; + + return $this; + } + + /** + * Helper to get the full Cron command + * + * @return string + */ + public function getCronCommand() + { + $phpBinaryFinder = new PhpExecutableFinder(); + $php = $phpBinaryFinder->find(); + $command = 'cd ' . str_replace(' ', '\ ', GRAV_ROOT) . ';' . $php . ' bin/grav scheduler'; + + return "(crontab -l; echo \"* * * * * {$command} 1>> /dev/null 2>&1\") | crontab -"; + } + + /** + * Helper to determine if cron job is setup + * + * @return int + */ + public function isCrontabSetup() + { + $process = new Process('crontab -l'); + $process->run(); + + if ($process->isSuccessful()) { + $output = $process->getOutput(); + + return preg_match('$bin\/grav schedule$', $output) ? 1 : 0; + } + + $error = $process->getErrorOutput(); + + return Utils::startsWith($error, 'crontab: no crontab') ? 0 : 2; + } + + /** + * Get the Job states file + * + * @return \RocketTheme\Toolbox\File\FileInterface|YamlFile + */ + public function getJobStates() + { + return YamlFile::instance($this->status_path . '/status.yaml'); + } + + /** + * Save job states to statys file + */ + private function saveJobStates() + { + $now = time(); + $new_states = []; + + foreach ($this->jobs_run as $job) { + if ($job->isSuccessful()) { + $new_states[$job->getId()] = ['state' => 'success', 'last-run' => $now]; + $this->pushExecutedJob($job); + } else { + $new_states[$job->getId()] = ['state' => 'failure', 'last-run' => $now, 'error' => $job->getOutput()]; + $this->pushFailedJob($job); + } + } + + $saved_states = $this->getJobStates(); + $saved_states->save(array_merge($saved_states->content(), $new_states)); + } + + /** + * Queue a job for execution in the correct queue. + * + * @param Job $job + * @return void + */ + private function queueJob(Job $job) + { + $this->jobs[] = $job; + + // Store jobs + } + + /** + * Add an entry to the scheduler verbose output array. + * + * @param string $string + * @return void + */ + private function addSchedulerVerboseOutput($string) + { + $now = '[' . (new \DateTime('now'))->format('c') . '] '; + $this->output_schedule[] = $now . $string; + // Print to stdoutput in light gray + // echo "\033[37m{$string}\033[0m\n"; + } + + /** + * Push a succesfully executed job. + * + * @param Job $job + * @return Job + */ + private function pushExecutedJob(Job $job) + { + $this->executed_jobs[] = $job; + $command = $job->getCommand(); + $args = $job->getArguments(); + // If callable, log the string Closure + if (is_callable($command)) { + $command = \is_string($command) ? $command : 'Closure'; + } + $this->addSchedulerVerboseOutput("Success: {$command} {$args}"); + + return $job; + } + + /** + * Push a failed job. + * + * @param Job $job + * @return Job + */ + private function pushFailedJob(Job $job) + { + $this->failed_jobs[] = $job; + $command = $job->getCommand(); + // If callable, log the string Closure + if (is_callable($command)) { + $command = \is_string($command) ? $command : 'Closure'; + } + $output = trim($job->getOutput()); + $this->addSchedulerVerboseOutput("Error: {$command}{$output}"); + + return $job; + } +} diff --git a/system/src/Grav/Common/Security.php b/system/src/Grav/Common/Security.php new file mode 100644 index 0000000..0f61097 --- /dev/null +++ b/system/src/Grav/Common/Security.php @@ -0,0 +1,165 @@ +routes(); + + // Remove duplicate for homepage + unset($routes['/']); + + $list = []; + + // This needs Symfony 4.1 to work + $status && $status([ + 'type' => 'count', + 'steps' => count($routes), + ]); + + foreach ($routes as $path) { + + $status && $status([ + 'type' => 'progress', + ]); + + try { + $page = $pages->get($path); + + // call the content to load/cache it + $header = (array) $page->header(); + $content = $page->value('content'); + + $data = ['header' => $header, 'content' => $content]; + $results = Security::detectXssFromArray($data); + + if (!empty($results)) { + if ($route) { + $list[$page->route()] = $results; + } else { + $list[$page->filePathClean()] = $results; + } + + } + + } catch (\Exception $e) { + continue; + } + } + + return $list; + } + + /** + * @param array $array Array such as $_POST or $_GET + * @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 = '') + { + $list = []; + + foreach ($array as $key => $value) { + if (\is_array($value)) { + $list[] = static::detectXssFromArray($value, $prefix . $key . '.'); + } + if ($result = static::detectXss($value)) { + $list[] = [$prefix . $key => $result]; + } + } + + if (!empty($list)) { + return array_merge(...$list); + } + + return $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. + * + * Copies the code from: https://github.com/symphonycms/xssfilter/blob/master/extension.driver.php#L138 + */ + public static function detectXss($string) + { + // Skip any null or non string values + if (null === $string || !\is_string($string) || empty($string)) { + return false; + } + + // Keep a copy of the original string before cleaning up + $orig = $string; + + // URL decode + $string = urldecode($string); + + // Convert Hexadecimals + $string = (string)preg_replace_callback('!(&#|\\\)[xX]([0-9a-fA-F]+);?!u', function($m) { + return \chr(hexdec($m[2])); + }, $string); + + // Clean up entities + $string = preg_replace('!(�+[0-9]+)!u','$1;', $string); + + // Decode entities + $string = html_entity_decode($string, ENT_NOQUOTES, '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'); + + // 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', + + // Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols + 'invalid_protocols' => '#(' . implode('|', $invalid_protocols) . '):.*?#iUu', + + // Match -moz-bindings + 'moz_binding' => '#-moz-binding[a-z\x00-\x20]*:#u', + + // Match style attributes + 'html_inline_styles' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(style=[^>]*(url\:|x\:expression).*)>?#iUu', + + // Match potentially dangerous tags + '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)) { + return $name; + } + + } + } + + return false; + } +} diff --git a/system/src/Grav/Common/Service/AccountsServiceProvider.php b/system/src/Grav/Common/Service/AccountsServiceProvider.php new file mode 100644 index 0000000..e0778be --- /dev/null +++ b/system/src/Grav/Common/Service/AccountsServiceProvider.php @@ -0,0 +1,129 @@ +get('system.accounts.type', 'data')); + if ($type === 'flex') { + /** @var Debugger $debugger */ + $debugger = $container['debugger']; + $debugger->addMessage('User Accounts: Flex Directory'); + return $this->flexAccounts($container); + } + + return $this->dataAccounts($container); + }; + + $container['users'] = $container->factory(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) + { + if (!defined('GRAV_USER_INSTANCE')) { + define('GRAV_USER_INSTANCE', 'DATA'); + } + + // Use User class for backwards compatibility. + return new DataUser\UserCollection(User::class); + } + + protected function flexAccounts(Container $container) + { + if (!defined('GRAV_USER_INSTANCE')) { + define('GRAV_USER_INSTANCE', 'FLEX'); + } + + /** @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(); + } + + protected function getFlexStorage($config) + { + if (\is_array($config)) { + return $config; + } + + 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 + ], + ]; + } + + return [ + 'class' => FlexUser\Storage\UserFileStorage::class, + 'options' => [ + 'formatter' => ['class' => YamlFormatter::class], + 'folder' => 'account://', + 'pattern' => '{FOLDER}/{KEY}.yaml', + 'key' => 'storage_key', + 'indexed' => true + ], + ]; + } +} diff --git a/system/src/Grav/Common/Service/AssetsServiceProvider.php b/system/src/Grav/Common/Service/AssetsServiceProvider.php new file mode 100644 index 0000000..004da9c --- /dev/null +++ b/system/src/Grav/Common/Service/AssetsServiceProvider.php @@ -0,0 +1,24 @@ +setup(); + + return $backups; + }; + } +} diff --git a/system/src/Grav/Common/Service/ConfigServiceProvider.php b/system/src/Grav/Common/Service/ConfigServiceProvider.php new file mode 100644 index 0000000..c874441 --- /dev/null +++ b/system/src/Grav/Common/Service/ConfigServiceProvider.php @@ -0,0 +1,172 @@ +init(); + + return $setup; + }; + + $container['blueprints'] = function ($c) { + return static::blueprints($c); + }; + + $container['config'] = function ($c) { + $config = static::load($c); + + // After configuration has been loaded, we can disable YAML compatibility if strict mode has been enabled. + if (!$config->get('system.strict_mode.yaml_compat', true)) { + YamlFile::globalSettings(['compat' => false, 'native' => true]); + } + + return $config; + }; + + $container['languages'] = function ($c) { + return static::languages($c); + }; + + $container['language'] = function ($c) { + return new Language($c); + }; + } + + public static function blueprints(Container $container) + { + /** Setup $setup */ + $setup = $container['setup']; + + /** @var UniformResourceLocator $locator */ + $locator = $container['locator']; + + $cache = $locator->findResource('cache://compiled/blueprints', true, true); + + $files = []; + $paths = $locator->findResources('blueprints://config'); + $files += (new ConfigFileFinder)->locateFiles($paths); + $paths = $locator->findResources('plugins://'); + $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'blueprints'); + + $blueprints = new CompiledBlueprints($cache, $files, GRAV_ROOT); + + return $blueprints->name("master-{$setup->environment}")->load(); + } + + /** + * @param Container $container + * @return Config + */ + public static function load(Container $container) + { + /** Setup $setup */ + $setup = $container['setup']; + + /** @var UniformResourceLocator $locator */ + $locator = $container['locator']; + + $cache = $locator->findResource('cache://compiled/config', true, true); + + $files = []; + $paths = $locator->findResources('config://'); + $files += (new ConfigFileFinder)->locateFiles($paths); + $paths = $locator->findResources('plugins://'); + $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths); + + $compiled = new CompiledConfig($cache, $files, GRAV_ROOT); + $compiled->setBlueprints(function() use ($container) { + return $container['blueprints']; + }); + + $config = $compiled->name("master-{$setup->environment}")->load(); + $config->environment = $setup->environment; + + return $config; + } + + public static function languages(Container $container) + { + /** @var Setup $setup */ + $setup = $container['setup']; + + /** @var Config $config */ + $config = $container['config']; + + /** @var UniformResourceLocator $locator */ + $locator = $container['locator']; + + $cache = $locator->findResource('cache://compiled/languages', true, true); + $files = []; + + // Process languages only if enabled in configuration. + if ($config->get('system.languages.translations', true)) { + $paths = $locator->findResources('languages://'); + $files += (new ConfigFileFinder)->locateFiles($paths); + $paths = $locator->findResources('plugins://'); + $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'languages'); + $paths = static::pluginFolderPaths($paths, 'languages'); + $files += (new ConfigFileFinder)->locateFiles($paths); + } + + $languages = new CompiledLanguages($cache, $files, GRAV_ROOT); + + return $languages->name("master-{$setup->environment}")->load(); + } + + /** + * Find specific paths in plugins + * + * @param array $plugins + * @param string $folder_path + * @return array + */ + private static function pluginFolderPaths($plugins, $folder_path) + { + $paths = []; + + foreach ($plugins as $path) { + $iterator = new \DirectoryIterator($path); + + /** @var \DirectoryIterator $directory */ + foreach ($iterator as $directory) { + if (!$directory->isDir() || $directory->isDot()) { + continue; + } + + // Path to the languages folder + $lang_path = $directory->getPathName() . '/' . $folder_path; + + // If this folder exists, add it to the list of paths + if (file_exists($lang_path)) { + $paths []= $lang_path; + } + } + } + return $paths; + } + +} diff --git a/system/src/Grav/Common/Service/ErrorServiceProvider.php b/system/src/Grav/Common/Service/ErrorServiceProvider.php new file mode 100644 index 0000000..fce69ff --- /dev/null +++ b/system/src/Grav/Common/Service/ErrorServiceProvider.php @@ -0,0 +1,22 @@ +findResource('log://grav.log', true, true); + $log->pushHandler(new StreamHandler($log_file, Logger::DEBUG)); + + return $log; + }; + } +} diff --git a/system/src/Grav/Common/Service/OutputServiceProvider.php b/system/src/Grav/Common/Service/OutputServiceProvider.php new file mode 100644 index 0000000..a9b2877 --- /dev/null +++ b/system/src/Grav/Common/Service/OutputServiceProvider.php @@ -0,0 +1,31 @@ +processSite($page->templateFormat()); + }; + } +} diff --git a/system/src/Grav/Common/Service/PagesServiceProvider.php b/system/src/Grav/Common/Service/PagesServiceProvider.php new file mode 100644 index 0000000..f425b6d --- /dev/null +++ b/system/src/Grav/Common/Service/PagesServiceProvider.php @@ -0,0 +1,117 @@ +findResource('system://pages/notfound.md'); + $page = new Page(); + $page->init(new \SplFileInfo($path)); + $page->routable(false); + + return $page; + }; + + return; + } + + $container['page'] = function ($c) { + /** @var Grav $c */ + + /** @var Pages $pages */ + $pages = $c['pages']; + + /** @var Config $config */ + $config = $c['config']; + + /** @var Uri $uri */ + $uri = $c['uri']; + + $path = $uri->path() ?: '/'; // Don't trim to support trailing slash default routes + $page = $pages->dispatch($path); + + // Redirection tests + if ($page) { + // some debugger override logic + if ($page->debugger() === false) { + $c['debugger']->enabled(false); + } + + if ($config->get('system.force_ssl')) { + if (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') { + $url = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $c->redirect($url); + } + } + + $url = $pages->route($page->route()); + + 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 = $c['language']; + + // 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 page is not found, try some fallback stuff + if (!$page || !$page->routable()) { + + // Try fallback URL stuff... + $page = $c->fallbackUrl($path); + + if (!$page) { + $path = $c['locator']->findResource('system://pages/notfound.md'); + $page = new Page(); + $page->init(new \SplFileInfo($path)); + $page->routable(false); + } + } + + return $page; + }; + } +} diff --git a/system/src/Grav/Common/Service/RequestServiceProvider.php b/system/src/Grav/Common/Service/RequestServiceProvider.php new file mode 100644 index 0000000..6410ef7 --- /dev/null +++ b/system/src/Grav/Common/Service/RequestServiceProvider.php @@ -0,0 +1,38 @@ +fromGlobals(); + }; + + $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 new file mode 100644 index 0000000..1df4bbc --- /dev/null +++ b/system/src/Grav/Common/Service/SchedulerServiceProvider.php @@ -0,0 +1,24 @@ +get('system.session.enabled', false); + $cookie_secure = (bool)$config->get('system.session.secure', false); + $cookie_httponly = (bool)$config->get('system.session.httponly', true); + $cookie_lifetime = (int)$config->get('system.session.timeout', 1800); + $cookie_path = $config->get('system.session.path'); + 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')) { + $admin_base = '/' . trim($config->get('plugins.admin.route'), '/'); + + // Uri::route() is not processed yet, let's quickly get what we need. + $current_route = str_replace(Uri::filterPath($uri->rootUrl(false)), '', parse_url($uri->url(true), PHP_URL_PATH)); + + // Test to see if path starts with a supported language + admin base + $lang = Utils::pathPrefixedByLangCode($current_route); + $lang_admin_base = '/' . $lang . $admin_base; + + // Check no language, simple language prefix (en) and region specific language prefix (en-US). + if (Utils::startsWith($current_route, $admin_base) || Utils::startsWith($current_route, $lang_admin_base)) { + $cookie_lifetime = $config->get('plugins.admin.session.timeout', 1800); + $enabled = $is_admin = true; + } + } + + // Fix for HUGE session timeouts. + if ($cookie_lifetime > 99999999999) { + $cookie_lifetime = 9999999999; + } + + $session_prefix = $c['inflector']->hyphenize($config->get('system.session.name', 'grav-site')); + $session_uniqueness = $config->get('system.session.uniqueness', 'path') === 'path' ? substr(md5(GRAV_ROOT), 0, 7) : md5($config->get('security.salt')); + + $session_name = $session_prefix . '-' . $session_uniqueness; + + if ($is_admin && $config->get('system.session.split', true)) { + $session_name .= '-admin'; + } + + // Define session service. + $options = [ + 'name' => $session_name, + 'cookie_lifetime' => $cookie_lifetime, + 'cookie_path' => $cookie_path, + 'cookie_domain' => $cookie_domain, + 'cookie_secure' => $cookie_secure, + 'cookie_httponly' => $cookie_httponly + ] + (array) $config->get('system.session.options'); + + $session = new Session($options); + $session->setAutoStart($enabled); + + return $session; + }; + + // Define session message service. + $container['messages'] = function ($c) { + if (!isset($c['session']) || !$c['session']->isStarted()) { + /** @var Debugger $debugger */ + $debugger = $c['debugger']; + $debugger->addMessage('Inactive session: session messages may disappear', 'warming'); + + return new Message; + } + + /** @var Session $session */ + $session = $c['session']; + + if (!isset($session->messages)) { + $session->messages = new Message; + } + + return $session->messages; + }; + } +} diff --git a/system/src/Grav/Common/Service/StreamsServiceProvider.php b/system/src/Grav/Common/Service/StreamsServiceProvider.php new file mode 100644 index 0000000..3bbbea1 --- /dev/null +++ b/system/src/Grav/Common/Service/StreamsServiceProvider.php @@ -0,0 +1,48 @@ +initializeLocator($locator); + + return $locator; + }; + + $container['streams'] = function(Container $container) { + /** @var Setup $setup */ + $setup = $container['setup']; + + /** @var UniformResourceLocator $locator */ + $locator = $container['locator']; + + // Set locator to both streams. + Stream::setLocator($locator); + ReadOnlyStream::setLocator($locator); + + return new StreamBuilder($setup->getStreams()); + }; + } +} diff --git a/system/src/Grav/Common/Service/TaskServiceProvider.php b/system/src/Grav/Common/Service/TaskServiceProvider.php new file mode 100644 index 0000000..c22121a --- /dev/null +++ b/system/src/Grav/Common/Service/TaskServiceProvider.php @@ -0,0 +1,38 @@ +param('task'); + if (null !== $task) { + $task = filter_var($task, FILTER_SANITIZE_STRING); + } + + return $task ?: null; + }; + + $container['action'] = function (Grav $c) { + $action = $_POST['action'] ?? $c['uri']->param('action'); + if (null !== $action) { + $action = filter_var($action, FILTER_SANITIZE_STRING); + } + + return $action ?: null; + }; + } +} diff --git a/system/src/Grav/Common/Session.php b/system/src/Grav/Common/Session.php new file mode 100644 index 0000000..cd9b2c1 --- /dev/null +++ b/system/src/Grav/Common/Session.php @@ -0,0 +1,166 @@ +getInstance() method instead. + */ + public static function instance() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getInstance() method instead', E_USER_DEPRECATED); + + return static::getInstance(); + } + + /** + * Initialize session. + * + * Code in this function has been moved into SessionServiceProvider class. + */ + public function init() + { + if ($this->autoStart && !$this->isStarted()) { + $this->start(); + + $this->autoStart = false; + } + } + + /** + * @param bool $auto + * @return $this + */ + public function setAutoStart($auto) + { + $this->autoStart = (bool)$auto; + + return $this; + } + + /** + * Returns attributes. + * + * @return array Attributes + * @deprecated 1.5 Use ->getAll() method instead. + */ + public function all() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getAll() method instead', E_USER_DEPRECATED); + + return $this->getAll(); + } + + /** + * Checks if the session was started. + * + * @return Boolean + * @deprecated 1.5 Use ->isStarted() method instead. + */ + public function started() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->isStarted() method instead', E_USER_DEPRECATED); + + return $this->isStarted(); + } + + /** + * Store something in session temporarily. + * + * @param string $name + * @param mixed $object + * @return $this + */ + public function setFlashObject($name, $object) + { + $this->__set($name, serialize($object)); + + return $this; + } + + /** + * Return object and remove it from session. + * + * @param string $name + * @return mixed + */ + public function getFlashObject($name) + { + $serialized = $this->__get($name); + + $object = \is_string($serialized) ? unserialize($serialized, ['allowed_classes' => true]) : $serialized; + + $this->__unset($name); + + if ($name === 'files-upload') { + $grav = Grav::instance(); + + // Make sure that Forms 3.0+ has been installed. + if (null === $object && isset($grav['forms'])) { + user_error( + __CLASS__ . '::' . __FUNCTION__ . '(\'files-upload\') is deprecated since Grav 1.6, use $form->getFlash()->getLegacyFiles() instead', + E_USER_DEPRECATED + ); + + /** @var Uri $uri */ + $uri = $grav['uri']; + /** @var Grav\Plugin\Form\Forms $form */ + $form = $grav['forms']->getActiveForm(); + + $sessionField = base64_encode($uri->url); + + /** @var FormFlash $flash */ + $flash = $form ? $form->getFlash() : null; + $object = $flash ? [$sessionField => $flash->getLegacyFiles()] : null; + } + } + + return $object; + } + + /** + * Store something in cookie temporarily. + * + * @param string $name + * @param mixed $object + * @param int $time + * @return $this + */ + public function setFlashCookieObject($name, $object, $time = 60) + { + setcookie($name, json_encode($object), time() + $time, '/'); + + return $this; + } + + /** + * Return object and remove it from the cookie. + * + * @param string $name + * @return mixed|null + */ + public function getFlashCookieObject($name) + { + if (isset($_COOKIE[$name])) { + $object = json_decode($_COOKIE[$name]); + setcookie($name, '', time() - 3600, '/'); + return $object; + } + + return null; + } +} diff --git a/system/src/Grav/Common/Taxonomy.php b/system/src/Grav/Common/Taxonomy.php new file mode 100644 index 0000000..e323647 --- /dev/null +++ b/system/src/Grav/Common/Taxonomy.php @@ -0,0 +1,144 @@ +taxonomy_map = []; + $this->grav = $grav; + } + + /** + * Takes an individual page and processes the taxonomies configured in its header. It + * then adds those taxonomies to the map + * + * @param PageInterface $page the page to process + * @param array $page_taxonomy + */ + public function addTaxonomy(PageInterface $page, $page_taxonomy = null) + { + if (!$page_taxonomy) { + $page_taxonomy = $page->taxonomy(); + } + + if (empty($page_taxonomy) || !$page->published()) { + 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()]; + } + } + } + } + } + + /** + * Returns a new Page object with the sub-pages containing all the values set for a + * particular taxonomy. + * + * @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') + { + $matches = []; + $results = []; + + foreach ((array)$taxonomies as $taxonomy => $items) { + foreach ((array)$items as $item) { + if (isset($this->taxonomy_map[$taxonomy][$item])) { + $matches[] = $this->taxonomy_map[$taxonomy][$item]; + } else { + $matches[] = []; + } + } + } + + if (strtolower($operator) === 'or') { + foreach ($matches as $match) { + $results = array_merge($results, $match); + } + } else { + $results = $matches ? array_pop($matches) : []; + foreach ($matches as $match) { + $results = array_intersect_key($results, $match); + } + } + + return new Collection($results, ['taxonomies' => $taxonomies]); + } + + /** + * Gets and Sets the taxonomy map + * + * @param array $var the taxonomy map + * + * @return array the taxonomy map + */ + public function taxonomy($var = null) + { + if ($var) { + $this->taxonomy_map = $var; + } + + return $this->taxonomy_map; + } + + /** + * Gets item keys per taxonomy + * + * @param string $taxonomy taxonomy name + * + * @return array keys of this taxonomy + */ + public function getTaxonomyItemKeys($taxonomy) + { + return isset($this->taxonomy_map[$taxonomy]) ? array_keys($this->taxonomy_map[$taxonomy]) : []; + } +} diff --git a/system/src/Grav/Common/Theme.php b/system/src/Grav/Common/Theme.php new file mode 100644 index 0000000..dbc589f --- /dev/null +++ b/system/src/Grav/Common/Theme.php @@ -0,0 +1,96 @@ +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 + */ + public static function saveConfig($theme_name) + { + if (!$theme_name) { + return false; + } + + $grav = Grav::instance(); + $locator = $grav['locator']; + $filename = 'config://themes/' . $theme_name . '.yaml'; + $file = YamlFile::instance($locator->findResource($filename, true, true)); + $content = $grav['config']->get('themes.' . $theme_name); + $file->save($content); + $file->free(); + + 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. + */ + protected function loadBlueprint() + { + if (!$this->blueprint) { + $grav = Grav::instance(); + $themes = $grav['themes']; + $this->blueprint = $themes->get($this->name)->blueprints(); + } + } +} diff --git a/system/src/Grav/Common/Themes.php b/system/src/Grav/Common/Themes.php new file mode 100644 index 0000000..6720d46 --- /dev/null +++ b/system/src/Grav/Common/Themes.php @@ -0,0 +1,371 @@ +grav = $grav; + $this->config = $grav['config']; + + // Register instance as autoloader for theme inheritance + spl_autoload_register([$this, 'autoloadTheme']); + } + + public function init() + { + /** @var Themes $themes */ + $themes = $this->grav['themes']; + $themes->configure(); + + $this->initTheme(); + } + + public function initTheme() + { + if ($this->inited === false) { + /** @var Themes $themes */ + $themes = $this->grav['themes']; + + try { + $instance = $themes->load(); + } catch (\InvalidArgumentException $e) { + throw new \RuntimeException($this->current() . ' theme could not be found'); + } + + if ($instance instanceof EventSubscriberInterface) { + /** @var EventDispatcher $events */ + $events = $this->grav['events']; + + $events->addSubscriber($instance); + } + + $this->grav['theme'] = $instance; + + $this->grav->fireEvent('onThemeInitialized'); + + $this->inited = true; + } + } + + /** + * Return list of all theme data with their blueprints. + * + * @return array + */ + public function all() + { + $list = []; + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + $iterator = $locator->getIterator('themes://'); + + /** @var \DirectoryIterator $directory */ + foreach ($iterator as $directory) { + if (!$directory->isDir() || $directory->isDot()) { + continue; + } + + $theme = $directory->getFilename(); + + try { + $result = $this->get($theme); + } catch (\Exception $e) { + $exception = new \RuntimeException(sprintf('Theme %s: %s', $theme, $e->getMessage()), $e->getCode(), $e); + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage("Theme {$theme} cannot be loaded, please check Exceptions tab", 'error'); + $debugger->addException($exception); + + continue; + } + + if ($result) { + $list[$theme] = $result; + } + } + ksort($list); + + return $list; + } + + /** + * Get theme configuration or throw exception if it cannot be found. + * + * @param string $name + * + * @return Data + * @throws \RuntimeException + */ + public function get($name) + { + if (!$name) { + throw new \RuntimeException('Theme name not provided.'); + } + + $blueprints = new Blueprints('themes://'); + $blueprint = $blueprints->get("{$name}/blueprints"); + + // Load default configuration. + $file = CompiledYamlFile::instance("themes://{$name}/{$name}" . YAML_EXT); + + // ensure this is a valid theme + if (!$file->exists()) { + return null; + } + + // Find thumbnail. + $thumb = "themes://{$name}/thumbnail.jpg"; + $path = $this->grav['locator']->findResource($thumb, false); + + if ($path) { + $blueprint->set('thumbnail', $this->grav['base_url'] . '/' . $path); + } + + $obj = new Data((array)$file->content(), $blueprint); + + // Override with user configuration. + $obj->merge($this->config->get('themes.' . $name) ?: []); + + // Save configuration always to user/config. + $file = CompiledYamlFile::instance("config://themes/{$name}" . YAML_EXT); + $obj->file($file); + + return $obj; + } + + /** + * Return name of the current theme. + * + * @return string + */ + public function current() + { + return (string)$this->config->get('system.pages.theme'); + } + + /** + * Load current theme. + * + * @return Theme + */ + public function load() + { + // NOTE: ALL THE LOCAL VARIABLES ARE USED INSIDE INCLUDED FILE, DO NOT REMOVE THEM! + $grav = $this->grav; + $config = $this->config; + $name = $this->current(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $file = $locator('theme://theme.php') ?: $locator("theme://{$name}.php"); + + $inflector = $grav['inflector']; + + if ($file) { + // Local variables available in the file: $grav, $config, $name, $file + $class = include $file; + + if (!is_object($class)) { + $themeClassFormat = [ + 'Grav\\Theme\\' . ucfirst($name), + 'Grav\\Theme\\' . $inflector->camelize($name) + ]; + + foreach ($themeClassFormat as $themeClass) { + if (class_exists($themeClass)) { + $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)) { + $class = new Theme($grav, $config, $name); + } + + return $class; + } + + /** + * Configure and prepare streams for current template. + * + * @throws \InvalidArgumentException + */ + public function configure() + { + $name = $this->current(); + $config = $this->config; + + $this->loadConfiguration($name, $config); + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + $registered = stream_get_wrappers(); + + $schemes = $config->get("themes.{$name}.streams.schemes", []); + $schemes += [ + 'theme' => [ + 'type' => 'ReadOnlyStream', + 'paths' => $locator->findResources("themes://{$name}", false) + ] + ]; + + foreach ($schemes as $scheme => $config) { + if (isset($config['paths'])) { + $locator->addPath($scheme, '', $config['paths']); + } + if (isset($config['prefixes'])) { + foreach ($config['prefixes'] as $prefix => $paths) { + $locator->addPath($scheme, $prefix, $paths); + } + } + + if (\in_array($scheme, $registered, true)) { + stream_wrapper_unregister($scheme); + } + $type = !empty($config['type']) ? $config['type'] : 'ReadOnlyStream'; + if ($type[0] !== '\\') { + $type = '\\RocketTheme\\Toolbox\\StreamWrapper\\' . $type; + } + + if (!stream_wrapper_register($scheme, $type)) { + throw new \InvalidArgumentException("Stream '{$type}' could not be initialized."); + } + } + + // Load languages after streams has been properly initialized + $this->loadLanguages($this->config); + } + + /** + * Load theme configuration. + * + * @param string $name Theme name + * @param Config $config Configuration class + */ + protected function loadConfiguration($name, Config $config) + { + $themeConfig = CompiledYamlFile::instance("themes://{$name}/{$name}" . YAML_EXT)->content(); + $config->joinDefaults("themes.{$name}", $themeConfig); + } + + /** + * Load theme languages. + * + * @param Config $config Configuration class + */ + protected function loadLanguages(Config $config) + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + if ($config->get('system.languages.translations', true)) { + $language_file = $locator->findResource('theme://languages' . YAML_EXT); + if ($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 = []; + $iterator = new \DirectoryIterator($languages_folder); + + /** @var \DirectoryIterator $directory */ + foreach ($iterator as $file) { + if ($file->getExtension() !== 'yaml') { + continue; + } + $languages[$file->getBasename('.yaml')] = CompiledYamlFile::instance($file->getPathname())->content(); + } + $this->grav['languages']->mergeRecursive($languages); + } + } + } + + /** + * Autoload theme classes for inheritance + * + * @param string $class Class name + * + * @return mixed false FALSE if unable to load $class; Class name if + * $class is successfully loaded + */ + protected function autoloadTheme($class) + { + $prefix = 'Grav\\Theme\\'; + if (false !== strpos($class, $prefix)) { + // Remove prefix from class + $class = substr($class, strlen($prefix)); + $locator = $this->grav['locator']; + + // First try lowercase version of the classname. + $path = strtolower($class); + $file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php"); + + if ($file) { + return include_once $file; + } + + // Replace namespace tokens to directory separators + $path = $this->grav['inflector']->hyphenize($class); + $file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php"); + + // Load class + if ($file) { + return include_once $file; + } + + // Try Old style theme classes + $path = strtolower(preg_replace('#\\\|_(?!.+\\\)#', '/', $class)); + $file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php"); + + // Load class + if ($file) { + return include_once $file; + } + } + + return false; + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php b/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php new file mode 100644 index 0000000..7bb2bfc --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php @@ -0,0 +1,46 @@ + $body], [], $lineno, $tag); + } + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig_Compiler instance + */ + public function compile(Compiler $compiler) + { + $compiler + ->addDebugInfo($this) + ->write('ob_start();' . PHP_EOL) + ->subcompile($this->getNode('body')) + ->write('$content = ob_get_clean();' . PHP_EOL) + ->write('preg_match("/^\s*/", $content, $matches);' . PHP_EOL) + ->write('$lines = explode("\n", $content);' . PHP_EOL) + ->write('$content = preg_replace(\'/^\' . $matches[0]. \'/\', "", $lines);' . PHP_EOL) + ->write('$content = join("\n", $content);' . PHP_EOL) + ->write('echo $this->env->getExtension(\'Grav\Common\Twig\TwigExtension\')->markdownFunction($context, $content);' . PHP_EOL); + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeRender.php b/system/src/Grav/Common/Twig/Node/TwigNodeRender.php new file mode 100644 index 0000000..753adf0 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeRender.php @@ -0,0 +1,78 @@ + $object, 'layout' => $layout, 'context' => $context], [], $lineno, $tag); + } + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig_Compiler instance + * @throws \LogicException + */ + public function compile(Compiler $compiler) + { + $compiler->addDebugInfo($this); + $compiler->write('$object = ')->subcompile($this->getNode('object'))->raw(';' . PHP_EOL); + + $layout = $this->getNode('layout'); + if ($layout) { + $compiler->write('$layout = ')->subcompile($layout)->raw(';' . PHP_EOL); + } else { + $compiler->write('$layout = null;' . PHP_EOL); + } + + $context = $this->getNode('context'); + if ($context) { + $compiler->write('$attributes = ')->subcompile($context)->raw(';' . PHP_EOL); + } else { + $compiler->write('$attributes = null;' . PHP_EOL); + } + + $compiler + ->write('$html = $object->render($layout, $attributes ?? []);' . PHP_EOL) + ->write('$block = $context[\'block\'] ?? null;' . PHP_EOL) + ->write('if ($block instanceof \Grav\Framework\ContentBlock\ContentBlock && $html instanceof \Grav\Framework\ContentBlock\ContentBlock) {' . PHP_EOL) + ->indent() + ->write('$block->addBlock($html);' . PHP_EOL) + ->write('echo $html->getToken();' . PHP_EOL) + ->outdent() + ->write('} else {' . PHP_EOL) + ->indent() + ->write('echo (string)$html;' . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL) + ; + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeScript.php b/system/src/Grav/Common/Twig/Node/TwigNodeScript.php new file mode 100644 index 0000000..4f6f776 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeScript.php @@ -0,0 +1,101 @@ + $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes], [], $lineno, $tag); + } + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig_Compiler instance + * @throws \LogicException + */ + public function compile(Compiler $compiler) + { + $compiler->addDebugInfo($this); + + $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];\n"); + + if ($this->getNode('attributes') !== null) { + $compiler + ->write('$attributes = ') + ->subcompile($this->getNode('attributes')) + ->raw(";\n") + ->write("if (!is_array(\$attributes)) {\n") + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');\n") + ->outdent() + ->write("}\n"); + } else { + $compiler->write('$attributes = [];' . "\n"); + } + + if ($this->getNode('group') !== null) { + $compiler + ->write("\$attributes['group'] = ") + ->subcompile($this->getNode('group')) + ->raw(";\n") + ->write("if (!is_string(\$attributes['group'])) {\n") + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');\n") + ->outdent() + ->write("}\n"); + } + + if ($this->getNode('priority') !== null) { + $compiler + ->write("\$attributes['priority'] = (int)(") + ->subcompile($this->getNode('priority')) + ->raw(");\n"); + } + + if ($this->getNode('file') !== null) { + $compiler + ->write('$assets->addJs(') + ->subcompile($this->getNode('file')) + ->raw(", \$attributes);\n"); + } else { + $compiler + ->write("ob_start();\n") + ->subcompile($this->getNode('body')) + ->write('$content = ob_get_clean();' . "\n") + ->write("\$assets->addInlineJs(\$content, \$attributes);\n"); + } + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php b/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php new file mode 100644 index 0000000..22f3302 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php @@ -0,0 +1,101 @@ + $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes], [], $lineno, $tag); + } + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig_Compiler instance + * @throws \LogicException + */ + public function compile(Compiler $compiler) + { + $compiler->addDebugInfo($this); + + $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];\n"); + + if ($this->getNode('attributes') !== null) { + $compiler + ->write('$attributes = ') + ->subcompile($this->getNode('attributes')) + ->raw(";\n") + ->write("if (!is_array(\$attributes)) {\n") + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');\n") + ->outdent() + ->write("}\n"); + } else { + $compiler->write('$attributes = [];' . "\n"); + } + + if ($this->getNode('group') !== null) { + $compiler + ->write("\$attributes['group'] = ") + ->subcompile($this->getNode('group')) + ->raw(";\n") + ->write("if (!is_string(\$attributes['group'])) {\n") + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');\n") + ->outdent() + ->write("}\n"); + } + + if ($this->getNode('priority') !== null) { + $compiler + ->write("\$attributes['priority'] = (int)(") + ->subcompile($this->getNode('priority')) + ->raw(");\n"); + } + + if ($this->getNode('file') !== null) { + $compiler + ->write('$assets->addCss(') + ->subcompile($this->getNode('file')) + ->raw(", \$attributes);\n"); + } else { + $compiler + ->write("ob_start();\n") + ->subcompile($this->getNode('body')) + ->write('$content = ob_get_clean();' . "\n") + ->write("\$assets->addInlineCss(\$content, \$attributes);\n"); + } + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php b/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php new file mode 100644 index 0000000..f29ed6d --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php @@ -0,0 +1,85 @@ + $value, 'cases' => $cases, 'default' => $default), array(), $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig_Compiler instance + */ + public function compile(Compiler $compiler) + { + $compiler + ->addDebugInfo($this) + ->write('switch (') + ->subcompile($this->getNode('value')) + ->raw(") {\n") + ->indent(); + + foreach ($this->getNode('cases') as $case) { + if (!$case->hasNode('body')) { + continue; + } + + foreach ($case->getNode('values') as $value) { + $compiler + ->write('case ') + ->subcompile($value) + ->raw(":\n"); + } + + $compiler + ->write("{\n") + ->indent() + ->subcompile($case->getNode('body')) + ->write("break;\n") + ->outdent() + ->write("}\n"); + } + + if ($this->hasNode('default') && $this->getNode('default') !== null) { + $compiler + ->write("default:\n") + ->write("{\n") + ->indent() + ->subcompile($this->getNode('default')) + ->outdent() + ->write("}\n"); + } + + $compiler + ->outdent() + ->write("}\n"); + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php b/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php new file mode 100644 index 0000000..5bb5899 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php @@ -0,0 +1,51 @@ + $message], ['code' => $code], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig_Compiler instance + * @throws \LogicException + */ + public function compile(Compiler $compiler) + { + $compiler->addDebugInfo($this); + + $compiler + ->write('throw new \RuntimeException(') + ->subcompile($this->getNode('message')) + ->write(', ') + ->write($this->getAttribute('code') ?: 500) + ->write(");\n"); + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php b/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php new file mode 100644 index 0000000..4028e74 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php @@ -0,0 +1,68 @@ + $try, 'catch' => $catch], [], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig_Compiler instance + * @throws \LogicException + */ + public function compile(Compiler $compiler) + { + $compiler->addDebugInfo($this); + + $compiler + ->write('try {') + ; + + $compiler + ->indent() + ->subcompile($this->getNode('try')) + ; + + if ($this->hasNode('catch') && null !== $this->getNode('catch')) { + $compiler + ->outdent() + ->write('} catch (\Exception $e) {' . "\n") + ->indent() + ->write('if (isset($context[\'grav\'][\'debugger\'])) $context[\'grav\'][\'debugger\']->addException($e);' . "\n") + ->write('$context[\'e\'] = $e;' . "\n") + ->subcompile($this->getNode('catch')) + ; + } + + $compiler + ->outdent() + ->write("}\n"); + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php new file mode 100644 index 0000000..7a1d139 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php @@ -0,0 +1,56 @@ +getLine(); + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + $body = $this->parser->subparse(array($this, 'decideMarkdownEnd'), true); + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + return new TwigNodeMarkdown($body, $lineno, $this->getTag()); + } + /** + * Decide if current token marks end of Markdown block. + * + * @param Token $token + * @return bool + */ + public function decideMarkdownEnd(Token $token) + { + return $token->test('endmarkdown'); + } + /** + * {@inheritdoc} + */ + public function getTag() + { + return 'markdown'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php new file mode 100644 index 0000000..811705d --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php @@ -0,0 +1,75 @@ +getLine(); + + [$object, $layout, $context] = $this->parseArguments($token); + + return new TwigNodeRender($object, $layout, $context, $lineno, $this->getTag()); + } + + /** + * @param Token $token + * @return array + */ + protected function parseArguments(Token $token) + { + $stream = $this->parser->getStream(); + + $object = $this->parser->getExpressionParser()->parseExpression(); + + $layout = null; + if ($stream->nextIf(Token::NAME_TYPE, 'layout')) { + $stream->expect(Token::PUNCTUATION_TYPE, ':'); + $layout = $this->parser->getExpressionParser()->parseExpression(); + } + + $context = null; + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { + $context = $this->parser->getExpressionParser()->parseExpression(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return [$object, $layout, $context]; + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag() + { + return 'render'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php new file mode 100644 index 0000000..bb04707 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php @@ -0,0 +1,104 @@ +getLine(); + $stream = $this->parser->getStream(); + + list($file, $group, $priority, $attributes) = $this->parseArguments($token); + + $content = null; + if ($file === null) { + $content = $this->parser->subparse([$this, 'decideBlockEnd'], true); + $stream->expect(Token::BLOCK_END_TYPE); + } + + return new TwigNodeScript($content, $file, $group, $priority, $attributes, $lineno, $this->getTag()); + } + + /** + * @param Token $token + * @return array + */ + protected function parseArguments(Token $token) + { + $stream = $this->parser->getStream(); + + $file = null; + if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE) && !$stream->test(Token::BLOCK_END_TYPE)) { + $file = $this->parser->getExpressionParser()->parseExpression(); + } + + $group = null; + if ($stream->nextIf(Token::OPERATOR_TYPE, 'in')) { + $group = $this->parser->getExpressionParser()->parseExpression(); + } + + $priority = null; + if ($stream->nextIf(Token::NAME_TYPE, 'priority')) { + $stream->expect(Token::PUNCTUATION_TYPE, ':'); + $priority = $this->parser->getExpressionParser()->parseExpression(); + } + + $attributes = null; + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { + $attributes = $this->parser->getExpressionParser()->parseExpression(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return [$file, $group, $priority, $attributes]; + } + + /** + * @param Token $token + * @return bool + */ + public function decideBlockEnd(Token $token) + { + return $token->test('endscript'); + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag() + { + return 'script'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php new file mode 100644 index 0000000..5a5beb6 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php @@ -0,0 +1,103 @@ +getLine(); + $stream = $this->parser->getStream(); + + list ($file, $group, $priority, $attributes) = $this->parseArguments($token); + + $content = null; + if (!$file) { + $content = $this->parser->subparse([$this, 'decideBlockEnd'], true); + $stream->expect(Token::BLOCK_END_TYPE); + } + + return new TwigNodeStyle($content, $file, $group, $priority, $attributes, $lineno, $this->getTag()); + } + + /** + * @param Token $token + * @return array + */ + protected function parseArguments(Token $token) + { + $stream = $this->parser->getStream(); + + $file = null; + if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE) && !$stream->test(Token::BLOCK_END_TYPE)) { + $file = $this->parser->getExpressionParser()->parseExpression(); + } + + $group = null; + if ($stream->nextIf(Token::OPERATOR_TYPE, 'in')) { + $group = $this->parser->getExpressionParser()->parseExpression(); + } + + $priority = null; + if ($stream->nextIf(Token::NAME_TYPE, 'priority')) { + $stream->expect(Token::PUNCTUATION_TYPE, ':'); + $priority = $this->parser->getExpressionParser()->parseExpression(); + } + + $attributes = null; + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { + $attributes = $this->parser->getExpressionParser()->parseExpression(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return [$file, $group, $priority, $attributes]; + } + + /** + * @param Token $token + * @return bool + */ + public function decideBlockEnd(Token $token) + { + return $token->test('endstyle'); + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag() + { + return 'style'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php new file mode 100644 index 0000000..7bd3ba9 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php @@ -0,0 +1,130 @@ +getLine(); + $stream = $this->parser->getStream(); + + $name = $this->parser->getExpressionParser()->parseExpression(); + $stream->expect(Token::BLOCK_END_TYPE); + + // There can be some whitespace between the {% switch %} and first {% case %} tag. + while ($stream->getCurrent()->getType() === Token::TEXT_TYPE && trim($stream->getCurrent()->getValue()) === '') { + $stream->next(); + } + + $stream->expect(Token::BLOCK_START_TYPE); + + $expressionParser = $this->parser->getExpressionParser(); + + $default = null; + $cases = []; + $end = false; + + while (!$end) { + $next = $stream->next(); + + switch ($next->getValue()) { + case 'case': + $values = []; + + while (true) { + $values[] = $expressionParser->parsePrimaryExpression(); + // Multiple allowed values? + if ($stream->test(Token::OPERATOR_TYPE, 'or')) { + $stream->next(); + } else { + break; + } + } + + $stream->expect(Token::BLOCK_END_TYPE); + $body = $this->parser->subparse(array($this, 'decideIfFork')); + $cases[] = new Node([ + 'values' => new Node($values), + 'body' => $body + ]); + break; + + case 'default': + $stream->expect(Token::BLOCK_END_TYPE); + $default = $this->parser->subparse(array($this, 'decideIfEnd')); + break; + + case 'endswitch': + $end = true; + break; + + default: + throw new SyntaxError(sprintf('Unexpected end of template. Twig was looking for the following tags "case", "default", or "endswitch" to close the "switch" block started at line %d)', $lineno), -1); + } + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return new TwigNodeSwitch($name, new Node($cases), $default, $lineno, $this->getTag()); + } + + /** + * Decide if current token marks switch logic. + * + * @param Token $token + * @return bool + */ + public function decideIfFork(Token $token) + { + return $token->test(array('case', 'default', 'endswitch')); + } + + /** + * Decide if current token marks end of swtich block. + * + * @param Token $token + * @return bool + */ + public function decideIfEnd(Token $token) + { + return $token->test(array('endswitch')); + } + + /** + * {@inheritdoc} + */ + public function getTag() + { + return 'switch'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php new file mode 100644 index 0000000..c0d6de9 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php @@ -0,0 +1,54 @@ + + * {% throw 404 'Not Found' %} + * + */ +class TwigTokenParserThrow extends AbstractTokenParser +{ + /** + * Parses a token and returns a node. + * + * @param Token $token A Twig_Token instance + * + * @return Node A Twig_Node instance + */ + public function parse(Token $token) + { + $lineno = $token->getLine(); + $stream = $this->parser->getStream(); + + $code = $stream->expect(Token::NUMBER_TYPE)->getValue(); + $message = $this->parser->getExpressionParser()->parseExpression(); + $stream->expect(Token::BLOCK_END_TYPE); + + return new TwigNodeThrow($code, $message, $lineno, $this->getTag()); + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag() + { + return 'throw'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php new file mode 100644 index 0000000..95adef3 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php @@ -0,0 +1,72 @@ + + * {% try %} + *
  • {{ user.get('name') }}
  • + * {% catch %} + * {{ e.message }} + * {% endcatch %} + * + */ +class TwigTokenParserTryCatch extends AbstractTokenParser +{ + /** + * Parses a token and returns a node. + * + * @param Token $token A Twig_Token instance + * + * @return Node A Twig_Node instance + */ + public function parse(Token $token) + { + $lineno = $token->getLine(); + $stream = $this->parser->getStream(); + + $stream->expect(Token::BLOCK_END_TYPE); + $try = $this->parser->subparse([$this, 'decideCatch']); + $stream->next(); + $stream->expect(Token::BLOCK_END_TYPE); + $catch = $this->parser->subparse([$this, 'decideEnd']); + $stream->next(); + $stream->expect(Token::BLOCK_END_TYPE); + + return new TwigNodeTryCatch($try, $catch, $lineno, $this->getTag()); + } + + public function decideCatch(Token $token) + { + return $token->test(array('catch')); + } + + public function decideEnd(Token $token) + { + return $token->test(array('endtry')) || $token->test(array('endcatch')); + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag() + { + return 'try'; + } +} diff --git a/system/src/Grav/Common/Twig/Twig.php b/system/src/Grav/Common/Twig/Twig.php new file mode 100644 index 0000000..5c9008f --- /dev/null +++ b/system/src/Grav/Common/Twig/Twig.php @@ -0,0 +1,454 @@ +grav = $grav; + $this->twig_paths = []; + } + + /** + * Twig initialization that sets the twig loader chain, then the environment, then extensions + * and also the base set of twig vars + */ + public function init() + { + if (null === $this->twig) { + /** @var Config $config */ + $config = $this->grav['config']; + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + /** @var Language $language */ + $language = $this->grav['language']; + + $active_language = $language->getActive(); + + // handle language templates if available + if ($language->enabled()) { + $lang_templates = $locator->findResource('theme://templates/' . ($active_language ? $active_language : $language->getDefault())); + if ($lang_templates) { + $this->twig_paths[] = $lang_templates; + } + } + + $this->twig_paths = array_merge($this->twig_paths, $locator->findResources('theme://templates')); + + $this->grav->fireEvent('onTwigTemplatePaths'); + + // Add Grav core templates location + $core_templates = array_merge($locator->findResources('system://templates'), $locator->findResources('system://templates/testing')); + $this->twig_paths = array_merge($this->twig_paths, $core_templates); + + $this->loader = new \Twig_Loader_Filesystem($this->twig_paths); + + // Register all other prefixes as namespaces in twig + foreach ($locator->getPaths('theme') as $prefix => $_) { + if ($prefix === '') { + continue; + } + + $twig_paths = []; + + // handle language templates if available + if ($language->enabled()) { + $lang_templates = $locator->findResource('theme://'.$prefix.'templates/' . ($active_language ? $active_language : $language->getDefault())); + if ($lang_templates) { + $twig_paths[] = $lang_templates; + } + } + + $twig_paths = array_merge($twig_paths, $locator->findResources('theme://'.$prefix.'templates')); + + $namespace = trim($prefix, '/'); + $this->loader->setPaths($twig_paths, $namespace); + } + + $this->grav->fireEvent('onTwigLoader'); + + $this->loaderArray = new \Twig_Loader_Array([]); + $loader_chain = new \Twig_Loader_Chain([$this->loaderArray, $this->loader]); + + $params = $config->get('system.twig'); + if (!empty($params['cache'])) { + $cachePath = $locator->findResource('cache://twig', true, true); + $params['cache'] = new \Twig_Cache_Filesystem($cachePath, \Twig_Cache_Filesystem::FORCE_BYTECODE_INVALIDATION); + } + + if (!$config->get('system.strict_mode.twig_compat', true)) { + // Force autoescape on for all files if in strict mode. + $params['autoescape'] = 'html'; + } elseif (!empty($this->autoescape)) { + $params['autoescape'] = $this->autoescape ? 'html' : false; + } + + if (empty($params['autoescape'])) { + user_error('Grav 2.0 will have Twig auto-escaping forced on (can be emulated by turning off \'system.strict_mode.twig_compat\' setting in your configuration)', E_USER_DEPRECATED); + } + + $this->twig = new TwigEnvironment($loader_chain, $params); + + if ($config->get('system.twig.undefined_functions')) { + $this->twig->registerUndefinedFunctionCallback(function ($name) { + if (function_exists($name)) { + return new \Twig_SimpleFunction($name, $name); + } + + return new \Twig_SimpleFunction($name, function () { + }); + }); + } + + if ($config->get('system.twig.undefined_filters')) { + $this->twig->registerUndefinedFilterCallback(function ($name) { + if (function_exists($name)) { + return new \Twig_SimpleFilter($name, $name); + } + + return new \Twig_SimpleFilter($name, function () { + }); + }); + } + + $this->grav->fireEvent('onTwigInitialized'); + + // set default date format if set in config + if ($config->get('system.pages.dateformat.long')) { + /** @var \Twig_Extension_Core $extension */ + $extension = $this->twig->getExtension('Twig_Extension_Core'); + $extension->setDateFormat($config->get('system.pages.dateformat.long')); + } + // enable the debug extension if required + if ($config->get('system.twig.debug')) { + $this->twig->addExtension(new \Twig_Extension_Debug()); + } + $this->twig->addExtension(new TwigExtension()); + $this->twig->addExtension(new DeferredExtension()); + + $this->grav->fireEvent('onTwigExtensions'); + + /** @var Pages $pages */ + $pages = $this->grav['pages']; + + // Set some standard variables for twig + $this->twig_vars += [ + 'config' => $config, + 'system' => $config->get('system'), + 'theme' => $config->get('theme'), + 'site' => $config->get('site'), + 'uri' => $this->grav['uri'], + 'assets' => $this->grav['assets'], + 'taxonomy' => $this->grav['taxonomy'], + 'browser' => $this->grav['browser'], + 'base_dir' => rtrim(ROOT_DIR, '/'), + 'home_url' => $pages->homeUrl($active_language), + 'base_url' => $pages->baseUrl($active_language), + 'base_url_absolute' => $pages->baseUrl($active_language, true), + 'base_url_relative' => $pages->baseUrl($active_language, false), + 'base_url_simple' => $this->grav['base_url'], + 'theme_dir' => $locator->findResource('theme://'), + 'theme_url' => $this->grav['base_url'] . '/' . $locator->findResource('theme://', false), + 'html_lang' => $this->grav['language']->getActive() ?: $config->get('site.default_lang', 'en'), + 'language_codes' => new LanguageCodes, + ]; + } + + return $this; + } + + /** + * @return \Twig_Environment + */ + public function twig() + { + return $this->twig; + } + + /** + * @return \Twig_Loader_Filesystem + */ + public function loader() + { + return $this->loader; + } + + /** + * Adds or overrides a template. + * + * @param string $name The template name + * @param string $template The template source + */ + public function setTemplate($name, $template) + { + $this->loaderArray->setTemplate($name, $template); + } + + /** + * Twig process that renders a page item. It supports two variations: + * 1) Handles modular pages by rendering a specific page based on its modular twig template + * 2) Renders individual page items for twig processing before the site rendering + * + * @param PageInterface $item The page item to render + * @param string $content Optional content override + * + * @return string The rendered output + * @throws \Twig_Error_Loader + */ + public function processPage(PageInterface $item, $content = null) + { + $content = $content ?? $item->content(); + + // override the twig header vars for local resolution + $this->grav->fireEvent('onTwigPageVariables', new Event(['page' => $item])); + $twig_vars = $this->twig_vars; + + $twig_vars['page'] = $item; + $twig_vars['media'] = $item->media(); + $twig_vars['header'] = $item->header(); + + $local_twig = clone $this->twig; + + $output = ''; + try { + // Process Modular Twig + if ($item->modularTwig()) { + $twig_vars['content'] = $content; + $extension = $item->templateFormat(); + $extension = $extension ? ".{$extension}.twig" : TEMPLATE_EXT; + $template = $item->template() . $extension; + $output = $content = $local_twig->render($template, $twig_vars); + } + + // Process in-page Twig + if ($item->shouldProcess('twig')) { + $name = '@Page:' . $item->path(); + $this->setTemplate($name, $content); + $output = $local_twig->render($name, $twig_vars); + } + + } catch (\Twig_Error_Loader $e) { + throw new \RuntimeException($e->getRawMessage(), 404, $e); + } + + return $output; + } + + /** + * Process a Twig template directly by using a template name + * and optional array of variables + * + * @param string $template template to render with + * @param array $vars Optional variables + * + * @return string + */ + public function processTemplate($template, $vars = []) + { + // override the twig header vars for local resolution + $this->grav->fireEvent('onTwigTemplateVariables'); + $vars += $this->twig_vars; + + try { + $output = $this->twig->render($template, $vars); + } catch (\Twig_Error_Loader $e) { + throw new \RuntimeException($e->getRawMessage(), 404, $e); + } + + return $output; + + } + + + /** + * Process a Twig template directly by using a Twig string + * and optional array of variables + * + * @param string $string string to render. + * @param array $vars Optional variables + * + * @return string + */ + public function processString($string, array $vars = []) + { + // override the twig header vars for local resolution + $this->grav->fireEvent('onTwigStringVariables'); + $vars += $this->twig_vars; + + $name = '@Var:' . $string; + $this->setTemplate($name, $string); + + try { + $output = $this->twig->render($name, $vars); + } catch (\Twig_Error_Loader $e) { + throw new \RuntimeException($e->getRawMessage(), 404, $e); + } + + return $output; + } + + /** + * Twig process that renders the site layout. This is the main twig process that renders the overall + * page and handles all the layout for the site display. + * + * @param string $format Output format (defaults to HTML). + * + * @return string the rendered output + * @throws \RuntimeException + */ + public function processSite($format = null, array $vars = []) + { + // set the page now its been processed + $this->grav->fireEvent('onTwigSiteVariables'); + $pages = $this->grav['pages']; + $page = $this->grav['page']; + $content = $page->content(); + + $twig_vars = $this->twig_vars; + + $twig_vars['theme'] = $this->grav['config']->get('theme'); + $twig_vars['pages'] = $pages->root(); + $twig_vars['page'] = $page; + $twig_vars['header'] = $page->header(); + $twig_vars['media'] = $page->media(); + $twig_vars['content'] = $content; + $ext = '.' . ($format ?: 'html') . TWIG_EXT; + + // determine if params are set, if so disable twig cache + $params = $this->grav['uri']->params(null, true); + if (!empty($params)) { + $this->twig->setCache(false); + } + + // Get Twig template layout + $template = $this->template($page->template() . $ext); + + try { + $output = $this->twig->render($template, $vars + $twig_vars); + } catch (\Twig_Error_Loader $e) { + $error_msg = $e->getMessage(); + // Try html version of this template if initial template was NOT html + if ($ext !== '.html' . TWIG_EXT) { + try { + $page->templateFormat('html'); + $output = $this->twig->render($page->template() . '.html' . TWIG_EXT, $vars + $twig_vars); + } catch (\Twig_Error_Loader $e) { + throw new \RuntimeException($error_msg, 400, $e); + } + } else { + throw new \RuntimeException($error_msg, 400, $e); + } + } + + return $output; + } + + /** + * Wraps the Twig_Loader_Filesystem addPath method (should be used only in `onTwigLoader()` event + * @param string $template_path + * @param string $namespace + */ + public function addPath($template_path, $namespace = '__main__') + { + $this->loader->addPath($template_path, $namespace); + } + + /** + * Wraps the Twig_Loader_Filesystem prependPath method (should be used only in `onTwigLoader()` event + * @param string $template_path + * @param string $namespace + */ + public function prependPath($template_path, $namespace = '__main__') + { + $this->loader->prependPath($template_path, $namespace); + } + + /** + * Simple helper method to get the twig template if it has already been set, else return + * the one being passed in + * + * @param string $template the template name + * + * @return string the template name + */ + public function template($template) + { + return $this->template ?? $template; + } + + /** + * Overrides the autoescape setting + * + * @param bool $state + * @deprecated 1.5 Auto-escape should always be turned on to protect against XSS issues (can be disabled per template file). + */ + public function setAutoescape($state) + { + if (!$state) { + user_error(__CLASS__ . '::' . __FUNCTION__ . '(false) is deprecated since Grav 1.5', E_USER_DEPRECATED); + } + + $this->autoescape = (bool) $state; + } +} diff --git a/system/src/Grav/Common/Twig/TwigEnvironment.php b/system/src/Grav/Common/Twig/TwigEnvironment.php new file mode 100644 index 0000000..54ef24f --- /dev/null +++ b/system/src/Grav/Common/Twig/TwigEnvironment.php @@ -0,0 +1,15 @@ +grav = Grav::instance(); + $this->debugger = $this->grav['debugger'] ?? null; + $this->config = $this->grav['config']; + } + + /** + * Register some standard globals + * + * @return array + */ + public function getGlobals() + { + return [ + 'grav' => $this->grav, + ]; + } + + /** + * Return a list of all filters. + * + * @return array + */ + public function getFilters() + { + return [ + new \Twig_SimpleFilter('*ize', [$this, 'inflectorFilter']), + new \Twig_SimpleFilter('absolute_url', [$this, 'absoluteUrlFilter']), + new \Twig_SimpleFilter('contains', [$this, 'containsFilter']), + new \Twig_SimpleFilter('chunk_split', [$this, 'chunkSplitFilter']), + new \Twig_SimpleFilter('nicenumber', [$this, 'niceNumberFunc']), + new \Twig_SimpleFilter('nicefilesize', [$this, 'niceFilesizeFunc']), + new \Twig_SimpleFilter('nicetime', [$this, 'nicetimeFunc']), + new \Twig_SimpleFilter('defined', [$this, 'definedDefaultFilter']), + new \Twig_SimpleFilter('ends_with', [$this, 'endsWithFilter']), + new \Twig_SimpleFilter('fieldName', [$this, 'fieldNameFilter']), + new \Twig_SimpleFilter('ksort', [$this, 'ksortFilter']), + new \Twig_SimpleFilter('ltrim', [$this, 'ltrimFilter']), + new \Twig_SimpleFilter('markdown', [$this, 'markdownFunction'], ['needs_context' => true, 'is_safe' => ['html']]), + new \Twig_SimpleFilter('md5', [$this, 'md5Filter']), + new \Twig_SimpleFilter('base32_encode', [$this, 'base32EncodeFilter']), + new \Twig_SimpleFilter('base32_decode', [$this, 'base32DecodeFilter']), + new \Twig_SimpleFilter('base64_encode', [$this, 'base64EncodeFilter']), + new \Twig_SimpleFilter('base64_decode', [$this, 'base64DecodeFilter']), + new \Twig_SimpleFilter('randomize', [$this, 'randomizeFilter']), + new \Twig_SimpleFilter('modulus', [$this, 'modulusFilter']), + new \Twig_SimpleFilter('rtrim', [$this, 'rtrimFilter']), + new \Twig_SimpleFilter('pad', [$this, 'padFilter']), + new \Twig_SimpleFilter('regex_replace', [$this, 'regexReplace']), + new \Twig_SimpleFilter('safe_email', [$this, 'safeEmailFilter']), + new \Twig_SimpleFilter('safe_truncate', ['\Grav\Common\Utils', 'safeTruncate']), + new \Twig_SimpleFilter('safe_truncate_html', ['\Grav\Common\Utils', 'safeTruncateHTML']), + new \Twig_SimpleFilter('sort_by_key', [$this, 'sortByKeyFilter']), + new \Twig_SimpleFilter('starts_with', [$this, 'startsWithFilter']), + new \Twig_SimpleFilter('truncate', ['\Grav\Common\Utils', 'truncate']), + new \Twig_SimpleFilter('truncate_html', ['\Grav\Common\Utils', 'truncateHTML']), + new \Twig_SimpleFilter('json_decode', [$this, 'jsonDecodeFilter']), + new \Twig_SimpleFilter('array_unique', 'array_unique'), + new \Twig_SimpleFilter('basename', 'basename'), + new \Twig_SimpleFilter('dirname', 'dirname'), + new \Twig_SimpleFilter('print_r', 'print_r'), + new \Twig_SimpleFilter('yaml_encode', [$this, 'yamlEncodeFilter']), + new \Twig_SimpleFilter('yaml_decode', [$this, 'yamlDecodeFilter']), + new \Twig_SimpleFilter('nicecron', [$this, 'niceCronFilter']), + + // Translations + new \Twig_SimpleFilter('t', [$this, 'translate'], ['needs_environment' => true]), + new \Twig_SimpleFilter('tl', [$this, 'translateLanguage']), + new \Twig_SimpleFilter('ta', [$this, 'translateArray']), + + // Casting values + new \Twig_SimpleFilter('string', [$this, 'stringFilter']), + new \Twig_SimpleFilter('int', [$this, 'intFilter'], ['is_safe' => ['all']]), + new \Twig_SimpleFilter('bool', [$this, 'boolFilter']), + new \Twig_SimpleFilter('float', [$this, 'floatFilter'], ['is_safe' => ['all']]), + new \Twig_SimpleFilter('array', [$this, 'arrayFilter']), + + // Object Types + new \Twig_SimpleFilter('get_type', [$this, 'getTypeFunc']), + new \Twig_SimpleFilter('of_type', [$this, 'ofTypeFunc']) + ]; + } + + /** + * Return a list of all functions. + * + * @return array + */ + public function getFunctions() + { + return [ + new \Twig_SimpleFunction('array', [$this, 'arrayFilter']), + new \Twig_SimpleFunction('array_key_value', [$this, 'arrayKeyValueFunc']), + new \Twig_SimpleFunction('array_key_exists', 'array_key_exists'), + new \Twig_SimpleFunction('array_unique', 'array_unique'), + new \Twig_SimpleFunction('array_intersect', [$this, 'arrayIntersectFunc']), + new \Twig_SimpleFunction('authorize', [$this, 'authorize']), + new \Twig_SimpleFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]), + new \Twig_SimpleFunction('dump', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]), + new \Twig_SimpleFunction('vardump', [$this, 'vardumpFunc']), + new \Twig_SimpleFunction('print_r', 'print_r'), + new \Twig_SimpleFunction('http_response_code', 'http_response_code'), + new \Twig_SimpleFunction('evaluate', [$this, 'evaluateStringFunc'], ['needs_context' => true]), + new \Twig_SimpleFunction('evaluate_twig', [$this, 'evaluateTwigFunc'], ['needs_context' => true]), + new \Twig_SimpleFunction('gist', [$this, 'gistFunc']), + new \Twig_SimpleFunction('nonce_field', [$this, 'nonceFieldFunc']), + new \Twig_SimpleFunction('pathinfo', 'pathinfo'), + new \Twig_SimpleFunction('random_string', [$this, 'randomStringFunc']), + new \Twig_SimpleFunction('repeat', [$this, 'repeatFunc']), + new \Twig_SimpleFunction('regex_replace', [$this, 'regexReplace']), + new \Twig_SimpleFunction('regex_filter', [$this, 'regexFilter']), + new \Twig_SimpleFunction('string', [$this, 'stringFunc']), + new \Twig_SimpleFunction('url', [$this, 'urlFunc']), + new \Twig_SimpleFunction('json_decode', [$this, 'jsonDecodeFilter']), + new \Twig_SimpleFunction('get_cookie', [$this, 'getCookie']), + new \Twig_SimpleFunction('redirect_me', [$this, 'redirectFunc']), + new \Twig_SimpleFunction('range', [$this, 'rangeFunc']), + new \Twig_SimpleFunction('isajaxrequest', [$this, 'isAjaxFunc']), + new \Twig_SimpleFunction('exif', [$this, 'exifFunc']), + new \Twig_SimpleFunction('media_directory', [$this, 'mediaDirFunc']), + new \Twig_SimpleFunction('body_class', [$this, 'bodyClassFunc']), + new \Twig_SimpleFunction('theme_var', [$this, 'themeVarFunc']), + new \Twig_SimpleFunction('header_var', [$this, 'pageHeaderVarFunc']), + new \Twig_SimpleFunction('read_file', [$this, 'readFileFunc']), + new \Twig_SimpleFunction('nicenumber', [$this, 'niceNumberFunc']), + new \Twig_SimpleFunction('nicefilesize', [$this, 'niceFilesizeFunc']), + new \Twig_SimpleFunction('nicetime', [$this, 'nicetimeFunc']), + new \Twig_SimpleFunction('cron', [$this, 'cronFunc']), + new \Twig_SimpleFunction('xss', [$this, 'xssFunc']), + + + // Translations + new \Twig_SimpleFunction('t', [$this, 'translate'], ['needs_environment' => true]), + new \Twig_SimpleFunction('tl', [$this, 'translateLanguage']), + new \Twig_SimpleFunction('ta', [$this, 'translateArray']), + + // Object Types + new \Twig_SimpleFunction('get_type', [$this, 'getTypeFunc']), + new \Twig_SimpleFunction('of_type', [$this, 'ofTypeFunc']) + ]; + } + + /** + * @return array + */ + public function getTokenParsers() + { + return [ + new TwigTokenParserRender(), + new TwigTokenParserThrow(), + new TwigTokenParserTryCatch(), + new TwigTokenParserScript(), + new TwigTokenParserStyle(), + new TwigTokenParserMarkdown(), + new TwigTokenParserSwitch(), + ]; + } + + /** + * 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) . ']' : ''); + } + + /** + * Protects email address. + * + * @param string $str + * + * @return string + */ + public function safeEmailFilter($str) + { + $email = ''; + for ($i = 0, $len = strlen($str); $i < $len; $i++) { + $j = random_int(0, 1); + + $email .= $j === 0 ? '&#' . ord($str[$i]) . ';' : $str[$i]; + } + + return str_replace('@', '@', $email); + } + + /** + * Returns array in a random order. + * + * @param array $original + * @param int $offset Can be used to return only slice of the array. + * + * @return array + */ + public function randomizeFilter($original, $offset = 0) + { + if (!\is_array($original)) { + return $original; + } + + if ($original instanceof \Traversable) { + $original = iterator_to_array($original, false); + } + + $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 $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 $count + * + * @return string + */ + public function inflectorFilter($action, $data, $count = null) + { + $action .= 'ize'; + + $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 bool|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 bool|string + */ + 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 $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 bool + */ + 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 $at + * @return string + */ + public function niceCronFilter($at) + { + $cron = new Cron($at); + return $cron->getText('en'); + } + + /** + * 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 bool + */ + public function nicetimeFunc($date, $long_strings = true, $show_tense = true) + { + if (empty($date)) { + return $this->grav['language']->translate('GRAV.NICETIME.NO_DATE_PROVIDED', null, true); + } + + 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', null, true); + } + + // is it future date or past date + if ($now > $unix_date) { + $difference = $now - $unix_date; + $tense = $this->grav['language']->translate('GRAV.NICETIME.AGO', null, true); + + } elseif ($now == $unix_date) { + $difference = $now - $unix_date; + $tense = $this->grav['language']->translate('GRAV.NICETIME.JUST_NOW', null, false); + + } else { + $difference = $unix_date - $now; + $tense = $this->grav['language']->translate('GRAV.NICETIME.FROM_NOW', null, true); + } + + 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], null, true); + + 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(function($value, $key) { + return $key.': \''.$value . '\''; + }, array_values($results), array_keys($results)); + + return implode(', ', $results_parts); + } + + /** + * @param string $string + * + * @return mixed + */ + public function absoluteUrlFilter($string) + { + $url = $this->grav['uri']->base(); + $string = preg_replace('/((?:href|src) *= *[\'"](?!(http|ftp)))/i', "$1$url", $string); + + return $string; + + } + + /** + * @param string $string + * + * @param array $context + * @param bool $block Block or Line processing + * @return mixed|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 null + */ + public function definedDefaultFilter($value, $default = null) + { + return null !== $value ? $value : $default; + } + + /** + * @param string $value + * @param null $chars + * + * @return string + */ + public function rtrimFilter($value, $chars = null) + { + return rtrim($value, $chars); + } + + /** + * @param string $value + * @param null $chars + * + * @return string + */ + public function ltrimFilter($value, $chars = null) + { + return ltrim($value, $chars); + } + + /** + * Casts input to string. + * + * @param mixed $input + * @return string + */ + public function stringFilter($input) + { + return (string) $input; + } + + + /** + * 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) + { + return (array) $input; + } + + /** + * @return string + */ + public function translate(\Twig_Environment $twig) + { + // shift off the environment + $args = func_get_args(); + array_shift($args); + + // If admin and tu filter provided, use it + if (isset($this->grav['admin'])) { + $numargs = count($args); + $lang = null; + + if (($numargs === 3 && is_array($args[1])) || ($numargs === 2 && !is_array($args[1]))) { + $lang = array_pop($args); + } 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. + * + * @return string|null Returns url to the resource or null if resource was not found. + */ + public function urlFunc($input, $domain = false) + { + return Utils::url($input, $domain); + } + + /** + * 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 \Twig_Loader_Filesystem('.'); + $env = new \Twig_Environment($loader); + + $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 \Twig_Environment $env + * @param string $context + */ + public function dump(\Twig_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++) { + $this->debugger->addMessage(func_get_arg($i), 'debug'); + } + } + } + + /** + * Output a Gist + * + * @param string $id + * @param string|bool $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 $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 $array1 + * @param array $array2 + * @return array + */ + public function arrayIntersectFunc($array1, $array2) + { + if ($array1 instanceof Collection && $array2 instanceof Collection) { + return $array1->intersect($array2); + } + + return array_intersect($array1, $array2); + } + + /** + * Returns a string from a value. If the value is array, return it json encoded + * + * @param array|string $value + * + * @return string + */ + public function stringFunc($value) + { + if (is_array($value)) { //format the array as a string + return json_encode($value); + } + + return $value; + } + + /** + * 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) + { + /** @var UserInterface|null $user */ + $user = $this->grav['user'] ?? null; + + if (!$user || !$user->authenticated || (isset($user->authorized) && !$user->authorized)) { + return false; + } + + $action = (array) $action; + foreach ($action as $key => $perms) { + $prefix = is_int($key) ? '' : $key . '.'; + $perms = $prefix ? (array) $perms : [$perms => true]; + foreach ($perms as $action2 => $authenticated) { + if ($user->authorize($prefix . $action2)) { + return $authenticated; + } + } + } + + 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 mixed + */ + public function getCookie($key) + { + return filter_input(INPUT_COOKIE, $key, FILTER_SANITIZE_STRING); + } + + /** + * Twig wrapper for PHP's preg_replace method + * + * @param mixed $subject the content to perform the replacement on + * @param mixed $pattern the regex pattern to use for matches + * @param mixed $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); + } + + /** + * redirect browser from twig + * + * @param string $url the url to redirect to + * @param int $statusCode statusCode, default 303 + */ + public function redirectFunc($url, $statusCode = 303) + { + header('Location: ' . $url, true, $statusCode); + exit(); + } + + /** + * 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 + */ + public function vardumpFunc($var) + { + 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 + * + * @param string $var + * @param bool $default + * @return string + */ + public function themeVarFunc($var, $default = null) + { + $header = $this->grav['page']->header(); + $header_classes = $header->{$var} ?? null; + + return $header_classes ?: $this->config->get('theme.' . $var, $default); + } + + /** + * 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 string|string[] $classes + * @return string + */ + public function bodyClassFunc($classes) + { + + $header = $this->grav['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; + } + + /** + * Look for a page header variable in an array of pages working its way through until a value is found + * + * @param string $var + * @param string|string[]|null $pages + * @return mixed + */ + public function pageHeaderVarFunc($var, $pages = null) + { + if ($pages === null) { + $pages = $this->grav['page']; + } + + // Make sure pages are an array + if (!\is_array($pages)) { + $pages = [$pages]; + } + + // Loop over pages and look for header vars + foreach ($pages as $page) { + if (\is_string($page)) { + $page = $this->grav['pages']->find($page); + } + + if ($page) { + $header = $page->header(); + if (isset($header->{$var})) { + return $header->{$var}; + } + } + } + + return null; + } + + /** + * Dump/Encode data into YAML format + * + * @param array $data + * @param int $inline integer number of levels of inline syntax + * @return string + */ + public function yamlEncodeFilter($data, $inline = 10) + { + return Yaml::dump($data, $inline); + } + + /** + * Decode/Parse data from YAML format + * + * @param string $data + * @return array + */ + public function yamlDecodeFilter($data) + { + return Yaml::parse($data); + } + + /** + * Function/Filter to return the type of variable + * + * @param mixed $var + * @return string + */ + public function getTypeFunc($var) + { + return gettype($var); + } + + /** + * Function/Filter to test type of variable + * + * @param mixed $var + * @param string|null $typeTest + * @param string|null $className + * @return bool + */ + public function ofTypeFunc($var, $typeTest=null, $className=null) + { + + switch ($typeTest) + { + default: + return false; + break; + + case 'array': + return is_array($var); + break; + + case 'bool': + return is_bool($var); + break; + + case 'class': + return is_object($var) === true && get_class($var) === $className; + break; + + case 'float': + return is_float($var); + break; + + case 'int': + return is_int($var); + break; + + case 'numeric': + return is_numeric($var); + break; + + case 'object': + return is_object($var); + break; + + case 'scalar': + return is_scalar($var); + break; + + case 'string': + return is_string($var); + break; + } + } +} diff --git a/system/src/Grav/Common/Twig/WriteCacheFileTrait.php b/system/src/Grav/Common/Twig/WriteCacheFileTrait.php new file mode 100644 index 0000000..f87ff67 --- /dev/null +++ b/system/src/Grav/Common/Twig/WriteCacheFileTrait.php @@ -0,0 +1,49 @@ +get('system.twig.umask_fix', false); + } + + if (self::$umask) { + $dir = dirname($file); + if (!is_dir($dir)) { + $old = umask(0002); + Folder::create($dir); + umask($old); + } + parent::writeCacheFile($file, $content); + chmod($file, 0775); + } else { + parent::writeCacheFile($file, $content); + } + } +} diff --git a/system/src/Grav/Common/Uri.php b/system/src/Grav/Common/Uri.php new file mode 100644 index 0000000..32c6e41 --- /dev/null +++ b/system/src/Grav/Common/Uri.php @@ -0,0 +1,1435 @@ +createFromString($env); + } else { + $this->createFromEnvironment(\is_array($env) ? $env : $_SERVER); + } + } + + /** + * Initialize the URI class with a url passed via parameter. + * Used for testing purposes. + * + * @param string $url the URL to use in the class + * + * @return $this + */ + public function initializeWithUrl($url = '') + { + if ($url) { + $this->createFromString($url); + } + + return $this; + } + + /** + * Initialize the URI class by providing url and root_path arguments + * + * @param string $url + * @param string $root_path + * + * @return $this + */ + public function initializeWithUrlAndRootPath($url, $root_path) + { + $this->initializeWithUrl($url); + $this->root_path = $root_path; + + return $this; + } + + /** + * Validate a hostname + * + * @param string $hostname The hostname + * + * @return boolean + */ + public function validateHostname($hostname) + { + return (bool)preg_match(static::HOSTNAME_REGEX, $hostname); + } + + /** + * Initializes the URI object based on the url set on the object + */ + public function init() + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + /** @var Language $language */ + $language = $grav['language']; + + // add the port to the base for non-standard ports + if ($this->port !== null && $config->get('system.reverse_proxy_setup') === false) { + $this->base .= ':' . (string)$this->port; + } + + // Handle custom base + $custom_base = rtrim($grav['config']->get('system.custom_base_url'), '/'); + + if ($custom_base) { + $custom_parts = parse_url($custom_base); + $orig_root_path = $this->root_path; + $this->root_path = isset($custom_parts['path']) ? rtrim($custom_parts['path'], '/') : ''; + if (isset($custom_parts['scheme'])) { + $this->base = $custom_parts['scheme'] . '://' . $custom_parts['host']; + $this->root = $custom_base; + } else { + $this->root = $this->base . $this->root_path; + } + $this->uri = Utils::replaceFirstOccurrence($orig_root_path, $this->root_path, $this->uri); + } else { + $this->root = $this->base . $this->root_path; + } + + $this->url = $this->base . $this->uri; + + $uri = Utils::replaceFirstOccurrence(static::filterPath($this->root), '', $this->url); + + // remove the setup.php based base if set: + $setup_base = $grav['pages']->base(); + if ($setup_base) { + $uri = preg_replace('|^' . preg_quote($setup_base, '|') . '|', '', $uri); + } + + // process params + $uri = $this->processParams($uri, $config->get('system.param_sep')); + + // set active language + $uri = $language->setActiveFromUri($uri); + + // split the URL and params + $bits = parse_url($uri); + + //process fragment + if (isset($bits['fragment'])) { + $this->fragment = $bits['fragment']; + } + + // Get the path. If there's no path, make sure pathinfo() still returns dirname variable + $path = $bits['path'] ?? '/'; + + // remove the extension if there is one set + $parts = pathinfo($path); + + // set the original basename + $this->basename = $parts['basename']; + + // set the extension + if (isset($parts['extension'])) { + $this->extension = $parts['extension']; + } + + // Strip the file extension for valid page types + if ($this->isValidExtension($this->extension)) { + $path = Utils::replaceLastOccurrence(".{$this->extension}", '', $path); + } + + // set the new url + $this->url = $this->root . $path; + $this->path = static::cleanPath($path); + $this->content_path = trim(Utils::replaceFirstOccurrence($this->base, '', $this->path), '/'); + if ($this->content_path !== '') { + $this->paths = explode('/', $this->content_path); + } + + // Set some Grav stuff + $grav['base_url_absolute'] = $config->get('system.custom_base_url') ?: $this->rootUrl(true); + $grav['base_url_relative'] = $this->rootUrl(false); + $grav['base_url'] = $config->get('system.absolute_urls') ? $grav['base_url_absolute'] : $grav['base_url_relative']; + + RouteFactory::setRoot($this->root_path); + RouteFactory::setLanguage($language->getLanguageURLPrefix()); + } + + /** + * Return URI path. + * + * @param string $id + * + * @return string|string[] + */ + public function paths($id = null) + { + if ($id !== null) { + return $this->paths[$id]; + } + + return $this->paths; + } + + /** + * Return route to the current URI. By default route doesn't include base path. + * + * @param bool $absolute True to include full path. + * @param bool $domain True to include domain. Works only if first parameter is also true. + * + * @return string + */ + public function route($absolute = false, $domain = false) + { + return ($absolute ? $this->rootUrl($domain) : '') . '/' . implode('/', $this->paths); + } + + /** + * Return full query string or a single query attribute. + * + * @param string $id Optional attribute. Get a single query attribute if set + * @param bool $raw If true and $id is not set, return the full query array. Otherwise return the query string + * + * @return string|array Returns an array if $id = null and $raw = true + */ + public function query($id = null, $raw = false) + { + if ($id !== null) { + return $this->queries[$id] ?? null; + } + + if ($raw) { + return $this->queries; + } + + if (!$this->queries) { + return ''; + } + + return http_build_query($this->queries); + } + + /** + * Return all or a single query parameter as a URI compatible string. + * + * @param string $id Optional parameter name. + * @param boolean $array return the array format or not + * + * @return null|string|array + */ + public function params($id = null, $array = false) + { + $config = Grav::instance()['config']; + $sep = $config->get('system.param_sep'); + + $params = null; + if ($id === null) { + if ($array) { + return $this->params; + } + $output = []; + foreach ($this->params as $key => $value) { + $output[] = "{$key}{$sep}{$value}"; + $params = '/' . implode('/', $output); + } + } elseif (isset($this->params[$id])) { + if ($array) { + return $this->params[$id]; + } + $params = "/{$id}{$sep}{$this->params[$id]}"; + } + + return $params; + } + + /** + * Get URI parameter. + * + * @param string $id + * + * @return bool|string + */ + public function param($id) + { + if (isset($this->params[$id])) { + return html_entity_decode(rawurldecode($this->params[$id]), ENT_COMPAT | ENT_HTML401, 'UTF-8'); + } + + return false; + } + + /** + * Gets the Fragment portion of a URI (eg #target) + * + * @param string $fragment + * + * @return string|null + */ + public function fragment($fragment = null) + { + if ($fragment !== null) { + $this->fragment = $fragment; + } + return $this->fragment; + } + + /** + * Return URL. + * + * @param bool $include_host Include hostname. + * + * @return string + */ + public function url($include_host = false) + { + if ($include_host) { + return $this->url; + } + + $url = Utils::replaceFirstOccurrence($this->base, '', rtrim($this->url, '/')); + + return $url ?: '/'; + } + + /** + * Return the Path + * + * @return String The path of the URI + */ + public function path() + { + return $this->path; + } + + /** + * Return the Extension of the URI + * + * @param string|null $default + * + * @return string The extension of the URI + */ + public function extension($default = null) + { + if (!$this->extension) { + $this->extension = $default; + } + + return $this->extension; + } + + public function method() + { + $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET'; + + if ($method === 'POST' && isset($_SERVER['X-HTTP-METHOD-OVERRIDE'])) { + $method = strtoupper($_SERVER['X-HTTP-METHOD-OVERRIDE']); + } + + return $method; + } + + /** + * Return the scheme of the URI + * + * @param bool $raw + * @return string The scheme of the URI + */ + public function scheme($raw = false) + { + if (!$raw) { + $scheme = ''; + if ($this->scheme) { + $scheme = $this->scheme . '://'; + } elseif ($this->host) { + $scheme = '//'; + } + + return $scheme; + } + + return $this->scheme; + } + + + /** + * Return the host of the URI + * + * @return string|null The host of the URI + */ + public function host() + { + return $this->host; + } + + /** + * Return the port number if it can be figured out + * + * @param bool $raw + * @return int|null + */ + public function port($raw = false) + { + $port = $this->port; + // If not in raw mode and port is not set, figure it out from scheme. + if (!$raw && $port === null) { + if ($this->scheme === 'http') { + $this->port = 80; + } elseif ($this->scheme === 'https') { + $this->port = 443; + } + } + + return $this->port; + } + + /** + * Return user + * + * @return string|null + */ + public function user() + { + return $this->user; + } + + /** + * Return password + * + * @return string|null + */ + public function password() + { + return $this->password; + } + + /** + * Gets the environment name + * + * @return String + */ + public function environment() + { + return $this->env; + } + + + /** + * Return the basename of the URI + * + * @return String The basename of the URI + */ + public function basename() + { + return $this->basename; + } + + /** + * Return the full uri + * + * @param bool $include_root + * @return mixed + */ + public function uri($include_root = true) + { + if ($include_root) { + return $this->uri; + } + + return Utils::replaceFirstOccurrence($this->root_path, '', $this->uri); + } + + /** + * Return the base of the URI + * + * @return String The base of the URI + */ + public function base() + { + return $this->base; + } + + /** + * Return the base relative URL including the language prefix + * or the base relative url if multi-language is not enabled + * + * @return String The base of the URI + */ + public function baseIncludingLanguage() + { + $grav = Grav::instance(); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + return $pages->baseUrl(null, false); + } + + /** + * Return root URL to the site. + * + * @param bool $include_host Include hostname. + * + * @return mixed + */ + public function rootUrl($include_host = false) + { + if ($include_host) { + return $this->root; + } + + return Utils::replaceFirstOccurrence($this->base, '', $this->root); + } + + /** + * Return current page number. + * + * @return int + */ + public function currentPage() + { + $page = (int)($this->params['page'] ?? 1); + + return max(1, $page); + } + + /** + * Return relative path to the referrer defaulting to current or given page. + * + * @param string $default + * @param string $attributes + * + * @return string + */ + public function referrer($default = null, $attributes = null) + { + $referrer = $_SERVER['HTTP_REFERER'] ?? null; + + // Check that referrer came from our site. + $root = $this->rootUrl(true); + if ($referrer) { + // Referrer should always have host set and it should come from the same base address. + if (stripos($referrer, $root) !== 0) { + $referrer = null; + } + } + + if (!$referrer) { + $referrer = $default ?: $this->route(true, true); + } + + if ($attributes) { + $referrer .= $attributes; + } + + // Return relative path. + return substr($referrer, strlen($root)); + } + + public function __toString() + { + return static::buildUrl($this->toArray()); + } + + public function toOriginalString() + { + return static::buildUrl($this->toArray(true)); + } + + public function toArray($full = false) + { + if ($full === true) { + $root_path = $this->root_path ?? ''; + $extension = isset($this->extension) && $this->isValidExtension($this->extension) ? '.' . $this->extension : ''; + $path = $root_path . $this->path . $extension; + } else { + $path = $this->path; + } + + return [ + 'scheme' => $this->scheme, + 'host' => $this->host, + 'port' => $this->port, + 'user' => $this->user, + 'pass' => $this->password, + 'path' => $path, + 'params' => $this->params, + 'query' => $this->query, + 'fragment' => $this->fragment + ]; + } + + /** + * Calculate the parameter regex based on the param_sep setting + * + * @return string + */ + public static function paramsRegex() + { + return '/\/([^\:\#\/\?]*' . Grav::instance()['config']->get('system.param_sep') . '[^\:\#\/\?]*)/'; + } + + /** + * Return the IP address of the current user + * + * @return string ip address + */ + public static function ip() + { + if (getenv('HTTP_CLIENT_IP')) { + $ip = getenv('HTTP_CLIENT_IP'); + } elseif (getenv('HTTP_X_FORWARDED_FOR') && Grav::instance()['config']->get('system.http_x_forwarded.ip')) { + $ip = getenv('HTTP_X_FORWARDED_FOR'); + } elseif (getenv('HTTP_X_FORWARDED') && Grav::instance()['config']->get('system.http_x_forwarded.ip')) { + $ip = getenv('HTTP_X_FORWARDED'); + } elseif (getenv('HTTP_FORWARDED_FOR')) { + $ip = getenv('HTTP_FORWARDED_FOR'); + } elseif (getenv('HTTP_FORWARDED')) { + $ip = getenv('HTTP_FORWARDED'); + } elseif (getenv('REMOTE_ADDR')){ + $ip = getenv('REMOTE_ADDR'); + } else { + $ip = 'UNKNOWN'; + } + + return $ip; + } + + /** + * Returns current Uri. + * + * @return \Grav\Framework\Uri\Uri + */ + public static function getCurrentUri() + { + if (!static::$currentUri) { + static::$currentUri = UriFactory::createFromEnvironment($_SERVER); + } + + return static::$currentUri; + } + + /** + * Returns current route. + * + * @return \Grav\Framework\Route\Route + */ + public static function getCurrentRoute() + { + if (!static::$currentRoute) { + $uri = Grav::instance()['uri']; + static::$currentRoute = RouteFactory::createFromParts($uri->toArray()); + } + + return static::$currentRoute; + } + + /** + * Is this an external URL? if it starts with `http` then yes, else false + * + * @param string $url the URL in question + * + * @return boolean is eternal state + */ + public static function isExternal($url) + { + return (0 === strpos($url, 'http://') || 0 === strpos($url, 'https://') || 0 === strpos($url, '//')); + } + + /** + * The opposite of built-in PHP method parse_url() + * + * @param array $parsed_url + * + * @return string + */ + public static function buildUrl($parsed_url) + { + $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . ':' : ''; + $authority = isset($parsed_url['host']) ? '//' : ''; + $host = $parsed_url['host'] ?? ''; + $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; + $user = $parsed_url['user'] ?? ''; + $pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : ''; + $pass = ($user || $pass) ? "{$pass}@" : ''; + $path = $parsed_url['path'] ?? ''; + $path = !empty($parsed_url['params']) ? rtrim($path, '/') . static::buildParams($parsed_url['params']) : $path; + $query = !empty($parsed_url['query']) ? '?' . $parsed_url['query'] : ''; + $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : ''; + + return "{$scheme}{$authority}{$user}{$pass}{$host}{$port}{$path}{$query}{$fragment}"; + } + + /** + * @param array $params + * @return string + */ + public static function buildParams(array $params) + { + if (!$params) { + return ''; + } + + $grav = Grav::instance(); + $sep = $grav['config']->get('system.param_sep'); + + $output = []; + foreach ($params as $key => $value) { + $output[] = "{$key}{$sep}{$value}"; + } + + return '/' . implode('/', $output); + } + + /** + * Converts links from absolute '/' or relative (../..) to a Grav friendly format + * + * @param PageInterface $page the current page to use as reference + * @param string|array $url the URL as it was written in the markdown + * @param string $type the type of URL, image | link + * @param bool $absolute if null, will use system default, if true will use absolute links internally + * @param bool $route_only only return the route, not full URL path + * @return string|array the more friendly formatted url + */ + public static function convertUrl(PageInterface $page, $url, $type = 'link', $absolute = false, $route_only = false) + { + $grav = Grav::instance(); + + $uri = $grav['uri']; + + // Link processing should prepend language + $language = $grav['language']; + $language_append = ''; + if ($type === 'link' && $language->enabled()) { + $language_append = $language->getLanguageURLPrefix(); + } + + // Handle Excerpt style $url array + $url_path = is_array($url) ? $url['path'] : $url; + + $external = false; + $base = $grav['base_url_relative']; + $base_url = rtrim($base . $grav['pages']->base(), '/') . $language_append; + $pages_dir = $grav['locator']->findResource('page://'); + + // if absolute and starts with a base_url move on + if (isset($url['scheme']) && Utils::startsWith($url['scheme'], 'http')) { + $external = true; + } elseif ($url_path === '' && isset($url['fragment'])) { + $external = true; + } elseif ($url_path === '/' || ($base_url !== '' && Utils::startsWith($url_path, $base_url))) { + $url_path = $base_url . $url_path; + } else { + + // see if page is relative to this or absolute + if (Utils::startsWith($url_path, '/')) { + $normalized_url = Utils::normalizePath($base_url . $url_path); + $normalized_path = Utils::normalizePath($pages_dir . $url_path); + } else { + $page_route = ($page->home() && !empty($url_path)) ? $page->rawRoute() : $page->route(); + $normalized_url = $base_url . Utils::normalizePath(rtrim($page_route, '/') . '/' . $url_path); + $normalized_path = Utils::normalizePath($page->path() . '/' . $url_path); + } + + // special check to see if path checking is required. + $just_path = Utils::replaceFirstOccurrence($normalized_url, '', $normalized_path); + if ($normalized_url === '/' || $just_path === $page->path()) { + $url_path = $normalized_url; + } else { + $url_bits = static::parseUrl($normalized_path); + $full_path = $url_bits['path']; + $raw_full_path = rawurldecode($full_path); + + if (file_exists($raw_full_path)) { + $full_path = $raw_full_path; + } elseif (!file_exists($full_path)) { + $full_path = false; + } + + if ($full_path) { + $path_info = pathinfo($full_path); + $page_path = $path_info['dirname']; + $filename = ''; + + if ($url_path === '..') { + $page_path = $full_path; + } else { + // save the filename if a file is part of the path + if (is_file($full_path)) { + if ($path_info['extension'] !== 'md') { + $filename = '/' . $path_info['basename']; + } + } else { + $page_path = $full_path; + } + } + + // get page instances and try to find one that fits + $instances = $grav['pages']->instances(); + if (isset($instances[$page_path])) { + /** @var PageInterface $target */ + $target = $instances[$page_path]; + $url_bits['path'] = $base_url . rtrim($target->route(), '/') . $filename; + + $url_path = Uri::buildUrl($url_bits); + } else { + $url_path = $normalized_url; + } + } else { + $url_path = $normalized_url; + } + } + } + + // handle absolute URLs + if (\is_array($url) && !$external && ($absolute === true || $grav['config']->get('system.absolute_urls', false))) { + + $url['scheme'] = $uri->scheme(true); + $url['host'] = $uri->host(); + $url['port'] = $uri->port(true); + + // check if page exists for this route, and if so, check if it has SSL enabled + $pages = $grav['pages']; + $routes = $pages->routes(); + + // if this is an image, get the proper path + $url_bits = pathinfo($url_path); + if (isset($url_bits['extension'])) { + $target_path = $url_bits['dirname']; + } else { + $target_path = $url_path; + } + + // strip base from this path + $target_path = Utils::replaceFirstOccurrence($uri->rootUrl(), '', $target_path); + + // set to / if root + if (empty($target_path)) { + $target_path = '/'; + } + + // look to see if this page exists and has ssl enabled + if (isset($routes[$target_path])) { + $target_page = $pages->get($routes[$target_path]); + if ($target_page) { + $ssl_enabled = $target_page->ssl(); + if ($ssl_enabled !== null) { + if ($ssl_enabled) { + $url['scheme'] = 'https'; + } else { + $url['scheme'] = 'http'; + } + } + } + } + } + + // Handle route only + if ($route_only) { + $url_path = Utils::replaceFirstOccurrence(static::filterPath($base_url), '', $url_path); + } + + // transform back to string/array as needed + if (is_array($url)) { + $url['path'] = $url_path; + } else { + $url = $url_path; + } + + return $url; + } + + public static function parseUrl($url) + { + $grav = Grav::instance(); + + $encodedUrl = preg_replace_callback( + '%[^:/@?&=#]+%usD', + function ($matches) { return rawurlencode($matches[0]); }, + $url + ); + + $parts = parse_url($encodedUrl); + + if (false === $parts) { + return false; + } + + foreach($parts as $name => $value) { + $parts[$name] = rawurldecode($value); + } + + if (!isset($parts['path'])) { + $parts['path'] = ''; + } + + list($stripped_path, $params) = static::extractParams($parts['path'], $grav['config']->get('system.param_sep')); + + if (!empty($params)) { + $parts['path'] = $stripped_path; + $parts['params'] = $params; + } + + return $parts; + } + + public static function extractParams($uri, $delimiter) + { + $params = []; + + if (strpos($uri, $delimiter) !== false) { + preg_match_all(static::paramsRegex(), $uri, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $param = explode($delimiter, $match[1]); + if (\count($param) === 2) { + $plain_var = filter_var(rawurldecode($param[1]), FILTER_SANITIZE_STRING); + $params[$param[0]] = $plain_var; + $uri = str_replace($match[0], '', $uri); + } + } + } + + return [$uri, $params]; + } + + /** + * Converts links from absolute '/' or relative (../..) to a Grav friendly format + * + * @param PageInterface $page the current page to use as reference + * @param string $markdown_url the URL as it was written in the markdown + * @param string $type the type of URL, image | link + * @param null $relative if null, will use system default, if true will use relative links internally + * + * @return string the more friendly formatted url + */ + public static function convertUrlOld(PageInterface $page, $markdown_url, $type = 'link', $relative = null) + { + $grav = Grav::instance(); + + $language = $grav['language']; + + // Link processing should prepend language + $language_append = ''; + if ($type === 'link' && $language->enabled()) { + $language_append = $language->getLanguageURLPrefix(); + } + $pages_dir = $grav['locator']->findResource('page://'); + if ($relative === null) { + $base = $grav['base_url']; + } else { + $base = $relative ? $grav['base_url_relative'] : $grav['base_url_absolute']; + } + + $base_url = rtrim($base . $grav['pages']->base(), '/') . $language_append; + + // if absolute and starts with a base_url move on + if (pathinfo($markdown_url, PATHINFO_DIRNAME) === '.' && $page->url() === '/') { + return '/' . $markdown_url; + } + // no path to convert + if ($base_url !== '' && Utils::startsWith($markdown_url, $base_url)) { + return $markdown_url; + } + // if contains only a fragment + if (Utils::startsWith($markdown_url, '#')) { + return $markdown_url; + } + + $target = null; + // see if page is relative to this or absolute + if (Utils::startsWith($markdown_url, '/')) { + $normalized_url = Utils::normalizePath($base_url . $markdown_url); + $normalized_path = Utils::normalizePath($pages_dir . $markdown_url); + } else { + $normalized_url = $base_url . Utils::normalizePath($page->route() . '/' . $markdown_url); + $normalized_path = Utils::normalizePath($page->path() . '/' . $markdown_url); + } + + // special check to see if path checking is required. + $just_path = Utils::replaceFirstOccurrence($normalized_url, '', $normalized_path); + if ($just_path === $page->path()) { + return $normalized_url; + } + + $url_bits = parse_url($normalized_path); + $full_path = $url_bits['path']; + + if (file_exists($full_path)) { + // do nothing + } elseif (file_exists(rawurldecode($full_path))) { + $full_path = rawurldecode($full_path); + } else { + return $normalized_url; + } + + $path_info = pathinfo($full_path); + $page_path = $path_info['dirname']; + $filename = ''; + + if ($markdown_url === '..') { + $page_path = $full_path; + } else { + // save the filename if a file is part of the path + if (is_file($full_path)) { + if ($path_info['extension'] !== 'md') { + $filename = '/' . $path_info['basename']; + } + } else { + $page_path = $full_path; + } + } + + // get page instances and try to find one that fits + $instances = $grav['pages']->instances(); + if (isset($instances[$page_path])) { + /** @var PageInterface $target */ + $target = $instances[$page_path]; + $url_bits['path'] = $base_url . rtrim($target->route(), '/') . $filename; + + return static::buildUrl($url_bits); + } + + return $normalized_url; + } + + /** + * Adds the nonce to a URL for a specific action + * + * @param string $url the url + * @param string $action the action + * @param string $nonceParamName the param name to use + * + * @return string the url with the nonce + */ + public static function addNonce($url, $action, $nonceParamName = 'nonce') + { + $fake = $url && strpos($url, '/') === 0; + + if ($fake) { + $url = 'http://domain.com' . $url; + } + $uri = new static($url); + $parts = $uri->toArray(); + $nonce = Utils::getNonce($action); + $parts['params'] = ($parts['params'] ?? []) + [$nonceParamName => $nonce]; + + if ($fake) { + unset($parts['scheme'], $parts['host']); + } + + return static::buildUrl($parts); + } + + /** + * Is the passed in URL a valid URL? + * + * @param string $url + * @return bool + */ + public static function isValidUrl($url) + { + $regex = '/^(?:(https?|ftp|telnet):)?\/\/((?:[a-z0-9@:.-]|%[0-9A-F]{2}){3,})(?::(\d+))?((?:\/(?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\@]|%[0-9A-F]{2})*)*)(?:\?((?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\/?@]|%[0-9A-F]{2})*))?(?:#((?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\/?@]|%[0-9A-F]{2})*))?/'; + if (preg_match($regex, $url)) { + return true; + } + + return false; + } + + /** + * Removes extra double slashes and fixes back-slashes + * + * @param string $path + * @return mixed|string + */ + public static function cleanPath($path) + { + $regex = '/(\/)\/+/'; + $path = str_replace(['\\', '/ /'], '/', $path); + $path = preg_replace($regex,'$1',$path); + + return $path; + } + + /** + * Filters the user info string. + * + * @param string $info The raw user or password. + * @return string The percent-encoded user or password string. + */ + public static function filterUserInfo($info) + { + return $info !== null ? UriPartsFilter::filterUserInfo($info) : ''; + } + + /** + * Filter Uri path. + * + * This method percent-encodes all reserved + * characters in the provided path string. This method + * will NOT double-encode characters that are already + * percent-encoded. + * + * @param string $path The raw uri path. + * @return string The RFC 3986 percent-encoded uri path. + * @link http://www.faqs.org/rfcs/rfc3986.html + */ + public static function filterPath($path) + { + return $path !== null ? UriPartsFilter::filterPath($path) : ''; + } + + /** + * Filters the query string or fragment of a URI. + * + * @param string $query The raw uri query string. + * @return string The percent-encoded query string. + */ + public static function filterQuery($query) + { + return $query !== null ? UriPartsFilter::filterQueryOrFragment($query) : ''; + } + + /** + * @param array $env + */ + protected function createFromEnvironment(array $env) + { + // Build scheme. + if (isset($env['HTTP_X_FORWARDED_PROTO']) && Grav::instance()['config']->get('system.http_x_forwarded.protocol')) { + $this->scheme = $env['HTTP_X_FORWARDED_PROTO']; + } elseif (isset($env['X-FORWARDED-PROTO'])) { + $this->scheme = $env['X-FORWARDED-PROTO']; + } elseif (isset($env['HTTP_CLOUDFRONT_FORWARDED_PROTO'])) { + $this->scheme = $env['HTTP_CLOUDFRONT_FORWARDED_PROTO']; + } elseif (isset($env['REQUEST_SCHEME']) && empty($env['HTTPS'])) { + $this->scheme = $env['REQUEST_SCHEME']; + } else { + $https = $env['HTTPS'] ?? ''; + $this->scheme = (empty($https) || strtolower($https) === 'off') ? 'http' : 'https'; + } + + // Build user and password. + $this->user = $env['PHP_AUTH_USER'] ?? null; + $this->password = $env['PHP_AUTH_PW'] ?? null; + + // Build host. + if (isset($env['HTTP_X_FORWARDED_HOST']) && Grav::instance()['config']->get('system.http_x_forwarded.host')) { + $hostname = $env['HTTP_X_FORWARDED_HOST']; + } else if (isset($env['HTTP_HOST'])) { + $hostname = $env['HTTP_HOST']; + } elseif (isset($env['SERVER_NAME'])) { + $hostname = $env['SERVER_NAME']; + } else { + $hostname = 'localhost'; + } + // Remove port from HTTP_HOST generated $hostname + $hostname = Utils::substrToString($hostname, ':'); + // Validate the hostname + $this->host = $this->validateHostname($hostname) ? $hostname : 'unknown'; + + // Build port. + if (isset($env['HTTP_X_FORWARDED_PORT']) && Grav::instance()['config']->get('system.http_x_forwarded.port')) { + $this->port = (int)$env['HTTP_X_FORWARDED_PORT']; + } elseif (isset($env['X-FORWARDED-PORT'])) { + $this->port = (int)$env['X-FORWARDED-PORT']; + } elseif (isset($env['HTTP_CLOUDFRONT_FORWARDED_PROTO'])) { + // Since AWS Cloudfront does not provide a forwarded port header, + // we have to build the port using the scheme. + $this->port = $this->port(); + } elseif (isset($env['SERVER_PORT'])) { + $this->port = (int)$env['SERVER_PORT']; + } else { + $this->port = null; + } + + if ($this->hasStandardPort()) { + $this->port = null; + } + + // Build path. + $request_uri = $env['REQUEST_URI'] ?? ''; + $this->path = rawurldecode(parse_url('http://example.com' . $request_uri, PHP_URL_PATH)); + + // Build query string. + $this->query = $env['QUERY_STRING'] ?? ''; + if ($this->query === '') { + $this->query = parse_url('http://example.com' . $request_uri, PHP_URL_QUERY); + } + + // Support ngnix routes. + if (strpos($this->query, '_url=') === 0) { + parse_str($this->query, $query); + unset($query['_url']); + $this->query = http_build_query($query); + } + + // Build fragment. + $this->fragment = null; + + // Filter userinfo, path and query string. + $this->user = $this->user !== null ? static::filterUserInfo($this->user) : null; + $this->password = $this->password !== null ? static::filterUserInfo($this->password) : null; + $this->path = empty($this->path) ? '/' : static::filterPath($this->path); + $this->query = static::filterQuery($this->query); + + $this->reset(); + } + + /** + * Does this Uri use a standard port? + * + * @return bool + */ + protected function hasStandardPort() + { + return ($this->port === 80 || $this->port === 443); + } + + /** + * @param string $url + */ + protected function createFromString($url) + { + // Set Uri parts. + $parts = parse_url($url); + if ($parts === false) { + throw new \RuntimeException('Malformed URL: ' . $url); + } + $this->scheme = $parts['scheme'] ?? null; + $this->user = $parts['user'] ?? null; + $this->password = $parts['pass'] ?? null; + $this->host = $parts['host'] ?? null; + $this->port = isset($parts['port']) ? (int)$parts['port'] : null; + $this->path = $parts['path'] ?? ''; + $this->query = $parts['query'] ?? ''; + $this->fragment = $parts['fragment'] ?? null; + + // Validate the hostname + if ($this->host) { + $this->host = $this->validateHostname($this->host) ? $this->host : 'unknown'; + } + // Filter userinfo, path, query string and fragment. + $this->user = $this->user !== null ? static::filterUserInfo($this->user) : null; + $this->password = $this->password !== null ? static::filterUserInfo($this->password) : null; + $this->path = empty($this->path) ? '/' : static::filterPath($this->path); + $this->query = static::filterQuery($this->query); + $this->fragment = $this->fragment !== null ? static::filterQuery($this->fragment) : null; + + $this->reset(); + } + + protected function reset() + { + // resets + parse_str($this->query, $this->queries); + $this->extension = null; + $this->basename = null; + $this->paths = []; + $this->params = []; + $this->env = $this->buildEnvironment(); + $this->uri = $this->path . (!empty($this->query) ? '?' . $this->query : ''); + + $this->base = $this->buildBaseUrl(); + $this->root_path = $this->buildRootPath(); + $this->root = $this->base . $this->root_path; + $this->url = $this->base . $this->uri; + } + + /** + * Get post from either $_POST or JSON response object + * By default returns all data, or can return a single item + * + * @param string $element + * @param string $filter_type + * @return array|mixed|null + */ + public function post($element = null, $filter_type = null) + { + if (!$this->post) { + $content_type = $this->getContentType(); + if ($content_type === 'application/json') { + $json = file_get_contents('php://input'); + $this->post = json_decode($json, true); + } elseif (!empty($_POST)) { + $this->post = (array)$_POST; + } + + $event = new Event(['post' => &$this->post]); + Grav::instance()->fireEvent('onHttpPostFilter', $event); + } + + if ($this->post && null !== $element) { + $item = Utils::getDotNotation($this->post, $element); + if ($filter_type) { + $item = filter_var($item, $filter_type); + } + return $item; + } + + return $this->post; + } + + /** + * Get content type from request + * + * @param bool $short + * @return null|string + */ + public function getContentType($short = true) + { + if (isset($_SERVER['CONTENT_TYPE'])) { + $content_type = $_SERVER['CONTENT_TYPE']; + if ($short) { + return Utils::substrToString($content_type,';'); + } + return $content_type; + } + return null; + } + + /** + * Check if this is a valid Grav extension + * + * @param $extension + * @return bool + */ + public function isValidExtension($extension) + { + $valid_page_types = implode('|', Utils::getSupportPageTypes()); + + // Strip the file extension for valid page types + if (preg_match('/(' . $valid_page_types . ')/', $extension)) { + return true; + } + return false; + } + + /** + * Allow overriding of any element (be careful!) + * + * @param $data + * @return Uri + */ + public function setUriProperties($data) + { + foreach (get_object_vars($this) as $property => $default) { + if (!array_key_exists($property, $data)) continue; + $this->{$property} = $data[$property]; // assign value to object + } + return $this; + } + + /** + * Get the base URI with port if needed + * + * @return string + */ + private function buildBaseUrl() + { + return $this->scheme() . $this->host; + } + + /** + * Get the Grav Root Path + * + * @return string + */ + private function buildRootPath() + { + // In Windows script path uses backslash, convert it: + $scriptPath = str_replace('\\', '/', $_SERVER['PHP_SELF']); + $rootPath = str_replace(' ', '%20', rtrim(substr($scriptPath, 0, strpos($scriptPath, 'index.php')), '/')); + + return $rootPath; + } + + private function buildEnvironment() + { + // check for localhost variations + if ($this->host === '127.0.0.1' || $this->host === '::1') { + return 'localhost'; + } + + return $this->host ?: 'unknown'; + } + + /** + * Process any params based in this URL, supports any valid delimiter + * + * @param string $uri + * @param string $delimiter + * + * @return string + */ + private function processParams($uri, $delimiter = ':') + { + if (strpos($uri, $delimiter) !== false) { + preg_match_all(static::paramsRegex(), $uri, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $param = explode($delimiter, $match[1]); + if (count($param) === 2) { + $plain_var = filter_var($param[1], FILTER_SANITIZE_STRING); + $this->params[$param[0]] = $plain_var; + $uri = str_replace($match[0], '', $uri); + } + } + } + return $uri; + } +} diff --git a/system/src/Grav/Common/User/Authentication.php b/system/src/Grav/Common/User/Authentication.php new file mode 100644 index 0000000..2447732 --- /dev/null +++ b/system/src/Grav/Common/User/Authentication.php @@ -0,0 +1,55 @@ +get('user/account'); + } + + parent::__construct($items, $blueprints); + } + + /** + * @param string $offset + * @return bool + */ + public function offsetExists($offset) + { + $value = parent::offsetExists($offset); + + // Handle special case where user was logged in before 'authorized' was added to the user object. + if (false === $value && $offset === 'authorized') { + $value = $this->offsetExists('authenticated'); + } + + return $value; + } + + /** + * @param string $offset + * @return mixed + */ + public function offsetGet($offset) + { + $value = parent::offsetGet($offset); + + // Handle special case where user was logged in before 'authorized' was added to the user object. + if (null === $value && $offset === 'authorized') { + $value = $this->offsetGet('authenticated'); + $this->offsetSet($offset, $value); + } + + return $value; + } + + public function isValid(): bool + { + return $this->items !== null; + } + + /** + * Update object with data + * + * @param array $data + * @param array $files + * @return $this + */ + public function update(array $data, array $files = []) + { + // Note: $this->merge() would cause infinite loop as it calls this method. + parent::merge($data); + + return $this; + } + + /** + * Save user without the username + */ + public function save() + { + /** @var CompiledYamlFile $file */ + $file = $this->file(); + if (!$file || !$file->filename()) { + user_error(__CLASS__ . ': calling \$user = new ' . __CLASS__ . "() is deprecated since Grav 1.6, use \$grav['accounts']->load(\$username) or \$grav['accounts']->load('') instead", E_USER_DEPRECATED); + } + + if ($file) { + $username = $this->get('username'); + + if (!$file->filename()) { + $locator = Grav::instance()['locator']; + $file->filename($locator->findResource('account://' . mb_strtolower($username) . YAML_EXT, true, true)); + } + + // if plain text password, hash it and remove plain text + $password = $this->get('password'); + if ($password) { + $this->set('hashed_password', Authentication::create($password)); + $this->undef('password'); + } + + $data = $this->items; + unset($data['username'], $data['authenticated'], $data['authorized']); + + $file->save($data); + } + } + + public function getMedia() + { + if (null === $this->_media) { + // Media object should only contain avatar, nothing else. + $media = new Media($this->getMediaFolder(), $this->getMediaOrder(), false); + + $path = $this->getAvatarFile(); + if ($path && is_file($path)) { + $medium = MediumFactory::fromFile($path); + if ($medium) { + $media->add(basename($path), $medium); + } + } + + $this->_media = $media; + } + + return $this->_media; + } + + public function getMediaFolder() + { + return $this->blueprints()->fields()['avatar']['destination'] ?? 'user://accounts/avatars'; + } + + public function getMediaOrder() + { + return []; + } + + /** + * Serialize user. + */ + public function __sleep() + { + return [ + 'items', + 'storage' + ]; + } + + /** + * Unserialize user. + */ + public function __wakeup() + { + $this->gettersVariable = 'items'; + $this->nestedSeparator = '.'; + + if (null === $this->items) { + $this->items = []; + } + + // Always set blueprints. + if (null === $this->blueprints) { + $this->blueprints = (new Blueprints)->get('user/account'); + } + } + + /** + * Merge two configurations together. + * + * @param array $data + * @return $this + * @deprecated 1.6 Use `->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); + + return $this->update($data); + } + + /** + * Return media object for the User's avatar. + * + * @return ImageMedium|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); + } + + /** + * Implements Countable interface. + * + * @return int + * @deprecated 1.6 Method makes no sense for user account. + */ + public function count() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + return parent::count(); + } + + protected function getAvatarFile(): ?string + { + $avatars = $this->get('avatar'); + if (\is_array($avatars) && $avatars) { + $avatar = array_shift($avatars); + return $avatar['path'] ?? null; + } + + return null; + } +} diff --git a/system/src/Grav/Common/User/DataUser/UserCollection.php b/system/src/Grav/Common/User/DataUser/UserCollection.php new file mode 100644 index 0000000..800322a --- /dev/null +++ b/system/src/Grav/Common/User/DataUser/UserCollection.php @@ -0,0 +1,130 @@ +className = $className; + } + + /** + * Load user account. + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $username + * @return UserInterface + */ + public function load($username): UserInterface + { + $grav = Grav::instance(); + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + // force lowercase of username + $username = mb_strtolower($username); + + $filename = 'account://' . $username . YAML_EXT; + $path = $locator->findResource($filename) ?: $locator->findResource($filename, true, true); + $file = CompiledYamlFile::instance($path); + $content = (array)$file->content() + ['username' => $username, 'state' => 'enabled']; + + $userClass = $this->className; + $callable = function() { + $blueprints = new Blueprints; + + return $blueprints->get('user/account'); + }; + + /** @var UserInterface $user */ + $user = new $userClass($content, $callable); + $user->file($file); + + return $user; + } + + /** + * Find a user by username, email, etc + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + */ + public function find($query, $fields = ['username', 'email']): UserInterface + { + $fields = (array)$fields; + + $account_dir = Grav::instance()['locator']->findResource('account://'); + $files = $account_dir ? array_diff(scandir($account_dir), ['.', '..']) : []; + + // Try with username first, you never know! + if (in_array('username', $fields, true)) { + $user = $this->load($query); + unset($fields[array_search('username', $fields, true)]); + } else { + $user = $this->load(''); + } + + // If not found, try the fields + if (!$user->exists()) { + foreach ($files as $file) { + if (Utils::endsWith($file, YAML_EXT)) { + $find_user = $this->load(trim(pathinfo($file, PATHINFO_FILENAME))); + foreach ($fields as $field) { + if ($find_user[$field] === $query) { + return $find_user; + } + } + } + } + } + return $user; + } + + /** + * Remove user account. + * + * @param string $username + * + * @return bool True if the action was performed + */ + public function delete($username): bool + { + $file_path = Grav::instance()['locator']->findResource('account://' . $username . YAML_EXT); + + return $file_path && unlink($file_path); + } + + public function count(): int + { + // check for existence of a user account + $account_dir = $file_path = Grav::instance()['locator']->findResource('account://'); + $accounts = glob($account_dir . '/*.yaml') ?: []; + + return count($accounts); + } +} diff --git a/system/src/Grav/Common/User/FlexUser/Storage/UserFileStorage.php b/system/src/Grav/Common/User/FlexUser/Storage/UserFileStorage.php new file mode 100644 index 0000000..3156fc4 --- /dev/null +++ b/system/src/Grav/Common/User/FlexUser/Storage/UserFileStorage.php @@ -0,0 +1,16 @@ + false, + 'find' => false, + 'remove' => false, + 'get' => true, + 'set' => false, + 'undef' => false, + 'def' => false, + ] + parent::getCachedMethods(); + } + + public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false) + { + // User can only be authenticated via login. + unset($elements['authenticated'], $elements['authorized']); + + parent::__construct($elements, $key, $directory, $validate); + + // Define username and state if they aren't set. + $this->defProperty('username', $key); + $this->defProperty('state', 'enabled'); + } + + /** + * 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 $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 $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 $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 $separator Separator, defaults to '.' + * @return $this + */ + public function def($name, $default = null, $separator = null) + { + $this->defNestedProperty($name, $default, $separator); + + return $this; + } + + /** + * Get value from a page variable (used mostly for creating edit forms). + * + * @param string $name Variable name. + * @param mixed $default + * @param string|null $separator + * @return mixed + */ + public function getFormValue(string $name, $default = null, string $separator = null) + { + $value = parent::getFormValue($name, null, $separator); + + if ($name === 'avatar') { + return $this->parseFileProperty($value); + } + + if (null === $value) { + if ($name === 'media_order') { + return implode(',', $this->getMediaOrder()); + } + } + + return $value ?? $default; + } + + /** + * @param string $property + * @param mixed $default + * @return mixed + */ + public function getProperty($property, $default = null) + { + $value = parent::getProperty($property, $default); + + if ($property === 'avatar') { + $value = $this->parseFileProperty($value); + } + + return $value; + } + + /** + * Convert object into an array. + * + * @return array + */ + public function toArray() + { + $array = $this->jsonSerialize(); + $array['avatar'] = $this->parseFileProperty($array['avatar'] ?? null); + + 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 $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 $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()); + } + + /** + * @param string $name + * @return Blueprint + */ + public function getBlueprint(string $name = '') + { + $blueprint = clone parent::getBlueprint($name); + + $blueprint->addDynamicHandler('flex', function (array &$field, $property, array &$call) { + $params = (array)$call['params']; + $method = array_shift($params); + + if (method_exists($this, $method)) { + $value = $this->{$method}(...$params); + if (\is_array($value) && isset($field[$property]) && \is_array($field[$property])) { + $field[$property] = array_merge_recursive($field[$property], $value); + } else { + $field[$property] = $value; + } + } + }); + + return $blueprint->init(); + } + + /** + * 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 $storage Optionally enter a new storage. + * @return FileInterface + */ + public function file(FileInterface $storage = null) + { + if (null !== $storage) { + $this->_storage = $storage; + } + + return $this->_storage; + } + + public function isValid(): bool + { + return $this->getProperty('state') !== null; + } + + /** + * Save user without the username + */ + 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'); + if (null !== $password) { + $this->unsetProperty('password'); + $this->unsetProperty('password1'); + $this->unsetProperty('password2'); + $this->setProperty('hashed_password', Authentication::create($password)); + } + + return parent::save(); + } + + public function isAuthorized(string $action, string $scope = null, UserInterface $user = null): bool + { + if (null === $user) { + /** @var UserInterface $user */ + $user = Grav::instance()['user'] ?? null; + } + + if ($user instanceof User && $user->getStorageKey() === $this->getStorageKey()) { + return true; + } + + return parent::isAuthorized($action, $scope, $user); + } + + /** + * @return array + */ + public function prepareStorage(): array + { + $elements = parent::prepareStorage(); + + // Do not save authorization information. + unset($elements['authenticated'], $elements['authorized']); + + return $elements; + } + + /** + * Merge two configurations together. + * + * @param array $data + * @return $this + * @deprecated 1.6 Use `->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|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); + } + + /** + * Implements Countable interface. + * + * @return int + * @deprecated 1.6 Method makes no sense for user account. + */ + public function count() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + return \count($this->jsonSerialize()); + } + + /** + * Gets the associated media collection (original images). + * + * @return MediaCollectionInterface Representation of associated media. + */ + protected function getOriginalMedia() + { + return (new Media($this->getMediaFolder() . '/original', $this->getMediaOrder()))->setTimestamps(); + } + + /** + * @param array $files + */ + protected function setUpdatedMedia(array $files): void + { + $list = []; + $list_original = []; + foreach ($files as $field => $group) { + foreach ($group as $filename => $file) { + if (strpos($field, '/original')) { + // Special handling for original images. + $list_original[$filename] = $file; + continue; + } + + $list[$filename] = $file; + + if ($file) { + /** @var FormFlashFile $file */ + $data = $file->jsonSerialize(); + $path = $file->getClientFilename(); + unset($data['tmp_name'], $data['path']); + + $this->setNestedProperty("{$field}\n{$path}", $data, "\n"); + } else { + $this->unsetNestedProperty("{$field}\n{$filename}", "\n"); + } + } + } + + $this->_uploads = $list; + $this->_uploads_original = $list_original; + } + + protected function saveUpdatedMedia(): void + { + // Upload/delete original sized images. + /** @var FormFlashFile $file */ + foreach ($this->_uploads_original ?? [] as $name => $file) { + $name = 'original/' . $name; + if ($file) { + $this->uploadMediaFile($file, $name); + } else { + $this->deleteMediaFile($name); + } + } + + /** + * @var string $filename + * @var UploadedFileInterface $file + */ + foreach ($this->getUpdatedMedia() as $filename => $file) { + if ($file) { + $this->uploadMediaFile($file, $filename); + } else { + $this->deleteMediaFile($filename); + } + } + + $this->setUpdatedMedia([]); + } + + /** + * @param array $value + * @return array + */ + protected function parseFileProperty($value) + { + if (!\is_array($value)) { + return $value; + } + + $originalMedia = $this->getOriginalMedia(); + $resizedMedia = $this->getMedia(); + + $list = []; + foreach ($value as $filename => $info) { + if (!\is_array($info)) { + continue; + } + + /** @var Medium $thumbFile */ + $thumbFile = $resizedMedia[$filename]; + /** @var Medium $imageFile */ + $imageFile = $originalMedia[$filename] ?? $thumbFile; + if ($thumbFile) { + $list[$filename] = [ + 'name' => $filename, + 'type' => $info['type'], + 'size' => $info['size'], + 'image_url' => $imageFile->url(), + 'thumb_url' => $thumbFile->url(), + 'cropData' => (object)($imageFile->metadata()['upload']['crop'] ?? []) + ]; + } + } + + return $list; + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return [ + 'type' => 'accounts', + 'key' => $this->getKey(), + 'elements' => $this->jsonSerialize(), + 'storage' => $this->getStorage() + ]; + } + + /** + * @param array $serialized + */ + protected function doUnserialize(array $serialized): void + { + $grav = Grav::instance(); + + /** @var UserCollection $accounts */ + $accounts = $grav['accounts']; + + $directory = $accounts->getFlexDirectory(); + if (!$directory) { + throw new \InvalidArgumentException('Internal error'); + } + + $this->setFlexDirectory($directory); + $this->setStorage($serialized['storage']); + $this->setKey($serialized['key']); + $this->setElements($serialized['elements']); + } +} diff --git a/system/src/Grav/Common/User/FlexUser/UserCollection.php b/system/src/Grav/Common/User/FlexUser/UserCollection.php new file mode 100644 index 0000000..0f7a1d1 --- /dev/null +++ b/system/src/Grav/Common/User/FlexUser/UserCollection.php @@ -0,0 +1,100 @@ +exists(). + * + * @param string $username + * @return User + */ + public function load($username): UserInterface + { + if ($username !== '') { + $key = mb_strtolower($username); + $user = $this->get($key); + if ($user) { + return $user; + } + } else { + $key = ''; + } + + $directory = $this->getFlexDirectory(); + + /** @var User $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 array $fields the fields to search + * @return User + */ + public function find($query, $fields = ['username', 'email']): UserInterface + { + 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(mb_strtolower($query)); + } else { + $user = parent::find($query, $field); + } + if ($user) { + 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; + } +} diff --git a/system/src/Grav/Common/User/FlexUser/UserIndex.php b/system/src/Grav/Common/User/FlexUser/UserIndex.php new file mode 100644 index 0000000..f95f51b --- /dev/null +++ b/system/src/Grav/Common/User/FlexUser/UserIndex.php @@ -0,0 +1,133 @@ +exists(). + * + * @param string $username + * + * @return User + */ + public function load($username): UserInterface + { + if ($username !== '') { + $key = mb_strtolower($username); + $user = $this->get($key); + if ($user) { + return $user; + } + } else { + $key = ''; + } + + $directory = $this->getFlexDirectory(); + + /** @var User $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 array $fields the fields to search + * @return User + */ + public function find($query, $fields = ['username', 'email']): UserInterface + { + 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') { + $user = $this->withKeyField('email')->get($query); + } elseif ($field === 'username') { + $user = $this->get(mb_strtolower($query)); + } else { + $user = $this->__call('find', [$query, $field]); + } + if ($user) { + return $user; + } + } + + return $this->load(''); + } + + protected static function updateIndexData(array &$entry, array $data) + { + $entry['key'] = mb_strtolower($entry['key']); + $entry['email'] = isset($data['email']) ? mb_strtolower($data['email']) : 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); + } + + protected static function onChanges(array $entries, array $added, array $updated, array $removed) + { + $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/User/Group.php b/system/src/Grav/Common/User/Group.php new file mode 100644 index 0000000..9815c0e --- /dev/null +++ b/system/src/Grav/Common/User/Group.php @@ -0,0 +1,155 @@ +get('groups', []); + } + + /** + * Get the groups list + * + * @return array + */ + public static function groupNames() + { + $groups = []; + + foreach(static::groups() as $groupname => $group) { + $groups[$groupname] = $group['readableName'] ?? $groupname; + } + + return $groups; + } + + /** + * Checks if a group exists + * + * @param string $groupname + * + * @return bool + */ + public static function groupExists($groupname) + { + return isset(self::groups()[$groupname]); + } + + /** + * Get a group by name + * + * @param string $groupname + * + * @return object + */ + public static function load($groupname) + { + $groups = self::groups(); + + $content = $groups[$groupname] ?? []; + $content += ['groupname' => $groupname]; + + $blueprints = new Blueprints(); + $blueprint = $blueprints->get('user/group'); + + return new Group($content, $blueprint); + } + + /** + * Save a group + */ + public function save() + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + $blueprints = new Blueprints(); + $blueprint = $blueprints->get('user/group'); + + $config->set("groups.{$this->get('groupname')}", []); + + $fields = $blueprint->fields(); + foreach ($fields as $field) { + if ($field['type'] === 'text') { + $value = $field['name']; + if (isset($this->items['data'][$value])) { + $config->set("groups.{$this->get('groupname')}.{$value}", $this->items['data'][$value]); + } + } + if ($field['type'] === 'array' || $field['type'] === 'permissions') { + $value = $field['name']; + $arrayValues = Utils::getDotNotation($this->items['data'], $field['name']); + + if ($arrayValues) { + foreach ($arrayValues as $arrayIndex => $arrayValue) { + $config->set("groups.{$this->get('groupname')}.{$value}.{$arrayIndex}", $arrayValue); + } + } + } + } + + $type = 'groups'; + $blueprints = $this->blueprints(); + + $filename = CompiledYamlFile::instance($grav['locator']->findResource("config://{$type}.yaml")); + + $obj = new Data($config->get($type), $blueprints); + $obj->file($filename); + $obj->save(); + } + + /** + * Remove a group + * + * @param string $groupname + * + * @return bool True if the action was performed + */ + public static function remove($groupname) + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + $blueprints = new Blueprints(); + $blueprint = $blueprints->get('user/group'); + + $type = 'groups'; + + $groups = $config->get($type); + unset($groups[$groupname]); + $config->set($type, $groups); + + $filename = CompiledYamlFile::instance($grav['locator']->findResource("config://{$type}.yaml")); + + $obj = new Data($groups, $blueprint); + $obj->file($filename); + $obj->save(); + + return true; + } +} diff --git a/system/src/Grav/Common/User/Interfaces/UserCollectionInterface.php b/system/src/Grav/Common/User/Interfaces/UserCollectionInterface.php new file mode 100644 index 0000000..37b9009 --- /dev/null +++ b/system/src/Grav/Common/User/Interfaces/UserCollectionInterface.php @@ -0,0 +1,40 @@ +exists(). + * + * @param string $username + * @return UserInterface + */ + public function load($username): UserInterface; + + /** + * Find a user by username, email, etc + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + */ + public function find($query, $fields = ['username', 'email']): UserInterface; + + /** + * Delete user account. + * + * @param string $username + * @return bool True if user account was found and was deleted. + */ + public function delete($username): bool; +} diff --git a/system/src/Grav/Common/User/Interfaces/UserInterface.php b/system/src/Grav/Common/User/Interfaces/UserInterface.php new file mode 100644 index 0000000..0fba676 --- /dev/null +++ b/system/src/Grav/Common/User/Interfaces/UserInterface.php @@ -0,0 +1,198 @@ +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 $separator Separator, defaults to '.' + * @return mixed Value. + */ + public function get($name, $default = null, $separator = null); + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @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 $separator Separator, defaults to '.' + * @return $this + */ + public function set($name, $value, $separator = null); + + /** + * 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 $separator Separator, defaults to '.' + * @return $this + */ + public function undef($name, $separator = null); + + /** + * 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 $separator Separator, defaults to '.' + * @return $this + */ + public function def($name, $default = null, $separator = null); + + /** + * 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 $separator Separator, defaults to '.' + * @return $this + * @throws \RuntimeException + */ + public function join($name, $value, $separator = '.'); + + /** + * Get nested structure containing default values defined in the blueprints. + * + * Fields without default value are ignored in the list. + + * @return array + */ + public function 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 $separator Separator, defaults to '.' + * @return $this + */ + public function joinDefaults($name, $value, $separator = '.'); + + /** + * 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 = '.'); + + /** + * Set default values to the configuration if variables were not set. + * + * @param array $data + * @return $this + */ + public function setDefaults(array $data); + + /** + * Update object with data + * + * @param array $data + * @param array $files + * @return $this + */ + public function update(array $data, array $files = []); + + /** + * Returns whether the data already exists in the storage. + * + * NOTE: This method does not check if the data is current. + * + * @return bool + */ + public function exists(); + + /** + * Return unmodified data as raw string. + * + * NOTE: This function only returns data which has been saved to the storage. + * + * @return string + */ + public function raw(); + + /** + * Authenticate user. + * + * If user password needs to be updated, new information will be saved. + * + * @param string $password Plaintext password. + * + * @return bool + */ + public function authenticate(string $password): bool; + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool + */ + public function authorize(string $action, string $scope = null): bool; + + /** + * Return media object for the User's avatar. + * + * Note: if there's no local avatar image for the user, you should call getAvatarUrl() to get the external avatar URL. + * + * @return ImageMedium|null + */ + public function getAvatarImage(): ?ImageMedium; + + /** + * Return the User's avatar URL. + * + * @return string + */ + public function getAvatarUrl(): string; +} diff --git a/system/src/Grav/Common/User/Traits/UserTrait.php b/system/src/Grav/Common/User/Traits/UserTrait.php new file mode 100644 index 0000000..f711cc9 --- /dev/null +++ b/system/src/Grav/Common/User/Traits/UserTrait.php @@ -0,0 +1,171 @@ +get('hashed_password'); + + $isHashed = null !== $hash; + if (!$isHashed) { + // If there is no hashed password, fake verify with default hash. + $hash = Grav::instance()['config']->get('system.security.default_hash'); + } + + // Always execute verify() to protect us from timing attacks, but make the test to fail if hashed password wasn't set. + $result = Authentication::verify($password, $hash) && $isHashed; + + $plaintext_password = $this->get('password'); + if (null !== $plaintext_password) { + // Plain-text password is still stored, check if it matches. + if ($password !== $plaintext_password) { + return false; + } + + // Force hash update to get rid of plaintext password. + $result = 2; + } + + if ($result === 2) { + // Password needs to be updated, save the user. + $this->set('password', $password); + $this->undef('hashed_password'); + $this->save(); + } + + return (bool)$result; + } + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool + */ + public function authorize(string $action, string $scope = null): bool + { + if (!$this->get('authenticated')) { + return false; + } + + if ($this->get('state', 'enabled') !== 'enabled') { + return false; + } + + if (null !== $scope) { + $action = $scope . '.' . $action; + } + + $config = Grav::instance()['config']; + $authorized = false; + + //Check group access level + $groups = (array)$this->get('groups'); + foreach ($groups as $group) { + $permission = $config->get("groups.{$group}.access.{$action}"); + $authorized = Utils::isPositive($permission); + if ($authorized === true) { + break; + } + } + + //Check user access level + $access = $this->get('access'); + if ($access && Utils::getDotNotation($access, $action) !== null) { + $permission = $this->get("access.{$action}"); + $authorized = Utils::isPositive($permission); + } + + return $authorized; + } + + /** + * Return media object for the User's avatar. + * + * Note: if there's no local avatar image for the user, you should call getAvatarUrl() to get the external avatar URL. + * + * @return ImageMedium|null + */ + public function getAvatarImage(): ?ImageMedium + { + $avatars = $this->get('avatar'); + if (\is_array($avatars) && $avatars) { + $avatar = array_shift($avatars); + + $media = $this->getMedia(); + $name = $avatar['name'] ?? null; + + $image = $name ? $media[$name] : null; + if ($image instanceof ImageMedium) { + return $image; + } + } + + return null; + } + + /** + * Return the User's avatar URL + * + * @return string + */ + public function getAvatarUrl(): string + { + // Try to locate avatar image. + $avatar = $this->getAvatarImage(); + if ($avatar) { + return $avatar->url(); + } + + // Try if avatar is a sting (URL). + $avatar = $this->get('avatar'); + if (\is_string($avatar)) { + return $avatar; + } + + // Try looking for provider. + $provider = $this->get('provider'); + $provider_options = $this->get($provider); + if (\is_array($provider_options)) { + if (isset($provider_options['avatar_url']) && \is_string($provider_options['avatar_url'])) { + return $provider_options['avatar_url']; + } + if (isset($provider_options['avatar']) && \is_string($provider_options['avatar'])) { + return $provider_options['avatar']; + } + } + + $email = $this->get('email'); + + // By default fall back to gravatar image. + return $email ? 'https://www.gravatar.com/avatar/' . md5(strtolower(trim($email))) : ''; + } + + abstract public function get($name, $default = null, $separator = null); + abstract public function set($name, $value, $separator = null); + abstract public function undef($name, $separator = null); + abstract public function save(); +} diff --git a/system/src/Grav/Common/User/User.php b/system/src/Grav/Common/User/User.php new file mode 100644 index 0000000..ba1dbdc --- /dev/null +++ b/system/src/Grav/Common/User/User.php @@ -0,0 +1,146 @@ +exists(). + * + * @param string $username + * + * @return UserInterface + * @deprecated 1.6 Use $grav['accounts']->load(...) instead. + */ + public static function load($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); + + return static::getCollection()->load($username); + } + + /** + * Find a user by username, email, etc + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + * @deprecated 1.6 Use $grav['accounts']->find(...) instead. + */ + public static function find($query, $fields = ['username', 'email']) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); + + return static::getCollection()->find($query, $fields); + } + + /** + * Remove user account. + * + * @param string $username + * @return bool True if the action was performed + * @deprecated 1.6 Use $grav['accounts']->delete(...) instead. + */ + public static function remove($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->delete() instead', E_USER_DEPRECATED); + + return static::getCollection()->delete($username); + } + + /** + * @return UserCollectionInterface + */ + protected static function getCollection() + { + return Grav::instance()['accounts']; + } + } +} else { + /** + * @deprecated 1.6 Use $grav['accounts'] instead of static calls. In type hints, use UserInterface. + */ + class User extends DataUser\User + { + /** + * Load user account. + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $username + * + * @return UserInterface + * @deprecated 1.6 Use $grav['accounts']->load(...) instead. + */ + public static function load($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); + + return static::getCollection()->load($username); + } + + /** + * Find a user by username, email, etc + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + * @deprecated 1.6 Use $grav['accounts']->find(...) instead. + */ + public static function find($query, $fields = ['username', 'email']) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); + + return static::getCollection()->find($query, $fields); + } + + /** + * Remove user account. + * + * @param string $username + * @return bool True if the action was performed + * @deprecated 1.6 Use $grav['accounts']->delete(...) instead. + */ + public static function remove($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->delete() instead', E_USER_DEPRECATED); + + return static::getCollection()->delete($username); + } + + /** + * @return UserCollectionInterface + */ + protected static function getCollection() + { + return Grav::instance()['accounts']; + } + } +} diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php new file mode 100644 index 0000000..56b2d89 --- /dev/null +++ b/system/src/Grav/Common/Utils.php @@ -0,0 +1,1619 @@ +schemeExists($scheme)) { + // If scheme does not exists as a stream, assume it's external. + return str_replace(' ', '%20', $input); + } + + // Attempt to find the resource (because of parse_url() we need to put host back to path). + $resource = $locator->findResource("{$scheme}://{$host}{$path}", false); + + if ($resource === false) { + if (!$fail_gracefully) { + return false; + } + + // Return location where the file would be if it was saved. + $resource = $locator->findResource("{$scheme}://{$host}{$path}", false, true); + } + + } elseif ($host || $port) { + // If URL doesn't have scheme but has host or port, it is external. + return str_replace(' ', '%20', $input); + } + + if (!empty($resource)) { + // Add query string back. + if (isset($parts['query'])) { + $resource .= '?' . $parts['query']; + } + + // Add fragment back. + if (isset($parts['fragment'])) { + $resource .= '#' . $parts['fragment']; + } + } + + } else { + // Not a valid URL (can still be a stream). + $resource = $locator->findResource($input, false); + } + + } else { + $root = $uri->rootUrl(); + + if (static::startsWith($input, $root)) { + $input = static::replaceFirstOccurrence($root, '', $input); + } + + $input = ltrim($input, '/'); + + $resource = $input; + } + + if (!$fail_gracefully && $resource === false) { + return false; + } + + $domain = $domain ?: $grav['config']->get('system.absolute_urls', false); + + return rtrim($uri->rootUrl($domain), '/') . '/' . ($resource ?? ''); + } + + /** + * Check if the $haystack string starts with the substring $needle + * + * @param string $haystack + * @param string|string[] $needle + * @param bool $case_sensitive + * + * @return bool + */ + public static function startsWith($haystack, $needle, $case_sensitive = true) + { + $status = false; + + $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos'; + + foreach ((array)$needle as $each_needle) { + $status = $each_needle === '' || $compare_func($haystack, $each_needle) === 0; + if ($status) { + break; + } + } + + return $status; + } + + /** + * Check if the $haystack string ends with the substring $needle + * + * @param string $haystack + * @param string|string[] $needle + * @param bool $case_sensitive + * + * @return bool + */ + public static function endsWith($haystack, $needle, $case_sensitive = true) + { + $status = false; + + $compare_func = $case_sensitive ? 'mb_strrpos' : 'mb_strripos'; + + foreach ((array)$needle as $each_needle) { + $expectedPosition = mb_strlen($haystack) - mb_strlen($each_needle); + $status = $each_needle === '' || $compare_func($haystack, $each_needle, 0) === $expectedPosition; + if ($status) { + break; + } + } + + return $status; + } + + /** + * Check if the $haystack string contains the substring $needle + * + * @param string $haystack + * @param string|string[] $needle + * @param bool $case_sensitive + * + * @return bool + */ + public static function contains($haystack, $needle, $case_sensitive = true) + { + $status = false; + + $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos'; + + foreach ((array)$needle as $each_needle) { + $status = $each_needle === '' || $compare_func($haystack, $each_needle) !== false; + if ($status) { + break; + } + } + + return $status; + } + + /** + * Function that can match wildcards + * + * match_wildcard('foo*', $test), // TRUE + * match_wildcard('bar*', $test), // FALSE + * match_wildcard('*bar*', $test), // TRUE + * match_wildcard('**blob**', $test), // TRUE + * match_wildcard('*a?d*', $test), // TRUE + * match_wildcard('*etc**', $test) // TRUE + * + * @param string $wildcard_pattern + * @param string $haystack + * @return false|int + */ + public static function matchWildcard($wildcard_pattern, $haystack) { + $regex = str_replace( + array("\*", "\?"), // wildcard chars + array('.*','.'), // regexp chars + preg_quote($wildcard_pattern, '/') + ); + + return preg_match('/^'.$regex.'$/is', $haystack); + } + + /** + * Returns the substring of a string up to a specified needle. if not found, return the whole haystack + * + * @param string $haystack + * @param string $needle + * @param bool $case_sensitive + * + * @return string + */ + public static function substrToString($haystack, $needle, $case_sensitive = true) + { + $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos'; + + if (static::contains($haystack, $needle, $case_sensitive)) { + return mb_substr($haystack, 0, $compare_func($haystack, $needle, $case_sensitive)); + } + + return $haystack; + } + + /** + * Utility method to replace only the first occurrence in a string + * + * @param string $search + * @param string $replace + * @param string $subject + * + * @return string + */ + public static function replaceFirstOccurrence($search, $replace, $subject) + { + if (!$search) { + return $subject; + } + + $pos = mb_strpos($subject, $search); + if ($pos !== false) { + $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search)); + } + + + return $subject; + } + + /** + * Utility method to replace only the last occurrence in a string + * + * @param string $search + * @param string $replace + * @param string $subject + * @return string + */ + public static function replaceLastOccurrence($search, $replace, $subject) + { + $pos = strrpos($subject, $search); + + if($pos !== false) + { + $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search)); + } + + return $subject; + } + + /** + * Multibyte compatible substr_replace + * + * @param string $original + * @param string $replacement + * @param int $position + * @param int $length + * @return string + */ + public static function mb_substr_replace($original, $replacement, $position, $length) + { + $startString = mb_substr($original, 0, $position, "UTF-8"); + $endString = mb_substr($original, $position + $length, mb_strlen($original), "UTF-8"); + + return $startString . $replacement . $endString; + } + + /** + * Merge two objects into one. + * + * @param object $obj1 + * @param object $obj2 + * + * @return object + */ + public static function mergeObjects($obj1, $obj2) + { + return (object)array_merge((array)$obj1, (array)$obj2); + } + + /** + * Lowercase an entire array. Useful when combined with `in_array()` + * + * @param array $a + * @return array|false + */ + public static function arrayLower(Array $a) + { + return array_map('mb_strtolower', $a); + } + + /** + * Simple function to remove item/s in an array by value + * + * @param $search array + * @param $value string|array + * @return array + */ + public static function arrayRemoveValue(Array $search, $value) + { + foreach ((array) $value as $val) { + $key = array_search($val, $search); + if ($key !== false) { + unset($search[$key]); + } + } + return $search; + } + + /** + * Recursive Merge with uniqueness + * + * @param array $array1 + * @param array $array2 + * @return array + */ + public static function arrayMergeRecursiveUnique($array1, $array2) + { + if (empty($array1)) { + // Optimize the base case + return $array2; + } + + foreach ($array2 as $key => $value) { + if (is_array($value) && isset($array1[$key]) && is_array($array1[$key])) { + $value = static::arrayMergeRecursiveUnique($array1[$key], $value); + } + $array1[$key] = $value; + } + + return $array1; + } + + /** + * Returns an array with the differences between $array1 and $array2 + * + * @param array $array1 + * @param array $array2 + * @return array + */ + public static function arrayDiffMultidimensional($array1, $array2) + { + $result = array(); + foreach ($array1 as $key => $value) { + if (!is_array($array2) || !array_key_exists($key, $array2)) { + $result[$key] = $value; + continue; + } + if (is_array($value)) { + $recursiveArrayDiff = static::ArrayDiffMultidimensional($value, $array2[$key]); + if (count($recursiveArrayDiff)) { + $result[$key] = $recursiveArrayDiff; + } + continue; + } + if ($value != $array2[$key]) { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * Array combine but supports different array lengths + * + * @param array $arr1 + * @param array $arr2 + * @return array|false + */ + public static function arrayCombine($arr1, $arr2) + { + $count = min(count($arr1), count($arr2)); + + return array_combine(array_slice($arr1, 0, $count), array_slice($arr2, 0, $count)); + } + + /** + * Array is associative or not + * + * @param array $arr + * @return bool + */ + public static function arrayIsAssociative($arr) + { + if ([] === $arr) { + return false; + } + + return array_keys($arr) !== range(0, count($arr) - 1); + } + + /** + * Return the Grav date formats allowed + * + * @return array + */ + public static function dateFormats() + { + $now = new \DateTime(); + + $date_formats = [ + 'd-m-Y H:i' => 'd-m-Y H:i (e.g. '.$now->format('d-m-Y H:i').')', + 'Y-m-d H:i' => 'Y-m-d H:i (e.g. '.$now->format('Y-m-d H:i').')', + 'm/d/Y h:i a' => 'm/d/Y h:i a (e.g. '.$now->format('m/d/Y h:i a').')', + 'H:i d-m-Y' => 'H:i d-m-Y (e.g. '.$now->format('H:i d-m-Y').')', + 'h:i a m/d/Y' => 'h:i a m/d/Y (e.g. '.$now->format('h:i a m/d/Y').')', + ]; + $default_format = Grav::instance()['config']->get('system.pages.dateformat.default'); + if ($default_format) { + $date_formats = array_merge([$default_format => $default_format.' (e.g. '.$now->format($default_format).')'], $date_formats); + } + + return $date_formats; + } + + /** + * Get current date/time + * + * @param string|null $default_format + * @return string + * @throws \Exception + */ + public static function dateNow($default_format = null) + { + $now = new \DateTime(); + + if (is_null($default_format)) { + $default_format = Grav::instance()['config']->get('system.pages.dateformat.default'); + } + + return $now->format($default_format); + } + + /** + * Truncate text by number of characters but can cut off words. + * + * @param string $string + * @param int $limit Max number of characters. + * @param bool $up_to_break truncate up to breakpoint after char count + * @param string $break Break point. + * @param string $pad Appended padding to the end of the string. + * + * @return string + */ + public static function truncate($string, $limit = 150, $up_to_break = false, $break = ' ', $pad = '…') + { + // return with no change if string is shorter than $limit + if (mb_strlen($string) <= $limit) { + return $string; + } + + // is $break present between $limit and the end of the string? + if ($up_to_break && false !== ($breakpoint = mb_strpos($string, $break, $limit))) { + if ($breakpoint < mb_strlen($string) - 1) { + $string = mb_substr($string, 0, $breakpoint) . $pad; + } + } else { + $string = mb_substr($string, 0, $limit) . $pad; + } + + return $string; + } + + /** + * Truncate text by number of characters in a "word-safe" manor. + * + * @param string $string + * @param int $limit + * + * @return string + */ + public static function safeTruncate($string, $limit = 150) + { + return static::truncate($string, $limit, true); + } + + + /** + * Truncate HTML by number of characters. not "word-safe"! + * + * @param string $text + * @param int $length in characters + * @param string $ellipsis + * + * @return string + */ + public static function truncateHtml($text, $length = 100, $ellipsis = '...') + { + return Truncator::truncateLetters($text, $length, $ellipsis); + } + + /** + * Truncate HTML by number of characters in a "word-safe" manor. + * + * @param string $text + * @param int $length in words + * @param string $ellipsis + * + * @return string + */ + public static function safeTruncateHtml($text, $length = 25, $ellipsis = '...') + { + return Truncator::truncateWords($text, $length, $ellipsis); + } + + /** + * Generate a random string of a given length + * + * @param int $length + * + * @return string + */ + public static function generateRandomString($length = 5) + { + return substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, $length); + } + + /** + * Provides the ability to download a file to the browser + * + * @param string $file the full path to the file to be downloaded + * @param bool $force_download as opposed to letting browser choose if to download or render + * @param int $sec Throttling, try 0.1 for some speed throttling of downloads + * @param int $bytes Size of chunks to send in bytes. Default is 1024 + * @throws \Exception + */ + public static function download($file, $force_download = true, $sec = 0, $bytes = 1024) + { + if (file_exists($file)) { + // fire download event + Grav::instance()->fireEvent('onBeforeDownload', new Event(['file' => $file])); + + $file_parts = pathinfo($file); + $mimetype = static::getMimeByExtension($file_parts['extension']); + $size = filesize($file); // File size + + // clean all buffers + while (ob_get_level()) { + ob_end_clean(); + } + + // required for IE, otherwise Content-Disposition may be ignored + if (ini_get('zlib.output_compression')) { + ini_set('zlib.output_compression', 'Off'); + } + + header('Content-Type: ' . $mimetype); + header('Accept-Ranges: bytes'); + + if ($force_download) { + // output the regular HTTP headers + header('Content-Disposition: attachment; filename="' . $file_parts['basename'] . '"'); + } + + // multipart-download and download resuming support + if (isset($_SERVER['HTTP_RANGE'])) { + list($a, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2); + list($range) = explode(',', $range, 2); + list($range, $range_end) = explode('-', $range); + $range = (int)$range; + if (!$range_end) { + $range_end = $size - 1; + } else { + $range_end = (int)$range_end; + } + $new_length = $range_end - $range + 1; + header('HTTP/1.1 206 Partial Content'); + header("Content-Length: {$new_length}"); + header("Content-Range: bytes {$range}-{$range_end}/{$size}"); + } else { + $range = 0; + $new_length = $size; + header('Content-Length: ' . $size); + + if (Grav::instance()['config']->get('system.cache.enabled')) { + $expires = Grav::instance()['config']->get('system.pages.expires'); + if ($expires > 0) { + $expires_date = gmdate('D, d M Y H:i:s T', time() + $expires); + header('Cache-Control: max-age=' . $expires); + header('Expires: ' . $expires_date); + header('Pragma: cache'); + } + header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file))); + + // Return 304 Not Modified if the file is already cached in the browser + if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && + strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) + { + header('HTTP/1.1 304 Not Modified'); + exit(); + } + } + } + + /* output the file itself */ + $chunksize = $bytes * 8; //you may want to change this + $bytes_send = 0; + + $fp = @fopen($file, 'rb'); + if ($fp) { + if ($range) { + fseek($fp, $range); + } + while (!feof($fp) && (!connection_aborted()) && ($bytes_send < $new_length) ) { + $buffer = fread($fp, $chunksize); + echo($buffer); //echo($buffer); // is also possible + flush(); + usleep($sec * 1000000); + $bytes_send += strlen($buffer); + } + fclose($fp); + } else { + throw new \RuntimeException('Error - can not open file.'); + } + + exit; + } + } + + /** + * Return the mimetype based on filename extension + * + * @param string $extension Extension of file (eg "txt") + * @param string $default + * + * @return string + */ + public static function getMimeByExtension($extension, $default = 'application/octet-stream') + { + $extension = strtolower($extension); + + // look for some standard types + switch ($extension) { + case null: + return $default; + case 'json': + return 'application/json'; + case 'html': + return 'text/html'; + case 'atom': + return 'application/atom+xml'; + case 'rss': + return 'application/rss+xml'; + case 'xml': + return 'application/xml'; + } + + $media_types = Grav::instance()['config']->get('media.types'); + + if (isset($media_types[$extension])) { + if (isset($media_types[$extension]['mime'])) { + return $media_types[$extension]['mime']; + } + } + + return $default; + } + + /** + * Get all the mimetypes for an array of extensions + * + * @param array $extensions + * @return array + */ + public static function getMimeTypes(array $extensions) + { + $mimetypes = []; + foreach ($extensions as $extension) { + $mimetype = static::getMimeByExtension($extension, false); + if ($mimetype && !in_array($mimetype, $mimetypes)) { + $mimetypes[] = $mimetype; + } + } + return $mimetypes; + } + + + /** + * Return the mimetype based on filename extension + * + * @param string $mime mime type (eg "text/html") + * @param string $default default value + * + * @return string + */ + public static function getExtensionByMime($mime, $default = 'html') + { + $mime = strtolower($mime); + + // look for some standard mime types + switch ($mime) { + case '*/*': + case 'text/*': + case 'text/html': + return 'html'; + case 'application/json': + return 'json'; + case 'application/atom+xml': + return 'atom'; + case 'application/rss+xml': + return 'rss'; + case 'application/xml': + return 'xml'; + } + + $media_types = (array)Grav::instance()['config']->get('media.types'); + + foreach ($media_types as $extension => $type) { + if ($extension === 'defaults') { + continue; + } + if (isset($type['mime']) && $type['mime'] === $mime) { + return $extension; + } + } + + return $default; + } + + /** + * Get all the extensions for an array of mimetypes + * + * @param array $mimetypes + * @return array + */ + public static function getExtensions(array $mimetypes) + { + $extensions = []; + foreach ($mimetypes as $mimetype) { + $extension = static::getExtensionByMime($mimetype, false); + if ($extension && !\in_array($extension, $extensions, true)) { + $extensions[] = $extension; + } + } + + return $extensions; + } + + /** + * Return the mimetype based on filename + * + * @param string $filename Filename or path to file + * @param string $default default value + * + * @return string + */ + public static function getMimeByFilename($filename, $default = 'application/octet-stream') + { + return static::getMimeByExtension(pathinfo($filename, PATHINFO_EXTENSION), $default); + } + + /** + * Return the mimetype based on existing local file + * + * @param string $filename Path to the file + * + * @return string|bool + */ + public static function getMimeByLocalFile($filename, $default = 'application/octet-stream') + { + $type = false; + + // For local files we can detect type by the file content. + if (!stream_is_local($filename) || !file_exists($filename)) { + return false; + } + + // Prefer using finfo if it exists. + if (\extension_loaded('fileinfo')) { + $finfo = finfo_open(FILEINFO_SYMLINK | FILEINFO_MIME_TYPE); + $type = finfo_file($finfo, $filename); + finfo_close($finfo); + } else { + // Fall back to use getimagesize() if it is available (not recommended, but better than nothing) + $info = @getimagesize($filename); + if ($info) { + $type = $info['mime']; + } + } + + return $type ?: static::getMimeByFilename($filename, $default); + } + + + /** + * Returns true if filename is considered safe. + * + * @param string $filename + * @return bool + */ + public static function checkFilename($filename) + { + $dangerous_extensions = Grav::instance()['config']->get('security.uploads_dangerous_extensions', []); + array_walk($dangerous_extensions, function(&$val) { + $val = '.' . $val; + }); + + $extension = '.' . pathinfo($filename, PATHINFO_EXTENSION); + + return !( + // Empty filenames are not allowed. + !$filename + // Filename should not contain horizontal/vertical tabs, newlines, nils or back/forward slashes. + || strtr($filename, "\t\v\n\r\0\\/", '_______') !== $filename + // Filename should not start or end with dot or space. + || trim($filename, '. ') !== $filename + // Filename should not contain .php in it. + || static::contains($extension, $dangerous_extensions) + ); + } + + /** + * Normalize path by processing relative `.` and `..` syntax and merging path + * + * @param string $path + * + * @return string + */ + public static function normalizePath($path) + { + // Resolve any streams + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($path)) { + $path = $locator->findResource($path); + } + + // Set root properly for any URLs + $root = ''; + preg_match(self::ROOTURL_REGEX, $path, $matches); + if ($matches) { + $root = $matches[1]; + $path = $matches[2]; + } + + // Strip off leading / to ensure explode is accurate + if (Utils::startsWith($path,'/')) { + $root .= '/'; + $path = ltrim($path, '/'); + } + + // If there are any relative paths (..) handle those + if (Utils::contains($path, '..')) { + $segments = explode('/', trim($path, '/')); + $ret = []; + foreach ($segments as $segment) { + if (($segment === '.') || $segment === '') { + continue; + } + if ($segment === '..') { + array_pop($ret); + } else { + $ret[] = $segment; + } + } + $path = implode('/', $ret); + } + + // Stick everything back together + $normalized = $root . $path; + return $normalized; + } + + /** + * Check whether a function is disabled in the PHP settings + * + * @param string $function the name of the function to check + * + * @return bool + */ + public static function isFunctionDisabled($function) + { + return \in_array($function, explode(',', ini_get('disable_functions')), true); + } + + /** + * Get the formatted timezones list + * + * @return array + */ + public static function timezones() + { + $timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL); + $offsets = []; + $testDate = new \DateTime(); + + foreach ($timezones as $zone) { + $tz = new \DateTimeZone($zone); + $offsets[$zone] = $tz->getOffset($testDate); + } + + asort($offsets); + + $timezone_list = []; + foreach ($offsets as $timezone => $offset) { + $offset_prefix = $offset < 0 ? '-' : '+'; + $offset_formatted = gmdate('H:i', abs($offset)); + + $pretty_offset = "UTC${offset_prefix}${offset_formatted}"; + + $timezone_list[$timezone] = "(${pretty_offset}) ".str_replace('_', ' ', $timezone); + } + + return $timezone_list; + } + + /** + * Recursively filter an array, filtering values by processing them through the $fn function argument + * + * @param array $source the Array to filter + * @param callable $fn the function to pass through each array item + * + * @return array + */ + public static function arrayFilterRecursive(Array $source, $fn) + { + $result = []; + foreach ($source as $key => $value) { + if (is_array($value)) { + $result[$key] = static::arrayFilterRecursive($value, $fn); + continue; + } + if ($fn($key, $value)) { + $result[$key] = $value; // KEEP + continue; + } + } + + return $result; + } + + /** + * Flatten an array + * + * @param array $array + * @return array + */ + public static function arrayFlatten($array) + { + $flatten = array(); + foreach ($array as $key => $inner) { + if (is_array($inner)) { + foreach ($inner as $inner_key => $value) { + $flatten[$inner_key] = $value; + } + } else { + $flatten[$key] = $inner; + } + } + + return $flatten; + } + + /** + * Flatten a multi-dimensional associative array into dot notation + * + * @param array $array + * @param string $prepend + * @return array + */ + public static function arrayFlattenDotNotation($array, $prepend = '') + { + $results = array(); + foreach ($array as $key => $value) { + if (is_array($value)) { + $results = array_merge($results, static::arrayFlattenDotNotation($value, $prepend.$key.'.')); + } else { + $results[$prepend.$key] = $value; + } + } + + return $results; + } + + /** + * Opposite of flatten, convert flat dot notation array to multi dimensional array + * + * @param array $array + * @param string $separator + * @return array + */ + public static function arrayUnflattenDotNotation($array, $separator = '.') + { + $newArray = []; + foreach ($array as $key => $value) { + $dots = explode($separator, $key); + if (\count($dots) > 1) { + $last = &$newArray[$dots[0]]; + foreach ($dots as $k => $dot) { + if ($k === 0) { + continue; + } + $last = &$last[$dot]; + } + $last = $value; + } else { + $newArray[$key] = $value; + } + } + + return $newArray; + } + + /** + * Checks if the passed path contains the language code prefix + * + * @param string $string The path + * + * @return bool|string Either false or the language + * + */ + public static function pathPrefixedByLangCode($string) + { + $languages_enabled = Grav::instance()['config']->get('system.languages.supported', []); + $parts = explode('/', trim($string, '/')); + + if (count($parts) > 0 && in_array($parts[0], $languages_enabled)) { + return $parts[0]; + } + + return false; + } + + /** + * Get the timestamp of a date + * + * @param string $date a String expressed in the system.pages.dateformat.default format, with fallback to a + * strtotime argument + * @param string $format a date format to use if possible + * @return int the timestamp + */ + public static function date2timestamp($date, $format = null) + { + $config = Grav::instance()['config']; + $dateformat = $format ?: $config->get('system.pages.dateformat.default'); + + // try to use DateTime and default format + if ($dateformat) { + $datetime = \DateTime::createFromFormat($dateformat, $date); + } else { + $datetime = new \DateTime($date); + } + + // fallback to strtotime() if DateTime approach failed + if ($datetime !== false) { + return $datetime->getTimestamp(); + } + + return strtotime($date); + } + + /** + * @param array $array + * @param string $path + * @param null $default + * @return mixed + * + * @deprecated 1.5 Use ->getDotNotation() method instead. + */ + public static function resolve(array $array, $path, $default = null) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getDotNotation() method instead', E_USER_DEPRECATED); + + return static::getDotNotation($array, $path, $default); + } + + /** + * Checks if a value is positive + * + * @param string $value + * + * @return boolean + */ + public static function isPositive($value) + { + return in_array($value, [true, 1, '1', 'yes', 'on', 'true'], true); + } + + /** + * Generates a nonce string to be hashed. Called by self::getNonce() + * We removed the IP portion in this version because it causes too many inconsistencies + * with reverse proxy setups. + * + * @param string $action + * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours) + * + * @return string the nonce string + */ + private static function generateNonceString($action, $previousTick = false) + { + $grav = Grav::instance(); + + $username = isset($grav['user']) ? $grav['user']->username : ''; + $token = session_id(); + $i = self::nonceTick(); + + if ($previousTick) { + $i--; + } + + return ($i . '|' . $action . '|' . $username . '|' . $token . '|' . $grav['config']->get('security.salt')); + } + + /** + * Get the time-dependent variable for nonce creation. + * + * Now a tick lasts a day. Once the day is passed, the nonce is not valid any more. Find a better way + * to ensure nonces issued near the end of the day do not expire in that small amount of time + * + * @return int the time part of the nonce. Changes once every 24 hours + */ + private static function nonceTick() + { + $secondsInHalfADay = 60 * 60 * 12; + + return (int)ceil(time() / $secondsInHalfADay); + } + + /** + * Creates a hashed nonce tied to the passed action. Tied to the current user and time. The nonce for a given + * action is the same for 12 hours. + * + * @param string $action the action the nonce is tied to (e.g. save-user-admin or move-page-homepage) + * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours) + * + * @return string the nonce + */ + public static function getNonce($action, $previousTick = false) + { + // Don't regenerate this again if not needed + if (isset(static::$nonces[$action][$previousTick])) { + return static::$nonces[$action][$previousTick]; + } + $nonce = md5(self::generateNonceString($action, $previousTick)); + static::$nonces[$action][$previousTick] = $nonce; + + return static::$nonces[$action][$previousTick]; + } + + /** + * Verify the passed nonce for the give action + * + * @param string|string[] $nonce the nonce to verify + * @param string $action the action to verify the nonce to + * + * @return boolean verified or not + */ + public static function verifyNonce($nonce, $action) + { + //Safety check for multiple nonces + if (is_array($nonce)) { + $nonce = array_shift($nonce); + } + + //Nonce generated 0-12 hours ago + if ($nonce === self::getNonce($action)) { + return true; + } + + //Nonce generated 12-24 hours ago + $previousTick = true; + + return $nonce === self::getNonce($action, $previousTick); + } + + /** + * Simple helper method to get whether or not the admin plugin is active + * + * @return bool + */ + public static function isAdminPlugin() + { + if (isset(Grav::instance()['admin'])) { + return true; + } + + return false; + } + + /** + * Get a portion of an array (passed by reference) with dot-notation key + * + * @param array $array + * @param string|int $key + * @param null $default + * @return mixed + */ + public static function getDotNotation($array, $key, $default = null) + { + if (null === $key) { + return $array; + } + + if (isset($array[$key])) { + return $array[$key]; + } + + foreach (explode('.', $key) as $segment) { + if (!is_array($array) || !array_key_exists($segment, $array)) { + return $default; + } + + $array = $array[$segment]; + } + + return $array; + } + + /** + * Set portion of array (passed by reference) for a dot-notation key + * and set the value + * + * @param array $array + * @param string|int $key + * @param mixed $value + * @param bool $merge + * + * @return mixed + */ + public static function setDotNotation(&$array, $key, $value, $merge = false) + { + if (null === $key) { + return $array = $value; + } + + $keys = explode('.', $key); + + while (count($keys) > 1) { + $key = array_shift($keys); + + if ( ! isset($array[$key]) || ! is_array($array[$key])) + { + $array[$key] = array(); + } + + $array =& $array[$key]; + } + + $key = array_shift($keys); + + if (!$merge || !isset($array[$key])) { + $array[$key] = $value; + } else { + $array[$key] = array_merge($array[$key], $value); + } + + return $array; + } + + /** + * Utility method to determine if the current OS is Windows + * + * @return bool + */ + public static function isWindows() + { + return strncasecmp(PHP_OS, 'WIN', 3) === 0; + } + + /** + * Utility to determine if the server running PHP is Apache + * + * @return bool + */ + public static function isApache() { + return isset($_SERVER['SERVER_SOFTWARE']) && strpos($_SERVER['SERVER_SOFTWARE'], 'Apache') !== false; + } + + /** + * Sort a multidimensional array by another array of ordered keys + * + * @param array $array + * @param array $orderArray + * @return array + */ + public static function sortArrayByArray(array $array, array $orderArray) + { + $ordered = array(); + foreach ($orderArray as $key) { + if (array_key_exists($key, $array)) { + $ordered[$key] = $array[$key]; + unset($array[$key]); + } + } + return $ordered + $array; + } + + /** + * Sort an array by a key value in the array + * + * @param mixed $array + * @param string|int $array_key + * @param int $direction + * @param int $sort_flags + * @return array + */ + public static function sortArrayByKey($array, $array_key, $direction = SORT_DESC, $sort_flags = SORT_REGULAR) + { + $output = []; + + if (!is_array($array) || !$array) { + return $output; + } + + foreach ($array as $key => $row) { + $output[$key] = $row[$array_key]; + } + + array_multisort($output, $direction, $sort_flags, $array); + + return $array; + } + + /** + * Get path based on a token + * + * @param string $path + * @param PageInterface|null $page + * @return string + * @throws \RuntimeException + */ + public static function getPagePathFromToken($path, PageInterface $page = null) + { + $path_parts = pathinfo($path); + $grav = Grav::instance(); + + $basename = ''; + if (isset($path_parts['extension'])) { + $basename = '/' . $path_parts['basename']; + $path = rtrim($path_parts['dirname'], ':'); + } + + $regex = '/(@self|self@)|((?:@page|page@):(?:.*))|((?:@theme|theme@):(?:.*))/'; + preg_match($regex, $path, $matches); + + if ($matches) { + if ($matches[1]) { + if (null === $page) { + throw new \RuntimeException('Page not available for this self@ reference'); + } + } elseif ($matches[2]) { + // page@ + $parts = explode(':', $path); + $route = $parts[1]; + $page = $grav['page']->find($route); + } elseif ($matches[3]) { + // theme@ + $parts = explode(':', $path); + $route = $parts[1]; + $theme = str_replace(ROOT_DIR, '', $grav['locator']->findResource("theme://")); + + return $theme . $route . $basename; + } + } else { + return $path . $basename; + } + + if (!$page) { + throw new \RuntimeException('Page route not found: ' . $path); + } + + $path = str_replace($matches[0], rtrim($page->relativePagePath(), '/'), $path); + + return $path . $basename; + } + + public static function getUploadLimit() + { + static $max_size = -1; + + if ($max_size < 0) { + $post_max_size = static::parseSize(ini_get('post_max_size')); + if ($post_max_size > 0) { + $max_size = $post_max_size; + } else { + $max_size = 0; + } + + $upload_max = static::parseSize(ini_get('upload_max_filesize')); + if ($upload_max > 0 && $upload_max < $max_size) { + $max_size = $upload_max; + } + } + + return $max_size; + } + + /** + * Convert bytes to the unit specified by the $to parameter. + * + * @param int $bytes The filesize in Bytes. + * @param string $to The unit type to convert to. Accepts K, M, or G for Kilobytes, Megabytes, or Gigabytes, respectively. + * @param int $decimal_places The number of decimal places to return. + * + * @return int Returns only the number of units, not the type letter. Returns 0 if the $to unit type is out of scope. + * + */ + public static function convertSize($bytes, $to, $decimal_places = 1) + { + $formulas = array( + 'K' => number_format($bytes / 1024, $decimal_places), + 'M' => number_format($bytes / 1048576, $decimal_places), + 'G' => number_format($bytes / 1073741824, $decimal_places) + ); + return $formulas[$to] ?? 0; + } + + /** + * Return a pretty size based on bytes + * + * @param int $bytes + * @param int $precision + * @return string + */ + public static function prettySize($bytes, $precision = 2) + { + $units = array('B', 'KB', 'MB', 'GB', 'TB'); + + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + // Uncomment one of the following alternatives + $bytes /= 1024 ** $pow; + // $bytes /= (1 << (10 * $pow)); + + return round($bytes, $precision) . ' ' . $units[$pow]; + } + + /** + * Parse a readable file size and return a value in bytes + * + * @param string|int $size + * @return int + */ + public static function parseSize($size) + { + $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); + $size = preg_replace('/[^0-9\.]/', '', $size); + + if ($unit) { + $size = $size * pow(1024, stripos('bkmgtpezy', $unit[0])); + } + + return (int) abs(round($size)); + } + + /** + * Multibyte-safe Parse URL function + * + * @param string $url + * @return array + * @throws \InvalidArgumentException + */ + public static function multibyteParseUrl($url) + { + $enc_url = preg_replace_callback( + '%[^:/@?&=#]+%usD', + function ($matches) { + return urlencode($matches[0]); + }, + $url + ); + + $parts = parse_url($enc_url); + + if($parts === false) { + throw new \InvalidArgumentException('Malformed URL: ' . $url); + } + + foreach($parts as $name => $value) { + $parts[$name] = urldecode($value); + } + + return $parts; + } + + /** + * Process a string as markdown + * + * @param string $string + * + * @param bool $block Block or Line processing + * @param null $page + * @return string + * @throws \Exception + */ + public static function processMarkdown($string, $block = true, $page = null) + { + $grav = Grav::instance(); + $page = $page ?? $grav['page'] ?? null; + $defaults = [ + 'markdown' => $grav['config']->get('system.pages.markdown', []), + 'images' => $grav['config']->get('system.images', []) + ]; + $extra = $defaults['markdown']['extra'] ?? false; + + $excerpts = new Excerpts($page, $defaults); + + // Initialize the preferred variant of Parsedown + if ($extra) { + $parsedown = new ParsedownExtra($excerpts); + } else { + $parsedown = new Parsedown($excerpts); + } + + if ($block) { + $string = $parsedown->text($string); + } else { + $string = $parsedown->line($string); + } + + return $string; + } + + /** + * Find the subnet of an ip with CIDR prefix size + * + * @param string $ip + * @param int $prefix + * + * @return string + */ + public static function getSubnet($ip, $prefix = 64) + { + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + + // Packed representation of IP + $ip = inet_pton($ip); + + // Maximum netmask length = same as packed address + $len = 8*strlen($ip); + if ($prefix > $len) $prefix = $len; + + $mask = str_repeat('f', $prefix>>2); + + switch($prefix & 3) + { + case 3: $mask .= 'e'; break; + case 2: $mask .= 'c'; break; + case 1: $mask .= '8'; break; + } + $mask = str_pad($mask, $len>>2, '0'); + + // Packed representation of netmask + $mask = pack('H*', $mask); + // Bitwise - Take all bits that are both 1 to generate subnet + $subnet = inet_ntop($ip & $mask); + + return $subnet; + } + + /** + * Wrapper to ensure html, htm in the front of the supported page types + * + * @param array|null $defaults + * @return array|mixed + */ + public static function getSupportPageTypes(array $defaults = null) + { + $types = Grav::instance()['config']->get('system.pages.types', $defaults); + + // remove html/htm + $types = static::arrayRemoveValue($types, ['html', 'htm']); + + // put them back at the front + $types = array_merge(['html', 'htm'], $types); + + return $types; + } +} diff --git a/system/src/Grav/Common/Yaml.php b/system/src/Grav/Common/Yaml.php new file mode 100644 index 0000000..ad6d81b --- /dev/null +++ b/system/src/Grav/Common/Yaml.php @@ -0,0 +1,48 @@ +decode($data); + } + + public static function dump($data, $inline = null, $indent = null) + { + if (null === static::$yaml) { + static::init(); + } + + return static::$yaml->encode($data, $inline, $indent); + } + + private static function init() + { + $config = [ + 'inline' => 5, + 'indent' => 2, + 'native' => true, + 'compat' => true + ]; + + static::$yaml = new YamlFormatter($config); + } +} diff --git a/system/src/Grav/Console/Cli/BackupCommand.php b/system/src/Grav/Console/Cli/BackupCommand.php new file mode 100644 index 0000000..54a26cf --- /dev/null +++ b/system/src/Grav/Console/Cli/BackupCommand.php @@ -0,0 +1,120 @@ +setName('backup') + ->addArgument( + 'id', + InputArgument::OPTIONAL, + 'The ID of the backup profile to perform without prompting' + + ) + ->setDescription('Creates a backup of the Grav instance') + ->setHelp('The backup creates a zipped backup.'); + + $this->source = getcwd(); + } + + protected function serve() + { + $this->progress = new ProgressBar($this->output); + $this->progress->setFormat('Archiving %current% files [%bar%] %percent:3s%% %elapsed:6s% %message%'); + $this->progress->setBarWidth(100); + + Grav::instance()['config']->init(); + + $io = new SymfonyStyle($this->input, $this->output); + $io->title('Grav Backup'); + + /** @var Backups $backups */ + $backups = Grav::instance()['backups']; + $backups_list = $backups->getBackupProfiles(); + $backups_names = $backups->getBackupNames(); + + $id = null; + + $inline_id = $this->input->getArgument('id'); + if (null !== $inline_id && is_numeric($inline_id)) { + $id = $inline_id; + } + + if (null === $id) { + if (\count($backups_list) > 1) { + $helper = $this->getHelper('question'); + $question = new ChoiceQuestion( + 'Choose a backup?', + $backups_names, + 0 + ); + $question->setErrorMessage('Option %s is invalid.'); + $backup_name = $helper->ask($this->input, $this->output, $question); + $id = array_search($backup_name, $backups_names, true); + + $io->newLine(); + $io->note('Selected backup: ' . $backup_name); + } else { + $id = 0; + } + } + + $backup = $backups->backup($id, [$this, 'outputProgress']); + + $io->newline(2); + $io->success('Backup Successfully Created: ' . $backup); + } + + /** + * @param array $args + */ + public function outputProgress($args) + { + switch ($args['type']) { + case 'count': + $steps = $args['steps']; + $freq = (int)($steps > 100 ? round($steps / 100) : $steps); + $this->progress->setMaxSteps($steps); + $this->progress->setRedrawFrequency($freq); + $this->progress->setMessage('Adding files...'); + break; + case 'message': + $this->progress->setMessage($args['message']); + $this->progress->display(); + break; + case 'progress': + if (isset($args['complete']) && $args['complete']) { + $this->progress->finish(); + } else { + $this->progress->advance(); + } + break; + } + } + +} + diff --git a/system/src/Grav/Console/Cli/CleanCommand.php b/system/src/Grav/Console/Cli/CleanCommand.php new file mode 100644 index 0000000..a905973 --- /dev/null +++ b/system/src/Grav/Console/Cli/CleanCommand.php @@ -0,0 +1,323 @@ +setName('clean') + ->setDescription('Handles cleaning chores for Grav distribution') + ->setHelp('The clean clean extraneous folders and data'); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->setupConsole($input, $output); + + $this->cleanPaths(); + } + + private function cleanPaths() + { + $this->output->writeln(''); + $this->output->writeln('DELETING'); + $anything = false; + foreach ($this->paths_to_remove as $path) { + $path = ROOT_DIR . $path; + if (is_dir($path) && @Folder::delete($path)) { + $anything = true; + $this->output->writeln('dir: ' . $path); + } elseif (is_file($path) && @unlink($path)) { + $anything = true; + $this->output->writeln('file: ' . $path); + } + } + if (!$anything) { + $this->output->writeln(''); + $this->output->writeln('Nothing to clean...'); + } + } + + /** + * Set colors style definition for the formatter. + * + * @param InputInterface $input + * @param OutputInterface $output + */ + public function setupConsole(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + + $this->output->getFormatter()->setStyle('normal', new OutputFormatterStyle('white')); + $this->output->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, ['bold'])); + $this->output->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, ['bold'])); + $this->output->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, ['bold'])); + $this->output->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, ['bold'])); + $this->output->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, ['bold'])); + $this->output->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, ['bold'])); + } + +} diff --git a/system/src/Grav/Console/Cli/ClearCacheCommand.php b/system/src/Grav/Console/Cli/ClearCacheCommand.php new file mode 100644 index 0000000..8ef6f4f --- /dev/null +++ b/system/src/Grav/Console/Cli/ClearCacheCommand.php @@ -0,0 +1,80 @@ +setName('cache') + ->setAliases(['clearcache', 'cache-clear']) + ->setDescription('Clears Grav cache') + ->addOption('invalidate', null, InputOption::VALUE_NONE, 'Invalidate cache, but do not remove any files') + ->addOption('purge', null, InputOption::VALUE_NONE, 'If set purge old caches') + ->addOption('all', null, InputOption::VALUE_NONE, 'If set will remove all including compiled, twig, doctrine caches') + ->addOption('assets-only', null, InputOption::VALUE_NONE, 'If set will remove only assets/*') + ->addOption('images-only', null, InputOption::VALUE_NONE, 'If set will remove only images/*') + ->addOption('cache-only', null, InputOption::VALUE_NONE, 'If set will remove only cache/*') + ->addOption('tmp-only', null, InputOption::VALUE_NONE, 'If set will remove only tmp/*') + + ->setHelp('The cache command allows you to interact with Grav cache'); + } + + protected function serve() + { + $this->cleanPaths(); + } + + /** + * loops over the array of paths and deletes the files/folders + */ + private function cleanPaths() + { + $this->output->writeln(''); + + + if ($this->input->getOption('purge')) { + $this->output->writeln('Purging old cache'); + $this->output->writeln(''); + + $msg = Cache::purgeJob(); + $this->output->writeln($msg); + } else { + $this->output->writeln('Clearing cache'); + $this->output->writeln(''); + + if ($this->input->getOption('all')) { + $remove = 'all'; + } elseif ($this->input->getOption('assets-only')) { + $remove = 'assets-only'; + } elseif ($this->input->getOption('images-only')) { + $remove = 'images-only'; + } elseif ($this->input->getOption('cache-only')) { + $remove = 'cache-only'; + } elseif ($this->input->getOption('tmp-only')) { + $remove = 'tmp-only'; + } elseif ($this->input->getOption('invalidate')) { + $remove = 'invalidate'; + } else { + $remove = 'standard'; + } + + foreach (Cache::clearCache($remove) as $result) { + $this->output->writeln($result); + } + } + } +} + diff --git a/system/src/Grav/Console/Cli/ComposerCommand.php b/system/src/Grav/Console/Cli/ComposerCommand.php new file mode 100644 index 0000000..ffb4e99 --- /dev/null +++ b/system/src/Grav/Console/Cli/ComposerCommand.php @@ -0,0 +1,50 @@ +setName("composer") + ->addOption( + 'install', + 'i', + InputOption::VALUE_NONE, + 'install the dependencies' + ) + ->addOption( + 'update', + 'u', + InputOption::VALUE_NONE, + 'update the dependencies' + ) + ->setDescription("Updates the composer vendor dependencies needed by Grav.") + ->setHelp('The composer command updates the composer vendor dependencies needed by Grav'); + } + + protected function serve() + { + $action = $this->input->getOption('install') ? 'install' : ($this->input->getOption('update') ? 'update' : 'install'); + + if ($this->input->getOption('install')) { + $action = 'install'; + } + + // Updates composer first + $this->output->writeln("\nInstalling vendor dependencies"); + $this->output->writeln($this->composerUpdate(GRAV_ROOT, $action)); + } + +} diff --git a/system/src/Grav/Console/Cli/InstallCommand.php b/system/src/Grav/Console/Cli/InstallCommand.php new file mode 100644 index 0000000..05f281e --- /dev/null +++ b/system/src/Grav/Console/Cli/InstallCommand.php @@ -0,0 +1,176 @@ +setName('install') + ->addOption( + 'symlink', + 's', + InputOption::VALUE_NONE, + 'Symlink the required bits' + ) + ->addArgument( + 'destination', + InputArgument::OPTIONAL, + 'Where to install the required bits (default to current project)' + ) + ->setDescription('Installs the dependencies needed by Grav. Optionally can create symbolic links') + ->setHelp('The install command installs the dependencies needed by Grav. Optionally can create symbolic links'); + } + + protected function serve() + { + $dependencies_file = '.dependencies'; + $this->destination = $this->input->getArgument('destination') ?: ROOT_DIR; + + // fix trailing slash + $this->destination = rtrim($this->destination, DS) . DS; + $this->user_path = $this->destination . USER_PATH; + if ($local_config_file = $this->loadLocalConfig()) { + $this->output->writeln('Read local config from ' . $local_config_file . ''); + } + + // Look for dependencies file in ROOT and USER dir + if (file_exists($this->user_path . $dependencies_file)) { + $file = YamlFile::instance($this->user_path . $dependencies_file); + } elseif (file_exists($this->destination . $dependencies_file)) { + $file = YamlFile::instance($this->destination . $dependencies_file); + } else { + $this->output->writeln('ERROR Missing .dependencies file in user/ folder'); + if ($this->input->getArgument('destination')) { + $this->output->writeln('HINT Are you trying to install a plugin or a theme? Make sure you use bin/gpm install , not bin/grav install. This command is only used to install Grav skeletons.'); + } else { + $this->output->writeln('HINT Are you trying to install Grav? Grav is already installed. You need to run this command only if you download a skeleton from GitHub directly.'); + } + + return; + } + + $this->config = $file->content(); + $file->free(); + + // If yaml config, process + if ($this->config) { + if (!$this->input->getOption('symlink')) { + // Updates composer first + $this->output->writeln("\nInstalling vendor dependencies"); + $this->output->writeln($this->composerUpdate(GRAV_ROOT, 'install')); + + $this->gitclone(); + } else { + $this->symlink(); + } + } else { + $this->output->writeln('ERROR invalid YAML in ' . $dependencies_file); + } + + + } + + /** + * Clones from Git + */ + private function gitclone() + { + $this->output->writeln(''); + $this->output->writeln('Cloning Bits'); + $this->output->writeln('============'); + $this->output->writeln(''); + + foreach ($this->config['git'] as $repo => $data) { + $this->destination = rtrim($this->destination, DS); + $path = $this->destination . DS . $data['path']; + if (!file_exists($path)) { + exec('cd "' . $this->destination . '" && git clone -b ' . $data['branch'] . ' --depth 1 ' . $data['url'] . ' ' . $data['path'], $output, $return); + + if (!$return) { + $this->output->writeln('SUCCESS cloned ' . $data['url'] . ' -> ' . $path . ''); + } else { + $this->output->writeln('ERROR cloning ' . $data['url']); + + } + + $this->output->writeln(''); + } else { + $this->output->writeln('' . $path . ' already exists, skipping...'); + $this->output->writeln(''); + } + + } + } + + /** + * Symlinks + */ + private function symlink() + { + $this->output->writeln(''); + $this->output->writeln('Symlinking Bits'); + $this->output->writeln('==============='); + $this->output->writeln(''); + + if (!$this->local_config) { + $this->output->writeln('No local configuration available, aborting...'); + $this->output->writeln(''); + return; + } + + exec('cd ' . $this->destination); + foreach ($this->config['links'] as $repo => $data) { + $repos = (array) $this->local_config[$data['scm'] . '_repos']; + $from = false; + $to = $this->destination . $data['path']; + + foreach ($repos as $repo) { + $path = $repo . $data['src']; + if (file_exists($path)) { + $from = $path; + continue; + } + } + + if (!$from) { + $this->output->writeln('source for ' . $data['src'] . ' does not exists, skipping...'); + $this->output->writeln(''); + } else { + if (!file_exists($to)) { + symlink($from, $to); + $this->output->writeln('SUCCESS symlinked ' . $data['src'] . ' -> ' . $data['path'] . ''); + $this->output->writeln(''); + } else { + $this->output->writeln('destination: ' . $to . ' already exists, skipping...'); + $this->output->writeln(''); + } + } + } + } +} diff --git a/system/src/Grav/Console/Cli/LogViewerCommand.php b/system/src/Grav/Console/Cli/LogViewerCommand.php new file mode 100644 index 0000000..f9e93a3 --- /dev/null +++ b/system/src/Grav/Console/Cli/LogViewerCommand.php @@ -0,0 +1,85 @@ +setName('logviewer') + ->addOption( + 'file', + 'f', + InputOption::VALUE_OPTIONAL, + 'custom log file location (default = grav.log)' + ) + ->addOption( + 'lines', + 'l', + InputOption::VALUE_OPTIONAL, + 'number of lines (default = 10)' + ) + ->setDescription('Display the last few entries of Grav log') + ->setHelp("Display the last few entries of Grav log"); + } + + protected function serve() + { + $grav = Grav::instance(); + $grav->setup(); + + $file = $this->input->getOption('file') ?? 'grav.log'; + $lines = $this->input->getOption('lines') ?? 20; + $verbose = $this->input->getOption('verbose', false); + + $io = new SymfonyStyle($this->input, $this->output); + + $io->title('Log Viewer'); + + $io->writeln(sprintf('viewing last %s entries in %s', $lines, $file)); + $io->newLine(); + + $viewer = new LogViewer(); + + $logfile = $grav['locator']->findResource("log://" . $file); + + if ($logfile) { + $rows = $viewer->objectTail($logfile, $lines, true); + foreach ($rows as $log) { + $date = $log['date']; + $level_color = LogViewer::levelColor($log['level']); + + if ($date instanceof \DateTime) { + $output = "{$log['date']->format('Y-m-d h:i:s')} [<{$level_color}>{$log['level']}]"; + if ($log['trace'] && $verbose) { + $output .= " {$log['message']}\n"; + foreach ((array) $log['trace'] as $index => $tracerow) { + $output .= "{$index}${tracerow}\n"; + } + } else { + $output .= " {$log['message']}"; + } + $io->writeln($output); + } + } + } else { + $io->error('cannot find the log file: logs/' . $file); + } + + } +} diff --git a/system/src/Grav/Console/Cli/NewProjectCommand.php b/system/src/Grav/Console/Cli/NewProjectCommand.php new file mode 100644 index 0000000..90583f0 --- /dev/null +++ b/system/src/Grav/Console/Cli/NewProjectCommand.php @@ -0,0 +1,59 @@ +setName('new-project') + ->setAliases(['newproject']) + ->addArgument( + 'destination', + InputArgument::REQUIRED, + 'The destination directory of your new Grav project' + ) + ->addOption( + 'symlink', + 's', + InputOption::VALUE_NONE, + 'Symlink the required bits' + ) + ->setDescription('Creates a new Grav project with all the dependencies installed') + ->setHelp("The new-project command is a combination of the `setup` and `install` commands.\nCreates a new Grav instance and performs the installation of all the required dependencies."); + } + + protected function serve() + { + $sandboxCommand = $this->getApplication()->find('sandbox'); + $installCommand = $this->getApplication()->find('install'); + + $sandboxArguments = new ArrayInput([ + 'command' => 'sandbox', + 'destination' => $this->input->getArgument('destination'), + '-s' => $this->input->getOption('symlink') + ]); + + $installArguments = new ArrayInput([ + 'command' => 'install', + 'destination' => $this->input->getArgument('destination'), + '-s' => $this->input->getOption('symlink') + ]); + + $sandboxCommand->run($sandboxArguments, $this->output); + $installCommand->run($installArguments, $this->output); + } +} diff --git a/system/src/Grav/Console/Cli/SandboxCommand.php b/system/src/Grav/Console/Cli/SandboxCommand.php new file mode 100644 index 0000000..f961412 --- /dev/null +++ b/system/src/Grav/Console/Cli/SandboxCommand.php @@ -0,0 +1,270 @@ + '/.gitignore', + '/.editorconfig' => '/.editorconfig', + '/CHANGELOG.md' => '/CHANGELOG.md', + '/LICENSE.txt' => '/LICENSE.txt', + '/README.md' => '/README.md', + '/CONTRIBUTING.md' => '/CONTRIBUTING.md', + '/index.php' => '/index.php', + '/composer.json' => '/composer.json', + '/bin' => '/bin', + '/system' => '/system', + '/vendor' => '/vendor', + '/webserver-configs' => '/webserver-configs', + ]; + + /** @var string */ + protected $default_file = "---\ntitle: HomePage\n---\n# HomePage\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque porttitor eu felis sed ornare. Sed a mauris venenatis, pulvinar velit vel, dictum enim. Phasellus ac rutrum velit. Nunc lorem purus, hendrerit sit amet augue aliquet, iaculis ultricies nisl. Suspendisse tincidunt euismod risus, quis feugiat arcu tincidunt eget. Nulla eros mi, commodo vel ipsum vel, aliquet congue odio. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Pellentesque velit orci, laoreet at adipiscing eu, interdum quis nibh. Nunc a accumsan purus."; + + protected $source; + protected $destination; + + protected function configure() + { + $this + ->setName('sandbox') + ->setDescription('Setup of a base Grav system in your webroot, good for development, playing around or starting fresh') + ->addArgument( + 'destination', + InputArgument::REQUIRED, + 'The destination directory to symlink into' + ) + ->addOption( + 'symlink', + 's', + InputOption::VALUE_NONE, + 'Symlink the base grav system' + ) + ->setHelp("The sandbox command help create a development environment that can optionally use symbolic links to link the core of grav to the git cloned repository.\nGood for development, playing around or starting fresh"); + $this->source = getcwd(); + } + + protected function serve() + { + $this->destination = $this->input->getArgument('destination'); + + // Symlink the Core Stuff + if ($this->input->getOption('symlink')) { + // Create Some core stuff if it doesn't exist + $this->createDirectories(); + + // Loop through the symlink mappings and create the symlinks + $this->symlink(); + + // Copy the Core STuff + } else { + // Create Some core stuff if it doesn't exist + $this->createDirectories(); + + // Loop through the symlink mappings and copy what otherwise would be symlinks + $this->copy(); + } + + $this->pages(); + $this->initFiles(); + $this->perms(); + } + + private function createDirectories() + { + $this->output->writeln(''); + $this->output->writeln('Creating Directories'); + $dirs_created = false; + + if (!file_exists($this->destination)) { + Folder::create($this->destination); + } + + foreach ($this->directories as $dir) { + if (!file_exists($this->destination . $dir)) { + $dirs_created = true; + $this->output->writeln(' ' . $dir . ''); + Folder::create($this->destination . $dir); + } + } + + if (!$dirs_created) { + $this->output->writeln(' Directories already exist'); + } + } + + private function copy() + { + $this->output->writeln(''); + $this->output->writeln('Copying Files'); + + + foreach ($this->mappings as $source => $target) { + if ((string)(int)$source === (string)$source) { + $source = $target; + } + + $from = $this->source . $source; + $to = $this->destination . $target; + + $this->output->writeln(' ' . $source . ' -> ' . $to); + @Folder::rcopy($from, $to); + } + } + + private function symlink() + { + $this->output->writeln(''); + $this->output->writeln('Resetting Symbolic Links'); + + + foreach ($this->mappings as $source => $target) { + if ((string)(int)$source === (string)$source) { + $source = $target; + } + + $from = $this->source . $source; + $to = $this->destination . $target; + + $this->output->writeln(' ' . $source . ' -> ' . $to); + + if (is_dir($to)) { + @Folder::delete($to); + } else { + @unlink($to); + } + symlink($from, $to); + } + } + + private function initFiles() + { + $this->check(); + + $this->output->writeln(''); + $this->output->writeln('File Initializing'); + $files_init = false; + + // Copy files if they do not exist + foreach ($this->files as $source => $target) { + if ((string)(int)$source === (string)$source) { + $source = $target; + } + + $from = $this->source . $source; + $to = $this->destination . $target; + + if (!file_exists($to)) { + $files_init = true; + copy($from, $to); + $this->output->writeln(' ' . $target . ' -> Created'); + } + } + + if (!$files_init) { + $this->output->writeln(' Files already exist'); + } + } + + private function pages() + { + $this->output->writeln(''); + $this->output->writeln('Pages Initializing'); + + // get pages files and initialize if no pages exist + $pages_dir = $this->destination . '/user/pages'; + $pages_files = array_diff(scandir($pages_dir), ['..', '.']); + + if (\count($pages_files) === 0) { + $destination = $this->source . '/user/pages'; + Folder::rcopy($destination, $pages_dir); + $this->output->writeln(' ' . $destination . ' -> Created'); + + } + } + + private function perms() + { + $this->output->writeln(''); + $this->output->writeln('Permissions Initializing'); + + $dir_perms = 0755; + + $binaries = glob($this->destination . DS . 'bin' . DS . '*'); + + foreach ($binaries as $bin) { + chmod($bin, $dir_perms); + $this->output->writeln(' bin/' . basename($bin) . ' permissions reset to ' . decoct($dir_perms)); + } + + $this->output->writeln(""); + } + + private function check() + { + $success = true; + + if (!file_exists($this->destination)) { + $this->output->writeln(' file: $this->destination does not exist!'); + $success = false; + } + + foreach ($this->directories as $dir) { + if (!file_exists($this->destination . $dir)) { + $this->output->writeln(' directory: ' . $dir . ' does not exist!'); + $success = false; + } + } + + foreach ($this->mappings as $target => $link) { + if (!file_exists($this->destination . $target)) { + $this->output->writeln(' mappings: ' . $target . ' does not exist!'); + $success = false; + } + } + + if (!$success) { + $this->output->writeln(''); + $this->output->writeln('install should be run with --symlink|--s to symlink first'); + exit; + } + } +} diff --git a/system/src/Grav/Console/Cli/SchedulerCommand.php b/system/src/Grav/Console/Cli/SchedulerCommand.php new file mode 100644 index 0000000..eaf45cd --- /dev/null +++ b/system/src/Grav/Console/Cli/SchedulerCommand.php @@ -0,0 +1,179 @@ +setName('scheduler') + ->addOption( + 'install', + 'i', + InputOption::VALUE_NONE, + 'Show Install Command' + ) + ->addOption( + 'jobs', + 'j', + InputOption::VALUE_NONE, + 'Show Jobs Summary' + ) + ->addOption( + 'details', + 'd', + InputOption::VALUE_NONE, + 'Show Job Details' + ) + ->setDescription('Run the Grav Scheduler. Best when integrated with system cron') + ->setHelp("Running without any options will force the Scheduler to run through it's jobs and process them"); + } + + protected function serve() + { +// error_reporting(1); + $grav = Grav::instance(); + $grav->setup(); + + $grav['uri']->init(); + $grav['config']->init(); + $grav['plugins']->init(); + $grav['themes']->init(); + $grav['backups']->init(); + + // Initialize Plugins + $grav->fireEvent('onPluginsInitialized'); + + /** @var Scheduler $scheduler */ + $scheduler = $grav['scheduler']; + $grav->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler])); + + $this->setHelp('foo'); + + $io = new SymfonyStyle($this->input, $this->output); + + if ($this->input->getOption('jobs')) { + // Show jobs list + + $jobs = $scheduler->getAllJobs(); + $job_states = $scheduler->getJobStates()->content(); + $rows = []; + + $table = new Table($this->output); + $table->setStyle('box'); + $headers = ['Job ID', 'Command', 'Run At', 'Status', 'Last Run', 'State']; + + $io->title('Scheduler Jobs Listing'); + + foreach ($jobs as $job) { + $job_status = ucfirst($job_states[$job->getId()]['state'] ?? 'ready'); + $last_run = $job_states[$job->getId()]['last-run'] ?? 0; + $status = $job_status === 'Failure' ? "{$job_status}" : "{$job_status}"; + $state = $job->getEnabled() ? 'Enabled' : 'Disabled'; + $row = [ + $job->getId(), + "{$job->getCommand()}", + "{$job->getAt()}", + $status, + '' . ($last_run === 0 ? 'Never' : date('Y-m-d H:i', $last_run)) . '', + $state, + + ]; + $rows[] = $row; + } + + if (!empty($rows)) { + $table->setHeaders($headers); + $table->setRows($rows); + $table->render(); + } else { + $io->text('no jobs found...'); + } + + $io->newLine(); + $io->note('For error details run "bin/grav scheduler -d"'); + $io->newLine(); + } elseif ($this->input->getOption('details')) { + $jobs = $scheduler->getAllJobs(); + $job_states = $scheduler->getJobStates()->content(); + + $io->title('Job Details'); + + $table = new Table($this->output); + $table->setStyle('box'); + $table->setHeaders(['Job ID', 'Last Run', 'Next Run', 'Errors']); + $rows = []; + + foreach ($jobs as $job) { + $job_state = $job_states[$job->getId()]; + $error = isset($job_state['error']) ? trim($job_state['error']) : false; + + /** @var CronExpression $expression */ + $expression = $job->getCronExpression(); + $next_run = $expression->getNextRunDate(); + + $row = []; + $row[] = $job->getId(); + if (!is_null($job_state['last-run'])) { + $row[] = '' . date('Y-m-d H:i', $job_state['last-run']) . ''; + } else { + $row[] = 'Never'; + } + $row[] = '' . $next_run->format('Y-m-d H:i') . ''; + + if ($error) { + $row[] = "{$error}"; + } else { + $row[] = 'None'; + } + $rows[] = $row; + } + + $table->setRows($rows); + $table->render(); + + } elseif ($this->input->getOption('install')) { + $io->title('Install Scheduler'); + + if ($scheduler->isCrontabSetup()) { + $io->success('All Ready! You have already set up Grav\'s Scheduler in your crontab'); + } else { + $io->error('You still need to set up Grav\'s Scheduler in your crontab'); + } + if (!Utils::isWindows()) { + $io->note('To install, run the following command from your terminal:'); + $io->newLine(); + $io->text(trim($scheduler->getCronCommand())); + } else { + $io->note('To install, create a scheduled task in Windows.'); + $io->text('Learn more at https://learn.getgrav.org/advanced/scheduler'); + } + } else { + // Run scheduler + $scheduler->run(); + + if ($this->input->getOption('verbose')) { + $io->title('Running Scheduled Jobs'); + $io->text($scheduler->getVerboseOutput()); + } + } + } +} diff --git a/system/src/Grav/Console/Cli/SecurityCommand.php b/system/src/Grav/Console/Cli/SecurityCommand.php new file mode 100644 index 0000000..7f73086 --- /dev/null +++ b/system/src/Grav/Console/Cli/SecurityCommand.php @@ -0,0 +1,106 @@ +setName('security') + ->setDescription('Capable of running various Security checks') + ->setHelp('The security runs various security checks on your Grav site'); + + $this->source = getcwd(); + } + + protected function serve() + { + /** @var Grav $grav */ + $grav = Grav::instance(); + $grav->setup(); + + $grav['uri']->init(); + $grav['config']->init(); + $grav['debugger']->enabled(false); + $grav['plugins']->init(); + $grav['themes']->init(); + + $grav['twig']->init(); + $grav['pages']->init(); + + $this->progress = new ProgressBar($this->output, \count($grav['pages']->routes()) - 1); + $this->progress->setFormat('Scanning %current% pages [%bar%] %percent:3s%% %elapsed:6s%'); + $this->progress->setBarWidth(100); + + $io = new SymfonyStyle($this->input, $this->output); + $io->title('Grav Security Check'); + + $output = Security::detectXssFromPages($grav['pages'], false, [$this, 'outputProgress']); + + $io->newline(2); + + if (!empty($output)) { + + $counter = 1; + foreach ($output as $route => $results) { + + $results_parts = array_map(function($value, $key) { + return $key.': \''.$value . '\''; + }, array_values($results), array_keys($results)); + + $io->writeln($counter++ .' - ' . $route . '' . implode(', ', $results_parts) . ''); + } + + $io->error('Security Scan complete: ' . \count($output) . ' potential XSS issues found...'); + + } else { + $io->success('Security Scan complete: No issues found...'); + } + + $io->newline(1); + + } + + /** + * @param array $args + */ + public function outputProgress($args) + { + switch ($args['type']) { + case 'count': + $steps = $args['steps']; + $freq = (int)($steps > 100 ? round($steps / 100) : $steps); + $this->progress->setMaxSteps($steps); + $this->progress->setRedrawFrequency($freq); + break; + case 'progress': + if (isset($args['complete']) && $args['complete']) { + $this->progress->finish(); + } else { + $this->progress->advance(); + } + break; + } + } + +} + diff --git a/system/src/Grav/Console/Cli/YamlLinterCommand.php b/system/src/Grav/Console/Cli/YamlLinterCommand.php new file mode 100644 index 0000000..894f1c0 --- /dev/null +++ b/system/src/Grav/Console/Cli/YamlLinterCommand.php @@ -0,0 +1,82 @@ +setName('yamllinter') + ->addOption( + 'env', + 'e', + InputOption::VALUE_OPTIONAL, + 'The environment to trigger a specific configuration. For example: localhost, mysite.dev, www.mysite.com' + ) + ->setDescription('Checks various files for YAML errors') + ->setHelp("Checks various files for YAML errors"); + } + + protected function serve() + { + $grav = Grav::instance(); + $grav->setup(); + + $io = new SymfonyStyle($this->input, $this->output); + + $io->title('Yaml Linter'); + + $io->section('User Configuration'); + $errors = YamlLinter::lintConfig(); + + if (empty($errors)) { + $io->success('No YAML Linting issues with configuration'); + } else { + $this->displayErrors($errors, $io); + } + + + $io->section('Pages Frontmatter'); + $errors = YamlLinter::lintPages(); + + if (empty($errors)) { + $io->success('No YAML Linting issues with pages'); + } else { + $this->displayErrors($errors, $io); + } + + $io->section('Page Blueprints'); + $errors = YamlLinter::lintBlueprints(); + + if (empty($errors)) { + $io->success('No YAML Linting issues with blueprints'); + } else { + $this->displayErrors($errors, $io); + } + + } + + protected function displayErrors($errors, $io) + { + $io->error("YAML Linting issues found..."); + foreach ($errors as $path => $error) { + $io->writeln("$path - $error"); + } + } +} diff --git a/system/src/Grav/Console/ConsoleCommand.php b/system/src/Grav/Console/ConsoleCommand.php new file mode 100644 index 0000000..c0fd9ad --- /dev/null +++ b/system/src/Grav/Console/ConsoleCommand.php @@ -0,0 +1,186 @@ +setupConsole($input, $output); + $this->serve(); + } + + /** + * Override with your implementation. + */ + protected function serve() + { + } + + /** + * Initialize Grav. + * + * - Load configuration + * - Disable debugger + * - Set timezone, locale + * - Load plugins + * - Set Users type to be used in the site + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializeGrav() + { + InitializeProcessor::initializeCli(Grav::instance()); + + return $this; + } + + /** + * Set language to be used in CLI. + * + * @param string|null $code + */ + final protected function setLanguage(string $code = null) + { + $this->initializeGrav(); + + $grav = Grav::instance(); + /** @var Language $language */ + $language = $grav['language']; + if ($language->enabled()) { + if ($code && $language->validate($code)) { + $language->setActive($code); + } else { + $language->setActive($language->getDefault()); + } + } + } + + /** + * Properly initialize plugins. + * + * - call $this->initializeGrav() + * - call onPluginsInitialized event + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializePlugins() + { + if (!$this->plugins_initialized) { + $this->plugins_initialized = true; + + $this->initializeGrav(); + + // Initialize plugins. + $grav = Grav::instance(); + $grav->fireEvent('onPluginsInitialized'); + } + + return $this; + } + + /** + * Properly initialize themes. + * + * - call $this->initializePlugins() + * - initialize theme (call onThemeInitialized event) + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializeThemes() + { + if (!$this->themes_initialized) { + $this->themes_initialized = true; + + $this->initializePlugins(); + + // Initialize themes. + $grav = Grav::instance(); + $grav['themes']->init(); + } + + return $this; + } + + /** + * Properly initialize pages. + * + * - call $this->initializeThemes() + * - initialize assets (call onAssetsInitialized event) + * - initialize twig (calls the twig events) + * - initialize pages (calls onPagesInitialized event) + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializePages() + { + if (!$this->pages_initialized) { + $this->pages_initialized = true; + + $this->initializeThemes(); + + $grav = Grav::instance(); + + // Initialize assets. + $grav['assets']->init(); + $grav->fireEvent('onAssetsInitialized'); + + // Initialize twig. + $grav['twig']->init(); + + // Initialize pages. + $pages = $grav['pages']; + $pages->init(); + $grav->fireEvent('onPagesInitialized', new Event(['pages' => $pages])); + } + + return $this; + } + + protected function displayGPMRelease() + { + $this->output->writeln(''); + $this->output->writeln('GPM Releases Configuration: ' . ucfirst(Grav::instance()['config']->get('system.gpm.releases')) . ''); + $this->output->writeln(''); + } + +} diff --git a/system/src/Grav/Console/ConsoleTrait.php b/system/src/Grav/Console/ConsoleTrait.php new file mode 100644 index 0000000..1901095 --- /dev/null +++ b/system/src/Grav/Console/ConsoleTrait.php @@ -0,0 +1,139 @@ +set('system.cache.cli_compatibility', true); + Grav::instance()['cache']; + + $this->argv = $_SERVER['argv'][0]; + $this->input = $input; + $this->output = $output; + + $this->output->getFormatter()->setStyle('normal', new OutputFormatterStyle('white')); + $this->output->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, array('bold'))); + $this->output->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, array('bold'))); + $this->output->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, array('bold'))); + $this->output->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, array('bold'))); + $this->output->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, array('bold'))); + $this->output->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, array('bold'))); + } + + /** + * @param string $path + */ + public function isGravInstance($path) + { + if (!file_exists($path)) { + $this->output->writeln(''); + $this->output->writeln("ERROR: Destination doesn't exist:"); + $this->output->writeln(" $path"); + $this->output->writeln(''); + exit; + } + + if (!is_dir($path)) { + $this->output->writeln(''); + $this->output->writeln("ERROR: Destination chosen to install is not a directory:"); + $this->output->writeln(" $path"); + $this->output->writeln(''); + exit; + } + + if (!file_exists($path . DS . 'index.php') || !file_exists($path . DS . '.dependencies') || !file_exists($path . DS . 'system' . DS . 'config' . DS . 'system.yaml')) { + $this->output->writeln(''); + $this->output->writeln('ERROR: Destination chosen to install does not appear to be a Grav instance:'); + $this->output->writeln(" $path"); + $this->output->writeln(''); + exit; + } + } + + public function composerUpdate($path, $action = 'install') + { + $composer = Composer::getComposerExecutor(); + + return system($composer . ' --working-dir="'.$path.'" --no-interaction --no-dev --prefer-dist -o '. $action); + } + + /** + * @param array $all + * + * @return int + * @throws \Exception + */ + public function clearCache($all = []) + { + if ($all) { + $all = ['--all' => true]; + } + + $command = new ClearCacheCommand(); + $input = new ArrayInput($all); + return $command->run($input, $this->output); + } + + public function invalidateCache() + { + Cache::invalidateCache(); + } + + /** + * Load the local config file + * + * @return mixed string the local config file name. false if local config does not exist + */ + public function loadLocalConfig() + { + $home_folder = getenv('HOME') ?: getenv('HOMEDRIVE') . getenv('HOMEPATH'); + $local_config_file = $home_folder . '/.grav/config'; + + if (file_exists($local_config_file)) { + $file = YamlFile::instance($local_config_file); + $this->local_config = $file->content(); + $file->free(); + return $local_config_file; + } + + return false; + } +} diff --git a/system/src/Grav/Console/Gpm/DirectInstallCommand.php b/system/src/Grav/Console/Gpm/DirectInstallCommand.php new file mode 100644 index 0000000..bd27651 --- /dev/null +++ b/system/src/Grav/Console/Gpm/DirectInstallCommand.php @@ -0,0 +1,309 @@ +setName('direct-install') + ->setAliases(['directinstall']) + ->addArgument( + 'package-file', + InputArgument::REQUIRED, + 'Installable package local or remote . Can install specific version' + ) + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addOption( + 'destination', + 'd', + InputOption::VALUE_OPTIONAL, + 'The destination where the package should be installed at. By default this would be where the grav instance has been launched from', + GRAV_ROOT + ) + ->setDescription('Installs Grav, plugin, or theme directly from a file or a URL') + ->setHelp('The direct-install command installs Grav, plugin, or theme directly from a file or a URL'); + } + + /** + * @return bool + */ + protected function serve() + { + // Making sure the destination is usable + $this->destination = realpath($this->input->getOption('destination')); + + if ( + !Installer::isGravInstance($this->destination) || + !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK]) + ) { + $this->output->writeln('ERROR: ' . Installer::lastErrorMsg()); + exit; + } + + + $this->all_yes = $this->input->getOption('all-yes'); + + $package_file = $this->input->getArgument('package-file'); + + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion("Are you sure you want to direct-install {$package_file} [y|N] ", false); + + $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + + if (!$answer) { + $this->output->writeln('exiting...'); + $this->output->writeln(''); + exit; + } + + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); + $tmp_zip = $tmp_dir . '/Grav-' . uniqid(); + + $this->output->writeln(''); + $this->output->writeln("Preparing to install {$package_file}"); + + + if (Response::isRemote($package_file)) { + $this->output->write(' |- Downloading package... 0%'); + try { + $zip = GPM::downloadPackage($package_file, $tmp_zip); + } catch (\RuntimeException $e) { + $this->output->writeln(''); + $this->output->writeln(" `- ERROR: {$e->getMessage()}"); + $this->output->writeln(''); + exit; + } + + if ($zip) { + $this->output->write("\x0D"); + $this->output->write(' |- Downloading package... 100%'); + $this->output->writeln(''); + } + } else { + $this->output->write(' |- Copying package... 0%'); + $zip = GPM::copyPackage($package_file, $tmp_zip); + if ($zip) { + $this->output->write("\x0D"); + $this->output->write(' |- Copying package... 100%'); + $this->output->writeln(''); + } + } + + if (file_exists($zip)) { + $tmp_source = $tmp_dir . '/Grav-' . uniqid(); + + $this->output->write(' |- Extracting package... '); + $extracted = Installer::unZip($zip, $tmp_source); + + if (!$extracted) { + $this->output->write("\x0D"); + $this->output->writeln(' |- Extracting package... failed'); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + exit; + } + + $this->output->write("\x0D"); + $this->output->writeln(' |- Extracting package... ok'); + + + $type = GPM::getPackageType($extracted); + + if (!$type) { + $this->output->writeln(" '- ERROR: Not a valid Grav package"); + $this->output->writeln(''); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + exit; + } + + $blueprint = GPM::getBlueprints($extracted); + if ($blueprint) { + if (isset($blueprint['dependencies'])) { + $dependencies = []; + foreach ($blueprint['dependencies'] as $dependency) { + if (is_array($dependency)){ + if (isset($dependency['name'])) { + $dependencies[] = $dependency['name']; + } + if (isset($dependency['github'])) { + $dependencies[] = $dependency['github']; + } + } else { + $dependencies[] = $dependency; + } + } + $this->output->writeln(' |- Dependencies found... [' . implode(',', $dependencies) . ']'); + + $question = new ConfirmationQuestion(" | '- Dependencies will not be satisfied. Continue ? [y|N] ", false); + $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + + if (!$answer) { + $this->output->writeln('exiting...'); + $this->output->writeln(''); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + exit; + } + } + } + + if ($type === 'grav') { + + $this->output->write(' |- Checking destination... '); + Installer::isValidDestination(GRAV_ROOT . '/system'); + if (Installer::IS_LINK === Installer::lastErrorCode()) { + $this->output->write("\x0D"); + $this->output->writeln(' |- Checking destination... symbolic link'); + $this->output->writeln(" '- ERROR: symlinks found... " . GRAV_ROOT . ''); + $this->output->writeln(''); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + exit; + } + + $this->output->write("\x0D"); + $this->output->writeln(' |- Checking destination... ok'); + + $this->output->write(' |- Installing package... '); + + static::upgradeGrav($zip, $extracted); + } else { + $name = GPM::getPackageName($extracted); + + if (!$name) { + $this->output->writeln('ERROR: Name could not be determined. Please specify with --name|-n'); + $this->output->writeln(''); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + exit; + } + + $install_path = GPM::getInstallPath($type, $name); + $is_update = file_exists($install_path); + + $this->output->write(' |- Checking destination... '); + + Installer::isValidDestination(GRAV_ROOT . DS . $install_path); + if (Installer::lastErrorCode() == Installer::IS_LINK) { + $this->output->write("\x0D"); + $this->output->writeln(' |- Checking destination... symbolic link'); + $this->output->writeln(" '- ERROR: symlink found... " . GRAV_ROOT . DS . $install_path . ''); + $this->output->writeln(''); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + exit; + + } + + $this->output->write("\x0D"); + $this->output->writeln(' |- Checking destination... ok'); + + $this->output->write(' |- Installing package... '); + + Installer::install( + $zip, + $this->destination, + $options = [ + 'install_path' => $install_path, + 'theme' => (($type === 'theme')), + 'is_update' => $is_update + ], + $extracted + ); + } + + Folder::delete($tmp_source); + + $this->output->write("\x0D"); + + if(Installer::lastErrorCode()) { + $this->output->writeln(" '- " . Installer::lastErrorMsg() . ''); + $this->output->writeln(''); + } else { + $this->output->writeln(' |- Installing package... ok'); + $this->output->writeln(" '- Success! "); + $this->output->writeln(''); + } + + } else { + $this->output->writeln(" '- ERROR: ZIP package could not be found"); + } + + Folder::delete($tmp_zip); + + // clear cache after successful upgrade + $this->clearCache(); + + return true; + } + + private function upgradeGrav($zip, $folder, $keepFolder = false) + { + static $ignores = [ + 'backup', + 'cache', + 'images', + 'logs', + 'tmp', + 'user', + '.htaccess', + 'robots.txt' + ]; + + if (!is_dir($folder)) { + Installer::setError('Invalid source folder'); + } + + try { + $script = $folder . '/system/install.php'; + /** Install $installer */ + if ((file_exists($script) && $install = include $script) && is_callable($install)) { + $install($zip); + } else { + Installer::install( + $zip, + GRAV_ROOT, + ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $ignores], + $folder, + $keepFolder + ); + + Cache::clearCache(); + } + } catch (\Exception $e) { + Installer::setError($e->getMessage()); + } + } +} diff --git a/system/src/Grav/Console/Gpm/IndexCommand.php b/system/src/Grav/Console/Gpm/IndexCommand.php new file mode 100644 index 0000000..06d662b --- /dev/null +++ b/system/src/Grav/Console/Gpm/IndexCommand.php @@ -0,0 +1,265 @@ +setName('index') + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force re-fetching the data from remote' + ) + ->addOption( + 'filter', + 'F', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Allows to limit the results based on one or multiple filters input. This can be either portion of a name/slug or a regex' + ) + ->addOption( + 'themes-only', + 'T', + InputOption::VALUE_NONE, + 'Filters the results to only Themes' + ) + ->addOption( + 'plugins-only', + 'P', + InputOption::VALUE_NONE, + 'Filters the results to only Plugins' + ) + ->addOption( + 'updates-only', + 'U', + InputOption::VALUE_NONE, + 'Filters the results to Updatable Themes and Plugins only' + ) + ->addOption( + 'installed-only', + 'I', + InputOption::VALUE_NONE, + 'Filters the results to only the Themes and Plugins you have installed' + ) + ->addOption( + 'sort', + 's', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Allows to sort (ASC) the results based on one or multiple keys. SORT can be either "name", "slug", "author", "date"', + ['date'] + ) + ->addOption( + 'desc', + 'D', + InputOption::VALUE_NONE, + 'Reverses the order of the output.' + ) + ->setDescription('Lists the plugins and themes available for installation') + ->setHelp('The index command lists the plugins and themes available for installation') + ; + } + + protected function serve() + { + $this->options = $this->input->getOptions(); + $this->gpm = new GPM($this->options['force']); + $this->displayGPMRelease(); + $this->data = $this->gpm->getRepository(); + + $data = $this->filter($this->data); + + $climate = new CLImate; + $climate->extend('Grav\Console\TerminalObjects\Table'); + + if (!$data) { + $this->output->writeln('No data was found in the GPM repository stored locally.'); + $this->output->writeln('Please try clearing cache and running the bin/gpm index -f command again'); + $this->output->writeln('If this doesn\'t work try tweaking your GPM system settings.'); + $this->output->writeln(''); + $this->output->writeln('For more help go to:'); + $this->output->writeln(' -> https://learn.getgrav.org/troubleshooting/common-problems#cannot-connect-to-the-gpm'); + + die; + } + + foreach ($data as $type => $packages) { + $this->output->writeln('' . strtoupper($type) . ' [ ' . \count($packages) . ' ]'); + $packages = $this->sort($packages); + + if (!empty($packages)) { + + $table = []; + $index = 0; + + foreach ($packages as $slug => $package) { + $row = [ + 'Count' => $index++ + 1, + 'Name' => '' . Utils::truncate($package->name, 20, false, ' ', '...') . ' ', + 'Slug' => $slug, + 'Version'=> $this->version($package), + 'Installed' => $this->installed($package) + ]; + $table[] = $row; + } + + $climate->table($table); + } + + $this->output->writeln(''); + } + + $this->output->writeln('You can either get more informations about a package by typing:'); + $this->output->writeln(" {$this->argv} info "); + $this->output->writeln(''); + $this->output->writeln('Or you can install a package by typing:'); + $this->output->writeln(" {$this->argv} install "); + $this->output->writeln(''); + } + + /** + * @param Package $package + * + * @return string + */ + private function version($package) + { + $list = $this->gpm->{'getUpdatable' . ucfirst($package->package_type)}(); + $package = $list[$package->slug] ?? $package; + $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $updatable = $this->gpm->{'is' . $type . 'Updatable'}($package->slug); + $installed = $this->gpm->{'is' . $type . 'Installed'}($package->slug); + $local = $this->gpm->{'getInstalled' . $type}($package->slug); + + if (!$installed || !$updatable) { + $version = $installed ? $local->version : $package->version; + return "v{$version}"; + } + + if ($updatable) { + return "v{$package->version} -> v{$package->available}"; + } + + return ''; + } + + /** + * @param Package $package + * + * @return string + */ + private function installed($package) + { + $package = $list[$package->slug] ?? $package; + $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $method = 'is' . $type . 'Installed'; + $installed = $this->gpm->{$method}($package->slug); + + return !$installed ? 'not installed' : 'installed'; + } + + /** + * @param array $data + * + * @return mixed + */ + public function filter($data) + { + // filtering and sorting + if ($this->options['plugins-only']) { + unset($data['themes']); + } + if ($this->options['themes-only']) { + unset($data['plugins']); + } + + $filter = [ + $this->options['filter'], + $this->options['installed-only'], + $this->options['updates-only'], + $this->options['desc'] + ]; + + if (\count(array_filter($filter))) { + foreach ($data as $type => $packages) { + foreach ($packages as $slug => $package) { + $filter = true; + + // Filtering by string + if ($this->options['filter']) { + $filter = preg_grep('/(' . implode('|', $this->options['filter']) . ')/i', [$slug, $package->name]); + } + + // Filtering updatables only + if ($filter && $this->options['installed-only']) { + $method = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $function = 'is' . $method . 'Installed'; + $filter = $this->gpm->{$function}($package->slug); + } + + // Filtering updatables only + if ($filter && $this->options['updates-only']) { + $method = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $function = 'is' . $method . 'Updatable'; + $filter = $this->gpm->{$function}($package->slug); + } + + if (!$filter) { + unset($data[$type][$slug]); + } + } + } + } + + return $data; + } + + /** + * @param Packages $packages + */ + public function sort($packages) + { + foreach ($this->options['sort'] as $key) { + $packages = $packages->sort(function ($a, $b) use ($key) { + switch ($key) { + case 'author': + return strcmp($a->{$key}['name'], $b->{$key}['name']); + break; + default: + return strcmp($a->$key, $b->$key); + } + }, $this->options['desc'] ? true : false); + } + + return $packages; + } +} diff --git a/system/src/Grav/Console/Gpm/InfoCommand.php b/system/src/Grav/Console/Gpm/InfoCommand.php new file mode 100644 index 0000000..2fe1a2e --- /dev/null +++ b/system/src/Grav/Console/Gpm/InfoCommand.php @@ -0,0 +1,180 @@ +setName('info') + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force fetching the new data remotely' + ) + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addArgument( + 'package', + InputArgument::REQUIRED, + 'The package of which more informations are desired. Use the "index" command for a list of packages' + ) + ->setDescription('Shows more informations about a package') + ->setHelp('The info shows more informations about a package'); + } + + /** + * @return int|null|void + */ + protected function serve() + { + $this->gpm = new GPM($this->input->getOption('force')); + + $this->all_yes = $this->input->getOption('all-yes'); + + $this->displayGPMRelease(); + + $foundPackage = $this->gpm->findPackage($this->input->getArgument('package')); + + if (!$foundPackage) { + $this->output->writeln("The package '{$this->input->getArgument('package')}' was not found in the Grav repository."); + $this->output->writeln(''); + $this->output->writeln('You can list all the available packages by typing:'); + $this->output->writeln(" {$this->argv} index"); + $this->output->writeln(''); + exit; + } + + $this->output->writeln("Found package '{$this->input->getArgument('package')}' under the '" . ucfirst($foundPackage->package_type) . "' section"); + $this->output->writeln(''); + $this->output->writeln("{$foundPackage->name} [{$foundPackage->slug}]"); + $this->output->writeln(str_repeat('-', \strlen($foundPackage->name) + \strlen($foundPackage->slug) + 3)); + $this->output->writeln('' . strip_tags($foundPackage->description_plain) . ''); + $this->output->writeln(''); + + $packageURL = ''; + if (isset($foundPackage->author['url'])) { + $packageURL = '<' . $foundPackage->author['url'] . '>'; + } + + $this->output->writeln('' . str_pad('Author', + 12) . ': ' . $foundPackage->author['name'] . ' <' . $foundPackage->author['email'] . '> ' . $packageURL); + + foreach ([ + 'version', + 'keywords', + 'date', + 'homepage', + 'demo', + 'docs', + 'guide', + 'repository', + 'bugs', + 'zipball_url', + 'license' + ] as $info) { + if (isset($foundPackage->{$info})) { + $name = ucfirst($info); + $data = $foundPackage->{$info}; + + if ($info === 'zipball_url') { + $name = 'Download'; + } + + if ($info === 'date') { + $name = 'Last Update'; + $data = date('D, j M Y, H:i:s, P ', strtotime($data)); + } + + $name = str_pad($name, 12); + $this->output->writeln("{$name}: {$data}"); + } + } + + $type = rtrim($foundPackage->package_type, 's'); + $updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug); + $installed = $this->gpm->{'is' . $type . 'Installed'}($foundPackage->slug); + + // display current version if installed and different + if ($installed && $updatable) { + $local = $this->gpm->{'getInstalled'. $type}($foundPackage->slug); + $this->output->writeln(''); + $this->output->writeln("Currently installed version: {$local->version}"); + $this->output->writeln(''); + } + + // display changelog information + $questionHelper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Would you like to read the changelog? [y|N] ', + false); + $answer = $this->all_yes ? true : $questionHelper->ask($this->input, $this->output, $question); + + if ($answer) { + $changelog = $foundPackage->changelog; + + $this->output->writeln(''); + foreach ($changelog as $version => $log) { + $title = $version . ' [' . $log['date'] . ']'; + $content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', function ($match) { + return "\n" . ucfirst($match[1]) . ':'; + }, $log['content']); + + $this->output->writeln("{$title}"); + $this->output->writeln(str_repeat('-', \strlen($title))); + $this->output->writeln($content); + $this->output->writeln(''); + + $question = new ConfirmationQuestion('Press [ENTER] to continue or [q] to quit ', true); + $answer = $this->all_yes ? false : $questionHelper->ask($this->input, $this->output, $question); + if (!$answer) { + break; + } + $this->output->writeln(''); + } + } + + $this->output->writeln(''); + + if ($installed && $updatable) { + $this->output->writeln('You can update this package by typing:'); + $this->output->writeln(" {$this->argv} update {$foundPackage->slug}"); + } else { + $this->output->writeln("You can install this package by typing:"); + $this->output->writeln(" {$this->argv} install {$foundPackage->slug}"); + } + + $this->output->writeln(''); + + } +} diff --git a/system/src/Grav/Console/Gpm/InstallCommand.php b/system/src/Grav/Console/Gpm/InstallCommand.php new file mode 100644 index 0000000..8d544d4 --- /dev/null +++ b/system/src/Grav/Console/Gpm/InstallCommand.php @@ -0,0 +1,691 @@ +setName('install') + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force re-fetching the data from remote' + ) + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addOption( + 'destination', + 'd', + InputOption::VALUE_OPTIONAL, + 'The destination where the package should be installed at. By default this would be where the grav instance has been launched from', + GRAV_ROOT + ) + ->addArgument( + 'package', + InputArgument::IS_ARRAY | InputArgument::REQUIRED, + 'Package(s) to install. Use "bin/gpm index" to list packages. Use "bin/gpm direct-install" to install a specific version' + ) + ->setDescription('Performs the installation of plugins and themes') + ->setHelp('The install command allows to install plugins and themes'); + } + + /** + * Allows to set the GPM object, used for testing the class + * + * @param GPM $gpm + */ + public function setGpm(GPM $gpm) + { + $this->gpm = $gpm; + } + + /** + * @return bool + */ + protected function serve() + { + $this->gpm = new GPM($this->input->getOption('force')); + + $this->all_yes = $this->input->getOption('all-yes'); + + $this->displayGPMRelease(); + + $this->destination = realpath($this->input->getOption('destination')); + + $packages = array_map('strtolower', $this->input->getArgument('package')); + $this->data = $this->gpm->findPackages($packages); + $this->loadLocalConfig(); + + if ( + !Installer::isGravInstance($this->destination) || + !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK]) + ) { + $this->output->writeln('ERROR: ' . Installer::lastErrorMsg()); + exit; + } + + $this->output->writeln(''); + + if (!$this->data['total']) { + $this->output->writeln('Nothing to install.'); + $this->output->writeln(''); + exit; + } + + if (\count($this->data['not_found'])) { + $this->output->writeln('These packages were not found on Grav: ' . implode(', ', + array_keys($this->data['not_found'])) . ''); + } + + unset($this->data['not_found'], $this->data['total']); + + if (null !== $this->local_config) { + // Symlinks available, ask if Grav should use them + $this->use_symlinks = false; + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Should Grav use the symlinks if available? [y|N] ', false); + + $answer = $this->all_yes ? false : $helper->ask($this->input, $this->output, $question); + + if ($answer) { + $this->use_symlinks = true; + } + } + + $this->output->writeln(''); + + try { + $dependencies = $this->gpm->getDependencies($packages); + } catch (\Exception $e) { + //Error out if there are incompatible packages requirements and tell which ones, and what to do + //Error out if there is any error in parsing the dependencies and their versions, and tell which one is broken + $this->output->writeln("{$e->getMessage()}"); + return false; + } + + if ($dependencies) { + try { + $this->installDependencies($dependencies, 'install', 'The following dependencies need to be installed...'); + $this->installDependencies($dependencies, 'update', 'The following dependencies need to be updated...'); + $this->installDependencies($dependencies, 'ignore', "The following dependencies can be updated as there is a newer version, but it's not mandatory...", false); + } catch (\Exception $e) { + $this->output->writeln('Installation aborted'); + return false; + } + + $this->output->writeln('Dependencies are OK'); + $this->output->writeln(''); + } + + + //We're done installing dependencies. Install the actual packages + foreach ($this->data as $data) { + foreach ($data as $package_name => $package) { + if (array_key_exists($package_name, $dependencies)) { + $this->output->writeln("Package {$package_name} already installed as dependency"); + } else { + $is_valid_destination = Installer::isValidDestination($this->destination . DS . $package->install_path); + if ($is_valid_destination || Installer::lastErrorCode() == Installer::NOT_FOUND) { + $this->processPackage($package, false); + } else { + if (Installer::lastErrorCode() == Installer::EXISTS) { + + try { + $this->askConfirmationIfMajorVersionUpdated($package); + $this->gpm->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package->slug, $package->available, array_keys($data)); + } catch (\Exception $e) { + $this->output->writeln("{$e->getMessage()}"); + return false; + } + + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion("The package {$package_name} is already installed, overwrite? [y|N] ", false); + $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + + if ($answer) { + $is_update = true; + $this->processPackage($package, $is_update); + } else { + $this->output->writeln("Package {$package_name} not overwritten"); + } + } else { + if (Installer::lastErrorCode() == Installer::IS_LINK) { + $this->output->writeln("Cannot overwrite existing symlink for {$package_name}"); + $this->output->writeln(''); + } + } + } + } + } + } + + if (\count($this->demo_processing) > 0) { + foreach ($this->demo_processing as $package) { + $this->installDemoContent($package); + } + } + + // clear cache after successful upgrade + $this->clearCache(); + + return true; + } + + /** + * If the package is updated from an older major release, show warning and ask confirmation + * + * @param Package $package + */ + public function askConfirmationIfMajorVersionUpdated($package) + { + $helper = $this->getHelper('question'); + $package_name = $package->name; + $new_version = $package->available ?: $this->gpm->getLatestVersionOfPackage($package->slug); + $old_version = $package->version; + + $major_version_changed = explode('.', $new_version)[0] !== explode('.', $old_version)[0]; + + if ($major_version_changed) { + if ($this->all_yes) { + $this->output->writeln("The package {$package_name} will be updated to a new major version {$new_version}, from {$old_version}"); + return; + } + + $question = new ConfirmationQuestion("The package {$package_name} will be updated to a new major version {$new_version}, from {$old_version}. Be sure to read what changed with the new major release. Continue? [y|N] ", false); + + if (!$helper->ask($this->input, $this->output, $question)) { + $this->output->writeln("Package {$package_name} not updated"); + exit; + } + } + } + + /** + * Given a $dependencies list, filters their type according to $type and + * shows $message prior to listing them to the user. Then asks the user a confirmation prior + * to installing them. + * + * @param array $dependencies The dependencies array + * @param string $type The type of dependency to show: install, update, ignore + * @param string $message A message to be shown prior to listing the dependencies + * @param bool $required A flag that determines if the installation is required or optional + * + * @throws \Exception + */ + public function installDependencies($dependencies, $type, $message, $required = true) + { + $packages = array_filter($dependencies, function ($action) use ($type) { return $action === $type; }); + if (\count($packages) > 0) { + $this->output->writeln($message); + + foreach ($packages as $dependencyName => $dependencyVersion) { + $this->output->writeln(" |- Package {$dependencyName}"); + } + + $this->output->writeln(''); + + $helper = $this->getHelper('question'); + + if ($type === 'install') { + $questionAction = 'Install'; + } else { + $questionAction = 'Update'; + } + + if (\count($packages) === 1) { + $questionArticle = 'this'; + } else { + $questionArticle = 'these'; + } + + if (\count($packages) === 1) { + $questionNoun = 'package'; + } else { + $questionNoun = 'packages'; + } + + $question = new ConfirmationQuestion("${questionAction} {$questionArticle} {$questionNoun}? [Y|n] ", true); + $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + + if ($answer) { + foreach ($packages as $dependencyName => $dependencyVersion) { + $package = $this->gpm->findPackage($dependencyName); + $this->processPackage($package, $type === 'update'); + } + $this->output->writeln(''); + } else { + if ($required) { + throw new \Exception(); + } + } + } + } + + /** + * @param Package $package + * @param bool $is_update True if the package is an update + */ + private function processPackage($package, $is_update = false) + { + if (!$package) { + $this->output->writeln('Package not found on the GPM!'); + $this->output->writeln(''); + return; + } + + $symlink = false; + if ($this->use_symlinks) { + if (!isset($package->version) || $this->getSymlinkSource($package)) { + $symlink = true; + } + } + + $symlink ? $this->processSymlink($package) : $this->processGpm($package, $is_update); + + $this->processDemo($package); + } + + /** + * Add package to the queue to process the demo content, if demo content exists + * + * @param Package $package + */ + private function processDemo($package) + { + $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo'; + if (file_exists($demo_dir)) { + $this->demo_processing[] = $package; + } + } + + /** + * Prompt to install the demo content of a package + * + * @param Package $package + */ + private function installDemoContent($package) + { + $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo'; + + if (file_exists($demo_dir)) { + $dest_dir = $this->destination . DS . 'user'; + $pages_dir = $dest_dir . DS . 'pages'; + + // Demo content exists, prompt to install it. + $this->output->writeln("Attention: {$package->name} contains demo content"); + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Do you wish to install this demo content? [y|N] ', false); + + $answer = $helper->ask($this->input, $this->output, $question); + + if (!$answer) { + $this->output->writeln(" '- Skipped! "); + $this->output->writeln(''); + + return; + } + + // if pages folder exists in demo + if (file_exists($demo_dir . DS . 'pages')) { + $pages_backup = 'pages.' . date('m-d-Y-H-i-s'); + $question = new ConfirmationQuestion('This will backup your current `user/pages` folder to `user/' . $pages_backup . '`, continue? [y|N]', false); + $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + + if (!$answer) { + $this->output->writeln(" '- Skipped! "); + $this->output->writeln(''); + + return; + } + + // backup current pages folder + if (file_exists($dest_dir)) { + if (rename($pages_dir, $dest_dir . DS . $pages_backup)) { + $this->output->writeln(' |- Backing up pages... ok'); + } else { + $this->output->writeln(' |- Backing up pages... failed'); + } + } + } + + // Confirmation received, copy over the data + $this->output->writeln(' |- Installing demo content... ok '); + Folder::rcopy($demo_dir, $dest_dir); + $this->output->writeln(" '- Success! "); + $this->output->writeln(''); + } + } + + /** + * @param Package $package + * + * @return array|bool + */ + private function getGitRegexMatches($package) + { + if (isset($package->repository)) { + $repository = $package->repository; + } else { + return false; + } + + preg_match(GIT_REGEX, $repository, $matches); + + return $matches; + } + + /** + * @param Package $package + * + * @return bool|string + */ + private function getSymlinkSource($package) + { + $matches = $this->getGitRegexMatches($package); + + foreach ($this->local_config as $paths) { + if (Utils::endsWith($matches[2], '.git')) { + $repo_dir = preg_replace('/\.git$/', '', $matches[2]); + } else { + $repo_dir = $matches[2]; + } + + $paths = (array) $paths; + foreach ($paths as $repo) { + $path = rtrim($repo, '/') . '/' . $repo_dir; + if (file_exists($path)) { + return $path; + } + } + + } + + return false; + } + + /** + * @param Package $package + */ + private function processSymlink($package) + { + + exec('cd ' . $this->destination); + + $to = $this->destination . DS . $package->install_path; + $from = $this->getSymlinkSource($package); + + $this->output->writeln("Preparing to Symlink {$package->name}"); + $this->output->write(' |- Checking source... '); + + if (file_exists($from)) { + $this->output->writeln('ok'); + + $this->output->write(' |- Checking destination... '); + $checks = $this->checkDestination($package); + + if (!$checks) { + $this->output->writeln(" '- Installation failed or aborted."); + $this->output->writeln(''); + } else { + if (file_exists($to)) { + $this->output->writeln(" '- Symlink cannot overwrite an existing package, please remove first"); + $this->output->writeln(''); + } else { + symlink($from, $to); + + // extra white spaces to clear out the buffer properly + $this->output->writeln(' |- Symlinking package... ok '); + $this->output->writeln(" '- Success! "); + $this->output->writeln(''); + } + } + + return; + } + + $this->output->writeln('not found!'); + $this->output->writeln(" '- Installation failed or aborted."); + } + + /** + * @param Package $package + * @param bool $is_update + * + * @return bool + */ + private function processGpm($package, $is_update = false) + { + $version = isset($package->available) ? $package->available : $package->version; + $license = Licenses::get($package->slug); + + $this->output->writeln("Preparing to install {$package->name} [v{$version}]"); + + $this->output->write(' |- Downloading package... 0%'); + $this->file = $this->downloadPackage($package, $license); + + if (!$this->file) { + $this->output->writeln(" '- Installation failed or aborted."); + $this->output->writeln(''); + + return false; + } + + $this->output->write(' |- Checking destination... '); + $checks = $this->checkDestination($package); + + if (!$checks) { + $this->output->writeln(" '- Installation failed or aborted."); + $this->output->writeln(''); + } else { + $this->output->write(' |- Installing package... '); + $installation = $this->installPackage($package, $is_update); + if (!$installation) { + $this->output->writeln(" '- Installation failed or aborted."); + $this->output->writeln(''); + } else { + $this->output->writeln(" '- Success! "); + $this->output->writeln(''); + + return true; + } + } + + return false; + } + + /** + * @param Package $package + * + * @param string $license + * + * @return string + */ + private function downloadPackage($package, $license = null) + { + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); + $this->tmp = $tmp_dir . '/Grav-' . uniqid(); + $filename = $package->slug . basename($package->zipball_url); + $filename = preg_replace('/[\\\\\/:"*?&<>|]+/m', '-', $filename); + $query = ''; + + if (!empty($package->premium)) { + $query = \json_encode(array_merge( + $package->premium, + [ + 'slug' => $package->slug, + 'filename' => $package->premium['filename'], + 'license_key' => $license + ] + )); + + $query = '?d=' . base64_encode($query); + } + + try { + $output = Response::get($package->zipball_url . $query, [], [$this, 'progress']); + } catch (\Exception $e) { + $error = str_replace("\n", "\n | '- ", $e->getMessage()); + $this->output->write("\x0D"); + // extra white spaces to clear out the buffer properly + $this->output->writeln(' |- Downloading package... error '); + $this->output->writeln(" | '- " . $error); + + return false; + } + + Folder::create($this->tmp); + + $this->output->write("\x0D"); + $this->output->write(' |- Downloading package... 100%'); + $this->output->writeln(''); + + file_put_contents($this->tmp . DS . $filename, $output); + + return $this->tmp . DS . $filename; + } + + /** + * @param Package $package + * + * @return bool + */ + private function checkDestination($package) + { + $question_helper = $this->getHelper('question'); + + Installer::isValidDestination($this->destination . DS . $package->install_path); + + if (Installer::lastErrorCode() == Installer::IS_LINK) { + $this->output->write("\x0D"); + $this->output->writeln(' |- Checking destination... symbolic link'); + + if ($this->all_yes) { + $this->output->writeln(" | '- Skipped automatically."); + + return false; + } + + $question = new ConfirmationQuestion(" | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ", + false); + $answer = $question_helper->ask($this->input, $this->output, $question); + + if (!$answer) { + $this->output->writeln(" | '- You decided to not delete the symlink automatically."); + + return false; + } + + unlink($this->destination . DS . $package->install_path); + } + + $this->output->write("\x0D"); + $this->output->writeln(' |- Checking destination... ok'); + + return true; + } + + /** + * Install a package + * + * @param Package $package + * @param bool $is_update True if it's an update. False if it's an install + * + * @return bool + */ + private function installPackage($package, $is_update = false) + { + $type = $package->package_type; + + Installer::install($this->file, $this->destination, ['install_path' => $package->install_path, 'theme' => $type === 'themes', 'is_update' => $is_update]); + $error_code = Installer::lastErrorCode(); + Folder::delete($this->tmp); + + if ($error_code) { + $this->output->write("\x0D"); + // extra white spaces to clear out the buffer properly + $this->output->writeln(' |- Installing package... error '); + $this->output->writeln(" | '- " . Installer::lastErrorMsg()); + + return false; + } + + $message = Installer::getMessage(); + if ($message) { + $this->output->write("\x0D"); + // extra white spaces to clear out the buffer properly + $this->output->writeln(" |- {$message}"); + } + + $this->output->write("\x0D"); + // extra white spaces to clear out the buffer properly + $this->output->writeln(' |- Installing package... ok '); + + return true; + } + + /** + * @param array $progress + */ + public function progress($progress) + { + $this->output->write("\x0D"); + $this->output->write(' |- Downloading package... ' . str_pad($progress['percent'], 5, ' ', + STR_PAD_LEFT) . '%'); + } +} diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php new file mode 100644 index 0000000..62c6120 --- /dev/null +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -0,0 +1,297 @@ +setName('self-upgrade') + ->setAliases(['selfupgrade', 'selfupdate']) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force re-fetching the data from remote' + ) + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addOption( + 'overwrite', + 'o', + InputOption::VALUE_NONE, + 'Option to overwrite packages if they already exist' + ) + ->setDescription('Detects and performs an update of Grav itself when available') + ->setHelp('The update command updates Grav itself when a new version is available'); + } + + protected function serve() + { + $this->upgrader = new Upgrader($this->input->getOption('force')); + $this->all_yes = $this->input->getOption('all-yes'); + $this->overwrite = $this->input->getOption('overwrite'); + + $this->displayGPMRelease(); + + $update = $this->upgrader->getAssets()['grav-update']; + + $local = $this->upgrader->getLocalVersion(); + $remote = $this->upgrader->getRemoteVersion(); + $release = strftime('%c', strtotime($this->upgrader->getReleaseDate())); + + if (!$this->upgrader->meetsRequirements()) { + $this->output->writeln('ATTENTION:'); + $this->output->writeln(' Grav has increased the minimum PHP requirement.'); + $this->output->writeln(' You are currently running PHP ' . phpversion() . ', but PHP ' . $this->upgrader->minPHPVersion() . ' is required.'); + $this->output->writeln(' Additional information: http://getgrav.org/blog/changing-php-requirements'); + $this->output->writeln(''); + $this->output->writeln('Selfupgrade aborted.'); + $this->output->writeln(''); + exit; + } + + if (!$this->overwrite && !$this->upgrader->isUpgradable()) { + $this->output->writeln("You are already running the latest version of Grav (v{$local}) released on {$release}"); + exit; + } + + Installer::isValidDestination(GRAV_ROOT . '/system'); + if (Installer::IS_LINK === Installer::lastErrorCode()) { + $this->output->writeln('ATTENTION: Grav is symlinked, cannot upgrade, aborting...'); + $this->output->writeln(''); + $this->output->writeln("You are currently running a symbolically linked Grav v{$local}. Latest available is v{$remote}."); + exit; + } + + // not used but preloaded just in case! + new ArrayInput([]); + + $questionHelper = $this->getHelper('question'); + + + $this->output->writeln("Grav v{$remote} is now available [release date: {$release}]."); + $this->output->writeln('You are currently using v' . GRAV_VERSION . '.'); + + if (!$this->all_yes) { + $question = new ConfirmationQuestion('Would you like to read the changelog before proceeding? [y|N] ', + false); + $answer = $questionHelper->ask($this->input, $this->output, $question); + + if ($answer) { + $changelog = $this->upgrader->getChangelog(GRAV_VERSION); + + $this->output->writeln(''); + foreach ($changelog as $version => $log) { + $title = $version . ' [' . $log['date'] . ']'; + $content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', function ($match) { + return "\n" . ucfirst($match[1]) . ':'; + }, $log['content']); + + $this->output->writeln($title); + $this->output->writeln(str_repeat('-', \strlen($title))); + $this->output->writeln($content); + $this->output->writeln(''); + } + + $question = new ConfirmationQuestion('Press [ENTER] to continue.', true); + $questionHelper->ask($this->input, $this->output, $question); + } + + $question = new ConfirmationQuestion('Would you like to upgrade now? [y|N] ', false); + $answer = $questionHelper->ask($this->input, $this->output, $question); + + if (!$answer) { + $this->output->writeln('Aborting...'); + + exit; + } + } + + $this->output->writeln(''); + $this->output->writeln("Preparing to upgrade to v{$remote}.."); + + $this->output->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%"); + $this->file = $this->download($update); + + $this->output->write(' |- Installing upgrade... '); + $installation = $this->upgrade(); + + if (!$installation) { + $this->output->writeln(" '- Installation failed or aborted."); + $this->output->writeln(''); + } else { + $this->output->writeln(" '- Success! "); + $this->output->writeln(''); + } + + // clear cache after successful upgrade + $this->clearCache('all'); + } + + /** + * @param array $package + * + * @return string + */ + private function download($package) + { + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); + $this->tmp = $tmp_dir . '/Grav-' . uniqid('', false); + $output = Response::get($package['download'], [], [$this, 'progress']); + + Folder::create($this->tmp); + + $this->output->write("\x0D"); + $this->output->write(" |- Downloading upgrade [{$this->formatBytes($package['size'])}]... 100%"); + $this->output->writeln(''); + + file_put_contents($this->tmp . DS . $package['name'], $output); + + return $this->tmp . DS . $package['name']; + } + + /** + * @return bool + */ + private function upgrade() + { + if ($this->file) { + $folder = Installer::unZip($this->file, $this->tmp . '/zip'); + } else { + $folder = false; + } + + static::upgradeGrav($this->file, $folder); + + $errorCode = Installer::lastErrorCode(); + + if ($this->tmp) { + Folder::delete($this->tmp); + } + + if ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR)) { + $this->output->write("\x0D"); + // extra white spaces to clear out the buffer properly + $this->output->writeln(' |- Installing upgrade... error '); + $this->output->writeln(" | '- " . Installer::lastErrorMsg()); + + return false; + } + + $this->output->write("\x0D"); + // extra white spaces to clear out the buffer properly + $this->output->writeln(' |- Installing upgrade... ok '); + + return true; + } + + /** + * @param array $progress + */ + public function progress($progress) + { + $this->output->write("\x0D"); + $this->output->write(" |- Downloading upgrade [{$this->formatBytes($progress['filesize']) }]... " . str_pad($progress['percent'], + 5, ' ', STR_PAD_LEFT) . '%'); + } + + /** + * @param int|float $size + * @param int $precision + * + * @return string + */ + public function formatBytes($size, $precision = 2) + { + $base = log($size) / log(1024); + $suffixes = array('', 'k', 'M', 'G', 'T'); + + return round(1024 ** ($base - floor($base)), $precision) . $suffixes[(int)floor($base)]; + } + + private function upgradeGrav($zip, $folder, $keepFolder = false) + { + static $ignores = [ + 'backup', + 'cache', + 'images', + 'logs', + 'tmp', + 'user', + '.htaccess', + 'robots.txt' + ]; + + if (!is_dir($folder)) { + Installer::setError('Invalid source folder'); + } + + try { + $script = $folder . '/system/install.php'; + /** Install $installer */ + if ((file_exists($script) && $install = include $script) && is_callable($install)) { + $install($zip); + } else { + Installer::install( + $zip, + GRAV_ROOT, + ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $ignores], + $folder, + $keepFolder + ); + + Cache::clearCache(); + } + } catch (\Exception $e) { + Installer::setError($e->getMessage()); + } + } +} diff --git a/system/src/Grav/Console/Gpm/UninstallCommand.php b/system/src/Grav/Console/Gpm/UninstallCommand.php new file mode 100644 index 0000000..99fc81a --- /dev/null +++ b/system/src/Grav/Console/Gpm/UninstallCommand.php @@ -0,0 +1,295 @@ +setName('uninstall') + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addArgument( + 'package', + InputArgument::IS_ARRAY | InputArgument::REQUIRED, + 'The package(s) that are desired to be removed. Use the "index" command for a list of packages' + ) + ->setDescription('Performs the uninstallation of plugins and themes') + ->setHelp('The uninstall command allows to uninstall plugins and themes'); + } + + protected function serve() + { + $this->gpm = new GPM(); + + $this->all_yes = $this->input->getOption('all-yes'); + + $packages = array_map('strtolower', $this->input->getArgument('package')); + $this->data = ['total' => 0, 'not_found' => []]; + + $total = 0; + foreach ($packages as $package) { + $plugin = $this->gpm->getInstalledPlugin($package); + $theme = $this->gpm->getInstalledTheme($package); + if ($plugin || $theme) { + $this->data[strtolower($package)] = $plugin ?: $theme; + $total++; + } else { + $this->data['not_found'][] = $package; + } + } + $this->data['total'] = $total; + + $this->output->writeln(''); + + if (!$this->data['total']) { + $this->output->writeln('Nothing to uninstall.'); + $this->output->writeln(''); + exit; + } + + if (count($this->data['not_found'])) { + $this->output->writeln('These packages were not found installed: ' . implode(', ', + $this->data['not_found']) . ''); + } + + unset($this->data['not_found'], $this->data['total']); + + foreach ($this->data as $slug => $package) { + $this->output->writeln("Preparing to uninstall {$package->name} [v{$package->version}]"); + + $this->output->write(' |- Checking destination... '); + $checks = $this->checkDestination($slug, $package); + + if (!$checks) { + $this->output->writeln(" '- Installation failed or aborted."); + $this->output->writeln(''); + } else { + $uninstall = $this->uninstallPackage($slug, $package); + + if (!$uninstall) { + $this->output->writeln(" '- Uninstallation failed or aborted."); + } else { + $this->output->writeln(" '- Success! "); + } + } + + } + + // clear cache after successful upgrade + $this->clearCache(); + } + + + /** + * @param string $slug + * @param Package $package + * + * @return bool + */ + private function uninstallPackage($slug, $package, $is_dependency = false) + { + if (!$slug) { + return false; + } + + //check if there are packages that have this as a dependency. Abort and show list + $dependent_packages = $this->gpm->getPackagesThatDependOnPackage($slug); + if (count($dependent_packages) > ($is_dependency ? 1 : 0)) { + $this->output->writeln(''); + $this->output->writeln(''); + $this->output->writeln('Uninstallation failed.'); + $this->output->writeln(''); + if (\count($dependent_packages) > ($is_dependency ? 2 : 1)) { + $this->output->writeln('The installed packages ' . implode(', ', $dependent_packages) . ' depends on this package. Please remove those first.'); + } else { + $this->output->writeln('The installed package ' . implode(', ', $dependent_packages) . ' depends on this package. Please remove it first.'); + } + + $this->output->writeln(''); + return false; + } + + if (isset($package->dependencies)) { + + $dependencies = $package->dependencies; + + if ($is_dependency) { + foreach ($dependencies as $key => $dependency) { + if (\in_array($dependency['name'], $this->dependencies, true)) { + unset($dependencies[$key]); + } + } + } else { + if (\count($dependencies) > 0) { + $this->output->writeln(' `- Dependencies found...'); + $this->output->writeln(''); + } + } + + $questionHelper = $this->getHelper('question'); + + foreach ($dependencies as $dependency) { + + $this->dependencies[] = $dependency['name']; + + if (\is_array($dependency)) { + $dependency = $dependency['name']; + } + if ($dependency === 'grav' || $dependency === 'php') { + continue; + } + + $dependencyPackage = $this->gpm->findPackage($dependency); + + $dependency_exists = $this->packageExists($dependency, $dependencyPackage); + + if ($dependency_exists == Installer::EXISTS) { + $this->output->writeln("A dependency on {$dependencyPackage->name} [v{$dependencyPackage->version}] was found"); + + $question = new ConfirmationQuestion(" |- Uninstall {$dependencyPackage->name}? [y|N] ", false); + $answer = $this->all_yes ? true : $questionHelper->ask($this->input, $this->output, $question); + + if ($answer) { + $uninstall = $this->uninstallPackage($dependency, $dependencyPackage, true); + + if (!$uninstall) { + $this->output->writeln(" '- Uninstallation failed or aborted."); + } else { + $this->output->writeln(" '- Success! "); + + } + $this->output->writeln(''); + } else { + $this->output->writeln(" '- You decided not to uninstall {$dependencyPackage->name}."); + $this->output->writeln(''); + } + } + + } + } + + + $locator = Grav::instance()['locator']; + $path = $locator->findResource($package->package_type . '://' . $slug); + Installer::uninstall($path); + $errorCode = Installer::lastErrorCode(); + + if ($errorCode && $errorCode !== Installer::IS_LINK && $errorCode !== Installer::EXISTS) { + $this->output->writeln(" |- Uninstalling {$package->name} package... error "); + $this->output->writeln(" | '- " . Installer::lastErrorMsg() . ''); + + return false; + } + + $message = Installer::getMessage(); + if ($message) { + $this->output->writeln(" |- {$message}"); + } + + if (!$is_dependency && $this->dependencies) { + $this->output->writeln("Finishing up uninstalling {$package->name}"); + } + $this->output->writeln(" |- Uninstalling {$package->name} package... ok "); + + + + return true; + } + + /** + * @param string $slug + * @param Package $package + * + * @return bool + */ + + private function checkDestination($slug, $package) + { + $questionHelper = $this->getHelper('question'); + + $exists = $this->packageExists($slug, $package); + + if ($exists == Installer::IS_LINK) { + $this->output->write("\x0D"); + $this->output->writeln(' |- Checking destination... symbolic link'); + + if ($this->all_yes) { + $this->output->writeln(" | '- Skipped automatically."); + + return false; + } + + $question = new ConfirmationQuestion(" | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ", + false); + $answer = $this->all_yes ? true : $questionHelper->ask($this->input, $this->output, $question); + + if (!$answer) { + $this->output->writeln(" | '- You decided not to delete the symlink automatically."); + + return false; + } + } + + $this->output->write("\x0D"); + $this->output->writeln(' |- Checking destination... ok'); + + return true; + } + + /** + * Check if package exists + * + * @param string $slug + * @param Package $package + * @return int + */ + private function packageExists($slug, $package) + { + $path = Grav::instance()['locator']->findResource($package->package_type . '://' . $slug); + Installer::isValidDestination($path); + return Installer::lastErrorCode(); + } +} diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php new file mode 100644 index 0000000..d8ea735 --- /dev/null +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -0,0 +1,268 @@ +setName('update') + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force re-fetching the data from remote' + ) + ->addOption( + 'destination', + 'd', + InputOption::VALUE_OPTIONAL, + 'The grav instance location where the updates should be applied to. By default this would be where the grav cli has been launched from', + GRAV_ROOT + ) + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addOption( + 'overwrite', + 'o', + InputOption::VALUE_NONE, + 'Option to overwrite packages if they already exist' + ) + ->addOption( + 'plugins', + 'p', + InputOption::VALUE_NONE, + 'Update only plugins' + ) + ->addOption( + 'themes', + 't', + InputOption::VALUE_NONE, + 'Update only themes' + ) + ->addArgument( + 'package', + InputArgument::IS_ARRAY | InputArgument::OPTIONAL, + 'The package or packages that is desired to update. By default all available updates will be applied.' + ) + ->setDescription('Detects and performs an update of plugins and themes when available') + ->setHelp('The update command updates plugins and themes when a new version is available'); + } + + protected function serve() + { + $this->upgrader = new Upgrader($this->input->getOption('force')); + $local = $this->upgrader->getLocalVersion(); + $remote = $this->upgrader->getRemoteVersion(); + if ($local !== $remote) { + $this->output->writeln('WARNING: A new version of Grav is available. You should update Grav before updating plugins and themes. If you continue without updating Grav, some plugins or themes may stop working.'); + $this->output->writeln(''); + $questionHelper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true); + $answer = $questionHelper->ask($this->input, $this->output, $question); + + if (!$answer) { + $this->output->writeln('Update aborted. Exiting...'); + exit; + } + } + + $this->gpm = new GPM($this->input->getOption('force')); + + $this->all_yes = $this->input->getOption('all-yes'); + $this->overwrite = $this->input->getOption('overwrite'); + + $this->displayGPMRelease(); + + $this->destination = realpath($this->input->getOption('destination')); + + if (!Installer::isGravInstance($this->destination)) { + $this->output->writeln('ERROR: ' . Installer::lastErrorMsg()); + exit; + } + if ($this->input->getOption('plugins') === false && $this->input->getOption('themes') === false) { + $list_type = ['plugins' => true, 'themes' => true]; + } else { + $list_type['plugins'] = $this->input->getOption('plugins'); + $list_type['themes'] = $this->input->getOption('themes'); + } + + if ($this->overwrite) { + $this->data = $this->gpm->getInstallable($list_type); + $description = ' can be overwritten'; + } else { + $this->data = $this->gpm->getUpdatable($list_type); + $description = ' need updating'; + } + + $only_packages = array_map('strtolower', $this->input->getArgument('package')); + + if (!$this->overwrite && !$this->data['total']) { + $this->output->writeln('Nothing to update.'); + exit; + } + + $this->output->write("Found {$this->gpm->countInstalled()} packages installed of which {$this->data['total']}{$description}"); + + $limit_to = $this->userInputPackages($only_packages); + + $this->output->writeln(''); + + unset($this->data['total'], $limit_to['total']); + + + // updates review + $slugs = []; + + $index = 0; + foreach ($this->data as $packages) { + foreach ($packages as $slug => $package) { + if (!array_key_exists($slug, $limit_to) && \count($only_packages)) { + continue; + } + + if (!$package->available) { + $package->available = $package->version; + } + + $this->output->writeln( + // index + str_pad($index++ + 1, 2, '0', STR_PAD_LEFT) . '. ' . + // name + '' . str_pad($package->name, 15) . ' ' . + // version + "[v{$package->version} -> v{$package->available}]" + ); + $slugs[] = $slug; + } + } + + if (!$this->all_yes) { + // prompt to continue + $this->output->writeln(''); + $questionHelper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true); + $answer = $questionHelper->ask($this->input, $this->output, $question); + + if (!$answer) { + $this->output->writeln('Update aborted. Exiting...'); + exit; + } + } + + // finally update + $install_command = $this->getApplication()->find('install'); + + $args = new ArrayInput([ + 'command' => 'install', + 'package' => $slugs, + '-f' => $this->input->getOption('force'), + '-d' => $this->destination, + '-y' => true + ]); + $command_exec = $install_command->run($args, $this->output); + + if ($command_exec != 0) { + $this->output->writeln('Error: An error occurred while trying to install the packages'); + exit; + } + } + + /** + * @param array $only_packages + * + * @return array + */ + private function userInputPackages($only_packages) + { + $found = ['total' => 0]; + $ignore = []; + + if (!\count($only_packages)) { + $this->output->writeln(''); + } else { + foreach ($only_packages as $only_package) { + $find = $this->gpm->findPackage($only_package); + + if (!$find || (!$this->overwrite && !$this->gpm->isUpdatable($find->slug))) { + $name = $find->slug ?? $only_package; + $ignore[$name] = $name; + } else { + $found[$find->slug] = $find; + $found['total']++; + } + } + + if ($found['total']) { + $list = $found; + unset($list['total']); + $list = array_keys($list); + + if ($found['total'] !== $this->data['total']) { + $this->output->write(", only {$found['total']} will be updated"); + } + + $this->output->writeln(''); + $this->output->writeln('Limiting updates for only ' . implode(', ', + $list) . ''); + } + + if (\count($ignore)) { + $this->output->writeln(''); + $this->output->writeln('Packages not found or not requiring updates: ' . implode(', ', + $ignore) . ''); + + } + } + + return $found; + } +} diff --git a/system/src/Grav/Console/Gpm/VersionCommand.php b/system/src/Grav/Console/Gpm/VersionCommand.php new file mode 100644 index 0000000..becc327 --- /dev/null +++ b/system/src/Grav/Console/Gpm/VersionCommand.php @@ -0,0 +1,112 @@ +setName('version') + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force re-fetching the data from remote' + ) + ->addArgument( + 'package', + InputArgument::IS_ARRAY | InputArgument::OPTIONAL, + 'The package or packages that is desired to know the version of. By default and if not specified this would be grav' + ) + ->setDescription('Shows the version of an installed package. If available also shows pending updates.') + ->setHelp('The version command displays the current version of a package installed and, if available, the available version of pending updates'); + } + + /** + * @return int|null|void + */ + protected function serve() + { + $this->gpm = new GPM($this->input->getOption('force')); + $packages = $this->input->getArgument('package'); + + $installed = false; + + if (!count($packages)) { + $packages = ['grav']; + } + + foreach ($packages as $package) { + $package = strtolower($package); + $name = null; + $version = null; + $updatable = false; + + if ($package === 'grav') { + $name = 'Grav'; + $version = GRAV_VERSION; + $upgrader = new Upgrader(); + + if ($upgrader->isUpgradable()) { + $updatable = " [upgradable: v{$upgrader->getRemoteVersion()}]"; + } + + } else { + // get currently installed version + $locator = \Grav\Common\Grav::instance()['locator']; + $blueprints_path = $locator->findResource('plugins://' . $package . DS . 'blueprints.yaml'); + if (!file_exists($blueprints_path)) { // theme? + $blueprints_path = $locator->findResource('themes://' . $package . DS . 'blueprints.yaml'); + if (!file_exists($blueprints_path)) { + continue; + } + } + + $file = YamlFile::instance($blueprints_path); + $package_yaml = $file->content(); + $file->free(); + + $version = $package_yaml['version']; + + if (!$version) { + continue; + } + + $installed = $this->gpm->findPackage($package); + if ($installed) { + $name = $installed->name; + + if ($this->gpm->isUpdatable($package)) { + $updatable = " [updatable: v{$installed->available}]"; + } + } + } + + $updatable = $updatable ?: ''; + + if ($installed || $package === 'grav') { + $this->output->writeln("You are running {$name} v{$version}{$updatable}"); + } else { + $this->output->writeln("Package {$package} not found"); + } + } + } +} diff --git a/system/src/Grav/Console/TerminalObjects/Table.php b/system/src/Grav/Console/TerminalObjects/Table.php new file mode 100644 index 0000000..4f9df63 --- /dev/null +++ b/system/src/Grav/Console/TerminalObjects/Table.php @@ -0,0 +1,30 @@ +column_widths = $this->getColumnWidths(); + $this->table_width = $this->getWidth(); + $this->border = $this->getBorder(); + + $this->buildHeaderRow(); + + foreach ($this->data as $key => $columns) { + $this->rows[] = $this->buildRow($columns); + } + + $this->rows[] = $this->border; + + return $this->rows; + } +} diff --git a/system/src/Grav/Framework/Cache/AbstractCache.php b/system/src/Grav/Framework/Cache/AbstractCache.php new file mode 100644 index 0000000..127db45 --- /dev/null +++ b/system/src/Grav/Framework/Cache/AbstractCache.php @@ -0,0 +1,31 @@ +init($namespace, $defaultLifetime); + } +} diff --git a/system/src/Grav/Framework/Cache/Adapter/ChainCache.php b/system/src/Grav/Framework/Cache/Adapter/ChainCache.php new file mode 100644 index 0000000..e1eea7b --- /dev/null +++ b/system/src/Grav/Framework/Cache/Adapter/ChainCache.php @@ -0,0 +1,198 @@ +caches = array_values($caches); + $this->count = \count($caches); + } + + /** + * @inheritdoc + */ + public function doGet($key, $miss) + { + foreach ($this->caches as $i => $cache) { + $value = $cache->doGet($key, $miss); + if ($value !== $miss) { + while (--$i >= 0) { + // Update all the previous caches with missing value. + $this->caches[$i]->doSet($key, $value, $this->getDefaultLifetime()); + } + + return $value; + } + } + + return $miss; + } + + /** + * @inheritdoc + */ + public function doSet($key, $value, $ttl) + { + $success = true; + $i = $this->count; + + while ($i--) { + $success = $this->caches[$i]->doSet($key, $value, $ttl) && $success; + } + + return $success; + } + + /** + * @inheritdoc + */ + public function doDelete($key) + { + $success = true; + $i = $this->count; + + while ($i--) { + $success = $this->caches[$i]->doDelete($key) && $success; + } + + return $success; + } + + /** + * @inheritdoc + */ + public function doClear() + { + $success = true; + $i = $this->count; + + while ($i--) { + $success = $this->caches[$i]->doClear() && $success; + } + return $success; + } + + /** + * @inheritdoc + */ + public function doGetMultiple($keys, $miss) + { + $list = []; + foreach ($this->caches as $i => $cache) { + $list[$i] = $cache->doGetMultiple($keys, $miss); + + $keys = array_diff_key($keys, $list[$i]); + + if (!$keys) { + break; + } + } + + $values = []; + // Update all the previous caches with missing values. + foreach (array_reverse($list) as $i => $items) { + $values += $items; + if ($i && $values) { + $this->caches[$i-1]->doSetMultiple($values, $this->getDefaultLifetime()); + } + } + + return $values; + } + + /** + * @inheritdoc + */ + public function doSetMultiple($values, $ttl) + { + $success = true; + $i = $this->count; + + while ($i--) { + $success = $this->caches[$i]->doSetMultiple($values, $ttl) && $success; + } + + return $success; + } + + /** + * @inheritdoc + */ + public function doDeleteMultiple($keys) + { + $success = true; + $i = $this->count; + + while ($i--) { + $success = $this->caches[$i]->doDeleteMultiple($keys) && $success; + } + + return $success; + } + + /** + * @inheritdoc + */ + public function doHas($key) + { + foreach ($this->caches as $cache) { + if ($cache->doHas($key)) { + return true; + } + } + + return false; + } +} diff --git a/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php b/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php new file mode 100644 index 0000000..45c9d07 --- /dev/null +++ b/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php @@ -0,0 +1,116 @@ +getNamespace(); + if ($namespace) { + $doctrineCache->setNamespace($namespace); + } + + $this->driver = $doctrineCache; + } + + /** + * @inheritdoc + */ + public function doGet($key, $miss) + { + $value = $this->driver->fetch($key); + + // Doctrine cache does not differentiate between no result and cached 'false'. Make sure that we do. + return $value !== false || $this->driver->contains($key) ? $value : $miss; + } + + /** + * @inheritdoc + */ + public function doSet($key, $value, $ttl) + { + return $this->driver->save($key, $value, (int) $ttl); + } + + /** + * @inheritdoc + */ + public function doDelete($key) + { + return $this->driver->delete($key); + } + + /** + * @inheritdoc + */ + public function doClear() + { + return $this->driver->deleteAll(); + } + + /** + * @inheritdoc + */ + public function doGetMultiple($keys, $miss) + { + return $this->driver->fetchMultiple($keys); + } + + /** + * @inheritdoc + */ + public function doSetMultiple($values, $ttl) + { + return $this->driver->saveMultiple($values, (int) $ttl); + } + + /** + * @inheritdoc + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + public function doDeleteMultiple($keys) + { + return $this->driver->deleteMultiple($keys); + } + + /** + * @inheritdoc + */ + public function doHas($key) + { + return $this->driver->contains($key); + } +} diff --git a/system/src/Grav/Framework/Cache/Adapter/FileCache.php b/system/src/Grav/Framework/Cache/Adapter/FileCache.php new file mode 100644 index 0000000..a5a60fa --- /dev/null +++ b/system/src/Grav/Framework/Cache/Adapter/FileCache.php @@ -0,0 +1,233 @@ +initFileCache($namespace, $folder ?? ''); + } + + /** + * @inheritdoc + */ + public function doGet($key, $miss) + { + $now = time(); + $file = $this->getFile($key); + + if (!file_exists($file) || !$h = @fopen($file, 'rb')) { + return $miss; + } + + if ($now >= (int) $expiresAt = fgets($h)) { + fclose($h); + @unlink($file); + } else { + $i = rawurldecode(rtrim(fgets($h))); + $value = stream_get_contents($h); + fclose($h); + + if ($i === $key) { + return unserialize($value); + } + } + + return $miss; + } + + /** + * @inheritdoc + * @throws \Psr\SimpleCache\CacheException|InvalidArgumentException + */ + public function doSet($key, $value, $ttl) + { + $expiresAt = time() + (int)$ttl; + + $result = $this->write( + $this->getFile($key, true), + $expiresAt . "\n" . rawurlencode($key) . "\n" . serialize($value), + $expiresAt + ); + + if (!$result && !is_writable($this->directory)) { + throw new CacheException(sprintf('Cache directory is not writable (%s)', $this->directory)); + } + + return $result; + } + + /** + * @inheritdoc + */ + public function doDelete($key) + { + $file = $this->getFile($key); + + return (!file_exists($file) || @unlink($file) || !file_exists($file)); + } + + /** + * @inheritdoc + */ + public function doClear() + { + $result = true; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS)); + + foreach ($iterator as $file) { + $result = ($file->isDir() || @unlink($file) || !file_exists($file)) && $result; + } + + return $result; + } + + /** + * @inheritdoc + */ + public function doHas($key) + { + $file = $this->getFile($key); + + return file_exists($file) && (@filemtime($file) > time() || $this->doGet($key, null)); + } + + /** + * @param string $key + * @param bool $mkdir + * @return string + */ + protected function getFile($key, $mkdir = false) + { + $hash = str_replace('/', '-', base64_encode(hash('sha256', static::class . $key, true))); + $dir = $this->directory . $hash[0] . DIRECTORY_SEPARATOR . $hash[1] . DIRECTORY_SEPARATOR; + + if ($mkdir) { + $this->mkdir($dir); + } + + return $dir . substr($hash, 2, 20); + } + + /** + * @param string $namespace + * @param string $directory + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + protected function initFileCache($namespace, $directory) + { + if (!isset($directory[0])) { + $directory = sys_get_temp_dir() . '/grav-cache'; + } else { + $directory = realpath($directory) ?: $directory; + } + + if (isset($namespace[0])) { + if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) { + throw new InvalidArgumentException(sprintf('Namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0])); + } + $directory .= DIRECTORY_SEPARATOR . $namespace; + } + + $this->mkdir($directory); + + $directory .= DIRECTORY_SEPARATOR; + // On Windows the whole path is limited to 258 chars + if ('\\' === DIRECTORY_SEPARATOR && strlen($directory) > 234) { + throw new InvalidArgumentException(sprintf('Cache folder is too long (%s)', $directory)); + } + $this->directory = $directory; + } + + /** + * @param string $file + * @param string $data + * @param int|null $expiresAt + * @return bool + */ + private function write($file, $data, $expiresAt = null) + { + set_error_handler(__CLASS__.'::throwError'); + + try { + if ($this->tmp === null) { + $this->tmp = $this->directory . uniqid('', true); + } + + file_put_contents($this->tmp, $data); + + if ($expiresAt !== null) { + touch($this->tmp, $expiresAt); + } + + return rename($this->tmp, $file); + } finally { + restore_error_handler(); + } + } + + /** + * @param string $dir + * @throws \RuntimeException + */ + private function mkdir($dir) + { + // Silence error for open_basedir; should fail in mkdir instead. + if (@is_dir($dir)) { + return; + } + + $success = @mkdir($dir, 0777, true); + + if (!$success) { + // Take yet another look, make sure that the folder doesn't exist. + clearstatcache(true, $dir); + if (!@is_dir($dir)) { + throw new \RuntimeException(sprintf('Unable to create directory: %s', $dir)); + } + } + } + + /** + * @internal + * @throws \ErrorException + */ + public static function throwError($type, $message, $file, $line) + { + throw new \ErrorException($message, 0, $type, $file, $line); + } + + public function __destruct() + { + if ($this->tmp !== null && file_exists($this->tmp)) { + unlink($this->tmp); + } + } +} diff --git a/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php b/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php new file mode 100644 index 0000000..2d0fe40 --- /dev/null +++ b/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php @@ -0,0 +1,62 @@ +cache)) { + return $miss; + } + + return $this->cache[$key]; + } + + public function doSet($key, $value, $ttl) + { + $this->cache[$key] = $value; + + return true; + } + + public function doDelete($key) + { + unset($this->cache[$key]); + + return true; + } + + public function doClear() + { + $this->cache = []; + + return true; + } + + public function doHas($key) + { + return array_key_exists($key, $this->cache); + } +} diff --git a/system/src/Grav/Framework/Cache/Adapter/SessionCache.php b/system/src/Grav/Framework/Cache/Adapter/SessionCache.php new file mode 100644 index 0000000..62c68a0 --- /dev/null +++ b/system/src/Grav/Framework/Cache/Adapter/SessionCache.php @@ -0,0 +1,79 @@ +doGetStored($key); + + return $stored ? $stored[self::VALUE] : $miss; + } + + public function doSet($key, $value, $ttl) + { + $stored = [self::VALUE => $value]; + if (null !== $ttl) { + $stored[self::LIFETIME] = time() + $ttl; + + } + + $_SESSION[$this->getNamespace()][$key] = $stored; + + return true; + } + + public function doDelete($key) + { + unset($_SESSION[$this->getNamespace()][$key]); + + return true; + } + + public function doClear() + { + unset($_SESSION[$this->getNamespace()]); + + return true; + } + + public function doHas($key) + { + return $this->doGetStored($key) !== null; + } + + public function getNamespace() + { + return 'cache-' . parent::getNamespace(); + } + + protected function doGetStored($key) + { + $stored = $_SESSION[$this->getNamespace()][$key] ?? null; + + if (isset($stored[self::LIFETIME]) && $stored[self::LIFETIME] < time()) { + unset($_SESSION[$this->getNamespace()][$key]); + $stored = null; + } + + return $stored ?: null; + } +} diff --git a/system/src/Grav/Framework/Cache/CacheInterface.php b/system/src/Grav/Framework/Cache/CacheInterface.php new file mode 100644 index 0000000..5aefe35 --- /dev/null +++ b/system/src/Grav/Framework/Cache/CacheInterface.php @@ -0,0 +1,28 @@ +namespace = (string) $namespace; + $this->defaultLifetime = $this->convertTtl($defaultLifetime); + $this->miss = new \stdClass; + } + + /** + * @param bool $validation + */ + public function setValidation($validation) + { + $this->validation = (bool) $validation; + } + + /** + * @return string + */ + protected function getNamespace() + { + return $this->namespace; + } + + /** + * @return int|null + */ + protected function getDefaultLifetime() + { + return $this->defaultLifetime; + } + + /** + * @inheritdoc + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + public function get($key, $default = null) + { + $this->validateKey($key); + + $value = $this->doGet($key, $this->miss); + + return $value !== $this->miss ? $value : $default; + } + + /** + * @inheritdoc + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + public function set($key, $value, $ttl = null) + { + $this->validateKey($key); + + $ttl = $this->convertTtl($ttl); + + // If a negative or zero TTL is provided, the item MUST be deleted from the cache. + return null !== $ttl && $ttl <= 0 ? $this->doDelete($key) : $this->doSet($key, $value, $ttl); + } + + /** + * @inheritdoc + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + public function delete($key) + { + $this->validateKey($key); + + return $this->doDelete($key); + } + + /** + * @inheritdoc + */ + public function clear() + { + return $this->doClear(); + } + + /** + * @inheritdoc + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + public function getMultiple($keys, $default = null) + { + if ($keys instanceof \Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!\is_array($keys)) { + throw new InvalidArgumentException( + sprintf( + 'Cache keys must be array or Traversable, "%s" given', + \is_object($keys) ? \get_class($keys) : \gettype($keys) + ) + ); + } + + if (empty($keys)) { + return []; + } + + $this->validateKeys($keys); + $keys = array_unique($keys); + $keys = array_combine($keys, $keys); + + $list = $this->doGetMultiple($keys, $this->miss); + + // Make sure that values are returned in the same order as the keys were given. + $values = []; + foreach ($keys as $key) { + if (!array_key_exists($key, $list) || $list[$key] === $this->miss) { + $values[$key] = $default; + } else { + $values[$key] = $list[$key]; + } + } + + return $values; + } + + /** + * @inheritdoc + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + public function setMultiple($values, $ttl = null) + { + if ($values instanceof \Traversable) { + $values = iterator_to_array($values, true); + } elseif (!is_array($values)) { + throw new InvalidArgumentException( + sprintf( + 'Cache values must be array or Traversable, "%s" given', + \is_object($values) ? \get_class($values) : \gettype($values) + ) + ); + } + + $keys = array_keys($values); + + if (empty($keys)) { + return true; + } + + $this->validateKeys($keys); + + $ttl = $this->convertTtl($ttl); + + // If a negative or zero TTL is provided, the item MUST be deleted from the cache. + return null !== $ttl && $ttl <= 0 ? $this->doDeleteMultiple($keys) : $this->doSetMultiple($values, $ttl); + } + + /** + * @inheritdoc + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + public function deleteMultiple($keys) + { + if ($keys instanceof \Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!is_array($keys)) { + throw new InvalidArgumentException( + sprintf( + 'Cache keys must be array or Traversable, "%s" given', + \is_object($keys) ? \get_class($keys) : \gettype($keys) + ) + ); + } + + if (empty($keys)) { + return true; + } + + $this->validateKeys($keys); + + return $this->doDeleteMultiple($keys); + } + + /** + * @inheritdoc + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + public function has($key) + { + $this->validateKey($key); + + return $this->doHas($key); + } + + abstract public function doGet($key, $miss); + abstract public function doSet($key, $value, $ttl); + abstract public function doDelete($key); + abstract public function doClear(); + + /** + * @param array $keys + * @param mixed $miss + * @return array + */ + public function doGetMultiple($keys, $miss) + { + $results = []; + + foreach ($keys as $key) { + $value = $this->doGet($key, $miss); + if ($value !== $miss) { + $results[$key] = $value; + } + } + + return $results; + } + + /** + * @param array $values + * @param int $ttl + * @return bool + */ + public function doSetMultiple($values, $ttl) + { + $success = true; + + foreach ($values as $key => $value) { + $success = $this->doSet($key, $value, $ttl) && $success; + } + + return $success; + } + + /** + * @param array $keys + * @return bool + */ + public function doDeleteMultiple($keys) + { + $success = true; + + foreach ($keys as $key) { + $success = $this->doDelete($key) && $success; + } + + return $success; + } + + abstract public function doHas($key); + + /** + * @param string|mixed $key + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + protected function validateKey($key) + { + if (!\is_string($key)) { + throw new InvalidArgumentException( + sprintf( + 'Cache key must be string, "%s" given', + \is_object($key) ? \get_class($key) : \gettype($key) + ) + ); + } + if (!isset($key[0])) { + throw new InvalidArgumentException('Cache key length must be greater than zero'); + } + if (\strlen($key) > 64) { + throw new InvalidArgumentException( + sprintf('Cache key length must be less than 65 characters, key had %s characters', \strlen($key)) + ); + } + if (strpbrk($key, '{}()/\@:') !== false) { + throw new InvalidArgumentException( + sprintf('Cache key "%s" contains reserved characters {}()/\@:', $key) + ); + } + } + + /** + * @param array $keys + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + protected function validateKeys($keys) + { + if (!$this->validation) { + return; + } + + foreach ($keys as $key) { + $this->validateKey($key); + } + } + + /** + * @param null|int|\DateInterval|mixed $ttl + * @return int|null + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + */ + protected function convertTtl($ttl) + { + if ($ttl === null) { + return $this->getDefaultLifetime(); + } + + if (\is_int($ttl)) { + return $ttl; + } + + if ($ttl instanceof \DateInterval) { + $ttl = (int) \DateTime::createFromFormat('U', '0')->add($ttl)->format('U'); + } + + throw new InvalidArgumentException( + sprintf( + 'Expiration date must be an integer, a DateInterval or null, "%s" given', + \is_object($ttl) ? \get_class($ttl) : \gettype($ttl) + ) + ); + } +} diff --git a/system/src/Grav/Framework/Cache/Exception/CacheException.php b/system/src/Grav/Framework/Cache/Exception/CacheException.php new file mode 100644 index 0000000..55da2f5 --- /dev/null +++ b/system/src/Grav/Framework/Cache/Exception/CacheException.php @@ -0,0 +1,20 @@ +path = $path; + $this->flags = self::INCLUDE_FILES | self::INCLUDE_FOLDERS; + $this->nestingLimit = 0; + $this->createObjectFunction = [$this, 'createObject']; + + $this->setIterator(); + } + + /** + * @return string + */ + public function getPath() + { + return $this->path; + } + + /** + * @param Criteria $criteria + * @return ArrayCollection + * @todo Implement lazy matching + */ + public function matching(Criteria $criteria) + { + $expr = $criteria->getWhereExpression(); + + $oldFilter = $this->filterFunction; + if ($expr) { + $visitor = new ClosureExpressionVisitor(); + $filter = $visitor->dispatch($expr); + $this->addFilter($filter); + } + + $filtered = $this->doInitializeByIterator($this->iterator, $this->nestingLimit); + $this->filterFunction = $oldFilter; + + if ($orderings = $criteria->getOrderings()) { + $next = null; + foreach (array_reverse($orderings) as $field => $ordering) { + $next = ClosureExpressionVisitor::sortByField($field, $ordering === Criteria::DESC ? -1 : 1, $next); + } + + uasort($filtered, $next); + } else { + ksort($filtered); + } + + $offset = $criteria->getFirstResult(); + $length = $criteria->getMaxResults(); + + if ($offset || $length) { + $filtered = \array_slice($filtered, (int)$offset, $length); + } + + return new ArrayCollection($filtered); + } + + protected function setIterator() + { + $iteratorFlags = \RecursiveDirectoryIterator::SKIP_DOTS + \FilesystemIterator::UNIX_PATHS + + \FilesystemIterator::CURRENT_AS_SELF + \FilesystemIterator::FOLLOW_SYMLINKS; + + if (strpos($this->path, '://')) { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $this->iterator = $locator->getRecursiveIterator($this->path, $iteratorFlags); + } else { + $this->iterator = new \RecursiveDirectoryIterator($this->path, $iteratorFlags); + } + } + + /** + * @param callable $filterFunction + * @return $this + */ + protected function addFilter(callable $filterFunction) + { + if ($this->filterFunction) { + $oldFilterFunction = $this->filterFunction; + $this->filterFunction = function ($expr) use ($oldFilterFunction, $filterFunction) { + return $oldFilterFunction($expr) && $filterFunction($expr); + }; + } else { + $this->filterFunction = $filterFunction; + } + + return $this; + } + + /** + * {@inheritDoc} + */ + protected function doInitialize() + { + $filtered = $this->doInitializeByIterator($this->iterator, $this->nestingLimit); + ksort($filtered); + + $this->collection = new ArrayCollection($filtered); + } + + protected function doInitializeByIterator(\SeekableIterator $iterator, $nestingLimit) + { + $children = []; + $objects = []; + $filter = $this->filterFunction; + $objectFunction = $this->createObjectFunction; + + /** @var \RecursiveDirectoryIterator $file */ + foreach ($iterator as $file) { + // Skip files if they shouldn't be included. + if (!($this->flags & static::INCLUDE_FILES) && $file->isFile()) { + continue; + } + + // Apply main filter. + if ($filter && !$filter($file)) { + continue; + } + + // Include children if the recursive flag is set. + if (($this->flags & static::RECURSIVE) && $nestingLimit > 0 && $file->hasChildren()) { + $children[] = $file->getChildren(); + } + + // Skip folders if they shouldn't be included. + if (!($this->flags & static::INCLUDE_FOLDERS) && $file->isDir()) { + continue; + } + + $object = $objectFunction($file); + $objects[$object->key] = $object; + } + + if ($children) { + $objects += $this->doInitializeChildren($children, $nestingLimit - 1); + } + + return $objects; + } + + /** + * @param \RecursiveDirectoryIterator[] $children + * @return array + */ + protected function doInitializeChildren(array $children, $nestingLimit) + { + $objects = []; + + foreach ($children as $iterator) { + $objects += $this->doInitializeByIterator($iterator, $nestingLimit); + } + + return $objects; + } + + /** + * @param \RecursiveDirectoryIterator $file + * @return object + */ + protected function createObject($file) + { + return (object) [ + 'key' => $file->getSubPathname(), + 'type' => $file->isDir() ? 'folder' : 'file:' . $file->getExtension(), + 'url' => method_exists($file, 'getUrl') ? $file->getUrl() : null, + 'pathname' => $file->getPathname(), + 'mtime' => $file->getMTime() + ]; + } +} diff --git a/system/src/Grav/Framework/Collection/AbstractIndexCollection.php b/system/src/Grav/Framework/Collection/AbstractIndexCollection.php new file mode 100644 index 0000000..0e4fae2 --- /dev/null +++ b/system/src/Grav/Framework/Collection/AbstractIndexCollection.php @@ -0,0 +1,510 @@ +entries = $entries; + } + + /** + * {@inheritDoc} + */ + public function toArray() + { + return $this->loadElements($this->entries); + } + + /** + * {@inheritDoc} + */ + public function first() + { + $value = reset($this->entries); + $key = key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function last() + { + $value = end($this->entries); + $key = key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function key() + { + return key($this->entries); + } + + /** + * {@inheritDoc} + */ + public function next() + { + $value = next($this->entries); + $key = key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function current() + { + $value = current($this->entries); + $key = key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function remove($key) + { + if (!array_key_exists($key, $this->entries)) { + return null; + } + + $value = $this->entries[$key]; + unset($this->entries[$key]); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function removeElement($element) + { + $key = $this->isAllowedElement($element) ? $element->getKey() : null; + + if (!$key || !isset($this->entries[$key])) { + return false; + } + + unset($this->entries[$key]); + + return true; + } + + /** + * Required by interface ArrayAccess. + * + * {@inheritDoc} + */ + public function offsetExists($offset) + { + return $this->containsKey($offset); + } + + /** + * Required by interface ArrayAccess. + * + * {@inheritDoc} + */ + public function offsetGet($offset) + { + return $this->get($offset); + } + + /** + * Required by interface ArrayAccess. + * + * {@inheritDoc} + */ + public function offsetSet($offset, $value) + { + if (null === $offset) { + $this->add($value); + } + + $this->set($offset, $value); + } + + /** + * Required by interface ArrayAccess. + * + * {@inheritDoc} + */ + public function offsetUnset($offset) + { + return $this->remove($offset); + } + + /** + * {@inheritDoc} + */ + public function containsKey($key) + { + return isset($this->entries[$key]) || array_key_exists($key, $this->entries); + } + + /** + * {@inheritDoc} + */ + public function contains($element) + { + $key = $this->isAllowedElement($element) ? $element->getKey() : null; + + return $key && isset($this->entries[$key]); + } + + /** + * {@inheritDoc} + */ + public function exists(Closure $p) + { + return $this->loadCollection($this->entries)->exists($p); + } + + /** + * {@inheritDoc} + */ + public function indexOf($element) + { + $key = $this->isAllowedElement($element) ? $element->getKey() : null; + + return $key && isset($this->entries[$key]) ? $key : null; + } + + /** + * {@inheritDoc} + */ + public function get($key) + { + if (!isset($this->entries[$key])) { + return null; + } + + return $this->loadElement($key, $this->entries[$key]); + } + + /** + * {@inheritDoc} + */ + public function getKeys() + { + return array_keys($this->entries); + } + + /** + * {@inheritDoc} + */ + public function getValues() + { + return array_values($this->loadElements($this->entries)); + } + + /** + * {@inheritDoc} + */ + public function count() + { + return \count($this->entries); + } + + /** + * {@inheritDoc} + */ + public function set($key, $value) + { + if (!$this->isAllowedElement($value)) { + throw new \InvalidArgumentException('Invalid argument $value'); + } + + if ($key !== $value->getKey()) { + $value->setKey($key); + } + + $this->entries[$key] = $this->getElementMeta($value); + } + + /** + * {@inheritDoc} + */ + public function add($element) + { + if (!$this->isAllowedElement($element)) { + throw new \InvalidArgumentException('Invalid argument $element'); + } + + $this->entries[$element->getKey()] = $this->getElementMeta($element); + + return true; + } + + /** + * {@inheritDoc} + */ + public function isEmpty() + { + return empty($this->entries); + } + + /** + * Required by interface IteratorAggregate. + * + * {@inheritDoc} + */ + public function getIterator() + { + return new ArrayIterator($this->loadElements()); + } + + /** + * {@inheritDoc} + */ + public function map(Closure $func) + { + return $this->loadCollection($this->entries)->map($func); + } + + /** + * {@inheritDoc} + */ + public function filter(Closure $p) + { + return $this->loadCollection($this->entries)->filter($p); + } + + /** + * {@inheritDoc} + */ + public function forAll(Closure $p) + { + return $this->loadCollection($this->entries)->forAll($p); + } + + /** + * {@inheritDoc} + */ + public function partition(Closure $p) + { + return $this->loadCollection($this->entries)->partition($p); + } + + /** + * Returns a string representation of this object. + * + * @return string + */ + public function __toString() + { + return __CLASS__ . '@' . spl_object_hash($this); + } + + /** + * {@inheritDoc} + */ + public function clear() + { + $this->entries = []; + } + + /** + * {@inheritDoc} + */ + public function slice($offset, $length = null) + { + return $this->loadElements(\array_slice($this->entries, $offset, $length, true)); + } + + /** + * @param int $start + * @param int|null $limit + * @return static + */ + public function limit($start, $limit = null) + { + return $this->createFrom(\array_slice($this->entries, $start, $limit, true)); + } + + /** + * Reverse the order of the items. + * + * @return static + */ + public function reverse() + { + return $this->createFrom(array_reverse($this->entries)); + } + + /** + * Shuffle items. + * + * @return static + */ + public function shuffle() + { + $keys = $this->getKeys(); + shuffle($keys); + + return $this->createFrom(array_replace(array_flip($keys), $this->entries)); + } + + /** + * Select items from collection. + * + * Collection is returned in the order of $keys given to the function. + * + * @param array $keys + * @return static + */ + public function select(array $keys) + { + $list = []; + foreach ($keys as $key) { + if (isset($this->entries[$key])) { + $list[$key] = $this->entries[$key]; + } + } + + return $this->createFrom($list); + } + + /** + * Un-select items from collection. + * + * @param array $keys + * @return static + */ + public function unselect(array $keys) + { + return $this->select(array_diff($this->getKeys(), $keys)); + } + + /** + * Split collection into chunks. + * + * @param int $size Size of each chunk. + * @return array + */ + public function chunk($size) + { + return $this->loadCollection($this->entries)->chunk($size); + } + + /** + * @return string + */ + public function serialize() + { + return serialize(['entries' => $this->entries]); + } + + /** + * @param string $serialized + */ + public function unserialize($serialized) + { + $data = unserialize($serialized, ['allowed_classes' => false]); + + $this->entries = $data['entries']; + } + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + public function jsonSerialize() + { + return $this->loadCollection()->jsonSerialize(); + } + + /** + * Creates a new instance from the specified elements. + * + * This method is provided for derived classes to specify how a new + * instance should be created when constructor semantics have changed. + * + * @param array $entries Elements. + * + * @return static + */ + protected function createFrom(array $entries) + { + return new static($entries); + } + + /** + * @return array + */ + protected function getEntries() : array + { + return $this->entries; + } + + /** + * @param array $entries + */ + protected function setEntries(array $entries) : void + { + $this->entries = $entries; + } + + /** + * @param string $key + * @param mixed $value + * @return mixed|null + */ + abstract protected function loadElement($key, $value); + + /** + * @param array|null $entries + * @return array + */ + abstract protected function loadElements(array $entries = null) : array; + + /** + * @param array|null $entries + * @return CollectionInterface + */ + abstract protected function loadCollection(array $entries = null) : CollectionInterface; + + /** + * @param mixed $value + * @return bool + */ + abstract protected function isAllowedElement($value) : bool; + + /** + * @param mixed $element + * @return mixed + */ + abstract protected function getElementMeta($element); +} diff --git a/system/src/Grav/Framework/Collection/AbstractLazyCollection.php b/system/src/Grav/Framework/Collection/AbstractLazyCollection.php new file mode 100644 index 0000000..0653caa --- /dev/null +++ b/system/src/Grav/Framework/Collection/AbstractLazyCollection.php @@ -0,0 +1,81 @@ +initialize(); + return $this->collection->reverse(); + } + + /** + * {@inheritDoc} + */ + public function shuffle() + { + $this->initialize(); + return $this->collection->shuffle(); + } + + /** + * {@inheritDoc} + */ + public function chunk($size) + { + $this->initialize(); + return $this->collection->chunk($size); + } + + /** + * {@inheritDoc} + */ + public function select(array $keys) + { + $this->initialize(); + return $this->collection->select($keys); + } + + /** + * {@inheritDoc} + */ + public function unselect(array $keys) + { + $this->initialize(); + return $this->collection->unselect($keys); + } + + /** + * {@inheritDoc} + */ + public function jsonSerialize() + { + $this->initialize(); + return $this->collection->jsonSerialize(); + } +} diff --git a/system/src/Grav/Framework/Collection/ArrayCollection.php b/system/src/Grav/Framework/Collection/ArrayCollection.php new file mode 100644 index 0000000..f79ab96 --- /dev/null +++ b/system/src/Grav/Framework/Collection/ArrayCollection.php @@ -0,0 +1,95 @@ +createFrom(array_reverse($this->toArray())); + } + + /** + * Shuffle items. + * + * @return static + */ + public function shuffle() + { + $keys = $this->getKeys(); + shuffle($keys); + + return $this->createFrom(array_replace(array_flip($keys), $this->toArray())); + } + + /** + * Split collection into chunks. + * + * @param int $size Size of each chunk. + * @return array + */ + public function chunk($size) + { + return array_chunk($this->toArray(), $size, true); + } + + /** + * Select items from collection. + * + * Collection is returned in the order of $keys given to the function. + * + * @param array $keys + * @return static + */ + public function select(array $keys) + { + $list = []; + foreach ($keys as $key) { + if ($this->containsKey($key)) { + $list[$key] = $this->get($key); + } + } + + return $this->createFrom($list); + } + + /** + * Un-select items from collection. + * + * @param array $keys + * @return static + */ + public function unselect(array $keys) + { + return $this->select(array_diff($this->getKeys(), $keys)); + } + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + public function jsonSerialize() + { + return $this->toArray(); + } +} diff --git a/system/src/Grav/Framework/Collection/CollectionInterface.php b/system/src/Grav/Framework/Collection/CollectionInterface.php new file mode 100644 index 0000000..9239d4a --- /dev/null +++ b/system/src/Grav/Framework/Collection/CollectionInterface.php @@ -0,0 +1,60 @@ +flags = (int)($flags ?: self::INCLUDE_FILES | self::INCLUDE_FOLDERS | self::RECURSIVE); + + $this->setIterator(); + $this->setFilter(); + $this->setObjectBuilder(); + $this->setNestingLimit(); + } + + /** + * @return int + */ + public function getFlags() + { + return $this->flags; + } + + /** + * @return int + */ + public function getNestingLimit() + { + return $this->nestingLimit; + } + + /** + * @param int $limit + * @return $this + */ + public function setNestingLimit($limit = 99) + { + $this->nestingLimit = (int) $limit; + + return $this; + } + + /** + * @param callable|null $filterFunction + * @return $this + */ + public function setFilter(callable $filterFunction = null) + { + $this->filterFunction = $filterFunction; + + return $this; + } + + /** + * @param callable $filterFunction + * @return $this + */ + public function addFilter(callable $filterFunction) + { + parent::addFilter($filterFunction); + + return $this; + } + + /** + * @param callable|null $objectFunction + * @return $this + */ + public function setObjectBuilder(callable $objectFunction = null) + { + $this->createObjectFunction = $objectFunction ?: [$this, 'createObject']; + + return $this; + } +} diff --git a/system/src/Grav/Framework/Collection/FileCollectionInterface.php b/system/src/Grav/Framework/Collection/FileCollectionInterface.php new file mode 100644 index 0000000..fec4129 --- /dev/null +++ b/system/src/Grav/Framework/Collection/FileCollectionInterface.php @@ -0,0 +1,29 @@ +setContent('my inner content'); + * $outerBlock = ContentBlock::create(); + * $outerBlock->setContent(sprintf('Inside my outer block I have %s.', $innerBlock->getToken())); + * $outerBlock->addBlock($innerBlock); + * echo $outerBlock; + * + * @package Grav\Framework\ContentBlock + */ +class ContentBlock implements ContentBlockInterface +{ + protected $version = 1; + protected $id; + protected $tokenTemplate = '@@BLOCK-%s@@'; + protected $content = ''; + protected $blocks = []; + protected $checksum; + protected $cached = true; + + /** + * @param string $id + * @return static + */ + public static function create($id = null) + { + return new static($id); + } + + /** + * @param array $serialized + * @return ContentBlockInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $serialized) + { + try { + $type = $serialized['_type'] ?? null; + $id = $serialized['id'] ?? null; + + if (!$type || !$id || !is_a($type, ContentBlockInterface::class, true)) { + throw new \InvalidArgumentException('Bad data'); + } + + /** @var ContentBlockInterface $instance */ + $instance = new $type($id); + $instance->build($serialized); + } catch (\Exception $e) { + throw new \InvalidArgumentException(sprintf('Cannot unserialize Block: %s', $e->getMessage()), $e->getCode(), $e); + } + + return $instance; + } + + /** + * Block constructor. + * + * @param string $id + */ + public function __construct($id = null) + { + $this->id = $id ? (string) $id : $this->generateId(); + } + + /** + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * @return string + */ + public function getToken() + { + return sprintf($this->tokenTemplate, $this->getId()); + } + + /** + * @return array + */ + public function toArray() + { + $blocks = []; + /** + * @var string $id + * @var ContentBlockInterface $block + */ + foreach ($this->blocks as $block) { + $blocks[$block->getId()] = $block->toArray(); + } + + $array = [ + '_type' => \get_class($this), + '_version' => $this->version, + 'id' => $this->id, + 'cached' => $this->cached + ]; + + if ($this->checksum) { + $array['checksum'] = $this->checksum; + } + + if ($this->content) { + $array['content'] = $this->content; + } + + if ($blocks) { + $array['blocks'] = $blocks; + } + + return $array; + } + + /** + * @return string + */ + public function toString() + { + if (!$this->blocks) { + return (string) $this->content; + } + + $tokens = []; + $replacements = []; + foreach ($this->blocks as $block) { + $tokens[] = $block->getToken(); + $replacements[] = $block->toString(); + } + + return str_replace($tokens, $replacements, (string) $this->content); + } + + /** + * @return string + */ + public function __toString() + { + try { + return $this->toString(); + } catch (\Exception $e) { + return sprintf('Error while rendering block: %s', $e->getMessage()); + } + } + + /** + * @param array $serialized + * @throws \RuntimeException + */ + public function build(array $serialized) + { + $this->checkVersion($serialized); + + $this->id = $serialized['id'] ?? $this->generateId(); + $this->checksum = $serialized['checksum'] ?? null; + $this->cached = $serialized['cached'] ?? null; + + if (isset($serialized['content'])) { + $this->setContent($serialized['content']); + } + + $blocks = isset($serialized['blocks']) ? (array) $serialized['blocks'] : []; + foreach ($blocks as $block) { + $this->addBlock(self::fromArray($block)); + } + } + + /** + * @return bool + */ + public function isCached() + { + if (!$this->cached) { + return false; + } + + foreach ($this->blocks as $block) { + if (!$block->isCached()) { + return false; + } + } + + return true; + } + + /** + * @return $this + */ + public function disableCache() + { + $this->cached = false; + + return $this; + } + + /** + * @param string $checksum + * @return $this + */ + public function setChecksum($checksum) + { + $this->checksum = $checksum; + + return $this; + } + + /** + * @return string + */ + public function getChecksum() + { + return $this->checksum; + } + + /** + * @param string $content + * @return $this + */ + public function setContent($content) + { + $this->content = $content; + + return $this; + } + + /** + * @param ContentBlockInterface $block + * @return $this + */ + public function addBlock(ContentBlockInterface $block) + { + $this->blocks[$block->getId()] = $block; + + return $this; + } + + /** + * @return string + */ + public function serialize() + { + return serialize($this->toArray()); + } + + /** + * @param string $serialized + */ + public function unserialize($serialized) + { + $array = unserialize($serialized); + $this->build($array); + } + + /** + * @return string + */ + protected function generateId() + { + return uniqid('', true); + } + + /** + * @param array $serialized + * @throws \RuntimeException + */ + protected function checkVersion(array $serialized) + { + $version = isset($serialized['_version']) ? (int) $serialized['_version'] : 1; + if ($version !== $this->version) { + throw new \RuntimeException(sprintf('Unsupported version %s', $version)); + } + } +} diff --git a/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php b/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php new file mode 100644 index 0000000..330901e --- /dev/null +++ b/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php @@ -0,0 +1,87 @@ +getAssetsFast(); + + $this->sortAssets($assets['styles']); + $this->sortAssets($assets['scripts']); + $this->sortAssets($assets['html']); + + return $assets; + } + + /** + * @return array + */ + public function getFrameworks() + { + $assets = $this->getAssetsFast(); + + return array_keys($assets['frameworks']); + } + + /** + * @param string $location + * @return array + */ + public function getStyles($location = 'head') + { + return $this->getAssetsInLocation('styles', $location); + } + + /** + * @param string $location + * @return array + */ + public function getScripts($location = 'head') + { + return $this->getAssetsInLocation('scripts', $location); + } + + /** + * @param string $location + * @return array + */ + public function getHtml($location = 'bottom') + { + return $this->getAssetsInLocation('html', $location); + } + + /** + * @return array[] + */ + public function toArray() + { + $array = parent::toArray(); + + if ($this->frameworks) { + $array['frameworks'] = $this->frameworks; + } + if ($this->styles) { + $array['styles'] = $this->styles; + } + if ($this->scripts) { + $array['scripts'] = $this->scripts; + } + if ($this->html) { + $array['html'] = $this->html; + } + + return $array; + } + + /** + * @param array $serialized + * @throws \RuntimeException + */ + public function build(array $serialized) + { + parent::build($serialized); + + $this->frameworks = isset($serialized['frameworks']) ? (array) $serialized['frameworks'] : []; + $this->styles = isset($serialized['styles']) ? (array) $serialized['styles'] : []; + $this->scripts = isset($serialized['scripts']) ? (array) $serialized['scripts'] : []; + $this->html = isset($serialized['html']) ? (array) $serialized['html'] : []; + } + + /** + * @param string $framework + * @return $this + */ + public function addFramework($framework) + { + $this->frameworks[$framework] = 1; + + return $this; + } + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + * + * @example $block->addStyle('assets/js/my.js'); + * @example $block->addStyle(['href' => 'assets/js/my.js', 'media' => 'screen']); + */ + public function addStyle($element, $priority = 0, $location = 'head') + { + if (!\is_array($element)) { + $element = ['href' => (string) $element]; + } + if (empty($element['href'])) { + return false; + } + if (!isset($this->styles[$location])) { + $this->styles[$location] = []; + } + + $id = !empty($element['id']) ? ['id' => (string) $element['id']] : []; + $href = $element['href']; + $type = !empty($element['type']) ? (string) $element['type'] : 'text/css'; + $media = !empty($element['media']) ? (string) $element['media'] : null; + unset( + $element['tag'], + $element['id'], + $element['rel'], + $element['content'], + $element['href'], + $element['type'], + $element['media'] + ); + + $this->styles[$location][md5($href) . sha1($href)] = [ + ':type' => 'file', + ':priority' => (int) $priority, + 'href' => $href, + 'type' => $type, + 'media' => $media, + 'element' => $element + ] + $id; + + return true; + } + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addInlineStyle($element, $priority = 0, $location = 'head') + { + if (!\is_array($element)) { + $element = ['content' => (string) $element]; + } + if (empty($element['content'])) { + return false; + } + if (!isset($this->styles[$location])) { + $this->styles[$location] = []; + } + + $content = (string) $element['content']; + $type = !empty($element['type']) ? (string) $element['type'] : 'text/css'; + + $this->styles[$location][md5($content) . sha1($content)] = [ + ':type' => 'inline', + ':priority' => (int) $priority, + 'content' => $content, + 'type' => $type + ]; + + return true; + } + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addScript($element, $priority = 0, $location = 'head') + { + if (!\is_array($element)) { + $element = ['src' => (string) $element]; + } + if (empty($element['src'])) { + return false; + } + if (!isset($this->scripts[$location])) { + $this->scripts[$location] = []; + } + + $src = $element['src']; + $type = !empty($element['type']) ? (string) $element['type'] : 'text/javascript'; + $defer = isset($element['defer']) ? true : false; + $async = isset($element['async']) ? true : false; + $handle = !empty($element['handle']) ? (string) $element['handle'] : ''; + + $this->scripts[$location][md5($src) . sha1($src)] = [ + ':type' => 'file', + ':priority' => (int) $priority, + 'src' => $src, + 'type' => $type, + 'defer' => $defer, + 'async' => $async, + 'handle' => $handle + ]; + + return true; + } + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addInlineScript($element, $priority = 0, $location = 'head') + { + if (!\is_array($element)) { + $element = ['content' => (string) $element]; + } + if (empty($element['content'])) { + return false; + } + if (!isset($this->scripts[$location])) { + $this->scripts[$location] = []; + } + + $content = (string) $element['content']; + $type = !empty($element['type']) ? (string) $element['type'] : 'text/javascript'; + + $this->scripts[$location][md5($content) . sha1($content)] = [ + ':type' => 'inline', + ':priority' => (int) $priority, + 'content' => $content, + 'type' => $type + ]; + + return true; + } + + /** + * @param string $html + * @param int $priority + * @param string $location + * @return bool + */ + public function addHtml($html, $priority = 0, $location = 'bottom') + { + if (empty($html) || !\is_string($html)) { + return false; + } + if (!isset($this->html[$location])) { + $this->html[$location] = []; + } + + $this->html[$location][md5($html) . sha1($html)] = [ + ':priority' => (int) $priority, + 'html' => $html + ]; + + return true; + } + + /** + * @return array + */ + protected function getAssetsFast() + { + $assets = [ + 'frameworks' => $this->frameworks, + 'styles' => $this->styles, + 'scripts' => $this->scripts, + 'html' => $this->html + ]; + + foreach ($this->blocks as $block) { + if ($block instanceof self) { + $blockAssets = $block->getAssetsFast(); + $assets['frameworks'] += $blockAssets['frameworks']; + + foreach ($blockAssets['styles'] as $location => $styles) { + if (!isset($assets['styles'][$location])) { + $assets['styles'][$location] = $styles; + } elseif ($styles) { + $assets['styles'][$location] += $styles; + } + } + + foreach ($blockAssets['scripts'] as $location => $scripts) { + if (!isset($assets['scripts'][$location])) { + $assets['scripts'][$location] = $scripts; + } elseif ($scripts) { + $assets['scripts'][$location] += $scripts; + } + } + + foreach ($blockAssets['html'] as $location => $htmls) { + if (!isset($assets['html'][$location])) { + $assets['html'][$location] = $htmls; + } elseif ($htmls) { + $assets['html'][$location] += $htmls; + } + } + } + } + + return $assets; + } + + /** + * @param string $type + * @param string $location + * @return array + */ + protected function getAssetsInLocation($type, $location) + { + $assets = $this->getAssetsFast(); + + if (empty($assets[$type][$location])) { + return []; + } + + $styles = $assets[$type][$location]; + $this->sortAssetsInLocation($styles); + + return $styles; + } + + /** + * @param array $items + */ + protected function sortAssetsInLocation(array &$items) + { + $count = 0; + foreach ($items as &$item) { + $item[':order'] = ++$count; + } + unset($item); + + uasort( + $items, + function ($a, $b) { + return ($a[':priority'] === $b[':priority']) + ? $a[':order'] - $b[':order'] : $a[':priority'] - $b[':priority']; + } + ); + } + + /** + * @param array $array + */ + protected function sortAssets(array &$array) + { + foreach ($array as $location => &$items) { + $this->sortAssetsInLocation($items); + } + } +} diff --git a/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php b/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php new file mode 100644 index 0000000..ee486d2 --- /dev/null +++ b/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php @@ -0,0 +1,95 @@ +addStyle('assets/js/my.js'); + * @example $block->addStyle(['href' => 'assets/js/my.js', 'media' => 'screen']); + */ + public function addStyle($element, $priority = 0, $location = 'head'); + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addInlineStyle($element, $priority = 0, $location = 'head'); + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addScript($element, $priority = 0, $location = 'head'); + + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addInlineScript($element, $priority = 0, $location = 'head'); + + /** + * @param string $html + * @param int $priority + * @param string $location + * @return bool + */ + public function addHtml($html, $priority = 0, $location = 'bottom'); +} diff --git a/system/src/Grav/Framework/DI/Container.php b/system/src/Grav/Framework/DI/Container.php new file mode 100644 index 0000000..c4b3ef4 --- /dev/null +++ b/system/src/Grav/Framework/DI/Container.php @@ -0,0 +1,27 @@ +offsetGet($id); + } + + public function has($id): bool + { + return $this->offsetExists($id); + } +} \ No newline at end of file diff --git a/system/src/Grav/Framework/File/AbstractFile.php b/system/src/Grav/Framework/File/AbstractFile.php new file mode 100644 index 0000000..0411701 --- /dev/null +++ b/system/src/Grav/Framework/File/AbstractFile.php @@ -0,0 +1,425 @@ +filesystem = $filesystem ?? Filesystem::getInstance(); + $this->setFilepath($filepath); + } + + /** + * Unlock file when the object gets destroyed. + */ + public function __destruct() + { + if ($this->isLocked()) { + $this->unlock(); + } + } + + public function __clone() + { + $this->handle = null; + $this->locked = false; + } + + /** + * @return string + */ + public function serialize(): string + { + return serialize($this->doSerialize()); + } + + /** + * @param string $serialized + */ + public function unserialize($serialized): void + { + $this->doUnserialize(unserialize($serialized, ['allowed_classes' => false])); + } + + /** + * {@inheritdoc} + * @see FileInterface::getFilePath() + */ + public function getFilePath(): string + { + return $this->filepath; + } + + /** + * {@inheritdoc} + * @see FileInterface::getPath() + */ + public function getPath(): string + { + if (null === $this->path) { + $this->setPathInfo(); + } + + return $this->path; + } + + /** + * {@inheritdoc} + * @see FileInterface::getFilename() + */ + public function getFilename(): string + { + if (null === $this->filename) { + $this->setPathInfo(); + } + + return $this->filename; + } + + /** + * {@inheritdoc} + * @see FileInterface::getBasename() + */ + public function getBasename(): string + { + if (null === $this->basename) { + $this->setPathInfo(); + } + + return $this->basename; + } + + /** + * {@inheritdoc} + * @see FileInterface::getExtension() + */ + public function getExtension(bool $withDot = false): string + { + if (null === $this->extension) { + $this->setPathInfo(); + } + + return ($withDot ? '.' : '') . $this->extension; + } + + /** + * {@inheritdoc} + * @see FileInterface::exists() + */ + public function exists(): bool + { + return is_file($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::getCreationTime() + */ + public function getCreationTime(): int + { + return is_file($this->filepath) ? filectime($this->filepath) : time(); + } + + /** + * {@inheritdoc} + * @see FileInterface::getModificationTime() + */ + public function getModificationTime(): int + { + return is_file($this->filepath) ? filemtime($this->filepath) : time(); + } + + /** + * {@inheritdoc} + * @see FileInterface::lock() + */ + public function lock(bool $block = true): bool + { + if (!$this->handle) { + if (!$this->mkdir($this->getPath())) { + throw new \RuntimeException('Creating directory failed for ' . $this->filepath); + } + $this->handle = @fopen($this->filepath, 'cb+'); + if (!$this->handle) { + $error = error_get_last(); + + throw new \RuntimeException("Opening file for writing failed on error {$error['message']}"); + } + } + + $lock = $block ? LOCK_EX : LOCK_EX | LOCK_NB; + + // Some filesystems do not support file locks, only fail if another process holds the lock. + $this->locked = flock($this->handle, $lock, $wouldblock) || !$wouldblock; + + return $this->locked; + } + + /** + * {@inheritdoc} + * @see FileInterface::unlock() + */ + public function unlock(): bool + { + if (!$this->handle) { + return false; + } + + if ($this->locked) { + flock($this->handle, LOCK_UN); + $this->locked = false; + } + + fclose($this->handle); + $this->handle = null; + + return true; + } + + /** + * {@inheritdoc} + * @see FileInterface::isLocked() + */ + public function isLocked(): bool + { + return $this->locked; + } + + /** + * {@inheritdoc} + * @see FileInterface::isReadable() + */ + public function isReadable(): bool + { + return is_readable($this->filepath) && is_file($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::isWritable() + */ + public function isWritable(): bool + { + if (!file_exists($this->filepath)) { + return $this->isWritablePath($this->getPath()); + } + + return is_writable($this->filepath) && is_file($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::load() + */ + public function load() + { + return file_get_contents($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::save() + */ + public function save($data): void + { + $filepath = $this->filepath; + $dir = $this->getPath(); + + if (!$this->mkdir($dir)) { + throw new \RuntimeException('Creating directory failed for ' . $filepath); + } + + try { + if ($this->handle) { + $tmp = true; + // As we are using non-truncating locking, make sure that the file is empty before writing. + if (@ftruncate($this->handle, 0) === false || @fwrite($this->handle, $data) === false) { + // Writing file failed, throw an error. + $tmp = false; + } + } else { + // Create file with a temporary name and rename it to make the save action atomic. + $tmp = $this->tempname($filepath); + if (@file_put_contents($tmp, $data) === false) { + $tmp = false; + } elseif (@rename($tmp, $filepath) === false) { + @unlink($tmp); + $tmp = false; + } + } + } catch (\Exception $e) { + $tmp = false; + } + + if ($tmp === false) { + throw new \RuntimeException('Failed to save file ' . $filepath); + } + + // Touch the directory as well, thus marking it modified. + @touch($dir); + } + + /** + * {@inheritdoc} + * @see FileInterface::rename() + */ + public function rename(string $path): bool + { + if ($this->exists() && !@rename($this->filepath, $path)) { + return false; + } + + $this->setFilepath($path); + + return true; + } + + /** + * {@inheritdoc} + * @see FileInterface::delete() + */ + public function delete(): bool + { + return @unlink($this->filepath); + } + + /** + * @param string $dir + * @return bool + * @throws \RuntimeException + * @internal + */ + protected function mkdir(string $dir): bool + { + // Silence error for open_basedir; should fail in mkdir instead. + if (@is_dir($dir)) { + return true; + } + + $success = @mkdir($dir, 0777, true); + + if (!$success) { + // Take yet another look, make sure that the folder doesn't exist. + clearstatcache(true, $dir); + if (!@is_dir($dir)) { + return false; + } + } + + return true; + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return [ + 'filepath' => $this->filepath + ]; + } + + /** + * @param array $serialized + */ + protected function doUnserialize(array $serialized): void + { + $this->setFilepath($serialized['filepath']); + } + + /** + * @param string $filepath + */ + protected function setFilepath(string $filepath): void + { + $this->filepath = $filepath; + $this->filename = null; + $this->basename = null; + $this->path = null; + $this->extension = null; + } + + protected function setPathInfo(): void + { + $pathInfo = $this->filesystem->pathinfo($this->filepath); + + $this->filename = $pathInfo['filename'] ?? null; + $this->basename = $pathInfo['basename'] ?? null; + $this->path = $pathInfo['dirname'] ?? null; + $this->extension = $pathInfo['extension'] ?? null; + } + + /** + * @param string $dir + * @return bool + * @internal + */ + protected function isWritablePath(string $dir): bool + { + if ($dir === '') { + return false; + } + + if (!file_exists($dir)) { + // Recursively look up in the directory tree. + return $this->isWritablePath($this->filesystem->parent($dir)); + } + + return is_dir($dir) && is_writable($dir); + } + + /** + * @param string $filename + * @param int $length + * @return string + */ + protected function tempname(string $filename, int $length = 5) + { + do { + $test = $filename . substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, $length); + } while (file_exists($test)); + + return $test; + } +} diff --git a/system/src/Grav/Framework/File/CsvFile.php b/system/src/Grav/Framework/File/CsvFile.php new file mode 100644 index 0000000..53af766 --- /dev/null +++ b/system/src/Grav/Framework/File/CsvFile.php @@ -0,0 +1,31 @@ +formatter = $formatter; + } + + /** + * {@inheritdoc} + * @see FileInterface::load() + */ + public function load() + { + $raw = parent::load(); + + try { + return $raw !== false ? $this->formatter->decode($raw) : false; + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf("Failed to load file '%s': %s", $this->getFilePath(), $e->getMessage()), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + * @see FileInterface::save() + */ + public function save($data): void + { + if (\is_string($data)) { + // Make sure that the string is valid data. + try { + $this->formatter->decode($data); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf("Failed to save file '%s': %s", $this->getFilePath(), $e->getMessage()), $e->getCode(), $e); + } + $encoded = $data; + } else { + $encoded = $this->formatter->encode($data); + } + + parent::save($encoded); + } +} diff --git a/system/src/Grav/Framework/File/File.php b/system/src/Grav/Framework/File/File.php new file mode 100644 index 0000000..c1a2173 --- /dev/null +++ b/system/src/Grav/Framework/File/File.php @@ -0,0 +1,37 @@ +config = $config; + } + + /** + * @return string + */ + public function serialize(): string + { + return serialize($this->doSerialize()); + } + + /** + * @param string $serialized + */ + public function unserialize($serialized): void + { + $this->doUnserialize(unserialize($serialized, ['allowed_classes' => false])); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::getDefaultFileExtension() + */ + public function getDefaultFileExtension(): string + { + $extensions = $this->getSupportedFileExtensions(); + + // Call fails on bad configuration. + return reset($extensions); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::getSupportedFileExtensions() + */ + public function getSupportedFileExtensions(): array + { + $extensions = $this->getConfig('file_extension'); + + // Call fails on bad configuration. + return \is_string($extensions) ? [$extensions] : $extensions; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + abstract public function encode($data): string; + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + abstract public function decode($data); + + /** + * Get either full configuration or a single option. + * + * @param string|null $name Configuration option (optional) + * @return mixed + */ + protected function getConfig(string $name = null) + { + if (null !== $name) { + return $this->config[$name] ?? null; + } + + return $this->config; + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return ['config' => $this->config]; + } + + /** + * Note: if overridden, make sure you call parent::doUnserialize() + * + * @param array $serialized + */ + protected function doUnserialize(array $serialized): void + { + $this->config = $serialized['config']; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/CsvFormatter.php b/system/src/Grav/Framework/File/Formatter/CsvFormatter.php new file mode 100644 index 0000000..e412a73 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/CsvFormatter.php @@ -0,0 +1,127 @@ + ['.csv', '.tsv'], + 'delimiter' => ',' + ]; + + parent::__construct($config); + } + + /** + * Returns delimiter used to both encode and decode CSV. + * + * @return string + */ + public function getDelimiter(): string + { + // Call fails on bad configuration. + return $this->getConfig('delimiter'); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data, $delimiter = null): string + { + if (count($data) === 0) { + return ''; + } + $delimiter = $delimiter ?? $this->getDelimiter(); + $header = array_keys(reset($data)); + + // Encode the field names + $string = $this->encodeLine($header, $delimiter); + + // Encode the data + foreach ($data as $row) { + $string .= $this->encodeLine($row, $delimiter); + } + + return $string; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data, $delimiter = null): array + { + $delimiter = $delimiter ?? $this->getDelimiter(); + $lines = preg_split('/\r\n|\r|\n/', $data); + + if ($lines === false) { + throw new \RuntimeException('Decoding CSV failed'); + } + + // Get the field names + $header = str_getcsv(array_shift($lines), $delimiter); + + // Allow for replacing a null string with null/empty value + $null_replace = $this->getConfig('null'); + + // Get the data + $list = []; + $line = null; + try { + foreach ($lines as $line) { + if (!empty($line)) { + $csv_line = str_getcsv($line, $delimiter); + + if ($null_replace) { + array_walk($csv_line, function(&$el) use ($null_replace) { + $el = str_replace($null_replace, "\0", $el); + }); + } + + $list[] = array_combine($header, $csv_line); + } + } + } catch (\Exception $e) { + throw new \Exception('Badly formatted CSV line: ' . $line); + } + + return $list; + } + + protected function encodeLine(array $line, $delimiter = null): string + { + foreach ($line as $key => &$value) { + $value = $this->escape((string)$value); + } + unset($value); + + return implode($delimiter, $line). "\n"; + } + + protected function escape(string $value) + { + if (preg_match('/[,"\r\n]/u', $value)) { + $value = '"' . preg_replace('/"/', '""', $value) . '"'; + } + + return $value; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/FormatterInterface.php b/system/src/Grav/Framework/File/Formatter/FormatterInterface.php new file mode 100644 index 0000000..a8d5bc5 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/FormatterInterface.php @@ -0,0 +1,10 @@ + '.ini' + ]; + + parent::__construct($config); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data): string + { + $string = ''; + foreach ($data as $key => $value) { + $string .= $key . '="' . preg_replace( + ['/"/', '/\\\/', "/\t/", "/\n/", "/\r/"], + ['\"', '\\\\', '\t', '\n', '\r'], + $value + ) . "\"\n"; + } + + return $string; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data): array + { + $decoded = @parse_ini_string($data); + + if ($decoded === false) { + throw new \RuntimeException('Decoding INI failed'); + } + + return $decoded; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/JsonFormatter.php b/system/src/Grav/Framework/File/Formatter/JsonFormatter.php new file mode 100644 index 0000000..1ae16ae --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/JsonFormatter.php @@ -0,0 +1,100 @@ + '.json', + 'encode_options' => 0, + 'decode_assoc' => true, + 'decode_depth' => 512, + 'decode_options' => 0 + ]; + + parent::__construct($config); + } + + /** + * Returns options used in encode() function. + * + * @return int + */ + public function getEncodeOptions(): int + { + return $this->getConfig('encode_options'); + } + + /** + * Returns options used in decode() function. + * + * @return int + */ + public function getDecodeOptions(): int + { + return $this->getConfig('decode_options'); + } + + /** + * Returns recursion depth used in decode() function. + * + * @return int + */ + public function getDecodeDepth(): int + { + return $this->getConfig('decode_depth'); + } + + /** + * Returns true if JSON objects will be converted into associative arrays. + * + * @return bool + */ + public function getDecodeAssoc(): bool + { + return $this->getConfig('decode_assoc'); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data): string + { + $encoded = @json_encode($data, $this->getEncodeOptions()); + + if ($encoded === false && json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('Encoding JSON failed: ' . json_last_error_msg()); + } + + return $encoded; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data) + { + $decoded = @json_decode($data, $this->getDecodeAssoc(), $this->getDecodeDepth(), $this->getDecodeOptions()); + + if (null === $decoded && json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('Decoding JSON failed: ' . json_last_error_msg()); + } + + return $decoded; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php b/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php new file mode 100644 index 0000000..a2f84e8 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php @@ -0,0 +1,138 @@ + '.md', + 'header' => 'header', + 'body' => 'markdown', + 'raw' => 'frontmatter', + 'yaml' => ['inline' => 20] + ]; + + parent::__construct($config); + + $this->headerFormatter = $headerFormatter ?: new YamlFormatter($config['yaml']); + } + + /** + * Returns header field used in both encode() and decode(). + * + * @return string + */ + public function getHeaderField(): string + { + return $this->getConfig('header'); + } + + /** + * Returns body field used in both encode() and decode(). + * + * @return string + */ + public function getBodyField(): string + { + return $this->getConfig('body'); + } + + /** + * Returns raw field used in both encode() and decode(). + * + * @return string + */ + public function getRawField(): string + { + return $this->getConfig('raw'); + } + + /** + * Returns header formatter object used in both encode() and decode(). + * + * @return FileFormatterInterface + */ + public function getHeaderFormatter(): FileFormatterInterface + { + return $this->headerFormatter; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data): string + { + $headerVar = $this->getHeaderField(); + $bodyVar = $this->getBodyField(); + + $header = isset($data[$headerVar]) ? (array) $data[$headerVar] : []; + $body = isset($data[$bodyVar]) ? (string) $data[$bodyVar] : ''; + + // Create Markdown file with YAML header. + $encoded = ''; + if ($header) { + $encoded = "---\n" . trim($this->getHeaderFormatter()->encode($data['header'])) . "\n---\n\n"; + } + $encoded .= $body; + + // Normalize line endings to Unix style. + $encoded = preg_replace("/(\r\n|\r)/", "\n", $encoded); + + return $encoded; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data): array + { + $headerVar = $this->getHeaderField(); + $bodyVar = $this->getBodyField(); + $rawVar = $this->getRawField(); + + // Define empty content + $content = [ + $headerVar => [], + $bodyVar => '' + ]; + + $headerRegex = "/^---\n(.+?)\n---\n{0,}(.*)$/uis"; + + // Normalize line endings to Unix style. + $data = preg_replace("/(\r\n|\r)/", "\n", $data); + + // Parse header. + preg_match($headerRegex, ltrim($data), $matches); + if(empty($matches)) { + $content[$bodyVar] = $data; + } else { + // Normalize frontmatter. + $frontmatter = preg_replace("/\n\t/", "\n ", $matches[1]); + if ($rawVar) { + $content[$rawVar] = $frontmatter; + } + $content[$headerVar] = $this->getHeaderFormatter()->decode($frontmatter); + $content[$bodyVar] = $matches[2]; + } + + return $content; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php b/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php new file mode 100644 index 0000000..73ed812 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php @@ -0,0 +1,89 @@ + '.ser', + 'decode_options' => ['allowed_classes' => [\stdClass::class]] + ]; + + parent::__construct($config); + } + + /** + * Returns options used in decode(). + * + * By default only allow stdClass class. + * + * @return array|bool + */ + public function getOptions() + { + return $this->getConfig('decode_options'); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data): string + { + return serialize($this->preserveLines($data, ["\n", "\r"], ['\\n', '\\r'])); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data) + { + $decoded = @unserialize($data, $this->getOptions()); + + if ($decoded === false && $data !== serialize(false)) { + throw new \RuntimeException('Decoding serialized data failed'); + } + + return $this->preserveLines($decoded, ['\\n', '\\r'], ["\n", "\r"]); + } + + /** + * Preserve new lines, recursive function. + * + * @param mixed $data + * @param array $search + * @param array $replace + * @return mixed + */ + protected function preserveLines($data, array $search, array $replace) + { + if (\is_string($data)) { + $data = str_replace($search, $replace, $data); + } elseif (\is_array($data)) { + foreach ($data as &$value) { + $value = $this->preserveLines($value, $search, $replace); + } + unset($value); + } + + return $data; + } +} \ No newline at end of file diff --git a/system/src/Grav/Framework/File/Formatter/YamlFormatter.php b/system/src/Grav/Framework/File/Formatter/YamlFormatter.php new file mode 100644 index 0000000..189626d --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/YamlFormatter.php @@ -0,0 +1,114 @@ + '.yaml', + 'inline' => 5, + 'indent' => 2, + 'native' => true, + 'compat' => true + ]; + + parent::__construct($config); + } + + /** + * @return int + */ + public function getInlineOption(): int + { + return $this->getConfig('inline'); + } + + /** + * @return int + */ + public function getIndentOption(): int + { + return $this->getConfig('indent'); + } + + /** + * @return bool + */ + public function useNativeDecoder(): bool + { + return $this->getConfig('native'); + } + + /** + * @return bool + */ + public function useCompatibleDecoder(): bool + { + return $this->getConfig('compat'); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data, $inline = null, $indent = null): string + { + try { + return YamlParser::dump( + $data, + $inline ? (int) $inline : $this->getInlineOption(), + $indent ? (int) $indent : $this->getIndentOption(), + YamlParser::DUMP_EXCEPTION_ON_INVALID_TYPE + ); + } catch (DumpException $e) { + throw new \RuntimeException('Encoding YAML failed: ' . $e->getMessage(), 0, $e); + } + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data): array + { + // Try native PECL YAML PHP extension first if available. + if (\function_exists('yaml_parse') && $this->useNativeDecoder()) { + // Safely decode YAML. + $saved = @ini_get('yaml.decode_php'); + @ini_set('yaml.decode_php', '0'); + $decoded = @yaml_parse($data); + @ini_set('yaml.decode_php', $saved); + + if ($decoded !== false) { + return (array) $decoded; + } + } + + try { + return (array) YamlParser::parse($data); + } catch (ParseException $e) { + if ($this->useCompatibleDecoder()) { + return (array) FallbackYamlParser::parse($data); + } + + throw new \RuntimeException('Decoding YAML failed: ' . $e->getMessage(), 0, $e); + } + } +} diff --git a/system/src/Grav/Framework/File/IniFile.php b/system/src/Grav/Framework/File/IniFile.php new file mode 100644 index 0000000..cc861f9 --- /dev/null +++ b/system/src/Grav/Framework/File/IniFile.php @@ -0,0 +1,31 @@ +setNormalization() + * + * @return Filesystem + */ + public static function getInstance(bool $normalize = null): Filesystem + { + if ($normalize === true) { + $instance = &static::$safe; + } elseif ($normalize === false) { + $instance = &static::$unsafe; + } else { + $instance = &static::$default; + } + + if (null === $instance) { + $instance = new static($normalize); + } + + return $instance; + } + + /** + * Always use Filesystem::getInstance() instead. + * + * @param bool|null $normalize + */ + protected function __construct(bool $normalize = null) + { + $this->normalize = $normalize; + } + + /** + * Set path normalization. + * + * Default option enables normalization for the streams only, but you can force the normalization to be either + * on or off for every path. Disabling path normalization speeds up the calls, but may cause issues if paths were + * not normalized. + * + * @param bool|null $normalize + * + * @return Filesystem + */ + public function setNormalization(bool $normalize = null): self + { + return static::getInstance($normalize); + } + + /** + * Force all paths to be normalized. + * + * @return static + */ + public function unsafe(): self + { + return static::getInstance(true); + } + + /** + * Force all paths not to be normalized (speeds up the calls if given paths are known to be normalized). + * + * @return static + */ + public function safe(): self + { + return static::getInstance(false); + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::parent() + */ + public function parent(string $path, int $levels = 1): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalize !== false) { + $path = $this->normalizePathPart($path); + } + + if ($path === '' || $path === '.') { + return ''; + } + + [$scheme, $parent] = $this->dirnameInternal($scheme, $path, $levels); + + return $parent !== $path ? $this->toString($scheme, $parent) : ''; + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::normalize() + */ + public function normalize(string $path): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + $path = $this->normalizePathPart($path); + + return $this->toString($scheme, $path); + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::dirname() + */ + public function dirname(string $path, int $levels = 1): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalize || ($scheme && null === $this->normalize)) { + $path = $this->normalizePathPart($path); + } + + [$scheme, $path] = $this->dirnameInternal($scheme, $path, $levels); + + return $this->toString($scheme, $path); + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::pathinfo() + */ + public function pathinfo(string $path, int $options = null) + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalize || ($scheme && null === $this->normalize)) { + $path = $this->normalizePathPart($path); + } + + return $this->pathinfoInternal($scheme, $path, $options); + } + + /** + * @param string|null $scheme + * @param string $path + * @param int $levels + * + * @return array + */ + protected function dirnameInternal(?string $scheme, string $path, int $levels = 1): array + { + $path = \dirname($path, $levels); + + if (null !== $scheme && $path === '.') { + return [$scheme, '']; + } + + return [$scheme, $path]; + } + + /** + * @param string|null $scheme + * @param string $path + * @param int|null $options + * + * @return array + */ + protected function pathinfoInternal(?string $scheme, string $path, int $options = null) + { + $info = $options ? \pathinfo($path, $options) : \pathinfo($path); + + if (null !== $scheme) { + $info['scheme'] = $scheme; + $dirname = isset($info['dirname']) && $info['dirname'] !== '.' ? $info['dirname'] : null; + + if (null !== $dirname) { + $info['dirname'] = $scheme . '://' . $dirname; + } else { + $info = ['dirname' => $scheme . '://'] + $info; + } + } + + return $info; + } + + /** + * Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> array(file, tmp)). + * + * @param string $filename + * + * @return array + */ + protected function getSchemeAndHierarchy(string $filename): array + { + $components = explode('://', $filename, 2); + + return 2 === \count($components) ? $components : [null, $components[0]]; + } + + /** + * @param string|null $scheme + * @param string $path + * + * @return string + */ + protected function toString(?string $scheme, string $path): string + { + if ($scheme) { + return $scheme . '://' . $path; + } + + return $path; + } + + /** + * @param string $path + * + * @return string + * @throws \RuntimeException + */ + protected function normalizePathPart(string $path): string + { + // Quick check for empty path. + if ($path === '' || $path === '.') { + return ''; + } + + // Quick check for root. + if ($path === '/') { + return '/'; + } + + // If the last character is not '/' or any of '\', './', '//' and '..' are not found, path is clean and we're done. + if ($path[-1] !== '/' && !preg_match('`(\\\\|\./|//|\.\.)`', $path)) { + return $path; + } + + // Convert backslashes + $path = strtr($path, ['\\' => '/']); + + $parts = explode('/', $path); + + // Keep absolute paths. + $root = ''; + if ($parts[0] === '') { + $root = '/'; + array_shift($parts); + } + + $list = []; + foreach ($parts as $i => $part) { + // Remove empty parts: // and /./ + if ($part === '' || $part === '.') { + continue; + } + + // Resolve /../ by removing path part. + if ($part === '..') { + $test = array_shift($list); + if ($test === null) { + // Oops, user tried to access something outside of our root folder. + throw new \RuntimeException("Bad path {$path}"); + } + } + + $list[] = $part; + } + + // Build path back together. + return $root . implode('/', $list); + } +} diff --git a/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php b/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php new file mode 100644 index 0000000..7082ebf --- /dev/null +++ b/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php @@ -0,0 +1,75 @@ += 1). + * + * @return string Returns parent path. + * @throws \RuntimeException + * @api + */ + public function parent(string $path, int $levels = 1): string; + + /** + * Normalize path by cleaning up `\`, `/./`, `//` and `/../`. + * + * @param string $path A filename or path, does not need to exist as a file. + * + * @return string Returns normalized path. + * @throws \RuntimeException + * @api + */ + public function normalize(string $path): string; + + /** + * Stream-safe `\dirname()` replacement. + * + * @see http://php.net/manual/en/function.dirname.php + * + * @param string $path A filename or path, does not need to exist as a file. + * @param int $levels The number of parent directories to go up (>= 1). + * + * @return string Returns path to the directory. + * @throws \RuntimeException + * @api + */ + public function dirname(string $path, int $levels = 1): string; + + /** + * Stream-safe `\pathinfo()` replacement. + * + * @see http://php.net/manual/en/function.pathinfo.php + * + * @param string $path A filename or path, does not need to exist as a file. + * @param int $options A PATHINFO_* constant. + * + * @return array|string + * @api + */ + public function pathinfo(string $path, int $options = null); +} diff --git a/system/src/Grav/Framework/Flex/Flex.php b/system/src/Grav/Framework/Flex/Flex.php new file mode 100644 index 0000000..e87e903 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Flex.php @@ -0,0 +1,311 @@ + blueprint file, ...] + * @param array $config + */ + public function __construct(array $types, array $config) + { + $this->config = $config; + $this->types = []; + + foreach ($types as $type => $blueprint) { + $this->addDirectoryType($type, $blueprint); + } + } + + /** + * @param string $type + * @param string $blueprint + * @param array $config + * @return $this + */ + public function addDirectoryType(string $type, string $blueprint, array $config = []) + { + $config = array_merge_recursive(['enabled' => true], $this->config['object'] ?? [], $config); + + $this->types[$type] = new FlexDirectory($type, $blueprint, $config); + + return $this; + } + + /** + * @param FlexDirectory $directory + * @return $this + */ + public function addDirectory(FlexDirectory $directory) + { + $this->types[$directory->getFlexType()] = $directory; + + return $this; + } + + /** + * @param string $type + * @return bool + */ + public function hasDirectory(string $type): bool + { + return isset($this->types[$type]); + } + + /** + * @param array|string[] $types + * @param bool $keepMissing + * @return array|FlexDirectory[] + */ + public function getDirectories(array $types = null, bool $keepMissing = false): array + { + if ($types === null) { + return $this->types; + } + + // Return the directories in the given order. + $directories = []; + foreach ($types as $type) { + $directories[$type] = $this->types[$type] ?? null; + } + + return $keepMissing ? $directories : array_filter($directories); + } + + /** + * @param string $type + * @return FlexDirectory|null + */ + public function getDirectory(string $type): ?FlexDirectory + { + return $this->types[$type] ?? null; + } + + /** + * @param string $type + * @param array|null $keys + * @param string|null $keyField + * @return FlexCollectionInterface|null + */ + public function getCollection(string $type, array $keys = null, string $keyField = null): ?FlexCollectionInterface + { + $directory = $type ? $this->getDirectory($type) : null; + + return $directory ? $directory->getCollection($keys, $keyField) : null; + } + + /** + * @param array $keys + * @param array $options In addition to the options in getObjects(), following options can be passed: + * collection_class: Class to be used to create the collection. Defaults to ObjectCollection. + * @return FlexCollectionInterface + * @throws \RuntimeException + */ + public function getMixedCollection(array $keys, array $options = []): FlexCollectionInterface + { + $collectionClass = $options['collection_class'] ?? ObjectCollection::class; + if (!class_exists($collectionClass)) { + throw new \RuntimeException(sprintf('Cannot create collection: Class %s does not exist', $collectionClass)); + } + + $objects = $this->getObjects($keys, $options); + + return new $collectionClass($objects); + } + + /** + * @param array $keys + * @param array $options Following optional options can be passed: + * types: List of allowed types. + * type: Allowed type if types isn't defined, otherwise acts as default_type. + * default_type: Set default type for objects given without type (only used if key_field isn't set). + * keep_missing: Set to true if you want to return missing objects as null. + * key_field: Key field which is used to match the objects. + * @return array + */ + public function getObjects(array $keys, array $options = []): array + { + $type = $options['type'] ?? null; + $defaultType = $options['default_type'] ?? $type ?? null; + $keyField = $options['key_field'] ?? 'flex_key'; + + // Prepare empty result lists for all requested Flex types. + $types = $options['types'] ?? (array)$type ?: null; + if ($types) { + $types = array_fill_keys($types, []); + } + $strict = isset($types); + + $guessed = []; + if ($keyField === 'flex_key') { + // We need to split Flex key lookups into individual directories. + $undefined = []; + $keyFieldFind = 'storage_key'; + + foreach ($keys as $flexKey) { + if (!$flexKey) { + continue; + } + + $flexKey = (string)$flexKey; + // Normalize key and type using fallback to default type if it was set. + [$key, $type, $guess] = $this->resolveKeyAndType($flexKey, $defaultType); + + if ($type === '' && $types) { + // Add keys which are not associated to any Flex type. They will be included to every Flex type. + foreach ($types as $type => &$array) { + $array[] = $key; + $guessed[$key][] = "{$type}.obj:{$key}"; + } + unset($array); + } elseif (!$strict || isset($types[$type])) { + // Collect keys by their Flex type. If allowed types are defined, only include values from those types. + $types[$type][] = $key; + if ($guess) { + $guessed[$key][] = "{$type}.obj:{$key}"; + } + } + } + } else { + // We are using a specific key field, make every key undefined. + $undefined = $keys; + $keyFieldFind = $keyField; + } + + if (!$types) { + return []; + } + + $list = [[]]; + foreach ($types as $type => $typeKeys) { + // Also remember to look up keys from undefined Flex types. + $lookupKeys = $undefined ? array_merge($typeKeys, $undefined) : $typeKeys; + + $collection = $this->getCollection($type, $lookupKeys, $keyFieldFind); + if ($collection && $keyFieldFind !== $keyField) { + $collection = $collection->withKeyField($keyField); + } + + $list[] = $collection ? $collection->toArray() : []; + } + + // Merge objects from individual types back together. + $list = array_merge(...$list); + + // Use the original key ordering. + if (!$guessed) { + $list = array_replace(array_fill_keys($keys, null), $list); + } else { + // We have mixed keys, we need to map flex keys back to storage keys. + $results = []; + foreach ($keys as $key) { + $flexKey = $guessed[$key] ?? $key; + if (\is_array($flexKey)) { + $result = null; + foreach ($flexKey as $tryKey) { + if ($result = $list[$tryKey] ?? null) { + // Use the first matching object (conflicting objects will be ignored for now). + break; + } + } + } else { + $result = $list[$flexKey] ?? null; + } + + $results[$key] = $result; + } + + $list = $results; + } + + // Remove missing objects if not asked to keep them. + if (empty($option['keep_missing'])) { + $list = array_filter($list); + } + + return $list; + } + + /** + * @param string $key + * @param string|null $type + * @param string|null $keyField + * @return FlexObjectInterface|null + */ + public function getObject(string $key, string $type = null, string $keyField = null): ?FlexObjectInterface + { + if (null === $type && null === $keyField) { + // Special handling for quick Flex key lookups. + $keyField = 'storage_key'; + [$type, $key] = $this->resolveKeyAndType($key, $type); + } else { + $type = $this->resolveType($type); + } + + if ($type === '' || $key === '') { + return null; + } + + $directory = $this->getDirectory($type); + + return $directory ? $directory->getObject($key, $keyField) : null; + } + + /** + * @return int + */ + public function count(): int + { + return \count($this->types); + } + + protected function resolveKeyAndType(string $flexKey, string $type = null): array + { + $guess = false; + if (strpos($flexKey, ':') !== false) { + [$type, $key] = explode(':', $flexKey, 2); + + $type = $this->resolveType($type); + } else { + $key = $flexKey; + $type = (string)$type; + $guess = true; + } + + return [$key, $type, $guess]; + } + + protected function resolveType(string $type = null): string + { + if (null !== $type && strpos($type, '.') !== false) { + return preg_replace('|\.obj$|', '', $type); + } + + return $type ?? ''; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexCollection.php b/system/src/Grav/Framework/Flex/FlexCollection.php new file mode 100644 index 0000000..5e5431b --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexCollection.php @@ -0,0 +1,525 @@ + true, + 'getType' => true, + 'getFlexDirectory' => true, + 'getCacheKey' => true, + 'getCacheChecksum' => true, + 'getTimestamp' => true, + 'hasProperty' => true, + 'getProperty' => true, + 'hasNestedProperty' => true, + 'getNestedProperty' => true, + 'orderBy' => true, + + 'render' => false, + 'isAuthorized' => 'session', + 'search' => true, + 'sort' => true, + ]; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::createFromArray() + */ + public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null) + { + $instance = new static($entries, $directory); + $instance->setKeyField($keyField); + + return $instance; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::__construct() + */ + public function __construct(array $entries = [], FlexDirectory $directory = null) + { + parent::__construct($entries); + + if ($directory) { + $this->setFlexDirectory($directory)->setKey($directory->getFlexType()); + } + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::search() + */ + public function search(string $search, $properties = null, array $options = null) + { + $matching = $this->call('search', [$search, $properties, $options]); + $matching = array_filter($matching); + + if ($matching) { + uksort($matching, function ($a, $b) { + return -($a <=> $b); + }); + } + + return $this->select(array_keys($matching)); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::sort() + */ + public function sort(array $order) + { + $criteria = Criteria::create()->orderBy($order); + + /** @var FlexCollectionInterface $matching */ + $matching = $this->matching($criteria); + + return $matching; + } + + /** + * @param array $filters + * @return FlexCollectionInterface|Collection + */ + public function filterBy(array $filters) + { + $expr = Criteria::expr(); + $criteria = Criteria::create(); + + foreach ($filters as $key => $value) { + $criteria->andWhere($expr->eq($key, $value)); + } + + return $this->matching($criteria); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexType() + */ + public function getFlexType(): string + { + return $this->_flexDirectory->getFlexType(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getFlexDirectory(): FlexDirectory + { + return $this->_flexDirectory; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getTimestamp() + */ + public function getTimestamp(): int + { + $timestamps = $this->getTimestamps(); + + return $timestamps ? max($timestamps) : time(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getCacheKey(): string + { + return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->call('getKey'))); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getCacheChecksum(): string + { + return sha1(json_encode($this->getTimestamps())); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getTimestamps(): array + { + /** @var int[] $timestamps */ + $timestamps = $this->call('getTimestamp'); + + return $timestamps; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getStorageKeys(): array + { + /** @var string[] $keys */ + $keys = $this->call('getStorageKey'); + + return $keys; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getFlexKeys(): array + { + /** @var string[] $keys */ + $keys = $this->call('getFlexKey'); + + return $keys; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::withKeyField() + */ + public function withKeyField(string $keyField = null) + { + $keyField = $keyField ?: 'key'; + if ($keyField === $this->getKeyField()) { + return $this; + } + + $entries = []; + foreach ($this as $key => $object) { + // TODO: remove hardcoded logic + if ($keyField === 'storage_key') { + $entries[$object->getStorageKey()] = $object; + } elseif ($keyField === 'flex_key') { + $entries[$object->getFlexKey()] = $object; + } elseif ($keyField === 'key') { + $entries[$object->getKey()] = $object; + } + } + + return $this->createFrom($entries, $keyField); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getIndex() + */ + public function getIndex() + { + return $this->getFlexDirectory()->getIndex($this->getKeys(), $this->getKeyField()); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + if (null === $layout) { + $layout = 'default'; + } + $type = $this->getFlexType(); + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->startTimer('flex-collection-' . ($debugKey = uniqid($type, false)), 'Render Collection ' . $type . ' (' . $layout . ')'); + + $cache = $key = null; + foreach ($context as $value) { + if (!\is_scalar($value)) { + $key = false; + } + } + + if ($key !== false) { + $key = md5($this->getCacheKey() . '.' . $layout . json_encode($context)); + $cache = $this->getCache('render'); + } + + try { + $data = $cache ? $cache->get($key) : null; + + $block = $data ? HtmlBlock::fromArray($data) : null; + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + $block = null; + } catch (\InvalidArgumentException $e) { + $debugger->addException($e); + $block = null; + } + + $checksum = $this->getCacheChecksum(); + if ($block && $checksum !== $block->getChecksum()) { + $block = null; + } + + if (!$block) { + $block = HtmlBlock::create($key); + $block->setChecksum($checksum); + if ($key === false) { + $block->disableCache(); + } + + $grav->fireEvent('onFlexCollectionRender', new Event([ + 'collection' => $this, + 'layout' => &$layout, + 'context' => &$context + ])); + + $output = $this->getTemplate($layout)->render( + ['grav' => $grav, 'config' => $grav['config'], 'block' => $block, 'collection' => $this, 'layout' => $layout] + $context + ); + + if ($debugger->enabled()) { + $output = "\n\n{$output}\n\n"; + } + + $block->setContent($output); + + try { + $cache && $block->isCached() && $cache->set($key, $block->toArray()); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + } + + $debugger->stopTimer('flex-collection-' . $debugKey); + + return $block; + } + + /** + * @param bool $prefix + * @return string + * @deprecated 1.6 Use `->getFlexType()` instead. + */ + public function getType($prefix = false) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->getFlexType(); + } + + /** + * @param FlexDirectory $type + * @return $this + */ + public function setFlexDirectory(FlexDirectory $type) + { + $this->_flexDirectory = $type; + + return $this; + } + + /** + * @return array + */ + public function getMetaData(string $key) : array + { + $object = $this->get($key); + + return $object instanceof FlexObjectInterface ? $object->getMetaData() : []; + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + return $this->_flexDirectory->getCache($namespace); + } + + /** + * @return string + */ + public function getKeyField(): string + { + return $this->_keyField ?? 'storage_key'; + } + + /** + * @param string $action + * @param string|null $scope + * @param UserInterface|null $user + * @return static + */ + public function isAuthorized(string $action, string $scope = null, UserInterface $user = null) + { + $list = $this->call('isAuthorized', [$action, $scope, $user]); + $list = \array_filter($list); + + return $this->select(array_keys($list)); + } + + /** + * @param string $value + * @param string $field + * @return FlexObject|null + */ + public function find($value, $field = 'id') + { + if ($value) foreach ($this as $element) { + if (mb_strtolower($element->getProperty($field)) === mb_strtolower($value)) { + return $element; + } + } + + return null; + } + + /** + * @return array + */ + public function jsonSerialize() + { + $elements = []; + + /** + * @var string $key + * @var array|FlexObject $object + */ + foreach ($this->getElements() as $key => $object) { + $elements[$key] = \is_array($object) ? $object : $object->jsonSerialize(); + } + + return $elements; + } + + public function __debugInfo() + { + return [ + 'type:private' => $this->getFlexType(), + 'key:private' => $this->getKey(), + 'objects_key:private' => $this->getKeyField(), + 'objects:private' => $this->getElements() + ]; + } + + /** + * Creates a new instance from the specified elements. + * + * This method is provided for derived classes to specify how a new + * instance should be created when constructor semantics have changed. + * + * @param array $elements Elements. + * @param string|null $keyField + * + * @return static + * @throws \InvalidArgumentException + */ + protected function createFrom(array $elements, $keyField = null) + { + $collection = new static($elements, $this->_flexDirectory); + $collection->setKeyField($keyField ?: $this->_keyField); + + return $collection; + } + + /** + * @return string + */ + protected function getTypePrefix(): string + { + return 'c.'; + } + + /** + * @param string $layout + * @return TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + try { + return $twig->twig()->resolveTemplate( + [ + "flex-objects/layouts/{$this->getFlexType()}/collection/{$layout}.html.twig", + "flex-objects/layouts/_default/collection/{$layout}.html.twig" + ] + ); + } catch (LoaderError $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + return $twig->twig()->resolveTemplate(['flex-objects/layouts/404.html.twig']); + } + } + + /** + * @param string $type + * @return FlexDirectory + */ + protected function getRelatedDirectory($type): ?FlexDirectory + { + /** @var Flex $flex */ + $flex = Grav::instance()['flex_objects']; + + return $flex->getDirectory($type); + } + + protected function setKeyField($keyField = null): void + { + $this->_keyField = $keyField ?? 'storage_key'; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexDirectory.php b/system/src/Grav/Framework/Flex/FlexDirectory.php new file mode 100644 index 0000000..c75a901 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexDirectory.php @@ -0,0 +1,669 @@ +type = $type; + $this->blueprints = []; + $this->blueprint_file = $blueprint_file; + $this->defaults = $defaults; + $this->enabled = !empty($defaults['enabled']); + } + + /** + * @return bool + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * @return string + * @deprecated 1.6 Use ->getFlexType() method instead. + */ + public function getType(): string + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + return $this->type; + } + + /** + * @return string + */ + public function getFlexType(): string + { + return $this->type; + } + + /** + * @return string + */ + public function getTitle(): string + { + return $this->getBlueprintInternal()->get('title', ucfirst($this->getFlexType())); + } + + /** + * @return string + */ + public function getDescription(): string + { + return $this->getBlueprintInternal()->get('description', ''); + } + + /** + * @param string|null $name + * @param mixed $default + * @return mixed + */ + public function getConfig(string $name = null, $default = null) + { + if (null === $this->config) { + $this->config = new Config(array_merge_recursive($this->getBlueprintInternal()->get('config', []), $this->defaults)); + } + + return null === $name ? $this->config : $this->config->get($name, $default); + } + + /** + * @param string $type + * @param string $context + * @return Blueprint + */ + public function getBlueprint(string $type = '', string $context = '') + { + $blueprint = $this->getBlueprintInternal($type, $context); + + if (empty($this->blueprints_init[$type])) { + $this->blueprints_init[$type] = true; + + $blueprint->setScope('object'); + $blueprint->init(); + if (empty($blueprint->fields())) { + throw new RuntimeException(sprintf('Flex: Blueprint for %s is missing', $this->type)); + } + } + + return $blueprint; + } + + /** + * @param string $view + * @return string + */ + public function getBlueprintFile(string $view = ''): string + { + $file = $this->blueprint_file; + if ($view !== '') { + $file = preg_replace('/\.yaml/', "/{$view}.yaml", $file); + } + + return $file; + } + + /** + * Get collection. In the site this will be filtered by the default filters (published etc). + * + * Use $directory->getIndex() if you want unfiltered collection. + * + * @param array|null $keys Array of keys. + * @param string|null $keyField Field to be used as the key. + * @return FlexCollectionInterface + */ + public function getCollection(array $keys = null, string $keyField = null): FlexCollectionInterface + { + // Get all selected entries. + $index = $this->getIndex($keys, $keyField); + + if (!Utils::isAdminPlugin()) { + // If not in admin, filter the list by using default filters. + $filters = (array)$this->getConfig('site.filter', []); + + foreach ($filters as $filter) { + $index = $index->{$filter}(); + } + } + + return $index; + } + + /** + * Get the full collection of all stored objects. + * + * Use $directory->getCollection() if you want a filtered collection. + * + * @param array|null $keys Array of keys. + * @param string|null $keyField Field to be used as the key. + * @return FlexIndexInterface + */ + public function getIndex(array $keys = null, string $keyField = null): FlexIndexInterface + { + $index = clone $this->loadIndex(); + $index = $index->withKeyField($keyField); + + if (null !== $keys) { + $index = $index->select($keys); + } + + return $index->getIndex(); + } + + /** + * Returns an object if it exists. + * + * Note: It is not safe to use the object without checking if the user can access it. + * + * @param string $key + * @param string|null $keyField Field to be used as the key. + * @return FlexObjectInterface|null + */ + public function getObject($key, string $keyField = null): ?FlexObjectInterface + { + return $this->getIndex(null, $keyField)->get($key); + } + + /** + * @param array $data + * @param string|null $key + * @return FlexObjectInterface + */ + public function update(array $data, string $key = null): FlexObjectInterface + { + $object = null !== $key ? $this->getIndex()->get($key): null; + + $storage = $this->getStorage(); + + if (null === $object) { + $object = $this->createObject($data, $key, true); + $key = $object->getStorageKey(); + + if ($key) { + $rows = $storage->replaceRows([$key => $object->prepareStorage()]); + } else { + $rows = $storage->createRows([$object->prepareStorage()]); + } + } else { + $oldKey = $object->getStorageKey(); + $object->update($data); + $newKey = $object->getStorageKey(); + + if ($oldKey !== $newKey) { + $object->triggerEvent('move'); + $storage->renameRow($oldKey, $newKey); + // TODO: media support. + } + + $object->save(); + } + + try { + $this->clearCache(); + } catch (InvalidArgumentException $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + return $object; + } + + /** + * @param string $key + * @return FlexObjectInterface|null + */ + public function remove(string $key): ?FlexObjectInterface + { + $object = $this->getIndex()->get($key); + if (!$object) { + return null; + } + + $object->delete(); + + return $object; + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + $namespace = $namespace ?: 'index'; + $cache = $this->cache[$namespace] ?? null; + + if (null === $cache) { + try { + $grav = Grav::instance(); + + /** @var Cache $gravCache */ + $gravCache = $grav['cache']; + $config = $this->getConfig('cache.' . $namespace); + if (empty($config['enabled'])) { + $cache = new MemoryCache('flex-objects-' . $this->getFlexType()); + } else { + $timeout = $config['timeout'] ?? 60; + + $key = $gravCache->getKey(); + if (Utils::isAdminPlugin()) { + $key = substr($key, 0, -1); + } + $cache = new DoctrineCache($gravCache->getCacheDriver(), 'flex-objects-' . $this->getFlexType() . $key, $timeout); + } + } catch (\Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + $cache = new MemoryCache('flex-objects-' . $this->getFlexType()); + } + + // Disable cache key validation. + $cache->setValidation(false); + $this->cache[$namespace] = $cache; + } + + return $cache; + } + + /** + * @return $this + */ + public function clearCache() + { + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage(sprintf('Flex: Clearing all %s cache', $this->type), 'debug'); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $locator->clearCache(); + + $this->getCache('index')->clear(); + $this->getCache('object')->clear(); + $this->getCache('render')->clear(); + + $this->index = null; + + return $this; + } + + /** + * @param string|null $key + * @return string + */ + public function getStorageFolder(string $key = null): string + { + return $this->getStorage()->getStoragePath($key); + } + + /** + * @param string|null $key + * @return string + */ + public function getMediaFolder(string $key = null): string + { + return $this->getStorage()->getMediaPath($key); + } + + /** + * @return FlexStorageInterface + */ + public function getStorage(): FlexStorageInterface + { + if (null === $this->storage) { + $this->storage = $this->createStorage(); + } + + return $this->storage; + } + + /** + * @param array $data + * @param string $key + * @param bool $validate + * @return FlexObjectInterface + */ + public function createObject(array $data, string $key = '', bool $validate = false): FlexObjectInterface + { + /** @var string|FlexObjectInterface $className */ + $className = $this->objectClassName ?: $this->getObjectClass(); + + return new $className($data, $key, $this, $validate); + } + + /** + * @param array $entries + * @param string $keyField + * @return FlexCollectionInterface + */ + public function createCollection(array $entries, string $keyField = null): FlexCollectionInterface + { + /** @var string|FlexCollectionInterface $className */ + $className = $this->collectionClassName ?: $this->getCollectionClass(); + + return $className::createFromArray($entries, $this, $keyField); + } + + /** + * @param array $entries + * @param string $keyField + * @return FlexIndexInterface + */ + public function createIndex(array $entries, string $keyField = null): FlexIndexInterface + { + /** @var string|FlexIndexInterface $className */ + $className = $this->indexClassName ?: $this->getIndexClass(); + + return $className::createFromArray($entries, $this, $keyField); + } + + /** + * @return string + */ + public function getObjectClass(): string + { + if (!$this->objectClassName) { + $this->objectClassName = $this->getConfig('data.object', 'Grav\\Framework\\Flex\\FlexObject'); + } + + return $this->objectClassName; + + } + + /** + * @return string + */ + public function getCollectionClass(): string + { + if (!$this->collectionClassName) { + $this->collectionClassName = $this->getConfig('data.collection', 'Grav\\Framework\\Flex\\FlexCollection'); + } + + return $this->collectionClassName; + } + + + /** + * @return string + */ + public function getIndexClass(): string + { + if (!$this->indexClassName) { + $this->indexClassName = $this->getConfig('data.index', 'Grav\\Framework\\Flex\\FlexIndex'); + } + + return $this->indexClassName; + } + + /** + * @param array $entries + * @param string $keyField + * @return FlexCollectionInterface + */ + public function loadCollection(array $entries, string $keyField = null): FlexCollectionInterface + { + return $this->createCollection($this->loadObjects($entries), $keyField); + } + + /** + * @param array $entries + * @return FlexObjectInterface[] + * @internal + */ + public function loadObjects(array $entries): array + { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->startTimer('flex-objects', sprintf('Flex: Initializing %d %s', \count($entries), $this->type)); + + $storage = $this->getStorage(); + $cache = $this->getCache('object'); + + // Get storage keys for the objects. + $keys = []; + $rows = []; + foreach ($entries as $key => $value) { + $k = $value['storage_key']; + $keys[$k] = $key; + $rows[$k] = null; + } + + // Fetch rows from the cache. + try { + $rows = $cache->getMultiple(array_keys($rows)); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + + // Read missing rows from the storage. + $updated = []; + $rows = $storage->readRows($rows, $updated); + + // Store updated rows to the cache. + if ($updated) { + try { + if (!$cache instanceof MemoryCache) { + $debugger->addMessage(sprintf('Flex: Caching %d %s: %s', \count($updated), $this->type, implode(', ', array_keys($updated))), 'debug'); + } + $cache->setMultiple($updated); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + + // TODO: log about the issue. + } + } + + // Create objects from the rows. + $list = []; + foreach ($rows as $storageKey => $row) { + if ($row === null) { + $debugger->addMessage(sprintf('Flex: Object %s was not found from %s storage', $storageKey, $this->type), 'debug'); + continue; + } + + if (isset($row['__error'])) { + $message = sprintf('Flex: Object %s is broken in %s storage: %s', $storageKey, $this->type, $row['__error']); + $debugger->addException(new \RuntimeException($message)); + $debugger->addMessage($message, 'error'); + continue; + } + + $usedKey = $keys[$storageKey]; + $row += [ + 'storage_key' => $storageKey, + 'storage_timestamp' => $entries[$usedKey]['storage_timestamp'], + ]; + + $key = $entries[$usedKey]['key'] ?? $usedKey; + $object = $this->createObject($row, $key, false); + $list[$usedKey] = $object; + } + + $debugger->stopTimer('flex-objects'); + + return $list; + } + + /** + * @param string $type_view + * @param string $context + * @return Blueprint + */ + protected function getBlueprintInternal(string $type_view = '', string $context = '') + { + if (!isset($this->blueprints[$type_view])) { + if (!file_exists($this->blueprint_file)) { + throw new RuntimeException(sprintf('Flex: Blueprint file for %s is missing', $this->type)); + } + + $parts = explode('.', rtrim($type_view, '.'), 2); + $type = array_shift($parts); + $view = array_shift($parts) ?: ''; + + $blueprint = new Blueprint($this->getBlueprintFile($view)); + if ($context) { + $blueprint->setContext($context); + } + + $blueprint->load($type ?: null); + if ($blueprint->get('type') === 'flex-objects' && isset(Grav::instance()['admin'])) { + $blueprintBase = (new Blueprint('plugin://flex-objects/blueprints/flex-objects.yaml'))->load(); + $blueprint->extend($blueprintBase, true); + } + + $this->blueprints[$type_view] = $blueprint; + } + + return $this->blueprints[$type_view]; + } + + /** + * @return FlexStorageInterface + */ + protected function createStorage(): FlexStorageInterface + { + $this->collection = $this->createCollection([]); + + $storage = $this->getConfig('data.storage'); + + if (!\is_array($storage)) { + $storage = ['options' => ['folder' => $storage]]; + } + + $className = $storage['class'] ?? SimpleStorage::class; + $options = $storage['options'] ?? []; + + return new $className($options); + } + + /** + * @return FlexIndexInterface + */ + protected function loadIndex(): FlexIndexInterface + { + static $i = 0; + + $index = $this->index; + + if (null === $index) { + $i++; $j = $i; + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->startTimer('flex-keys-' . $this->type . $j, "Flex: Loading {$this->type} index"); + + $storage = $this->getStorage(); + $cache = $this->getCache('index'); + + try { + $keys = $cache->get('__keys'); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + $keys = null; + } + + if (null === $keys) { + /** @var string|FlexIndexInterface $className */ + $className = $this->getIndexClass(); + $keys = $className::loadEntriesFromStorage($storage); + if (!$cache instanceof MemoryCache) { + $debugger->addMessage(sprintf('Flex: Caching %s index of %d objects', $this->type, \count($keys)), + 'debug'); + } + try { + $cache->set('__keys', $keys); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + // TODO: log about the issue. + } + } + + // We need to do this in two steps as orderBy() calls loadIndex() again and we do not want infinite loop. + $this->index = $this->createIndex($keys); + /** @var FlexCollectionInterface $collection */ + $collection = $this->index->orderBy($this->getConfig('data.ordering', [])); + $this->index = $index = $collection->getIndex(); + + $debugger->stopTimer('flex-keys-' . $this->type . $j); + } + + return $index; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexForm.php b/system/src/Grav/Framework/Flex/FlexForm.php new file mode 100644 index 0000000..6ded345 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexForm.php @@ -0,0 +1,344 @@ +name = $name; + $this->form = $form; + + $uniqueId = $object->exists() ? $object->getStorageKey() : "{$object->getFlexType()}:new"; + $this->setObject($object); + $this->setId($this->getName()); + $this->setUniqueId(md5($uniqueId)); + $this->messages = []; + $this->submitted = false; + + $flash = $this->getFlash(); + if ($flash->exists()) { + $data = $flash->getData(); + $includeOriginal = (bool)($this->getBlueprint()->form()['images']['original'] ?? null); + + $this->data = $data ? new Data($data, $this->getBlueprint()) : null; + $this->files = $flash->getFilesByFields($includeOriginal); + } else { + $this->data = null; + $this->files = []; + } + } + + /** + * @return string + */ + public function getName(): string + { + $object = $this->getObject(); + $name = $this->name ?: 'object'; + + return "flex-{$object->getFlexType()}-{$name}"; + } + + /** + * @return Data|FlexObjectInterface|object + */ + public function getData() + { + return $this->data ?? $this->getObject(); + } + + /** + * Get a value from the form. + * + * Note: Used in form fields. + * + * @param string $name + * @return mixed + */ + public function getValue(string $name) + { + // Attempt to get value from the form data. + $value = $this->data ? $this->data[$name] : null; + + // Return the form data or fall back to the object property. + return $value ?? $this->getObject()->getFormValue($name); + } + + public function getDefaultValue(string $name) + { + return $this->object->getDefaultValue($name); + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->object->getDefaultValues(); + } + /** + * @return string + */ + public function getFlexType(): string + { + return $this->object->getFlexType(); + } + + /** + * @return FlexObjectInterface + */ + public function getObject(): FlexObjectInterface + { + return $this->object; + } + + public function updateObject(): FlexObjectInterface + { + $data = $this->data instanceof Data ? $this->data->toArray() : []; + $files = $this->files; + + return $this->getObject()->update($data, $files); + } + + /** + * @return Blueprint + */ + public function getBlueprint(): Blueprint + { + if (null === $this->blueprint) { + try { + $blueprint = $this->getObject()->getBlueprint(Utils::isAdminPlugin() ? '' : $this->name); + if ($this->form) { + // We have field overrides available. + $blueprint->extend(['form' => $this->form], true); + $blueprint->init(); + } + } catch (\RuntimeException $e) { + if (!isset($this->form['fields'])) { + throw $e; + } + + // Blueprint is not defined, but we have custom form fields available. + $blueprint = new Blueprint(null, ['form' => $this->form]); + $blueprint->load(); + $blueprint->setScope('object'); + $blueprint->init(); + } + + $this->blueprint = $blueprint; + } + + return $this->blueprint; + } + + /** + * @return Route|null + */ + public function getFileUploadAjaxRoute(): ?Route + { + $object = $this->getObject(); + if (!method_exists($object, 'route')) { + return null; + } + + return $object->route('/edit.json/task:media.upload'); + } + + /** + * @param string $field + * @param string $filename + * @return Route|null + */ + public function getFileDeleteAjaxRoute($field, $filename): ?Route + { + $object = $this->getObject(); + if (!method_exists($object, 'route')) { + return null; + } + + return $object->route('/edit.json/task:media.delete'); + } + + public function getMediaTaskRoute(array $params = [], $extension = null): string + { + $grav = Grav::instance(); + /** @var Flex $flex */ + $flex = $grav['flex_objects']; + + if (method_exists($flex, 'adminRoute')) { + return $flex->adminRoute($this->getObject(), $params, $extension ?? 'json'); + } + + return ''; + } + + /** + * Implements \Serializable::unserialize(). + * + * @param string $data + */ + public function unserialize($data): void + { + $data = unserialize($data, ['allowed_classes' => [FlexObject::class]]); + + $this->doUnserialize($data); + } + + public function __get($name) + { + $method = "get{$name}"; + if (method_exists($this, $method)) { + return $this->{$method}(); + } + + $form = $this->getBlueprint()->form(); + + return $form[$name] ?? null; + } + + public function __set($name, $value) + { + $method = "set{$name}"; + if (method_exists($this, $method)) { + $this->{$method}($value); + } + } + + public function __isset($name) + { + $method = "get{$name}"; + if (method_exists($this, $method)) { + return true; + } + + $form = $this->getBlueprint()->form(); + + return isset($form[$name]); + } + + public function __unset($name) + { + } + + /** + * Note: this method clones the object. + * + * @param FlexObjectInterface $object + * @return $this + */ + protected function setObject(FlexObjectInterface $object): self + { + $this->object = clone $object; + + return $this; + } + + /** + * @param string $layout + * @return Template|TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + return $twig->twig()->resolveTemplate( + [ + "flex-objects/layouts/{$this->getFlexType()}/form/{$layout}.html.twig", + "flex-objects/layouts/_default/form/{$layout}.html.twig", + "forms/{$layout}/form.html.twig", + 'forms/default/form.html.twig' + ] + ); + } + + /** + * @param array $data + * @param array $files + * @throws \Exception + */ + protected function doSubmit(array $data, array $files) + { + /** @var FlexObject $object */ + $object = clone $this->getObject(); + + $object->update($data, $files); + $object->save(); + + $this->setObject($object); + $this->reset(); + } + + protected function doSerialize(): array + { + return $this->doTraitSerialize() + [ + 'object' => $this->object, + ]; + } + + protected function doUnserialize(array $data): void + { + $this->doTraitUnserialize($data); + + $this->object = $data['object']; + } + + /** + * Filter validated data. + * + * @param \ArrayAccess $data + */ + protected function filterData(\ArrayAccess $data): void + { + if ($data instanceof Data) { + $data->filter(true, true); + } + } +} diff --git a/system/src/Grav/Framework/Flex/FlexIndex.php b/system/src/Grav/Framework/Flex/FlexIndex.php new file mode 100644 index 0000000..8b96b4a --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexIndex.php @@ -0,0 +1,704 @@ +getStorage()), $directory); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::createFromArray() + */ + public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null) + { + $instance = new static($entries, $directory); + $instance->setKeyField($keyField); + + return $instance; + } + + /** + * @param FlexStorageInterface $storage + * @return array + */ + public static function loadEntriesFromStorage(FlexStorageInterface $storage): array + { + return $storage->getExistingKeys(); + } + + /** + * Initializes a new FlexIndex. + * + * @param array $entries + * @param FlexDirectory|null $directory + */ + public function __construct(array $entries = [], FlexDirectory $directory = null) + { + parent::__construct($entries); + + $this->_flexDirectory = $directory; + $this->setKeyField(null); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::search() + */ + public function search(string $search, $properties = null, array $options = null) + { + return $this->__call('search', [$search, $properties, $options]); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::sort() + */ + public function sort(array $orderings) + { + return $this->orderBy($orderings); + } + + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::filterBy() + */ + public function filterBy(array $filters) + { + return $this->__call('filterBy', [$filters]); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexType() + */ + public function getFlexType(): string + { + return $this->_flexDirectory->getFlexType(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getFlexDirectory(): FlexDirectory + { + return $this->_flexDirectory; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getTimestamp() + */ + public function getTimestamp(): int + { + $timestamps = $this->getTimestamps(); + + return $timestamps ? max($timestamps) : time(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getCacheKey() + */ + public function getCacheKey(): string + { + return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->getKeys()) . $this->_keyField); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getCacheChecksum() + */ + public function getCacheChecksum(): string + { + return sha1($this->getCacheKey() . json_encode($this->getTimestamps())); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getTimestamps() + */ + public function getTimestamps(): array + { + return $this->getIndexMap('storage_timestamp'); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getStorageKeys() + */ + public function getStorageKeys(): array + { + return $this->getIndexMap('storage_key'); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexKeys() + */ + public function getFlexKeys(): array + { + // Get storage keys for the objects. + $keys = []; + $type = $this->_flexDirectory->getFlexType() . '.obj:'; + + foreach ($this->getEntries() as $key => $value) { + $keys[$key] = $value['flex_key'] ?? $type . $value['storage_key']; + } + + return $keys; + } + + /** + * {@inheritdoc} + * @see FlexIndexInterface::withKeyField() + */ + public function withKeyField(string $keyField = null) + { + $keyField = $keyField ?: 'key'; + if ($keyField === $this->getKeyField()) { + return $this; + } + + $type = $keyField === 'flex_key' ? $this->_flexDirectory->getFlexType() . '.obj:' : ''; + $entries = []; + foreach ($this->getEntries() as $key => $value) { + if (!isset($value['key'])) { + $value['key'] = $key; + } + + if (isset($value[$keyField])) { + $entries[$value[$keyField]] = $value; + } elseif ($keyField === 'flex_key') { + $entries[$type . $value['storage_key']] = $value; + } + } + + return $this->createFrom($entries, $keyField); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getIndex() + */ + public function getIndex() + { + return $this; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + return $this->__call('render', [$layout, $context]); + } + + /** + * @param bool $prefix + * @return string + * @deprecated 1.6 Use `->getFlexType()` instead. + */ + public function getType($prefix = false) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->getFlexType(); + } + + /** + * {@inheritdoc} + * @see FlexIndexInterface::getFlexKeys() + */ + public function getIndexMap(string $indexKey = null) + { + if (null === $indexKey) { + return $this->getEntries(); + } + + // Get storage keys for the objects. + $index = []; + foreach ($this->getEntries() as $key => $value) { + $index[$key] = $value[$indexKey] ?? null; + } + + return $index; + } + + /** + * @return array + */ + public function getMetaData(string $key): array + { + return $this->getEntries()[$key] ?? []; + } + + /** + * @return string + */ + public function getKeyField() : string + { + return $this->_keyField ?? 'storage_key'; + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + return $this->_flexDirectory->getCache($namespace); + } + + /** + * @param array $orderings + * @return FlexIndex|FlexCollection + */ + public function orderBy(array $orderings) + { + if (!$orderings || !$this->count()) { + return $this; + } + + // Check if ordering needs to load the objects. + if (array_diff_key($orderings, $this->getIndexKeys())) { + return $this->__call('orderBy', [$orderings]); + } + + // Ordering can be done by using index only. + $previous = null; + foreach (array_reverse($orderings) as $field => $ordering) { + $field = (string)$field; + if ($this->getKeyField() === $field) { + $keys = $this->getKeys(); + $search = array_combine($keys, $keys) ?: []; + } elseif ($field === 'flex_key') { + $search = $this->getFlexKeys(); + } else { + $search = $this->getIndexMap($field); + } + + // Update current search to match the previous ordering. + if (null !== $previous) { + $search = array_replace($previous, $search); + } + + // Order by current field. + if ($ordering === 'DESC') { + arsort($search, SORT_NATURAL); + } else { + asort($search, SORT_NATURAL); + } + + $previous = $search; + } + + return $this->createFrom(array_replace($previous, $this->getEntries())); + } + + /** + * {@inheritDoc} + */ + public function call($method, array $arguments = []) + { + return $this->__call('call', [$method, $arguments]); + } + + public function __call($name, $arguments) + { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + + /** @var FlexCollection $className */ + $className = $this->_flexDirectory->getCollectionClass(); + $cachedMethods = $className::getCachedMethods(); + + $flexType = $this->getFlexType(); + + if (!empty($cachedMethods[$name])) { + $type = $cachedMethods[$name]; + if ($type === 'session') { + /** @var Session $session */ + $session = Grav::instance()['session']; + $cacheKey = $session->getId() . $session->user->username; + } else { + $cacheKey = ''; + } + $key = "{$flexType}.idx." . sha1($name . '.' . $cacheKey . json_encode($arguments) . $this->getCacheKey()); + + $cache = $this->getCache('object'); + + try { + $result = $cache->get($key); + + // Make sure the keys aren't changed if the returned type is the same index type. + if ($result instanceof self && $flexType === $result->getFlexType()) { + $result = $result->withKeyField($this->getKeyField()); + } + } catch (InvalidArgumentException $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + } + + if (!isset($result)) { + $collection = $this->loadCollection(); + $result = $collection->{$name}(...$arguments); + + try { + // If flex collection is returned, convert it back to flex index. + if ($result instanceof FlexCollection) { + $cached = $result->getFlexDirectory()->getIndex($result->getKeys(), $this->getKeyField()); + } else { + $cached = $result; + } + + $cache->set($key, $cached); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + + // TODO: log error. + } + } + } else { + $collection = $this->loadCollection(); + $result = $collection->{$name}(...$arguments); + if (!isset($cachedMethods[$name])) { + $class = \get_class($collection); + $debugger->addMessage("Call '{$class}:{$name}()' isn't cached", 'debug'); + } + } + + return $result; + } + + /** + * @return string + */ + public function serialize() + { + return serialize(['type' => $this->getFlexType(), 'entries' => $this->getEntries()]); + } + + /** + * @param string $serialized + */ + public function unserialize($serialized) + { + $data = unserialize($serialized, ['allowed_classes' => false]); + + $this->_flexDirectory = Grav::instance()['flex_objects']->getDirectory($data['type']); + $this->setEntries($data['entries']); + } + + /** + * @param array $entries + * @param string $keyField + * @return static + */ + protected function createFrom(array $entries, string $keyField = null) + { + $index = new static($entries, $this->_flexDirectory); + $index->setKeyField($keyField ?? $this->_keyField); + + return $index; + } + + /** + * @param string|null $keyField + */ + protected function setKeyField(string $keyField = null) + { + $this->_keyField = $keyField ?? 'storage_key'; + } + + protected function getIndexKeys() + { + if (null === $this->_indexKeys) { + $entries = $this->getEntries(); + $first = reset($entries); + if ($first) { + $keys = array_keys($first); + $keys = array_combine($keys, $keys) ?: []; + } else { + $keys = []; + } + + $this->setIndexKeys($keys); + } + + return $this->_indexKeys; + } + + /** + * @param array $indexKeys + */ + protected function setIndexKeys(array $indexKeys) + { + // Add defaults. + $indexKeys += [ + 'key' => 'key', + 'storage_key' => 'storage_key', + 'storage_timestamp' => 'storage_timestamp', + 'flex_key' => 'flex_key' + ]; + + + $this->_indexKeys = $indexKeys; + } + + /** + * @return string + */ + protected function getTypePrefix() + { + return 'i.'; + } + + /** + * @param string $key + * @param mixed $value + * @return ObjectInterface|null + */ + protected function loadElement($key, $value): ?ObjectInterface + { + $objects = $this->_flexDirectory->loadObjects([$key => $value]); + + return $objects ? reset($objects): null; + } + + /** + * @param array|null $entries + * @return ObjectInterface[] + */ + protected function loadElements(array $entries = null): array + { + return $this->_flexDirectory->loadObjects($entries ?? $this->getEntries()); + } + + /** + * @param array|null $entries + * @return ObjectCollectionInterface + */ + protected function loadCollection(array $entries = null): CollectionInterface + { + return $this->_flexDirectory->loadCollection($entries ?? $this->getEntries(), $this->_keyField); + } + + /** + * @param mixed $value + * @return bool + */ + protected function isAllowedElement($value): bool + { + return $value instanceof FlexObject; + } + + /** + * @param FlexObjectInterface $object + * @return mixed + */ + protected function getElementMeta($object) + { + return $object->getTimestamp(); + } + + /** + * @param FlexStorageInterface $storage + * @param array $index Saved index + * @param array $entries Updated index + * @return array Compiled list of entries + */ + protected static function updateIndexFile(FlexStorageInterface $storage, array $index, array $entries): array + { + // Calculate removed objects. + $removed = array_diff_key($index, $entries); + + // First get rid of all removed objects. + if ($removed) { + $index = array_diff_key($index, $removed); + } + + if ($entries) { + // Calculate difference between saved index and current data. + foreach ($index as $key => $entry) { + $storage_key = $entry['storage_key'] ?? null; + if (isset($entries[$storage_key]) && $entries[$storage_key]['storage_timestamp'] === $entry['storage_timestamp']) { + // Entry is up to date, no update needed. + unset($entries[$storage_key]); + } + } + + if (empty($entries) && empty($removed)) { + // No objects were added, updated or removed. + return $index; + } + } elseif (!$removed) { + // There are no objects and nothing was removed. + return []; + } + + // Index should be updated, lock the index file for saving. + $indexFile = static::getIndexFile($storage); + $indexFile->lock(); + + // Read all the data rows into an array. + $keys = array_fill_keys(array_keys($entries), null); + $rows = $storage->readRows($keys); + + $keyField = $storage->getKeyField(); + + // Go through all the updated objects and refresh their index data. + $updated = $added = []; + foreach ($rows as $key => $row) { + if (null !== $row) { + $entry = ['key' => $key] + $entries[$key]; + if ($keyField !== 'storage_key' && isset($row[$keyField])) { + $entry['key'] = $row[$keyField]; + } + static::updateIndexData($entry, $row); + if (isset($row['__error'])) { + $entry['__error'] = true; + static::onException(new \RuntimeException(sprintf('Object failed to load: %s (%s)', $key, $row['__error']))); + } + if (isset($index[$key])) { + // Update object in the index. + $updated[$key] = $entry; + } else { + // Add object into the index. + $added[$key] = $entry; + } + + // Either way, update the entry. + $index[$key] = $entry; + } elseif (isset($index[$key])) { + // Remove object from the index. + $removed[$key] = $index[$key]; + unset($index[$key]); + } + } + + // Sort the index before saving it. + ksort($index, SORT_NATURAL); + + static::onChanges($index, $added, $updated, $removed); + + $indexFile->save(['count' => \count($index), 'index' => $index]); + $indexFile->unlock(); + + return $index; + } + + protected static function updateIndexData(array &$entry, array $data) + { + } + + protected static function loadEntriesFromIndex(FlexStorageInterface $storage) + { + $indexFile = static::getIndexFile($storage); + + $data = []; + try { + $data = (array)$indexFile->content(); + } catch (\Exception $e) { + $e = new \RuntimeException(sprintf('Index failed to load: %s', $e->getMessage()), $e->getCode(), $e); + + static::onException($e); + } + + return $data['index'] ?? []; + } + + protected static function getIndexFile(FlexStorageInterface $storage) + { + // Load saved index file. + $grav = Grav::instance(); + $locator = $grav['locator']; + $filename = $locator->findResource($storage->getStoragePath() . '/index.yaml', true, true); + + return CompiledYamlFile::instance($filename); + } + + protected static function onException(\Exception $e) + { + $grav = Grav::instance(); + + /** @var Logger $logger */ + $logger = $grav['log']; + $logger->addAlert($e->getMessage()); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addException($e); + $debugger->addMessage($e, 'error'); + } + + protected static function onChanges(array $entries, array $added, array $updated, array $removed) + { + $message = sprintf('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'); + } + + public function __debugInfo() + { + return [ + 'type:private' => $this->getFlexType(), + 'key:private' => $this->getKey(), + 'entries_key:private' => $this->getKeyField(), + 'entries:private' => $this->getEntries() + ]; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexObject.php b/system/src/Grav/Framework/Flex/FlexObject.php new file mode 100644 index 0000000..7fbdbc0 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexObject.php @@ -0,0 +1,877 @@ + true, + 'getType' => true, + 'getFlexType' => true, + 'getFlexDirectory' => true, + 'getCacheKey' => true, + 'getCacheChecksum' => true, + 'getTimestamp' => true, + 'value' => true, + 'exists' => true, + 'hasProperty' => true, + 'getProperty' => true, + + // FlexAclTrait + 'isAuthorized' => 'session', + ]; + } + + public static function createFromStorage(array $elements, array $storage, FlexDirectory $directory, bool $validate = false) + { + $instance = new static($elements, $storage['key'], $directory, $validate); + $instance->setStorage($storage); + + return $instance; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::__construct() + */ + public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false) + { + $this->_flexDirectory = $directory; + + if ($validate) { + $blueprint = $this->getFlexDirectory()->getBlueprint(); + + $blueprint->validate($elements); + + $elements = $blueprint->filter($elements); + } + + $this->filterElements($elements); + + $this->objectConstruct($elements, $key); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFlexType() + */ + public function getFlexType(): string + { + return $this->_flexDirectory->getFlexType(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFlexDirectory() + */ + public function getFlexDirectory(): FlexDirectory + { + return $this->_flexDirectory; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getTimestamp() + */ + public function getTimestamp(): int + { + return $this->_storage['storage_timestamp'] ?? 0; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getCacheKey() + */ + public function getCacheKey(): string + { + return $this->getTypePrefix() . $this->getFlexType() . '.' . $this->getStorageKey(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getCacheChecksum() + */ + public function getCacheChecksum(): string + { + return (string)$this->getTimestamp(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::search() + */ + public function search(string $search, $properties = null, array $options = null): float + { + $options = $options ?? $this->getFlexDirectory()->getConfig('data.search.options', []); + $properties = $properties ?? $this->getFlexDirectory()->getConfig('data.search.fields', []); + if (!$properties) { + foreach ($this->getFlexDirectory()->getConfig('admin.list.fields', []) as $property => $value) { + if (!empty($value['link'])) { + $properties[] = $property; + } + } + } + + $weight = 0; + foreach ((array)$properties as $property) { + $weight += $this->searchNestedProperty($property, $search, $options); + } + + return $weight > 0 ? min($weight, 1) : 0; + } + + /** + * {@inheritdoc} + * @see ObjectInterface::getFlexKey() + */ + public function getKey() + { + return $this->_key ?: $this->getFlexType() . '@@' . spl_object_hash($this); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFlexKey() + */ + public function getFlexKey(): string + { + return $this->_storage['flex_key'] ?? $this->_flexDirectory->getFlexType() . '.obj:' . $this->getStorageKey(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getStorageKey() + */ + public function getStorageKey(): string + { + return $this->_storage['storage_key'] ?? $this->getTypePrefix() . $this->getFlexType() . '@@' . spl_object_hash($this); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getMetaData() + */ + public function getMetaData(): array + { + return $this->getStorage(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::exists() + */ + public function exists(): bool + { + $key = $this->getStorageKey(); + + return $key && $this->getFlexDirectory()->getStorage()->hasKey($key); + } + + /** + * @param string $property + * @param string $search + * @param array|null $options + * @return float + */ + public function searchProperty(string $property, string $search, array $options = null): float + { + $options = $options ?? $this->getFlexDirectory()->getConfig('data.search.options', []); + $value = $this->getProperty($property); + + return $this->searchValue($property, $value, $search, $options); + } + + /** + * @param string $property + * @param string $search + * @param array|null $options + * @return float + */ + public function searchNestedProperty(string $property, string $search, array $options = null): float + { + $options = $options ?? $this->getFlexDirectory()->getConfig('data.search.options', []); + $value = $this->getNestedProperty($property); + + return $this->searchValue($property, $value, $search, $options); + } + + /** + * @param string $name + * @param mixed $value + * @param string $search + * @param array|null $options + * @return float + */ + protected function searchValue(string $name, $value, string $search, array $options = null): float + { + $search = trim($search); + + if ($search === '') { + return 0; + } + + if (!\is_string($value) || $value === '') { + return 0; + } + + $tested = false; + if (($tested |= !empty($options['starts_with'])) && Utils::startsWith($value, $search, $options['case_sensitive'] ?? false)) { + return (float)$options['starts_with']; + } + if (($tested |= !empty($options['ends_with'])) && Utils::endsWith($value, $search, $options['case_sensitive'] ?? false)) { + return (float)$options['ends_with']; + } + if ((!$tested || !empty($options['contains'])) && Utils::contains($value, $search, $options['case_sensitive'] ?? false)) { + return (float)($options['contains'] ?? 1); + } + + return 0; + } + + /** + * Get any changes based on data sent to update + * + * @return array + */ + public function getChanges(): array + { + return $this->_changes ?? []; + } + + /** + * @return string + */ + protected function getTypePrefix(): string + { + return 'o.'; + } + + /** + * @param bool $prefix + * @return string + * @deprecated 1.6 Use `->getFlexType()` instead. + */ + public function getType($prefix = false) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->getFlexType(); + } + + /** + * Alias of getBlueprint() + * + * @return Blueprint + * @deprecated 1.6 Admin compatibility + */ + public function blueprints() + { + return $this->getBlueprint(); + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + return $this->_flexDirectory->getCache($namespace); + } + + /** + * @param string|null $key + * @return $this + */ + public function setStorageKey($key = null) + { + $this->_storage['storage_key'] = $key; + + return $this; + } + + /** + * @param int $timestamp + * @return $this + */ + public function setTimestamp($timestamp = null) + { + $this->_storage['storage_timestamp'] = $timestamp ?? time(); + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + if (null === $layout) { + $layout = 'default'; + } + + $type = $this->getFlexType(); + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->startTimer('flex-object-' . ($debugKey = uniqid($type, false)), 'Render Object ' . $type . ' (' . $layout . ')'); + + $cache = $key = null; + foreach ($context as $value) { + if (!\is_scalar($value)) { + $key = false; + } + } + + if ($key !== false) { + $key = md5($this->getCacheKey() . '.' . $layout . json_encode($context)); + $cache = $this->getCache('render'); + } + + try { + $data = $cache && $key ? $cache->get($key) : null; + + $block = $data ? HtmlBlock::fromArray($data) : null; + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + + $block = null; + } catch (\InvalidArgumentException $e) { + $debugger->addException($e); + + $block = null; + } + + $checksum = $this->getCacheChecksum(); + if ($block && $checksum !== $block->getChecksum()) { + $block = null; + } + + if (!$block) { + $block = HtmlBlock::create($key ?: null); + $block->setChecksum($checksum); + if ($key === false) { + $block->disableCache(); + } + + $grav->fireEvent('onFlexObjectRender', new Event([ + 'object' => $this, + 'layout' => &$layout, + 'context' => &$context + ])); + + $output = $this->getTemplate($layout)->render( + ['grav' => $grav, 'config' => $grav['config'], 'block' => $block, 'object' => $this, 'layout' => $layout] + $context + ); + + if ($debugger->enabled()) { + $name = $this->getKey() . ' (' . $type . ')'; + $output = "\n\n{$output}\n\n"; + } + + $block->setContent($output); + + try { + $cache && $key && $block->isCached() && $cache->set($key, $block->toArray()); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + } + + $debugger->stopTimer('flex-object-' . $debugKey); + + return $block; + } + + /** + * @return array + */ + public function jsonSerialize() + { + return $this->getElements(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::prepareStorage() + */ + public function prepareStorage(): array + { + return $this->getElements(); + } + + /** + * @param string $name + * @return $this + */ + public function triggerEvent($name) + { + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::update() + */ + public function update(array $data, array $files = []) + { + if ($data) { + $blueprint = $this->getBlueprint(); + + // Process updated data through the object filters. + $this->filterElements($data); + + // Get currently stored data. + $elements = $this->getElements(); + + // Merge existing object to the test data to be validated. + $test = $blueprint->mergeData($elements, $data); + + // Validate and filter elements and throw an error if any issues were found. + $blueprint->validate($test + ['storage_key' => $this->getStorageKey(), 'timestamp' => $this->getTimestamp()]); + $data = $blueprint->filter($data, false, true); + + // Finally update the object. + foreach ($blueprint->flattenData($data) as $key => $value) { + if ($value === null) { + $this->unsetNestedProperty($key); + } else { + $this->setNestedProperty($key, $value); + } + } + + // Store the changes + $this->_changes = Utils::arrayDiffMultidimensional($this->getElements(), $elements); + } + + if ($files && method_exists($this, 'setUpdatedMedia')) { + $this->setUpdatedMedia($files); + } + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::create() + */ + public function create(string $key = null) + { + if ($key) { + $this->setStorageKey($key); + } + + if ($this->exists()) { + throw new \RuntimeException('Cannot create new object (Already exists)'); + } + + return $this->save(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::save() + */ + public function save() + { + $this->triggerEvent('onBeforeSave'); + + $result = $this->getFlexDirectory()->getStorage()->replaceRows([$this->getStorageKey() => $this->prepareStorage()]); + + $value = reset($result); + $storageKey = (string)key($result); + if ($value && $storageKey) { + $this->setStorageKey($storageKey); + if (!$this->hasKey()) { + $this->setKey($storageKey); + } + } + + // FIXME: For some reason locator caching isn't cleared for the file, investigate! + $locator = Grav::instance()['locator']; + $locator->clearCache(); + + // Make sure that the object exists before continuing (just in case). + if (!$this->exists()) { + throw new \RuntimeException('Saving failed: Object does not exist!'); + } + + if (method_exists($this, 'saveUpdatedMedia')) { + $this->saveUpdatedMedia(); + } + + try { + $this->getFlexDirectory()->clearCache(); + if (method_exists($this, 'clearMediaCache')) { + $this->clearMediaCache(); + } + } catch (\Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + $this->triggerEvent('onAfterSave'); + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::delete() + */ + public function delete() + { + $this->triggerEvent('onBeforeDelete'); + + $this->getFlexDirectory()->getStorage()->deleteRows([$this->getStorageKey() => $this->prepareStorage()]); + + try { + $this->getFlexDirectory()->clearCache(); + if (method_exists($this, 'clearMediaCache')) { + $this->clearMediaCache(); + } + } catch (\Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + $this->triggerEvent('onAfterDelete'); + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getBlueprint() + */ + public function getBlueprint(string $name = '') + { + return $this->_flexDirectory->getBlueprint($name ? '.' . $name : $name); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getForm() + */ + public function getForm(string $name = '', array $form = null) + { + if (!isset($this->_forms[$name])) { + $this->_forms[$name] = $this->createFormObject($name, $form); + } + + return $this->_forms[$name]; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getDefaultValue() + */ + public function getDefaultValue(string $name, string $separator = null) + { + $separator = $separator ?: '.'; + $path = explode($separator, $name) ?: []; + $offset = array_shift($path) ?? ''; + + $current = $this->getDefaultValues(); + + if (!isset($current[$offset])) { + return null; + } + + $current = $current[$offset]; + + while ($path) { + $offset = array_shift($path); + + if ((\is_array($current) || $current instanceof \ArrayAccess) && isset($current[$offset])) { + $current = $current[$offset]; + } elseif (\is_object($current) && isset($current->{$offset})) { + $current = $current->{$offset}; + } else { + return null; + } + }; + + return $current; + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->getBlueprint()->getDefaults(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFormValue() + */ + public function getFormValue(string $name, $default = null, string $separator = null) + { + if ($name === 'storage_key') { + return $this->getStorageKey(); + } + if ($name === 'storage_timestamp') { + return $this->getTimestamp(); + } + + return $this->getNestedProperty($name, $default, $separator); + } + + /** + * @param string $name + * @param mixed|null $default + * @param string|null $separator + * @return mixed + * + * @deprecated 1.6 Use ->getFormValue() method instead. + */ + public function value($name, $default = null, $separator = null) + { + return $this->getFormValue($name, $default, $separator); + } + + /** + * Returns a string representation of this object. + * + * @return string + */ + public function __toString() + { + return $this->getFlexKey(); + } + + public function __debugInfo() + { + return [ + 'type:private' => $this->getFlexType(), + 'key:private' => $this->getKey(), + 'elements:private' => $this->getElements(), + 'storage:private' => $this->getStorage() + ]; + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return [ + 'type' => $this->getFlexType(), + 'key' => $this->getKey(), + 'elements' => $this->getElements(), + 'storage' => $this->getStorage() + ]; + } + + /** + * @param array $serialized + */ + protected function doUnserialize(array $serialized): void + { + $type = $serialized['type'] ?? 'unknown'; + + if (!isset($serialized['key'], $serialized['type'], $serialized['elements'])) { + throw new \InvalidArgumentException("Cannot unserialize '{$type}': Bad data"); + } + + $grav = Grav::instance(); + /** @var Flex|null $flex */ + $flex = $grav['flex_objects'] ?? null; + $directory = $flex ? $flex->getDirectory($type) : null; + if (!$directory) { + throw new \InvalidArgumentException("Cannot unserialize '{$type}': Not found"); + } + $this->setFlexDirectory($directory); + $this->setStorage($serialized['storage']); + $this->setKey($serialized['key']); + $this->setElements($serialized['elements']); + } + + /** + * @param FlexDirectory $directory + */ + public function setFlexDirectory(FlexDirectory $directory): void + { + $this->_flexDirectory = $directory; + } + /** + * @param array $storage + */ + protected function setStorage(array $storage) : void + { + $this->_storage = $storage; + } + + /** + * @return array + */ + protected function getStorage() : array + { + return $this->_storage ?? []; + } + + /** + * @param string $type + * @param string $property + * @return FlexCollectionInterface + */ + protected function getCollectionByProperty($type, $property) + { + $directory = $this->getRelatedDirectory($type); + $collection = $directory->getCollection(); + $list = $this->getNestedProperty($property) ?: []; + + /** @var FlexCollection $collection */ + $collection = $collection->filter(function ($object) use ($list) { return \in_array($object->id, $list, true); }); + + return $collection; + } + + /** + * @param string $type + * @return FlexDirectory + * @throws \RuntimeException + */ + protected function getRelatedDirectory($type): FlexDirectory + { + /** @var Flex $flex */ + $flex = Grav::instance()['flex_objects']; + $directory = $flex->getDirectory($type); + if (!$directory) { + throw new \RuntimeException(ucfirst($type). ' directory does not exist!'); + } + + return $directory; + } + + /** + * @param string $layout + * @return Template|TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + try { + return $twig->twig()->resolveTemplate( + [ + "flex-objects/layouts/{$this->getFlexType()}/object/{$layout}.html.twig", + "flex-objects/layouts/_default/object/{$layout}.html.twig" + ] + ); + } catch (LoaderError $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + return $twig->twig()->resolveTemplate(['flex-objects/layouts/404.html.twig']); + } + } + + /** + * Filter data coming to constructor or $this->update() request. + * + * NOTE: The incoming data can be an arbitrary array so do not assume anything from its content. + * + * @param array $elements + */ + protected function filterElements(array &$elements): void + { + if (!empty($elements['storage_key'])) { + $this->_storage['storage_key'] = trim($elements['storage_key']); + } + if (!empty($elements['storage_timestamp'])) { + $this->_storage['storage_timestamp'] = (int)$elements['storage_timestamp']; + } + + unset ($elements['storage_key'], $elements['storage_timestamp'], $elements['_post_entries_save']); + } + + /** + * This methods allows you to override form objects in child classes. + * + * @param string $name Form name + * @param array|null $form Form fields + * @return FlexFormInterface + */ + protected function createFormObject(string $name, array $form = null) + { + return new FlexForm($name, $this, $form); + } +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php new file mode 100644 index 0000000..cfcb8a9 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php @@ -0,0 +1,32 @@ + 'ASC'|'DESC', ...]. + * + * @return FlexCollectionInterface Returns a sorted version from the collection. + */ + public function sort(array $orderings); + + /** + * Filter collection by filter array with keys and values. + * + * @param array $filters + * @return FlexCollectionInterface + */ + public function filterBy(array $filters); + + /** + * Get timestamps from all the objects in the collection. + * + * This method can be used for example in caching. + * + * @return int[] Returns [key => timestamp, ...] pairs. + */ + public function getTimestamps(): array; + + /** + * Get storage keys from all the objects in the collection. + * + * @see FlexDirectory::getObject() If you want to get Flex Object from the Flex Directory. + * + * @return string[] Returns [key => storage_key, ...] pairs. + */ + public function getStorageKeys(): array; + + /** + * Get Flex keys from all the objects in the collection. + * + * @see Flex::getObjects() If you want to get list of Flex Objects from any Flex Directory. + * + * @return string[] Returns[key => flex_key, ...] pairs. + */ + public function getFlexKeys(): array; + + /** + * Return new collection with a different key. + * + * @param string|null $keyField Switch key field of the collection. + * + * @return FlexCollectionInterface Returns a new Flex Collection with new key field. + * @api + */ + public function withKeyField(string $keyField = null); + + /** + * Get Flex Index from the Flex Collection. + * + * @return FlexIndexInterface Returns a Flex Index from the current collection. + */ + public function getIndex(); +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php new file mode 100644 index 0000000..482aef2 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php @@ -0,0 +1,64 @@ + [storage_key => xxx, storage_timestamp => 123456, ...]] + */ + public static function loadEntriesFromStorage(FlexStorageInterface $storage): array; + + /** + * Return new collection with a different key. + * + * @param string|null $keyField Switch key field of the collection. + * + * @return FlexIndexInterface Returns a new Flex Collection with new key field. + * @api + */ + public function withKeyField(string $keyField = null); + + /** + * @param string $indexKey + * @return array + */ + public function getIndexMap(string $indexKey = null); +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexObjectInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexObjectInterface.php new file mode 100644 index 0000000..a10ee84 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexObjectInterface.php @@ -0,0 +1,208 @@ + [storage_key => key, storage_timestamp => timestamp], ...]`. + */ + public function getExistingKeys(): array; + + /** + * Check if the key exists in the storage. + * + * @param string $key Storage key of an object. + * + * @return bool Returns `true` if the key exists in the storage, `false` otherwise. + */ + public function hasKey(string $key): bool; + + /** + * Check if the key exists in the storage. + * + * @param string[] $keys Storage key of an object. + * + * @return bool[] Returns keys with `true` if the key exists in the storage, `false` otherwise. + */ + public function hasKeys(array $keys): array; + + /** + * Create new rows into the storage. + * + * New keys will be assigned when the objects are created. + * + * @param array $rows List of rows as `[row, ...]`. + * + * @return array Returns created rows as `[key => row, ...] pairs. + */ + public function createRows(array $rows): array; + + /** + * Read rows from the storage. + * + * If you pass object or array as value, that value will be used to save I/O. + * + * @param array $rows Array of `[key => row, ...]` pairs. + * @param array $fetched Optional reference to store only fetched items. + * + * @return array Returns rows. Note that non-existing rows will have `null` as their value. + */ + public function readRows(array $rows, array &$fetched = null): array; + + /** + * Update existing rows in the storage. + * + * @param array $rows Array of `[key => row, ...]` pairs. + * + * @return array Returns updated rows. Note that non-existing rows will not be saved and have `null` as their value. + */ + public function updateRows(array $rows): array; + + /** + * Delete rows from the storage. + * + * @param array $rows Array of `[key => row, ...]` pairs. + * + * @return array Returns deleted rows. Note that non-existing rows have `null` as their value. + */ + public function deleteRows(array $rows): array; + + /** + * Replace rows regardless if they exist or not. + * + * All rows should have a specified key for replace to work properly. + * + * @param array $rows Array of `[key => row, ...]` pairs. + * + * @return array Returns both created and updated rows. + */ + public function replaceRows(array $rows): array; + + /** + * @param string $src + * @param string $dst + * + * @return bool + */ + public function renameRow(string $src, string $dst): bool; + + /** + * Get filesystem path for the collection or object storage. + * + * @param string|null $key Optional storage key. + * + * @return string Path in the filesystem. Can be URI. + */ + public function getStoragePath(string $key = null): string; + + /** + * Get filesystem path for the collection or object media. + * + * @param string|null $key Optional storage key. + * + * @return string Path in the filesystem. Can be URI. + */ + public function getMediaPath(string $key = null): string; +} diff --git a/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php b/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php new file mode 100644 index 0000000..cc460cc --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php @@ -0,0 +1,154 @@ +hasKey((string)$key); + } + + return $list; + } + + /** + * @return string + */ + public function getKeyField(): string + { + return $this->keyField; + } + + protected function initDataFormatter($formatter): void + { + // Initialize formatter. + if (!\is_array($formatter)) { + $formatter = ['class' => $formatter]; + } + $formatterClassName = $formatter['class'] ?? JsonFormatter::class; + $formatterOptions = $formatter['options'] ?? []; + + $this->dataFormatter = new $formatterClassName($formatterOptions); + } + + /** + * @param string $filename + * @return null|string + */ + protected function detectDataFormatter(string $filename): ?string + { + if (preg_match('|(\.[a-z0-9]*)$|ui', $filename, $matches)) { + switch ($matches[1]) { + case '.json': + return JsonFormatter::class; + case '.yaml': + return YamlFormatter::class; + case '.md': + return MarkdownFormatter::class; + } + } + + return null; + } + + /** + * @param string $filename + * @return File + */ + protected function getFile(string $filename) + { + $filename = $this->resolvePath($filename); + + switch ($this->dataFormatter->getDefaultFileExtension()) { + case '.json': + $file = CompiledJsonFile::instance($filename); + break; + case '.yaml': + $file = CompiledYamlFile::instance($filename); + break; + case '.md': + $file = CompiledMarkdownFile::instance($filename); + break; + default: + throw new RuntimeException('Unknown extension type ' . $this->dataFormatter->getDefaultFileExtension()); + } + + return $file; + } + + /** + * @param string $path + * @return string + */ + protected function resolvePath(string $path): string + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + if (!$locator->isStream($path)) { + return $path; + } + + return (string)($locator->findResource($path) ?: $locator->findResource($path, true, true)); + } + + /** + * Generates a random, unique key for the row. + * + * @return string + */ + protected function generateKey(): string + { + return substr(hash('sha256', random_bytes(32)), 0, 32); + } + + /** + * Checks if a key is valid. + * + * @param string $key + * @return bool + */ + protected function validateKey(string $key): bool + { + return (bool) preg_match('/^[^\\/\\?\\*:;{}\\\\\\n]+$/u', $key); + } +} diff --git a/system/src/Grav/Framework/Flex/Storage/FileStorage.php b/system/src/Grav/Framework/Flex/Storage/FileStorage.php new file mode 100644 index 0000000..e263e6c --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/FileStorage.php @@ -0,0 +1,82 @@ +dataPattern = '{FOLDER}/{KEY}'; + + if (!isset($options['formatter']) && isset($options['pattern'])) { + $options['formatter'] = $this->detectDataFormatter($options['pattern']); + } + + parent::__construct($options); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getMediaPath() + */ + public function getMediaPath(string $key = null): string + { + return $key ? \dirname($this->getStoragePath($key)) . '/' . $key : $this->getStoragePath(); + } + + /** + * {@inheritdoc} + */ + protected function getKeyFromPath(string $path): string + { + return basename($path, $this->dataFormatter->getDefaultFileExtension()); + } + + /** + * {@inheritdoc} + */ + protected function buildIndex(): array + { + if (!file_exists($this->getStoragePath())) { + return []; + } + + $flags = \FilesystemIterator::KEY_AS_PATHNAME | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS; + $iterator = new \FilesystemIterator($this->getStoragePath(), $flags); + $list = []; + /** @var \SplFileInfo $info */ + foreach ($iterator as $filename => $info) { + if (!$info->isFile() || !($key = $this->getKeyFromPath($filename)) || strpos($info->getFilename(), '.') === 0) { + continue; + } + + $list[$key] = [ + 'storage_key' => $key, + 'storage_timestamp' => $info->getMTime() + ]; + } + + ksort($list, SORT_NATURAL); + + return $list; + } +} diff --git a/system/src/Grav/Framework/Flex/Storage/FolderStorage.php b/system/src/Grav/Framework/Flex/Storage/FolderStorage.php new file mode 100644 index 0000000..76397c2 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/FolderStorage.php @@ -0,0 +1,470 @@ +initDataFormatter($options['formatter'] ?? []); + $this->initOptions($options); + + // Make sure that the data folder exists. + $folder = $this->resolvePath($this->dataFolder); + if (!file_exists($folder)) { + try { + Folder::create($folder); + } catch (\RuntimeException $e) { + throw new \RuntimeException(sprintf('Flex: %s', $e->getMessage())); + } + } + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getExistingKeys() + */ + public function getExistingKeys(): array + { + return $this->buildIndex(); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::hasKey() + */ + public function hasKey(string $key): bool + { + return $key && strpos($key, '@@') === false && file_exists($this->getPathFromKey($key)); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::createRows() + */ + public function createRows(array $rows): array + { + $list = []; + foreach ($rows as $key => $row) { + // Create new file and save it. + $key = $this->getNewKey(); + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + $list[$key] = $this->saveFile($file, $row); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::readRows() + */ + public function readRows(array $rows, array &$fetched = null): array + { + $list = []; + foreach ($rows as $key => $row) { + if (null === $row || (!\is_object($row) && !\is_array($row))) { + // Only load rows which haven't been loaded before. + $key = (string)$key; + if (!$this->hasKey($key)) { + $list[$key] = null; + } else { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + $list[$key] = $this->loadFile($file); + } + if (null !== $fetched) { + $fetched[$key] = $list[$key]; + } + } else { + // Keep the row if it has been loaded. + $list[$key] = $row; + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::updateRows() + */ + public function updateRows(array $rows): array + { + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + if (!$this->hasKey($key)) { + $list[$key] = null; + } else { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + $list[$key] = $this->saveFile($file, $row); + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::deleteRows() + */ + public function deleteRows(array $rows): array + { + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + if (!$this->hasKey($key)) { + $list[$key] = null; + } else { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + $list[$key] = $this->deleteFile($file); + + $storage = $this->getStoragePath($key); + $media = $this->getMediaPath($key); + + $this->deleteFolder($storage, true); + $media && $this->deleteFolder($media, true); + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::replaceRows() + */ + public function replaceRows(array $rows): array + { + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + if (strpos($key, '@@')) { + $key = $this->getNewKey(); + } + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + $list[$key] = $this->saveFile($file, $row); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::renameRow() + */ + public function renameRow(string $src, string $dst): bool + { + if ($this->hasKey($dst)) { + throw new \RuntimeException("Cannot rename object: key '{$dst}' is already taken"); + } + + if (!$this->hasKey($src)) { + return false; + } + + return $this->moveFolder($this->getMediaPath($src), $this->getMediaPath($dst)); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getStoragePath() + */ + public function getStoragePath(string $key = null): string + { + if (null === $key) { + $path = $this->dataFolder; + } else { + $path = sprintf($this->dataPattern, $this->dataFolder, $key, substr($key, 0, 2)); + } + + return $path; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getMediaPath() + */ + public function getMediaPath(string $key = null): string + { + return null !== $key ? \dirname($this->getStoragePath($key)) : $this->getStoragePath(); + } + + /** + * Get filesystem path from the key. + * + * @param string $key + * @return string + */ + public function getPathFromKey(string $key): string + { + return sprintf($this->dataPattern, $this->dataFolder, $key, substr($key, 0, 2)); + } + + /** + * @param File $file + * @return array|null + */ + protected function loadFile(File $file): ?array + { + if (!$file->exists()) { + return null; + } + + try { + $content = (array)$file->content(); + if (isset($content[0])) { + throw new \RuntimeException('Broken object file.'); + } + } catch (\RuntimeException $e) { + $content = ['__error' => $e->getMessage()]; + } + + return $content; + } + + /** + * @param File $file + * @param array $data + * @return array + */ + protected function saveFile(File $file, array $data): array + { + try { + $file->save($data); + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($file->filename())) { + $locator->clearCache($file->filename()); + } + } catch (\RuntimeException $e) { + throw new \RuntimeException(sprintf('Flex saveFile(%s): %s', $file->filename(), $e->getMessage())); + } + + return $data; + } + + /** + * @param File $file + * @return array|string + */ + protected function deleteFile(File $file) + { + try { + $data = $file->content(); + $file->delete(); + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($file->filename())) { + $locator->clearCache($file->filename()); + } + } catch (\RuntimeException $e) { + throw new \RuntimeException(sprintf('Flex deleteFile(%s): %s', $file->filename(), $e->getMessage())); + } + + return $data; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + protected function moveFolder(string $src, string $dst): bool + { + try { + Folder::move($this->resolvePath($src), $this->resolvePath($dst)); + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($src) || $locator->isStream($dst)) { + $locator->clearCache(); + } + } catch (\RuntimeException $e) { + throw new \RuntimeException(sprintf('Flex moveFolder(%s, %s): %s', $src, $dst, $e->getMessage())); + } + + return true; + } + + /** + * @param string $path + * @param bool $include_target + * @return bool + */ + protected function deleteFolder(string $path, bool $include_target = false): bool + { + try { + $success = Folder::delete($this->resolvePath($path), $include_target); + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($path)) { + $locator->clearCache(); + } + + return $success; + } catch (\RuntimeException $e) { + throw new \RuntimeException(sprintf('Flex deleteFolder(%s): %s', $path, $e->getMessage())); + } + } + + /** + * Get key from the filesystem path. + * + * @param string $path + * @return string + */ + protected function getKeyFromPath(string $path): string + { + return basename($path); + } + + /** + * Returns list of all stored keys in [key => timestamp] pairs. + * + * @return array + */ + protected function buildIndex(): array + { + $path = $this->getStoragePath(); + if (!file_exists($path)) { + return []; + } + + if ($this->prefixed) { + $list = $this->buildPrefixedIndexFromFilesystem($path); + } else { + $list = $this->buildIndexFromFilesystem($path); + } + + ksort($list, SORT_NATURAL); + + return $list; + } + + protected function buildIndexFromFilesystem($path) + { + $flags = \FilesystemIterator::KEY_AS_PATHNAME | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS; + + $iterator = new \FilesystemIterator($path, $flags); + $list = []; + /** @var \SplFileInfo $info */ + foreach ($iterator as $filename => $info) { + if (!$info->isDir()) { + continue; + } + + $key = $this->getKeyFromPath($filename); + $filename = $this->getPathFromKey($key); + $modified = is_file($filename) ? filemtime($filename) : null; + if (null === $modified) { + continue; + } + + $list[$key] = [ + 'storage_key' => $key, + 'storage_timestamp' => $modified + ]; + } + + return $list; + } + + protected function buildPrefixedIndexFromFilesystem($path) + { + $flags = \FilesystemIterator::KEY_AS_PATHNAME | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS; + + $iterator = new \FilesystemIterator($path, $flags); + $list = []; + /** @var \SplFileInfo $info */ + foreach ($iterator as $filename => $info) { + if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) { + continue; + } + + $list[] = $this->buildIndexFromFilesystem($filename); + } + + if (!$list) { + return []; + } + + return \count($list) > 1 ? array_merge(...$list) : $list[0]; + } + + /** + * @return string + */ + protected function getNewKey(): string + { + // Make sure that the file doesn't exist. + do { + $key = $this->generateKey(); + } while (file_exists($this->getPathFromKey($key))); + + return $key; + } + + /** + * @param array $options + */ + protected function initOptions(array $options): void + { + $extension = $this->dataFormatter->getDefaultFileExtension(); + + /** @var string $pattern */ + $pattern = !empty($options['pattern']) ? $options['pattern'] : $this->dataPattern; + + $this->dataFolder = $options['folder']; + $this->prefixed = (bool)($options['prefixed'] ?? strpos($pattern, '/{KEY:2}/')); + $this->indexed = (bool)($options['indexed'] ?? false); + $this->keyField = $options['key'] ?? 'storage_key'; + + $pattern = preg_replace(['/{FOLDER}/', '/{KEY}/', '/{KEY:2}/'], ['%1$s', '%2$s', '%3$s'], $pattern); + $this->dataPattern = \dirname($pattern) . '/' . basename($pattern, $extension) . $extension; + } +} diff --git a/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php b/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php new file mode 100644 index 0000000..088b952 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php @@ -0,0 +1,323 @@ +detectDataFormatter($options['folder']); + $this->initDataFormatter($formatter); + + $extension = $this->dataFormatter->getDefaultFileExtension(); + $pattern = basename($options['folder']); + + $this->dataPattern = basename($pattern, $extension) . $extension; + $this->dataFolder = \dirname($options['folder']); + + // Make sure that the data folder exists. + if (!file_exists($this->dataFolder)) { + try { + Folder::create($this->dataFolder); + } catch (\RuntimeException $e) { + throw new \RuntimeException(sprintf('Flex: %s', $e->getMessage())); + } + } + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getExistingKeys() + */ + public function getExistingKeys(): array + { + return $this->buildIndex(); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::hasKey() + */ + public function hasKey(string $key): bool + { + if (null === $this->data) { + $this->buildIndex(); + } + + return $key && strpos($key, '@@') === false && isset($this->data[$key]); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::createRows() + */ + public function createRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + $key = $this->getNewKey(); + $this->data[$key] = $list[$key] = $row; + } + + if ($list) { + $this->save(); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::readRows() + */ + public function readRows(array $rows, array &$fetched = null): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + if (null === $row || (!\is_object($row) && !\is_array($row))) { + // Only load rows which haven't been loaded before. + $key = (string)$key; + $list[$key] = $this->hasKey($key) ? $this->data[$key] : null; + if (null !== $fetched) { + $fetched[$key] = $list[$key]; + } + } else { + // Keep the row if it has been loaded. + $list[$key] = $row; + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::updateRows() + */ + public function updateRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + if ($this->hasKey($key)) { + $this->data[$key] = $list[$key] = $row; + } + } + + if ($list) { + $this->save(); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::deleteRows() + */ + public function deleteRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + if ($this->hasKey($key)) { + unset($this->data[$key]); + $list[$key] = $row; + } + } + + if ($list) { + $this->save(); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::replaceRows() + */ + public function replaceRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + if (strpos($key, '@@')) { + $key = $this->getNewKey(); + } + $this->data[$key] = $list[$key] = $row; + } + + if ($list) { + $this->save(); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::renameRow() + */ + public function renameRow(string $src, string $dst): bool + { + if (null === $this->data) { + $this->buildIndex(); + } + + if ($this->hasKey($dst)) { + throw new \RuntimeException("Cannot rename object: key '{$dst}' is already taken"); + } + + if (!$this->hasKey($src)) { + return false; + } + + // Change single key in the array without changing the order or value. + $keys = array_keys($this->data); + $keys[array_search($src, $keys, true)] = $dst; + + $data = array_combine($keys, $this->data); + if (false === $data) { + throw new \LogicException('Bad data'); + } + + $this->data = $data; + + return true; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getStoragePath() + */ + public function getStoragePath(string $key = null): string + { + return $this->dataFolder . '/' . $this->dataPattern; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getMediaPath() + */ + public function getMediaPath(string $key = null): string + { + return sprintf('%s/%s/%s', $this->dataFolder, basename($this->dataPattern, $this->dataFormatter->getDefaultFileExtension()), $key); + } + + protected function save() : void + { + if (null === $this->data) { + $this->buildIndex(); + } + + try { + $file = $this->getFile($this->getStoragePath()); + $file->save($this->data); + $file->free(); + } catch (\RuntimeException $e) { + throw new \RuntimeException(sprintf('Flex save(): %s', $e->getMessage())); + } + } + + /** + * Get key from the filesystem path. + * + * @param string $path + * @return string + */ + protected function getKeyFromPath(string $path): string + { + return basename($path); + } + + /** + * Returns list of all stored keys in [key => timestamp] pairs. + * + * @return array + */ + protected function buildIndex(): array + { + $file = $this->getFile($this->getStoragePath()); + $modified = $file->modified(); + + $this->data = (array) $file->content(); + + $list = []; + foreach ($this->data as $key => $info) { + $list[$key] = [ + 'storage_key' => $key, + 'storage_timestamp' => $modified + ]; + } + + return $list; + } + + /** + * @return string + */ + protected function getNewKey(): string + { + if (null === $this->data) { + $this->buildIndex(); + } + + // Make sure that the key doesn't exist. + do { + $key = $this->generateKey(); + } while (isset($this->data[$key])); + + return $key; + } +} diff --git a/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php new file mode 100644 index 0000000..e1b63d9 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php @@ -0,0 +1,60 @@ +isAuthorizedAction($user, $action, $scope) || $this->isAuthorizedSuperAdmin($user)); + } + + protected function isAuthorizedSuperAdmin(UserInterface $user): bool + { + return $user->authorize('admin.super'); + } + + protected function isAuthorizedAction(UserInterface $user, string $action, string $scope = null) : bool + { + $scope = $scope ?? isset(Grav::instance()['admin']) ? 'admin' : 'site'; + + if ($action === 'save' && $this instanceof FlexObjectInterface) { + $action = $this->exists() ? 'update' : 'create'; + } + + $directory = $this instanceof FlexDirectory ? $this : $this->getFlexDirectory(); + $config = $directory->getConfig(); + $allowed = $config->get("{$scope}.actions.{$action}") ?? $config->get("actions.{$action}") ?? true; + + return $allowed && $user->authorize(sprintf($this->_authorize, $scope, $action)); + } + + protected function setAuthorizeRule(string $authorize) : void + { + $this->_authorize = $authorize; + } +} diff --git a/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php new file mode 100644 index 0000000..c894634 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php @@ -0,0 +1,346 @@ + $this->getUpdatedMedia() + ]; + } + + /** + * @return string + */ + public function getStorageFolder() + { + return $this->exists() ? $this->getFlexDirectory()->getStorageFolder($this->getStorageKey()) : ''; + } + + /** + * @return string + */ + public function getMediaFolder() + { + return $this->exists() ? $this->getFlexDirectory()->getMediaFolder($this->getStorageKey()) : ''; + } + + /** + * @return MediaCollectionInterface + */ + public function getMedia() + { + if ($this->media === null) { + /** @var AbstractMedia $media */ + $media = $this->getExistingMedia(); + + // Include uploaded media to the object media. + /** @var FormFlashFile $upload */ + foreach ($this->getUpdatedMedia() as $filename => $upload) { + // Just make sure we do not include removed or moved media. + if ($upload && $upload->getError() === \UPLOAD_ERR_OK && !$upload->isMoved()) { + $media->add($filename, MediumFactory::fromUploadedFile($upload)); + } + } + + $media->setTimestamps(); + } + + return $this->media; + } + + public function checkUploadedMediaFile(UploadedFileInterface $uploadedFile) + { + $grav = Grav::instance(); + $language = $grav['language']; + + switch ($uploadedFile->getError()) { + case UPLOAD_ERR_OK: + break; + case UPLOAD_ERR_NO_FILE: + if ($uploadedFile instanceof FormFlashFile) { + break; + } + throw new RuntimeException($language->translate('PLUGIN_ADMIN.NO_FILES_SENT'), 400); + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + throw new RuntimeException($language->translate('PLUGIN_ADMIN.EXCEEDED_FILESIZE_LIMIT'), 400); + case UPLOAD_ERR_NO_TMP_DIR: + throw new RuntimeException($language->translate('PLUGIN_ADMIN.UPLOAD_ERR_NO_TMP_DIR'), 400); + default: + throw new RuntimeException($language->translate('PLUGIN_ADMIN.UNKNOWN_ERRORS'), 400); + } + + $filename = $uploadedFile->getClientFilename(); + + if (!Utils::checkFilename($filename)) { + throw new RuntimeException(sprintf($language->translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD'), $filename, 'Bad filename'), 400); + } + + $grav_limit = Utils::getUploadLimit(); + if ($grav_limit > 0 && $uploadedFile->getSize() > $grav_limit) { + throw new RuntimeException($language->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400); + } + + $this->checkMediaFilename($filename); + } + + public function checkMediaFilename(string $filename) + { + // Check the file extension. + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + // If not a supported type, return + if (!$extension || !$config->get("media.types.{$extension}")) { + $language = $grav['language']; + throw new RuntimeException($language->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400); + } + } + + public function uploadMediaFile(UploadedFileInterface $uploadedFile, string $filename = null): void + { + $this->checkUploadedMediaFile($uploadedFile); + + if ($filename) { + $this->checkMediaFilename(basename($filename)); + } else { + $filename = $uploadedFile->getClientFilename(); + } + + $media = $this->getMedia(); + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $path = $media->getPath(); + if (!$path) { + $language = $grav['language']; + + throw new RuntimeException($language->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400); + } + + if ($locator->isStream($path)) { + $path = $locator->findResource($path, true, true); + $locator->clearCache($path); + } + + try { + // Upload it + $filepath = sprintf('%s/%s', $path, $filename); + Folder::create(\dirname($filepath)); + if ($uploadedFile instanceof FormFlashFile) { + $metadata = $uploadedFile->getMetaData(); + if ($metadata) { + $file = YamlFile::instance($filepath . '.meta.yaml'); + $file->save(['upload' => $metadata]); + } + if ($uploadedFile->getError() === \UPLOAD_ERR_OK) { + $uploadedFile->moveTo($filepath); + } elseif (!file_exists($filepath) && $pos = strpos($filename, '/')) { + $origpath = sprintf('%s/%s', $path, substr($filename, $pos)); + if (file_exists($origpath)) { + copy($origpath, $filepath); + } + } + } else { + $uploadedFile->moveTo($filepath); + } + } catch (\Exception $e) { + $language = $grav['language']; + + throw new RuntimeException($language->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400); + } + + $this->clearMediaCache(); + } + + public function deleteMediaFile(string $filename): void + { + $grav = Grav::instance(); + $language = $grav['language']; + + $basename = basename($filename); + $dirname = dirname($filename); + $dirname = $dirname === '.' ? '' : '/' . $dirname; + + if (!Utils::checkFilename($basename)) { + throw new RuntimeException($language->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': Bad filename: ' . $filename, 400); + } + + $media = $this->getMedia(); + $path = $media->getPath(); + if (!$path) { + return; + } + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $targetPath = $path . '/' . $dirname; + $targetFile = $path . '/' . $filename; + if ($locator->isStream($targetFile)) { + $targetPath = $locator->findResource($targetPath, true, true); + $targetFile = $locator->findResource($targetFile, true, true); + $locator->clearCache($targetPath); + $locator->clearCache($targetFile); + } + + $fileParts = pathinfo($basename); + + if (!file_exists($targetPath)) { + return; + } + + if (file_exists($targetFile)) { + $result = unlink($targetFile); + if (!$result) { + throw new RuntimeException($language->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + } + } + + // Remove Extra Files + foreach (scandir($targetPath, SCANDIR_SORT_NONE) 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 = $locator->findResource($testPath, true, true); + $locator->clearCache($testPath); + } + + if (is_file($testPath)) { + $result = unlink($testPath); + if (!$result) { + throw new RuntimeException($language->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + } + } + } + } + + $this->clearMediaCache(); + } + + /** + * @param array $files + */ + protected function setUpdatedMedia(array $files): void + { + $list = []; + foreach ($files as $field => $group) { + if ($field === '' || \strpos($field, '/', true)) { + continue; + } + foreach ($group as $filename => $file) { + $list[$filename] = $file; + } + } + + $this->_uploads = $list; + } + + /** + * @return array + */ + protected function getUpdatedMedia(): array + { + return $this->_uploads ?? []; + } + + protected function saveUpdatedMedia(): void + { + /** + * @var string $filename + * @var UploadedFileInterface $file + */ + foreach ($this->getUpdatedMedia() as $filename => $file) { + if ($file) { + $this->uploadMediaFile($file, $filename); + } else { + $this->deleteMediaFile($filename); + } + } + + $this->setUpdatedMedia([]); + } + + /** + * @param string $uri + * @return Medium|null + */ + protected function createMedium($uri) + { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $file = $uri && $locator->isStream($uri) ? $locator->findResource($uri) : $uri; + + return $file && file_exists($file) ? MediumFactory::fromFile($file) : null; + } + + /** + * @return Cache + */ + protected function getMediaCache() + { + return $this->getCache('object'); + } + + protected function offsetLoad_media() + { + return $this->getMedia(); + } + + protected function offsetSerialize_media() + { + return null; + } + + abstract public function getFlexDirectory(): FlexDirectory; + + abstract public function getStorageKey(): string; +} diff --git a/system/src/Grav/Framework/Form/FormFlash.php b/system/src/Grav/Framework/Form/FormFlash.php new file mode 100644 index 0000000..e2ba826 --- /dev/null +++ b/system/src/Grav/Framework/Form/FormFlash.php @@ -0,0 +1,519 @@ + $args[0], + 'unique_id' => $args[1] ?? null, + 'form_name' => $args[2] ?? null, + ]; + } + + $this->sessionId = $config['session_id'] ?? 'no-session'; + $this->uniqueId = $config['unique_id'] ?? ''; + + $folder = $config['folder'] ?? ($this->sessionId ? 'tmp://forms/' . $this->sessionId : ''); + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + $this->folder = $folder && $locator->isStream($folder) ? $locator->findResource($folder, true, true) : $folder; + $file = $this->getTmpIndex(); + $this->exists = $file->exists(); + + if ($this->exists) { + try { + $data = (array)$file->content(); + } catch (\Exception $e) { + $data = []; + } + $this->formName = $content['form'] ?? $config['form_name'] ?? ''; + $this->url = $data['url'] ?? ''; + $this->user = $data['user'] ?? null; + $this->updatedTimestamp = $data['timestamps']['updated'] ?? time(); + $this->createdTimestamp = $data['timestamps']['created'] ?? $this->updatedTimestamp; + $this->data = $data['data'] ?? null; + $this->files = $data['files'] ?? []; + } else { + $this->formName = $config['form_name'] ?? ''; + $this->url = ''; + $this->createdTimestamp = $this->updatedTimestamp = time(); + $this->files = []; + } + } + + /** + * @inheritDoc + */ + public function getSessionId(): string + { + return $this->sessionId; + } + + /** + * @inheritDoc + */ + public function getUniqueId(): string + { + return $this->uniqueId; + } + + /** + * @deprecated 1.6.11 Use '->getUniqueId()' method instead. + */ + public function getUniqieId(): string + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6.11, use ->getUniqueId() method instead', E_USER_DEPRECATED); + + return $this->getUniqueId(); + } + + /** + * @inheritDoc + */ + public function getFormName(): string + { + return $this->formName; + } + + + /** + * @inheritDoc + */ + public function getUrl(): string + { + return $this->url; + } + + /** + * @inheritDoc + */ + public function getUsername(): string + { + return $this->user['username'] ?? ''; + } + + /** + * @inheritDoc + */ + public function getUserEmail(): string + { + return $this->user['email'] ?? ''; + } + + /** + * @inheritDoc + */ + public function getCreatedTimestamp(): int + { + return $this->createdTimestamp; + } + + /** + * @inheritDoc + */ + public function getUpdatedTimestamp(): int + { + return $this->updatedTimestamp; + } + + + /** + * @inheritDoc + */ + public function getData(): ?array + { + return $this->data; + } + + /** + * @inheritDoc + */ + public function setData(?array $data): void + { + $this->data = $data; + } + + /** + * @inheritDoc + */ + public function exists(): bool + { + return $this->exists; + } + + /** + * @inheritDoc + */ + public function save(): self + { + if (!($this->folder && $this->uniqueId)) { + return $this; + } + + if ($this->data || $this->files) { + // Only save if there is data or files to be saved. + $file = $this->getTmpIndex(); + $file->save($this->jsonSerialize()); + $this->exists = true; + } elseif ($this->exists) { + // Delete empty form flash if it exists (it carries no information). + return $this->delete(); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function delete(): self + { + if ($this->folder && $this->uniqueId) { + $this->removeTmpDir(); + $this->files = []; + $this->exists = false; + } + + return $this; + } + + /** + * @inheritDoc + */ + public function getFilesByField(string $field): array + { + if (!isset($this->uploadObjects[$field])) { + $objects = []; + foreach ($this->files[$field] ?? [] as $name => $upload) { + $objects[$name] = $upload ? new FormFlashFile($field, $upload, $this) : null; + } + $this->uploadedFiles[$field] = $objects; + } + + return $this->uploadedFiles[$field]; + } + + /** + * @inheritDoc + */ + public function getFilesByFields($includeOriginal = false): array + { + $list = []; + foreach ($this->files as $field => $values) { + if (!$includeOriginal && strpos($field, '/')) { + continue; + } + $list[$field] = $this->getFilesByField($field); + } + + return $list; + } + + /** + * @inheritDoc + */ + public function addUploadedFile(UploadedFileInterface $upload, string $field = null, array $crop = null): string + { + $tmp_dir = $this->getTmpDir(); + $tmp_name = Utils::generateRandomString(12); + $name = $upload->getClientFilename(); + + // Prepare upload data for later save + $data = [ + 'name' => $name, + 'type' => $upload->getClientMediaType(), + 'size' => $upload->getSize(), + 'tmp_name' => $tmp_name + ]; + + Folder::create($tmp_dir); + $upload->moveTo("{$tmp_dir}/{$tmp_name}"); + + $this->addFileInternal($field, $name, $data, $crop); + + return $name; + } + + + /** + * @inheritDoc + */ + public function addFile(string $filename, string $field, array $crop = null): bool + { + if (!file_exists($filename)) { + throw new \RuntimeException("File not found: {$filename}"); + } + + // Prepare upload data for later save + $data = [ + 'name' => basename($filename), + 'type' => Utils::getMimeByLocalFile($filename), + 'size' => filesize($filename), + ]; + + $this->addFileInternal($field, $data['name'], $data, $crop); + + return true; + } + + /** + * @inheritDoc + */ + public function removeFile(string $name, string $field = null): bool + { + if (!$name) { + return false; + } + + $field = $field ?: 'undefined'; + + $upload = $this->files[$field][$name] ?? null; + if (null !== $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + $upload = $this->files[$field . '/original'][$name] ?? null; + if (null !== $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + + // Mark file as deleted. + $this->files[$field][$name] = null; + $this->files[$field . '/original'][$name] = null; + + unset( + $this->uploadedFiles[$field][$name], + $this->uploadedFiles[$field . '/original'][$name] + ); + + return true; + } + + /** + * @inheritDoc + */ + public function clearFiles() + { + foreach ($this->files as $field => $files) { + foreach ($files as $name => $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + } + + $this->files = []; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array + { + return [ + 'form' => $this->formName, + 'unique_id' => $this->uniqueId, + 'url' => $this->url, + 'user' => $this->user, + 'timestamps' => [ + 'created' => $this->createdTimestamp, + 'updated' => time(), + ], + 'data' => $this->data, + 'files' => $this->files + ]; + } + + /** + * @param string $url + * @return $this + */ + public function setUrl(string $url): self + { + $this->url = $url; + + return $this; + } + + /** + * @param UserInterface|null $user + * @return $this + */ + public function setUser(UserInterface $user = null) + { + if ($user && $user->username) { + $this->user = [ + 'username' => $user->username, + 'email' => $user->email ?? '' + ]; + } else { + $this->user = null; + } + + return $this; + } + + /** + * @param string|null $username + * @return $this + */ + public function setUserName(string $username = null): self + { + $this->user['username'] = $username; + + return $this; + } + + /** + * @param string|null $email + * @return $this + */ + public function setUserEmail(string $email = null): self + { + $this->user['email'] = $email; + + return $this; + } + + /** + * @return string + */ + public function getTmpDir(): string + { + return $this->folder && $this->uniqueId ? "{$this->folder}/{$this->uniqueId}" : ''; + } + + /** + * @return YamlFile + */ + protected function getTmpIndex(): YamlFile + { + // Do not use CompiledYamlFile as the file can change multiple times per second. + return YamlFile::instance($this->getTmpDir() . '/index.yaml'); + } + + /** + * @param string $name + */ + protected function removeTmpFile(string $name): void + { + $tmpDir = $this->getTmpDir(); + $filename = $tmpDir ? $tmpDir . '/' . $name : ''; + if ($name && $filename && is_file($filename)) { + unlink($filename); + } + } + + protected function removeTmpDir(): void + { + $tmpDir = $this->getTmpDir(); + if ($tmpDir && file_exists($tmpDir)) { + Folder::delete($tmpDir); + } + } + + /** + * @param string $field + * @param string $name + * @param array $data + * @param array|null $crop + */ + protected function addFileInternal(?string $field, string $name, array $data, array $crop = null): void + { + if (!($this->folder && $this->uniqueId)) { + throw new \RuntimeException('Cannot upload files: form flash folder not defined'); + } + + $field = $field ?: 'undefined'; + if (!isset($this->files[$field])) { + $this->files[$field] = []; + } + + $oldUpload = $this->files[$field][$name] ?? null; + + if ($crop) { + // Deal with crop upload + if ($oldUpload) { + $originalUpload = $this->files[$field . '/original'][$name] ?? null; + if ($originalUpload) { + // If there is original file already present, remove the modified file + $this->files[$field . '/original'][$name]['crop'] = $crop; + $this->removeTmpFile($oldUpload['tmp_name'] ?? ''); + } else { + // Otherwise make the previous file as original + $oldUpload['crop'] = $crop; + $this->files[$field . '/original'][$name] = $oldUpload; + } + } else { + $this->files[$field . '/original'][$name] = [ + 'name' => $name, + 'type' => $data['type'], + 'crop' => $crop + ]; + } + } else { + // Deal with replacing upload + $originalUpload = $this->files[$field . '/original'][$name] ?? null; + $this->files[$field . '/original'][$name] = null; + + $this->removeTmpFile($oldUpload['tmp_name'] ?? ''); + $this->removeTmpFile($originalUpload['tmp_name'] ?? ''); + } + + // Prepare data to be saved later + $this->files[$field][$name] = $data; + } +} diff --git a/system/src/Grav/Framework/Form/FormFlashFile.php b/system/src/Grav/Framework/Form/FormFlashFile.php new file mode 100644 index 0000000..9183689 --- /dev/null +++ b/system/src/Grav/Framework/Form/FormFlashFile.php @@ -0,0 +1,159 @@ +field = $field; + $this->upload = $upload; + $this->flash = $flash; + + $tmpFile = $this->getTmpFile(); + if (!$tmpFile && $this->isOk()) { + $this->upload['error'] = \UPLOAD_ERR_NO_FILE; + } + + if (!isset($this->upload['size'])) { + $this->upload['size'] = $tmpFile && $this->isOk() ? filesize($tmpFile) : 0; + } + } + + /** + * @return StreamInterface + */ + public function getStream() + { + $this->validateActive(); + + $resource = \fopen($this->getTmpFile(), 'rb'); + + return Stream::create($resource); + } + + public function moveTo($targetPath) + { + $this->validateActive(); + + if (!\is_string($targetPath) || empty($targetPath)) { + throw new \InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); + } + + $this->moved = \copy($this->getTmpFile(), $targetPath); + + if (false === $this->moved) { + throw new \RuntimeException(\sprintf('Uploaded file could not be moved to %s', $targetPath)); + } + + $this->flash->removeFile($this->getClientFilename(), $this->field); + } + + public function getSize() + { + return $this->upload['size']; + } + + public function getError() + { + return $this->upload['error'] ?? \UPLOAD_ERR_OK; + } + + public function getClientFilename() + { + return $this->upload['name'] ?? 'unknown'; + } + + public function getClientMediaType() + { + return $this->upload['type'] ?? 'application/octet-stream'; + } + + public function isMoved() : bool + { + return $this->moved; + } + + public function getMetaData() : array + { + if (isset($this->upload['crop'])) { + return ['crop' => $this->upload['crop']]; + } + + return []; + } + + public function getDestination() + { + return $this->upload['path'] ?? ''; + } + + public function jsonSerialize() + { + return $this->upload; + } + + public function getTmpFile() : ?string + { + $tmpName = $this->upload['tmp_name'] ?? null; + + if (!$tmpName) { + return null; + } + + $tmpFile = $this->flash->getTmpDir() . '/' . $tmpName; + + return file_exists($tmpFile) ? $tmpFile : null; + } + + public function __debugInfo() + { + return [ + 'field:private' => $this->field, + 'moved:private' => $this->moved, + 'upload:private' => $this->upload, + ]; + } + + /** + * @throws \RuntimeException if is moved or not ok + */ + private function validateActive(): void + { + if (!$this->isOk()) { + throw new \RuntimeException('Cannot retrieve stream due to upload error'); + } + + if ($this->moved) { + throw new \RuntimeException('Cannot retrieve stream after it has already been moved'); + } + + if (!$this->getTmpFile()) { + throw new \RuntimeException('Cannot retrieve stream as the file is missing'); + } + } + + /** + * @return bool return true if there is no upload error + */ + private function isOk(): bool + { + return \UPLOAD_ERR_OK === $this->getError(); + } +} diff --git a/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php b/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php new file mode 100644 index 0000000..fb57db1 --- /dev/null +++ b/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php @@ -0,0 +1,38 @@ +id; + } + + public function setId(string $id): void + { + $this->id = $id; + } + + public function getUniqueId(): string + { + return $this->uniqueid; + } + + public function setUniqueId(string $uniqueId): void + { + $this->uniqueid = $uniqueId; + } + + public function getName(): string + { + return $this->name; + } + + public function getFormName(): string + { + return $this->name; + } + + public function getNonceName(): string + { + return 'form-nonce'; + } + + public function getNonceAction(): string + { + return 'form'; + } + + public function getNonce(): string + { + return Utils::getNonce($this->getNonceAction()); + } + + public function getAction(): string + { + return ''; + } + + public function getTask(): string + { + return $this->getBlueprint()->get('form/task') ?? ''; + } + + public function getData(string $name = null) + { + return null !== $name ? $this->data[$name] : $this->data; + } + + /** + * @return array|UploadedFileInterface[] + */ + public function getFiles(): array + { + return $this->files ?? []; + } + + public function getValue(string $name) + { + return $this->data[$name] ?? null; + } + + public function getDefaultValue(string $name) + { + $path = explode('.', $name) ?: []; + $offset = array_shift($path) ?? ''; + + $current = $this->getDefaultValues(); + + if (!isset($current[$offset])) { + return null; + } + + $current = $current[$offset]; + + while ($path) { + $offset = array_shift($path); + + if ((\is_array($current) || $current instanceof \ArrayAccess) && isset($current[$offset])) { + $current = $current[$offset]; + } elseif (\is_object($current) && isset($current->{$offset})) { + $current = $current->{$offset}; + } else { + return null; + } + }; + + return $current; + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->getBlueprint()->getDefaults(); + } + + /** + * @param ServerRequestInterface $request + * @return FormInterface|$this + */ + public function handleRequest(ServerRequestInterface $request): FormInterface + { + // Set current form to be active. + $grav = Grav::instance(); + $forms = $grav['forms'] ?? null; + if ($forms) { + $forms->setActiveForm($this); + + /** @var Twig $twig */ + $twig = $grav['twig']; + $twig->twig_vars['form'] = $this; + + } + + try { + [$data, $files] = $this->parseRequest($request); + + $this->submit($data, $files); + } catch (\Exception $e) { + $this->setError($e->getMessage()); + } + + return $this; + } + + /** + * @param ServerRequestInterface $request + * @return FormInterface|$this + */ + public function setRequest(ServerRequestInterface $request): FormInterface + { + [$data, $files] = $this->parseRequest($request); + + $this->data = new Data($data, $this->getBlueprint()); + $this->files = $files; + + return $this; + } + + public function isValid(): bool + { + return $this->status === 'success'; + } + + public function getError(): ?string + { + return !$this->isValid() ? $this->message : null; + } + + public function getErrors(): array + { + return !$this->isValid() ? $this->messages : []; + } + + public function isSubmitted(): bool + { + return $this->submitted; + } + + public function validate(): bool + { + if (!$this->isValid()) { + return false; + } + + try { + $this->validateData($this->data); + $this->validateUploads($this->getFiles()); + } catch (ValidationException $e) { + $this->setErrors($e->getMessages()); + } catch (\Exception $e) { + $this->setError($e->getMessage()); + } + + $this->filterData($this->data); + + return $this->isValid(); + } + + /** + * @param array $data + * @param UploadedFileInterface[] $files + * @return FormInterface|$this + */ + public function submit(array $data, array $files = null): FormInterface + { + try { + if ($this->isSubmitted()) { + throw new \RuntimeException('Form has already been submitted'); + } + + $this->data = new Data($data, $this->getBlueprint()); + $this->files = $files ?? []; + + if (!$this->validate()) { + return $this; + } + + $this->doSubmit($this->data->toArray(), $this->files); + + $this->submitted = true; + } catch (\Exception $e) { + $this->setError($e->getMessage()); + } + + return $this; + } + + public function reset(): void + { + // Make sure that the flash object gets deleted. + $this->getFlash()->delete(); + + $this->data = null; + $this->files = []; + $this->status = 'success'; + $this->message = null; + $this->messages = []; + $this->submitted = false; + $this->flash = null; + } + + public function getFields(): array + { + return $this->getBlueprint()->fields(); + } + + public function getButtons(): array + { + return $this->getBlueprint()->get('form/buttons') ?? []; + } + + public function getTasks(): array + { + return $this->getBlueprint()->get('form/tasks') ?? []; + } + + abstract public function getBlueprint(): Blueprint; + + /** + * Implements \Serializable::serialize(). + * + * @return string + */ + public function serialize(): string + { + return serialize($this->doSerialize()); + } + + /** + * Implements \Serializable::unserialize(). + * + * @param string $serialized + */ + public function unserialize($serialized): void + { + $data = unserialize($serialized, ['allowed_classes' => false]); + + $this->doUnserialize($data); + } + + /** + * Get form flash object. + * + * @return FormFlash + */ + public function getFlash(): FormFlash + { + if (null === $this->flash) { + $grav = Grav::instance(); + $config = [ + 'session_id' => $this->getSessionId(), + 'unique_id' => $this->getUniqueId(), + 'form_name' => $this->getName(), + 'folder' => $this->getFlashFolder() + ]; + + + $this->flash = new FormFlash($config); + $this->flash->setUrl($grav['uri']->url)->setUser($grav['user'] ?? null); + } + + return $this->flash; + } + + /** + * Get all available form flash objects for this form. + * + * @return FormFlash[] + */ + public function getAllFlashes(): array + { + $folder = $this->getFlashFolder(); + if (!$folder || !is_dir($folder)) { + return []; + } + + $name = $this->getName(); + + $list = []; + /** @var \SplFileInfo $file */ + foreach (new \FilesystemIterator($folder) as $file) { + $uniqueId = $file->getFilename(); + $config = [ + 'session_id' => $this->getSessionId(), + 'unique_id' => $uniqueId, + 'form_name' => $name, + 'folder' => $this->getFlashFolder() + ]; + $flash = new FormFlash($config); + if ($flash->exists() && $flash->getFormName() === $name) { + $list[] = $flash; + } + } + + return $list; + + } + + /** + * {@inheritdoc} + * @see FormInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + if (null === $layout) { + $layout = 'default'; + } + + $grav = Grav::instance(); + + $block = HtmlBlock::create(); + $block->disableCache(); + + $output = $this->getTemplate($layout)->render( + ['grav' => $grav, 'config' => $grav['config'], 'block' => $block, 'form' => $this, 'layout' => $layout] + $context + ); + + $block->setContent($output); + + return $block; + } + + protected function getSessionId(): string + { + /** @var Grav $grav */ + $grav = Grav::instance(); + + /** @var SessionInterface $session */ + $session = $grav['session'] ?? null; + + return $session ? ($session->getId() ?? '') : ''; + } + + protected function unsetFlash(): void + { + $this->flash = null; + } + + protected function getFlashFolder(): ?string + { + $grav = Grav::instance(); + + /** @var UserInterface $user */ + $user = $grav['user'] ?? null; + $userExists = $user && $user->exists(); + $username = $userExists ? $user->username : null; + $mediaFolder = $userExists ? $user->getMediaFolder() : null; + $session = $grav['session'] ?? null; + $sessionId = $session ? $session->getId() : null; + + // Fill template token keys/value pairs. + $dataMap = [ + '[FORM_NAME]' => $this->getName(), + '[SESSIONID]' => $sessionId ?? '!!', + '[USERNAME]' => $username ?? '!!', + '[USERNAME_OR_SESSIONID]' => $username ?? $sessionId ?? '!!', + '[ACCOUNT]' => $mediaFolder ?? '!!' + ]; + + $flashFolder = $this->getBlueprint()->get('form/flash_folder', 'tmp://forms/[SESSIONID]'); + + $path = str_replace(array_keys($dataMap), array_values($dataMap), $flashFolder); + + // Make sure we only return valid paths. + return strpos($path, '!!') === false ? rtrim($path, '/') : null; + } + + /** + * Set a single error. + * + * @param string $error + */ + protected function setError(string $error): void + { + $this->status = 'error'; + $this->message = $error; + } + + /** + * Set all errors. + * + * @param array $errors + */ + protected function setErrors(array $errors): void + { + $this->status = 'error'; + $this->messages = $errors; + } + + /** + * @param string $layout + * @return TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + return $twig->twig()->resolveTemplate( + [ + "forms/{$layout}/form.html.twig", + 'forms/default/form.html.twig' + ] + ); + } + + /** + * Parse PSR-7 ServerRequest into data and files. + * + * @param ServerRequestInterface $request + * @return array + */ + protected function parseRequest(ServerRequestInterface $request): array + { + $method = $request->getMethod(); + if (!\in_array($method, ['PUT', 'POST', 'PATCH'])) { + throw new \RuntimeException(sprintf('FlexForm: Bad HTTP method %s', $method)); + } + + $body = $request->getParsedBody(); + $data = isset($body['data']) ? $this->decodeData($body['data']) : null; + + $flash = $this->getFlash(); + /* + if (null !== $data) { + $flash->setData($data); + $flash->save(); + } + */ + + $blueprint = $this->getBlueprint(); + $includeOriginal = (bool)($blueprint->form()['images']['original'] ?? null); + $files = $flash->getFilesByFields($includeOriginal); + + $data = $blueprint->processForm($data ?? [], $body['toggleable_data'] ?? []); + + return [ + $data, + $files ?? [] + ]; + } + + /** + * Form submit logic goes here. + * + * @param array $data + * @param array $files + * @return mixed + */ + abstract protected function doSubmit(array $data, array $files); + + /** + * Validate data and throw validation exceptions if validation fails. + * + * @param \ArrayAccess $data + * @throws ValidationException + * @throws \Exception + */ + protected function validateData(\ArrayAccess $data): void + { + if ($data instanceof Data) { + $data->validate(); + } + } + + /** + * Filter validated data. + * + * @param \ArrayAccess $data + */ + protected function filterData(\ArrayAccess $data): void + { + if ($data instanceof Data) { + $data->filter(); + } + } + + /** + * Validate all uploaded files. + * + * @param array $files + */ + protected function validateUploads(array $files): void + { + foreach ($files as $file) { + if (null === $file) { + continue; + } + if ($file instanceof UploadedFileInterface) { + $this->validateUpload($file); + } else { + $this->validateUploads($file); + } + } + } + + /** + * Validate uploaded file. + * + * @param UploadedFileInterface $file + */ + protected function validateUpload(UploadedFileInterface $file): void + { + // Handle bad filenames. + $filename = $file->getClientFilename(); + + if (!Utils::checkFilename($filename)) { + $grav = Grav::instance(); + throw new \RuntimeException( + sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null, true), $filename, 'Bad filename') + ); + } + } + + /** + * Decode POST data + * + * @param array $data + * @return array + */ + protected function decodeData($data): array + { + if (!\is_array($data)) { + return []; + } + + // Decode JSON encoded fields and merge them to data. + if (isset($data['_json'])) { + $data = array_replace_recursive($data, $this->jsonDecode($data['_json'])); + unset($data['_json']); + } + + return $data; + } + + /** + * Recursively JSON decode POST data. + * + * @param array $data + * @return array + */ + protected function jsonDecode(array $data): array + { + foreach ($data as $key => &$value) { + if (\is_array($value)) { + $value = $this->jsonDecode($value); + } elseif (trim($value) === '') { + unset($data[$key]); + } else { + $value = json_decode($value, true); + if ($value === null && json_last_error() !== JSON_ERROR_NONE) { + unset($data[$key]); + $this->setError("Badly encoded JSON data (for {$key}) was sent to the form"); + } + } + } + + return $data; + } + + /** + * @return array + */ + protected function doSerialize(): array + { + $data = $this->data instanceof Data ? $this->data->toArray() : null; + + return [ + 'name' => $this->name, + 'id' => $this->id, + 'uniqueid' => $this->uniqueid, + 'submitted' => $this->submitted, + 'status' => $this->status, + 'message' => $this->message, + 'messages' => $this->messages, + 'data' => $data, + 'files' => $this->files, + ]; + } + + /** + * @param array $data + */ + protected function doUnserialize(array $data): void + { + $this->name = $data['name']; + $this->id = $data['id']; + $this->uniqueid = $data['uniqueid']; + $this->submitted = $data['submitted'] ?? false; + $this->status = $data['status'] ?? 'success'; + $this->message = $data['message'] ?? null; + $this->messages = $data['messages'] ?? []; + $this->data = isset($data['data']) ? new Data($data['data'], $this->getBlueprint()) : null; + $this->files = $data['files'] ?? []; + } +} diff --git a/system/src/Grav/Framework/Interfaces/RenderInterface.php b/system/src/Grav/Framework/Interfaces/RenderInterface.php new file mode 100644 index 0000000..ca6d9bc --- /dev/null +++ b/system/src/Grav/Framework/Interfaces/RenderInterface.php @@ -0,0 +1,38 @@ +render('custom', ['variable' => 'value']); + * @example {% render object layout 'custom' with { variable: 'value' } %} + * + * @param string|null $layout Layout to be used. + * @param array $context Extra context given to the renderer. + * + * @return ContentBlockInterface|HtmlBlock Returns `HtmlBlock` containing the rendered output. + * @api + */ + public function render(string $layout = null, array $context = []); +} diff --git a/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php b/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php new file mode 100644 index 0000000..e834bc8 --- /dev/null +++ b/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php @@ -0,0 +1,17 @@ +hasProperty($offset); + } + + /** + * Returns the value at specified offset. + * + * @param mixed $offset The offset to retrieve. + * @return mixed Can return all value types. + */ + public function offsetGet($offset) + { + return $this->getProperty($offset); + } + + /** + * Assigns a value to the specified offset. + * + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + */ + public function offsetSet($offset, $value) + { + $this->setProperty($offset, $value); + } + + /** + * Unsets an offset. + * + * @param mixed $offset The offset to unset. + */ + public function offsetUnset($offset) + { + $this->unsetProperty($offset); + } + + abstract public function hasProperty($property); + abstract public function getProperty($property, $default = null); + abstract public function setProperty($property, $value); + abstract public function unsetProperty($property); +} diff --git a/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php b/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php new file mode 100644 index 0000000..858b391 --- /dev/null +++ b/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php @@ -0,0 +1,65 @@ +hasNestedProperty($offset); + } + + /** + * Returns the value at specified offset. + * + * @param mixed $offset The offset to retrieve. + * @return mixed Can return all value types. + */ + public function offsetGet($offset) + { + return $this->getNestedProperty($offset); + } + + /** + * Assigns a value to the specified offset. + * + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + */ + public function offsetSet($offset, $value) + { + $this->setNestedProperty($offset, $value); + } + + /** + * Unsets an offset. + * + * @param mixed $offset The offset to unset. + */ + public function offsetUnset($offset) + { + $this->unsetNestedProperty($offset); + } + + abstract public function hasNestedProperty($property, $separator = null); + abstract public function getNestedProperty($property, $default = null, $separator = null); + abstract public function setNestedProperty($property, $value, $separator = null); + abstract public function unsetNestedProperty($property, $separator = null); +} diff --git a/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php b/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php new file mode 100644 index 0000000..5ac0390 --- /dev/null +++ b/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php @@ -0,0 +1,125 @@ +getIterator() as $id => $element) { + $list[$id] = $element->hasNestedProperty($property, $separator); + } + + return $list; + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if not set. + * @param string $separator Separator, defaults to '.' + * @return array Key/Value pairs of the properties. + */ + public function getNestedProperty($property, $default = null, $separator = null) + { + $list = []; + + /** @var NestedObjectInterface $element */ + foreach ($this->getIterator() as $id => $element) { + $list[$id] = $element->getNestedProperty($property, $default, $separator); + } + + return $list; + } + + /** + * @param string $property Object property to be updated. + * @param string $value New value. + * @param string $separator Separator, defaults to '.' + * @return $this + */ + public function setNestedProperty($property, $value, $separator = null) + { + /** @var NestedObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->setNestedProperty($property, $value, $separator); + } + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @param string $separator Separator, defaults to '.' + * @return $this + */ + public function unsetNestedProperty($property, $separator = null) + { + /** @var NestedObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->unsetNestedProperty($property, $separator); + } + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @param string $default Default value. + * @param string $separator Separator, defaults to '.' + * @return $this + */ + public function defNestedProperty($property, $default, $separator = null) + { + /** @var NestedObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->defNestedProperty($property, $default, $separator); + } + + return $this; + } + + /** + * Group items in the collection by a field. + * + * @param string $property Object property to be used to make groups. + * @param string $separator Separator, defaults to '.' + * @return array + */ + public function group($property, $separator = null) + { + $list = []; + + /** @var NestedObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $list[(string) $element->getNestedProperty($property, null, $separator)][] = $element; + } + + return $list; + } + + /** + * @return \Traversable + */ + abstract public function getIterator(); +} diff --git a/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php b/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php new file mode 100644 index 0000000..2200871 --- /dev/null +++ b/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php @@ -0,0 +1,183 @@ +getNestedProperty($property, $test, $separator) !== $test; + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @param string $separator Separator, defaults to '.' + * @return mixed Property value. + */ + public function getNestedProperty($property, $default = null, $separator = null) + { + $separator = $separator ?: '.'; + $path = explode($separator, $property) ?: []; + $offset = array_shift($path) ?? ''; + + if (!$this->hasProperty($offset)) { + return $default; + } + + $current = $this->getProperty($offset); + + while ($path) { + // Get property of nested Object. + if ($current instanceof ObjectInterface) { + if (method_exists($current, 'getNestedProperty')) { + return $current->getNestedProperty(implode($separator, $path), $default, $separator); + } + return $current->getProperty(implode($separator, $path), $default); + } + + $offset = array_shift($path); + + if ((\is_array($current) || is_a($current, 'ArrayAccess')) && isset($current[$offset])) { + $current = $current[$offset]; + } elseif (\is_object($current) && isset($current->{$offset})) { + $current = $current->{$offset}; + } else { + return $default; + } + }; + + return $current; + } + + + /** + * @param string $property Object property to be updated. + * @param string $value New value. + * @param string $separator Separator, defaults to '.' + * @return $this + * @throws \RuntimeException + */ + public function setNestedProperty($property, $value, $separator = null) + { + $separator = $separator ?: '.'; + $path = explode($separator, $property) ?: []; + $offset = array_shift($path) ?? ''; + + if (!$path) { + $this->setProperty($offset, $value); + + return $this; + } + + $current = &$this->doGetProperty($offset, null, true); + + while ($path) { + $offset = array_shift($path); + + // Handle arrays and scalars. + if ($current === null) { + $current = [$offset => []]; + } elseif (\is_array($current)) { + if (!isset($current[$offset])) { + $current[$offset] = []; + } + } else { + throw new \RuntimeException("Cannot set nested property {$property} on non-array value"); + } + + $current = &$current[$offset]; + }; + + $current = $value; + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @param string $separator Separator, defaults to '.' + * @return $this + * @throws \RuntimeException + */ + public function unsetNestedProperty($property, $separator = null) + { + $separator = $separator ?: '.'; + $path = explode($separator, $property) ?: []; + $offset = array_shift($path) ?? ''; + + if (!$path) { + $this->unsetProperty($offset); + + return $this; + } + + $last = array_pop($path); + $current = &$this->doGetProperty($offset, null, true); + + while ($path) { + $offset = array_shift($path); + + // Handle arrays and scalars. + if ($current === null) { + return $this; + } + if (\is_array($current)) { + if (!isset($current[$offset])) { + return $this; + } + } else { + throw new \RuntimeException("Cannot unset nested property {$property} on non-array value"); + } + + $current = &$current[$offset]; + }; + + unset($current[$last]); + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $default Default value. + * @param string $separator Separator, defaults to '.' + * @return $this + * @throws \RuntimeException + */ + public function defNestedProperty($property, $default, $separator = null) + { + if (!$this->hasNestedProperty($property, $separator)) { + $this->setNestedProperty($property, $default, $separator); + } + + return $this; + } + + + abstract public function hasProperty($property); + abstract public function getProperty($property, $default = null); + abstract public function setProperty($property, $value); + abstract public function unsetProperty($property); + abstract protected function &doGetProperty($property, $default = null, $doCreate = false); +} diff --git a/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php b/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php new file mode 100644 index 0000000..029dbba --- /dev/null +++ b/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php @@ -0,0 +1,65 @@ +hasProperty($offset); + } + + /** + * Returns the value at specified offset. + * + * @param mixed $offset The offset to retrieve. + * @return mixed Can return all value types. + */ + public function __get($offset) + { + return $this->getProperty($offset); + } + + /** + * Assigns a value to the specified offset. + * + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + */ + public function __set($offset, $value) + { + $this->setProperty($offset, $value); + } + + /** + * Magic method to unset the attribute + * + * @param mixed $offset The name value to unset + */ + public function __unset($offset) + { + $this->unsetProperty($offset); + } + + abstract public function hasProperty($property); + abstract public function getProperty($property, $default = null); + abstract public function setProperty($property, $value); + abstract public function unsetProperty($property); +} diff --git a/system/src/Grav/Framework/Object/ArrayObject.php b/system/src/Grav/Framework/Object/ArrayObject.php new file mode 100644 index 0000000..a7062e8 --- /dev/null +++ b/system/src/Grav/Framework/Object/ArrayObject.php @@ -0,0 +1,29 @@ +getIterator() as $key => $value) { + $list[$key] = \is_object($value) ? clone $value : $value; + } + + return $this->createFrom($list); + } + + /** + * @return array + */ + public function getObjectKeys() + { + return $this->call('getKey'); + } + + /** + * @param string $property Object property to be matched. + * @return bool[] Key/Value pairs of the properties. + */ + public function doHasProperty($property) + { + $list = []; + + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $id => $element) { + $list[$id] = $element->hasProperty($property); + } + + return $list; + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if not set. + * @return mixed[] Key/Value pairs of the properties. + */ + public function doGetProperty($property, $default = null) + { + $list = []; + + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $id => $element) { + $list[$id] = $element->getProperty($property, $default); + } + + return $list; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return $this + */ + public function doSetProperty($property, $value) + { + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->setProperty($property, $value); + } + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @return $this + */ + public function doUnsetProperty($property) + { + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->unsetProperty($property); + } + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $default Default value. + * @return $this + */ + public function doDefProperty($property, $default) + { + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->defProperty($property, $default); + } + + return $this; + } + + /** + * @param string $method Method name. + * @param array $arguments List of arguments passed to the function. + * @return mixed[] Return values. + */ + public function call($method, array $arguments = []) + { + $list = []; + + /** + * @var string|int $id + * @var ObjectInterface $element + */ + foreach ($this->getIterator() as $id => $element) { + $callable = method_exists($element, $method) ? [$element, $method] : null; + $list[$id] = $callable ? \call_user_func_array($callable, $arguments) : null; + } + + return $list; + } + + /** + * Group items in the collection by a field and return them as associated array. + * + * @param string $property + * @return array + */ + public function group($property) + { + $list = []; + + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $list[(string) $element->getProperty($property)][] = $element; + } + + return $list; + } + + /** + * Group items in the collection by a field and return them as associated array of collections. + * + * @param string $property + * @return static[] + */ + public function collectionGroup($property) + { + $collections = []; + foreach ($this->group($property) as $id => $elements) { + $collection = $this->createFrom($elements); + + $collections[$id] = $collection; + } + + return $collections; + } + + /** + * @return \Traversable + */ + abstract public function getIterator(); +} diff --git a/system/src/Grav/Framework/Object/Base/ObjectTrait.php b/system/src/Grav/Framework/Object/Base/ObjectTrait.php new file mode 100644 index 0000000..baeeb2b --- /dev/null +++ b/system/src/Grav/Framework/Object/Base/ObjectTrait.php @@ -0,0 +1,204 @@ +getTypePrefix() : ''; + + if (static::$type) { + return $type . static::$type; + } + + $class = \get_class($this); + return $type . strtolower(substr($class, strrpos($class, '\\') + 1)); + } + + /** + * @return string + */ + public function getKey() + { + return $this->_key ?: $this->getType() . '@@' . spl_object_hash($this); + } + + /** + * @return bool + */ + public function hasKey() + { + return !empty($this->_key); + } + + /** + * @param string $property Object property name. + * @return bool|bool[] True if property has been defined (can be null). + */ + public function hasProperty($property) + { + return $this->doHasProperty($property); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @return mixed|mixed[] Property value. + */ + public function getProperty($property, $default = null) + { + return $this->doGetProperty($property, $default); + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return $this + */ + public function setProperty($property, $value) + { + $this->doSetProperty($property, $value); + + return $this; + } + + /** + * @param string $property Object property to be unset. + * @return $this + */ + public function unsetProperty($property) + { + $this->doUnsetProperty($property); + + return $this; + } + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @return $this + */ + public function defProperty($property, $default) + { + if (!$this->hasProperty($property)) { + $this->setProperty($property, $default); + } + + return $this; + } + + /** + * Implements Serializable interface. + * + * @return string + */ + public function serialize() + { + return serialize($this->doSerialize()); + } + + /** + * @param string $serialized + */ + public function unserialize($serialized) + { + $data = unserialize($serialized); + + if (method_exists($this, 'initObjectProperties')) { + $this->initObjectProperties(); + } + $this->doUnserialize($data); + } + + /** + * @return array + */ + protected function doSerialize() + { + return ['key' => $this->getKey(), 'type' => $this->getType(), 'elements' => $this->getElements()]; + } + + /** + * @param array $serialized + */ + protected function doUnserialize(array $serialized) + { + if (!isset($serialized['key'], $serialized['type'], $serialized['elements']) || $serialized['type'] !== $this->getType()) { + throw new \InvalidArgumentException("Cannot unserialize '{$this->getType()}': Bad data"); + } + + $this->setKey($serialized['key']); + $this->setElements($serialized['elements']); + } + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + public function jsonSerialize() + { + return $this->doSerialize(); + } + + /** + * Returns a string representation of this object. + * + * @return string + */ + public function __toString() + { + return $this->getKey(); + } + + /** + * @param string $key + * @return $this + */ + protected function setKey($key) + { + $this->_key = (string) $key; + + return $this; + } + + abstract protected function doHasProperty($property); + abstract protected function &doGetProperty($property, $default = null, $doCreate = false); + abstract protected function doSetProperty($property, $value); + abstract protected function doUnsetProperty($property); + abstract protected function getElements(); + abstract protected function setElements(array $elements); +} diff --git a/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php b/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php new file mode 100644 index 0000000..0eefb78 --- /dev/null +++ b/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php @@ -0,0 +1,199 @@ +{$accessor}(); + break; + } + } + + if ($op) { + $function = 'filter' . ucfirst(strtolower($op)); + if (method_exists(static::class, $function)) { + $value = static::$function($value); + } + } + + return $value; + } + + public static function filterLower($str) + { + return mb_strtolower($str); + } + + public static function filterUpper($str) + { + return mb_strtoupper($str); + } + + public static function filterLength($str) + { + return mb_strlen($str); + } + + public static function filterLtrim($str) + { + return ltrim($str); + } + + public static function filterRtrim($str) + { + return rtrim($str); + } + + public static function filterTrim($str) + { + return trim($str); + } + + /** + * Helper for sorting arrays of objects based on multiple fields + orientations. + * + * @param string $name + * @param int $orientation + * @param \Closure $next + * + * @return \Closure + */ + public static function sortByField($name, $orientation = 1, \Closure $next = null) + { + if (!$next) { + $next = function($a, $b) { + return 0; + }; + } + + return function ($a, $b) use ($name, $next, $orientation) { + $aValue = static::getObjectFieldValue($a, $name); + $bValue = static::getObjectFieldValue($b, $name); + + if ($aValue === $bValue) { + return $next($a, $b); + } + + return (($aValue > $bValue) ? 1 : -1) * $orientation; + }; + } + + /** + * {@inheritDoc} + */ + public function walkComparison(Comparison $comparison) + { + $field = $comparison->getField(); + $value = $comparison->getValue()->getValue(); // shortcut for walkValue() + + switch ($comparison->getOperator()) { + case Comparison::EQ: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) === $value; + }; + + case Comparison::NEQ: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) !== $value; + }; + + case Comparison::LT: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) < $value; + }; + + case Comparison::LTE: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) <= $value; + }; + + case Comparison::GT: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) > $value; + }; + + case Comparison::GTE: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) >= $value; + }; + + case Comparison::IN: + return function ($object) use ($field, $value) { + return \in_array(static::getObjectFieldValue($object, $field), $value, true); + }; + + case Comparison::NIN: + return function ($object) use ($field, $value) { + return !\in_array(static::getObjectFieldValue($object, $field), $value, true); + }; + + case Comparison::CONTAINS: + return function ($object) use ($field, $value) { + return false !== strpos(static::getObjectFieldValue($object, $field), $value); + }; + + case Comparison::MEMBER_OF: + return function ($object) use ($field, $value) { + $fieldValues = static::getObjectFieldValue($object, $field); + if (!\is_array($fieldValues)) { + $fieldValues = iterator_to_array($fieldValues); + } + return \in_array($value, $fieldValues, true); + }; + + case Comparison::STARTS_WITH: + return function ($object) use ($field, $value) { + return 0 === strpos(static::getObjectFieldValue($object, $field), $value); + }; + + case Comparison::ENDS_WITH: + return function ($object) use ($field, $value) { + return $value === substr(static::getObjectFieldValue($object, $field), -strlen($value)); + }; + + + default: + throw new \RuntimeException("Unknown comparison operator: " . $comparison->getOperator()); + } + } +} diff --git a/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php b/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php new file mode 100644 index 0000000..8d6025a --- /dev/null +++ b/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php @@ -0,0 +1,58 @@ +setElements($elements)); + + $this->setKey($key ?? ''); + } + + /** + * @param array $ordering + * @return Collection|static + */ + public function orderBy(array $ordering) + { + $criteria = Criteria::create()->orderBy($ordering); + + return $this->matching($criteria); + } + + /** + * @param int $start + * @param int|null $limit + * @return static + */ + public function limit($start, $limit = null) + { + return $this->createFrom($this->slice($start, $limit)); + } + + /** + * {@inheritDoc} + */ + public function matching(Criteria $criteria) + { + $expr = $criteria->getWhereExpression(); + $filtered = $this->getElements(); + + if ($expr) { + $visitor = new ObjectExpressionVisitor(); + $filter = $visitor->dispatch($expr); + $filtered = array_filter($filtered, $filter); + } + + if ($orderings = $criteria->getOrderings()) { + $next = null; + /** + * @var string $field + * @var string $ordering + */ + foreach (array_reverse($orderings) as $field => $ordering) { + $next = ObjectExpressionVisitor::sortByField($field, $ordering === Criteria::DESC ? -1 : 1, $next); + } + + if ($next) { + uasort($filtered, $next); + } + } + + $offset = $criteria->getFirstResult(); + $length = $criteria->getMaxResults(); + + if ($offset || $length) { + $filtered = \array_slice($filtered, (int)$offset, $length); + } + + return $this->createFrom($filtered); + } + + protected function getElements() + { + return $this->toArray(); + } + + protected function setElements(array $elements) + { + return $elements; + } +} diff --git a/system/src/Grav/Framework/Object/ObjectIndex.php b/system/src/Grav/Framework/Object/ObjectIndex.php new file mode 100644 index 0000000..4ba9b75 --- /dev/null +++ b/system/src/Grav/Framework/Object/ObjectIndex.php @@ -0,0 +1,251 @@ +getTypePrefix() : ''; + + if (static::$type) { + return $type . static::$type; + } + + $class = \get_class($this); + return $type . strtolower(substr($class, strrpos($class, '\\') + 1)); + } + + /** + * @return string + */ + public function getKey() + { + return $this->_key ?: $this->getType() . '@@' . spl_object_hash($this); + } + + /** + * @param string $key + * @return $this + */ + public function setKey($key) + { + $this->_key = $key; + + return $this; + } + + /** + * @param string $property Object property name. + * @return array True if property has been defined (can be null). + */ + public function hasProperty($property) + { + return $this->__call('hasProperty', [$property]); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @return array Property values. + */ + public function getProperty($property, $default = null) + { + return $this->__call('getProperty', [$property, $default]); + } + + /** + * @param string $property Object property to be updated. + * @param string $value New value. + * @return ObjectCollectionInterface + */ + public function setProperty($property, $value) + { + return $this->__call('setProperty', [$property, $value]); + } + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @return ObjectCollectionInterface + */ + public function defProperty($property, $default) + { + return $this->__call('defProperty', [$property, $default]); + } + + /** + * @param string $property Object property to be unset. + * @return ObjectCollectionInterface + */ + public function unsetProperty($property) + { + return $this->__call('unsetProperty', [$property]); + } + + /** + * @param string $property Object property name. + * @param string $separator Separator, defaults to '.' + * @return bool True if property has been defined (can be null). + */ + public function hasNestedProperty($property, $separator = null) + { + return $this->__call('hasNestedProperty', [$property, $separator]); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @param string $separator Separator, defaults to '.' + * @return mixed Property value. + */ + public function getNestedProperty($property, $default = null, $separator = null) + { + return $this->__call('getNestedProperty', [$property, $default, $separator]); + } + + /** + * @param string $property Object property to be updated. + * @param string $value New value. + * @param string $separator Separator, defaults to '.' + * @return ObjectCollectionInterface + */ + public function setNestedProperty($property, $value, $separator = null) + { + return $this->__call('setNestedProperty', [$property, $value, $separator]); + } + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @param string $separator Separator, defaults to '.' + * @return ObjectCollectionInterface + */ + public function defNestedProperty($property, $default, $separator = null) + { + return $this->__call('defNestedProperty', [$property, $default, $separator]); + } + + /** + * @param string $property Object property to be unset. + * @return ObjectCollectionInterface + */ + public function unsetNestedProperty($property, $separator = null) + { + return $this->__call('unsetNestedProperty', [$property, $separator]); + } + + /** + * Create a copy from this collection by cloning all objects in the collection. + * + * @return static + */ + public function copy() + { + $list = []; + foreach ($this->getIterator() as $key => $value) { + $list[$key] = \is_object($value) ? clone $value : $value; + } + + return $this->createFrom($list); + } + + /** + * @return array + */ + public function getObjectKeys() + { + return $this->getKeys(); + } + + /** + * @param array $ordering + * @return ObjectCollectionInterface + */ + public function orderBy(array $ordering) + { + return $this->__call('orderBy', [$ordering]); + } + + /** + * {@inheritDoc} + */ + public function call($method, array $arguments = []) + { + return $this->__call('call', [$method, $arguments]); + } + + /** + * Group items in the collection by a field and return them as associated array. + * + * @param string $property + * @return array + */ + public function group($property) + { + return $this->__call('group', [$property]); + } + + /** + * Group items in the collection by a field and return them as associated array of collections. + * + * @param string $property + * @return ObjectCollectionInterface[] + */ + public function collectionGroup($property) + { + return $this->__call('collectionGroup', [$property]); + } + + /** + * {@inheritDoc} + */ + public function matching(Criteria $criteria) + { + /** @var ObjectCollectionInterface $collection */ + $collection = $this->loadCollection($this->getEntries()); + + return $collection->matching($criteria); + } + + abstract public function __call($name, $arguments); + + /** + * @return string + */ + protected function getTypePrefix() + { + return ''; + } +} diff --git a/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php b/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php new file mode 100644 index 0000000..7092e15 --- /dev/null +++ b/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php @@ -0,0 +1,110 @@ +setElements($elements); + $this->setKey($key ?? ''); + } + + /** + * @param string $property Object property name. + * @return bool True if property has been defined (can be null). + */ + protected function doHasProperty($property) + { + return array_key_exists($property, $this->_elements); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @param bool $doCreate Set true to create variable. + * @return mixed Property value. + */ + protected function &doGetProperty($property, $default = null, $doCreate = false) + { + if (!array_key_exists($property, $this->_elements)) { + if ($doCreate) { + $this->_elements[$property] = null; + } else { + return $default; + } + } + + return $this->_elements[$property]; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + */ + protected function doSetProperty($property, $value) + { + $this->_elements[$property] = $value; + } + + /** + * @param string $property Object property to be unset. + */ + protected function doUnsetProperty($property) + { + unset($this->_elements[$property]); + } + + /** + * @param string $property + * @param mixed|null $default + * @return mixed|null + */ + protected function getElement($property, $default = null) + { + return array_key_exists($property, $this->_elements) ? $this->_elements[$property] : $default; + } + + /** + * @return array + */ + protected function getElements() + { + return array_filter($this->_elements, function ($val) { return $val !== null; }); + } + + /** + * @param array $elements + */ + protected function setElements(array $elements) + { + $this->_elements = $elements; + } + + abstract protected function setKey($key); +} diff --git a/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php b/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php new file mode 100644 index 0000000..1066b99 --- /dev/null +++ b/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php @@ -0,0 +1,118 @@ +offsetLoad($offset, $value)` called first time object property gets accessed + * - `$this->offsetPrepare($offset, $value)` called on every object property set + * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed + * + * @package Grav\Framework\Object\Property + */ +trait LazyPropertyTrait +{ + use ArrayPropertyTrait, ObjectPropertyTrait { + ObjectPropertyTrait::__construct insteadof ArrayPropertyTrait; + ArrayPropertyTrait::doHasProperty as hasArrayProperty; + ArrayPropertyTrait::doGetProperty as getArrayProperty; + ArrayPropertyTrait::doSetProperty as setArrayProperty; + ArrayPropertyTrait::doUnsetProperty as unsetArrayProperty; + ArrayPropertyTrait::getElement as getArrayElement; + ArrayPropertyTrait::getElements as getArrayElements; + ArrayPropertyTrait::setElements insteadof ObjectPropertyTrait; + ObjectPropertyTrait::doHasProperty as hasObjectProperty; + ObjectPropertyTrait::doGetProperty as getObjectProperty; + ObjectPropertyTrait::doSetProperty as setObjectProperty; + ObjectPropertyTrait::doUnsetProperty as unsetObjectProperty; + ObjectPropertyTrait::getElement as getObjectElement; + ObjectPropertyTrait::getElements as getObjectElements; + } + + /** + * @param string $property Object property name. + * @return bool True if property has been defined (can be null). + */ + protected function doHasProperty($property) + { + return $this->hasArrayProperty($property) || $this->hasObjectProperty($property); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @return mixed Property value. + */ + protected function &doGetProperty($property, $default = null, $doCreate = false) + { + if ($this->hasObjectProperty($property)) { + return $this->getObjectProperty($property, $default, function ($default = null) use ($property) { + return $this->getArrayProperty($property, $default); + }); + } + + return $this->getArrayProperty($property, $default, $doCreate); + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return $this + */ + protected function doSetProperty($property, $value) + { + if ($this->hasObjectProperty($property)) { + $this->setObjectProperty($property, $value); + } else { + $this->setArrayProperty($property, $value); + } + + return $this; + } + + /** + * @param string $property Object property to be unset. + * @return $this + */ + protected function doUnsetProperty($property) + { + $this->hasObjectProperty($property) ? + $this->unsetObjectProperty($property) : $this->unsetArrayProperty($property); + + return $this; + } + + /** + * @param string $property + * @param mixed|null $default + * @return mixed|null + */ + protected function getElement($property, $default = null) + { + if ($this->isPropertyLoaded($property)) { + return $this->getObjectElement($property, $default); + } + + return $this->getArrayElement($property, $default); + } + + /** + * @return array + */ + protected function getElements() + { + return $this->getObjectElements() + $this->getArrayElements(); + } +} diff --git a/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php b/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php new file mode 100644 index 0000000..149ac40 --- /dev/null +++ b/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php @@ -0,0 +1,123 @@ +offsetLoad($offset, $value)` called first time object property gets accessed + * - `$this->offsetPrepare($offset, $value)` called on every object property set + * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed + + * + * @package Grav\Framework\Object\Property + */ +trait MixedPropertyTrait +{ + use ArrayPropertyTrait, ObjectPropertyTrait { + ObjectPropertyTrait::__construct insteadof ArrayPropertyTrait; + ArrayPropertyTrait::doHasProperty as hasArrayProperty; + ArrayPropertyTrait::doGetProperty as getArrayProperty; + ArrayPropertyTrait::doSetProperty as setArrayProperty; + ArrayPropertyTrait::doUnsetProperty as unsetArrayProperty; + ArrayPropertyTrait::getElement as getArrayElement; + ArrayPropertyTrait::getElements as getArrayElements; + ArrayPropertyTrait::setElements as setArrayElements; + ObjectPropertyTrait::doHasProperty as hasObjectProperty; + ObjectPropertyTrait::doGetProperty as getObjectProperty; + ObjectPropertyTrait::doSetProperty as setObjectProperty; + ObjectPropertyTrait::doUnsetProperty as unsetObjectProperty; + ObjectPropertyTrait::getElement as getObjectElement; + ObjectPropertyTrait::getElements as getObjectElements; + ObjectPropertyTrait::setElements as setObjectElements; + } + + /** + * @param string $property Object property name. + * @return bool True if property has been defined (can be null). + */ + protected function doHasProperty($property) + { + return $this->hasArrayProperty($property) || $this->hasObjectProperty($property); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @return mixed Property value. + */ + protected function &doGetProperty($property, $default = null, $doCreate = false) + { + if ($this->hasObjectProperty($property)) { + return $this->getObjectProperty($property); + } + + return $this->getArrayProperty($property, $default, $doCreate); + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return $this + */ + protected function doSetProperty($property, $value) + { + $this->hasObjectProperty($property) + ? $this->setObjectProperty($property, $value) : $this->setArrayProperty($property, $value); + + return $this; + } + + /** + * @param string $property Object property to be unset. + * @return $this + */ + protected function doUnsetProperty($property) + { + $this->hasObjectProperty($property) ? + $this->unsetObjectProperty($property) : $this->unsetArrayProperty($property); + + return $this; + } + + /** + * @param string $property + * @param mixed|null $default + * @return mixed|null + */ + protected function getElement($property, $default = null) + { + if ($this->hasObjectProperty($property)) { + return $this->getObjectElement($property, $default); + } + + return $this->getArrayElement($property, $default); + } + + /** + * @return array + */ + protected function getElements() + { + return $this->getObjectElements() + $this->getArrayElements(); + } + + /** + * @param array $elements + */ + protected function setElements(array $elements) + { + $this->setObjectElements(array_intersect_key($elements, $this->_definedProperties)); + $this->setArrayElements(array_diff_key($elements, $this->_definedProperties)); + } +} diff --git a/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php b/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php new file mode 100644 index 0000000..5f8fdf0 --- /dev/null +++ b/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php @@ -0,0 +1,207 @@ +offsetLoad($offset, $value)` called first time object property gets accessed + * - `$this->offsetPrepare($offset, $value)` called on every object property set + * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed + * + * @package Grav\Framework\Object\Property + */ +trait ObjectPropertyTrait +{ + /** + * @var array + */ + private $_definedProperties; + + /** + * @param array $elements + * @param string $key + * @throws \InvalidArgumentException + */ + public function __construct(array $elements = [], $key = null) + { + $this->initObjectProperties(); + $this->setElements($elements); + $this->setKey($key ?? ''); + } + + /** + * @param string $property Object property name. + * @return bool True if property has been loaded. + */ + protected function isPropertyLoaded($property) + { + return !empty($this->_definedProperties[$property]); + } + + /** + * @param string $offset + * @param mixed $value + * @return mixed + */ + protected function offsetLoad($offset, $value) + { + $methodName = "offsetLoad_{$offset}"; + + return method_exists($this, $methodName)? $this->{$methodName}($value) : $value; + } + + /** + * @param string $offset + * @param mixed $value + * @return mixed + */ + protected function offsetPrepare($offset, $value) + { + $methodName = "offsetPrepare_{$offset}"; + + return method_exists($this, $methodName) ? $this->{$methodName}($value) : $value; + } + + /** + * @param string $offset + * @param mixed $value + * @return mixed + */ + protected function offsetSerialize($offset, $value) + { + $methodName = "offsetSerialize_{$offset}"; + + return method_exists($this, $methodName) ? $this->{$methodName}($value) : $value; + } + + /** + * @param string $property Object property name. + * @return bool True if property has been defined (can be null). + */ + protected function doHasProperty($property) + { + return array_key_exists($property, $this->_definedProperties); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @param callable|bool $doCreate Set true to create variable. + * @return mixed Property value. + */ + protected function &doGetProperty($property, $default = null, $doCreate = false) + { + if (!array_key_exists($property, $this->_definedProperties)) { + throw new \InvalidArgumentException("Property '{$property}' does not exist in the object!"); + } + + if (empty($this->_definedProperties[$property])) { + if ($doCreate === true) { + $this->_definedProperties[$property] = true; + $this->{$property} = null; + } elseif (\is_callable($doCreate)) { + $this->_definedProperties[$property] = true; + $this->{$property} = $this->offsetLoad($property, $doCreate()); + } else { + return $default; + } + } + + return $this->{$property}; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @throws \InvalidArgumentException + */ + protected function doSetProperty($property, $value) + { + if (!array_key_exists($property, $this->_definedProperties)) { + throw new \InvalidArgumentException("Property '{$property}' does not exist in the object!"); + } + + $this->_definedProperties[$property] = true; + $this->{$property} = $this->offsetPrepare($property, $value); + } + + /** + * @param string $property Object property to be unset. + */ + protected function doUnsetProperty($property) + { + if (!array_key_exists($property, $this->_definedProperties)) { + return; + } + + $this->_definedProperties[$property] = false; + unset($this->{$property}); + } + + protected function initObjectProperties() + { + $this->_definedProperties = []; + foreach (\get_object_vars($this) as $property => $value) { + if ($property[0] !== '_') { + $this->_definedProperties[$property] = ($value !== null); + } + } + } + + /** + * @param string $property + * @param mixed|null $default + * @return mixed|null + */ + protected function getElement($property, $default = null) + { + if (empty($this->_definedProperties[$property])) { + return $default; + } + + return $this->offsetSerialize($property, $this->{$property}); + } + + /** + * @return array + */ + protected function getElements() + { + $properties = array_intersect_key(get_object_vars($this), array_filter($this->_definedProperties)); + + $elements = []; + foreach ($properties as $offset => $value) { + $serialized = $this->offsetSerialize($offset, $value); + if ($serialized !== null) { + $elements[$offset] = $this->offsetSerialize($offset, $value); + } + } + + return $elements; + } + + /** + * @param array $elements + */ + protected function setElements(array $elements) + { + foreach ($elements as $property => $value) { + $this->setProperty($property, $value); + } + } + + abstract public function setProperty($property, $value); + abstract protected function setKey($key); +} diff --git a/system/src/Grav/Framework/Object/PropertyObject.php b/system/src/Grav/Framework/Object/PropertyObject.php new file mode 100644 index 0000000..f740fc6 --- /dev/null +++ b/system/src/Grav/Framework/Object/PropertyObject.php @@ -0,0 +1,29 @@ + 'page', + 'limit' => 10, + 'display' => 5, + 'opening' => 0, + 'ending' => 0, + 'url' => null + ]; + + /** @var array */ + private $items; + + public function isEnabled(): bool + { + return $this->count() > 1; + } + + public function getOptions(): array + { + return $this->options; + } + + public function getRoute(): ?Route + { + return $this->route; + } + + public function getTotalPages(): int + { + return $this->pages; + } + + public function getPageNumber(): int + { + return $this->page; + } + + public function getPrevNumber(int $count = 1): ?int + { + $page = $this->page - $count; + + return $page >= 1 ? $page : null; + } + + public function getNextNumber(int $count = 1): ?int + { + $page = $this->page + $count; + + return $page <= $this->pages ? $page : null; + } + + public function getPage(int $page, string $label = null): ?PaginationPage + { + if ($page < 1 || $page > $this->pages) { + return null; + } + + $start = ($page - 1) * $this->limit; + if ($this->getOptions()['type'] === 'page') { + $name = 'page'; + $offset = $page; + } else { + $name = 'start'; + $offset = $start; + } + + return new PaginationPage( + [ + 'label' => $label ?? (string)$page, + 'number' => $page, + 'offset_start' => $start, + 'offset_end' => min($start + $this->limit, $this->total) - 1, + 'enabled' => $page !== $this->page || $this->viewAll, + 'active' => $page === $this->page, + 'route' => $this->route->withGravParam($name, $offset) + ] + ); + } + + public function getFirstPage(string $label = null, int $count = 0): ?PaginationPage + { + return $this->getPage(1 + $count, $label ?? $this->getOptions()['label_first'] ?? null); + } + + public function getPrevPage(string $label = null, int $count = 1): ?PaginationPage + { + return $this->getPage($this->page - $count, $label ?? $this->getOptions()['label_prev'] ?? null); + } + + public function getNextPage(string $label = null, int $count = 1): ?PaginationPage + { + return $this->getPage($this->page + $count, $label ?? $this->getOptions()['label_next'] ?? null); + } + + public function getLastPage(string $label = null, int $count = 0): ?PaginationPage + { + return $this->getPage($this->pages - $count, $label ?? $this->getOptions()['label_last'] ?? null); + } + + public function getStart(): int + { + return $this->start; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getTotal(): int + { + return $this->total; + } + + public function count(): int + { + $this->loadItems(); + + return \count($this->items); + } + + public function getIterator() + { + $this->loadItems(); + + return new \ArrayIterator($this->items); + } + + public function getPages(): array + { + $this->loadItems(); + + return $this->items; + } + + protected function loadItems() + { + $this->calculateRange(); + + // Make list like: 1 ... 4 5 6 ... 10 + $range = range($this->pagesStart, $this->pagesStop); + //$range[] = 1; + //$range[] = $this->pages; + natsort($range); + $range = array_unique($range); + + $this->items = []; + foreach ($range as $i) { + $this->items[$i] = $this->getPage($i); + } + } + + protected function setRoute(Route $route) + { + $this->route = $route; + + return $this; + } + + protected function setOptions(array $options = null) + { + $this->options = $options ? array_merge($this->defaultOptions, $options) : $this->defaultOptions; + + return $this; + } + + protected function setPage(int $page = null) + { + $this->page = (int)max($page, 1); + $this->start = null; + + return $this; + } + + /** + * @param int $start + * @return $this + */ + protected function setStart(int $start = null) + { + $this->start = (int)max($start, 0); + $this->page = null; + + return $this; + } + + /** + * @param int|null $limit + * @return $this + */ + protected function setLimit(int $limit = null) + { + $this->limit = (int)max($limit ?? $this->getOptions()['limit'], 0); + + // No limit, display all records in a single page. + $this->viewAll = !$limit; + + return $this; + } + + /** + * @param int $total + * @return $this + */ + protected function setTotal(int $total) + { + $this->total = (int)max($total, 0); + + return $this; + } + + protected function initialize(Route $route, int $total, int $pos = null, int $limit = null, array $options = null) + { + $this->setRoute($route); + $this->setOptions($options); + $this->setTotal($total); + if ($this->getOptions()['type'] === 'start') { + $this->setStart($pos); + } else { + $this->setPage($pos); + } + $this->setLimit($limit); + $this->calculateLimits(); + } + + protected function calculateLimits() + { + $limit = $this->limit; + $total = $this->total; + + if (!$limit || $limit > $total) { + // All records fit into a single page. + $this->start = 0; + $this->page = 1; + $this->pages = 1; + + return; + } + + if (null === $this->start) { + // If we are using page, convert it to start. + $this->start = (int)(($this->page - 1) * $limit); + } + + if ($this->start > $total - $limit) { + // If start is greater than total count (i.e. we are asked to display records that don't exist) + // then set start to display the last natural page of results. + $this->start = (int)max(0, (ceil($total / $limit) - 1) * $limit); + } + + // Set the total pages and current page values. + $this->page = (int)ceil(($this->start + 1) / $limit); + $this->pages = (int)ceil($total / $limit); + } + + protected function calculateRange() + { + $options = $this->getOptions(); + $displayed = $options['display']; + $opening = $options['opening']; + $ending = $options['ending']; + + // Set the pagination iteration loop values. + $this->pagesStart = $this->page - (int)($displayed / 2); + if ($this->pagesStart < 1 + $opening) { + $this->pagesStart = 1 + $opening; + } + if ($this->pagesStart + $displayed - $opening > $this->pages) { + $this->pagesStop = $this->pages; + if ($this->pages < $displayed) { + $this->pagesStart = 1 + $opening; + } else { + $this->pagesStart = $this->pages - $displayed + 1 + $opening; + } + } else { + $this->pagesStop = (int)max(1, $this->pagesStart + $displayed - 1 - $ending); + } + } +} diff --git a/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php b/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php new file mode 100644 index 0000000..04f536b --- /dev/null +++ b/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php @@ -0,0 +1,53 @@ +options['active'] ?? false; + } + + public function isEnabled(): bool + { + return $this->options['enabled'] ?? false; + } + + public function getOptions(): array + { + return $this->options ?? []; + } + + public function getNumber(): ?int + { + return $this->options['number'] ?? null; + } + + public function getLabel(): string + { + return $this->options['label'] ?? (string)$this->getNumber(); + } + + public function getUrl(): ?string + { + return $this->options['route'] ? (string)$this->options['route']->getUri() : null; + } + + protected function setOptions(array $options): void + { + $this->options = $options; + } +} diff --git a/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php b/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php new file mode 100644 index 0000000..0a14525 --- /dev/null +++ b/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php @@ -0,0 +1,43 @@ +initialize($route, $total, $pos, $limit, $options); + } +} diff --git a/system/src/Grav/Framework/Pagination/PaginationPage.php b/system/src/Grav/Framework/Pagination/PaginationPage.php new file mode 100644 index 0000000..ca35a8d --- /dev/null +++ b/system/src/Grav/Framework/Pagination/PaginationPage.php @@ -0,0 +1,18 @@ +setOptions($options); + } +} diff --git a/system/src/Grav/Framework/Parsedown/Parsedown.php b/system/src/Grav/Framework/Parsedown/Parsedown.php new file mode 100644 index 0000000..20b634d --- /dev/null +++ b/system/src/Grav/Framework/Parsedown/Parsedown.php @@ -0,0 +1,1554 @@ +DefinitionData = array(); + + # standardize line breaks + $text = str_replace(array("\r\n", "\r"), "\n", $text); + + # remove surrounding line breaks + $text = trim($text, "\n"); + + # split text into lines + $lines = explode("\n", $text); + + # iterate through lines to identify blocks + $markup = $this->lines($lines); + + # trim line breaks + $markup = trim($markup, "\n"); + + return $markup; + } + + # + # Setters + # + + function setBreaksEnabled($breaksEnabled) + { + $this->breaksEnabled = $breaksEnabled; + + return $this; + } + + protected $breaksEnabled; + + function setMarkupEscaped($markupEscaped) + { + $this->markupEscaped = $markupEscaped; + + return $this; + } + + protected $markupEscaped; + + function setUrlsLinked($urlsLinked) + { + $this->urlsLinked = $urlsLinked; + + return $this; + } + + protected $urlsLinked = true; + + # + # Lines + # + + protected $BlockTypes = array( + '#' => array('Header'), + '*' => array('Rule', 'List'), + '+' => array('List'), + '-' => array('SetextHeader', 'Table', 'Rule', 'List'), + '0' => array('List'), + '1' => array('List'), + '2' => array('List'), + '3' => array('List'), + '4' => array('List'), + '5' => array('List'), + '6' => array('List'), + '7' => array('List'), + '8' => array('List'), + '9' => array('List'), + ':' => array('Table'), + '<' => array('Comment', 'Markup'), + '=' => array('SetextHeader'), + '>' => array('Quote'), + '[' => array('Reference'), + '_' => array('Rule'), + '`' => array('FencedCode'), + '|' => array('Table'), + '~' => array('FencedCode'), + ); + + # ~ + + protected $unmarkedBlockTypes = array( + 'Code', + ); + + # + # Blocks + # + + protected function lines(array $lines) + { + $CurrentBlock = null; + + foreach ($lines as $line) + { + if (chop($line) === '') + { + if (isset($CurrentBlock)) + { + $CurrentBlock['interrupted'] = true; + } + + continue; + } + + if (strpos($line, "\t") !== false) + { + $parts = explode("\t", $line); + + $line = $parts[0]; + + unset($parts[0]); + + foreach ($parts as $part) + { + $shortage = 4 - mb_strlen($line, 'utf-8') % 4; + + $line .= str_repeat(' ', $shortage); + $line .= $part; + } + } + + $indent = 0; + + while (isset($line[$indent]) and $line[$indent] === ' ') + { + $indent ++; + } + + $text = $indent > 0 ? substr($line, $indent) : $line; + + # ~ + + $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); + + # ~ + + if (isset($CurrentBlock['continuable'])) + { + $Block = $this->{'block'.$CurrentBlock['type'].'Continue'}($Line, $CurrentBlock); + + if (isset($Block)) + { + $CurrentBlock = $Block; + + continue; + } + else + { + if ($this->isBlockCompletable($CurrentBlock['type'])) + { + $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock); + } + } + } + + # ~ + + $marker = $text[0]; + + # ~ + + $blockTypes = $this->unmarkedBlockTypes; + + if (isset($this->BlockTypes[$marker])) + { + foreach ($this->BlockTypes[$marker] as $blockType) + { + $blockTypes []= $blockType; + } + } + + # + # ~ + + foreach ($blockTypes as $blockType) + { + $Block = $this->{'block'.$blockType}($Line, $CurrentBlock); + + if (isset($Block)) + { + $Block['type'] = $blockType; + + if ( ! isset($Block['identified'])) + { + $Blocks []= $CurrentBlock; + + $Block['identified'] = true; + } + + if ($this->isBlockContinuable($blockType)) + { + $Block['continuable'] = true; + } + + $CurrentBlock = $Block; + + continue 2; + } + } + + # ~ + + if (isset($CurrentBlock) and ! isset($CurrentBlock['type']) and ! isset($CurrentBlock['interrupted'])) + { + $CurrentBlock['element']['text'] .= "\n".$text; + } + else + { + $Blocks []= $CurrentBlock; + + $CurrentBlock = $this->paragraph($Line); + + $CurrentBlock['identified'] = true; + } + } + + # ~ + + if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) + { + $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock); + } + + # ~ + + $Blocks []= $CurrentBlock; + + unset($Blocks[0]); + + # ~ + + $markup = ''; + + foreach ($Blocks as $Block) + { + if (isset($Block['hidden'])) + { + continue; + } + + $markup .= "\n"; + $markup .= isset($Block['markup']) ? $Block['markup'] : $this->element($Block['element']); + } + + $markup .= "\n"; + + # ~ + + return $markup; + } + + protected function isBlockContinuable($Type) + { + return method_exists($this, 'block'.$Type.'Continue'); + } + + protected function isBlockCompletable($Type) + { + return method_exists($this, 'block'.$Type.'Complete'); + } + + # + # Code + + protected function blockCode($Line, $Block = null) + { + if (isset($Block) and ! isset($Block['type']) and ! isset($Block['interrupted'])) + { + return; + } + + if ($Line['indent'] >= 4) + { + $text = substr($Line['body'], 4); + + $Block = array( + 'element' => array( + 'name' => 'pre', + 'handler' => 'element', + 'text' => array( + 'name' => 'code', + 'text' => $text, + ), + ), + ); + + return $Block; + } + } + + protected function blockCodeContinue($Line, $Block) + { + if ($Line['indent'] >= 4) + { + if (isset($Block['interrupted'])) + { + $Block['element']['text']['text'] .= "\n"; + + unset($Block['interrupted']); + } + + $Block['element']['text']['text'] .= "\n"; + + $text = substr($Line['body'], 4); + + $Block['element']['text']['text'] .= $text; + + return $Block; + } + } + + protected function blockCodeComplete($Block) + { + $text = $Block['element']['text']['text']; + + $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8'); + + $Block['element']['text']['text'] = $text; + + return $Block; + } + + # + # Comment + + protected function blockComment($Line) + { + if ($this->markupEscaped) + { + return; + } + + if (isset($Line['text'][3]) and $Line['text'][3] === '-' and $Line['text'][2] === '-' and $Line['text'][1] === '!') + { + $Block = array( + 'markup' => $Line['body'], + ); + + if (preg_match('/-->$/', $Line['text'])) + { + $Block['closed'] = true; + } + + return $Block; + } + } + + protected function blockCommentContinue($Line, array $Block) + { + if (isset($Block['closed'])) + { + return; + } + + $Block['markup'] .= "\n" . $Line['body']; + + if (preg_match('/-->$/', $Line['text'])) + { + $Block['closed'] = true; + } + + return $Block; + } + + # + # Fenced Code + + protected function blockFencedCode($Line) + { + if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([\w-]+)?[ ]*$/', $Line['text'], $matches)) + { + $Element = array( + 'name' => 'code', + 'text' => '', + ); + + if (isset($matches[1])) + { + $class = 'language-'.$matches[1]; + + $Element['attributes'] = array( + 'class' => $class, + ); + } + + $Block = array( + 'char' => $Line['text'][0], + 'element' => array( + 'name' => 'pre', + 'handler' => 'element', + 'text' => $Element, + ), + ); + + return $Block; + } + } + + protected function blockFencedCodeContinue($Line, $Block) + { + if (isset($Block['complete'])) + { + return; + } + + if (isset($Block['interrupted'])) + { + $Block['element']['text']['text'] .= "\n"; + + unset($Block['interrupted']); + } + + if (preg_match('/^'.$Block['char'].'{3,}[ ]*$/', $Line['text'])) + { + $Block['element']['text']['text'] = substr($Block['element']['text']['text'], 1); + + $Block['complete'] = true; + + return $Block; + } + + $Block['element']['text']['text'] .= "\n".$Line['body']; + + return $Block; + } + + protected function blockFencedCodeComplete($Block) + { + $text = $Block['element']['text']['text']; + + $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8'); + + $Block['element']['text']['text'] = $text; + + return $Block; + } + + # + # Header + + protected function blockHeader($Line) + { + if (isset($Line['text'][1])) + { + $level = 1; + + while (isset($Line['text'][$level]) and $Line['text'][$level] === '#') + { + $level ++; + } + + if ($level > 6) + { + return; + } + + $text = trim($Line['text'], '# '); + + $Block = array( + 'element' => array( + 'name' => 'h' . min(6, $level), + 'text' => $text, + 'handler' => 'line', + ), + ); + + return $Block; + } + } + + # + # List + + protected function blockList($Line) + { + list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]+[.]'); + + if (preg_match('/^('.$pattern.'[ ]+)(.*)/', $Line['text'], $matches)) + { + $Block = array( + 'indent' => $Line['indent'], + 'pattern' => $pattern, + 'element' => array( + 'name' => $name, + 'handler' => 'elements', + ), + ); + + if($name === 'ol') + { + $listStart = stristr($matches[0], '.', true); + + if($listStart !== '1') + { + $Block['element']['attributes'] = array('start' => $listStart); + } + } + + $Block['li'] = array( + 'name' => 'li', + 'handler' => 'li', + 'text' => array( + $matches[2], + ), + ); + + $Block['element']['text'] []= & $Block['li']; + + return $Block; + } + } + + protected function blockListContinue($Line, array $Block) + { + if ($Block['indent'] === $Line['indent'] and preg_match('/^'.$Block['pattern'].'(?:[ ]+(.*)|$)/', $Line['text'], $matches)) + { + if (isset($Block['interrupted'])) + { + $Block['li']['text'] []= ''; + + unset($Block['interrupted']); + } + + unset($Block['li']); + + $text = isset($matches[1]) ? $matches[1] : ''; + + $Block['li'] = array( + 'name' => 'li', + 'handler' => 'li', + 'text' => array( + $text, + ), + ); + + $Block['element']['text'] []= & $Block['li']; + + return $Block; + } + + if ($Line['text'][0] === '[' and $this->blockReference($Line)) + { + return $Block; + } + + if ( ! isset($Block['interrupted'])) + { + $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); + + $Block['li']['text'] []= $text; + + return $Block; + } + + if ($Line['indent'] > 0) + { + $Block['li']['text'] []= ''; + + $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); + + $Block['li']['text'] []= $text; + + unset($Block['interrupted']); + + return $Block; + } + } + + # + # Quote + + protected function blockQuote($Line) + { + if (preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) + { + $Block = array( + 'element' => array( + 'name' => 'blockquote', + 'handler' => 'lines', + 'text' => (array) $matches[1], + ), + ); + + return $Block; + } + } + + protected function blockQuoteContinue($Line, array $Block) + { + if ($Line['text'][0] === '>' and preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) + { + if (isset($Block['interrupted'])) + { + $Block['element']['text'] []= ''; + + unset($Block['interrupted']); + } + + $Block['element']['text'] []= $matches[1]; + + return $Block; + } + + if ( ! isset($Block['interrupted'])) + { + $Block['element']['text'] []= $Line['text']; + + return $Block; + } + } + + # + # Rule + + protected function blockRule($Line) + { + if (preg_match('/^(['.$Line['text'][0].'])([ ]*\1){2,}[ ]*$/', $Line['text'])) + { + $Block = array( + 'element' => array( + 'name' => 'hr' + ), + ); + + return $Block; + } + } + + # + # Setext + + protected function blockSetextHeader($Line, array $Block = null) + { + if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted'])) + { + return; + } + + if (chop($Line['text'], $Line['text'][0]) === '') + { + $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; + + return $Block; + } + } + + # + # Markup + + protected function blockMarkup($Line) + { + if ($this->markupEscaped) + { + return; + } + + if (preg_match('/^<(\w*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) + { + $element = strtolower($matches[1]); + + if (in_array($element, $this->textLevelElements)) + { + return; + } + + $Block = array( + 'name' => $matches[1], + 'depth' => 0, + 'markup' => $Line['text'], + ); + + $length = strlen($matches[0]); + + $remainder = substr($Line['text'], $length); + + if (trim($remainder) === '') + { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) + { + $Block['closed'] = true; + + $Block['void'] = true; + } + } + else + { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) + { + return; + } + + if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder)) + { + $Block['closed'] = true; + } + } + + return $Block; + } + } + + protected function blockMarkupContinue($Line, array $Block) + { + if (isset($Block['closed'])) + { + return; + } + + if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open + { + $Block['depth'] ++; + } + + if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close + { + if ($Block['depth'] > 0) + { + $Block['depth'] --; + } + else + { + $Block['closed'] = true; + } + } + + if (isset($Block['interrupted'])) + { + $Block['markup'] .= "\n"; + + unset($Block['interrupted']); + } + + $Block['markup'] .= "\n".$Line['body']; + + return $Block; + } + + # + # Reference + + protected function blockReference($Line) + { + if (preg_match('/^\[(.+?)\]:[ ]*?(?:[ ]+["\'(](.+)["\')])?[ ]*$/', $Line['text'], $matches)) + { + $id = strtolower($matches[1]); + + $Data = array( + 'url' => $matches[2], + 'title' => null, + ); + + if (isset($matches[3])) + { + $Data['title'] = $matches[3]; + } + + $this->DefinitionData['Reference'][$id] = $Data; + + $Block = array( + 'hidden' => true, + ); + + return $Block; + } + } + + # + # Table + + protected function blockTable($Line, array $Block = null) + { + if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted'])) + { + return; + } + + if (strpos($Block['element']['text'], '|') !== false and chop($Line['text'], ' -:|') === '') + { + $alignments = array(); + + $divider = $Line['text']; + + $divider = trim($divider); + $divider = trim($divider, '|'); + + $dividerCells = explode('|', $divider); + + foreach ($dividerCells as $dividerCell) + { + $dividerCell = trim($dividerCell); + + if ($dividerCell === '') + { + continue; + } + + $alignment = null; + + if ($dividerCell[0] === ':') + { + $alignment = 'left'; + } + + if (substr($dividerCell, - 1) === ':') + { + $alignment = $alignment === 'left' ? 'center' : 'right'; + } + + $alignments []= $alignment; + } + + # ~ + + $HeaderElements = array(); + + $header = $Block['element']['text']; + + $header = trim($header); + $header = trim($header, '|'); + + $headerCells = explode('|', $header); + + foreach ($headerCells as $index => $headerCell) + { + $headerCell = trim($headerCell); + + $HeaderElement = array( + 'name' => 'th', + 'text' => $headerCell, + 'handler' => 'line', + ); + + if (isset($alignments[$index])) + { + $alignment = $alignments[$index]; + + $HeaderElement['attributes'] = array( + 'style' => 'text-align: '.$alignment.';', + ); + } + + $HeaderElements []= $HeaderElement; + } + + # ~ + + $Block = array( + 'alignments' => $alignments, + 'identified' => true, + 'element' => array( + 'name' => 'table', + 'handler' => 'elements', + ), + ); + + $Block['element']['text'] []= array( + 'name' => 'thead', + 'handler' => 'elements', + ); + + $Block['element']['text'] []= array( + 'name' => 'tbody', + 'handler' => 'elements', + 'text' => array(), + ); + + $Block['element']['text'][0]['text'] []= array( + 'name' => 'tr', + 'handler' => 'elements', + 'text' => $HeaderElements, + ); + + return $Block; + } + } + + protected function blockTableContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) + { + return; + } + + if ($Line['text'][0] === '|' or strpos($Line['text'], '|')) + { + $Elements = array(); + + $row = $Line['text']; + + $row = trim($row); + $row = trim($row, '|'); + + preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]+`|`)+/', $row, $matches); + + foreach ($matches[0] as $index => $cell) + { + $cell = trim($cell); + + $Element = array( + 'name' => 'td', + 'handler' => 'line', + 'text' => $cell, + ); + + if (isset($Block['alignments'][$index])) + { + $Element['attributes'] = array( + 'style' => 'text-align: '.$Block['alignments'][$index].';', + ); + } + + $Elements []= $Element; + } + + $Element = array( + 'name' => 'tr', + 'handler' => 'elements', + 'text' => $Elements, + ); + + $Block['element']['text'][1]['text'] []= $Element; + + return $Block; + } + } + + # + # ~ + # + + protected function paragraph($Line) + { + $Block = array( + 'element' => array( + 'name' => 'p', + 'text' => $Line['text'], + 'handler' => 'line', + ), + ); + + return $Block; + } + + # + # Inline Elements + # + + protected $InlineTypes = array( + '"' => array('SpecialCharacter'), + '!' => array('Image'), + '&' => array('SpecialCharacter'), + '*' => array('Emphasis'), + ':' => array('Url'), + '<' => array('UrlTag', 'EmailTag', 'Markup', 'SpecialCharacter'), + '>' => array('SpecialCharacter'), + '[' => array('Link'), + '_' => array('Emphasis'), + '`' => array('Code'), + '~' => array('Strikethrough'), + '\\' => array('EscapeSequence'), + ); + + # ~ + + protected $inlineMarkerList = '!"*_&[:<>`~\\'; + + # + # ~ + # + + public function line($text) + { + $markup = ''; + + # $excerpt is based on the first occurrence of a marker + + while ($excerpt = strpbrk($text, $this->inlineMarkerList)) + { + $marker = $excerpt[0]; + + $markerPosition = strpos($text, $marker); + + $Excerpt = array('text' => $excerpt, 'context' => $text); + + foreach ($this->InlineTypes[$marker] as $inlineType) + { + $Inline = $this->{'inline'.$inlineType}($Excerpt); + + if ( ! isset($Inline)) + { + continue; + } + + # makes sure that the inline belongs to "our" marker + + if (isset($Inline['position']) and $Inline['position'] > $markerPosition) + { + continue; + } + + # sets a default inline position + + if ( ! isset($Inline['position'])) + { + $Inline['position'] = $markerPosition; + } + + # the text that comes before the inline + $unmarkedText = substr($text, 0, $Inline['position']); + + # compile the unmarked text + $markup .= $this->unmarkedText($unmarkedText); + + # compile the inline + $markup .= isset($Inline['markup']) ? $Inline['markup'] : $this->element($Inline['element']); + + # remove the examined text + $text = substr($text, $Inline['position'] + $Inline['extent']); + + continue 2; + } + + # the marker does not belong to an inline + + $unmarkedText = substr($text, 0, $markerPosition + 1); + + $markup .= $this->unmarkedText($unmarkedText); + + $text = substr($text, $markerPosition + 1); + } + + $markup .= $this->unmarkedText($text); + + return $markup; + } + + # + # ~ + # + + protected function inlineCode($Excerpt) + { + $marker = $Excerpt['text'][0]; + + if (preg_match('/^('.$marker.'+)[ ]*(.+?)[ ]*(? strlen($matches[0]), + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ); + } + } + + protected function inlineEmailTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<((mailto:)?\S+?@\S+?)>/i', $Excerpt['text'], $matches)) + { + $url = $matches[1]; + + if ( ! isset($matches[2])) + { + $url = 'mailto:' . $url; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $matches[1], + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + protected function inlineEmphasis($Excerpt) + { + if ( ! isset($Excerpt['text'][1])) + { + return; + } + + $marker = $Excerpt['text'][0]; + + if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) + { + $emphasis = 'strong'; + } + elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) + { + $emphasis = 'em'; + } + else + { + return; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => $emphasis, + 'handler' => 'line', + 'text' => $matches[1], + ), + ); + } + + protected function inlineEscapeSequence($Excerpt) + { + if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) + { + return array( + 'markup' => $Excerpt['text'][1], + 'extent' => 2, + ); + } + } + + protected function inlineImage($Excerpt) + { + if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') + { + return; + } + + $Excerpt['text']= substr($Excerpt['text'], 1); + + $Link = $this->inlineLink($Excerpt); + + if ($Link === null) + { + return; + } + + $Inline = array( + 'extent' => $Link['extent'] + 1, + 'element' => array( + 'name' => 'img', + 'attributes' => array( + 'src' => $Link['element']['attributes']['href'], + 'alt' => $Link['element']['text'], + ), + ), + ); + + $Inline['element']['attributes'] += $Link['element']['attributes']; + + unset($Inline['element']['attributes']['href']); + + return $Inline; + } + + protected function inlineLink($Excerpt) + { + $Element = array( + 'name' => 'a', + 'handler' => 'line', + 'text' => null, + 'attributes' => array( + 'href' => null, + 'title' => null, + ), + ); + + $extent = 0; + + $remainder = $Excerpt['text']; + + if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) + { + $Element['text'] = $matches[1]; + + $extent += strlen($matches[0]); + + $remainder = substr($remainder, $extent); + } + else + { + return; + } + + if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches)) + { + $Element['attributes']['href'] = $matches[1]; + + if (isset($matches[2])) + { + $Element['attributes']['title'] = substr($matches[2], 1, - 1); + } + + $extent += strlen($matches[0]); + } + else + { + if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) + { + $definition = strlen($matches[1]) ? $matches[1] : $Element['text']; + $definition = strtolower($definition); + + $extent += strlen($matches[0]); + } + else + { + $definition = strtolower($Element['text']); + } + + if ( ! isset($this->DefinitionData['Reference'][$definition])) + { + return; + } + + $Definition = $this->DefinitionData['Reference'][$definition]; + + $Element['attributes']['href'] = $Definition['url']; + $Element['attributes']['title'] = $Definition['title']; + } + + $Element['attributes']['href'] = str_replace(array('&', '<'), array('&', '<'), $Element['attributes']['href']); + + return array( + 'extent' => $extent, + 'element' => $Element, + ); + } + + protected function inlineMarkup($Excerpt) + { + if ($this->markupEscaped or strpos($Excerpt['text'], '>') === false) + { + return; + } + + if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w*[ ]*>/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\/?>/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + } + + protected function inlineSpecialCharacter($Excerpt) + { + if ($Excerpt['text'][0] === '&' and ! preg_match('/^&#?\w+;/', $Excerpt['text'])) + { + return array( + 'markup' => '&', + 'extent' => 1, + ); + } + + $SpecialCharacter = array('>' => 'gt', '<' => 'lt', '"' => 'quot'); + + if (isset($SpecialCharacter[$Excerpt['text'][0]])) + { + return array( + 'markup' => '&'.$SpecialCharacter[$Excerpt['text'][0]].';', + 'extent' => 1, + ); + } + } + + protected function inlineStrikethrough($Excerpt) + { + if ( ! isset($Excerpt['text'][1])) + { + return; + } + + if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) + { + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'del', + 'text' => $matches[1], + 'handler' => 'line', + ), + ); + } + } + + protected function inlineUrl($Excerpt) + { + if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') + { + return; + } + + if (preg_match('/\bhttps?:[\/]{2}[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) + { + $Inline = array( + 'extent' => strlen($matches[0][0]), + 'position' => $matches[0][1], + 'element' => array( + 'name' => 'a', + 'text' => $matches[0][0], + 'attributes' => array( + 'href' => $matches[0][0], + ), + ), + ); + + return $Inline; + } + } + + protected function inlineUrlTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w+:\/{2}[^ >]+)>/i', $Excerpt['text'], $matches)) + { + $url = str_replace(array('&', '<'), array('&', '<'), $matches[1]); + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + # ~ + + protected function unmarkedText($text) + { + if ($this->breaksEnabled) + { + $text = preg_replace('/[ ]*\n/', "
    \n", $text); + } + else + { + $text = preg_replace('/(?:[ ][ ]+|[ ]*\\\\)\n/', "
    \n", $text); + $text = str_replace(" \n", "\n", $text); + } + + return $text; + } + + # + # Handlers + # + + protected function element(array $Element) + { + $markup = '<'.$Element['name']; + + if (isset($Element['attributes'])) + { + foreach ($Element['attributes'] as $name => $value) + { + if ($value === null) + { + continue; + } + + $markup .= ' '.$name.'="'.$value.'"'; + } + } + + if (isset($Element['text'])) + { + $markup .= '>'; + + if (isset($Element['handler'])) + { + $markup .= $this->{$Element['handler']}($Element['text']); + } + else + { + $markup .= $Element['text']; + } + + $markup .= ''; + } + else + { + $markup .= ' />'; + } + + return $markup; + } + + protected function elements(array $Elements) + { + $markup = ''; + + foreach ($Elements as $Element) + { + $markup .= "\n" . $this->element($Element); + } + + $markup .= "\n"; + + return $markup; + } + + # ~ + + protected function li($lines) + { + $markup = $this->lines($lines); + + $trimmedMarkup = trim($markup); + + if ( ! in_array('', $lines) and substr($trimmedMarkup, 0, 3) === '

    ') + { + $markup = $trimmedMarkup; + $markup = substr($markup, 3); + + $position = strpos($markup, "

    "); + + $markup = substr_replace($markup, '', $position, 4); + } + + return $markup; + } + + # + # Deprecated Methods + # + + function parse($text) + { + $markup = $this->text($text); + + return $markup; + } + + # + # Static Methods + # + + static function instance($name = 'default') + { + if (isset(self::$instances[$name])) + { + return self::$instances[$name]; + } + + $instance = new static(); + + self::$instances[$name] = $instance; + + return $instance; + } + + private static $instances = array(); + + # + # Fields + # + + protected $DefinitionData; + + # + # Read-Only + + protected $specialCharacters = array( + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', + ); + + protected $StrongRegex = array( + '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s', + '_' => '/^__((?:\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us', + ); + + protected $EmRegex = array( + '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', + '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', + ); + + protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:[^"\'=<>`\s]+|"[^"]*"|\'[^\']*\'))?'; + + protected $voidElements = array( + 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + ); + + protected $textLevelElements = array( + 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', + 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', + 'i', 'rp', 'del', 'code', 'strike', 'marquee', + 'q', 'rt', 'ins', 'font', 'strong', + 's', 'tt', 'kbd', 'mark', + 'u', 'xm', 'sub', 'nobr', + 'sup', 'ruby', + 'var', 'span', + 'wbr', 'time', + ); +} diff --git a/system/src/Grav/Framework/Parsedown/ParsedownExtra.php b/system/src/Grav/Framework/Parsedown/ParsedownExtra.php new file mode 100644 index 0000000..8219005 --- /dev/null +++ b/system/src/Grav/Framework/Parsedown/ParsedownExtra.php @@ -0,0 +1,532 @@ +BlockTypes[':'] []= 'DefinitionList'; + $this->BlockTypes['*'] []= 'Abbreviation'; + + # identify footnote definitions before reference definitions + array_unshift($this->BlockTypes['['], 'Footnote'); + + # identify footnote markers before before links + array_unshift($this->InlineTypes['['], 'FootnoteMarker'); + } + + # + # ~ + + function text($text) + { + $markup = parent::text($text); + + # merge consecutive dl elements + + $markup = preg_replace('/<\/dl>\s+
    \s+/', '', $markup); + + # add footnotes + + if (isset($this->DefinitionData['Footnote'])) + { + $Element = $this->buildFootnoteElement(); + + $markup .= "\n" . $this->element($Element); + } + + return $markup; + } + + # + # Blocks + # + + # + # Abbreviation + + protected function blockAbbreviation($Line) + { + if (preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $Line['text'], $matches)) + { + $this->DefinitionData['Abbreviation'][$matches[1]] = $matches[2]; + + $Block = array( + 'hidden' => true, + ); + + return $Block; + } + } + + # + # Footnote + + protected function blockFootnote($Line) + { + if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $Line['text'], $matches)) + { + $Block = array( + 'label' => $matches[1], + 'text' => $matches[2], + 'hidden' => true, + ); + + return $Block; + } + } + + protected function blockFootnoteContinue($Line, $Block) + { + if ($Line['text'][0] === '[' and preg_match('/^\[\^(.+?)\]:/', $Line['text'])) + { + return; + } + + if (isset($Block['interrupted'])) + { + if ($Line['indent'] >= 4) + { + $Block['text'] .= "\n\n" . $Line['text']; + + return $Block; + } + } + else + { + $Block['text'] .= "\n" . $Line['text']; + + return $Block; + } + } + + protected function blockFootnoteComplete($Block) + { + $this->DefinitionData['Footnote'][$Block['label']] = array( + 'text' => $Block['text'], + 'count' => null, + 'number' => null, + ); + + return $Block; + } + + # + # Definition List + + protected function blockDefinitionList($Line, $Block) + { + if ( ! isset($Block) or isset($Block['type'])) + { + return; + } + + $Element = array( + 'name' => 'dl', + 'handler' => 'elements', + 'text' => array(), + ); + + $terms = explode("\n", $Block['element']['text']); + + foreach ($terms as $term) + { + $Element['text'] []= array( + 'name' => 'dt', + 'handler' => 'line', + 'text' => $term, + ); + } + + $Block['element'] = $Element; + + $Block = $this->addDdElement($Line, $Block); + + return $Block; + } + + protected function blockDefinitionListContinue($Line, array $Block) + { + if ($Line['text'][0] === ':') + { + $Block = $this->addDdElement($Line, $Block); + + return $Block; + } + else + { + if (isset($Block['interrupted']) and $Line['indent'] === 0) + { + return; + } + + if (isset($Block['interrupted'])) + { + $Block['dd']['handler'] = 'text'; + $Block['dd']['text'] .= "\n\n"; + + unset($Block['interrupted']); + } + + $text = substr($Line['body'], min($Line['indent'], 4)); + + $Block['dd']['text'] .= "\n" . $text; + + return $Block; + } + } + + # + # Header + + protected function blockHeader($Line) + { + $Block = parent::blockHeader($Line); + + if ($Block !== null && preg_match('/[ #]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['text'], $matches, PREG_OFFSET_CAPTURE)) + { + $attributeString = $matches[1][0]; + + $Block['element']['attributes'] = $this->parseAttributeData($attributeString); + + $Block['element']['text'] = substr($Block['element']['text'], 0, $matches[0][1]); + } + + return $Block; + } + + # + # Markup + + protected function blockMarkupComplete($Block) + { + if ( ! isset($Block['void'])) + { + $Block['markup'] = $this->processTag($Block['markup']); + } + + return $Block; + } + + # + # Setext + + protected function blockSetextHeader($Line, array $Block = null) + { + $Block = parent::blockSetextHeader($Line, $Block); + + if ($Block !== null && preg_match('/[ ]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['text'], $matches, PREG_OFFSET_CAPTURE)) + { + $attributeString = $matches[1][0]; + + $Block['element']['attributes'] = $this->parseAttributeData($attributeString); + + $Block['element']['text'] = substr($Block['element']['text'], 0, $matches[0][1]); + } + + return $Block; + } + + # + # Inline Elements + # + + # + # Footnote Marker + + protected function inlineFootnoteMarker($Excerpt) + { + if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches)) + { + $name = $matches[1]; + + if ( ! isset($this->DefinitionData['Footnote'][$name])) + { + return; + } + + $this->DefinitionData['Footnote'][$name]['count'] ++; + + if ( ! isset($this->DefinitionData['Footnote'][$name]['number'])) + { + $this->DefinitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; # » & + } + + $Element = array( + 'name' => 'sup', + 'attributes' => array('id' => 'fnref'.$this->DefinitionData['Footnote'][$name]['count'].':'.$name), + 'handler' => 'element', + 'text' => array( + 'name' => 'a', + 'attributes' => array('href' => '#fn:'.$name, 'class' => 'footnote-ref'), + 'text' => $this->DefinitionData['Footnote'][$name]['number'], + ), + ); + + return array( + 'extent' => strlen($matches[0]), + 'element' => $Element, + ); + } + } + + private $footnoteCount = 0; + + # + # Link + + protected function inlineLink($Excerpt) + { + $Link = parent::inlineLink($Excerpt); + + $remainder = $Link !== null ? substr($Excerpt['text'], $Link['extent']) : ''; + + if (preg_match('/^[ ]*{('.$this->regexAttribute.'+)}/', $remainder, $matches)) + { + $Link['element']['attributes'] += $this->parseAttributeData($matches[1]); + + $Link['extent'] += strlen($matches[0]); + } + + return $Link; + } + + # + # ~ + # + + protected function unmarkedText($text) + { + $text = parent::unmarkedText($text); + + if (isset($this->DefinitionData['Abbreviation'])) + { + foreach ($this->DefinitionData['Abbreviation'] as $abbreviation => $meaning) + { + $pattern = '/\b'.preg_quote($abbreviation, '/').'\b/'; + + $text = preg_replace($pattern, ''.$abbreviation.'', $text); + } + } + + return $text; + } + + # + # Util Methods + # + + protected function addDdElement(array $Line, array $Block) + { + $text = substr($Line['text'], 1); + $text = trim($text); + + unset($Block['dd']); + + $Block['dd'] = array( + 'name' => 'dd', + 'handler' => 'line', + 'text' => $text, + ); + + if (isset($Block['interrupted'])) + { + $Block['dd']['handler'] = 'text'; + + unset($Block['interrupted']); + } + + $Block['element']['text'] []= & $Block['dd']; + + return $Block; + } + + protected function buildFootnoteElement() + { + $Element = array( + 'name' => 'div', + 'attributes' => array('class' => 'footnotes'), + 'handler' => 'elements', + 'text' => array( + array( + 'name' => 'hr', + ), + array( + 'name' => 'ol', + 'handler' => 'elements', + 'text' => array(), + ), + ), + ); + + uasort($this->DefinitionData['Footnote'], 'self::sortFootnotes'); + + foreach ($this->DefinitionData['Footnote'] as $definitionId => $DefinitionData) + { + if ( ! isset($DefinitionData['number'])) + { + continue; + } + + $text = $DefinitionData['text']; + + $text = parent::text($text); + + $numbers = range(1, $DefinitionData['count']); + + $backLinksMarkup = ''; + + foreach ($numbers as $number) + { + $backLinksMarkup .= ' '; + } + + $backLinksMarkup = substr($backLinksMarkup, 1); + + if (substr($text, - 4) === '

    ') + { + $backLinksMarkup = ' '.$backLinksMarkup; + + $text = substr_replace($text, $backLinksMarkup.'

    ', - 4); + } + else + { + $text .= "\n".'

    '.$backLinksMarkup.'

    '; + } + + $Element['text'][1]['text'] []= array( + 'name' => 'li', + 'attributes' => array('id' => 'fn:'.$definitionId), + 'text' => "\n".$text."\n", + ); + } + + return $Element; + } + + # ~ + + protected function parseAttributeData($attributeString) + { + $Data = array(); + + $attributes = preg_split('/[ ]+/', $attributeString, - 1, PREG_SPLIT_NO_EMPTY); + + foreach ($attributes as $attribute) + { + if ($attribute[0] === '#') + { + $Data['id'] = substr($attribute, 1); + } + else # "." + { + $classes []= substr($attribute, 1); + } + } + + if (isset($classes)) + { + $Data['class'] = implode(' ', $classes); + } + + return $Data; + } + + # ~ + + protected function processTag($elementMarkup) # recursive + { + # http://stackoverflow.com/q/1148928/200145 + libxml_use_internal_errors(true); + + $DOMDocument = new \DOMDocument; + + # http://stackoverflow.com/q/11309194/200145 + $elementMarkup = mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8'); + + # http://stackoverflow.com/q/4879946/200145 + $DOMDocument->loadHTML($elementMarkup); + $DOMDocument->removeChild($DOMDocument->doctype); + $DOMDocument->replaceChild($DOMDocument->firstChild->firstChild->firstChild, $DOMDocument->firstChild); + + $elementText = ''; + + if ($DOMDocument->documentElement->getAttribute('markdown') === '1') + { + foreach ($DOMDocument->documentElement->childNodes as $Node) + { + $elementText .= $DOMDocument->saveHTML($Node); + } + + $DOMDocument->documentElement->removeAttribute('markdown'); + + $elementText = "\n".$this->text($elementText)."\n"; + } + else + { + foreach ($DOMDocument->documentElement->childNodes as $Node) + { + $nodeMarkup = $DOMDocument->saveHTML($Node); + + if ($Node instanceof \DOMElement and ! in_array($Node->nodeName, $this->textLevelElements)) + { + $elementText .= $this->processTag($nodeMarkup); + } + else + { + $elementText .= $nodeMarkup; + } + } + } + + # because we don't want for markup to get encoded + $DOMDocument->documentElement->nodeValue = 'placeholder\x1A'; + + $markup = $DOMDocument->saveHTML($DOMDocument->documentElement); + $markup = str_replace('placeholder\x1A', $elementText, $markup); + + return $markup; + } + + # ~ + + protected function sortFootnotes($A, $B) # callback + { + return $A['number'] - $B['number']; + } + + # + # Fields + # + + protected $regexAttribute = '(?:[#.][-\w]+[ ]*)'; +} diff --git a/system/src/Grav/Framework/Psr7/AbstractUri.php b/system/src/Grav/Framework/Psr7/AbstractUri.php new file mode 100644 index 0000000..e0d46b6 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/AbstractUri.php @@ -0,0 +1,408 @@ + 80, + 'https' => 443 + ]; + + /** @var string Uri scheme. */ + private $scheme = ''; + + /** @var string Uri user. */ + private $user = ''; + + /** @var string Uri password. */ + private $password = ''; + + /** @var string Uri host. */ + private $host = ''; + + /** @var int|null Uri port. */ + private $port; + + /** @var string Uri path. */ + private $path = ''; + + /** @var string Uri query string (without ?). */ + private $query = ''; + + /** @var string Uri fragment (without #). */ + private $fragment = ''; + + /** + * Please define constructor which calls $this->init(). + */ + abstract public function __construct(); + + /** + * @inheritdoc + */ + public function getScheme() + { + return $this->scheme; + } + + /** + * @inheritdoc + */ + public function getAuthority() + { + $authority = $this->host; + + $userInfo = $this->getUserInfo(); + if ($userInfo !== '') { + $authority = $userInfo . '@' . $authority; + } + + if ($this->port !== null) { + $authority .= ':' . $this->port; + } + + return $authority; + } + + /** + * @inheritdoc + */ + public function getUserInfo() + { + $userInfo = $this->user; + + if ($this->password !== '') { + $userInfo .= ':' . $this->password; + } + + return $userInfo; + } + + /** + * @inheritdoc + */ + public function getHost() + { + return $this->host; + } + + /** + * @inheritdoc + */ + public function getPort() + { + return $this->port; + } + + /** + * @inheritdoc + */ + public function getPath() + { + return $this->path; + } + + /** + * @inheritdoc + */ + public function getQuery() + { + return $this->query; + } + + /** + * @inheritdoc + */ + public function getFragment() + { + return $this->fragment; + } + + /** + * @inheritdoc + */ + public function withScheme($scheme) + { + $scheme = UriPartsFilter::filterScheme($scheme); + + if ($this->scheme === $scheme) { + return $this; + } + + $new = clone $this; + $new->scheme = $scheme; + $new->unsetDefaultPort(); + $new->validate(); + + return $new; + } + + /** + * @inheritdoc + * @throws \InvalidArgumentException + */ + public function withUserInfo($user, $password = null) + { + $user = UriPartsFilter::filterUserInfo($user); + $password = UriPartsFilter::filterUserInfo($password ?? ''); + + if ($this->user === $user && $this->password === $password) { + return $this; + } + + $new = clone $this; + $new->user = $user; + $new->password = $user !== '' ? $password : ''; + $new->validate(); + + return $new; + } + + /** + * @inheritdoc + */ + public function withHost($host) + { + $host = UriPartsFilter::filterHost($host); + + if ($this->host === $host) { + return $this; + } + + $new = clone $this; + $new->host = $host; + $new->validate(); + + return $new; + } + + /** + * @inheritdoc + */ + public function withPort($port) + { + $port = UriPartsFilter::filterPort($port); + + if ($this->port === $port) { + return $this; + } + + $new = clone $this; + $new->port = $port; + $new->unsetDefaultPort(); + $new->validate(); + + return $new; + } + + /** + * @inheritdoc + */ + public function withPath($path) + { + $path = UriPartsFilter::filterPath($path); + + if ($this->path === $path) { + return $this; + } + + $new = clone $this; + $new->path = $path; + $new->validate(); + + return $new; + } + + /** + * @inheritdoc + */ + public function withQuery($query) + { + $query = UriPartsFilter::filterQueryOrFragment($query); + + if ($this->query === $query) { + return $this; + } + + $new = clone $this; + $new->query = $query; + + return $new; + } + + /** + * @inheritdoc + * @throws \InvalidArgumentException + */ + public function withFragment($fragment) + { + $fragment = UriPartsFilter::filterQueryOrFragment($fragment); + + if ($this->fragment === $fragment) { + return $this; + } + + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + /** + * @return string + */ + public function __toString() + { + return $this->getUrl(); + } + + /** + * @return array + */ + protected function getParts() + { + return [ + 'scheme' => $this->scheme, + 'host' => $this->host, + 'port' => $this->port, + 'user' => $this->user, + 'pass' => $this->password, + 'path' => $this->path, + 'query' => $this->query, + 'fragment' => $this->fragment + ]; + } + + /** + * Return the fully qualified base URL ( like http://getgrav.org ). + * + * Note that this method never includes a trailing / + * + * @return string + */ + protected function getBaseUrl() + { + $uri = ''; + + $scheme = $this->getScheme(); + if ($scheme !== '') { + $uri .= $scheme . ':'; + } + + $authority = $this->getAuthority(); + if ($authority !== '' || $scheme === 'file') { + $uri .= '//' . $authority; + } + + return $uri; + } + + /** + * @return string + */ + protected function getUrl() + { + $uri = $this->getBaseUrl() . $this->getPath(); + + $query = $this->getQuery(); + if ($query !== '') { + $uri .= '?' . $query; + } + + $fragment = $this->getFragment(); + if ($fragment !== '') { + $uri .= '#' . $fragment; + } + + return $uri; + } + + /** + * @return string + */ + protected function getUser() + { + return $this->user; + } + + /** + * @return string + */ + protected function getPassword() + { + return $this->password; + } + + /** + * @param array $parts + * @throws \InvalidArgumentException + */ + protected function initParts(array $parts) + { + $this->scheme = isset($parts['scheme']) ? UriPartsFilter::filterScheme($parts['scheme']) : ''; + $this->user = isset($parts['user']) ? UriPartsFilter::filterUserInfo($parts['user']) : ''; + $this->password = isset($parts['pass']) ? UriPartsFilter::filterUserInfo($parts['pass']) : ''; + $this->host = isset($parts['host']) ? UriPartsFilter::filterHost($parts['host']) : ''; + $this->port = isset($parts['port']) ? UriPartsFilter::filterPort((int)$parts['port']) : null; + $this->path = isset($parts['path']) ? UriPartsFilter::filterPath($parts['path']) : ''; + $this->query = isset($parts['query']) ? UriPartsFilter::filterQueryOrFragment($parts['query']) : ''; + $this->fragment = isset($parts['fragment']) ? UriPartsFilter::filterQueryOrFragment($parts['fragment']) : ''; + + $this->unsetDefaultPort(); + $this->validate(); + } + + /** + * @throws \InvalidArgumentException + */ + private function validate() + { + if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) { + throw new \InvalidArgumentException('Uri with a scheme must have a host'); + } + + if ($this->getAuthority() === '') { + if (0 === strpos($this->path, '//')) { + throw new \InvalidArgumentException('The path of a URI without an authority must not start with two slashes \'//\''); + } + if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) { + throw new \InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon'); + } + } elseif (isset($this->path[0]) && $this->path[0] !== '/') { + throw new \InvalidArgumentException('The path of a URI with an authority must start with a slash \'/\' or be empty'); + } + } + + protected function isDefaultPort() + { + $scheme = $this->scheme; + $port = $this->port; + + return $this->port === null + || (isset(static::$defaultPorts[$scheme]) && $port === static::$defaultPorts[$scheme]); + } + + private function unsetDefaultPort() + { + if ($this->isDefaultPort()) { + $this->port = null; + } + } +} diff --git a/system/src/Grav/Framework/Psr7/Request.php b/system/src/Grav/Framework/Psr7/Request.php new file mode 100644 index 0000000..24ea3b6 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Request.php @@ -0,0 +1,34 @@ +message = new \Nyholm\Psr7\Request($method, $uri, $headers, $body, $version); + } +} diff --git a/system/src/Grav/Framework/Psr7/Response.php b/system/src/Grav/Framework/Psr7/Response.php new file mode 100644 index 0000000..960621d --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Response.php @@ -0,0 +1,265 @@ +message = new \Nyholm\Psr7\Response($status, $headers, $body, $version, $reason); + } + + /** + * Json. + * + * Note: This method is not part of the PSR-7 standard. + * + * This method prepares the response object to return an HTTP Json + * response to the client. + * + * @param mixed $data The data + * @param int $status The HTTP status code. + * @param int $options Json encoding options + * @param int $depth Json encoding max depth + * @return static + */ + public function withJson($data, int $status = null, int $options = 0, int $depth = 512): ResponseInterface + { + $json = (string) json_encode($data, $options, $depth); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException(json_last_error_msg(), json_last_error()); + } + + $response = $this->getResponse() + ->withHeader('Content-Type', 'application/json;charset=utf-8') + ->withBody(new Stream($json)); + + if ($status !== null) { + $response = $response->withStatus($status); + } + + $new = clone $this; + $new->message = $response; + + return $new; + } + + /** + * Redirect. + * + * Note: This method is not part of the PSR-7 standard. + * + * This method prepares the response object to return an HTTP Redirect + * response to the client. + * + * @param string $url The redirect destination. + * @param int|null $status The redirect HTTP status code. + * @return static + */ + public function withRedirect(string $url, $status = null): ResponseInterface + { + $response = $this->getResponse()->withHeader('Location', $url); + + if ($status === null) { + $status = 302; + } + + $new = clone $this; + $new->message = $response->withStatus($status); + + return $new; + } + + /** + * Is this response empty? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isEmpty(): bool + { + return \in_array($this->getResponse()->getStatusCode(), [204, 205, 304], true); + } + + + /** + * Is this response OK? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isOk(): bool + { + return $this->getResponse()->getStatusCode() === 200; + } + + /** + * Is this response a redirect? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isRedirect(): bool + { + return \in_array($this->getResponse()->getStatusCode(), [301, 302, 303, 307, 308], true); + } + + /** + * Is this response forbidden? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + * @api + */ + public function isForbidden(): bool + { + return $this->getResponse()->getStatusCode() === 403; + } + + /** + * Is this response not Found? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isNotFound(): bool + { + return $this->getResponse()->getStatusCode() === 404; + } + + /** + * Is this response informational? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isInformational(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 100 && $response->getStatusCode() < 200; + } + + /** + * Is this response successful? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isSuccessful(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 200 && $response->getStatusCode() < 300; + } + + /** + * Is this response a redirection? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isRedirection(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 300 && $response->getStatusCode() < 400; + } + + /** + * Is this response a client error? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isClientError(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 400 && $response->getStatusCode() < 500; + } + + /** + * Is this response a server error? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isServerError(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 500 && $response->getStatusCode() < 600; + } + + /** + * Convert response to string. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string + */ + public function __toString(): string + { + $response = $this->getResponse(); + $output = sprintf( + 'HTTP/%s %s %s%s', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase(), + self::EOL + ); + + foreach ($response->getHeaders() as $name => $values) { + $output .= sprintf('%s: %s', $name, $response->getHeaderLine($name)) . self::EOL; + } + + $output .= self::EOL; + $output .= (string) $response->getBody(); + + return $output; + } +} diff --git a/system/src/Grav/Framework/Psr7/ServerRequest.php b/system/src/Grav/Framework/Psr7/ServerRequest.php new file mode 100644 index 0000000..bb6b8b8 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/ServerRequest.php @@ -0,0 +1,372 @@ +message = new \Nyholm\Psr7\ServerRequest($method, $uri, $headers, $body, $version, $serverParams); + } + + /** + * Get serverRequest content character set, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string|null + */ + public function getContentCharset(): ?string + { + $mediaTypeParams = $this->getMediaTypeParams(); + + if (isset($mediaTypeParams['charset'])) { + return $mediaTypeParams['charset']; + } + + return null; + } + + /** + * Get serverRequest content type. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string|null The serverRequest content type, if known + */ + public function getContentType(): ?string + { + $result = $this->getRequest()->getHeader('Content-Type'); + + return $result ? $result[0] : null; + } + + /** + * Get serverRequest content length, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return int|null + */ + public function getContentLength(): ?int + { + $result = $this->getRequest()->getHeader('Content-Length'); + + return $result ? (int) $result[0] : null; + } + + /** + * Fetch cookie value from cookies sent by the client to the server. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key The attribute name. + * @param mixed $default Default value to return if the attribute does not exist. + * + * @return mixed + */ + public function getCookieParam($key, $default = null) + { + $cookies = $this->getRequest()->getCookieParams(); + $result = $default; + + if (isset($cookies[$key])) { + $result = $cookies[$key]; + } + + return $result; + } + + /** + * Get serverRequest media type, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string|null The serverRequest media type, minus content-type params + */ + public function getMediaType(): ?string + { + $contentType = $this->getContentType(); + + if ($contentType) { + $contentTypeParts = preg_split('/\s*[;,]\s*/', $contentType); + if ($contentTypeParts === false) { + return null; + } + return strtolower($contentTypeParts[0]); + } + + return null; + } + + /** + * Get serverRequest media type params, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return mixed[] + */ + public function getMediaTypeParams(): array + { + $contentType = $this->getContentType(); + $contentTypeParams = []; + + if ($contentType) { + $contentTypeParts = preg_split('/\s*[;,]\s*/', $contentType); + if ($contentTypeParts !== false) { + $contentTypePartsLength = count($contentTypeParts); + for ($i = 1; $i < $contentTypePartsLength; $i++) { + $paramParts = explode('=', $contentTypeParts[$i]); + $contentTypeParams[strtolower($paramParts[0])] = $paramParts[1]; + } + } + } + + return $contentTypeParams; + } + + /** + * Fetch serverRequest parameter value from body or query string (in that order). + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key The parameter key. + * @param string $default The default value. + * + * @return mixed The parameter value. + */ + public function getParam($key, $default = null) + { + $postParams = $this->getParsedBody(); + $getParams = $this->getQueryParams(); + $result = $default; + + if (is_array($postParams) && isset($postParams[$key])) { + $result = $postParams[$key]; + } elseif (is_object($postParams) && property_exists($postParams, $key)) { + $result = $postParams->$key; + } elseif (isset($getParams[$key])) { + $result = $getParams[$key]; + } + + return $result; + } + + /** + * Fetch associative array of body and query string parameters. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return mixed[] + */ + public function getParams(): array + { + $params = $this->getQueryParams(); + $postParams = $this->getParsedBody(); + + if ($postParams) { + $params = array_merge($params, (array)$postParams); + } + + return $params; + } + + /** + * Fetch parameter value from serverRequest body. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key + * @param mixed $default + * + * @return mixed + */ + public function getParsedBodyParam($key, $default = null) + { + $postParams = $this->getParsedBody(); + $result = $default; + + if (is_array($postParams) && isset($postParams[$key])) { + $result = $postParams[$key]; + } elseif (is_object($postParams) && property_exists($postParams, $key)) { + $result = $postParams->{$key}; + } + + return $result; + } + + /** + * Fetch parameter value from query string. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key + * @param mixed $default + * + * @return mixed + */ + public function getQueryParam($key, $default = null) + { + $getParams = $this->getQueryParams(); + $result = $default; + + if (isset($getParams[$key])) { + $result = $getParams[$key]; + } + + return $result; + } + + /** + * Retrieve a server parameter. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function getServerParam($key, $default = null) + { + $serverParams = $this->getRequest()->getServerParams(); + + return $serverParams[$key] ?? $default; + } + + /** + * Does this serverRequest use a given method? + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $method HTTP method + * @return bool + */ + public function isMethod($method): bool + { + return $this->getRequest()->getMethod() === $method; + } + + /** + * Is this a DELETE serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isDelete(): bool + { + return $this->isMethod('DELETE'); + } + + /** + * Is this a GET serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isGet(): bool + { + return $this->isMethod('GET'); + } + + /** + * Is this a HEAD serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isHead(): bool + { + return $this->isMethod('HEAD'); + } + + /** + * Is this a OPTIONS serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isOptions(): bool + { + return $this->isMethod('OPTIONS'); + } + + /** + * Is this a PATCH serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isPatch(): bool + { + return $this->isMethod('PATCH'); + } + + /** + * Is this a POST serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isPost(): bool + { + return $this->isMethod('POST'); + } + + /** + * Is this a PUT serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isPut(): bool + { + return $this->isMethod('PUT'); + } + + /** + * Is this an XHR serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isXhr(): bool + { + return $this->getRequest()->getHeaderLine('X-Requested-With') === 'XMLHttpRequest'; + } +} diff --git a/system/src/Grav/Framework/Psr7/Stream.php b/system/src/Grav/Framework/Psr7/Stream.php new file mode 100644 index 0000000..9431052 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Stream.php @@ -0,0 +1,25 @@ +stream = \Nyholm\Psr7\Stream::create($body); + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php new file mode 100644 index 0000000..28ed1f5 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php @@ -0,0 +1,140 @@ + + */ +trait MessageDecoratorTrait +{ + /** @var MessageInterface */ + private $message; + + /** + * Returns the decorated message. + * + * Since the underlying Message is immutable as well + * exposing it is not an issue, because it's state cannot be altered + * + * @return MessageInterface + */ + public function getMessage(): MessageInterface + { + return $this->message; + } + + /** + * {@inheritdoc} + */ + public function getProtocolVersion(): string + { + return $this->message->getProtocolVersion(); + } + + /** + * {@inheritdoc} + */ + public function withProtocolVersion($version): self + { + $new = clone $this; + $new->message = $this->message->withProtocolVersion($version); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getHeaders(): array + { + return $this->message->getHeaders(); + } + + /** + * {@inheritdoc} + */ + public function hasHeader($header): bool + { + return $this->message->hasHeader($header); + } + + /** + * {@inheritdoc} + */ + public function getHeader($header): array + { + return $this->message->getHeader($header); + } + + /** + * {@inheritdoc} + */ + public function getHeaderLine($header): string + { + return $this->message->getHeaderLine($header); + } + + /** + * {@inheritdoc} + */ + public function getBody(): StreamInterface + { + return $this->message->getBody(); + } + + /** + * {@inheritdoc} + */ + public function withHeader($header, $value): self + { + $new = clone $this; + $new->message = $this->message->withHeader($header, $value); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withAddedHeader($header, $value): self + { + $new = clone $this; + $new->message = $this->message->withAddedHeader($header, $value); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withoutHeader($header): self + { + $new = clone $this; + $new->message = $this->message->withoutHeader($header); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withBody(StreamInterface $body): self + { + $new = clone $this; + $new->message = $this->message->withBody($body); + + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php new file mode 100644 index 0000000..ab1ffad --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php @@ -0,0 +1,112 @@ + + */ +trait RequestDecoratorTrait +{ + use MessageDecoratorTrait { + getMessage as private; + } + + /** + * Returns the decorated request. + * + * Since the underlying Request is immutable as well + * exposing it is not an issue, because it's state cannot be altered + * + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + /** @var RequestInterface $message */ + $message = $this->getMessage(); + + return $message; + } + + /** + * Exchanges the underlying request with another. + * + * @param RequestInterface $request + * @return self + */ + public function withRequest(RequestInterface $request): self + { + $new = clone $this; + $new->message = $request; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getRequestTarget(): string + { + return $this->getRequest()->getRequestTarget(); + } + + /** + * {@inheritdoc} + */ + public function withRequestTarget($requestTarget): self + { + $new = clone $this; + $new->message = $this->getRequest()->withRequestTarget($requestTarget); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getMethod(): string + { + return $this->getRequest()->getMethod(); + } + + /** + * {@inheritdoc} + */ + public function withMethod($method): self + { + $new = clone $this; + $new->message = $this->getRequest()->withMethod($method); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getUri(): UriInterface + { + return $this->getRequest()->getUri(); + } + + /** + * {@inheritdoc} + */ + public function withUri(UriInterface $uri, $preserveHost = false): self + { + $new = clone $this; + $new->message = $this->getRequest()->withUri($uri, $preserveHost); + + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php new file mode 100644 index 0000000..32a1bfb --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php @@ -0,0 +1,82 @@ + + */ +trait ResponseDecoratorTrait +{ + use MessageDecoratorTrait { + getMessage as private; + } + + /** + * Returns the decorated response. + * + * Since the underlying Response is immutable as well + * exposing it is not an issue, because it's state cannot be altered + * + * @return ResponseInterface + */ + public function getResponse(): ResponseInterface + { + /** @var ResponseInterface $message */ + $message = $this->getMessage(); + + return $message; + } + + /** + * Exchanges the underlying response with another. + * + * @param ResponseInterface $response + * + * @return self + */ + public function withResponse(ResponseInterface $response): self + { + $new = clone $this; + $new->message = $response; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getStatusCode(): int + { + return $this->getResponse()->getStatusCode(); + } + + /** + * {@inheritdoc} + */ + public function withStatus($code, $reasonPhrase = ''): self + { + $new = clone $this; + $new->message = $this->getResponse()->withStatus($code, $reasonPhrase); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getReasonPhrase(): string + { + return $this->getResponse()->getReasonPhrase(); + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php new file mode 100644 index 0000000..f9297dd --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php @@ -0,0 +1,168 @@ +getMessage(); + + return $message; + } + + /** + * @inheritdoc + */ + public function getAttribute($name, $default = null) + { + return $this->getRequest()->getAttribute($name, $default); + } + + /** + * @inheritdoc + */ + public function getAttributes() + { + return $this->getRequest()->getAttributes(); + } + + + /** + * @inheritdoc + */ + public function getCookieParams() + { + return $this->getRequest()->getCookieParams(); + } + + /** + * @inheritdoc + */ + public function getParsedBody() + { + return $this->getRequest()->getParsedBody(); + } + + /** + * @inheritdoc + */ + public function getQueryParams() + { + return $this->getRequest()->getQueryParams(); + } + + /** + * @inheritdoc + */ + public function getServerParams() + { + return $this->getRequest()->getServerParams(); + } + + /** + * @inheritdoc + */ + public function getUploadedFiles() + { + return $this->getRequest()->getUploadedFiles(); + } + + /** + * @inheritdoc + */ + public function withAttribute($name, $value) + { + $new = clone $this; + $new->message = $this->getRequest()->withAttribute($name, $value); + + return $new; + } + + public function withAttributes(array $attributes) + { + $new = clone $this; + foreach ($attributes as $attribute => $value) { + $new->message = $new->withAttribute($attribute, $value); + } + + return $new; + } + + /** + * @inheritdoc + */ + public function withoutAttribute($name) + { + $new = clone $this; + $new->message = $this->getRequest()->withoutAttribute($name); + + return $new; + } + + /** + * @inheritdoc + */ + public function withCookieParams(array $cookies) + { + $new = clone $this; + $new->message = $this->getRequest()->withCookieParams($cookies); + + return $new; + } + + /** + * @inheritdoc + */ + public function withParsedBody($data) + { + $new = clone $this; + $new->message = $this->getRequest()->withParsedBody($data); + + return $new; + } + + /** + * @inheritdoc + */ + public function withQueryParams(array $query) + { + $new = clone $this; + $new->message = $this->getRequest()->withQueryParams($query); + + return $new; + } + + /** + * @inheritdoc + */ + public function withUploadedFiles(array $uploadedFiles) + { + $new = clone $this; + $new->message = $this->getRequest()->withUploadedFiles($uploadedFiles); + + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php new file mode 100644 index 0000000..f61ea4b --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php @@ -0,0 +1,145 @@ +stream->__toString(); + } + + public function __destruct() + { + $this->stream->close(); + } + + /** + * {@inheritdoc} + */ + public function close(): void + { + $this->stream->close(); + } + + /** + * {@inheritdoc} + */ + public function detach() + { + return $this->stream->detach(); + } + + /** + * {@inheritdoc} + */ + public function getSize(): ?int + { + return $this->stream->getSize(); + } + + /** + * {@inheritdoc} + */ + public function tell(): int + { + return $this->stream->tell(); + } + + /** + * {@inheritdoc} + */ + public function eof(): bool + { + return $this->stream->eof(); + } + + /** + * {@inheritdoc} + */ + public function isSeekable(): bool + { + return $this->stream->isSeekable(); + } + + /** + * {@inheritdoc} + */ + public function seek($offset, $whence = \SEEK_SET): void + { + $this->stream->seek($offset, $whence); + } + + /** + * {@inheritdoc} + */ + public function rewind(): void + { + $this->stream->rewind(); + } + + /** + * {@inheritdoc} + */ + public function isWritable(): bool + { + return $this->stream->isWritable(); + } + + /** + * {@inheritdoc} + */ + public function write($string): int + { + return $this->stream->write($string); + } + + /** + * {@inheritdoc} + */ + public function isReadable(): bool + { + return $this->stream->isReadable(); + } + + /** + * {@inheritdoc} + */ + public function read($length): string + { + return $this->stream->read($length); + } + + /** + * {@inheritdoc} + */ + public function getContents(): string + { + return $this->stream->getContents(); + } + + /** + * {@inheritdoc} + */ + public function getMetadata($key = null) + { + return $this->stream->getMetadata($key); + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php new file mode 100644 index 0000000..677c4d2 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php @@ -0,0 +1,51 @@ +uploadedFile->getStream(); + } + + public function moveTo($targetPath): void + { + $this->uploadedFile->moveTo($targetPath); + } + + public function getSize(): ?int + { + return $this->uploadedFile->getSize(); + } + + public function getError(): int + { + return $this->uploadedFile->getError(); + } + + public function getClientFilename(): ?string + { + return $this->uploadedFile->getClientFilename(); + } + + public function getClientMediaType(): ?string + { + return $this->uploadedFile->getClientMediaType(); + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php b/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php new file mode 100644 index 0000000..9e7946a --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php @@ -0,0 +1,128 @@ +uri->__toString(); + } + + public function getScheme(): string + { + return $this->uri->getScheme(); + } + + public function getAuthority(): string + { + return $this->uri->getAuthority(); + } + + public function getUserInfo(): string + { + return $this->uri->getUserInfo(); + } + + public function getHost(): string + { + return $this->uri->getHost(); + } + + public function getPort(): ?int + { + return $this->uri->getPort(); + } + + public function getPath(): string + { + return $this->uri->getPath(); + } + + public function getQuery(): string + { + return $this->uri->getQuery(); + } + + public function getFragment(): string + { + return $this->uri->getFragment(); + } + + public function withScheme($scheme): UriInterface + { + /** @var UriInterface|UriDecorationTrait $new */ + $new = clone $this; + $new->uri = $this->uri->withScheme($scheme); + + return $new; + } + + public function withUserInfo($user, $password = null): UriInterface + { + /** @var UriInterface|UriDecorationTrait $new */ + $new = clone $this; + $new->uri = $this->uri->withUserInfo($user, $password); + + return $new; + } + + public function withHost($host): UriInterface + { + /** @var UriInterface|UriDecorationTrait $new */ + $new = clone $this; + $new->uri = $this->uri->withHost($host); + + return $new; + } + + public function withPort($port): UriInterface + { + /** @var UriInterface|UriDecorationTrait $new */ + $new = clone $this; + $new->uri = $this->uri->withPort($port); + + return $new; + } + + public function withPath($path): UriInterface + { + /** @var UriInterface|UriDecorationTrait $new */ + $new = clone $this; + $new->uri = $this->uri->withPath($path); + + return $new; + } + + public function withQuery($query): UriInterface + { + /** @var UriInterface|UriDecorationTrait $new */ + $new = clone $this; + $new->uri = $this->uri->withQuery($query); + + return $new; + } + + public function withFragment($fragment): UriInterface + { + /** @var UriInterface|UriDecorationTrait $new */ + $new = clone $this; + $new->uri = $this->uri->withFragment($fragment); + + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/UploadedFile.php b/system/src/Grav/Framework/Psr7/UploadedFile.php new file mode 100644 index 0000000..d6b062f --- /dev/null +++ b/system/src/Grav/Framework/Psr7/UploadedFile.php @@ -0,0 +1,33 @@ +uploadedFile = new \Nyholm\Psr7\UploadedFile($streamOrFile, $size, $errorStatus, $clientFilename, $clientMediaType); + } +} diff --git a/system/src/Grav/Framework/Psr7/Uri.php b/system/src/Grav/Framework/Psr7/Uri.php new file mode 100644 index 0000000..28614ca --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Uri.php @@ -0,0 +1,134 @@ +uri = new \Nyholm\Psr7\Uri($uri); + } + + /** + * @return array + */ + public function getQueryParams(): array + { + return UriFactory::parseQuery($this->getQuery()); + } + + /** + * @param array $params + * @return UriInterface + */ + public function withQueryParams(array $params): UriInterface + { + $query = UriFactory::buildQuery($params); + + return $this->withQuery($query); + } + + /** + * Whether the URI has the default port of the current scheme. + * + * `$uri->getPort()` may return the standard port. This method can be used for some non-http/https Uri. + * + * @return bool + */ + public function isDefaultPort(): bool + { + return $this->getPort() === null || GuzzleUri::isDefaultPort($this); + } + + /** + * Whether the URI is absolute, i.e. it has a scheme. + * + * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true + * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative + * to another URI, the base URI. Relative references can be divided into several forms: + * - network-path references, e.g. '//example.com/path' + * - absolute-path references, e.g. '/path' + * - relative-path references, e.g. 'subpath' + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4 + */ + public function isAbsolute(): bool + { + return GuzzleUri::isAbsolute($this); + } + + /** + * Whether the URI is a network-path reference. + * + * A relative reference that begins with two slash characters is termed an network-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isNetworkPathReference(): bool + { + return GuzzleUri::isNetworkPathReference($this); + } + + /** + * Whether the URI is a absolute-path reference. + * + * A relative reference that begins with a single slash character is termed an absolute-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isAbsolutePathReference(): bool + { + return GuzzleUri::isAbsolutePathReference($this); + } + + /** + * Whether the URI is a relative-path reference. + * + * A relative reference that does not begin with a slash character is termed a relative-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isRelativePathReference(): bool + { + return GuzzleUri::isRelativePathReference($this); + } + + /** + * Whether the URI is a same-document reference. + * + * A same-document reference refers to a URI that is, aside from its fragment + * component, identical to the base URI. When no base URI is given, only an empty + * URI reference (apart from its fragment) is considered a same-document reference. + * + * @param UriInterface|null $base An optional base URI to compare against + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.4 + */ + public function isSameDocumentReference(UriInterface $base = null): bool + { + return GuzzleUri::isSameDocumentReference($this, $base); + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php b/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..4cacd71 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php @@ -0,0 +1,47 @@ +invalidMiddleware = $invalidMiddleware; + } + + /** + * Return the invalid middleware + * + * @return mixed|null + */ + public function getInvalidMiddleware() + { + return $this->invalidMiddleware; + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php b/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php new file mode 100644 index 0000000..5e8d22a --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php @@ -0,0 +1,39 @@ +getMethod()), ['PUT', 'PATCH', 'DELETE'])) { + parent::__construct($request, 'Method Not Allowed', 405, $previous); + } else { + parent::__construct($request, 'Not Found', 404, $previous); + } + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php b/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php new file mode 100644 index 0000000..6696013 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php @@ -0,0 +1,16 @@ + 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Time-out', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Large', + 415 => 'Unsupported Media Type', + 416 => 'Requested range not satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 419 => 'Page Expired', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Time-out', + 505 => 'HTTP Version not supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 511 => 'Network Authentication Required', + ]; + + /** @var ServerRequestInterface */ + private $request; + + /** + * @param ServerRequestInterface $request + * @param string $message + * @param int $code + * @param \Throwable|null $previous + */ + public function __construct(ServerRequestInterface $request, string $message, int $code = 500, \Throwable $previous = null) + { + $this->request = $request; + + parent::__construct($message, $code, $previous); + } + + /** + * @return ServerRequestInterface + */ + public function getRequest() : ServerRequestInterface + { + return $this->request; + } + + public function getHttpCode() : int + { + $code = $this->getCode(); + + return isset(self::$phrases[$code]) ? $code : 500; + } + + public function getHttpReason() : ?string + { + return self::$phrases[$this->getCode()] ?? self::$phrases[500]; + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php b/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php new file mode 100644 index 0000000..ec1de6d --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php @@ -0,0 +1,44 @@ +handle($request); + } catch (\Throwable $exception) { + $response = [ + 'error' => [ + 'type' => \get_class($exception), + 'code' => $exception->getCode(), + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => explode("\n", $exception->getTraceAsString()), + ] + ]; + + /** @var string $json */ + $json = json_encode($response); + + return new Response($exception->getCode() ?: 500, ['Content-Type' => 'application/json'], $json); + } + } +} diff --git a/system/src/Grav/Framework/RequestHandler/RequestHandler.php b/system/src/Grav/Framework/RequestHandler/RequestHandler.php new file mode 100644 index 0000000..c4d5387 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/RequestHandler.php @@ -0,0 +1,66 @@ +middleware = $middleware; + $this->handler = $default; + $this->container = $container; + } + + /** + * Add callable initializing Middleware that will be executed as soon as possible. + * + * @param string $name + * @param callable $callable + * @return $this + */ + public function addCallable(string $name, callable $callable): self + { + $this->container[$name] = $callable; + array_unshift($this->middleware, $name); + + return $this; + } + + /** + * Add Middleware that will be executed as soon as possible. + * + * @param string $name + * @param MiddlewareInterface $middleware + * @return $this + */ + public function addMiddleware(string $name, MiddlewareInterface $middleware): self + { + $this->container[$name] = $middleware; + array_unshift($this->middleware, $name); + + return $this; + } +} \ No newline at end of file diff --git a/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php b/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php new file mode 100644 index 0000000..153d1a1 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php @@ -0,0 +1,59 @@ + */ + protected $middleware; + + /** @var callable */ + private $handler; + + /** @var ContainerInterface|null */ + private $container; + + /** + * {@inheritdoc} + * @throws InvalidArgumentException + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $middleware = array_shift($this->middleware); + + // Use default callable if there is no middleware. + if ($middleware === null) { + return \call_user_func($this->handler, $request); + } + + if ($middleware instanceof MiddlewareInterface) { + return $middleware->process($request, clone $this); + } + + if (null === $this->container || !$this->container->has($middleware)) { + throw new InvalidArgumentException( + sprintf('The middleware is not a valid %s and is not passed in the Container', MiddlewareInterface::class), + $middleware + ); + } + + array_unshift($this->middleware, $this->container->get($middleware)); + + return $this->handle($request); + } +} \ No newline at end of file diff --git a/system/src/Grav/Framework/Route/Route.php b/system/src/Grav/Framework/Route/Route.php new file mode 100644 index 0000000..94ce57b --- /dev/null +++ b/system/src/Grav/Framework/Route/Route.php @@ -0,0 +1,396 @@ +initParts($parts); + } + + /** + * @return array + */ + public function getParts() + { + return [ + 'path' => $this->getUriPath(true), + 'query' => $this->getUriQuery(), + 'grav' => [ + 'root' => $this->root, + 'language' => $this->language, + 'route' => $this->route, + 'extension' => $this->extension, + 'grav_params' => $this->gravParams, + 'query_params' => $this->queryParams, + ], + ]; + } + + /** + * @return string + */ + public function getRootPrefix() + { + return $this->root; + } + + /** + * @return string + */ + public function getLanguagePrefix() + { + return $this->language !== '' ? '/' . $this->language : ''; + } + + /** + * @param int $offset + * @param int|null $length + * @return string + */ + public function getRoute($offset = 0, $length = null) + { + if ($offset !== 0 || $length !== null) { + return ($offset === 0 ? '/' : '') . implode('/', $this->getRouteParts($offset, $length)); + } + + return '/' . $this->route; + } + + /** + * @return string + */ + public function getExtension() + { + return $this->extension; + } + + /** + * @param int $offset + * @param int|null $length + * @return array + */ + public function getRouteParts($offset = 0, $length = null) + { + $parts = explode('/', $this->route); + + if ($offset !== 0 || $length !== null) { + $parts = \array_slice($parts, $offset, $length); + } + + return $parts; + } + + /** + * Return array of both query and Grav parameters. + * + * If a parameter exists in both, prefer Grav parameter. + * + * @return array + */ + public function getParams() + { + return $this->gravParams + $this->queryParams; + } + + /** + * @return array + */ + public function getGravParams() + { + return $this->gravParams; + } + + /** + * @return array + */ + public function getQueryParams() + { + return $this->queryParams; + } + + /** + * Return value of the parameter, looking into both Grav parameters and query parameters. + * + * If the parameter exists in both, return Grav parameter. + * + * @param string $param + * @return string|array|null + */ + public function getParam($param) + { + return $this->getGravParam($param) ?? $this->getQueryParam($param); + } + + /** + * @param string $param + * @return string|null + */ + public function getGravParam($param) + { + return $this->gravParams[$param] ?? null; + } + + /** + * @param string $param + * @return string|array|null + */ + public function getQueryParam($param) + { + return $this->queryParams[$param] ?? null; + } + + /** + * Allow the ability to set the route to something else + * + * @param string $route + * @return $this + */ + public function withRoute($route) + { + $this->route = $route; + + return $this; + } + + /** + * Allow the ability to set the root to something else + * + * @param string $root + * @return $this + */ + public function withRoot($root) + { + $this->root = $root; + + return $this; + } + + /** + * @param string $path + * @return Route + */ + public function withAddedPath($path) + { + $this->route .= '/' . ltrim($path, '/'); + + return $this; + } + + /** + * @param string $extension + * @return Route + */ + public function withExtension($extension) + { + $this->extension = $extension; + + return $this; + } + + /** + * @param string $param + * @param mixed $value + * @return Route + */ + public function withGravParam($param, $value) + { + return $this->withParam('gravParams', $param, null !== $value ? (string)$value : null); + } + + /** + * @param string $param + * @param mixed $value + * @return Route + */ + public function withQueryParam($param, $value) + { + return $this->withParam('queryParams', $param, $value); + } + + public function withoutParams() + { + return $this->withoutGravParams()->withoutQueryParams(); + } + + public function withoutGravParams() + { + $this->gravParams = []; + + return $this; + } + + public function withoutQueryParams() + { + $this->queryParams = []; + + return $this; + } + + /** + * @return \Grav\Framework\Uri\Uri + */ + public function getUri() + { + return UriFactory::createFromParts($this->getParts()); + } + + /** + * @param bool $includeRoot + * @return string + */ + public function toString(bool $includeRoot = false) + { + $url = $this->getUriPath($includeRoot); + + if ($this->queryParams) { + $url .= '?' . $this->getUriQuery(); + } + + return $url; + } + + /** + * @return string + * @deprecated 1.6 Use ->toString(true) or ->getUri() instead. + */ + public function __toString() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() will change in the future to return route, not relative url: use ->toString(true) or ->getUri() instead.', E_USER_DEPRECATED); + + return $this->toString(true); + } + + /** + * @param string $type + * @param string $param + * @param mixed $value + * @return static + */ + protected function withParam($type, $param, $value) + { + $values = $this->{$type} ?? []; + $oldValue = $values[$param] ?? null; + + if ($oldValue === $value) { + return $this; + } + + $new = $this->copy(); + if ($value === null) { + unset($values[$param]); + } else { + $values[$param] = $value; + } + + $new->{$type} = $values; + + return $new; + } + + protected function copy() + { + return clone $this; + } + + /** + * @param bool $includeRoot + * @return string + */ + protected function getUriPath($includeRoot = false) + { + $parts = $includeRoot ? [$this->root] : ['']; + + if ($this->language !== '') { + $parts[] = $this->language; + } + + $parts[] = $this->extension ? $this->route . '.' . $this->extension : $this->route; + + + if ($this->gravParams) { + $parts[] = RouteFactory::buildParams($this->gravParams); + } + + return implode('/', $parts); + } + + /** + * @return string + */ + protected function getUriQuery() + { + return UriFactory::buildQuery($this->queryParams); + } + + /** + * @param array $parts + */ + protected function initParts(array $parts) + { + if (isset($parts['grav'])) { + $gravParts = $parts['grav']; + $this->root = $gravParts['root']; + $this->language = $gravParts['language']; + $this->route = $gravParts['route']; + $this->extension = $gravParts['extension'] ?? ''; + $this->gravParams = $gravParts['params'] ?: []; + $this->queryParams = $parts['query_params'] ?: []; + + } else { + $this->root = RouteFactory::getRoot(); + $this->language = RouteFactory::getLanguage(); + + $path = $parts['path'] ?? '/'; + if (isset($parts['params'])) { + $this->route = trim(rawurldecode($path), '/'); + $this->gravParams = $parts['params']; + } else { + $this->route = trim(RouteFactory::stripParams($path, true), '/'); + $this->gravParams = RouteFactory::getParams($path); + } + if (isset($parts['query'])) { + $this->queryParams = UriFactory::parseQuery($parts['query']); + } + } + } +} diff --git a/system/src/Grav/Framework/Route/RouteFactory.php b/system/src/Grav/Framework/Route/RouteFactory.php new file mode 100644 index 0000000..6b3414a --- /dev/null +++ b/system/src/Grav/Framework/Route/RouteFactory.php @@ -0,0 +1,157 @@ + $path, + 'query' => '', + 'query_params' => [], + 'grav' => [ + 'root' => self::$root, + 'language' => self::$language, + 'route' => $path, + 'params' => '' + ], + ]; + return new Route($parts); + } + + public static function getRoot() + { + return self::$root; + } + + public static function setRoot($root) + { + self::$root = rtrim($root, '/'); + } + + public static function getLanguage() + { + return self::$language; + } + + public static function setLanguage($language) + { + self::$language = trim($language, '/'); + } + + public static function getParamValueDelimiter() + { + return self::$delimiter; + } + + public static function setParamValueDelimiter($delimiter) + { + self::$delimiter = $delimiter ?: ':'; + } + + /** + * @param array $params + * @return string + */ + public static function buildParams(array $params) + { + if (!$params) { + return ''; + } + + $delimiter = self::$delimiter; + + $output = []; + foreach ($params as $key => $value) { + $output[] = "{$key}{$delimiter}{$value}"; + } + + return implode('/', $output); + } + + /** + * @param string $path + * @param bool $decode + * @return string + */ + public static function stripParams($path, $decode = false) + { + $pos = strpos($path, self::$delimiter); + + if ($pos === false) { + return $path; + } + + $path = \dirname(substr($path, 0, $pos)); + if ($path === '.') { + return ''; + } + + return $decode ? rawurldecode($path) : $path; + } + + /** + * @param string $path + * @return array + */ + public static function getParams($path) + { + $params = ltrim(substr($path, \strlen(static::stripParams($path))), '/'); + + return $params !== '' ? static::parseParams($params) : []; + } + + /** + * @param string $str + * @return array + */ + public static function parseParams($str) + { + if ($str === '') { + return []; + } + + $delimiter = self::$delimiter; + + /** @var array $params */ + $params = explode('/', $str); + foreach ($params as &$param) { + /** @var array $parts */ + $parts = explode($delimiter, $param, 2); + if (isset($parts[1])) { + $var = rawurldecode($parts[0]); + $val = rawurldecode($parts[1]); + $param = [$var => $val]; + } + } + + return $params; + } +} diff --git a/system/src/Grav/Framework/Session/Exceptions/SessionException.php b/system/src/Grav/Framework/Session/Exceptions/SessionException.php new file mode 100644 index 0000000..4caab82 --- /dev/null +++ b/system/src/Grav/Framework/Session/Exceptions/SessionException.php @@ -0,0 +1,14 @@ +isSessionStarted()) { + session_unset(); + session_destroy(); + } + + // Set default options. + $options += [ + 'cache_limiter' => 'nocache', + 'use_trans_sid' => 0, + 'use_cookies' => 1, + 'lazy_write' => 1, + 'use_strict_mode' => 1 + ]; + + $this->setOptions($options); + + session_register_shutdown(); + + self::$instance = $this; + } + + /** + * @inheritdoc + */ + public function getId() + { + return session_id(); + } + + /** + * @inheritdoc + */ + public function setId($id) + { + session_id($id); + + return $this; + } + + /** + * @inheritdoc + */ + public function getName() + { + return session_name(); + } + + /** + * @inheritdoc + */ + public function setName($name) + { + session_name($name); + + return $this; + } + + /** + * @inheritdoc + */ + public function setOptions(array $options) + { + if (headers_sent() || \PHP_SESSION_ACTIVE === session_status()) { + return; + } + + $allowedOptions = [ + 'save_path' => true, + 'name' => true, + 'save_handler' => true, + 'gc_probability' => true, + 'gc_divisor' => true, + 'gc_maxlifetime' => true, + 'serialize_handler' => true, + 'cookie_lifetime' => true, + 'cookie_path' => true, + 'cookie_domain' => true, + 'cookie_secure' => true, + 'cookie_httponly' => true, + 'use_strict_mode' => true, + 'use_cookies' => true, + 'use_only_cookies' => true, + 'referer_check' => true, + 'cache_limiter' => true, + 'cache_expire' => true, + 'use_trans_sid' => true, + 'trans_sid_tags' => true, + 'trans_sid_hosts' => true, + 'sid_length' => true, + 'sid_bits_per_character' => true, + 'upload_progress.enabled' => true, + 'upload_progress.cleanup' => true, + 'upload_progress.prefix' => true, + 'upload_progress.name' => true, + 'upload_progress.freq' => true, + 'upload_progress.min-freq' => true, + 'lazy_write' => true + ]; + + foreach ($options as $key => $value) { + if (\is_array($value)) { + // Allow nested options. + foreach ($value as $key2 => $value2) { + $ckey = "{$key}.{$key2}"; + if (isset($value2, $allowedOptions[$ckey])) { + $this->setOption($ckey, $value2); + } + } + } elseif (isset($value, $allowedOptions[$key])) { + $this->setOption($key, $value); + } + } + } + + /** + * @inheritdoc + */ + public function start($readonly = false) + { + if (\PHP_SAPI === 'cli') { + return $this; + } + + $sessionName = session_name(); + $sessionExists = isset($_COOKIE[$sessionName]); + + // Protection against invalid session cookie names throwing exception: http://php.net/manual/en/function.session-id.php#116836 + if ($sessionExists && !preg_match('/^[-,a-zA-Z0-9]{1,128}$/', $_COOKIE[$sessionName])) { + unset($_COOKIE[$sessionName]); + $sessionExists = false; + } + + $options = $this->options; + if ($readonly) { + $options['read_and_close'] = '1'; + } + + $success = @session_start($options); + $user = $success ? $this->__get('user') : null; + if (!$success) { + $last = error_get_last(); + $error = $last ? $last['message'] : 'Unknown error'; + + throw new SessionException('Failed to start session: ' . $error, 500); + } + + $this->started = true; + + if ($user && (!$user instanceof UserInterface || !$user->isValid())) { + $this->invalidate(); + + throw new SessionException('Invalid User object, session destroyed.', 500); + } + + // Extend the lifetime of the session. + if ($sessionExists) { + $params = session_get_cookie_params(); + + setcookie( + $sessionName, + session_id(), + time() + $params['lifetime'], + $params['path'], + $params['domain'], + $params['secure'], + $params['httponly'] + ); + } + + return $this; + } + + /** + * @inheritdoc + */ + public function invalidate() + { + $params = session_get_cookie_params(); + setcookie( + session_name(), + '', + time() - 42000, + $params['path'], + $params['domain'], + $params['secure'], + $params['httponly'] + ); + + if ($this->isSessionStarted()) { + session_unset(); + session_destroy(); + } + + $this->started = false; + + return $this; + } + + /** + * @inheritdoc + */ + public function close() + { + if ($this->started) { + session_write_close(); + } + + $this->started = false; + + return $this; + } + + /** + * @inheritdoc + */ + public function clear() + { + session_unset(); + + return $this; + } + + /** + * @inheritdoc + */ + public function getAll() + { + return $_SESSION; + } + + /** + * @inheritdoc + */ + public function getIterator() + { + return new \ArrayIterator($_SESSION); + } + + /** + * @inheritdoc + */ + public function isStarted() + { + return $this->started; + } + + /** + * @inheritdoc + */ + public function __isset($name) + { + return isset($_SESSION[$name]); + } + + /** + * @inheritdoc + */ + public function __get($name) + { + return $_SESSION[$name] ?? null; + } + + /** + * @inheritdoc + */ + public function __set($name, $value) + { + $_SESSION[$name] = $value; + } + + /** + * @inheritdoc + */ + public function __unset($name) + { + unset($_SESSION[$name]); + } + + /** + * http://php.net/manual/en/function.session-status.php#113468 + * Check if session is started nicely. + * @return bool + */ + protected function isSessionStarted() + { + return \PHP_SAPI !== 'cli' ? \PHP_SESSION_ACTIVE === session_status() : false; + } + + /** + * @param string $key + * @param mixed $value + */ + protected function setOption($key, $value) + { + if (!\is_string($value)) { + if (\is_bool($value)) { + $value = $value ? '1' : '0'; + } else { + $value = (string)$value; + } + } + + $this->options[$key] = $value; + ini_set("session.{$key}", $value); + } +} diff --git a/system/src/Grav/Framework/Session/SessionInterface.php b/system/src/Grav/Framework/Session/SessionInterface.php new file mode 100644 index 0000000..75df0e9 --- /dev/null +++ b/system/src/Grav/Framework/Session/SessionInterface.php @@ -0,0 +1,148 @@ +initParts($parts); + } + + /** + * @return string + */ + public function getUser() + { + return parent::getUser(); + } + + /** + * @return string + */ + public function getPassword() + { + return parent::getPassword(); + } + + /** + * @return array + */ + public function getParts() + { + return parent::getParts(); + } + + /** + * @return string + */ + public function getUrl() + { + return parent::getUrl(); + } + + /** + * @return string + */ + public function getBaseUrl() + { + return parent::getBaseUrl(); + } + + /** + * @param string $key + * @return string|null + */ + public function getQueryParam($key) + { + $queryParams = $this->getQueryParams(); + + return $queryParams[$key] ?? null; + } + + /** + * @param string $key + * @return UriInterface + */ + public function withoutQueryParam($key) + { + return GuzzleUri::withoutQueryValue($this, $key); + } + + /** + * @param string $key + * @param string|null $value + * @return UriInterface + */ + public function withQueryParam($key, $value) + { + return GuzzleUri::withQueryValue($this, $key, $value); + } + + /** + * @return array + */ + public function getQueryParams() + { + if ($this->queryParams === null) { + $this->queryParams = UriFactory::parseQuery($this->getQuery()); + } + + return $this->queryParams; + } + + /** + * @param array $params + * @return UriInterface + */ + public function withQueryParams(array $params) + { + $query = UriFactory::buildQuery($params); + + return $this->withQuery($query); + } + + /** + * Whether the URI has the default port of the current scheme. + * + * `$uri->getPort()` may return the standard port. This method can be used for some non-http/https Uri. + * + * @return bool + */ + public function isDefaultPort() + { + return $this->getPort() === null || GuzzleUri::isDefaultPort($this); + } + + /** + * Whether the URI is absolute, i.e. it has a scheme. + * + * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true + * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative + * to another URI, the base URI. Relative references can be divided into several forms: + * - network-path references, e.g. '//example.com/path' + * - absolute-path references, e.g. '/path' + * - relative-path references, e.g. 'subpath' + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4 + */ + public function isAbsolute() + { + return GuzzleUri::isAbsolute($this); + } + + /** + * Whether the URI is a network-path reference. + * + * A relative reference that begins with two slash characters is termed an network-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isNetworkPathReference() + { + return GuzzleUri::isNetworkPathReference($this); + } + + /** + * Whether the URI is a absolute-path reference. + * + * A relative reference that begins with a single slash character is termed an absolute-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isAbsolutePathReference() + { + return GuzzleUri::isAbsolutePathReference($this); + } + + /** + * Whether the URI is a relative-path reference. + * + * A relative reference that does not begin with a slash character is termed a relative-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isRelativePathReference() + { + return GuzzleUri::isRelativePathReference($this); + } + + /** + * Whether the URI is a same-document reference. + * + * A same-document reference refers to a URI that is, aside from its fragment + * component, identical to the base URI. When no base URI is given, only an empty + * URI reference (apart from its fragment) is considered a same-document reference. + * + * @param UriInterface|null $base An optional base URI to compare against + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.4 + */ + public function isSameDocumentReference(UriInterface $base = null) + { + return GuzzleUri::isSameDocumentReference($this, $base); + } +} diff --git a/system/src/Grav/Framework/Uri/UriFactory.php b/system/src/Grav/Framework/Uri/UriFactory.php new file mode 100644 index 0000000..e5cb1d3 --- /dev/null +++ b/system/src/Grav/Framework/Uri/UriFactory.php @@ -0,0 +1,166 @@ + $scheme, + 'user' => $user, + 'pass' => $pass, + 'host' => $host, + 'port' => $port, + 'path' => $path, + 'query' => $query + ]; + } + + /** + * UTF-8 aware parse_url() implementation. + * + * @param string $url + * @return array + * @throws \InvalidArgumentException + */ + public static function parseUrl($url) + { + if (!\is_string($url)) { + throw new \InvalidArgumentException('URL must be a string'); + } + + $encodedUrl = preg_replace_callback( + '%[^:/@?&=#]+%u', + function ($matches) { return rawurlencode($matches[0]); }, + $url + ); + + $parts = \is_string($encodedUrl) ? parse_url($encodedUrl) : false; + if ($parts === false) { + throw new \InvalidArgumentException("Malformed URL: {$url}"); + } + + return $parts; + } + + /** + * Parse query string and return it as an array. + * + * @param string $query + * @return mixed + */ + public static function parseQuery($query) + { + parse_str($query, $params); + + return $params; + } + + /** + * Build query string from variables. + * + * @param array $params + * @return string + */ + public static function buildQuery(array $params) + { + if (!$params) { + return ''; + } + + $separator = ini_get('arg_separator.output') ?: '&'; + + return http_build_query($params, '', $separator, PHP_QUERY_RFC3986); + } +} diff --git a/system/src/Grav/Framework/Uri/UriPartsFilter.php b/system/src/Grav/Framework/Uri/UriPartsFilter.php new file mode 100644 index 0000000..51a971a --- /dev/null +++ b/system/src/Grav/Framework/Uri/UriPartsFilter.php @@ -0,0 +1,141 @@ += 0 && $port <= 65535))) { + return $port; + } + + throw new \InvalidArgumentException('Uri port must be null or an integer between 0 and 65535'); + } + + /** + * Filter Uri path. + * + * This method percent-encodes all reserved characters in the provided path string. This method + * will NOT double-encode characters that are already percent-encoded. + * + * @param string $path The raw uri path. + * @return string The RFC 3986 percent-encoded uri path. + * @throws \InvalidArgumentException If the path is invalid. + * @link http://www.faqs.org/rfcs/rfc3986.html + */ + public static function filterPath($path) + { + if (!\is_string($path)) { + throw new \InvalidArgumentException('Uri path must be a string'); + } + + return preg_replace_callback( + '/(?:[^a-zA-Z0-9_\-\.~:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u', + function ($match) { + return rawurlencode($match[0]); + }, + $path + ) ?? ''; + } + + /** + * Filters the query string or fragment of a URI. + * + * @param string $query The raw uri query string. + * @return string The percent-encoded query string. + * @throws \InvalidArgumentException If the query is invalid. + */ + public static function filterQueryOrFragment($query) + { + if (!\is_string($query)) { + throw new \InvalidArgumentException('Uri query string and fragment must be a string'); + } + + return preg_replace_callback( + '/(?:[^a-zA-Z0-9_\-\.~!\$&\'\(\)\*\+,;=%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u', + function ($match) { + return rawurlencode($match[0]); + }, + $query + ) ?? ''; + } +} diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php new file mode 100644 index 0000000..5345ce3 --- /dev/null +++ b/system/src/Grav/Installer/Install.php @@ -0,0 +1,287 @@ + [ + 'name' => 'PHP', + 'versions' => [ + '7.3' => '7.3.1', + '7.2' => '7.2.0', + '7.1' => '7.1.3', + '' => '7.2.14' + ] + ], + 'grav' => [ + 'name' => 'Grav', + 'versions' => [ + '1.5' => '1.5.0', + '' => '1.5.7' + ] + ], + 'plugins' => [ + 'admin' => [ + 'name' => 'Admin', + 'optional' => true, + 'versions' => [ + '1.8' => '1.8.0', + '' => '1.8.16' + ] + ], + 'email' => [ + 'name' => 'Email', + 'optional' => true, + 'versions' => [ + '2.7' => '2.7.0', + '' => '2.7.2' + ] + ], + 'form' => [ + 'name' => 'Form', + 'optional' => true, + 'versions' => [ + '2.16' => '2.16.0', + '' => '2.16.4' + ] + ], + 'login' => [ + 'name' => 'Login', + 'optional' => true, + 'versions' => [ + '2.8' => '2.8.0', + '' => '2.8.3' + ] + ], + ] + ]; + + private $ignores = [ + 'backup', + 'cache', + 'images', + 'logs', + 'tmp', + 'user', + '.htaccess', + 'robots.txt' + ]; + + private $classMap = [ + // 'Grav\\Installer\\Test' => __DIR__ . '/Test.php', + ]; + + private $zip; + private $location; + + private static $instance; + + public static function instance() + { + if (null === self::$instance) { + self::$instance = new static(); + } + + return self::$instance; + } + + private function __construct() + { + } + + public function setZip(string $zip) + { + $this->zip = $zip; + + return $this; + } + + public function __invoke(?string $zip) + { + $this->zip = $zip; + + $failedRequirements = $this->checkRequirements(); + if ($failedRequirements) { + $error = ['Following requirements have failed:']; + + foreach ($failedRequirements as $name => $req) { + $error[] = "{$req['title']} >= v{$req['minimum']} required, you have v{$req['installed']}"; + } + + throw new \RuntimeException(implode("
    \n", $error)); + } + + $this->prepare(); + $this->install(); + $this->finalize(); + } + + /** + * NOTE: This method can only be called after $grav['plugins']->init(). + * + * @return array List of failed requirements. If the list is empty, installation can go on. + */ + public function checkRequirements(): array + { + $results = []; + + $this->checkVersion($results, 'php','php', $this->requires['php'], PHP_VERSION); + $this->checkVersion($results, 'grav', 'grav', $this->requires['grav'], GRAV_VERSION); + $this->checkPlugins($results, $this->requires['plugins']); + + return $results; + } + + /** + * @throws \RuntimeException + */ + public function prepare(): void + { + // Locate the new Grav update and the target site from the filesystem. + $location = dirname(realpath(__DIR__), 4); + $target = dirname(realpath(GRAV_ROOT . '/index.php')); + if ($location === $target) { + // We cannot copy files into themselves, abort! + throw new \RuntimeException('Grav has already been installed here!', 400); + } + + // Make sure that none of the Grav\Installer classes have been loaded, otherwise installation may fail! + foreach ($this->classMap as $class_name => $path) { + if (\class_exists($class_name, false)) { + throw new \RuntimeException(sprintf('Cannot update Grav, class %s has already been loaded!', $class_name), 500); + } + } + + $grav = Grav::instance(); + + /** @var ClassLoader $loader */ + $loader = $grav['loader']; + + // Override Grav\Installer classes by using this version of Grav. + $loader->addClassMap($this->classMap); + + $this->location = $location; + } + + /** + * @throws \RuntimeException + */ + public function install(): void + { + if (!$this->location) { + throw new \RuntimeException('Oops, installer was run without prepare()!', 500); + } + + try { + Installer::install( + $this->zip, + GRAV_ROOT, + ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores], + $this->location, + !($this->zip && is_file($this->zip)) + ); + } catch (\Exception $e) { + Installer::setError($e->getMessage()); + } + + $errorCode = Installer::lastErrorCode(); + + $success = !(is_string($errorCode) || ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR))); + + if (!$success) { + throw new \RuntimeException(Installer::lastErrorMsg()); + } + } + + /** + * @throws \RuntimeException + */ + public function finalize(): void + { + Cache::clearCache(); + + clearstatcache(); + if (function_exists('opcache_reset')) { + @opcache_reset(); + } + } + + protected function checkVersion(array &$results, $type, $name, array $check, $version): void + { + if (null === $version && !empty($check['optional'])) { + return; + } + + $major = $minor = 0; + $versions = $check['versions'] ?? []; + foreach ($versions as $major => $minor) { + if (!$major || version_compare($version, $major, '<')) { + continue; + } + + if (version_compare($version, $minor, '>=')) { + return; + } + + break; + } + + if (!$major) { + $minor = reset($versions); + } + + $recommended = end($versions); + + if (version_compare($recommended, $minor, '<=')) { + $recommended = null; + } + + $results[$name] = [ + 'type' => $type, + 'name' => $name, + 'title' => $check['name'] ?? $name, + 'installed' => $version, + 'minimum' => $minor, + 'recommended' => $recommended + ]; + } + + protected function checkPlugins(array &$results, array $plugins): void + { + if (!\class_exists('Plugins')) { + return; + } + + foreach ($plugins as $name => $check) { + $plugin = Plugins::get($name); + if (!$plugin) { + $this->checkVersion($results, 'plugin', $name, $check, null); + continue; + } + + $blueprint = $plugin->blueprints(); + $version = (string)$blueprint->get('version'); + $check['name'] = ($blueprint->get('name') ?? $check['name'] ?? $name) . ' Plugin'; + $this->checkVersion($results, 'plugin', $name, $check, $version); + } + } +} \ No newline at end of file diff --git a/system/templates/partials/messages.html.twig b/system/templates/partials/messages.html.twig new file mode 100644 index 0000000..261b3fc --- /dev/null +++ b/system/templates/partials/messages.html.twig @@ -0,0 +1,14 @@ +{% set status_mapping = {'info':'green', 'error': 'red', 'warning': 'yellow'} %} + +{% if grav.messages.all %} +
    + {% for message in grav.messages.fetch %} + + {% set scope = message.scope|e %} + {% set color = status_mapping[scope] %} + +

    {{ message.message|raw }}

    + + {% endfor %} +
    +{% endif %} diff --git a/system/templates/partials/metadata.html.twig b/system/templates/partials/metadata.html.twig new file mode 100644 index 0000000..bf323e7 --- /dev/null +++ b/system/templates/partials/metadata.html.twig @@ -0,0 +1,3 @@ +{% for meta in page.metadata %} + +{% endfor %} \ No newline at end of file diff --git a/tmp/.gitkeep b/tmp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/user/config/backups.yaml b/user/config/backups.yaml new file mode 100644 index 0000000..e69de29 diff --git a/user/config/groups.yaml b/user/config/groups.yaml new file mode 100644 index 0000000..6c75bde --- /dev/null +++ b/user/config/groups.yaml @@ -0,0 +1,20 @@ +administrators: + groupname: administrators + readableName: Administrators + description: 'The group of administrators' + icon: child + access: + admin: + login: true + site: + login: true +moderator: + groupname: moderator + readableName: Moderator + description: 'The group of moderator' + icon: child + access: + admin: + login: true + site: + login: true diff --git a/user/config/media.yaml b/user/config/media.yaml new file mode 100644 index 0000000..e69de29 diff --git a/user/config/plugins/taxonomylist.yaml b/user/config/plugins/taxonomylist.yaml new file mode 100644 index 0000000..13a97b5 --- /dev/null +++ b/user/config/plugins/taxonomylist.yaml @@ -0,0 +1,2 @@ +enabled: true +route: /blog diff --git a/user/config/scheduler.yaml b/user/config/scheduler.yaml new file mode 100644 index 0000000..e69de29 diff --git a/user/config/security.yaml b/user/config/security.yaml new file mode 100644 index 0000000..fe8e4f3 --- /dev/null +++ b/user/config/security.yaml @@ -0,0 +1 @@ +salt: nYsAV6yboIDM12 diff --git a/user/config/site.yaml b/user/config/site.yaml new file mode 100644 index 0000000..e4e6919 --- /dev/null +++ b/user/config/site.yaml @@ -0,0 +1,17 @@ +title: HeHe +default_lang: en +author: + name: 'Joe Bloggs' + email: joe@example.com +taxonomies: + - category + - tag +metadata: + description: 'Grav is an easy to use, yet powerful, open source flat-file CMS' +summary: + enabled: true + format: short + size: 300 + delimiter: '===' +blog: + route: /blog diff --git a/user/config/streams.yaml b/user/config/streams.yaml new file mode 100644 index 0000000..e69de29 diff --git a/user/config/system.yaml b/user/config/system.yaml new file mode 100644 index 0000000..bda011b --- /dev/null +++ b/user/config/system.yaml @@ -0,0 +1,140 @@ +absolute_urls: false +param_sep: ':' +wrapped_site: false +reverse_proxy_setup: false +force_ssl: false +force_lowercase_urls: true +username_regex: '^[a-z0-9_-]{3,16}$' +pwd_regex: '(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}' +intl_enabled: true +languages: + include_default_lang: true + translations: true + translations_fallback: true + session_store_active: false + http_accept_language: true + override_locale: true +home: + alias: /home + hide_in_urls: false +pages: + theme: hehe + order: + by: default + dir: asc + list: + count: 20 + dateformat: + short: 'F jS \a\t g:ia' + long: 'F jS \a\t g:ia' + publish_dates: true + process: + markdown: true + twig: false + twig_first: false + never_cache_twig: false + events: + page: true + twig: true + markdown: + extra: false + auto_line_breaks: true + auto_url_links: false + escape_markup: false + special_chars: + '>': gt + '<': lt + types: + - txt + - xml + - html + - htm + - json + - rss + - atom + expires: 604800 + last_modified: false + etag: false + vary_accept_encoding: false + redirect_default_route: false + redirect_default_code: '302' + redirect_trailing_slash: true + ignore_files: + - .DS_Store + ignore_folders: + - .git + - .idea + ignore_hidden: true + url_taxonomy_filters: true + frontmatter: + process_twig: false + ignore_fields: + - form + - forms +cache: + enabled: false + check: + method: file + driver: auto + prefix: g + clear_images_by_default: false + cli_compatibility: false + lifetime: 604800 + gzip: false + allow_webserver_gzip: false +twig: + cache: false + debug: true + auto_reload: true + autoescape: false + undefined_functions: true + undefined_filters: true + umask_fix: false +assets: + css_pipeline: false + css_pipeline_include_externals: true + css_pipeline_before_excludes: true + css_minify: true + css_minify_windows: false + css_rewrite: true + js_pipeline: false + js_pipeline_include_externals: true + js_pipeline_before_excludes: true + js_minify: true + enable_asset_timestamp: false + collections: + jquery: 'system://assets/jquery/jquery-2.x.min.js' +errors: + display: 1 + log: true +debugger: + enabled: false + shutdown: + close_connection: true + twig: true +images: + default_image_quality: 85 + cache_all: false + cache_perms: '0755' + debug: false + auto_fix_orientation: false +media: + enable_media_timestamp: false + auto_metadata_exif: false + upload_limit: 67108864 +session: + enabled: true + initialize: true + timeout: 1800 + name: grav-site + secure: false + httponly: true + split: true +gpm: + releases: stable + method: auto + verify_peer: true + official_gpm_only: true +strict_mode: + yaml_compat: true + twig_compat: true diff --git a/user/data/.gitkeep b/user/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/user/data/feed/0cfa4fe11640014bf00e100a23e5ceff.yaml b/user/data/feed/0cfa4fe11640014bf00e100a23e5ceff.yaml new file mode 100644 index 0000000..49677f8 --- /dev/null +++ b/user/data/feed/0cfa4fe11640014bf00e100a23e5ceff.yaml @@ -0,0 +1,52 @@ +last_checked: 1591381969 +data: + - + title: 'Grav 1.7 RC.1 Released' + url: 'https://getgrav.org/blog/grav-17-rc1-released' + date: 1573060980 + nicetime: '7 months ago' + - + title: 'macOS 10.15 Catalina Apache Setup: Upgrading Homebrew' + url: 'https://getgrav.org/blog/macos-catalina-apache-upgrade-homebrew' + date: 1570536000 + nicetime: '8 months ago' + - + title: 'macOS 10.15 Catalina Apache Setup: SSL' + url: 'https://getgrav.org/blog/macos-catalina-apache-ssl' + date: 1570532580 + nicetime: '8 months ago' + - + title: 'macOS 10/15 Catalina Apache Setup: MySQL, Xdebug & More...' + url: 'https://getgrav.org/blog/macos-catalina-apache-mysql-vhost-apc' + date: 1570532400 + nicetime: '8 months ago' + - + title: 'macOS 10.15 Catalina Apache Setup: Multiple PHP Versions' + url: 'https://getgrav.org/blog/macos-catalina-apache-multiple-php-versions' + date: 1570528800 + nicetime: '8 months ago' + - + title: 'Grav 1.6 Released!' + url: 'https://getgrav.org/blog/grav-1.6-released' + date: 1554987300 + nicetime: '1 years ago' + - + title: 'Important Theme Updates' + url: 'https://getgrav.org/blog/important-theme-updates' + date: 1553169300 + nicetime: '1 years ago' + - + title: 'All RocketTheme Grav Themes 50% Off for Black Friday' + url: 'https://getgrav.org/blog/rockettheme-blackfriday-2018' + date: 1542790800 + nicetime: '2 years ago' + - + title: 'We''re Moving Chat from Slack to Discord' + url: 'https://getgrav.org/blog/chat-moving-to-discord' + date: 1542203100 + nicetime: '2 years ago' + - + title: 'RocketTheme''s Halloween Sale is Going on Right Now' + url: 'https://getgrav.org/blog/rockettheme-halloween-2018' + date: 1540792800 + nicetime: '2 years ago' diff --git a/user/data/feed/21232f297a57a5a743894a0e4a801fc3.yaml b/user/data/feed/21232f297a57a5a743894a0e4a801fc3.yaml new file mode 100644 index 0000000..f69a974 --- /dev/null +++ b/user/data/feed/21232f297a57a5a743894a0e4a801fc3.yaml @@ -0,0 +1,52 @@ +last_checked: 1591881558 +data: + - + title: 'Grav 1.7 RC.1 Released' + url: 'https://getgrav.org/blog/grav-17-rc1-released' + date: 1573060980 + nicetime: '7 months ago' + - + title: 'macOS 10.15 Catalina Apache Setup: Upgrading Homebrew' + url: 'https://getgrav.org/blog/macos-catalina-apache-upgrade-homebrew' + date: 1570536000 + nicetime: '8 months ago' + - + title: 'macOS 10.15 Catalina Apache Setup: SSL' + url: 'https://getgrav.org/blog/macos-catalina-apache-ssl' + date: 1570532580 + nicetime: '8 months ago' + - + title: 'macOS 10/15 Catalina Apache Setup: MySQL, Xdebug & More...' + url: 'https://getgrav.org/blog/macos-catalina-apache-mysql-vhost-apc' + date: 1570532400 + nicetime: '8 months ago' + - + title: 'macOS 10.15 Catalina Apache Setup: Multiple PHP Versions' + url: 'https://getgrav.org/blog/macos-catalina-apache-multiple-php-versions' + date: 1570528800 + nicetime: '8 months ago' + - + title: 'Grav 1.6 Released!' + url: 'https://getgrav.org/blog/grav-1.6-released' + date: 1554987300 + nicetime: '1 years ago' + - + title: 'Important Theme Updates' + url: 'https://getgrav.org/blog/important-theme-updates' + date: 1553169300 + nicetime: '1 years ago' + - + title: 'All RocketTheme Grav Themes 50% Off for Black Friday' + url: 'https://getgrav.org/blog/rockettheme-blackfriday-2018' + date: 1542790800 + nicetime: '2 years ago' + - + title: 'We''re Moving Chat from Slack to Discord' + url: 'https://getgrav.org/blog/chat-moving-to-discord' + date: 1542203100 + nicetime: '2 years ago' + - + title: 'RocketTheme''s Halloween Sale is Going on Right Now' + url: 'https://getgrav.org/blog/rockettheme-halloween-2018' + date: 1540792800 + nicetime: '2 years ago' diff --git a/user/data/licenses.yaml b/user/data/licenses.yaml new file mode 100644 index 0000000..e69de29 diff --git a/user/data/notifications/0cfa4fe11640014bf00e100a23e5ceff.yaml b/user/data/notifications/0cfa4fe11640014bf00e100a23e5ceff.yaml new file mode 100644 index 0000000..1c72fc1 --- /dev/null +++ b/user/data/notifications/0cfa4fe11640014bf00e100a23e5ceff.yaml @@ -0,0 +1,92 @@ +last_checked: 1591384550 +data: + feed: + - + id: 25 + date: '2020-02-18 11:27' + message: '🕵️‍♂️ Would you attend an Official Grav Conference? We need your help!' + link: 'https://forms.gle/22NQJuARmT1SFmXS6' + type: note + location: + - feed + - + id: 24 + date: '2019-10-03 11:27' + message: '🙏 Thanks to our amazing community, Grav was voted Best Flat File CMS in the 2019 CMS Critics'' Awards!' + link: 'https://www.cmscritic.com/awards/' + type: info + location: + - feed + - + id: 23 + date: '2018-11-15 15:50' + message: 'Grav community chat has moved from Slack to Discord' + link: 'https://chat.getgrav.org' + type: info + location: + - feed + - + id: 21 + date: '2018-10-01 17:09' + message: '🕺 Grav 1.6.0-beta.1 & Admin 1.9.0-beta.1 available for testing' + link: 'https://getgrav.org/downloads' + type: info + location: + - feed + - + id: 20 + date: '2018-10-01 17:05' + message: '🚨 Grav 1.5.2 & Admin 1.8.10 released' + link: 'https://getgrav.org/blog/new-xss-protection' + type: info + location: + - feed + - + id: 18 + date: '2018-08-17 11:40' + message: '🔥 Grav 1.5.0 final released, please update!' + link: 'https://getgrav.org/downloads' + type: info + location: + - feed + - + id: 12 + date: '2017-02-17 15:15' + message: 'Support Grav for the price of a a month!' + link: 'https://opencollective.com/grav' + type: note + location: + - feed + - + id: 4 + date: '2016-08-05 02:23' + message: 'Join the Grav mailing list to stay in the loop!' + link: 'http://eepurl.com/b41_oP' + type: info + location: + - feed + - + id: 3 + date: '2016-08-05 02:23' + message: 'Please follow us on Twitter' + link: 'https://twitter.com/getgrav' + type: note + location: + - feed + - + id: 2 + date: '2016-08-05 02:23' + message: 'Don''t forget to star Grav on GitHub!' + link: 'https://github.com/getgrav/grav' + type: info + location: + - feed + none: + - + id: 15 + date: '2017-11-16 12:15' + message: 'We won! Grav voted "Best Flat File CMS" in the 2017 CMS Critic Awards!' + link: 'https://getgrav.org/blog/cms-critic-award-2017' + type: note + location: + - none diff --git a/user/data/notifications/21232f297a57a5a743894a0e4a801fc3.yaml b/user/data/notifications/21232f297a57a5a743894a0e4a801fc3.yaml new file mode 100644 index 0000000..9d0fe08 --- /dev/null +++ b/user/data/notifications/21232f297a57a5a743894a0e4a801fc3.yaml @@ -0,0 +1,92 @@ +last_checked: 1591883471 +data: + feed: + - + id: 25 + date: '2020-02-18 11:27' + message: '🕵️‍♂️ Would you attend an Official Grav Conference? We need your help!' + link: 'https://forms.gle/22NQJuARmT1SFmXS6' + type: note + location: + - feed + - + id: 24 + date: '2019-10-03 11:27' + message: '🙏 Thanks to our amazing community, Grav was voted Best Flat File CMS in the 2019 CMS Critics'' Awards!' + link: 'https://www.cmscritic.com/awards/' + type: info + location: + - feed + - + id: 23 + date: '2018-11-15 15:50' + message: 'Grav community chat has moved from Slack to Discord' + link: 'https://chat.getgrav.org' + type: info + location: + - feed + - + id: 21 + date: '2018-10-01 17:09' + message: '🕺 Grav 1.6.0-beta.1 & Admin 1.9.0-beta.1 available for testing' + link: 'https://getgrav.org/downloads' + type: info + location: + - feed + - + id: 20 + date: '2018-10-01 17:05' + message: '🚨 Grav 1.5.2 & Admin 1.8.10 released' + link: 'https://getgrav.org/blog/new-xss-protection' + type: info + location: + - feed + - + id: 18 + date: '2018-08-17 11:40' + message: '🔥 Grav 1.5.0 final released, please update!' + link: 'https://getgrav.org/downloads' + type: info + location: + - feed + - + id: 12 + date: '2017-02-17 15:15' + message: 'Support Grav for the price of a a month!' + link: 'https://opencollective.com/grav' + type: note + location: + - feed + - + id: 4 + date: '2016-08-05 02:23' + message: 'Join the Grav mailing list to stay in the loop!' + link: 'http://eepurl.com/b41_oP' + type: info + location: + - feed + - + id: 3 + date: '2016-08-05 02:23' + message: 'Please follow us on Twitter' + link: 'https://twitter.com/getgrav' + type: note + location: + - feed + - + id: 2 + date: '2016-08-05 02:23' + message: 'Don''t forget to star Grav on GitHub!' + link: 'https://github.com/getgrav/grav' + type: info + location: + - feed + none: + - + id: 15 + date: '2017-11-16 12:15' + message: 'We won! Grav voted "Best Flat File CMS" in the 2017 CMS Critic Awards!' + link: 'https://getgrav.org/blog/cms-critic-award-2017' + type: note + location: + - none diff --git a/user/plugins/.gitkeep b/user/plugins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/user/plugins/admin-addon-user-manager/.gitattributes b/user/plugins/admin-addon-user-manager/.gitattributes new file mode 100644 index 0000000..2ce3627 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/.gitattributes @@ -0,0 +1,2 @@ +vendor/* linguist-generated=true +composer.lock linguist-generated=true \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/CHANGELOG.md b/user/plugins/admin-addon-user-manager/CHANGELOG.md new file mode 100644 index 0000000..aba3303 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/CHANGELOG.md @@ -0,0 +1,314 @@ +# v2.3.0 +## 05/04/2020 + +1. [](#new) + * Chinese translation (Thanks: https://github.com/dallaslu PR #68) + +2. [](#improved) + * Replace deprecated User::load (#66) + * Updated dependencies + * Better compatibility with v1.7 + +# v2.2.1 +## 12/11/2019 + +1. [](#bugfix) + * Fix YAML linting error (#63) + +# v2.2.0 +## 10/11/2019 + +1. [](#new) + * Brazilian Portuguese translation (Thanks: https://github.com/diegomagikal PR #61) + * Added "Enabled" toggle to user editor + +2. [](#bugfix) + * Fixed avatar upload + +# v2.1.8 +## 28/05/2019 + +1. [](#new) + * French translation (Thanks: https://github.com/Miaourt PR #58) + +2. [](#bugfix) + * Fixed a problem with saving groups (#59) + +# v2.1.7 +## 13/02/2019 + +1. [](#new) + * Serbian translation (Thanks: https://github.com/tomaja-linuxo PR #47) + * Russian translation (Thanks: https://github.com/Lufog-git PR #50) + * Ukrainian translation (Thanks: https://github.com/Lufog-git PR #51) + +2. [](#improved) + * Fixed some non-translatable strings (Thanks: https://github.com/Lufog-git PR #52) + +# v2.1.6 +## 06/06/2018 + +1. [](#bugfix) + * Fixed error when using 'Login As' feature with an user without admin permissions (#43) + +# v2.1.5 +## 09/04/2018 + +1. [](#bugfix) + * Fixed error when rendering front-end (#40) + +# v2.1.4 +## 09/04/2018 + +1. [](#improved) + * Moved 'site.login' permission to the front of permission list. (#36) + +# v2.1.3 +## 02/04/2018 + +1. [](#improved) + * Validate user object on save + +2. [](#bugfix) + * Fixed unset user permissions being pushed into the access array with an empty string value. Causing inherited permissions to be overwritten. (#38) + +# v2.1.2 +## 29/03/2018 + +1. [](#new) + * Norwegian translation (Thanks: https://github.com/achwell PR #37) + +# v2.1.1 +## 22/03/2018 + +1. [](#improved) + * Added 'site.login' permission to the permission list. (#36) + +# v2.1.0 +## 14/03/2018 + +1. [](#new) + * Czech translation (Thanks: https://github.com/07pepa Issue #29) + * Spanish translation (Thanks: https://github.com/filisko PR #31) + +2. [](#bugfix) + * Fixed user editor using wrong task when saving, which caused save error when you didn't have 'admin.super'. (#34) + * Added a temporary fix for user editor's permission area. The toggles moved below the permission's name at a specific width. (#22) + * Minor bugfixes + +# v2.0.3 +## 27/02/2018 + +1. [](#improved) + * Added missing translations + +# v2.0.2 +## 27/01/2018 + +1. [](#bugfix) + * Fixed wrong redirection after deleting an user (#28) + * Added missing translation for user delete confirmation + +# v2.0.1 +## 01/01/2018 + +1. [](#bugfix) + * Fixed admin links not working when something is changed in the form (#27) + +# v2.0.0 +## 29/12/2017 + +1. [](#new) + * 'Login As' button + +2. [](#bugfix) + * Fixed being redirected to the deleted user, now redirects to the user manager + * The delete button now shows up when editing the user + * Avatar upload now works + +# v1.9.1 +## 29/12/2017 + +1. [](#bugfix) + * Fixed 'Memory leak when using non-ascii character (?) to create group' (#26) + * Fixed being redirected to the deleted group, now redirects to the group manager + +# v1.9.0 +## 02/12/2017 + +1. [](#improved) + * Using custom blueprint for user editing (#23) + * Using custom request handler for saving user data (#23) + +# v1.8.1 +## 18/09/2017 + +1. [](#improved) + * Added username validating (#21) + +# v1.8.0 +## 14/08/2017 + +1. [](#new) + * Custom permissions (#18) + * User Expert editor (#19) + +# v1.7.1 +## 08/08/2017 + +1. [](#new) + * Permissions input in the bulk modal now accepts new values too + +# v1.7.0 +## 08/08/2017 + +1. [](#new) + * User permissions bulk actions + +# v1.6.1 +## 08/06/2017 + +1. [](#bugfix) + * Fixed removing of user from group not working. (#16, #17 Moonlight63 ) + +# v1.6.0 +## 07/31/2017 + +1. [](#new) + * User bulk actions + * Group bulk actions + +# v1.5.4 +## 07/28/2017 + +1. [](#bugfix) + * Fixed groups.yaml is not created when saving a group for the first time + +# v1.5.3 +## 07/28/2017 + +1. [](#bugfix) + * Fixed group creating not working properly + +# v1.5.2 +## 07/28/2017 + +1. [](#bugfix) + * Fixed an error which appeared when there are no groups.yaml + +# v1.5.1 +## 07/28/2017 + +1. [](#bugfix) + * Better PHP compatibility + +# v1.5.0 +## 07/28/2017 + +1. [](#new) + * Filter users + * Filter groups + +# v1.4.3 +## 07/27/2017 + +1. [](#new) + * Users count are now shown at group manager + * Users now can be added and/or removed from the group you are currently editing + +# v1.4.2 +## 07/27/2017 + +1. [](#improved) + * Pagination performance improvement + +2. [](#bugfix) + * Fixed group name is not shown when editing a group + +# v1.4.1 +## 07/27/2017 + +1. [](#improved) + * Permissions support + +# v1.4.0 +## 07/27/2017 + +1. [](#feature) + * Groups management! + +# v1.3.4 +## 07/24/2017 + +1. [](#improved) + * Plugin is now compatible with Grav Admin Styles Plugin + +# v1.3.3 +## 07/20/2017 + +1. [](#new) + * Users to be shown per page is now configurable + * Default list style is now configurable + +2. [](#improved) + * Refactored code a bit + +# v1.3.2 +## 07/20/2017 + +1. [](#improved) + * Performance is improved when admin cache is disabled + +1. [](#bugfix) + * Fixed plugin not working when admin cache is disabled + +# v1.3.1 +## 07/19/2017 + +1. [](#improved) + * Redirects to last URL after user delete + * Prevents cache refresh after user delete (performance improvement) + +2. [](#bugfix) + * Params are now validated + +# v1.3.0 +## 07/19/2017 + +1. [](#improved) + * Pagination is now more user friendly + * Users are cached (better performance) + +# v1.2.0 +## 07/19/2017 + +1. [](#feature) + * Added pagination + * Added list style + +# v1.1.2 +## 07/19/2017 + +1. [](#improved) + * No more page jumping because of avatars loading + +# v1.1.1 +## 07/06/2017 + +1. [](#bugfix) + * Plugin is now compatible with PHP 5.5 + +# v1.1.0 +## 07/03/2017 + +1. [](#new) + * Delete users from the UI + +2. [](#improved) + * Revamped UI + +# v1.0.0 +## 06/27/2017 + +1. [](#new) + * ChangeLog started... diff --git a/user/plugins/admin-addon-user-manager/LICENSE b/user/plugins/admin-addon-user-manager/LICENSE new file mode 100644 index 0000000..f5412ab --- /dev/null +++ b/user/plugins/admin-addon-user-manager/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Dávid Szabó + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/user/plugins/admin-addon-user-manager/README.md b/user/plugins/admin-addon-user-manager/README.md new file mode 100644 index 0000000..7330da2 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/README.md @@ -0,0 +1,39 @@ +# Admin Addon User Manager Plugin + +The **Admin Addon User Manager** Plugin is for [Grav CMS](http://github.com/getgrav/grav). A simple admin panel extension which adds the option to manage users and groups. + +## Installation + +Installing the Admin Addon User Manager plugin can be done in one of two ways. The GPM (Grav Package Manager) installation method enables you to quickly and easily install the plugin with a simple terminal command, while the manual method enables you to do so via a zip file. + +### GPM Installation (Preferred) + +The simplest way to install this plugin is via the [Grav Package Manager (GPM)](http://learn.getgrav.org/advanced/grav-gpm) through your system's terminal (also called the command line). From the root of your Grav install type: + + bin/gpm install admin-addon-user-manager + +This will install the Admin Addon User Manager plugin into your `/user/plugins` directory within Grav. Its files can be found under `/your/site/grav/user/plugins/admin-addon-user-manager`. + +### Manual Installation + +To install this plugin, just download the zip version of this repository and unzip it under `/your/site/grav/user/plugins`. Then, rename the folder to `admin-addon-user-manager`. You can find these files on [GitHub](https://github.com/d-vid-szab-/grav-plugin-admin-addon-user-manager) or via [GetGrav.org](http://getgrav.org/downloads/plugins#extras). + +You should now have all the plugin files under + + /your/site/grav/user/plugins/admin-addon-user-manager + +> NOTE: This plugin is a modular component for Grav which requires [Grav](http://github.com/getgrav/grav) and the [Admin](https://github.com/getgrav/grav-plugin-admin) plugin to operate. + +## Configuration + +Before configuring this plugin, you should copy the `user/plugins/admin-addon-user-manager/admin-addon-user-manager.yaml` to `user/config/plugins/admin-addon-user-manager.yaml` and only edit that copy. + +Here is the default configuration and an explanation of available options: + +```yaml +enabled: true +``` + +## Usage + +Install the plugin and you are good to go. 'User Manager' and 'Group Manager' will appear in the sidebar of the admin panel. diff --git a/user/plugins/admin-addon-user-manager/admin-addon-user-manager.php b/user/plugins/admin-addon-user-manager/admin-addon-user-manager.php new file mode 100644 index 0000000..5a9ccfa --- /dev/null +++ b/user/plugins/admin-addon-user-manager/admin-addon-user-manager.php @@ -0,0 +1,157 @@ +name; + + return ($key !== null) ? $pluginKey . '.' . $key : $pluginKey; + } + + public function getPluginConfigValue($key = null, $default = null) { + return $this->config->get($this->getPluginConfigKey($key), $default); + } + + public function getConfigValue($key, $default = null) { + return $this->config->get($key, $default); + } + + public function getPreviousUrl() { + return $this->grav['session']->{$this->name . '.previous_url'}; + } + + public function getModalsConfiguration() { + return CompiledYamlFile::instance(__DIR__ . DS . 'modals.yaml')->content(); + } + + public static function getSubscribedEvents() { + return [ + 'onPluginsInitialized' => ['onPluginsInitialized', 0], + 'onAdminRegisterPermissions' => ['onAdminRegisterPermissions', 1000] + ]; + } + + public function onPluginsInitialized() { + if (!$this->isAdmin() || !$this->grav['user']->authenticated) { + return; + } + + $this->grav['locator']->addPath('blueprints', '', __DIR__ . DS . 'blueprints'); + + include __DIR__ . DS . 'vendor' . DS . 'autoload.php'; + + $this->managers[] = new UsersManager($this->grav, $this); + $this->managers[] = new GroupsManager($this->grav, $this); + $this->managers[] = new UsersExpertManager($this->grav, $this); + + $this->enable([ + 'onAdminTwigTemplatePaths' => ['onAdminTwigTemplatePaths', -10], + 'onTwigSiteVariables' => ['onTwigSiteVariables', 0], + 'onAdminMenu' => ['onAdminMenu', 0], + 'onAssetsInitialized' => ['onAssetsInitialized', 0], + 'onAdminTaskExecute' => ['onAdminTaskExecute', 0], + ]); + + $this->registerPermissions(); + } + + public function onAssetsInitialized() { + $assets = $this->grav['assets']; + + foreach ($this->managers as $manager) { + $manager->initializeAssets($assets); + } + } + + public function onAdminMenu() { + $twig = $this->grav['twig']; + $twig->plugins_hooked_nav = (isset($twig->plugins_hooked_nav)) ? $twig->plugins_hooked_nav : []; + + foreach ($this->managers as $manager) { + $nav = $manager->getNav(); + if ($nav) { + $twig->plugins_hooked_nav[$nav['label']] = $nav; + } + } + } + + public function onAdminTwigTemplatePaths($e) { + $paths = $e['paths']; + $paths[] = __DIR__ . DS . 'templates'; + $e['paths'] = $paths; + } + + public function onTwigSiteVariables() { + $page = $this->grav['page']; + $twig = $this->grav['twig']; + $session = $this->grav['session']; + $uri = $this->grav['uri']; + + foreach ($this->managers as $manager) { + if ($page->slug() === $manager->getLocation() && $this->grav['admin']->authorize(['admin.super', $manager->getRequiredPermission()])) { + $session->{$this->name . '.previous_url'} = $uri->route() . $uri->params(); + + $page = $this->grav['admin']->page(true); + $page->id('aaum-' . implode('/', $uri->paths())); + + $twig->twig_vars['context'] = $page; + + $vars = $manager->handleRequest(); + $twig->twig_vars = array_merge($twig->twig_vars, $vars); + + return true; + } + } + } + + public function onAdminTaskExecute($e) { + foreach ($this->managers as $manager) { + if ($this->grav['admin']->authorize(['admin.super', $manager->getRequiredPermission()])) { + $result = $manager->handleTask($e); + + if ($result) { + return true; + } + } + } + + return false; + } + + public function registerPermissions() { + foreach ($this->managers as $manager) { + $this->grav['admin']->addPermissions([$manager->getRequiredPermission() => 'boolean']); + } + + // Custom permissions + $customPermissions = $this->getPluginConfigValue('custom_permissions', []); + foreach ($customPermissions as $permission) { + $this->grav['admin']->addPermissions([$permission => 'boolean']); + } + } + + public function onAdminRegisterPermissions() { + if (!$this->isAdmin() || !$this->grav['user']->authenticated) { + return; + } + + $this->grav['admin']->addPermissions(['site.login' => 'boolean']); + } + +} diff --git a/user/plugins/admin-addon-user-manager/admin-addon-user-manager.yaml b/user/plugins/admin-addon-user-manager/admin-addon-user-manager.yaml new file mode 100644 index 0000000..953d197 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/admin-addon-user-manager.yaml @@ -0,0 +1,4 @@ +enabled: true +default_list_style: list +pagination: + per_page: 20 \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/admin/pages/group-manager.md b/user/plugins/admin-addon-user-manager/admin/pages/group-manager.md new file mode 100644 index 0000000..41522a7 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/admin/pages/group-manager.md @@ -0,0 +1,7 @@ +--- +title: Group Manager +access: + admin.groups: true + admin.login: true + admin.super: true +--- diff --git a/user/plugins/admin-addon-user-manager/admin/pages/user-expert.md b/user/plugins/admin-addon-user-manager/admin/pages/user-expert.md new file mode 100644 index 0000000..80753c8 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/admin/pages/user-expert.md @@ -0,0 +1,7 @@ +--- +title: User Expert +access: + admin.users_expert: true + admin.login: true + admin.super: true +--- diff --git a/user/plugins/admin-addon-user-manager/admin/pages/user-manager.md b/user/plugins/admin-addon-user-manager/admin/pages/user-manager.md new file mode 100644 index 0000000..c1c6ed1 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/admin/pages/user-manager.md @@ -0,0 +1,7 @@ +--- +title: User Manager +access: + admin.users: true + admin.login: true + admin.super: true +--- diff --git a/user/plugins/admin-addon-user-manager/assets/groups/style.css b/user/plugins/admin-addon-user-manager/assets/groups/style.css new file mode 100644 index 0000000..f2c7027 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/assets/groups/style.css @@ -0,0 +1,3 @@ +.admin-addon-user-manager--group.admin-addon-user-manager--list .user__username { + flex: 1 0 auto; +} \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/assets/users/style.css b/user/plugins/admin-addon-user-manager/assets/users/style.css new file mode 100644 index 0000000..611a22a --- /dev/null +++ b/user/plugins/admin-addon-user-manager/assets/users/style.css @@ -0,0 +1,227 @@ +#admin-main .admin-block .admin-addon-user-manager h2 { + text-align: center; + font-size: 2rem; +} + +/** + * Style chooser + */ +.admin-addon-user-manager-style { + float: right; + overflow: hidden; + font-size: 1.5em; + margin: 0 16px; +} + +.admin-addon-user-manager-style i { + padding-left: 8px; +} + +.admin-addon-user-manager .cell--header { + font-weight: bold; +} + +/** + * Pagination + */ +.admin-addon-user-manager-pagination { + margin: 16px auto; + padding: 0; + text-align: center; +} + +.admin-addon-user-manager-pagination ul { + list-style: none; + margin: 0 auto; + overflow: hidden; + clear: both; + display: inline-block; + padding: 0; +} + +.admin-addon-user-manager-pagination li { + padding: 4px 8px; + border: 1px solid rgba(0, 0, 0, .24); + float: left; +} + +.admin-addon-user-manager-pagination li.current { + background: #0082ba; + color: white; +} + +.admin-addon-user-manager-pagination li + li { + border-left: 0; +} + +/** + * Grid style + */ +.admin-addon-user-manager--grid { + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; +} + +.admin-addon-user-manager--grid .cell { + flex: 0 0 100%; +} + +.admin-addon-user-manager--grid .user { + margin: 16px; + border-radius: 2px; + box-shadow: 0 1px 3px rgba(0, 0, 0, .12), 0 1px 2px rgba(0, 0, 0, .24); +} + +.admin-addon-user-manager--grid .user__avatar { + display: block; + width: 100%; + height: 0; + position: relative; + padding-top: 100%; + overflow: hidden; +} + +.admin-addon-user-manager--grid .user__avatar img { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: auto; + max-width: 100%; + display: block; + border-radius: 2px 2px 0 0; + object-fit: cover; +} + +.admin-addon-user-manager--grid .user__username { + padding: 8px; +} + +.admin-addon-user-manager--grid .user__email { + padding: 0 8px 8px 8px; +} + +.admin-addon-user-manager--grid .user__actions { + padding: 8px; + border-top: 1px solid rgba(0, 0, 0, .14); + overflow: hidden; + display: flex; + justify-content: flex-end; +} + +.admin-addon-user-manager--grid .user__actions > a { + text-transform: uppercase; +} + +.admin-addon-user-manager--grid .user__actions > a + a { + padding-left: 8px; +} + +#admin-main .admin-addon-user-manager--grid .user__actions > a.delete { + color: #f44336; +} + +#admin-main .admin-addon-user-manager--grid .user__actions > a.delete:hover { + color: #f6685e; +} + +@media only screen and (min-width: 480px) { + .admin-addon-user-manager--grid .cell { + flex: 0 0 50%; + } +} + +@media only screen and (min-width: 768px) { + .admin-addon-user-manager--grid .cell { + flex: 0 0 33.333%; + } +} + +@media only screen and (min-width: 1400px) { + .admin-addon-user-manager--grid .cell { + flex: 0 0 20%; + } +} + +/** + * List style + */ +.admin-addon-user-manager--list { + width: 100%; + display: flex; + flex-direction: column; + padding: 16px; +} + +.admin-addon-user-manager--list .cell + .cell { + border-top: 1px solid rgba(0, 0, 0, .24); + padding-top: 8px; + margin-top: 8px; +} + +.admin-addon-user-manager--list .user { + display: flex; + flex-direction: row; +} + +.admin-addon-user-manager--list .user > * { + padding: 0 8px; +} + +.admin-addon-user-manager--list .user__checkbox { + flex: 0 0 auto; +} + +.admin-addon-user-manager--list .user__username { + flex: 0 0 25%; +} + +.admin-addon-user-manager--list .user__email { + flex: 1 0 auto; +} + +.admin-addon-user-manager--list .user__actions { + flex: 0 0 20%; +} + +/** + * Filter + */ +.admin-addon-user-manager-filter { + display: flex; +} + +.admin-addon-user-manager-filter__input { + flex: 1 0 auto; +} + +.admin-addon-user-manager-filter__input .form-data { + padding-right: 1rem; +} + +.admin-addon-user-manager-filter__help { + flex: 0 0 auto; + padding-right: 2rem; +} + +#admin-main .admin-block .admin-addon-user-manager-filter__help .button { + padding-top: .425rem; + padding-bottom: .425rem; +} + +/** + * Bulk + */ +.admin-addon-user-manager-bulk-action { + margin-left: 16px; +} + +.button.warning:hover { + background: red; +} + +.remodal[data-remodal-id="modal-admin-addon-user-manager-bulk"] input[type="checkbox"] { + display: none; +} \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/blueprints.yaml b/user/plugins/admin-addon-user-manager/blueprints.yaml new file mode 100644 index 0000000..8782664 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/blueprints.yaml @@ -0,0 +1,58 @@ +name: Admin Addon User Manager +version: 2.3.0 +description: A simple admin panel extension which adds the option to manage users and groups +icon: plug +author: + name: Dávid Szabó + email: david.szabo97@gmail.com +homepage: https://github.com/david-szabo97/grav-plugin-admin-addon-user-manager +keywords: grav, plugin, admin, media +bugs: https://github.com/david-szabo97/grav-plugin-admin-addon-user-manager/issues +docs: https://github.com/david-szabo97/grav-plugin-admin-addon-user-manager/blob/master/README.md +license: MIT + +dependencies: + - { name: grav, version: '>=1.0.0' } + - { name: admin, version: '>=1.0.0' } + +form: + validation: strict + fields: + enabled: + type: toggle + label: PLUGIN_ADMIN.PLUGIN_STATUS + highlight: 1 + default: 0 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + default_list_style: + label: PLUGIN_ADMIN_ADDON_USER_MANAGER.DEFAULT_LIST_STYLE + type: select + options: + grid: PLUGIN_ADMIN_ADDON_USER_MANAGER.GRID + list: PLUGIN_ADMIN_ADDON_USER_MANAGER.LIST + + pagination.per_page: + label: PLUGIN_ADMIN_ADDON_USER_MANAGER.USERS_PER_PAGE + type: select + options: + 10: 10 + 20: 20 + 30: 30 + 40: 40 + 50: 50 + 60: 60 + 70: 70 + 80: 80 + 90: 90 + 100: 100 + + custom_permissions: + label: PLUGIN_ADMIN_ADDON_USER_MANAGER.CUSTOM_PERMISSIONS + type: array + placeholder_value: PLUGIN_ADMIN_ADDON_USER_MANAGER.CUSTOM_PERMISSIONS_PLACEHOLDER + value_only: true \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/blueprints/user/aaum-account.yaml b/user/plugins/admin-addon-user-manager/blueprints/user/aaum-account.yaml new file mode 100644 index 0000000..0770bf6 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/blueprints/user/aaum-account.yaml @@ -0,0 +1,20 @@ +extends@: + type: user/account + context: blueprints:// + +form: + fields: + security: + unset-security@: true + state: + ordering@: content + type: toggle + label: PLUGIN_ADMIN.ENABLED + classes: twofa-toggle + highlight: enabled + default: enabled + options: + enabled: PLUGIN_ADMIN.YES + disabled: PLUGIN_ADMIN.NO + validate: + type: string \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/blueprints/user/account-raw.yaml b/user/plugins/admin-addon-user-manager/blueprints/user/account-raw.yaml new file mode 100644 index 0000000..facaa4f --- /dev/null +++ b/user/plugins/admin-addon-user-manager/blueprints/user/account-raw.yaml @@ -0,0 +1,16 @@ +title: User +form: + fields: + raw: + type: editor + label: Raw + autofocus: true + codemirror: + mode: 'yaml' + indentUnit: 4 + autofocus: true + indentWithTabs: false + lineNumbers: true + styleActiveLine: true + gutters: ['CodeMirror-lint-markers'] + lint: true \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/blueprints/user/group.yaml b/user/plugins/admin-addon-user-manager/blueprints/user/group.yaml new file mode 100644 index 0000000..0abadb8 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/blueprints/user/group.yaml @@ -0,0 +1,43 @@ +title: Group +form: + validation: loose + + fields: + groupname: + type: text + size: large + label: PLUGIN_ADMIN.NAME + readonly: true + + readableName: + type: text + size: large + label: PLUGIN_ADMIN_ADDON_USER_MANAGER.READABLE_NAME + + description: + type: text + size: large + label: PLUGIN_ADMIN.DESCRIPTION + + icon: + type: text + size: small + label: PLUGIN_ADMIN_ADDON_USER_MANAGER.ICON + + access: + type: permissions + label: PLUGIN_ADMIN.PERMISSIONS + ignore_empty: true + validate: + type: array + + users: + type: select + multiple: true + size: large + label: PLUGIN_ADMIN_ADDON_USER_MANAGER.USERS + data-options@: '\AdminAddonUserManager\Users\Manager::userNames' + classes: fancy + help: PLUGIN_ADMIN_ADDON_USER_MANAGER.USERS_HELP + validate: + type: commalist \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/composer.json b/user/plugins/admin-addon-user-manager/composer.json new file mode 100644 index 0000000..aa7ac23 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/composer.json @@ -0,0 +1,10 @@ +{ + "autoload": { + "psr-4": { + "AdminAddonUserManager\\": "src/" + } + }, + "require": { + "symfony/expression-language": "^3.3" + } +} diff --git a/user/plugins/admin-addon-user-manager/composer.lock b/user/plugins/admin-addon-user-manager/composer.lock new file mode 100644 index 0000000..b5278cd --- /dev/null +++ b/user/plugins/admin-addon-user-manager/composer.lock @@ -0,0 +1,570 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "a47ed7fe21ccca95f49cff0e9e3b871e", + "packages": [ + { + "name": "paragonie/random_compat", + "version": "v9.99.99", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", + "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", + "shasum": "" + }, + "require": { + "php": "^7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "time": "2018-07-02T15:55:56+00:00" + }, + { + "name": "psr/cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "time": "2016-08-06T20:24:11+00:00" + }, + { + "name": "psr/container", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "time": "2017-02-14T16:28:37+00:00" + }, + { + "name": "psr/log", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "time": "2020-03-23T09:12:05+00:00" + }, + { + "name": "symfony/cache", + "version": "v4.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "f777b570291aebe51081b9827e05f3a747665e87" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/f777b570291aebe51081b9827e05f3a747665e87", + "reference": "f777b570291aebe51081b9827e05f3a747665e87", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/cache": "~1.0", + "psr/log": "~1.0", + "symfony/cache-contracts": "^1.1.7|^2", + "symfony/service-contracts": "^1.1|^2", + "symfony/var-exporter": "^4.2|^5.0" + }, + "conflict": { + "doctrine/dbal": "<2.5", + "symfony/dependency-injection": "<3.4", + "symfony/http-kernel": "<4.4", + "symfony/var-dumper": "<4.4" + }, + "provide": { + "psr/cache-implementation": "1.0", + "psr/simple-cache-implementation": "1.0", + "symfony/cache-implementation": "1.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/cache": "~1.6", + "doctrine/dbal": "~2.5", + "predis/predis": "~1.1", + "psr/simple-cache": "^1.0", + "symfony/config": "^4.2|^5.0", + "symfony/dependency-injection": "^3.4|^4.1|^5.0", + "symfony/var-dumper": "^4.4|^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Cache component with PSR-6, PSR-16, and tags", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "time": "2020-03-27T16:54:36+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "23ed8bfc1a4115feca942cb5f1aacdf3dcdf3c16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/23ed8bfc1a4115feca942cb5f1aacdf3dcdf3c16", + "reference": "23ed8bfc1a4115feca942cb5f1aacdf3dcdf3c16", + "shasum": "" + }, + "require": { + "php": "^7.2.5", + "psr/cache": "^1.0" + }, + "suggest": { + "symfony/cache-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-11-18T17:27:11+00:00" + }, + { + "name": "symfony/expression-language", + "version": "v3.4.39", + "source": { + "type": "git", + "url": "https://github.com/symfony/expression-language.git", + "reference": "206165f46c660f3231df0afbdeec6a62f81afc59" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/206165f46c660f3231df0afbdeec6a62f81afc59", + "reference": "206165f46c660f3231df0afbdeec6a62f81afc59", + "shasum": "" + }, + "require": { + "php": "^5.5.9|>=7.0.8", + "symfony/cache": "~3.1|~4.0", + "symfony/polyfill-php70": "~1.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\ExpressionLanguage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony ExpressionLanguage Component", + "homepage": "https://symfony.com", + "time": "2020-03-16T08:31:04+00:00" + }, + { + "name": "symfony/polyfill-php70", + "version": "v1.15.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php70.git", + "reference": "2a18e37a489803559284416df58c71ccebe50bf0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/2a18e37a489803559284416df58c71ccebe50bf0", + "reference": "2a18e37a489803559284416df58c71ccebe50bf0", + "shasum": "" + }, + "require": { + "paragonie/random_compat": "~1.0|~2.0|~9.99", + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.15-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php70\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2020-02-27T09:26:54+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "144c5e51266b281231e947b51223ba14acf1a749" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/144c5e51266b281231e947b51223ba14acf1a749", + "reference": "144c5e51266b281231e947b51223ba14acf1a749", + "shasum": "" + }, + "require": { + "php": "^7.2.5", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-11-18T17:27:11+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v5.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "ffd29a70370e466343e33154b5df197a07a13afa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/ffd29a70370e466343e33154b5df197a07a13afa", + "reference": "ffd29a70370e466343e33154b5df197a07a13afa", + "shasum": "" + }, + "require": { + "php": "^7.2.5" + }, + "require-dev": { + "symfony/var-dumper": "^4.4|^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A blend of var_export() + serialize() to turn any serializable data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "serialize" + ], + "time": "2020-03-27T16:56:45+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/user/plugins/admin-addon-user-manager/docs/filter.md b/user/plugins/admin-addon-user-manager/docs/filter.md new file mode 100644 index 0000000..5ad9cac --- /dev/null +++ b/user/plugins/admin-addon-user-manager/docs/filter.md @@ -0,0 +1,67 @@ +# Filter expression + +## Users +### Available variables +* `user.username` username of the user +* `user.email` email of the user +* `user.fullName` full name of the user +* `user.title` title of the user +* `user.language` language of the user +* `user.groups` array of the groups the user is in +* `user.access` an array which contains the permissions of the user + +### Available methods +* `user.authorize('example.permission')` checks whether user has access to the given permission or not (take groups into account) + +### Examples +* filter users by permissions + ``` + user.authorize('admin.super') + ``` +* show users who are in the 'paid' group + ``` + 'paid' in user.groups + ``` +* show users without groups + ``` + count(user.groups) > 0 + ``` +* show users with access to 'admin.users' + ``` + group.authorize('admin.users') and groups.users > 0 +* show users with gmail email provider + ``` + user.email matches '/@gmail.com/' + ``` + +## Groups +### Available variables +* `group.groupname` name of the group +* `group.readableName` readable name of the group +* `group.description` description of the group +* `group.icon` icon of the group +* `group.access` an array which contains the permissions of the group + +### Available methods +* `group.authorize('example.permission')` checks whether group has access to the given permission or not + +### Examples +* filter groups by permissions + ``` + group.authorize('admin.super') + ``` +* show groups with more than 5 users + ``` + group.users > 5 + ``` +* show empty groups + ``` + group.users == 0 + ``` +* show groups with access to 'admin.users' and not empty + ``` + group.authorize('admin.users') and groups.users > 0 +* show groups which contains 'admin' in its description + ``` + group.description matches '/admin/' + ``` \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/languages/cs.yaml b/user/plugins/admin-addon-user-manager/languages/cs.yaml new file mode 100644 index 0000000..c68cc17 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/languages/cs.yaml @@ -0,0 +1,34 @@ +PLUGIN_ADMIN_ADDON_USER_MANAGER: + USER_MANAGER: Správce uživatelů + GROUP_MANAGER: Správce skupin + FILTER_ERROR: Chyba filtru + FILTER_USERS: Filtruj uživatele + FILTER_GROUPS: Filtruj skupiny + NO_RESULTS: Žádné shody + EXPERT: Expert + PAGINATION_SUMMARY: Zobrazeno %d - %d z %d + BULK_ACTION: Hromadné akce + ACTIONS: Akce + CUSTOM_PERMISSIONS: Specifická opravnění + CUSTOM_PERMISSIONS_PLACEHOLDER: "opravnění (např. 'site.login')" + USERS_PER_PAGE: Uživatelů na stránku + DEFAULT_LIST_STYLE: Výchozí styl listu + GRID: Mřížka + LIST: Stránka + GROUP: Skupina + GROUPS: Skupiny + READABLE_NAME: Zobrazené jméno + ICON: Ikona + ADD_GROUP: Přidat skupinu + USERS: Uživatelé + HELP: Pomoc + BULK_DELETE_USER: Delete selected users + BULK_DELETE: Delete + BULK_USER_GROUP: Bulk user actions related to groups + BULK_ADD_GROUP: Add selected users to selected groups + BULK_REMOVE_GROUP: Odeber vybrané uživatele + BULK_DELETE_GROUP: Odeber vybrané skupiny + BULK_ADD_PERMISSIONS: Přidej vybraná opravnění vybraným + BULK_REMOVE_PERMISSIONS: Odeber vybraná opravnění vybraným uživatelům + LOGIN_AS: Přihlaš se jako + USER_CONFIRM_DELETE: Opravdu chcete smazat tohoto uživatele? diff --git a/user/plugins/admin-addon-user-manager/languages/en.yaml b/user/plugins/admin-addon-user-manager/languages/en.yaml new file mode 100644 index 0000000..d937e48 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/languages/en.yaml @@ -0,0 +1,34 @@ +PLUGIN_ADMIN_ADDON_USER_MANAGER: + USER_MANAGER: Users Manager + GROUP_MANAGER: Groups Manager + FILTER_ERROR: Filter Error + FILTER_USERS: Filter users + FILTER_GROUPS: Filter groups + NO_RESULTS: No results + EXPERT: Expert + PAGINATION_SUMMARY: Showing %d - %d of %d + BULK_ACTION: Bulk Action + ACTIONS: Actions + CUSTOM_PERMISSIONS: Custom permissions + CUSTOM_PERMISSIONS_PLACEHOLDER: "Permission (e.g. 'site.login')" + USERS_PER_PAGE: Users per page + DEFAULT_LIST_STYLE: Default list style + GRID: Grid + LIST: List + GROUP: Group + GROUPS: Groups + READABLE_NAME: Readable name + ICON: Icon + ADD_GROUP: Add Group + USERS: Users + HELP: Help + BULK_DELETE_USER: Delete selected users + BULK_DELETE: Delete + BULK_USER_GROUP: Bulk user actions related to groups + BULK_ADD_GROUP: Add selected users to selected groups + BULK_REMOVE_GROUP: Remove selected users from selected groups + BULK_DELETE_GROUP: Delete selected groups + BULK_ADD_PERMISSIONS: Add selected permissions to selected users + BULK_REMOVE_PERMISSIONS: Remove selected permissions from selected users + LOGIN_AS: Login as + USER_CONFIRM_DELETE: Are you sure you want to delete this user? diff --git a/user/plugins/admin-addon-user-manager/languages/es.yaml b/user/plugins/admin-addon-user-manager/languages/es.yaml new file mode 100644 index 0000000..e602423 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/languages/es.yaml @@ -0,0 +1,34 @@ +PLUGIN_ADMIN_ADDON_USER_MANAGER: + USER_MANAGER: Usuarios + GROUP_MANAGER: Grupos + FILTER_ERROR: Error de filtro + FILTER_USERS: Filtrar usuarios + FILTER_GROUPS: Filtrar grupos + NO_RESULTS: Sin resultados + EXPERT: Experto + PAGINATION_SUMMARY: Mostrando %d - %d de %d + BULK_ACTION: Acciones en masa + ACTIONS: Acciones + CUSTOM_PERMISSIONS: Permisos customizados + CUSTOM_PERMISSIONS_PLACEHOLDER: "Permiso (e.g. 'site.login')" + USERS_PER_PAGE: Usuarios por página + DEFAULT_LIST_STYLE: Estilo de lista predeterminado + GRID: Grid + LIST: Lista + GROUP: Grupo + GROUPS: Grupos + READABLE_NAME: Nombre legible + ICON: Icono + ADD_GROUP: Añadir Grupo + USERS: Usuarios + HELP: Ayuda + BULK_DELETE_USER: Eliminar usuarios seleccionados + BULK_DELETE: Eliminar + BULK_USER_GROUP: Acciones masivas de usuarios relacionadas con grupos + BULK_ADD_GROUP: Agregar usuarios seleccionados a los grupos seleccionados + BULK_REMOVE_GROUP: Eliminar usuarios seleccionados de los grupos seleccionados + BULK_DELETE_GROUP: Eliminar grupos seleccionados + BULK_ADD_PERMISSIONS: Añadir permisos seleccionados a los usuarios seleccionados + BULK_REMOVE_PERMISSIONS: Eliminar permisos seleccionados de los usuarios seleccionados + LOGIN_AS: Entrar como + USER_CONFIRM_DELETE: ¿Estás seguro de que deseas eliminar este usuario? diff --git a/user/plugins/admin-addon-user-manager/languages/fr.yaml b/user/plugins/admin-addon-user-manager/languages/fr.yaml new file mode 100644 index 0000000..6c9a03f --- /dev/null +++ b/user/plugins/admin-addon-user-manager/languages/fr.yaml @@ -0,0 +1,34 @@ +PLUGIN_ADMIN_ADDON_USER_MANAGER: + USER_MANAGER: Utilisateurs + GROUP_MANAGER: Groupes + FILTER_ERROR: Erreur de filtre + FILTER_USERS: Filtrer les utilisateurs + FILTER_GROUPS: Filtrer les groupes + NO_RESULTS: Pas de resultats + EXPERT: Expert + PAGINATION_SUMMARY: Afficher %d - %d de %d + BULK_ACTION: Action en bloc + ACTIONS: Actions + CUSTOM_PERMISSIONS: Permissions personnalisées + CUSTOM_PERMISSIONS_PLACEHOLDER: "Permission (par ex. 'site.login')" + USERS_PER_PAGE: Utilisateurs par page + DEFAULT_LIST_STYLE: Style de liste par défaut + GRID: Grille + LIST: Liste + GROUP: Groupe + GROUPS: Groupes + READABLE_NAME: Nom lisible + ICON: Icone + ADD_GROUP: Ajouter un groupe + USERS: Utilisateurs + HELP: Aide + BULK_DELETE_USER: Supprimer les utilisateurs sélectionnés + BULK_DELETE: Supprimer + BULK_USER_GROUP: Actions en bloc liées aux groupes + BULK_ADD_GROUP: Ajouter des utilisateurs sélectionnés aux groupes sélectionnés + BULK_REMOVE_GROUP: Supprimer les utilisateurs sélectionnés des groupes sélectionnés + BULK_DELETE_GROUP: Supprimer les groupes sélectionnés + BULK_ADD_PERMISSIONS: Ajouter les permissions sélectionnées aux utilisateurs sélectionnés + BULK_REMOVE_PERMISSIONS: Supprimer les permissions sélectionnées des utilisateurs sélectionnés + LOGIN_AS: Se connecter en tant que + USER_CONFIRM_DELETE: Êtes-vous sûr de vouloir supprimer cet utilisateur ? diff --git a/user/plugins/admin-addon-user-manager/languages/no.yaml b/user/plugins/admin-addon-user-manager/languages/no.yaml new file mode 100644 index 0000000..55d9caf --- /dev/null +++ b/user/plugins/admin-addon-user-manager/languages/no.yaml @@ -0,0 +1,34 @@ +PLUGIN_ADMIN_ADDON_USER_MANAGER: + USER_MANAGER: Brukere + GROUP_MANAGER: Grupper + FILTER_ERROR: Ingen treff + FILTER_USERS: Filtrer brukere + FILTER_GROUPS: Filtrer grupper + NO_RESULTS: Ingen treff + EXPERT: Ekspert + PAGINATION_SUMMARY: Viser %d - %d av %d + BULK_ACTION: Bulk Handlinger + ACTIONS: Handlinger + CUSTOM_PERMISSIONS: Tilganger + CUSTOM_PERMISSIONS_PLACEHOLDER: "Tilganger (f.eks. 'site.login')" + USERS_PER_PAGE: Brukere pr side + DEFAULT_LIST_STYLE: Standard listevisning + GRID: Rutenett + LIST: Liste + GROUP: Gruppe + GROUPS: Grupper + READABLE_NAME: Lesbart navn + ICON: Ikon + ADD_GROUP: Legg til gruppe + USERS: Brukere + HELP: Hjelp + BULK_DELETE_USER: Slett valgte brukere + BULK_DELETE: Slett + BULK_USER_GROUP: Bulk brukerhandlinger relatert til grupper + BULK_ADD_GROUP: Legg valgte brukere til valgte grupper + BULK_REMOVE_GROUP: Fjern valgte brukere fra valgte grupper + BULK_DELETE_GROUP: Slett valgte grupper + BULK_ADD_PERMISSIONS: Legg valgte rettigheter til valgte brukere + BULK_REMOVE_PERMISSIONS: Fjern valgte rettigheter fra valgte brukere + LOGIN_AS: Logg inn som + USER_CONFIRM_DELETE: Er du sikker på at du vil slette denne brukeren? diff --git a/user/plugins/admin-addon-user-manager/languages/pt.yaml b/user/plugins/admin-addon-user-manager/languages/pt.yaml new file mode 100644 index 0000000..98b7b78 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/languages/pt.yaml @@ -0,0 +1,34 @@ +PLUGIN_ADMIN_ADDON_USER_MANAGER: + USER_MANAGER: Gerenciar usuários + GROUP_MANAGER: Gerenciar grupos + FILTER_ERROR: Erro no filtro + FILTER_USERS: Filtrar usuários + FILTER_GROUPS: Filtrar grupos + NO_RESULTS: Nenhum resultado + EXPERT: Avançado + PAGINATION_SUMMARY: Mostrando %d - %d de %d + BULK_ACTION: Ações em massa + ACTIONS: Ações + CUSTOM_PERMISSIONS: Permissões personalizadas + CUSTOM_PERMISSIONS_PLACEHOLDER: "Permissão (ex. 'site.login')" + USERS_PER_PAGE: Usuários por página + DEFAULT_LIST_STYLE: Estilo padrão da listagem + GRID: Grade + LIST: Lista + GROUP: Grupo + GROUPS: Grupos + READABLE_NAME: Nome de leitura + ICON: Ícone + ADD_GROUP: Adicionar grupo + USERS: Usuários + HELP: Ajuda + BULK_DELETE_USER: Excluir usuários slecionados + BULK_DELETE: Excluir + BULK_USER_GROUP: Ações em massa do usuário relacionadas a grupos + BULK_ADD_GROUP: Adicionar usuários selecionados aos grupos selecionados + BULK_REMOVE_GROUP: Remover usuários selecionados dos grupos selecionados + BULK_DELETE_GROUP: Excluir grupos selecionados + BULK_ADD_PERMISSIONS: Adicionar permissões selecionadas aos usuários selecionados + BULK_REMOVE_PERMISSIONS: Remover permissões selecionadas dos usuários selecionados + LOGIN_AS: Entrar como + USER_CONFIRM_DELETE: Você tem certeza que deseja excluir este usuário? diff --git a/user/plugins/admin-addon-user-manager/languages/ru.yaml b/user/plugins/admin-addon-user-manager/languages/ru.yaml new file mode 100644 index 0000000..6935dcc --- /dev/null +++ b/user/plugins/admin-addon-user-manager/languages/ru.yaml @@ -0,0 +1,34 @@ +PLUGIN_ADMIN_ADDON_USER_MANAGER: + USER_MANAGER: Пользователи + GROUP_MANAGER: Группы пользователей + FILTER_ERROR: Ошибка фильтрации + FILTER_USERS: Фильтр пользователей + FILTER_GROUPS: Фильтр групп + NO_RESULTS: Нет результатов + EXPERT: Экспертный + PAGINATION_SUMMARY: Страница %d - %d из %d + BULK_ACTION: Групповые действия + ACTIONS: Действия + CUSTOM_PERMISSIONS: Пользовательские разрешения + CUSTOM_PERMISSIONS_PLACEHOLDER: "Разрешение (например, 'site.login')" + USERS_PER_PAGE: Пользователи на странице + DEFAULT_LIST_STYLE: Стиль списка по умолчанию + GRID: Сетка + LIST: Список + GROUP: Группа + GROUPS: Группы + READABLE_NAME: Читаемое имя + ICON: Иконка + ADD_GROUP: Добавить группу + USERS: Пользователи + HELP: Помощь + BULK_DELETE_USER: Удалить выбранных пользователей + BULK_DELETE: Удалить + BULK_USER_GROUP: Массовые пользовательские действия, связанные с группами + BULK_ADD_GROUP: Добавить выбранных пользователей в выбранные группы + BULK_REMOVE_GROUP: Удалить выбранных пользователей из выбранных групп + BULK_DELETE_GROUP: Удалить выбранные группы + BULK_ADD_PERMISSIONS: Добавить выбранные разрешения для выбранных пользователей + BULK_REMOVE_PERMISSIONS: Удалить выбранные разрешения у выбранных пользователей + LOGIN_AS: Войти как + USER_CONFIRM_DELETE: Вы уверены, что хотите удалить этого пользователя? diff --git a/user/plugins/admin-addon-user-manager/languages/sr.yaml b/user/plugins/admin-addon-user-manager/languages/sr.yaml new file mode 100644 index 0000000..be9fa64 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/languages/sr.yaml @@ -0,0 +1,34 @@ +PLUGIN_ADMIN_ADDON_USER_MANAGER: + USER_MANAGER: Менаџер Корисника + GROUP_MANAGER: Менаџер Група + FILTER_ERROR: Грешка у филтрирању + FILTER_USERS: Филтрирај кориснике + FILTER_GROUPS: Филтрирај групе + NO_RESULTS: Нема резултата + EXPERT: Напредно + PAGINATION_SUMMARY: Приказујем %d - %d од %d + BULK_ACTION: Групна Акција + ACTIONS: Акције + CUSTOM_PERMISSIONS: Прилагођена овлашћења + CUSTOM_PERMISSIONS_PLACEHOLDER: "Овлашћења (e.g. 'site.login')" + USERS_PER_PAGE: Корисника по страници + DEFAULT_LIST_STYLE: Подразумевани изглед листе + GRID: Мрежа + LIST: Листа + GROUP: Група + GROUPS: Групе + READABLE_NAME: Читљиво име + ICON: Икона + ADD_GROUP: Додај групу + USERS: Корисници + HELP: Помоћ + BULK_DELETE_USER: Обриши изабране кориснике + BULK_DELETE: Обриши + BULK_USER_GROUP: Групна корисничка акција везана за фрупе + BULK_ADD_GROUP: Додај изабране кориснике у изабране групе + BULK_REMOVE_GROUP: Уклони изабране кориснике из изабраних група + BULK_DELETE_GROUP: Обриши изабране кориснике + BULK_ADD_PERMISSIONS: Додај изабрана овлашћења изабраним корисницима + BULK_REMOVE_PERMISSIONS: Уклони изабрана овлашћења изабраним корисницима + LOGIN_AS: Пријављује се као + USER_CONFIRM_DELETE: Да ли си сигуран да желиш да обришеш овог корисника? diff --git a/user/plugins/admin-addon-user-manager/languages/uk.yaml b/user/plugins/admin-addon-user-manager/languages/uk.yaml new file mode 100644 index 0000000..a68b997 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/languages/uk.yaml @@ -0,0 +1,34 @@ +PLUGIN_ADMIN_ADDON_USER_MANAGER: + USER_MANAGER: Користувачі + GROUP_MANAGER: Групи користувачів + FILTER_ERROR: Помилка фільтрації + FILTER_USERS: Фільтр користувачів + FILTER_GROUPS: Фільтр груп + NO_RESULTS: Немає результатів + EXPERT: Експертний + PAGINATION_SUMMARY: Сторінка %d - %d з %d + BULK_ACTION: Групові дії + ACTIONS: Дії + CUSTOM_PERMISSIONS: Призначені для користувача дозволи + CUSTOM_PERMISSIONS_PLACEHOLDER: "Дозвіл (наприклад, 'site.login')" + USERS_PER_PAGE: Користувачі на сторінці + DEFAULT_LIST_STYLE: Стиль списку за замовчуванням + GRID: Сітка + LIST: Список + GROUP: Група + GROUPS: Групи + READABLE_NAME: Читаєме ім'я + ICON: Іконка + ADD_GROUP: Додати групу + USERS: Користувачі + HELP: Допомога + BULK_DELETE_USER: Видалити вибраних користувачів + BULK_DELETE: Видалити + BULK_USER_GROUP: Масові дії користувача, пов'язані з групами + BULK_ADD_GROUP: Додати обраних користувачів в обрані групи + BULK_REMOVE_GROUP: Видалити вибраних користувачів з обраних груп + BULK_DELETE_GROUP: Видалити вибрані групи + BULK_ADD_PERMISSIONS: Додати вибрані дозволи для обраних користувачів + BULK_REMOVE_PERMISSIONS: Видалити вибрані дозволи у вибраних користувачів + LOGIN_AS: Увійти як + USER_CONFIRM_DELETE: Ви впевнені, що хочете видалити цього користувача? diff --git a/user/plugins/admin-addon-user-manager/languages/zh.yaml b/user/plugins/admin-addon-user-manager/languages/zh.yaml new file mode 100644 index 0000000..38ee7f5 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/languages/zh.yaml @@ -0,0 +1,34 @@ +PLUGIN_ADMIN_ADDON_USER_MANAGER: + USER_MANAGER: 用户管理 + GROUP_MANAGER: 组管理 + FILTER_ERROR: 筛选错误 + FILTER_USERS: 筛选用户 + FILTER_GROUPS: 筛选组 + NO_RESULTS: 没有内容 + EXPERT: 专家 + PAGINATION_SUMMARY: 正在显示 %d - %d (共 %d) + BULK_ACTION: 批量操作 + ACTIONS: 操作 + CUSTOM_PERMISSIONS: 自定义权限 + CUSTOM_PERMISSIONS_PLACEHOLDER: "权限 (例如 'site.login')" + USERS_PER_PAGE: 每页用户数 + DEFAULT_LIST_STYLE: 默认列表风格 + GRID: 网格 + LIST: 列表 + GROUP: 组 + GROUPS: 组 + READABLE_NAME: 显示名称 + ICON: 图标 + ADD_GROUP: 添加组 + USERS: 用户 + HELP: 帮助 + BULK_DELETE_USER: 删除所选用户 + BULK_DELETE: 删除 + BULK_USER_GROUP: 批量用户与关联组操作 + BULK_ADD_GROUP: 添加所选用户到指定组 + BULK_REMOVE_GROUP: 从指定组中移除所选用户 + BULK_DELETE_GROUP: 删除所选组 + BULK_ADD_PERMISSIONS: 向所选用户添加指定权限 + BULK_REMOVE_PERMISSIONS: 从所选用户上移除指定权限 + LOGIN_AS: 模拟登录 + USER_CONFIRM_DELETE: 确认要删除该用户吗? diff --git a/user/plugins/admin-addon-user-manager/modals.yaml b/user/plugins/admin-addon-user-manager/modals.yaml new file mode 100644 index 0000000..141e756 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/modals.yaml @@ -0,0 +1,93 @@ +add_user: + fields: + - type: section + title: PLUGIN_ADMIN.ADD_ACCOUNT + + - type: text + label: PLUGIN_ADMIN.USERNAME + help: PLUGIN_ADMIN.USERNAME_HELP + name: username + placeholder: PLUGIN_ADMIN.USERNAME_PLACEHOLDER + validate: + required: true + message: PLUGIN_LOGIN.USERNAME_NOT_VALID + +add_group: + fields: + - type: section + title: PLUGIN_ADMIN_ADDON_USER_MANAGER.ADD_GROUP + + - type: text + label: PLUGIN_ADMIN.NAME + name: name + validate: + required: true + +bulk_user: + fields: + - type: section + title: PLUGIN_ADMIN_ADDON_USER_MANAGER.BULK_DELETE_USER + + - type: button + text: PLUGIN_ADMIN_ADDON_USER_MANAGER.BULK_DELETE + name: bulk_delete + style: vertical + classes: large warning + + - type: section + title: PLUGIN_ADMIN_ADDON_USER_MANAGER.BULK_USER_GROUP + + - type: select + name: groups + multiple: true + size: large + label: PLUGIN_ADMIN.GROUPS + classes: fancy + help: PLUGIN_ADMIN.GROUPS_HELP + validate: + type: commalist + + - type: button + text: PLUGIN_ADMIN_ADDON_USER_MANAGER.BULK_ADD_GROUP + name: bulk_add_to_group + style: vertical + classes: large + + - type: button + text: PLUGIN_ADMIN_ADDON_USER_MANAGER.BULK_REMOVE_GROUP + name: bulk_remove_from_group + style: vertical + classes: large warning + + - type: selectize + name: permissions + multiple: true + size: large + label: PLUGIN_ADMIN.PERMISSIONS + classes: fancy + help: PLUGIN_ADMIN.PERMISSIONS_HELP + validate: + type: commalist + + - type: button + text: PLUGIN_ADMIN_ADDON_USER_MANAGER.BULK_ADD_PERMISSIONS + name: bulk_add_acl + style: vertical + classes: large + + - type: button + text: PLUGIN_ADMIN_ADDON_USER_MANAGER.BULK_REMOVE_PERMISSIONS + name: bulk_remove_acl + style: vertical + classes: large warning + +bulk_group: + fields: + - type: section + title: PLUGIN_ADMIN_ADDON_USER_MANAGER.BULK_DELETE_GROUP + + - type: button + text: PLUGIN_ADMIN_ADDON_USER_MANAGER.BULK_DELETE + name: bulk_delete + style: vertical + classes: large warning \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/src/Dot.php b/user/plugins/admin-addon-user-manager/src/Dot.php new file mode 100644 index 0000000..30bd78a --- /dev/null +++ b/user/plugins/admin-addon-user-manager/src/Dot.php @@ -0,0 +1,108 @@ + 1) { + $key = array_shift($keys); + + if (!isset($arr[$key]) || !is_array($arr[$key])) { + $arr[$key] = []; + } + + $arr = &$arr[$key]; + } + + $arr[array_shift($keys)] = $value; + } else { + $arr[$key] = $value; + } + } + + /** + * Deletes a $key and its value from the $arr + * + * @param array &$arr + * @param string $key + */ + public static function delete(array &$arr, $key) + { + if (strpos($key, '.') !== false && ($keys = explode('.', $key)) && count($keys)) { + while (count($keys) > 1) { + $arr = &$arr[array_shift($keys)]; + } + + unset($arr[array_shift($keys)]); + } else { + unset($arr[$key]); + } + } +} \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/src/Group.php b/user/plugins/admin-addon-user-manager/src/Group.php new file mode 100644 index 0000000..b864f48 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/src/Group.php @@ -0,0 +1,164 @@ +get('groups', []); + + $blueprints = new Blueprints; + $blueprint = $blueprints->get('user/group'); + foreach ($groups as $groupname => &$content) { + if (!isset($content['groupname'])) { + $content['groupname'] = $groupname; + } + $content = new Group($content, $blueprint); + } + + return $groups; + } + + /** + * Get the groups list + * + * @return array + */ + public static function groupNames() { + $groups = []; + + foreach(Grav::instance()['config']->get('groups', []) as $groupname => $group) { + $groups[$groupname] = isset($group['readableName']) ? $group['readableName'] : $groupname; + } + + return $groups; + } + + /** + * Checks if a group exists + * + * @param string $groupname + * + * @return bool + */ + public static function groupExists($groupname) { + return isset(self::groups()[$groupname]); + } + + /** + * Get a group by name + * + * @param string $groupname + * + * @return object + */ + public static function load($groupname) { + if (self::groupExists($groupname)) { + $group = self::groups()[$groupname]; + } else { + $blueprints = new Blueprints; + $blueprint = $blueprints->get('user/group'); + $content = ['groupname' => $groupname]; + $group = new Group($content, $blueprint); + } + + return $group; + } + + /** + * Save a group + */ + public function save() { + $grav = Grav::instance(); + $config = $grav['config']; + + $blueprints = new Blueprints; + $blueprint = $blueprints->get('user/group'); + + $fields = $blueprint->fields(); + + $config->set("groups.$this->groupname", []); + + foreach ($fields as $field) { + if ($field['type'] == 'text') { + $value = $field['name']; + if (isset($this->items[$value])) { + $config->set("groups.$this->groupname.$value", $this->items[$value]); + } + } + + if ($field['type'] == 'array' || $field['type'] == 'permissions') { + $value = $field['name']; + $arrayValues = Utils::getDotNotation($this->items, $field['name']); + + if ($arrayValues) { + foreach ($arrayValues as $arrayIndex => $arrayValue) { + $config->set("groups.$this->groupname.$value.$arrayIndex", $arrayValue); + } + } + } + } + + $type = 'groups'; + $obj = new Data($config->get($type), $blueprint); + $file = CompiledYamlFile::instance($grav['locator']->findResource('config://') . DS . "{$type}.yaml"); + $obj->file($file); + $obj->save(); + } + + /** + * Remove a group + * + * @param string $groupname + * + * @return bool True if the action was performed + */ + public static function remove($groupname) { + $grav = Grav::instance(); + $config = $grav['config']; + $blueprints = new Blueprints; + $blueprint = $blueprints->get('user/group'); + + $groups = $config->get('groups', []); + if (!isset($groups[$groupname])) { + return false; + } + unset($groups[$groupname]); + $config->set('groups', $groups); + + $type = 'groups'; + $obj = new Data($config->get($type), $blueprint); + $file = CompiledYamlFile::instance($grav['locator']->findResource("config://{$type}.yaml")); + $obj->file($file); + $obj->save(); + + return true; + } + + public function authorize($access) { + if (empty($this->items)) { + return false; + } + + if (!isset($this->items['access'])) { + return false; + } + + $val = Utils::getDotNotation($this->items['access'], $access); + + return Utils::isPositive($val) === true; + } + +} \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/src/Groups/Manager.php b/user/plugins/admin-addon-user-manager/src/Groups/Manager.php new file mode 100644 index 0000000..219c869 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/src/Groups/Manager.php @@ -0,0 +1,242 @@ +grav = $grav; + $this->plugin = $plugin; + + $this->grav['events']->addSubscriber($this); + } + + public static function getSubscribedEvents() { + return [ + 'onAdminControllerInit' => ['onAdminControllerInit', 0], + 'onAdminData' => ['onAdminData', 0] + ]; + } + + public function onAdminControllerInit($e) { + $controller = $e['controller']; + $this->adminController = $controller; + } + + public function onAdminData($e) { + $type = $e['type']; + + if (preg_match('|group-manager|', $type) && ($group = $this->grav['uri']->param('name', false))) { + $obj = Group::load($group); + $post = $this->adminController->data; + if (isset($post['users'])) { + $usersInGroup = $post['users']; + unset($post['users']); + } else { + $usersInGroup = []; + } + $obj->merge($post); + $e['data_type'] = $obj; + + foreach (UsersManager::$instance->users() as $u) { + $groups = $u->get('groups', []); + if (in_array($u['username'], $usersInGroup)) { + if (!in_array($obj['groupname'], $groups)) { + $u['groups'] = array_merge($groups, [$obj['groupname']]); + $u->save(); + } + } else { + if (in_array($obj['groupname'], $groups)) { + $u['groups'] = array_diff($groups, [$obj['groupname']]); + if (empty($u['groups'])) { + unset($u['groups']); + } + $u->save(); + } + } + } + } + } + + /** + * Returns the required permission to access the manager + * + * @return string + */ + public function getRequiredPermission() { + return $this->plugin->name . '.groups'; + } + + /** + * Returns the location of the manager + * It will be accessible at this path + * + * @return string + */ + public function getLocation() { + return 'group-manager'; + } + + /** + * Returns the plugin hooked nav array + * + * @return array + */ + public function getNav() { + return [ + 'label' => 'PLUGIN_ADMIN_ADDON_USER_MANAGER.GROUP_MANAGER', + 'location' => $this->getLocation(), + 'icon' => 'fa-group', + 'authorize' => $this->getRequiredPermission(), + 'badge' => [ + 'count' => count($this->groups()) + ] + ]; + } + + /** + * Initialiaze required assets + * + * @param \Grav\Common\Assets $assets + * @return void + */ + public function initializeAssets(Assets $assets) { + $this->grav['assets']->addCss('plugin://' . $this->plugin->name . '/assets/groups/style.css'); + } + + /** + * Handle task requests + * + * @param \RocketTheme\Toolbox\Event\Event $event + * @return boolean + */ + public function handleTask(Event $event) { + $method = $event['method']; + + if ($method === 'taskGroupDelete' && ($group = $this->grav['uri']->param('name', false))) { + Group::remove($group); + $this->grav->redirect($this->grav['uri']->url($this->getLocation())); + return true; + } + + return false; + } + + /** + * Logic of the manager goes here + * + * @return array The array to be merged to Twig vars + */ + public function handleRequest() { + $vars = []; + + $twig = $this->grav['twig']; + $uri = $this->grav['uri']; + + // Bulk actions + if (isset($_POST['selected'])) { + $groupnames = $_POST['selected']; + + if (isset($_POST['bulk_delete'])) { + // Bulk delete groups + foreach ($groupnames as $groupname) { + Group::remove($groupname); + } + + $this->grav->redirect($this->plugin->getPreviousUrl()); + } + } + + $group = $this->grav['uri']->param('name', false); + + if ($group) { + $vars['exists'] = Group::groupExists($group); + $vars['group'] = $group = Group::load($group); + $users = []; + foreach (UsersManager::$instance->users() as $u) { + if (in_array($group['groupname'], $u->get('groups', []))) { + $users[] = $u->username; + } + } + $group['users'] = $users; + } else { + $vars['fields'] = $this->plugin->getModalsConfiguration()['add_group']['fields']; + $vars['bulkFields'] = $this->plugin->getModalsConfiguration()['bulk_group']['fields']; + + $groups = $this->groups(); + foreach ($groups as &$group) { + $group['users'] = 0; + + foreach (UsersManager::$instance->users() as $u) { + if (in_array($group['groupname'], $u->get('groups', []))) { + $group['users'] += 1; + } + } + } + + // Filtering + $filterException = false; + $filter = (empty($_GET['filter'])) ? '' : $_GET['filter']; + $vars['filter'] = $filter; + if ($filter) { + try { + $language = new ExpressionLanguage(); + $language->addFunction(ExpressionFunction::fromPhp('count')); + foreach ($groups as $k => $group) { + if (!$language->evaluate($_GET['filter'], ['group' => $group])) { + unset($groups[$k]); + } + } + } catch (\Exception $exception) { + $vars['filterException'] = $exception; + $filterException = true; + } + } + + if ($filterException) { + $groups = []; + } + + // Pagination + $perPage = $this->plugin->getPluginConfigValue('pagination.per_page', 10); + $pagination = new ArrayPagination($groups, $perPage); + $pagination->paginate($uri->param('page')); + + $vars['pagination'] = [ + 'current' => $pagination->getCurrentPage(), + 'count' => $pagination->getPagesCount(), + 'total' => $pagination->getRowsCount(), + 'perPage' => $pagination->getRowsPerPage(), + 'startOffset' => $pagination->getStartOffset(), + 'endOffset' => $pagination->getEndOffset() + ]; + $groups = $pagination->getPaginatedRows(); + + $vars['groups'] = $groups; + } + + return $vars; + } + + public function groups() { + return Group::groups(); + } + +} diff --git a/user/plugins/admin-addon-user-manager/src/Manager.php b/user/plugins/admin-addon-user-manager/src/Manager.php new file mode 100644 index 0000000..ea8f793 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/src/Manager.php @@ -0,0 +1,58 @@ +data = $data; + $this->rowsPerPage = $rowsPerPage; + $this->page = 1; + } + + public function paginate($page) { + if ($page > $this->getPagesCount()) { + $page = $page; + } + + if ($page < 1) { + $page = 1; + } + + $this->page = $page; + + $this->slicedData = null; + } + + public function getRowsPerPage() { + return $this->rowsPerPage; + } + + public function getRowsCount() { + if ($this->rowsCount !== null) { + return $this->rowsCount; + } + + return $this->rowsCount = count($this->data); + } + + public function getCurrentPage() { + return $this->page; + } + + public function getPagesCount() { + return ceil($this->getRowsCount() / $this->getRowsPerPage()); + } + + public function getStartOffset() { + return ($this->getCurrentPage() - 1) * $this->getRowsPerPage(); + } + + public function getEndOffset() { + $endOffset = $this->getStartOffset() + $this->getRowsPerPage(); + + if ($endOffset > $this->getRowsCount()) { + $endOffset = $this->getRowsCount(); + } + + return $endOffset; + } + + public function getPaginatedRowsCount() { + return $this->getEndOffset() - $this->getStartOffset(); + } + + public function getPaginatedRows() { + if ($this->slicedData !== null) { + return $this->slicedData; + } + + return $this->slicedData = array_slice($this->data, $this->getStartOffset(), $this->getRowsPerPage()); + } + +} \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/src/Pagination/Pagination.php b/user/plugins/admin-addon-user-manager/src/Pagination/Pagination.php new file mode 100644 index 0000000..0dffde3 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/src/Pagination/Pagination.php @@ -0,0 +1,17 @@ +grav = $grav; + $this->plugin = $plugin; + + self::$instance = $this; + } + + /** + * Returns the required permission to access the manager + * + * @return string + */ + public function getRequiredPermission() { + return $this->plugin->name . '.users_expert'; + } + + /** + * Returns the location of the manager + * It will be accessible at this path + * + * @return string + */ + public function getLocation() { + return 'user-expert'; + } + + /** + * Returns the plugin hooked nav array + * + * @return array + */ + public function getNav() { + return false; + } + + /** + * Initialiaze required assets + * + * @param \Grav\Common\Assets $assets + * @return void + */ + public function initializeAssets(Assets $assets) { + $assets->addCss('plugin://admin/themes/grav/css/codemirror/codemirror.css'); + } + + /** + * Handle task requests + * + * @param \RocketTheme\Toolbox\Event\Event $event + * @return boolean + */ + public function handleTask(Event $event) {} + + /** + * Logic of the manager goes here + * + * @return array The array to be merged to Twig vars + */ + public function handleRequest() { + $vars = []; + + $twig = $this->grav['twig']; + $uri = $this->grav['uri']; + + $username = $uri->paths()[2]; + $user = $this->grav['accounts']->load($username); + + if (isset($_POST['raw'])) { + $user->file()->raw($_POST['raw']); + $user->file()->save(); + } + + $vars['raw'] = $user->file()->raw(); + $vars['user'] = $user; + + $blueprints = new Blueprints; + $vars['blueprint'] = $blueprints->get('user/account-raw'); + + return $vars; + } + +} \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/src/Users/Manager.php b/user/plugins/admin-addon-user-manager/src/Users/Manager.php new file mode 100644 index 0000000..b5579c7 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/src/Users/Manager.php @@ -0,0 +1,432 @@ + + */ + private $usersCached = null; + + /** + * In-memory cache of the account directory + * + * @var String + */ + private $accountDirCached = null; + + public static $instance; + + public function __construct(Grav $grav, AdminAddonUserManagerPlugin $plugin) { + $this->grav = $grav; + $this->plugin = $plugin; + + self::$instance = $this; + + $this->grav['events']->addSubscriber($this); + } + + public static function getSubscribedEvents() { + return [ + 'onAdminControllerInit' => ['onAdminControllerInit', 0], + 'onAdminData' => ['onAdminData', 0] + ]; + } + + public function onAdminControllerInit($e) { + $controller = $e['controller']; + $this->adminController = $controller; + } + + public function onAdminData($e) { + $type = $e['type']; + + if (preg_match('|user-manager/|', $type)) { + $post = $this->adminController->data; + + $obj = $this->grav['accounts']->load(preg_replace('|user-manager/|', '', $type)); + $obj->merge($post); + + $e['data_type'] = $obj; + } + } + + /** + * Returns the required permission to access the manager + * + * @return string + */ + public function getRequiredPermission() { + return $this->plugin->name . '.users'; + } + + /** + * Returns the location of the manager + * It will be accessible at this path + * + * @return string + */ + public function getLocation() { + return 'user-manager'; + } + + /** + * Returns the plugin hooked nav array + * + * @return array + */ + public function getNav() { + return [ + 'label' => 'PLUGIN_ADMIN_ADDON_USER_MANAGER.USER_MANAGER', + 'location' => $this->getLocation(), + 'icon' => 'fa-user', + 'authorize' => $this->getRequiredPermission(), + 'badge' => [ + 'count' => count($this->users()) + ] + ]; + } + + /** + * Initialiaze required assets + * + * @param \Grav\Common\Assets $assets + * @return void + */ + public function initializeAssets(Assets $assets) { + $assets->addCss('plugin://' . $this->plugin->name . '/assets/users/style.css'); + } + + /** + * Handle task requests + * + * @param \RocketTheme\Toolbox\Event\Event $event + * @return boolean + */ + public function handleTask(Event $event) { + $method = $event['method']; + + if ($method === 'taskUserDelete') { + $username = $this->grav['uri']->paths()[2]; + if ($this->removeUser($username)) { + $this->adminController->setRedirect($this->getLocation()); + } + } elseif ($method === 'taskUserLoginAs') { + $username = $this->grav['uri']->paths()[2]; + $user = $this->grav['accounts']->load($username); + $user->authenticated = true; + + $this->grav['session']->user = $user; + unset($this->grav['user']); + $this->grav['user'] = $user; + + if ($user->authorize('admin.login')) { + $this->adminController->setRedirect('/'); + } else { + $this->grav->redirect('/'); + } + } + + return false; + } + + /** + * Logic of the manager goes here + * + * @return array The array to be merged to Twig vars + */ + public function handleRequest() { + $vars = []; + + $twig = $this->grav['twig']; + $uri = $this->grav['uri']; + + $user = $this->grav['uri']->paths(); + if (count($user) == 3) { + $user = $user[2]; + } else { + $user = false; + } + + if ($user) { + if (isset($_POST['task']) && $_POST['task'] === 'admin-addon-user-manager-save') { + $user = $this->grav['accounts']->load($user); + $post = $_POST['data']; + + try { + $user->merge($post); + $method = new \ReflectionMethod('\Grav\Plugin\Admin\AdminController', 'storeFiles'); + $method->setAccessible(true); + $user = $method->invoke($this->adminController, $user); + $user->validate(); + $user->filter(); + $user->save(); + } catch (\Exception $e) { + $this->grav['admin']->setMessage($e->getMessage(), 'error'); + } + + $this->grav->redirect($this->plugin->getPreviousUrl()); + } else { + $blueprints = new Blueprints; + $blueprint = $blueprints->get('user/aaum-account'); + $vars['blueprints'] = $blueprint; + $vars['user'] = $user = $this->grav['accounts']->load($user); + $vars['exists'] = $user->exists(); + } + } else { + // Bulk actions + if (isset($_POST['selected'])) { + $usernames = $_POST['selected']; + + if (isset($_POST['bulk_delete'])) { + // Bulk delete + foreach ($usernames as $username) { + $this->removeUser($username); + } + + $this->grav->redirect($this->plugin->getPreviousUrl()); + } else if (isset($_POST['bulk_add_to_group']) && isset($_POST['groups'])) { + // Bulk add users to groups + $groups = $_POST['groups']; + + foreach ($usernames as $username) { + $user = $this->grav['accounts']->load($username); + if ($user->file()->exists()) { + if (!isset($user['groups']) || !is_array($user['groups'])) { + $user['groups'] = []; + } + + $user['groups'] = array_unique(array_merge($user['groups'], $groups)); + $user->save(); + } + } + + $this->grav->redirect($this->plugin->getPreviousUrl()); + } else if (isset($_POST['bulk_remove_from_group']) && isset($_POST['groups'])) { + // Bulk remove users from groups + $groups = $_POST['groups']; + + foreach ($usernames as $username) { + $user = $this->grav['accounts']->load($username); + if ($user->file()->exists()) { + if (!isset($user['groups']) || !is_array($user['groups'])) { + $user['groups'] = []; + } + + $user['groups'] = array_unique(array_diff($user['groups'], $groups)); + $user->save(); + } + } + + $this->grav->redirect($this->plugin->getPreviousUrl()); + } else if (isset($_POST['bulk_add_acl']) && isset($_POST['permissions'])) { + // Bulk add permissions to users + $access = []; + foreach ($_POST['permissions'] as $p) { + Dot::set($access, $p, true); + } + + foreach ($usernames as $username) { + $user = $this->grav['accounts']->load($username); + if ($user->file()->exists()) { + if (!isset($user['access']) || !is_array($user['access'])) { + $user['access'] = []; + } + + $user['access'] = array_merge_recursive($user['access'], $access); + $user->save(); + } + } + + $this->grav->redirect($this->plugin->getPreviousUrl()); + } else if (isset($_POST['bulk_remove_acl']) && isset($_POST['permissions'])) { + // Bulk remove permissions from users + foreach ($usernames as $username) { + $user = $this->grav['accounts']->load($username); + if ($user->file()->exists()) { + if (!isset($user['access']) || !is_array($user['access'])) { + $user['access'] = []; + } + + $access = $user['access']; + foreach ($_POST['permissions'] as $p) { + Dot::delete($access, $p); + } + $user['access'] = $access; + $user->save(); + } + } + + $this->grav->redirect($this->plugin->getPreviousUrl()); + } + } + + $vars['fields'] = $this->plugin->getModalsConfiguration()['add_user']['fields']; + $vars['bulkFields'] = $this->plugin->getModalsConfiguration()['bulk_user']['fields']; + $vars['groupnames'] = Group::groupNames(); + $permissions = array_keys($this->grav['admin']->getPermissions()); + foreach ($permissions as $k=>&$v) $v = ['text' => $v, 'value' => $v]; + $vars['permissions'] = $permissions; + + // List style (grid or list) + $listStyle = $uri->param('listStyle'); + if ($listStyle !== 'grid' && $listStyle !== 'list') { + $listStyle = $this->plugin->getPluginConfigValue('default_list_style', 'grid'); + } + $vars['listStyle'] = $listStyle; + + $users = $this->users(); + + // Filtering + $filterException = false; + $filter = (empty($_GET['filter'])) ? '' : $_GET['filter']; + $vars['filter'] = $filter; + if ($filter) { + try { + $language = new ExpressionLanguage(); + $language->addFunction(ExpressionFunction::fromPhp('count')); + foreach ($users as $k => $user) { + if (!is_array($user->groups)) { + $user->groups = []; + } + + if (!$language->evaluate($_GET['filter'], ['user' => $user])) { + unset($users[$k]); + } + } + } catch (\Exception $exception) { + $vars['filterException'] = $exception; + $filterException = true; + } + } + + if ($filterException) { + $users = []; + } + + // Pagination + $perPage = $this->plugin->getPluginConfigValue('pagination.per_page', 10); + $pagination = new ArrayPagination($users, $perPage); + $pagination->paginate($uri->param('page')); + + $vars['pagination'] = [ + 'current' => $pagination->getCurrentPage(), + 'count' => $pagination->getPagesCount(), + 'total' => $pagination->getRowsCount(), + 'perPage' => $pagination->getRowsPerPage(), + 'startOffset' => $pagination->getStartOffset(), + 'endOffset' => $pagination->getEndOffset() + ]; + $vars['users'] = $pagination->getPaginatedRows(); + $vars['user'] = false; + } + + return $vars; + } + + public function users() { + if ($this->usersCached) { + return $this->usersCached; + } + + $users = []; + $dir = $this->getAccountDir(); + + // Try cache + $cache = $this->grav['cache']; + $cacheKey = $this->plugin->name . '.users'; + + $modifyTime = filemtime($dir); + $usersCache = $cache->fetch($cacheKey); + if (!$usersCache || $modifyTime > $usersCache['modifyTime']) { + // Find accounts + $files = $dir ? array_diff(scandir($dir), ['.', '..']) : []; + foreach ($files as $file) { + if (Utils::endsWith($file, YAML_EXT)) { + $user = $this->grav['accounts']->load(trim(pathinfo($file, PATHINFO_FILENAME))); + $users[$user->username] = $user; + } + } + + // Populate and/or refresh cache + $this->saveUsersToCache($users); + } else { + $users = $usersCache['users']; + } + + $this->usersCached = $users; + + return $users; + } + + private function saveUsersToCache($users) { + $cache = $this->grav['cache']; + $cacheKey = $this->plugin->name . '.users'; + $dir = $this->getAccountDir(); + $modifyTime = filemtime($dir); + + $usersCache = [ + 'modifyTime' => $modifyTime, + 'users' => $users, + ]; + + $cache->save($cacheKey, $usersCache); + } + + private function getAccountDir() { + if ($this->accountDirCached) { + return $this->grav['locator']->findResource('account://'); + } + + return $this->accountDirCached = $this->grav['locator']->findResource('account://'); + } + + public function removeUser($username) { + $user = $this->grav['accounts']->load($username); + + if ($user->file()->exists()) { + $users = $this->users(); + $user->file()->delete(); + // Prevent users cache refresh + unset($users[$username]); + $this->saveUsersToCache($users); + return true; + } + + return false; + } + + public static function userNames() { + $instance = self::$instance; + $users = $instance->users(); + + $userNames = []; + foreach ($users as $u) { + $userNames[$u['username']] = $u['username']; + } + + return $userNames; + } + +} diff --git a/user/plugins/admin-addon-user-manager/templates/forms/fields/button/button.html.twig b/user/plugins/admin-addon-user-manager/templates/forms/fields/button/button.html.twig new file mode 100644 index 0000000..6b44ece --- /dev/null +++ b/user/plugins/admin-addon-user-manager/templates/forms/fields/button/button.html.twig @@ -0,0 +1,25 @@ +{% extends "forms/field.html.twig" %} + +{% block input %} +
    + +
    +{% endblock %} \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/templates/group-manager.html.twig b/user/plugins/admin-addon-user-manager/templates/group-manager.html.twig new file mode 100644 index 0000000..c65fd01 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/templates/group-manager.html.twig @@ -0,0 +1,170 @@ +{% extends 'partials/base.html.twig' %} +{% import 'user-manager-macros.html.twig' as macros %} + +{% set title = "PLUGIN_ADMIN_ADDON_USER_MANAGER.GROUP_MANAGER"|tu %} + +{% if group %} +{% set group_name = group.readableName ?: group.groupname %} +{% set title = "PLUGIN_ADMIN_ADDON_USER_MANAGER.GROUP"|tu ~ ": " ~ group_name|e %} +{% endif %} + +{% set ps = config.system.param_sep %} + +{% block titlebar %} + {% if not group %} + + +

    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.GROUP_MANAGER"|tu }}

    + {% else %} +
    + {{ "PLUGIN_ADMIN.BACK"|tu }} + {% if exists %} + {{ "PLUGIN_ADMIN.DELETE"|tu }} + {% endif %} + +
    +

    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.GROUP"|tu }}: {{ group.groupname|e }}

    + {% endif %} +{% endblock %} + +{% set appendUrl = '?filter=' ~ filter|url_encode %} + +{% block content %} + {% if not group %} +

    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.GROUPS"|tu }}

    + + {% if filterException %} +

    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.FILTER_ERROR"|tu }}

    {{ filterException.message }}

    + {% endif %} + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + +
    + +
    +
    + {% if groups is empty %} +

    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.NO_RESULTS"|tu }}

    + {% else %} +
    +
    +
    +
    {{ "PLUGIN_ADMIN.NAME"|tu }}
    +
    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.ACTIONS"|tu }}
    +
    +
    + + {% for groupName, group in groups %} + + {% endfor %} + {% endif %} +
    + + +
    + + {{ macros.pagination(pagination, uri.route(true), ps, appendUrl) }} + +
    +
    + {% for field in fields %} + {% if field.type %} + {% set value = data.value(field.name) %} +
    + {% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} +
    + {% endif %} + {% endfor %} + +
    + +
    +
    +
    + +
    +
    + {% for field in bulkFields %} + {% if field.type %} +
    + {% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} +
    + {% endif %} + {% endfor %} + +
    + +
    +
    +
    + + + {% else %} +

    {{ group.readableName ?: group.groupname }}

    + + {% include 'partials/blueprints.html.twig' with { data: group, blueprints: group.blueprints } %} + +
    +
    +

    {{ "PLUGIN_ADMIN.MODAL_CHANGED_DETECTED_TITLE"|tu }}

    +

    + {{ "PLUGIN_ADMIN.MODAL_CHANGED_DETECTED_DESC"|tu }} +

    +
    + +
    +
    + {% endif %} +{% endblock %} diff --git a/user/plugins/admin-addon-user-manager/templates/user-expert.html.twig b/user/plugins/admin-addon-user-manager/templates/user-expert.html.twig new file mode 100644 index 0000000..0427b12 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/templates/user-expert.html.twig @@ -0,0 +1,28 @@ +{% extends 'partials/base.html.twig' %} + +{% set title = "PLUGIN_ADMIN.USER"|tu ~ ": " ~ admin.route|e %} + +{% block titlebar %} +
    + {{ "PLUGIN_ADMIN.BACK"|tu }} + +
    + +

    {{ "PLUGIN_ADMIN.USER"|tu }}: {{ user.username|e }}

    +{% endblock %} + +{% block content %} +

    {{ title }}

    + +
    + {% for field in blueprint.fields %} + {% if field.type %} + {% set value = attribute(_context, field.name) %} +
    + {% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} +
    + {% endif %} + {% endfor %} + {{ nonce_field('form', 'form-nonce')|raw }} +
    +{% endblock %} diff --git a/user/plugins/admin-addon-user-manager/templates/user-manager-macros.html.twig b/user/plugins/admin-addon-user-manager/templates/user-manager-macros.html.twig new file mode 100644 index 0000000..27959a3 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/templates/user-manager-macros.html.twig @@ -0,0 +1,31 @@ +{% macro pagination(pagination, url, ps, appendUrl) %} + {% if pagination.count > 1 %} +
    +
      + {% if pagination.current > 1 %} +
    • <<
    • + {% endif %} + {% if pagination.current > 2 %} +
    • <
    • + {% endif %} + {% set fromPage = (pagination.current - 2 < 1) ? 1 : pagination.current - 2 %} + {% set toPage = (pagination.current + 2 > pagination.count) ? pagination.count : pagination.current + 2 %} + {% for i in fromPage..toPage %} + {% if pagination.current == i %} +
    • {{ i }}
    • + {% else %} +
    • {{ i }}
    • + {% endif %} + {% endfor %} + {% if pagination.current < pagination.count - 1 %} +
    • >
    • + {% endif %} + {% if pagination.current < pagination.count %} +
    • >>
    • + {% endif %} +
    + +
    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.PAGINATION_SUMMARY"|t(pagination.startOffset + 1, pagination.endOffset, pagination.total) }}
    +
    + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/templates/user-manager.html.twig b/user/plugins/admin-addon-user-manager/templates/user-manager.html.twig new file mode 100644 index 0000000..3e60d79 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/templates/user-manager.html.twig @@ -0,0 +1,199 @@ +{% extends 'partials/base.html.twig' %} +{% import 'user-manager-macros.html.twig' as macros %} + +{% set title = "PLUGIN_ADMIN_ADDON_USER_MANAGER.USER_MANAGER"|tu %} + +{% block titlebar %} + {% if not user %} + + +

    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.USER_MANAGER"|tu }}

    + {% else %} + +

    {{ "PLUGIN_ADMIN.USER"|tu }}: {{ user.username|e }}

    + {% endif %} +{% endblock %} + +{% set ps = config.system.param_sep %} +{% set appendUrl = '?filter=' ~ filter|url_encode %} + +{% block content %} + {% if not user %} + {% set style = listStyle|default('grid') %} + +

    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.USERS"|tu }}

    + + {% if filterException %} +

    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.FILTER_ERROR"|tu }}

    {{ filterException.message }}

    + {% endif %} + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + +
    + +
    + {% if style != 'grid' %}{% else %}{% endif %} + {% if style != 'list' %}{% else %}{% endif %} +
    + +
    +
    + {% if users is empty %} +

    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.NO_RESULTS"|tu }}

    + {% else %} + {% if style == 'list' %} +
    +
    +
    +
    {{ "PLUGIN_ADMIN.USERNAME"|tu }}
    + +
    {{ "PLUGIN_ADMIN_ADDON_USER_MANAGER.ACTIONS"|tu }}
    +
    +
    + {% endif %} + {% for user in users %} +
    +
    + {% if style == 'grid' %} +
    + {% else %} +
    + {% endif %} + + + +
    +
    + {% endfor %} + {% endif %} +
    + + +
    + + {{ macros.pagination(pagination, uri.route(true) ~ '/listStyle' ~ ps ~ listStyle, ps, appendUrl) }} + +
    +
    + {% for field in fields %} + {% if field.type %} + {% if field.name == 'username' %} + {% set field = field|merge({ validate: field.validate|merge({ pattern: grav.config.system.username_regex })}) %} + {% endif %} +
    + {% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} +
    + {% endif %} + {% endfor %} + +
    + +
    +
    +
    + +
    +
    + {% for field in bulkFields %} + {% if field.type %} + {% if field.name == 'groups' %} + {% set field = field|merge({options: groupnames}) %} + {% endif %} + {% if field.name == 'permissions' %} + {% set field = field|merge({selectize: { options: permissions }}) %} + {% endif %} +
    + {% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} +
    + {% endif %} + {% endfor %} + +
    + +
    +
    +
    + + + {% else %} +

    {{ user.username }}

    + + {% include 'partials/blueprints.html.twig' with { data: user, blueprints: blueprints } %} + +
    +
    +

    {{ "PLUGIN_ADMIN.MODAL_CHANGED_DETECTED_TITLE"|tu }}

    +

    + {{ "PLUGIN_ADMIN.MODAL_CHANGED_DETECTED_DESC"|tu }} +

    +
    + +
    +
    + + + + + + {% endif %} +{% endblock %} diff --git a/user/plugins/admin-addon-user-manager/vendor/autoload.php b/user/plugins/admin-addon-user-manager/vendor/autoload.php new file mode 100644 index 0000000..e967d8f --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/autoload.php @@ -0,0 +1,7 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + * @see http://www.php-fig.org/psr/psr-0/ + * @see http://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + // PSR-4 + private $prefixLengthsPsr4 = array(); + private $prefixDirsPsr4 = array(); + private $fallbackDirsPsr4 = array(); + + // PSR-0 + private $prefixesPsr0 = array(); + private $fallbackDirsPsr0 = array(); + + private $useIncludePath = false; + private $classMap = array(); + private $classMapAuthoritative = false; + private $missingClasses = array(); + private $apcuPrefix; + + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', $this->prefixesPsr0); + } + + return array(); + } + + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param array|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + */ + public function add($prefix, $paths, $prepend = false) + { + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + (array) $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + (array) $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = (array) $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + (array) $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + (array) $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param array|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + (array) $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + (array) $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + (array) $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + (array) $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param array|string $paths The PSR-0 base directories + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param array|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + } + + /** + * Unregisters this instance as an autoloader. + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return bool|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + includeFile($file); + + return true; + } + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } +} + +/** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + */ +function includeFile($file) +{ + include $file; +} diff --git a/user/plugins/admin-addon-user-manager/vendor/composer/LICENSE b/user/plugins/admin-addon-user-manager/vendor/composer/LICENSE new file mode 100644 index 0000000..f27399a --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/user/plugins/admin-addon-user-manager/vendor/composer/autoload_classmap.php b/user/plugins/admin-addon-user-manager/vendor/composer/autoload_classmap.php new file mode 100644 index 0000000..3a14165 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/composer/autoload_classmap.php @@ -0,0 +1,16 @@ + $vendorDir . '/symfony/polyfill-php70/Resources/stubs/ArithmeticError.php', + 'AssertionError' => $vendorDir . '/symfony/polyfill-php70/Resources/stubs/AssertionError.php', + 'DivisionByZeroError' => $vendorDir . '/symfony/polyfill-php70/Resources/stubs/DivisionByZeroError.php', + 'Error' => $vendorDir . '/symfony/polyfill-php70/Resources/stubs/Error.php', + 'ParseError' => $vendorDir . '/symfony/polyfill-php70/Resources/stubs/ParseError.php', + 'SessionUpdateTimestampHandlerInterface' => $vendorDir . '/symfony/polyfill-php70/Resources/stubs/SessionUpdateTimestampHandlerInterface.php', + 'TypeError' => $vendorDir . '/symfony/polyfill-php70/Resources/stubs/TypeError.php', +); diff --git a/user/plugins/admin-addon-user-manager/vendor/composer/autoload_files.php b/user/plugins/admin-addon-user-manager/vendor/composer/autoload_files.php new file mode 100644 index 0000000..268e870 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/composer/autoload_files.php @@ -0,0 +1,10 @@ + $vendorDir . '/symfony/polyfill-php70/bootstrap.php', +); diff --git a/user/plugins/admin-addon-user-manager/vendor/composer/autoload_namespaces.php b/user/plugins/admin-addon-user-manager/vendor/composer/autoload_namespaces.php new file mode 100644 index 0000000..b7fc012 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/composer/autoload_namespaces.php @@ -0,0 +1,9 @@ + array($vendorDir . '/symfony/polyfill-php70'), + 'Symfony\\Contracts\\Service\\' => array($vendorDir . '/symfony/service-contracts'), + 'Symfony\\Contracts\\Cache\\' => array($vendorDir . '/symfony/cache-contracts'), + 'Symfony\\Component\\VarExporter\\' => array($vendorDir . '/symfony/var-exporter'), + 'Symfony\\Component\\ExpressionLanguage\\' => array($vendorDir . '/symfony/expression-language'), + 'Symfony\\Component\\Cache\\' => array($vendorDir . '/symfony/cache'), + 'Psr\\Log\\' => array($vendorDir . '/psr/log/Psr/Log'), + 'Psr\\Container\\' => array($vendorDir . '/psr/container/src'), + 'Psr\\Cache\\' => array($vendorDir . '/psr/cache/src'), + 'AdminAddonUserManager\\' => array($baseDir . '/src'), +); diff --git a/user/plugins/admin-addon-user-manager/vendor/composer/autoload_real.php b/user/plugins/admin-addon-user-manager/vendor/composer/autoload_real.php new file mode 100644 index 0000000..7f50636 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/composer/autoload_real.php @@ -0,0 +1,70 @@ += 50600 && !defined('HHVM_VERSION') && (!function_exists('zend_loader_file_encoded') || !zend_loader_file_encoded()); + if ($useStaticLoader) { + require_once __DIR__ . '/autoload_static.php'; + + call_user_func(\Composer\Autoload\ComposerStaticInit089ad2d043aed688879945cd4bcae11c::getInitializer($loader)); + } else { + $map = require __DIR__ . '/autoload_namespaces.php'; + foreach ($map as $namespace => $path) { + $loader->set($namespace, $path); + } + + $map = require __DIR__ . '/autoload_psr4.php'; + foreach ($map as $namespace => $path) { + $loader->setPsr4($namespace, $path); + } + + $classMap = require __DIR__ . '/autoload_classmap.php'; + if ($classMap) { + $loader->addClassMap($classMap); + } + } + + $loader->register(true); + + if ($useStaticLoader) { + $includeFiles = Composer\Autoload\ComposerStaticInit089ad2d043aed688879945cd4bcae11c::$files; + } else { + $includeFiles = require __DIR__ . '/autoload_files.php'; + } + foreach ($includeFiles as $fileIdentifier => $file) { + composerRequire089ad2d043aed688879945cd4bcae11c($fileIdentifier, $file); + } + + return $loader; + } +} + +function composerRequire089ad2d043aed688879945cd4bcae11c($fileIdentifier, $file) +{ + if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) { + require $file; + + $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/composer/autoload_static.php b/user/plugins/admin-addon-user-manager/vendor/composer/autoload_static.php new file mode 100644 index 0000000..ace6766 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/composer/autoload_static.php @@ -0,0 +1,97 @@ + __DIR__ . '/..' . '/symfony/polyfill-php70/bootstrap.php', + ); + + public static $prefixLengthsPsr4 = array ( + 'S' => + array ( + 'Symfony\\Polyfill\\Php70\\' => 23, + 'Symfony\\Contracts\\Service\\' => 26, + 'Symfony\\Contracts\\Cache\\' => 24, + 'Symfony\\Component\\VarExporter\\' => 30, + 'Symfony\\Component\\ExpressionLanguage\\' => 37, + 'Symfony\\Component\\Cache\\' => 24, + ), + 'P' => + array ( + 'Psr\\Log\\' => 8, + 'Psr\\Container\\' => 14, + 'Psr\\Cache\\' => 10, + ), + 'A' => + array ( + 'AdminAddonUserManager\\' => 22, + ), + ); + + public static $prefixDirsPsr4 = array ( + 'Symfony\\Polyfill\\Php70\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/polyfill-php70', + ), + 'Symfony\\Contracts\\Service\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/service-contracts', + ), + 'Symfony\\Contracts\\Cache\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/cache-contracts', + ), + 'Symfony\\Component\\VarExporter\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/var-exporter', + ), + 'Symfony\\Component\\ExpressionLanguage\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/expression-language', + ), + 'Symfony\\Component\\Cache\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/cache', + ), + 'Psr\\Log\\' => + array ( + 0 => __DIR__ . '/..' . '/psr/log/Psr/Log', + ), + 'Psr\\Container\\' => + array ( + 0 => __DIR__ . '/..' . '/psr/container/src', + ), + 'Psr\\Cache\\' => + array ( + 0 => __DIR__ . '/..' . '/psr/cache/src', + ), + 'AdminAddonUserManager\\' => + array ( + 0 => __DIR__ . '/../..' . '/src', + ), + ); + + public static $classMap = array ( + 'ArithmeticError' => __DIR__ . '/..' . '/symfony/polyfill-php70/Resources/stubs/ArithmeticError.php', + 'AssertionError' => __DIR__ . '/..' . '/symfony/polyfill-php70/Resources/stubs/AssertionError.php', + 'DivisionByZeroError' => __DIR__ . '/..' . '/symfony/polyfill-php70/Resources/stubs/DivisionByZeroError.php', + 'Error' => __DIR__ . '/..' . '/symfony/polyfill-php70/Resources/stubs/Error.php', + 'ParseError' => __DIR__ . '/..' . '/symfony/polyfill-php70/Resources/stubs/ParseError.php', + 'SessionUpdateTimestampHandlerInterface' => __DIR__ . '/..' . '/symfony/polyfill-php70/Resources/stubs/SessionUpdateTimestampHandlerInterface.php', + 'TypeError' => __DIR__ . '/..' . '/symfony/polyfill-php70/Resources/stubs/TypeError.php', + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixLengthsPsr4 = ComposerStaticInit089ad2d043aed688879945cd4bcae11c::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInit089ad2d043aed688879945cd4bcae11c::$prefixDirsPsr4; + $loader->classMap = ComposerStaticInit089ad2d043aed688879945cd4bcae11c::$classMap; + + }, null, ClassLoader::class); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/composer/installed.json b/user/plugins/admin-addon-user-manager/vendor/composer/installed.json new file mode 100644 index 0000000..402ec27 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/composer/installed.json @@ -0,0 +1,574 @@ +[ + { + "name": "paragonie/random_compat", + "version": "v9.99.99", + "version_normalized": "9.99.99.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", + "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", + "shasum": "" + }, + "require": { + "php": "^7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "time": "2018-07-02T15:55:56+00:00", + "type": "library", + "installation-source": "dist", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ] + }, + { + "name": "psr/cache", + "version": "1.0.1", + "version_normalized": "1.0.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "time": "2016-08-06T20:24:11+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ] + }, + { + "name": "psr/container", + "version": "1.0.0", + "version_normalized": "1.0.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "time": "2017-02-14T16:28:37+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ] + }, + { + "name": "psr/log", + "version": "1.1.3", + "version_normalized": "1.1.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "time": "2020-03-23T09:12:05+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ] + }, + { + "name": "symfony/cache", + "version": "v4.4.7", + "version_normalized": "4.4.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "f777b570291aebe51081b9827e05f3a747665e87" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/f777b570291aebe51081b9827e05f3a747665e87", + "reference": "f777b570291aebe51081b9827e05f3a747665e87", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/cache": "~1.0", + "psr/log": "~1.0", + "symfony/cache-contracts": "^1.1.7|^2", + "symfony/service-contracts": "^1.1|^2", + "symfony/var-exporter": "^4.2|^5.0" + }, + "conflict": { + "doctrine/dbal": "<2.5", + "symfony/dependency-injection": "<3.4", + "symfony/http-kernel": "<4.4", + "symfony/var-dumper": "<4.4" + }, + "provide": { + "psr/cache-implementation": "1.0", + "psr/simple-cache-implementation": "1.0", + "symfony/cache-implementation": "1.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/cache": "~1.6", + "doctrine/dbal": "~2.5", + "predis/predis": "~1.1", + "psr/simple-cache": "^1.0", + "symfony/config": "^4.2|^5.0", + "symfony/dependency-injection": "^3.4|^4.1|^5.0", + "symfony/var-dumper": "^4.4|^5.0" + }, + "time": "2020-03-27T16:54:36+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Cache component with PSR-6, PSR-16, and tags", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ] + }, + { + "name": "symfony/cache-contracts", + "version": "v2.0.1", + "version_normalized": "2.0.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "23ed8bfc1a4115feca942cb5f1aacdf3dcdf3c16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/23ed8bfc1a4115feca942cb5f1aacdf3dcdf3c16", + "reference": "23ed8bfc1a4115feca942cb5f1aacdf3dcdf3c16", + "shasum": "" + }, + "require": { + "php": "^7.2.5", + "psr/cache": "^1.0" + }, + "suggest": { + "symfony/cache-implementation": "" + }, + "time": "2019-11-18T17:27:11+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ] + }, + { + "name": "symfony/expression-language", + "version": "v3.4.39", + "version_normalized": "3.4.39.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/expression-language.git", + "reference": "206165f46c660f3231df0afbdeec6a62f81afc59" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/206165f46c660f3231df0afbdeec6a62f81afc59", + "reference": "206165f46c660f3231df0afbdeec6a62f81afc59", + "shasum": "" + }, + "require": { + "php": "^5.5.9|>=7.0.8", + "symfony/cache": "~3.1|~4.0", + "symfony/polyfill-php70": "~1.6" + }, + "time": "2020-03-16T08:31:04+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.4-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Component\\ExpressionLanguage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony ExpressionLanguage Component", + "homepage": "https://symfony.com" + }, + { + "name": "symfony/polyfill-php70", + "version": "v1.15.0", + "version_normalized": "1.15.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php70.git", + "reference": "2a18e37a489803559284416df58c71ccebe50bf0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/2a18e37a489803559284416df58c71ccebe50bf0", + "reference": "2a18e37a489803559284416df58c71ccebe50bf0", + "shasum": "" + }, + "require": { + "paragonie/random_compat": "~1.0|~2.0|~9.99", + "php": ">=5.3.3" + }, + "time": "2020-02-27T09:26:54+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.15-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php70\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ] + }, + { + "name": "symfony/service-contracts", + "version": "v2.0.1", + "version_normalized": "2.0.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "144c5e51266b281231e947b51223ba14acf1a749" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/144c5e51266b281231e947b51223ba14acf1a749", + "reference": "144c5e51266b281231e947b51223ba14acf1a749", + "shasum": "" + }, + "require": { + "php": "^7.2.5", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "time": "2019-11-18T17:27:11+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ] + }, + { + "name": "symfony/var-exporter", + "version": "v5.0.7", + "version_normalized": "5.0.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "ffd29a70370e466343e33154b5df197a07a13afa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/ffd29a70370e466343e33154b5df197a07a13afa", + "reference": "ffd29a70370e466343e33154b5df197a07a13afa", + "shasum": "" + }, + "require": { + "php": "^7.2.5" + }, + "require-dev": { + "symfony/var-dumper": "^4.4|^5.0" + }, + "time": "2020-03-27T16:56:45+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A blend of var_export() + serialize() to turn any serializable data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "serialize" + ] + } +] diff --git a/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/LICENSE b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/LICENSE new file mode 100644 index 0000000..45c7017 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Paragon Initiative Enterprises + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/build-phar.sh b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/build-phar.sh new file mode 100644 index 0000000..b4a5ba3 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/build-phar.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +basedir=$( dirname $( readlink -f ${BASH_SOURCE[0]} ) ) + +php -dphar.readonly=0 "$basedir/other/build_phar.php" $* \ No newline at end of file diff --git a/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/composer.json b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/composer.json new file mode 100644 index 0000000..1fa8de9 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/composer.json @@ -0,0 +1,34 @@ +{ + "name": "paragonie/random_compat", + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "random", + "polyfill", + "pseudorandom" + ], + "license": "MIT", + "type": "library", + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "support": { + "issues": "https://github.com/paragonie/random_compat/issues", + "email": "info@paragonie.com", + "source": "https://github.com/paragonie/random_compat" + }, + "require": { + "php": "^7" + }, + "require-dev": { + "vimeo/psalm": "^1", + "phpunit/phpunit": "4.*|5.*" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/dist/random_compat.phar.pubkey b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/dist/random_compat.phar.pubkey new file mode 100644 index 0000000..eb50ebf --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/dist/random_compat.phar.pubkey @@ -0,0 +1,5 @@ +-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEEd+wCqJDrx5B4OldM0dQE0ZMX+lx1ZWm +pui0SUqD4G29L3NGsz9UhJ/0HjBdbnkhIK5xviT0X5vtjacF6ajgcCArbTB+ds+p ++h7Q084NuSuIpNb6YPfoUFgC/CL9kAoc +-----END PUBLIC KEY----- diff --git a/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/dist/random_compat.phar.pubkey.asc b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/dist/random_compat.phar.pubkey.asc new file mode 100644 index 0000000..6a1d7f3 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/dist/random_compat.phar.pubkey.asc @@ -0,0 +1,11 @@ +-----BEGIN PGP SIGNATURE----- +Version: GnuPG v2.0.22 (MingW32) + +iQEcBAABAgAGBQJWtW1hAAoJEGuXocKCZATaJf0H+wbZGgskK1dcRTsuVJl9IWip +QwGw/qIKI280SD6/ckoUMxKDCJiFuPR14zmqnS36k7N5UNPnpdTJTS8T11jttSpg +1LCmgpbEIpgaTah+cELDqFCav99fS+bEiAL5lWDAHBTE/XPjGVCqeehyPYref4IW +NDBIEsvnHPHPLsn6X5jq4+Yj5oUixgxaMPiR+bcO4Sh+RzOVB6i2D0upWfRXBFXA +NNnsg9/zjvoC7ZW73y9uSH+dPJTt/Vgfeiv52/v41XliyzbUyLalf02GNPY+9goV +JHG1ulEEBJOCiUD9cE1PUIJwHA/HqyhHIvV350YoEFiHl8iSwm7SiZu5kPjaq74= +=B6+8 +-----END PGP SIGNATURE----- diff --git a/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/lib/random.php b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/lib/random.php new file mode 100644 index 0000000..c7731a5 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/lib/random.php @@ -0,0 +1,32 @@ +buildFromDirectory(dirname(__DIR__).'/lib'); +rename( + dirname(__DIR__).'/lib/index.php', + dirname(__DIR__).'/lib/random.php' +); + +/** + * If we pass an (optional) path to a private key as a second argument, we will + * sign the Phar with OpenSSL. + * + * If you leave this out, it will produce an unsigned .phar! + */ +if ($argc > 1) { + if (!@is_readable($argv[1])) { + echo 'Could not read the private key file:', $argv[1], "\n"; + exit(255); + } + $pkeyFile = file_get_contents($argv[1]); + + $private = openssl_get_privatekey($pkeyFile); + if ($private !== false) { + $pkey = ''; + openssl_pkey_export($private, $pkey); + $phar->setSignatureAlgorithm(Phar::OPENSSL, $pkey); + + /** + * Save the corresponding public key to the file + */ + if (!@is_readable($dist.'/random_compat.phar.pubkey')) { + $details = openssl_pkey_get_details($private); + file_put_contents( + $dist.'/random_compat.phar.pubkey', + $details['key'] + ); + } + } else { + echo 'An error occurred reading the private key from OpenSSL.', "\n"; + exit(255); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/psalm-autoload.php b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/psalm-autoload.php new file mode 100644 index 0000000..d71d1b8 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/paragonie/random_compat/psalm-autoload.php @@ -0,0 +1,9 @@ + + + + + + + + + + + + + + + diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/cache/CHANGELOG.md b/user/plugins/admin-addon-user-manager/vendor/psr/cache/CHANGELOG.md new file mode 100644 index 0000000..58ddab0 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/cache/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file, in reverse chronological order by release. + +## 1.0.1 - 2016-08-06 + +### Fixed + +- Make spacing consistent in phpdoc annotations php-fig/cache#9 - chalasr +- Fix grammar in phpdoc annotations php-fig/cache#10 - chalasr +- Be more specific in docblocks that `getItems()` and `deleteItems()` take an array of strings (`string[]`) compared to just `array` php-fig/cache#8 - GrahamCampbell +- For `expiresAt()` and `expiresAfter()` in CacheItemInterface fix docblock to specify null as a valid parameters as well as an implementation of DateTimeInterface php-fig/cache#7 - GrahamCampbell + +## 1.0.0 - 2015-12-11 + +Initial stable release; reflects accepted PSR-6 specification diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/cache/LICENSE.txt b/user/plugins/admin-addon-user-manager/vendor/psr/cache/LICENSE.txt new file mode 100644 index 0000000..b1c2c97 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/cache/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2015 PHP Framework Interoperability Group + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/cache/README.md b/user/plugins/admin-addon-user-manager/vendor/psr/cache/README.md new file mode 100644 index 0000000..c8706ce --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/cache/README.md @@ -0,0 +1,9 @@ +PSR Cache +========= + +This repository holds all interfaces defined by +[PSR-6](http://www.php-fig.org/psr/psr-6/). + +Note that this is not a Cache implementation of its own. It is merely an +interface that describes a Cache implementation. See the specification for more +details. diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/cache/composer.json b/user/plugins/admin-addon-user-manager/vendor/psr/cache/composer.json new file mode 100644 index 0000000..e828fec --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/cache/composer.json @@ -0,0 +1,25 @@ +{ + "name": "psr/cache", + "description": "Common interface for caching libraries", + "keywords": ["psr", "psr-6", "cache"], + "license": "MIT", + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "require": { + "php": ">=5.3.0" + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/cache/src/CacheException.php b/user/plugins/admin-addon-user-manager/vendor/psr/cache/src/CacheException.php new file mode 100644 index 0000000..e27f22f --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/cache/src/CacheException.php @@ -0,0 +1,10 @@ +=5.3.0" + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/container/src/ContainerExceptionInterface.php b/user/plugins/admin-addon-user-manager/vendor/psr/container/src/ContainerExceptionInterface.php new file mode 100644 index 0000000..d35c6b4 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/container/src/ContainerExceptionInterface.php @@ -0,0 +1,13 @@ +log(LogLevel::EMERGENCY, $message, $context); + } + + /** + * Action must be taken immediately. + * + * Example: Entire website down, database unavailable, etc. This should + * trigger the SMS alerts and wake you up. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function alert($message, array $context = array()) + { + $this->log(LogLevel::ALERT, $message, $context); + } + + /** + * Critical conditions. + * + * Example: Application component unavailable, unexpected exception. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function critical($message, array $context = array()) + { + $this->log(LogLevel::CRITICAL, $message, $context); + } + + /** + * Runtime errors that do not require immediate action but should typically + * be logged and monitored. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function error($message, array $context = array()) + { + $this->log(LogLevel::ERROR, $message, $context); + } + + /** + * Exceptional occurrences that are not errors. + * + * Example: Use of deprecated APIs, poor use of an API, undesirable things + * that are not necessarily wrong. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function warning($message, array $context = array()) + { + $this->log(LogLevel::WARNING, $message, $context); + } + + /** + * Normal but significant events. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function notice($message, array $context = array()) + { + $this->log(LogLevel::NOTICE, $message, $context); + } + + /** + * Interesting events. + * + * Example: User logs in, SQL logs. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function info($message, array $context = array()) + { + $this->log(LogLevel::INFO, $message, $context); + } + + /** + * Detailed debug information. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function debug($message, array $context = array()) + { + $this->log(LogLevel::DEBUG, $message, $context); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/InvalidArgumentException.php b/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/InvalidArgumentException.php new file mode 100644 index 0000000..67f852d --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/InvalidArgumentException.php @@ -0,0 +1,7 @@ +logger = $logger; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/LoggerInterface.php b/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/LoggerInterface.php new file mode 100644 index 0000000..2206cfd --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/LoggerInterface.php @@ -0,0 +1,125 @@ +log(LogLevel::EMERGENCY, $message, $context); + } + + /** + * Action must be taken immediately. + * + * Example: Entire website down, database unavailable, etc. This should + * trigger the SMS alerts and wake you up. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function alert($message, array $context = array()) + { + $this->log(LogLevel::ALERT, $message, $context); + } + + /** + * Critical conditions. + * + * Example: Application component unavailable, unexpected exception. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function critical($message, array $context = array()) + { + $this->log(LogLevel::CRITICAL, $message, $context); + } + + /** + * Runtime errors that do not require immediate action but should typically + * be logged and monitored. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function error($message, array $context = array()) + { + $this->log(LogLevel::ERROR, $message, $context); + } + + /** + * Exceptional occurrences that are not errors. + * + * Example: Use of deprecated APIs, poor use of an API, undesirable things + * that are not necessarily wrong. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function warning($message, array $context = array()) + { + $this->log(LogLevel::WARNING, $message, $context); + } + + /** + * Normal but significant events. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function notice($message, array $context = array()) + { + $this->log(LogLevel::NOTICE, $message, $context); + } + + /** + * Interesting events. + * + * Example: User logs in, SQL logs. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function info($message, array $context = array()) + { + $this->log(LogLevel::INFO, $message, $context); + } + + /** + * Detailed debug information. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function debug($message, array $context = array()) + { + $this->log(LogLevel::DEBUG, $message, $context); + } + + /** + * Logs with an arbitrary level. + * + * @param mixed $level + * @param string $message + * @param array $context + * + * @return void + * + * @throws \Psr\Log\InvalidArgumentException + */ + abstract public function log($level, $message, array $context = array()); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/NullLogger.php b/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/NullLogger.php new file mode 100644 index 0000000..c8f7293 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/NullLogger.php @@ -0,0 +1,30 @@ +logger) { }` + * blocks. + */ +class NullLogger extends AbstractLogger +{ + /** + * Logs with an arbitrary level. + * + * @param mixed $level + * @param string $message + * @param array $context + * + * @return void + * + * @throws \Psr\Log\InvalidArgumentException + */ + public function log($level, $message, array $context = array()) + { + // noop + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/Test/DummyTest.php b/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/Test/DummyTest.php new file mode 100644 index 0000000..9638c11 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/Test/DummyTest.php @@ -0,0 +1,18 @@ + ". + * + * Example ->error('Foo') would yield "error Foo". + * + * @return string[] + */ + abstract public function getLogs(); + + public function testImplements() + { + $this->assertInstanceOf('Psr\Log\LoggerInterface', $this->getLogger()); + } + + /** + * @dataProvider provideLevelsAndMessages + */ + public function testLogsAtAllLevels($level, $message) + { + $logger = $this->getLogger(); + $logger->{$level}($message, array('user' => 'Bob')); + $logger->log($level, $message, array('user' => 'Bob')); + + $expected = array( + $level.' message of level '.$level.' with context: Bob', + $level.' message of level '.$level.' with context: Bob', + ); + $this->assertEquals($expected, $this->getLogs()); + } + + public function provideLevelsAndMessages() + { + return array( + LogLevel::EMERGENCY => array(LogLevel::EMERGENCY, 'message of level emergency with context: {user}'), + LogLevel::ALERT => array(LogLevel::ALERT, 'message of level alert with context: {user}'), + LogLevel::CRITICAL => array(LogLevel::CRITICAL, 'message of level critical with context: {user}'), + LogLevel::ERROR => array(LogLevel::ERROR, 'message of level error with context: {user}'), + LogLevel::WARNING => array(LogLevel::WARNING, 'message of level warning with context: {user}'), + LogLevel::NOTICE => array(LogLevel::NOTICE, 'message of level notice with context: {user}'), + LogLevel::INFO => array(LogLevel::INFO, 'message of level info with context: {user}'), + LogLevel::DEBUG => array(LogLevel::DEBUG, 'message of level debug with context: {user}'), + ); + } + + /** + * @expectedException \Psr\Log\InvalidArgumentException + */ + public function testThrowsOnInvalidLevel() + { + $logger = $this->getLogger(); + $logger->log('invalid level', 'Foo'); + } + + public function testContextReplacement() + { + $logger = $this->getLogger(); + $logger->info('{Message {nothing} {user} {foo.bar} a}', array('user' => 'Bob', 'foo.bar' => 'Bar')); + + $expected = array('info {Message {nothing} Bob Bar a}'); + $this->assertEquals($expected, $this->getLogs()); + } + + public function testObjectCastToString() + { + if (method_exists($this, 'createPartialMock')) { + $dummy = $this->createPartialMock('Psr\Log\Test\DummyTest', array('__toString')); + } else { + $dummy = $this->getMock('Psr\Log\Test\DummyTest', array('__toString')); + } + $dummy->expects($this->once()) + ->method('__toString') + ->will($this->returnValue('DUMMY')); + + $this->getLogger()->warning($dummy); + + $expected = array('warning DUMMY'); + $this->assertEquals($expected, $this->getLogs()); + } + + public function testContextCanContainAnything() + { + $closed = fopen('php://memory', 'r'); + fclose($closed); + + $context = array( + 'bool' => true, + 'null' => null, + 'string' => 'Foo', + 'int' => 0, + 'float' => 0.5, + 'nested' => array('with object' => new DummyTest), + 'object' => new \DateTime, + 'resource' => fopen('php://memory', 'r'), + 'closed' => $closed, + ); + + $this->getLogger()->warning('Crazy context data', $context); + + $expected = array('warning Crazy context data'); + $this->assertEquals($expected, $this->getLogs()); + } + + public function testContextExceptionKeyCanBeExceptionOrOtherValues() + { + $logger = $this->getLogger(); + $logger->warning('Random message', array('exception' => 'oops')); + $logger->critical('Uncaught Exception!', array('exception' => new \LogicException('Fail'))); + + $expected = array( + 'warning Random message', + 'critical Uncaught Exception!' + ); + $this->assertEquals($expected, $this->getLogs()); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/Test/TestLogger.php b/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/Test/TestLogger.php new file mode 100644 index 0000000..1be3230 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/log/Psr/Log/Test/TestLogger.php @@ -0,0 +1,147 @@ + $level, + 'message' => $message, + 'context' => $context, + ]; + + $this->recordsByLevel[$record['level']][] = $record; + $this->records[] = $record; + } + + public function hasRecords($level) + { + return isset($this->recordsByLevel[$level]); + } + + public function hasRecord($record, $level) + { + if (is_string($record)) { + $record = ['message' => $record]; + } + return $this->hasRecordThatPasses(function ($rec) use ($record) { + if ($rec['message'] !== $record['message']) { + return false; + } + if (isset($record['context']) && $rec['context'] !== $record['context']) { + return false; + } + return true; + }, $level); + } + + public function hasRecordThatContains($message, $level) + { + return $this->hasRecordThatPasses(function ($rec) use ($message) { + return strpos($rec['message'], $message) !== false; + }, $level); + } + + public function hasRecordThatMatches($regex, $level) + { + return $this->hasRecordThatPasses(function ($rec) use ($regex) { + return preg_match($regex, $rec['message']) > 0; + }, $level); + } + + public function hasRecordThatPasses(callable $predicate, $level) + { + if (!isset($this->recordsByLevel[$level])) { + return false; + } + foreach ($this->recordsByLevel[$level] as $i => $rec) { + if (call_user_func($predicate, $rec, $i)) { + return true; + } + } + return false; + } + + public function __call($method, $args) + { + if (preg_match('/(.*)(Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)(.*)/', $method, $matches) > 0) { + $genericMethod = $matches[1] . ('Records' !== $matches[3] ? 'Record' : '') . $matches[3]; + $level = strtolower($matches[2]); + if (method_exists($this, $genericMethod)) { + $args[] = $level; + return call_user_func_array([$this, $genericMethod], $args); + } + } + throw new \BadMethodCallException('Call to undefined method ' . get_class($this) . '::' . $method . '()'); + } + + public function reset() + { + $this->records = []; + $this->recordsByLevel = []; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/log/README.md b/user/plugins/admin-addon-user-manager/vendor/psr/log/README.md new file mode 100644 index 0000000..a9f20c4 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/log/README.md @@ -0,0 +1,58 @@ +PSR Log +======= + +This repository holds all interfaces/classes/traits related to +[PSR-3](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md). + +Note that this is not a logger of its own. It is merely an interface that +describes a logger. See the specification for more details. + +Installation +------------ + +```bash +composer require psr/log +``` + +Usage +----- + +If you need a logger, you can use the interface like this: + +```php +logger = $logger; + } + + public function doSomething() + { + if ($this->logger) { + $this->logger->info('Doing work'); + } + + try { + $this->doSomethingElse(); + } catch (Exception $exception) { + $this->logger->error('Oh no!', array('exception' => $exception)); + } + + // do something useful + } +} +``` + +You can then pick one of the implementations of the interface to get a logger. + +If you want to implement the interface, you can require this package and +implement `Psr\Log\LoggerInterface` in your code. Please read the +[specification text](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md) +for details. diff --git a/user/plugins/admin-addon-user-manager/vendor/psr/log/composer.json b/user/plugins/admin-addon-user-manager/vendor/psr/log/composer.json new file mode 100644 index 0000000..3f6d4ee --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/psr/log/composer.json @@ -0,0 +1,26 @@ +{ + "name": "psr/log", + "description": "Common interface for logging libraries", + "keywords": ["psr", "psr-3", "log"], + "homepage": "https://github.com/php-fig/log", + "license": "MIT", + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "require": { + "php": ">=5.3.0" + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/.gitignore b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/.gitignore new file mode 100644 index 0000000..c49a5d8 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/CacheInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/CacheInterface.php new file mode 100644 index 0000000..4b1686b --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/CacheInterface.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Cache; + +use Psr\Cache\CacheItemInterface; +use Psr\Cache\InvalidArgumentException; + +/** + * Covers most simple to advanced caching needs. + * + * @author Nicolas Grekas + */ +interface CacheInterface +{ + /** + * Fetches a value from the pool or computes it if not found. + * + * On cache misses, a callback is called that should return the missing value. + * This callback is given a PSR-6 CacheItemInterface instance corresponding to the + * requested key, that could be used e.g. for expiration control. It could also + * be an ItemInterface instance when its additional features are needed. + * + * @param string $key The key of the item to retrieve from the cache + * @param callable|CallbackInterface $callback Should return the computed value for the given key/item + * @param float|null $beta A float that, as it grows, controls the likeliness of triggering + * early expiration. 0 disables it, INF forces immediate expiration. + * The default (or providing null) is implementation dependent but should + * typically be 1.0, which should provide optimal stampede protection. + * See https://en.wikipedia.org/wiki/Cache_stampede#Probabilistic_early_expiration + * @param array &$metadata The metadata of the cached item {@see ItemInterface::getMetadata()} + * + * @return mixed The value corresponding to the provided key + * + * @throws InvalidArgumentException When $key is not valid or when $beta is negative + */ + public function get(string $key, callable $callback, float $beta = null, array &$metadata = null); + + /** + * Removes an item from the pool. + * + * @param string $key The key to delete + * + * @throws InvalidArgumentException When $key is not valid + * + * @return bool True if the item was successfully removed, false if there was any error + */ + public function delete(string $key): bool; +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/CacheTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/CacheTrait.php new file mode 100644 index 0000000..355ea29 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/CacheTrait.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Cache; + +use Psr\Cache\CacheItemPoolInterface; +use Psr\Cache\InvalidArgumentException; +use Psr\Log\LoggerInterface; + +/** + * An implementation of CacheInterface for PSR-6 CacheItemPoolInterface classes. + * + * @author Nicolas Grekas + */ +trait CacheTrait +{ + /** + * {@inheritdoc} + */ + public function get(string $key, callable $callback, float $beta = null, array &$metadata = null) + { + return $this->doGet($this, $key, $callback, $beta, $metadata); + } + + /** + * {@inheritdoc} + */ + public function delete(string $key): bool + { + return $this->deleteItem($key); + } + + private function doGet(CacheItemPoolInterface $pool, string $key, callable $callback, ?float $beta, array &$metadata = null, LoggerInterface $logger = null) + { + if (0 > $beta = $beta ?? 1.0) { + throw new class(sprintf('Argument "$beta" provided to "%s::get()" must be a positive number, %f given.', \get_class($this), $beta)) extends \InvalidArgumentException implements InvalidArgumentException { + }; + } + + $item = $pool->getItem($key); + $recompute = !$item->isHit() || INF === $beta; + $metadata = $item instanceof ItemInterface ? $item->getMetadata() : []; + + if (!$recompute && $metadata) { + $expiry = $metadata[ItemInterface::METADATA_EXPIRY] ?? false; + $ctime = $metadata[ItemInterface::METADATA_CTIME] ?? false; + + if ($recompute = $ctime && $expiry && $expiry <= ($now = microtime(true)) - $ctime / 1000 * $beta * log(random_int(1, PHP_INT_MAX) / PHP_INT_MAX)) { + // force applying defaultLifetime to expiry + $item->expiresAt(null); + $logger && $logger->info('Item "{key}" elected for early recomputation {delta}s before its expiration', [ + 'key' => $key, + 'delta' => sprintf('%.1f', $expiry - $now), + ]); + } + } + + if ($recompute) { + $save = true; + $item->set($callback($item, $save)); + if ($save) { + $pool->save($item); + } + } + + return $item->get(); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/CallbackInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/CallbackInterface.php new file mode 100644 index 0000000..7dae2aa --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/CallbackInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Cache; + +use Psr\Cache\CacheItemInterface; + +/** + * Computes and returns the cached value of an item. + * + * @author Nicolas Grekas + */ +interface CallbackInterface +{ + /** + * @param CacheItemInterface|ItemInterface $item The item to compute the value for + * @param bool &$save Should be set to false when the value should not be saved in the pool + * + * @return mixed The computed value for the passed item + */ + public function __invoke(CacheItemInterface $item, bool &$save); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/ItemInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/ItemInterface.php new file mode 100644 index 0000000..cbd7226 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/ItemInterface.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Cache; + +use Psr\Cache\CacheException; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\InvalidArgumentException; + +/** + * Augments PSR-6's CacheItemInterface with support for tags and metadata. + * + * @author Nicolas Grekas + */ +interface ItemInterface extends CacheItemInterface +{ + /** + * References the Unix timestamp stating when the item will expire. + */ + const METADATA_EXPIRY = 'expiry'; + + /** + * References the time the item took to be created, in milliseconds. + */ + const METADATA_CTIME = 'ctime'; + + /** + * References the list of tags that were assigned to the item, as string[]. + */ + const METADATA_TAGS = 'tags'; + + /** + * Reserved characters that cannot be used in a key or tag. + */ + const RESERVED_CHARACTERS = '{}()/\@:'; + + /** + * Adds a tag to a cache item. + * + * Tags are strings that follow the same validation rules as keys. + * + * @param string|string[] $tags A tag or array of tags + * + * @return $this + * + * @throws InvalidArgumentException When $tag is not valid + * @throws CacheException When the item comes from a pool that is not tag-aware + */ + public function tag($tags): self; + + /** + * Returns a list of metadata info that were saved alongside with the cached value. + * + * See ItemInterface::METADATA_* consts for keys potentially found in the returned array. + */ + public function getMetadata(): array; +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/LICENSE b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/LICENSE new file mode 100644 index 0000000..3f853aa --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018-2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/README.md b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/README.md new file mode 100644 index 0000000..58c589e --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/README.md @@ -0,0 +1,9 @@ +Symfony Cache Contracts +======================= + +A set of abstractions extracted out of the Symfony components. + +Can be used to build on semantics that the Symfony components proved useful - and +that already have battle tested implementations. + +See https://github.com/symfony/contracts/blob/master/README.md for more information. diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/TagAwareCacheInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/TagAwareCacheInterface.php new file mode 100644 index 0000000..7c4cf11 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/TagAwareCacheInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Cache; + +use Psr\Cache\InvalidArgumentException; + +/** + * Allows invalidating cached items using tags. + * + * @author Nicolas Grekas + */ +interface TagAwareCacheInterface extends CacheInterface +{ + /** + * Invalidates cached items using tags. + * + * When implemented on a PSR-6 pool, invalidation should not apply + * to deferred items. Instead, they should be committed as usual. + * This allows replacing old tagged values by new ones without + * race conditions. + * + * @param string[] $tags An array of tags to invalidate + * + * @return bool True on success + * + * @throws InvalidArgumentException When $tags is not valid + */ + public function invalidateTags(array $tags); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/composer.json b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/composer.json new file mode 100644 index 0000000..c9cf8b5 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache-contracts/composer.json @@ -0,0 +1,34 @@ +{ + "name": "symfony/cache-contracts", + "type": "library", + "description": "Generic abstractions related to caching", + "keywords": ["abstractions", "contracts", "decoupling", "interfaces", "interoperability", "standards"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.2.5", + "psr/cache": "^1.0" + }, + "suggest": { + "symfony/cache-implementation": "" + }, + "autoload": { + "psr-4": { "Symfony\\Contracts\\Cache\\": "" } + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/AbstractAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/AbstractAdapter.php new file mode 100644 index 0000000..79ecf3c --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/AbstractAdapter.php @@ -0,0 +1,203 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\AbstractAdapterTrait; +use Symfony\Component\Cache\Traits\ContractsTrait; +use Symfony\Contracts\Cache\CacheInterface; + +/** + * @author Nicolas Grekas + */ +abstract class AbstractAdapter implements AdapterInterface, CacheInterface, LoggerAwareInterface, ResettableInterface +{ + /** + * @internal + */ + protected const NS_SEPARATOR = ':'; + + use AbstractAdapterTrait; + use ContractsTrait; + + private static $apcuSupported; + private static $phpFilesSupported; + + protected function __construct(string $namespace = '', int $defaultLifetime = 0) + { + $this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace).static::NS_SEPARATOR; + if (null !== $this->maxIdLength && \strlen($namespace) > $this->maxIdLength - 24) { + throw new InvalidArgumentException(sprintf('Namespace must be %d chars max, %d given ("%s").', $this->maxIdLength - 24, \strlen($namespace), $namespace)); + } + $this->createCacheItem = \Closure::bind( + static function ($key, $value, $isHit) use ($defaultLifetime) { + $item = new CacheItem(); + $item->key = $key; + $item->value = $v = $value; + $item->isHit = $isHit; + $item->defaultLifetime = $defaultLifetime; + // Detect wrapped values that encode for their expiry and creation duration + // For compactness, these values are packed in the key of an array using + // magic numbers in the form 9D-..-..-..-..-00-..-..-..-5F + if (\is_array($v) && 1 === \count($v) && 10 === \strlen($k = key($v)) && "\x9D" === $k[0] && "\0" === $k[5] && "\x5F" === $k[9]) { + $item->value = $v[$k]; + $v = unpack('Ve/Nc', substr($k, 1, -1)); + $item->metadata[CacheItem::METADATA_EXPIRY] = $v['e'] + CacheItem::METADATA_EXPIRY_OFFSET; + $item->metadata[CacheItem::METADATA_CTIME] = $v['c']; + } + + return $item; + }, + null, + CacheItem::class + ); + $getId = \Closure::fromCallable([$this, 'getId']); + $this->mergeByLifetime = \Closure::bind( + static function ($deferred, $namespace, &$expiredIds) use ($getId) { + $byLifetime = []; + $now = microtime(true); + $expiredIds = []; + + foreach ($deferred as $key => $item) { + $key = (string) $key; + if (null === $item->expiry) { + $ttl = 0 < $item->defaultLifetime ? $item->defaultLifetime : 0; + } elseif (0 >= $ttl = (int) (0.1 + $item->expiry - $now)) { + $expiredIds[] = $getId($key); + continue; + } + if (isset(($metadata = $item->newMetadata)[CacheItem::METADATA_TAGS])) { + unset($metadata[CacheItem::METADATA_TAGS]); + } + // For compactness, expiry and creation duration are packed in the key of an array, using magic numbers as separators + $byLifetime[$ttl][$getId($key)] = $metadata ? ["\x9D".pack('VN', (int) (0.1 + $metadata[self::METADATA_EXPIRY] - self::METADATA_EXPIRY_OFFSET), $metadata[self::METADATA_CTIME])."\x5F" => $item->value] : $item->value; + } + + return $byLifetime; + }, + null, + CacheItem::class + ); + } + + /** + * Returns the best possible adapter that your runtime supports. + * + * Using ApcuAdapter makes system caches compatible with read-only filesystems. + * + * @param string $namespace + * @param int $defaultLifetime + * @param string $version + * @param string $directory + * + * @return AdapterInterface + */ + public static function createSystemCache($namespace, $defaultLifetime, $version, $directory, LoggerInterface $logger = null) + { + $opcache = new PhpFilesAdapter($namespace, $defaultLifetime, $directory, true); + if (null !== $logger) { + $opcache->setLogger($logger); + } + + if (!self::$apcuSupported = self::$apcuSupported ?? ApcuAdapter::isSupported()) { + return $opcache; + } + + $apcu = new ApcuAdapter($namespace, (int) $defaultLifetime / 5, $version); + if ('cli' === \PHP_SAPI && !filter_var(ini_get('apc.enable_cli'), FILTER_VALIDATE_BOOLEAN)) { + $apcu->setLogger(new NullLogger()); + } elseif (null !== $logger) { + $apcu->setLogger($logger); + } + + return new ChainAdapter([$apcu, $opcache]); + } + + public static function createConnection($dsn, array $options = []) + { + if (!\is_string($dsn)) { + throw new InvalidArgumentException(sprintf('The "%s()" method expect argument #1 to be string, "%s" given.', __METHOD__, \gettype($dsn))); + } + if (0 === strpos($dsn, 'redis:') || 0 === strpos($dsn, 'rediss:')) { + return RedisAdapter::createConnection($dsn, $options); + } + if (0 === strpos($dsn, 'memcached:')) { + return MemcachedAdapter::createConnection($dsn, $options); + } + + throw new InvalidArgumentException(sprintf('Unsupported DSN: "%s".', $dsn)); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function commit() + { + $ok = true; + $byLifetime = $this->mergeByLifetime; + $byLifetime = $byLifetime($this->deferred, $this->namespace, $expiredIds); + $retry = $this->deferred = []; + + if ($expiredIds) { + $this->doDelete($expiredIds); + } + foreach ($byLifetime as $lifetime => $values) { + try { + $e = $this->doSave($values, $lifetime); + } catch (\Exception $e) { + } + if (true === $e || [] === $e) { + continue; + } + if (\is_array($e) || 1 === \count($values)) { + foreach (\is_array($e) ? $e : array_keys($values) as $id) { + $ok = false; + $v = $values[$id]; + $type = \is_object($v) ? \get_class($v) : \gettype($v); + $message = sprintf('Failed to save key "{key}" of type %s%s', $type, $e instanceof \Exception ? ': '.$e->getMessage() : '.'); + CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->namespace)), 'exception' => $e instanceof \Exception ? $e : null]); + } + } else { + foreach ($values as $id => $v) { + $retry[$lifetime][] = $id; + } + } + } + + // When bulk-save failed, retry each item individually + foreach ($retry as $lifetime => $ids) { + foreach ($ids as $id) { + try { + $v = $byLifetime[$lifetime][$id]; + $e = $this->doSave([$id => $v], $lifetime); + } catch (\Exception $e) { + } + if (true === $e || [] === $e) { + continue; + } + $ok = false; + $type = \is_object($v) ? \get_class($v) : \gettype($v); + $message = sprintf('Failed to save key "{key}" of type %s%s', $type, $e instanceof \Exception ? ': '.$e->getMessage() : '.'); + CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->namespace)), 'exception' => $e instanceof \Exception ? $e : null]); + } + } + + return $ok; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/AbstractTagAwareAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/AbstractTagAwareAdapter.php new file mode 100644 index 0000000..5a9f94b --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/AbstractTagAwareAdapter.php @@ -0,0 +1,323 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Log\LoggerAwareInterface; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\AbstractAdapterTrait; +use Symfony\Component\Cache\Traits\ContractsTrait; +use Symfony\Contracts\Cache\TagAwareCacheInterface; + +/** + * Abstract for native TagAware adapters. + * + * To keep info on tags, the tags are both serialized as part of cache value and provided as tag ids + * to Adapters on operations when needed for storage to doSave(), doDelete() & doInvalidate(). + * + * @author Nicolas Grekas + * @author André Rømcke + * + * @internal + */ +abstract class AbstractTagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterface, LoggerAwareInterface, ResettableInterface +{ + use AbstractAdapterTrait; + use ContractsTrait; + + private const TAGS_PREFIX = "\0tags\0"; + + protected function __construct(string $namespace = '', int $defaultLifetime = 0) + { + $this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace).':'; + if (null !== $this->maxIdLength && \strlen($namespace) > $this->maxIdLength - 24) { + throw new InvalidArgumentException(sprintf('Namespace must be %d chars max, %d given ("%s").', $this->maxIdLength - 24, \strlen($namespace), $namespace)); + } + $this->createCacheItem = \Closure::bind( + static function ($key, $value, $isHit) use ($defaultLifetime) { + $item = new CacheItem(); + $item->key = $key; + $item->defaultLifetime = $defaultLifetime; + $item->isTaggable = true; + // If structure does not match what we expect return item as is (no value and not a hit) + if (!\is_array($value) || !\array_key_exists('value', $value)) { + return $item; + } + $item->isHit = $isHit; + // Extract value, tags and meta data from the cache value + $item->value = $value['value']; + $item->metadata[CacheItem::METADATA_TAGS] = $value['tags'] ?? []; + if (isset($value['meta'])) { + // For compactness these values are packed, & expiry is offset to reduce size + $v = unpack('Ve/Nc', $value['meta']); + $item->metadata[CacheItem::METADATA_EXPIRY] = $v['e'] + CacheItem::METADATA_EXPIRY_OFFSET; + $item->metadata[CacheItem::METADATA_CTIME] = $v['c']; + } + + return $item; + }, + null, + CacheItem::class + ); + $getId = \Closure::fromCallable([$this, 'getId']); + $tagPrefix = self::TAGS_PREFIX; + $this->mergeByLifetime = \Closure::bind( + static function ($deferred, &$expiredIds) use ($getId, $tagPrefix) { + $byLifetime = []; + $now = microtime(true); + $expiredIds = []; + + foreach ($deferred as $key => $item) { + $key = (string) $key; + if (null === $item->expiry) { + $ttl = 0 < $item->defaultLifetime ? $item->defaultLifetime : 0; + } elseif (0 >= $ttl = (int) (0.1 + $item->expiry - $now)) { + $expiredIds[] = $getId($key); + continue; + } + // Store Value and Tags on the cache value + if (isset(($metadata = $item->newMetadata)[CacheItem::METADATA_TAGS])) { + $value = ['value' => $item->value, 'tags' => $metadata[CacheItem::METADATA_TAGS]]; + unset($metadata[CacheItem::METADATA_TAGS]); + } else { + $value = ['value' => $item->value, 'tags' => []]; + } + + if ($metadata) { + // For compactness, expiry and creation duration are packed, using magic numbers as separators + $value['meta'] = pack('VN', (int) (0.1 + $metadata[self::METADATA_EXPIRY] - self::METADATA_EXPIRY_OFFSET), $metadata[self::METADATA_CTIME]); + } + + // Extract tag changes, these should be removed from values in doSave() + $value['tag-operations'] = ['add' => [], 'remove' => []]; + $oldTags = $item->metadata[CacheItem::METADATA_TAGS] ?? []; + foreach (array_diff($value['tags'], $oldTags) as $addedTag) { + $value['tag-operations']['add'][] = $getId($tagPrefix.$addedTag); + } + foreach (array_diff($oldTags, $value['tags']) as $removedTag) { + $value['tag-operations']['remove'][] = $getId($tagPrefix.$removedTag); + } + + $byLifetime[$ttl][$getId($key)] = $value; + } + + return $byLifetime; + }, + null, + CacheItem::class + ); + } + + /** + * Persists several cache items immediately. + * + * @param array $values The values to cache, indexed by their cache identifier + * @param int $lifetime The lifetime of the cached values, 0 for persisting until manual cleaning + * @param array[] $addTagData Hash where key is tag id, and array value is list of cache id's to add to tag + * @param array[] $removeTagData Hash where key is tag id, and array value is list of cache id's to remove to tag + * + * @return array The identifiers that failed to be cached or a boolean stating if caching succeeded or not + */ + abstract protected function doSave(array $values, ?int $lifetime, array $addTagData = [], array $removeTagData = []): array; + + /** + * Removes multiple items from the pool and their corresponding tags. + * + * @param array $ids An array of identifiers that should be removed from the pool + * + * @return bool True if the items were successfully removed, false otherwise + */ + abstract protected function doDelete(array $ids); + + /** + * Removes relations between tags and deleted items. + * + * @param array $tagData Array of tag => key identifiers that should be removed from the pool + */ + abstract protected function doDeleteTagRelations(array $tagData): bool; + + /** + * Invalidates cached items using tags. + * + * @param string[] $tagIds An array of tags to invalidate, key is tag and value is tag id + * + * @return bool True on success + */ + abstract protected function doInvalidate(array $tagIds): bool; + + /** + * Delete items and yields the tags they were bound to. + */ + protected function doDeleteYieldTags(array $ids): iterable + { + foreach ($this->doFetch($ids) as $id => $value) { + yield $id => \is_array($value) && \is_array($value['tags'] ?? null) ? $value['tags'] : []; + } + + $this->doDelete($ids); + } + + /** + * {@inheritdoc} + */ + public function commit(): bool + { + $ok = true; + $byLifetime = $this->mergeByLifetime; + $byLifetime = $byLifetime($this->deferred, $expiredIds); + $retry = $this->deferred = []; + + if ($expiredIds) { + // Tags are not cleaned up in this case, however that is done on invalidateTags(). + $this->doDelete($expiredIds); + } + foreach ($byLifetime as $lifetime => $values) { + try { + $values = $this->extractTagData($values, $addTagData, $removeTagData); + $e = $this->doSave($values, $lifetime, $addTagData, $removeTagData); + } catch (\Exception $e) { + } + if (true === $e || [] === $e) { + continue; + } + if (\is_array($e) || 1 === \count($values)) { + foreach (\is_array($e) ? $e : array_keys($values) as $id) { + $ok = false; + $v = $values[$id]; + $type = \is_object($v) ? \get_class($v) : \gettype($v); + $message = sprintf('Failed to save key "{key}" of type %s%s', $type, $e instanceof \Exception ? ': '.$e->getMessage() : '.'); + CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->namespace)), 'exception' => $e instanceof \Exception ? $e : null]); + } + } else { + foreach ($values as $id => $v) { + $retry[$lifetime][] = $id; + } + } + } + + // When bulk-save failed, retry each item individually + foreach ($retry as $lifetime => $ids) { + foreach ($ids as $id) { + try { + $v = $byLifetime[$lifetime][$id]; + $values = $this->extractTagData([$id => $v], $addTagData, $removeTagData); + $e = $this->doSave($values, $lifetime, $addTagData, $removeTagData); + } catch (\Exception $e) { + } + if (true === $e || [] === $e) { + continue; + } + $ok = false; + $type = \is_object($v) ? \get_class($v) : \gettype($v); + $message = sprintf('Failed to save key "{key}" of type %s%s', $type, $e instanceof \Exception ? ': '.$e->getMessage() : '.'); + CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->namespace)), 'exception' => $e instanceof \Exception ? $e : null]); + } + } + + return $ok; + } + + /** + * {@inheritdoc} + */ + public function deleteItems(array $keys): bool + { + if (!$keys) { + return true; + } + + $ok = true; + $ids = []; + $tagData = []; + + foreach ($keys as $key) { + $ids[$key] = $this->getId($key); + unset($this->deferred[$key]); + } + + try { + foreach ($this->doDeleteYieldTags(array_values($ids)) as $id => $tags) { + foreach ($tags as $tag) { + $tagData[$this->getId(self::TAGS_PREFIX.$tag)][] = $id; + } + } + } catch (\Exception $e) { + $ok = false; + } + + try { + if ((!$tagData || $this->doDeleteTagRelations($tagData)) && $ok) { + return true; + } + } catch (\Exception $e) { + } + + // When bulk-delete failed, retry each item individually + foreach ($ids as $key => $id) { + try { + $e = null; + if ($this->doDelete([$id])) { + continue; + } + } catch (\Exception $e) { + } + $message = 'Failed to delete key "{key}"'.($e instanceof \Exception ? ': '.$e->getMessage() : '.'); + CacheItem::log($this->logger, $message, ['key' => $key, 'exception' => $e]); + $ok = false; + } + + return $ok; + } + + /** + * {@inheritdoc} + */ + public function invalidateTags(array $tags) + { + if (empty($tags)) { + return false; + } + + $tagIds = []; + foreach (array_unique($tags) as $tag) { + $tagIds[] = $this->getId(self::TAGS_PREFIX.$tag); + } + + if ($this->doInvalidate($tagIds)) { + return true; + } + + return false; + } + + /** + * Extracts tags operation data from $values set in mergeByLifetime, and returns values without it. + */ + private function extractTagData(array $values, ?array &$addTagData, ?array &$removeTagData): array + { + $addTagData = $removeTagData = []; + foreach ($values as $id => $value) { + foreach ($value['tag-operations']['add'] as $tag => $tagId) { + $addTagData[$tagId][] = $id; + } + + foreach ($value['tag-operations']['remove'] as $tag => $tagId) { + $removeTagData[$tagId][] = $id; + } + + unset($values[$id]['tag-operations']); + } + + return $values; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/AdapterInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/AdapterInterface.php new file mode 100644 index 0000000..fbc74ca --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/AdapterInterface.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\CacheItem; + +// Help opcache.preload discover always-needed symbols +class_exists(CacheItem::class); + +/** + * Interface for adapters managing instances of Symfony's CacheItem. + * + * @author Kévin Dunglas + */ +interface AdapterInterface extends CacheItemPoolInterface +{ + /** + * {@inheritdoc} + * + * @return CacheItem + */ + public function getItem($key); + + /** + * {@inheritdoc} + * + * @return \Traversable|CacheItem[] + */ + public function getItems(array $keys = []); + + /** + * {@inheritdoc} + * + * @param string $prefix + * + * @return bool + */ + public function clear(/*string $prefix = ''*/); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ApcuAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ApcuAdapter.php new file mode 100644 index 0000000..7db3956 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ApcuAdapter.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Symfony\Component\Cache\Traits\ApcuTrait; + +class ApcuAdapter extends AbstractAdapter +{ + use ApcuTrait; + + /** + * @throws CacheException if APCu is not enabled + */ + public function __construct(string $namespace = '', int $defaultLifetime = 0, string $version = null) + { + $this->init($namespace, $defaultLifetime, $version); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ArrayAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ArrayAdapter.php new file mode 100644 index 0000000..d93dcbd --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ArrayAdapter.php @@ -0,0 +1,171 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Cache\CacheItemInterface; +use Psr\Log\LoggerAwareInterface; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\ArrayTrait; +use Symfony\Contracts\Cache\CacheInterface; + +/** + * @author Nicolas Grekas + */ +class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInterface, ResettableInterface +{ + use ArrayTrait; + + private $createCacheItem; + + /** + * @param bool $storeSerialized Disabling serialization can lead to cache corruptions when storing mutable values but increases performance otherwise + */ + public function __construct(int $defaultLifetime = 0, bool $storeSerialized = true) + { + $this->storeSerialized = $storeSerialized; + $this->createCacheItem = \Closure::bind( + static function ($key, $value, $isHit) use ($defaultLifetime) { + $item = new CacheItem(); + $item->key = $key; + $item->value = $value; + $item->isHit = $isHit; + $item->defaultLifetime = $defaultLifetime; + + return $item; + }, + null, + CacheItem::class + ); + } + + /** + * {@inheritdoc} + */ + public function get(string $key, callable $callback, float $beta = null, array &$metadata = null) + { + $item = $this->getItem($key); + $metadata = $item->getMetadata(); + + // ArrayAdapter works in memory, we don't care about stampede protection + if (INF === $beta || !$item->isHit()) { + $save = true; + $this->save($item->set($callback($item, $save))); + } + + return $item->get(); + } + + /** + * {@inheritdoc} + */ + public function getItem($key) + { + if (!$isHit = $this->hasItem($key)) { + $this->values[$key] = $value = null; + } else { + $value = $this->storeSerialized ? $this->unfreeze($key, $isHit) : $this->values[$key]; + } + $f = $this->createCacheItem; + + return $f($key, $value, $isHit); + } + + /** + * {@inheritdoc} + */ + public function getItems(array $keys = []) + { + foreach ($keys as $key) { + if (!\is_string($key) || !isset($this->expiries[$key])) { + CacheItem::validateKey($key); + } + } + + return $this->generateItems($keys, microtime(true), $this->createCacheItem); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItems(array $keys) + { + foreach ($keys as $key) { + $this->deleteItem($key); + } + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function save(CacheItemInterface $item) + { + if (!$item instanceof CacheItem) { + return false; + } + $item = (array) $item; + $key = $item["\0*\0key"]; + $value = $item["\0*\0value"]; + $expiry = $item["\0*\0expiry"]; + + if (null !== $expiry && $expiry <= microtime(true)) { + $this->deleteItem($key); + + return true; + } + if ($this->storeSerialized && null === $value = $this->freeze($value, $key)) { + return false; + } + if (null === $expiry && 0 < $item["\0*\0defaultLifetime"]) { + $expiry = microtime(true) + $item["\0*\0defaultLifetime"]; + } + + $this->values[$key] = $value; + $this->expiries[$key] = null !== $expiry ? $expiry : PHP_INT_MAX; + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function saveDeferred(CacheItemInterface $item) + { + return $this->save($item); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function commit() + { + return true; + } + + /** + * {@inheritdoc} + */ + public function delete(string $key): bool + { + return $this->deleteItem($key); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ChainAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ChainAdapter.php new file mode 100644 index 0000000..63e97a8 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ChainAdapter.php @@ -0,0 +1,332 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\ContractsTrait; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Service\ResetInterface; + +/** + * Chains several adapters together. + * + * Cached items are fetched from the first adapter having them in its data store. + * They are saved and deleted in all adapters at once. + * + * @author Kévin Dunglas + */ +class ChainAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface +{ + use ContractsTrait; + + private $adapters = []; + private $adapterCount; + private $syncItem; + + /** + * @param CacheItemPoolInterface[] $adapters The ordered list of adapters used to fetch cached items + * @param int $defaultLifetime The default lifetime of items propagated from lower adapters to upper ones + */ + public function __construct(array $adapters, int $defaultLifetime = 0) + { + if (!$adapters) { + throw new InvalidArgumentException('At least one adapter must be specified.'); + } + + foreach ($adapters as $adapter) { + if (!$adapter instanceof CacheItemPoolInterface) { + throw new InvalidArgumentException(sprintf('The class "%s" does not implement the "%s" interface.', \get_class($adapter), CacheItemPoolInterface::class)); + } + + if ($adapter instanceof AdapterInterface) { + $this->adapters[] = $adapter; + } else { + $this->adapters[] = new ProxyAdapter($adapter); + } + } + $this->adapterCount = \count($this->adapters); + + $this->syncItem = \Closure::bind( + static function ($sourceItem, $item, $sourceMetadata = null) use ($defaultLifetime) { + $sourceItem->isTaggable = false; + $sourceMetadata = $sourceMetadata ?? $sourceItem->metadata; + unset($sourceMetadata[CacheItem::METADATA_TAGS]); + + $item->value = $sourceItem->value; + $item->expiry = $sourceMetadata[CacheItem::METADATA_EXPIRY] ?? $sourceItem->expiry; + $item->isHit = $sourceItem->isHit; + $item->metadata = $item->newMetadata = $sourceItem->metadata = $sourceMetadata; + + if (0 < $sourceItem->defaultLifetime && $sourceItem->defaultLifetime < $defaultLifetime) { + $defaultLifetime = $sourceItem->defaultLifetime; + } + if (0 < $defaultLifetime && ($item->defaultLifetime <= 0 || $defaultLifetime < $item->defaultLifetime)) { + $item->defaultLifetime = $defaultLifetime; + } + + return $item; + }, + null, + CacheItem::class + ); + } + + /** + * {@inheritdoc} + */ + public function get(string $key, callable $callback, float $beta = null, array &$metadata = null) + { + $lastItem = null; + $i = 0; + $wrap = function (CacheItem $item = null) use ($key, $callback, $beta, &$wrap, &$i, &$lastItem, &$metadata) { + $adapter = $this->adapters[$i]; + if (isset($this->adapters[++$i])) { + $callback = $wrap; + $beta = INF === $beta ? INF : 0; + } + if ($adapter instanceof CacheInterface) { + $value = $adapter->get($key, $callback, $beta, $metadata); + } else { + $value = $this->doGet($adapter, $key, $callback, $beta, $metadata); + } + if (null !== $item) { + ($this->syncItem)($lastItem = $lastItem ?? $item, $item, $metadata); + } + + return $value; + }; + + return $wrap(); + } + + /** + * {@inheritdoc} + */ + public function getItem($key) + { + $syncItem = $this->syncItem; + $misses = []; + + foreach ($this->adapters as $i => $adapter) { + $item = $adapter->getItem($key); + + if ($item->isHit()) { + while (0 <= --$i) { + $this->adapters[$i]->save($syncItem($item, $misses[$i])); + } + + return $item; + } + + $misses[$i] = $item; + } + + return $item; + } + + /** + * {@inheritdoc} + */ + public function getItems(array $keys = []) + { + return $this->generateItems($this->adapters[0]->getItems($keys), 0); + } + + private function generateItems(iterable $items, int $adapterIndex) + { + $missing = []; + $misses = []; + $nextAdapterIndex = $adapterIndex + 1; + $nextAdapter = isset($this->adapters[$nextAdapterIndex]) ? $this->adapters[$nextAdapterIndex] : null; + + foreach ($items as $k => $item) { + if (!$nextAdapter || $item->isHit()) { + yield $k => $item; + } else { + $missing[] = $k; + $misses[$k] = $item; + } + } + + if ($missing) { + $syncItem = $this->syncItem; + $adapter = $this->adapters[$adapterIndex]; + $items = $this->generateItems($nextAdapter->getItems($missing), $nextAdapterIndex); + + foreach ($items as $k => $item) { + if ($item->isHit()) { + $adapter->save($syncItem($item, $misses[$k])); + } + + yield $k => $item; + } + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function hasItem($key) + { + foreach ($this->adapters as $adapter) { + if ($adapter->hasItem($key)) { + return true; + } + } + + return false; + } + + /** + * {@inheritdoc} + * + * @param string $prefix + * + * @return bool + */ + public function clear(/*string $prefix = ''*/) + { + $prefix = 0 < \func_num_args() ? (string) func_get_arg(0) : ''; + $cleared = true; + $i = $this->adapterCount; + + while ($i--) { + if ($this->adapters[$i] instanceof AdapterInterface) { + $cleared = $this->adapters[$i]->clear($prefix) && $cleared; + } else { + $cleared = $this->adapters[$i]->clear() && $cleared; + } + } + + return $cleared; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItem($key) + { + $deleted = true; + $i = $this->adapterCount; + + while ($i--) { + $deleted = $this->adapters[$i]->deleteItem($key) && $deleted; + } + + return $deleted; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItems(array $keys) + { + $deleted = true; + $i = $this->adapterCount; + + while ($i--) { + $deleted = $this->adapters[$i]->deleteItems($keys) && $deleted; + } + + return $deleted; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function save(CacheItemInterface $item) + { + $saved = true; + $i = $this->adapterCount; + + while ($i--) { + $saved = $this->adapters[$i]->save($item) && $saved; + } + + return $saved; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function saveDeferred(CacheItemInterface $item) + { + $saved = true; + $i = $this->adapterCount; + + while ($i--) { + $saved = $this->adapters[$i]->saveDeferred($item) && $saved; + } + + return $saved; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function commit() + { + $committed = true; + $i = $this->adapterCount; + + while ($i--) { + $committed = $this->adapters[$i]->commit() && $committed; + } + + return $committed; + } + + /** + * {@inheritdoc} + */ + public function prune() + { + $pruned = true; + + foreach ($this->adapters as $adapter) { + if ($adapter instanceof PruneableInterface) { + $pruned = $adapter->prune() && $pruned; + } + } + + return $pruned; + } + + /** + * {@inheritdoc} + */ + public function reset() + { + foreach ($this->adapters as $adapter) { + if ($adapter instanceof ResetInterface) { + $adapter->reset(); + } + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/DoctrineAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/DoctrineAdapter.php new file mode 100644 index 0000000..75ae4cb --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/DoctrineAdapter.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Doctrine\Common\Cache\CacheProvider; +use Symfony\Component\Cache\Traits\DoctrineTrait; + +class DoctrineAdapter extends AbstractAdapter +{ + use DoctrineTrait; + + public function __construct(CacheProvider $provider, string $namespace = '', int $defaultLifetime = 0) + { + parent::__construct('', $defaultLifetime); + $this->provider = $provider; + $provider->setNamespace($namespace); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/FilesystemAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/FilesystemAdapter.php new file mode 100644 index 0000000..7185dd4 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/FilesystemAdapter.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Symfony\Component\Cache\Marshaller\DefaultMarshaller; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\Traits\FilesystemTrait; + +class FilesystemAdapter extends AbstractAdapter implements PruneableInterface +{ + use FilesystemTrait; + + public function __construct(string $namespace = '', int $defaultLifetime = 0, string $directory = null, MarshallerInterface $marshaller = null) + { + $this->marshaller = $marshaller ?? new DefaultMarshaller(); + parent::__construct('', $defaultLifetime); + $this->init($namespace, $directory); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/FilesystemTagAwareAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/FilesystemTagAwareAdapter.php new file mode 100644 index 0000000..d9a1ad3 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/FilesystemTagAwareAdapter.php @@ -0,0 +1,239 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\Marshaller\TagAwareMarshaller; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\Traits\FilesystemTrait; + +/** + * Stores tag id <> cache id relationship as a symlink, and lookup on invalidation calls. + * + * @author Nicolas Grekas + * @author André Rømcke + */ +class FilesystemTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInterface +{ + use FilesystemTrait { + doClear as private doClearCache; + doSave as private doSaveCache; + } + + /** + * Folder used for tag symlinks. + */ + private const TAG_FOLDER = 'tags'; + + public function __construct(string $namespace = '', int $defaultLifetime = 0, string $directory = null, MarshallerInterface $marshaller = null) + { + $this->marshaller = new TagAwareMarshaller($marshaller); + parent::__construct('', $defaultLifetime); + $this->init($namespace, $directory); + } + + /** + * {@inheritdoc} + */ + protected function doClear($namespace) + { + $ok = $this->doClearCache($namespace); + + if ('' !== $namespace) { + return $ok; + } + + set_error_handler(static function () {}); + $chars = '+-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + try { + foreach ($this->scanHashDir($this->directory.self::TAG_FOLDER.\DIRECTORY_SEPARATOR) as $dir) { + if (rename($dir, $renamed = substr_replace($dir, bin2hex(random_bytes(4)), -8))) { + $dir = $renamed.\DIRECTORY_SEPARATOR; + } else { + $dir .= \DIRECTORY_SEPARATOR; + $renamed = null; + } + + for ($i = 0; $i < 38; ++$i) { + if (!file_exists($dir.$chars[$i])) { + continue; + } + for ($j = 0; $j < 38; ++$j) { + if (!file_exists($d = $dir.$chars[$i].\DIRECTORY_SEPARATOR.$chars[$j])) { + continue; + } + foreach (scandir($d, SCANDIR_SORT_NONE) ?: [] as $link) { + if ('.' !== $link && '..' !== $link && (null !== $renamed || !realpath($d.\DIRECTORY_SEPARATOR.$link))) { + unlink($d.\DIRECTORY_SEPARATOR.$link); + } + } + null === $renamed ?: rmdir($d); + } + null === $renamed ?: rmdir($dir.$chars[$i]); + } + null === $renamed ?: rmdir($renamed); + } + } finally { + restore_error_handler(); + } + + return $ok; + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, ?int $lifetime, array $addTagData = [], array $removeTagData = []): array + { + $failed = $this->doSaveCache($values, $lifetime); + + // Add Tags as symlinks + foreach ($addTagData as $tagId => $ids) { + $tagFolder = $this->getTagFolder($tagId); + foreach ($ids as $id) { + if ($failed && \in_array($id, $failed, true)) { + continue; + } + + $file = $this->getFile($id); + + if (!@symlink($file, $this->getFile($id, true, $tagFolder))) { + @unlink($file); + $failed[] = $id; + } + } + } + + // Unlink removed Tags + foreach ($removeTagData as $tagId => $ids) { + $tagFolder = $this->getTagFolder($tagId); + foreach ($ids as $id) { + if ($failed && \in_array($id, $failed, true)) { + continue; + } + + @unlink($this->getFile($id, false, $tagFolder)); + } + } + + return $failed; + } + + /** + * {@inheritdoc} + */ + protected function doDeleteYieldTags(array $ids): iterable + { + foreach ($ids as $id) { + $file = $this->getFile($id); + if (!file_exists($file) || !$h = @fopen($file, 'rb')) { + continue; + } + + if ((\PHP_VERSION_ID >= 70300 || '\\' !== \DIRECTORY_SEPARATOR) && !@unlink($file)) { + fclose($h); + continue; + } + + $meta = explode("\n", fread($h, 4096), 3)[2] ?? ''; + + // detect the compact format used in marshall() using magic numbers in the form 9D-..-..-..-..-00-..-..-..-5F + if (13 < \strlen($meta) && "\x9D" === $meta[0] && "\0" === $meta[5] && "\x5F" === $meta[9]) { + $meta[9] = "\0"; + $tagLen = unpack('Nlen', $meta, 9)['len']; + $meta = substr($meta, 13, $tagLen); + + if (0 < $tagLen -= \strlen($meta)) { + $meta .= fread($h, $tagLen); + } + + try { + yield $id => '' === $meta ? [] : $this->marshaller->unmarshall($meta); + } catch (\Exception $e) { + yield $id => []; + } + } + + fclose($h); + + if (\PHP_VERSION_ID < 70300 && '\\' === \DIRECTORY_SEPARATOR) { + @unlink($file); + } + } + } + + /** + * {@inheritdoc} + */ + protected function doDeleteTagRelations(array $tagData): bool + { + foreach ($tagData as $tagId => $idList) { + $tagFolder = $this->getTagFolder($tagId); + foreach ($idList as $id) { + @unlink($this->getFile($id, false, $tagFolder)); + } + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function doInvalidate(array $tagIds): bool + { + foreach ($tagIds as $tagId) { + if (!file_exists($tagFolder = $this->getTagFolder($tagId))) { + continue; + } + + set_error_handler(static function () {}); + + try { + if (rename($tagFolder, $renamed = substr_replace($tagFolder, bin2hex(random_bytes(4)), -9))) { + $tagFolder = $renamed.\DIRECTORY_SEPARATOR; + } else { + $renamed = null; + } + + foreach ($this->scanHashDir($tagFolder) as $itemLink) { + unlink(realpath($itemLink) ?: $itemLink); + unlink($itemLink); + } + + if (null === $renamed) { + continue; + } + + $chars = '+-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + for ($i = 0; $i < 38; ++$i) { + for ($j = 0; $j < 38; ++$j) { + rmdir($tagFolder.$chars[$i].\DIRECTORY_SEPARATOR.$chars[$j]); + } + rmdir($tagFolder.$chars[$i]); + } + rmdir($renamed); + } finally { + restore_error_handler(); + } + } + + return true; + } + + private function getTagFolder(string $tagId): string + { + return $this->getFile($tagId, false, $this->directory.self::TAG_FOLDER.\DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/MemcachedAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/MemcachedAdapter.php new file mode 100644 index 0000000..b678bb5 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/MemcachedAdapter.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\Traits\MemcachedTrait; + +class MemcachedAdapter extends AbstractAdapter +{ + use MemcachedTrait; + + protected $maxIdLength = 250; + + /** + * Using a MemcachedAdapter with a TagAwareAdapter for storing tags is discouraged. + * Using a RedisAdapter is recommended instead. If you cannot do otherwise, be aware that: + * - the Memcached::OPT_BINARY_PROTOCOL must be enabled + * (that's the default when using MemcachedAdapter::createConnection()); + * - tags eviction by Memcached's LRU algorithm will break by-tags invalidation; + * your Memcached memory should be large enough to never trigger LRU. + * + * Using a MemcachedAdapter as a pure items store is fine. + */ + public function __construct(\Memcached $client, string $namespace = '', int $defaultLifetime = 0, MarshallerInterface $marshaller = null) + { + $this->init($client, $namespace, $defaultLifetime, $marshaller); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/NullAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/NullAdapter.php new file mode 100644 index 0000000..a2fdd36 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/NullAdapter.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Cache\CacheItemInterface; +use Symfony\Component\Cache\CacheItem; +use Symfony\Contracts\Cache\CacheInterface; + +/** + * @author Titouan Galopin + */ +class NullAdapter implements AdapterInterface, CacheInterface +{ + private $createCacheItem; + + public function __construct() + { + $this->createCacheItem = \Closure::bind( + function ($key) { + $item = new CacheItem(); + $item->key = $key; + $item->isHit = false; + + return $item; + }, + $this, + CacheItem::class + ); + } + + /** + * {@inheritdoc} + */ + public function get(string $key, callable $callback, float $beta = null, array &$metadata = null) + { + $save = true; + + return $callback(($this->createCacheItem)($key), $save); + } + + /** + * {@inheritdoc} + */ + public function getItem($key) + { + $f = $this->createCacheItem; + + return $f($key); + } + + /** + * {@inheritdoc} + */ + public function getItems(array $keys = []) + { + return $this->generateItems($keys); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function hasItem($key) + { + return false; + } + + /** + * {@inheritdoc} + * + * @param string $prefix + * + * @return bool + */ + public function clear(/*string $prefix = ''*/) + { + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItem($key) + { + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItems(array $keys) + { + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function save(CacheItemInterface $item) + { + return false; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function saveDeferred(CacheItemInterface $item) + { + return false; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function commit() + { + return false; + } + + /** + * {@inheritdoc} + */ + public function delete(string $key): bool + { + return $this->deleteItem($key); + } + + private function generateItems(array $keys) + { + $f = $this->createCacheItem; + + foreach ($keys as $key) { + yield $key => $f($key); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/PdoAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/PdoAdapter.php new file mode 100644 index 0000000..d118736 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/PdoAdapter.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Doctrine\DBAL\Connection; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\Traits\PdoTrait; + +class PdoAdapter extends AbstractAdapter implements PruneableInterface +{ + use PdoTrait; + + protected $maxIdLength = 255; + + /** + * You can either pass an existing database connection as PDO instance or + * a Doctrine DBAL Connection or a DSN string that will be used to + * lazy-connect to the database when the cache is actually used. + * + * When a Doctrine DBAL Connection is passed, the cache table is created + * automatically when possible. Otherwise, use the createTable() method. + * + * List of available options: + * * db_table: The name of the table [default: cache_items] + * * db_id_col: The column where to store the cache id [default: item_id] + * * db_data_col: The column where to store the cache data [default: item_data] + * * db_lifetime_col: The column where to store the lifetime [default: item_lifetime] + * * db_time_col: The column where to store the timestamp [default: item_time] + * * db_username: The username when lazy-connect [default: ''] + * * db_password: The password when lazy-connect [default: ''] + * * db_connection_options: An array of driver-specific connection options [default: []] + * + * @param \PDO|Connection|string $connOrDsn a \PDO or Connection instance or DSN string or null + * + * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string + * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION + * @throws InvalidArgumentException When namespace contains invalid characters + */ + public function __construct($connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], MarshallerInterface $marshaller = null) + { + $this->init($connOrDsn, $namespace, $defaultLifetime, $options, $marshaller); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/PhpArrayAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/PhpArrayAdapter.php new file mode 100644 index 0000000..b4f13d1 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/PhpArrayAdapter.php @@ -0,0 +1,332 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\ContractsTrait; +use Symfony\Component\Cache\Traits\PhpArrayTrait; +use Symfony\Contracts\Cache\CacheInterface; + +/** + * Caches items at warm up time using a PHP array that is stored in shared memory by OPCache since PHP 7.0. + * Warmed up items are read-only and run-time discovered items are cached using a fallback adapter. + * + * @author Titouan Galopin + * @author Nicolas Grekas + */ +class PhpArrayAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface +{ + use PhpArrayTrait; + use ContractsTrait; + + private $createCacheItem; + + /** + * @param string $file The PHP file were values are cached + * @param AdapterInterface $fallbackPool A pool to fallback on when an item is not hit + */ + public function __construct(string $file, AdapterInterface $fallbackPool) + { + $this->file = $file; + $this->pool = $fallbackPool; + $this->createCacheItem = \Closure::bind( + static function ($key, $value, $isHit) { + $item = new CacheItem(); + $item->key = $key; + $item->value = $value; + $item->isHit = $isHit; + + return $item; + }, + null, + CacheItem::class + ); + } + + /** + * This adapter takes advantage of how PHP stores arrays in its latest versions. + * + * @param string $file The PHP file were values are cached + * @param CacheItemPoolInterface $fallbackPool A pool to fallback on when an item is not hit + * + * @return CacheItemPoolInterface + */ + public static function create($file, CacheItemPoolInterface $fallbackPool) + { + if (!$fallbackPool instanceof AdapterInterface) { + $fallbackPool = new ProxyAdapter($fallbackPool); + } + + return new static($file, $fallbackPool); + } + + /** + * {@inheritdoc} + */ + public function get(string $key, callable $callback, float $beta = null, array &$metadata = null) + { + if (null === $this->values) { + $this->initialize(); + } + if (!isset($this->keys[$key])) { + get_from_pool: + if ($this->pool instanceof CacheInterface) { + return $this->pool->get($key, $callback, $beta, $metadata); + } + + return $this->doGet($this->pool, $key, $callback, $beta, $metadata); + } + $value = $this->values[$this->keys[$key]]; + + if ('N;' === $value) { + return null; + } + try { + if ($value instanceof \Closure) { + return $value(); + } + } catch (\Throwable $e) { + unset($this->keys[$key]); + goto get_from_pool; + } + + return $value; + } + + /** + * {@inheritdoc} + */ + public function getItem($key) + { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + if (null === $this->values) { + $this->initialize(); + } + if (!isset($this->keys[$key])) { + return $this->pool->getItem($key); + } + + $value = $this->values[$this->keys[$key]]; + $isHit = true; + + if ('N;' === $value) { + $value = null; + } elseif ($value instanceof \Closure) { + try { + $value = $value(); + } catch (\Throwable $e) { + $value = null; + $isHit = false; + } + } + + $f = $this->createCacheItem; + + return $f($key, $value, $isHit); + } + + /** + * {@inheritdoc} + */ + public function getItems(array $keys = []) + { + foreach ($keys as $key) { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + } + if (null === $this->values) { + $this->initialize(); + } + + return $this->generateItems($keys); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function hasItem($key) + { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + if (null === $this->values) { + $this->initialize(); + } + + return isset($this->keys[$key]) || $this->pool->hasItem($key); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItem($key) + { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + if (null === $this->values) { + $this->initialize(); + } + + return !isset($this->keys[$key]) && $this->pool->deleteItem($key); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItems(array $keys) + { + $deleted = true; + $fallbackKeys = []; + + foreach ($keys as $key) { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + + if (isset($this->keys[$key])) { + $deleted = false; + } else { + $fallbackKeys[] = $key; + } + } + if (null === $this->values) { + $this->initialize(); + } + + if ($fallbackKeys) { + $deleted = $this->pool->deleteItems($fallbackKeys) && $deleted; + } + + return $deleted; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function save(CacheItemInterface $item) + { + if (null === $this->values) { + $this->initialize(); + } + + return !isset($this->keys[$item->getKey()]) && $this->pool->save($item); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function saveDeferred(CacheItemInterface $item) + { + if (null === $this->values) { + $this->initialize(); + } + + return !isset($this->keys[$item->getKey()]) && $this->pool->saveDeferred($item); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function commit() + { + return $this->pool->commit(); + } + + private function generateItems(array $keys): \Generator + { + $f = $this->createCacheItem; + $fallbackKeys = []; + + foreach ($keys as $key) { + if (isset($this->keys[$key])) { + $value = $this->values[$this->keys[$key]]; + + if ('N;' === $value) { + yield $key => $f($key, null, true); + } elseif ($value instanceof \Closure) { + try { + yield $key => $f($key, $value(), true); + } catch (\Throwable $e) { + yield $key => $f($key, null, false); + } + } else { + yield $key => $f($key, $value, true); + } + } else { + $fallbackKeys[] = $key; + } + } + + if ($fallbackKeys) { + yield from $this->pool->getItems($fallbackKeys); + } + } + + /** + * @throws \ReflectionException When $class is not found and is required + * + * @internal to be removed in Symfony 5.0 + */ + public static function throwOnRequiredClass($class) + { + $e = new \ReflectionException("Class $class does not exist"); + $trace = debug_backtrace(); + $autoloadFrame = [ + 'function' => 'spl_autoload_call', + 'args' => [$class], + ]; + $i = 1 + array_search($autoloadFrame, $trace, true); + + if (isset($trace[$i]['function']) && !isset($trace[$i]['class'])) { + switch ($trace[$i]['function']) { + case 'get_class_methods': + case 'get_class_vars': + case 'get_parent_class': + case 'is_a': + case 'is_subclass_of': + case 'class_exists': + case 'class_implements': + case 'class_parents': + case 'trait_exists': + case 'defined': + case 'interface_exists': + case 'method_exists': + case 'property_exists': + case 'is_callable': + return; + } + } + + throw $e; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/PhpFilesAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/PhpFilesAdapter.php new file mode 100644 index 0000000..10938a0 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/PhpFilesAdapter.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Symfony\Component\Cache\Exception\CacheException; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\Traits\PhpFilesTrait; + +class PhpFilesAdapter extends AbstractAdapter implements PruneableInterface +{ + use PhpFilesTrait; + + /** + * @param $appendOnly Set to `true` to gain extra performance when the items stored in this pool never expire. + * Doing so is encouraged because it fits perfectly OPcache's memory model. + * + * @throws CacheException if OPcache is not enabled + */ + public function __construct(string $namespace = '', int $defaultLifetime = 0, string $directory = null, bool $appendOnly = false) + { + $this->appendOnly = $appendOnly; + self::$startTime = self::$startTime ?? $_SERVER['REQUEST_TIME'] ?? time(); + parent::__construct('', $defaultLifetime); + $this->init($namespace, $directory); + $this->includeHandler = static function ($type, $msg, $file, $line) { + throw new \ErrorException($msg, 0, $type, $file, $line); + }; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ProxyAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ProxyAdapter.php new file mode 100644 index 0000000..bccafb3 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/ProxyAdapter.php @@ -0,0 +1,269 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\ContractsTrait; +use Symfony\Component\Cache\Traits\ProxyTrait; +use Symfony\Contracts\Cache\CacheInterface; + +/** + * @author Nicolas Grekas + */ +class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface +{ + use ProxyTrait; + use ContractsTrait; + + private $namespace; + private $namespaceLen; + private $createCacheItem; + private $setInnerItem; + private $poolHash; + + public function __construct(CacheItemPoolInterface $pool, string $namespace = '', int $defaultLifetime = 0) + { + $this->pool = $pool; + $this->poolHash = $poolHash = spl_object_hash($pool); + $this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace); + $this->namespaceLen = \strlen($namespace); + $this->createCacheItem = \Closure::bind( + static function ($key, $innerItem) use ($defaultLifetime, $poolHash) { + $item = new CacheItem(); + $item->key = $key; + + if (null === $innerItem) { + return $item; + } + + $item->value = $v = $innerItem->get(); + $item->isHit = $innerItem->isHit(); + $item->innerItem = $innerItem; + $item->defaultLifetime = $defaultLifetime; + $item->poolHash = $poolHash; + + // Detect wrapped values that encode for their expiry and creation duration + // For compactness, these values are packed in the key of an array using + // magic numbers in the form 9D-..-..-..-..-00-..-..-..-5F + if (\is_array($v) && 1 === \count($v) && 10 === \strlen($k = key($v)) && "\x9D" === $k[0] && "\0" === $k[5] && "\x5F" === $k[9]) { + $item->value = $v[$k]; + $v = unpack('Ve/Nc', substr($k, 1, -1)); + $item->metadata[CacheItem::METADATA_EXPIRY] = $v['e'] + CacheItem::METADATA_EXPIRY_OFFSET; + $item->metadata[CacheItem::METADATA_CTIME] = $v['c']; + } elseif ($innerItem instanceof CacheItem) { + $item->metadata = $innerItem->metadata; + } + $innerItem->set(null); + + return $item; + }, + null, + CacheItem::class + ); + $this->setInnerItem = \Closure::bind( + /** + * @param array $item A CacheItem cast to (array); accessing protected properties requires adding the "\0*\0" PHP prefix + */ + static function (CacheItemInterface $innerItem, array $item) { + // Tags are stored separately, no need to account for them when considering this item's newly set metadata + if (isset(($metadata = $item["\0*\0newMetadata"])[CacheItem::METADATA_TAGS])) { + unset($metadata[CacheItem::METADATA_TAGS]); + } + if ($metadata) { + // For compactness, expiry and creation duration are packed in the key of an array, using magic numbers as separators + $item["\0*\0value"] = ["\x9D".pack('VN', (int) (0.1 + $metadata[self::METADATA_EXPIRY] - self::METADATA_EXPIRY_OFFSET), $metadata[self::METADATA_CTIME])."\x5F" => $item["\0*\0value"]]; + } + $innerItem->set($item["\0*\0value"]); + $innerItem->expiresAt(null !== $item["\0*\0expiry"] ? \DateTime::createFromFormat('U.u', sprintf('%.6f', $item["\0*\0expiry"])) : null); + }, + null, + CacheItem::class + ); + } + + /** + * {@inheritdoc} + */ + public function get(string $key, callable $callback, float $beta = null, array &$metadata = null) + { + if (!$this->pool instanceof CacheInterface) { + return $this->doGet($this, $key, $callback, $beta, $metadata); + } + + return $this->pool->get($this->getId($key), function ($innerItem, bool &$save) use ($key, $callback) { + $item = ($this->createCacheItem)($key, $innerItem); + $item->set($value = $callback($item, $save)); + ($this->setInnerItem)($innerItem, (array) $item); + + return $value; + }, $beta, $metadata); + } + + /** + * {@inheritdoc} + */ + public function getItem($key) + { + $f = $this->createCacheItem; + $item = $this->pool->getItem($this->getId($key)); + + return $f($key, $item); + } + + /** + * {@inheritdoc} + */ + public function getItems(array $keys = []) + { + if ($this->namespaceLen) { + foreach ($keys as $i => $key) { + $keys[$i] = $this->getId($key); + } + } + + return $this->generateItems($this->pool->getItems($keys)); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function hasItem($key) + { + return $this->pool->hasItem($this->getId($key)); + } + + /** + * {@inheritdoc} + * + * @param string $prefix + * + * @return bool + */ + public function clear(/*string $prefix = ''*/) + { + $prefix = 0 < \func_num_args() ? (string) func_get_arg(0) : ''; + + if ($this->pool instanceof AdapterInterface) { + return $this->pool->clear($this->namespace.$prefix); + } + + return $this->pool->clear(); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItem($key) + { + return $this->pool->deleteItem($this->getId($key)); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItems(array $keys) + { + if ($this->namespaceLen) { + foreach ($keys as $i => $key) { + $keys[$i] = $this->getId($key); + } + } + + return $this->pool->deleteItems($keys); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function save(CacheItemInterface $item) + { + return $this->doSave($item, __FUNCTION__); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function saveDeferred(CacheItemInterface $item) + { + return $this->doSave($item, __FUNCTION__); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function commit() + { + return $this->pool->commit(); + } + + private function doSave(CacheItemInterface $item, string $method) + { + if (!$item instanceof CacheItem) { + return false; + } + $item = (array) $item; + if (null === $item["\0*\0expiry"] && 0 < $item["\0*\0defaultLifetime"]) { + $item["\0*\0expiry"] = microtime(true) + $item["\0*\0defaultLifetime"]; + } + + if ($item["\0*\0poolHash"] === $this->poolHash && $item["\0*\0innerItem"]) { + $innerItem = $item["\0*\0innerItem"]; + } elseif ($this->pool instanceof AdapterInterface) { + // this is an optimization specific for AdapterInterface implementations + // so we can save a round-trip to the backend by just creating a new item + $f = $this->createCacheItem; + $innerItem = $f($this->namespace.$item["\0*\0key"], null); + } else { + $innerItem = $this->pool->getItem($this->namespace.$item["\0*\0key"]); + } + + ($this->setInnerItem)($innerItem, $item); + + return $this->pool->$method($innerItem); + } + + private function generateItems(iterable $items) + { + $f = $this->createCacheItem; + + foreach ($items as $key => $item) { + if ($this->namespaceLen) { + $key = substr($key, $this->namespaceLen); + } + + yield $key => $f($key, $item); + } + } + + private function getId($key): string + { + CacheItem::validateKey($key); + + return $this->namespace.$key; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/Psr16Adapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/Psr16Adapter.php new file mode 100644 index 0000000..bb38871 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/Psr16Adapter.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\SimpleCache\CacheInterface; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\ProxyTrait; + +/** + * Turns a PSR-16 cache into a PSR-6 one. + * + * @author Nicolas Grekas + */ +class Psr16Adapter extends AbstractAdapter implements PruneableInterface, ResettableInterface +{ + /** + * @internal + */ + protected const NS_SEPARATOR = '_'; + + use ProxyTrait; + + private $miss; + + public function __construct(CacheInterface $pool, string $namespace = '', int $defaultLifetime = 0) + { + parent::__construct($namespace, $defaultLifetime); + + $this->pool = $pool; + $this->miss = new \stdClass(); + } + + /** + * {@inheritdoc} + */ + protected function doFetch(array $ids) + { + foreach ($this->pool->getMultiple($ids, $this->miss) as $key => $value) { + if ($this->miss !== $value) { + yield $key => $value; + } + } + } + + /** + * {@inheritdoc} + */ + protected function doHave($id) + { + return $this->pool->has($id); + } + + /** + * {@inheritdoc} + */ + protected function doClear($namespace) + { + return $this->pool->clear(); + } + + /** + * {@inheritdoc} + */ + protected function doDelete(array $ids) + { + return $this->pool->deleteMultiple($ids); + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, $lifetime) + { + return $this->pool->setMultiple($values, 0 === $lifetime ? null : $lifetime); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/RedisAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/RedisAdapter.php new file mode 100644 index 0000000..5c49f7a --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/RedisAdapter.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\Traits\RedisTrait; + +class RedisAdapter extends AbstractAdapter +{ + use RedisTrait; + + /** + * @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisClient The redis client + * @param string $namespace The default namespace + * @param int $defaultLifetime The default lifetime + */ + public function __construct($redisClient, string $namespace = '', int $defaultLifetime = 0, MarshallerInterface $marshaller = null) + { + $this->init($redisClient, $namespace, $defaultLifetime, $marshaller); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/RedisTagAwareAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/RedisTagAwareAdapter.php new file mode 100644 index 0000000..f936afd --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/RedisTagAwareAdapter.php @@ -0,0 +1,292 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Predis\Connection\Aggregate\ClusterInterface; +use Predis\Connection\Aggregate\PredisCluster; +use Predis\Response\Status; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Marshaller\DeflateMarshaller; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\Marshaller\TagAwareMarshaller; +use Symfony\Component\Cache\Traits\RedisTrait; + +/** + * Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using RENAME+SMEMBERS. + * + * Set (tag relation info) is stored without expiry (non-volatile), while cache always gets an expiry (volatile) even + * if not set by caller. Thus if you configure redis with the right eviction policy you can be safe this tag <> cache + * relationship survives eviction (cache cleanup when Redis runs out of memory). + * + * Requirements: + * - Client: PHP Redis or Predis + * Note: Due to lack of RENAME support it is NOT recommended to use Cluster on Predis, instead use phpredis. + * - Server: Redis 2.8+ + * Configured with any `volatile-*` eviction policy, OR `noeviction` if it will NEVER fill up memory + * + * Design limitations: + * - Max 4 billion cache keys per cache tag as limited by Redis Set datatype. + * E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to 4 billion cache items also. + * + * @see https://redis.io/topics/lru-cache#eviction-policies Documentation for Redis eviction policies. + * @see https://redis.io/topics/data-types#sets Documentation for Redis Set datatype. + * + * @author Nicolas Grekas + * @author André Rømcke + */ +class RedisTagAwareAdapter extends AbstractTagAwareAdapter +{ + use RedisTrait; + + /** + * Limits for how many keys are deleted in batch. + */ + private const BULK_DELETE_LIMIT = 10000; + + /** + * On cache items without a lifetime set, we set it to 100 days. This is to make sure cache items are + * preferred to be evicted over tag Sets, if eviction policy is configured according to requirements. + */ + private const DEFAULT_CACHE_TTL = 8640000; + + /** + * @var string|null detected eviction policy used on Redis server + */ + private $redisEvictionPolicy; + + /** + * @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisClient The redis client + * @param string $namespace The default namespace + * @param int $defaultLifetime The default lifetime + */ + public function __construct($redisClient, string $namespace = '', int $defaultLifetime = 0, MarshallerInterface $marshaller = null) + { + if ($redisClient instanceof \Predis\ClientInterface && $redisClient->getConnection() instanceof ClusterInterface && !$redisClient->getConnection() instanceof PredisCluster) { + throw new InvalidArgumentException(sprintf('Unsupported Predis cluster connection: only "%s" is, "%s" given.', PredisCluster::class, \get_class($redisClient->getConnection()))); + } + + if (\defined('Redis::OPT_COMPRESSION') && ($redisClient instanceof \Redis || $redisClient instanceof \RedisArray || $redisClient instanceof \RedisCluster)) { + $compression = $redisClient->getOption(\Redis::OPT_COMPRESSION); + + foreach (\is_array($compression) ? $compression : [$compression] as $c) { + if (\Redis::COMPRESSION_NONE !== $c) { + throw new InvalidArgumentException(sprintf('phpredis compression must be disabled when using "%s", use "%s" instead.', static::class, DeflateMarshaller::class)); + } + } + } + + $this->init($redisClient, $namespace, $defaultLifetime, new TagAwareMarshaller($marshaller)); + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, ?int $lifetime, array $addTagData = [], array $delTagData = []): array + { + $eviction = $this->getRedisEvictionPolicy(); + if ('noeviction' !== $eviction && 0 !== strpos($eviction, 'volatile-')) { + CacheItem::log($this->logger, sprintf('Redis maxmemory-policy setting "%s" is *not* supported by RedisTagAwareAdapter, use "noeviction" or "volatile-*" eviction policies', $eviction)); + + return false; + } + + // serialize values + if (!$serialized = $this->marshaller->marshall($values, $failed)) { + return $failed; + } + + // While pipeline isn't supported on RedisCluster, other setups will at least benefit from doing this in one op + $results = $this->pipeline(static function () use ($serialized, $lifetime, $addTagData, $delTagData, $failed) { + // Store cache items, force a ttl if none is set, as there is no MSETEX we need to set each one + foreach ($serialized as $id => $value) { + yield 'setEx' => [ + $id, + 0 >= $lifetime ? self::DEFAULT_CACHE_TTL : $lifetime, + $value, + ]; + } + + // Add and Remove Tags + foreach ($addTagData as $tagId => $ids) { + if (!$failed || $ids = array_diff($ids, $failed)) { + yield 'sAdd' => array_merge([$tagId], $ids); + } + } + + foreach ($delTagData as $tagId => $ids) { + if (!$failed || $ids = array_diff($ids, $failed)) { + yield 'sRem' => array_merge([$tagId], $ids); + } + } + }); + + foreach ($results as $id => $result) { + // Skip results of SADD/SREM operations, they'll be 1 or 0 depending on if set value already existed or not + if (is_numeric($result)) { + continue; + } + // setEx results + if (true !== $result && (!$result instanceof Status || Status::get('OK') !== $result)) { + $failed[] = $id; + } + } + + return $failed; + } + + /** + * {@inheritdoc} + */ + protected function doDeleteYieldTags(array $ids): iterable + { + $lua = <<<'EOLUA' + local v = redis.call('GET', KEYS[1]) + redis.call('DEL', KEYS[1]) + + if not v or v:len() <= 13 or v:byte(1) ~= 0x9D or v:byte(6) ~= 0 or v:byte(10) ~= 0x5F then + return '' + end + + return v:sub(14, 13 + v:byte(13) + v:byte(12) * 256 + v:byte(11) * 65536) +EOLUA; + + if ($this->redis instanceof \Predis\ClientInterface) { + $evalArgs = [$lua, 1, &$id]; + } else { + $evalArgs = [$lua, [&$id], 1]; + } + + $results = $this->pipeline(function () use ($ids, &$id, $evalArgs) { + foreach ($ids as $id) { + yield 'eval' => $evalArgs; + } + }); + + foreach ($results as $id => $result) { + try { + yield $id => !\is_string($result) || '' === $result ? [] : $this->marshaller->unmarshall($result); + } catch (\Exception $e) { + yield $id => []; + } + } + } + + /** + * {@inheritdoc} + */ + protected function doDeleteTagRelations(array $tagData): bool + { + $this->pipeline(static function () use ($tagData) { + foreach ($tagData as $tagId => $idList) { + array_unshift($idList, $tagId); + yield 'sRem' => $idList; + } + })->rewind(); + + return true; + } + + /** + * {@inheritdoc} + */ + protected function doInvalidate(array $tagIds): bool + { + if (!$this->redis instanceof \Predis\ClientInterface || !$this->redis->getConnection() instanceof PredisCluster) { + $movedTagSetIds = $this->renameKeys($this->redis, $tagIds); + } else { + $clusterConnection = $this->redis->getConnection(); + $tagIdsByConnection = new \SplObjectStorage(); + $movedTagSetIds = []; + + foreach ($tagIds as $id) { + $connection = $clusterConnection->getConnectionByKey($id); + $slot = $tagIdsByConnection[$connection] ?? $tagIdsByConnection[$connection] = new \ArrayObject(); + $slot[] = $id; + } + + foreach ($tagIdsByConnection as $connection) { + $slot = $tagIdsByConnection[$connection]; + $movedTagSetIds = array_merge($movedTagSetIds, $this->renameKeys(new $this->redis($connection, $this->redis->getOptions()), $slot->getArrayCopy())); + } + } + + // No Sets found + if (!$movedTagSetIds) { + return false; + } + + // Now safely take the time to read the keys in each set and collect ids we need to delete + $tagIdSets = $this->pipeline(static function () use ($movedTagSetIds) { + foreach ($movedTagSetIds as $movedTagId) { + yield 'sMembers' => [$movedTagId]; + } + }); + + // Return combination of the temporary Tag Set ids and their values (cache ids) + $ids = array_merge($movedTagSetIds, ...iterator_to_array($tagIdSets, false)); + + // Delete cache in chunks to avoid overloading the connection + foreach (array_chunk(array_unique($ids), self::BULK_DELETE_LIMIT) as $chunkIds) { + $this->doDelete($chunkIds); + } + + return true; + } + + /** + * Renames several keys in order to be able to operate on them without risk of race conditions. + * + * Filters out keys that do not exist before returning new keys. + * + * @see https://redis.io/commands/rename + * @see https://redis.io/topics/cluster-spec#keys-hash-tags + * + * @return array Filtered list of the valid moved keys (only those that existed) + */ + private function renameKeys($redis, array $ids): array + { + $newIds = []; + $uniqueToken = bin2hex(random_bytes(10)); + + $results = $this->pipeline(static function () use ($ids, $uniqueToken) { + foreach ($ids as $id) { + yield 'rename' => [$id, '{'.$id.'}'.$uniqueToken]; + } + }, $redis); + + foreach ($results as $id => $result) { + if (true === $result || ($result instanceof Status && Status::get('OK') === $result)) { + // Only take into account if ok (key existed), will be false on phpredis if it did not exist + $newIds[] = '{'.$id.'}'.$uniqueToken; + } + } + + return $newIds; + } + + private function getRedisEvictionPolicy(): string + { + if (null !== $this->redisEvictionPolicy) { + return $this->redisEvictionPolicy; + } + + foreach ($this->getHosts() as $host) { + $info = $host->info('Memory'); + $info = isset($info['Memory']) ? $info['Memory'] : $info; + + return $this->redisEvictionPolicy = $info['maxmemory_policy']; + } + + return $this->redisEvictionPolicy = ''; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/SimpleCacheAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/SimpleCacheAdapter.php new file mode 100644 index 0000000..d0d42e5 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/SimpleCacheAdapter.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +@trigger_error(sprintf('The "%s" class is @deprecated since Symfony 4.3, use "Psr16Adapter" instead.', SimpleCacheAdapter::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use Psr16Adapter instead. + */ +class SimpleCacheAdapter extends Psr16Adapter +{ +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TagAwareAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TagAwareAdapter.php new file mode 100644 index 0000000..7c0ee30 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TagAwareAdapter.php @@ -0,0 +1,429 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Cache\CacheItemInterface; +use Psr\Cache\InvalidArgumentException; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\ContractsTrait; +use Symfony\Component\Cache\Traits\ProxyTrait; +use Symfony\Contracts\Cache\TagAwareCacheInterface; + +/** + * @author Nicolas Grekas + */ +class TagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterface, PruneableInterface, ResettableInterface +{ + const TAGS_PREFIX = "\0tags\0"; + + use ProxyTrait; + use ContractsTrait; + + private $deferred = []; + private $createCacheItem; + private $setCacheItemTags; + private $getTagsByKey; + private $invalidateTags; + private $tags; + private $knownTagVersions = []; + private $knownTagVersionsTtl; + + public function __construct(AdapterInterface $itemsPool, AdapterInterface $tagsPool = null, float $knownTagVersionsTtl = 0.15) + { + $this->pool = $itemsPool; + $this->tags = $tagsPool ?: $itemsPool; + $this->knownTagVersionsTtl = $knownTagVersionsTtl; + $this->createCacheItem = \Closure::bind( + static function ($key, $value, CacheItem $protoItem) { + $item = new CacheItem(); + $item->key = $key; + $item->value = $value; + $item->defaultLifetime = $protoItem->defaultLifetime; + $item->expiry = $protoItem->expiry; + $item->poolHash = $protoItem->poolHash; + + return $item; + }, + null, + CacheItem::class + ); + $this->setCacheItemTags = \Closure::bind( + static function (CacheItem $item, $key, array &$itemTags) { + $item->isTaggable = true; + if (!$item->isHit) { + return $item; + } + if (isset($itemTags[$key])) { + foreach ($itemTags[$key] as $tag => $version) { + $item->metadata[CacheItem::METADATA_TAGS][$tag] = $tag; + } + unset($itemTags[$key]); + } else { + $item->value = null; + $item->isHit = false; + } + + return $item; + }, + null, + CacheItem::class + ); + $this->getTagsByKey = \Closure::bind( + static function ($deferred) { + $tagsByKey = []; + foreach ($deferred as $key => $item) { + $tagsByKey[$key] = $item->newMetadata[CacheItem::METADATA_TAGS] ?? []; + } + + return $tagsByKey; + }, + null, + CacheItem::class + ); + $this->invalidateTags = \Closure::bind( + static function (AdapterInterface $tagsAdapter, array $tags) { + foreach ($tags as $v) { + $v->defaultLifetime = 0; + $v->expiry = null; + $tagsAdapter->saveDeferred($v); + } + + return $tagsAdapter->commit(); + }, + null, + CacheItem::class + ); + } + + /** + * {@inheritdoc} + */ + public function invalidateTags(array $tags) + { + $ok = true; + $tagsByKey = []; + $invalidatedTags = []; + foreach ($tags as $tag) { + CacheItem::validateKey($tag); + $invalidatedTags[$tag] = 0; + } + + if ($this->deferred) { + $items = $this->deferred; + foreach ($items as $key => $item) { + if (!$this->pool->saveDeferred($item)) { + unset($this->deferred[$key]); + $ok = false; + } + } + + $f = $this->getTagsByKey; + $tagsByKey = $f($items); + $this->deferred = []; + } + + $tagVersions = $this->getTagVersions($tagsByKey, $invalidatedTags); + $f = $this->createCacheItem; + + foreach ($tagsByKey as $key => $tags) { + $this->pool->saveDeferred($f(static::TAGS_PREFIX.$key, array_intersect_key($tagVersions, $tags), $items[$key])); + } + $ok = $this->pool->commit() && $ok; + + if ($invalidatedTags) { + $f = $this->invalidateTags; + $ok = $f($this->tags, $invalidatedTags) && $ok; + } + + return $ok; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function hasItem($key) + { + if ($this->deferred) { + $this->commit(); + } + if (!$this->pool->hasItem($key)) { + return false; + } + + $itemTags = $this->pool->getItem(static::TAGS_PREFIX.$key); + + if (!$itemTags->isHit()) { + return false; + } + + if (!$itemTags = $itemTags->get()) { + return true; + } + + foreach ($this->getTagVersions([$itemTags]) as $tag => $version) { + if ($itemTags[$tag] !== $version && 1 !== $itemTags[$tag] - $version) { + return false; + } + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function getItem($key) + { + foreach ($this->getItems([$key]) as $item) { + return $item; + } + + return null; + } + + /** + * {@inheritdoc} + */ + public function getItems(array $keys = []) + { + if ($this->deferred) { + $this->commit(); + } + $tagKeys = []; + + foreach ($keys as $key) { + if ('' !== $key && \is_string($key)) { + $key = static::TAGS_PREFIX.$key; + $tagKeys[$key] = $key; + } + } + + try { + $items = $this->pool->getItems($tagKeys + $keys); + } catch (InvalidArgumentException $e) { + $this->pool->getItems($keys); // Should throw an exception + + throw $e; + } + + return $this->generateItems($items, $tagKeys); + } + + /** + * {@inheritdoc} + * + * @param string $prefix + * + * @return bool + */ + public function clear(/*string $prefix = ''*/) + { + $prefix = 0 < \func_num_args() ? (string) func_get_arg(0) : ''; + + if ('' !== $prefix) { + foreach ($this->deferred as $key => $item) { + if (0 === strpos($key, $prefix)) { + unset($this->deferred[$key]); + } + } + } else { + $this->deferred = []; + } + + if ($this->pool instanceof AdapterInterface) { + return $this->pool->clear($prefix); + } + + return $this->pool->clear(); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItem($key) + { + return $this->deleteItems([$key]); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItems(array $keys) + { + foreach ($keys as $key) { + if ('' !== $key && \is_string($key)) { + $keys[] = static::TAGS_PREFIX.$key; + } + } + + return $this->pool->deleteItems($keys); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function save(CacheItemInterface $item) + { + if (!$item instanceof CacheItem) { + return false; + } + $this->deferred[$item->getKey()] = $item; + + return $this->commit(); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function saveDeferred(CacheItemInterface $item) + { + if (!$item instanceof CacheItem) { + return false; + } + $this->deferred[$item->getKey()] = $item; + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function commit() + { + return $this->invalidateTags([]); + } + + public function __sleep() + { + throw new \BadMethodCallException('Cannot serialize '.__CLASS__); + } + + public function __wakeup() + { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + + public function __destruct() + { + $this->commit(); + } + + private function generateItems(iterable $items, array $tagKeys) + { + $bufferedItems = $itemTags = []; + $f = $this->setCacheItemTags; + + foreach ($items as $key => $item) { + if (!$tagKeys) { + yield $key => $f($item, static::TAGS_PREFIX.$key, $itemTags); + continue; + } + if (!isset($tagKeys[$key])) { + $bufferedItems[$key] = $item; + continue; + } + + unset($tagKeys[$key]); + + if ($item->isHit()) { + $itemTags[$key] = $item->get() ?: []; + } + + if (!$tagKeys) { + $tagVersions = $this->getTagVersions($itemTags); + + foreach ($itemTags as $key => $tags) { + foreach ($tags as $tag => $version) { + if ($tagVersions[$tag] !== $version && 1 !== $version - $tagVersions[$tag]) { + unset($itemTags[$key]); + continue 2; + } + } + } + $tagVersions = $tagKeys = null; + + foreach ($bufferedItems as $key => $item) { + yield $key => $f($item, static::TAGS_PREFIX.$key, $itemTags); + } + $bufferedItems = null; + } + } + } + + private function getTagVersions(array $tagsByKey, array &$invalidatedTags = []) + { + $tagVersions = $invalidatedTags; + + foreach ($tagsByKey as $tags) { + $tagVersions += $tags; + } + + if (!$tagVersions) { + return []; + } + + if (!$fetchTagVersions = 1 !== \func_num_args()) { + foreach ($tagsByKey as $tags) { + foreach ($tags as $tag => $version) { + if ($tagVersions[$tag] > $version) { + $tagVersions[$tag] = $version; + } + } + } + } + + $now = microtime(true); + $tags = []; + foreach ($tagVersions as $tag => $version) { + $tags[$tag.static::TAGS_PREFIX] = $tag; + if ($fetchTagVersions || !isset($this->knownTagVersions[$tag])) { + $fetchTagVersions = true; + continue; + } + $version -= $this->knownTagVersions[$tag][1]; + if ((0 !== $version && 1 !== $version) || $now - $this->knownTagVersions[$tag][0] >= $this->knownTagVersionsTtl) { + // reuse previously fetched tag versions up to the ttl, unless we are storing items or a potential miss arises + $fetchTagVersions = true; + } else { + $this->knownTagVersions[$tag][1] += $version; + } + } + + if (!$fetchTagVersions) { + return $tagVersions; + } + + foreach ($this->tags->getItems(array_keys($tags)) as $tag => $version) { + $tagVersions[$tag = $tags[$tag]] = $version->get() ?: 0; + if (isset($invalidatedTags[$tag])) { + $invalidatedTags[$tag] = $version->set(++$tagVersions[$tag]); + } + $this->knownTagVersions[$tag] = [$now, $tagVersions[$tag]]; + } + + return $tagVersions; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TagAwareAdapterInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TagAwareAdapterInterface.php new file mode 100644 index 0000000..340048c --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TagAwareAdapterInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Cache\InvalidArgumentException; + +/** + * Interface for invalidating cached items using tags. + * + * @author Nicolas Grekas + */ +interface TagAwareAdapterInterface extends AdapterInterface +{ + /** + * Invalidates cached items using tags. + * + * @param string[] $tags An array of tags to invalidate + * + * @return bool True on success + * + * @throws InvalidArgumentException When $tags is not valid + */ + public function invalidateTags(array $tags); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TraceableAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TraceableAdapter.php new file mode 100644 index 0000000..7e9dac8 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TraceableAdapter.php @@ -0,0 +1,302 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Cache\CacheItemInterface; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Service\ResetInterface; + +/** + * An adapter that collects data about all cache calls. + * + * @author Aaron Scherer + * @author Tobias Nyholm + * @author Nicolas Grekas + */ +class TraceableAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface +{ + protected $pool; + private $calls = []; + + public function __construct(AdapterInterface $pool) + { + $this->pool = $pool; + } + + /** + * {@inheritdoc} + */ + public function get(string $key, callable $callback, float $beta = null, array &$metadata = null) + { + if (!$this->pool instanceof CacheInterface) { + throw new \BadMethodCallException(sprintf('Cannot call "%s::get()": this class doesn\'t implement "%s".', \get_class($this->pool), CacheInterface::class)); + } + + $isHit = true; + $callback = function (CacheItem $item, bool &$save) use ($callback, &$isHit) { + $isHit = $item->isHit(); + + return $callback($item, $save); + }; + + $event = $this->start(__FUNCTION__); + try { + $value = $this->pool->get($key, $callback, $beta, $metadata); + $event->result[$key] = \is_object($value) ? \get_class($value) : \gettype($value); + } finally { + $event->end = microtime(true); + } + if ($isHit) { + ++$event->hits; + } else { + ++$event->misses; + } + + return $value; + } + + /** + * {@inheritdoc} + */ + public function getItem($key) + { + $event = $this->start(__FUNCTION__); + try { + $item = $this->pool->getItem($key); + } finally { + $event->end = microtime(true); + } + if ($event->result[$key] = $item->isHit()) { + ++$event->hits; + } else { + ++$event->misses; + } + + return $item; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function hasItem($key) + { + $event = $this->start(__FUNCTION__); + try { + return $event->result[$key] = $this->pool->hasItem($key); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItem($key) + { + $event = $this->start(__FUNCTION__); + try { + return $event->result[$key] = $this->pool->deleteItem($key); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function save(CacheItemInterface $item) + { + $event = $this->start(__FUNCTION__); + try { + return $event->result[$item->getKey()] = $this->pool->save($item); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function saveDeferred(CacheItemInterface $item) + { + $event = $this->start(__FUNCTION__); + try { + return $event->result[$item->getKey()] = $this->pool->saveDeferred($item); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + */ + public function getItems(array $keys = []) + { + $event = $this->start(__FUNCTION__); + try { + $result = $this->pool->getItems($keys); + } finally { + $event->end = microtime(true); + } + $f = function () use ($result, $event) { + $event->result = []; + foreach ($result as $key => $item) { + if ($event->result[$key] = $item->isHit()) { + ++$event->hits; + } else { + ++$event->misses; + } + yield $key => $item; + } + }; + + return $f(); + } + + /** + * {@inheritdoc} + * + * @param string $prefix + * + * @return bool + */ + public function clear(/*string $prefix = ''*/) + { + $prefix = 0 < \func_num_args() ? (string) func_get_arg(0) : ''; + $event = $this->start(__FUNCTION__); + try { + if ($this->pool instanceof AdapterInterface) { + return $event->result = $this->pool->clear($prefix); + } + + return $event->result = $this->pool->clear(); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItems(array $keys) + { + $event = $this->start(__FUNCTION__); + $event->result['keys'] = $keys; + try { + return $event->result['result'] = $this->pool->deleteItems($keys); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function commit() + { + $event = $this->start(__FUNCTION__); + try { + return $event->result = $this->pool->commit(); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + */ + public function prune() + { + if (!$this->pool instanceof PruneableInterface) { + return false; + } + $event = $this->start(__FUNCTION__); + try { + return $event->result = $this->pool->prune(); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + */ + public function reset() + { + if (!$this->pool instanceof ResetInterface) { + return; + } + $event = $this->start(__FUNCTION__); + try { + $this->pool->reset(); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + */ + public function delete(string $key): bool + { + $event = $this->start(__FUNCTION__); + try { + return $event->result[$key] = $this->pool->deleteItem($key); + } finally { + $event->end = microtime(true); + } + } + + public function getCalls() + { + return $this->calls; + } + + public function clearCalls() + { + $this->calls = []; + } + + protected function start($name) + { + $this->calls[] = $event = new TraceableAdapterEvent(); + $event->name = $name; + $event->start = microtime(true); + + return $event; + } +} + +class TraceableAdapterEvent +{ + public $name; + public $start; + public $end; + public $result; + public $hits = 0; + public $misses = 0; +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TraceableTagAwareAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TraceableTagAwareAdapter.php new file mode 100644 index 0000000..69461b8 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Adapter/TraceableTagAwareAdapter.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Symfony\Contracts\Cache\TagAwareCacheInterface; + +/** + * @author Robin Chalas + */ +class TraceableTagAwareAdapter extends TraceableAdapter implements TagAwareAdapterInterface, TagAwareCacheInterface +{ + public function __construct(TagAwareAdapterInterface $pool) + { + parent::__construct($pool); + } + + /** + * {@inheritdoc} + */ + public function invalidateTags(array $tags) + { + $event = $this->start(__FUNCTION__); + try { + return $event->result = $this->pool->invalidateTags($tags); + } finally { + $event->end = microtime(true); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/CHANGELOG.md b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/CHANGELOG.md new file mode 100644 index 0000000..435eaf3 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/CHANGELOG.md @@ -0,0 +1,73 @@ +CHANGELOG +========= + +4.4.0 +----- + + * added support for connecting to Redis Sentinel clusters + * added argument `$prefix` to `AdapterInterface::clear()` + * improved `RedisTagAwareAdapter` to support Redis server >= 2.8 and up to 4B items per tag + * added `TagAwareMarshaller` for optimized data storage when using `AbstractTagAwareAdapter` + * added `DeflateMarshaller` to compress serialized values + * removed support for phpredis 4 `compression` + * [BC BREAK] `RedisTagAwareAdapter` is not compatible with `RedisCluster` from `Predis` anymore, use `phpredis` instead + * Marked the `CacheDataCollector` class as `@final`. + +4.3.0 +----- + + * removed `psr/simple-cache` dependency, run `composer require psr/simple-cache` if you need it + * deprecated all PSR-16 adapters, use `Psr16Cache` or `Symfony\Contracts\Cache\CacheInterface` implementations instead + * deprecated `SimpleCacheAdapter`, use `Psr16Adapter` instead + +4.2.0 +----- + + * added support for connecting to Redis clusters via DSN + * added support for configuring multiple Memcached servers via DSN + * added `MarshallerInterface` and `DefaultMarshaller` to allow changing the serializer and provide one that automatically uses igbinary when available + * implemented `CacheInterface`, which provides stampede protection via probabilistic early expiration and should become the preferred way to use a cache + * added sub-second expiry accuracy for backends that support it + * added support for phpredis 4 `compression` and `tcp_keepalive` options + * added automatic table creation when using Doctrine DBAL with PDO-based backends + * throw `LogicException` when `CacheItem::tag()` is called on an item coming from a non tag-aware pool + * deprecated `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead + * deprecated the `AbstractAdapter::unserialize()` and `AbstractCache::unserialize()` methods + * added `CacheCollectorPass` (originally in `FrameworkBundle`) + * added `CachePoolClearerPass` (originally in `FrameworkBundle`) + * added `CachePoolPass` (originally in `FrameworkBundle`) + * added `CachePoolPrunerPass` (originally in `FrameworkBundle`) + +3.4.0 +----- + + * added using options from Memcached DSN + * added PruneableInterface so PSR-6 or PSR-16 cache implementations can declare support for manual stale cache pruning + * added prune logic to FilesystemTrait, PhpFilesTrait, PdoTrait, TagAwareAdapter and ChainTrait + * now FilesystemAdapter, PhpFilesAdapter, FilesystemCache, PhpFilesCache, PdoAdapter, PdoCache, ChainAdapter, and + ChainCache implement PruneableInterface and support manual stale cache pruning + +3.3.0 +----- + + * added CacheItem::getPreviousTags() to get bound tags coming from the pool storage if any + * added PSR-16 "Simple Cache" implementations for all existing PSR-6 adapters + * added Psr6Cache and SimpleCacheAdapter for bidirectional interoperability between PSR-6 and PSR-16 + * added MemcachedAdapter (PSR-6) and MemcachedCache (PSR-16) + * added TraceableAdapter (PSR-6) and TraceableCache (PSR-16) + +3.2.0 +----- + + * added TagAwareAdapter for tags-based invalidation + * added PdoAdapter with PDO and Doctrine DBAL support + * added PhpArrayAdapter and PhpFilesAdapter for OPcache-backed shared memory storage (PHP 7+ only) + * added NullAdapter + +3.1.0 +----- + + * added the component with strict PSR-6 implementations + * added ApcuAdapter, ArrayAdapter, FilesystemAdapter and RedisAdapter + * added AbstractAdapter, ChainAdapter and ProxyAdapter + * added DoctrineAdapter and DoctrineProvider for bidirectional interoperability with Doctrine Cache diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/CacheItem.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/CacheItem.php new file mode 100644 index 0000000..00661e0 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/CacheItem.php @@ -0,0 +1,202 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Exception\LogicException; +use Symfony\Contracts\Cache\ItemInterface; + +/** + * @author Nicolas Grekas + */ +final class CacheItem implements ItemInterface +{ + private const METADATA_EXPIRY_OFFSET = 1527506807; + + protected $key; + protected $value; + protected $isHit = false; + protected $expiry; + protected $defaultLifetime; + protected $metadata = []; + protected $newMetadata = []; + protected $innerItem; + protected $poolHash; + protected $isTaggable = false; + + /** + * {@inheritdoc} + */ + public function getKey(): string + { + return $this->key; + } + + /** + * {@inheritdoc} + */ + public function get() + { + return $this->value; + } + + /** + * {@inheritdoc} + */ + public function isHit(): bool + { + return $this->isHit; + } + + /** + * {@inheritdoc} + * + * @return $this + */ + public function set($value): self + { + $this->value = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @return $this + */ + public function expiresAt($expiration): self + { + if (null === $expiration) { + $this->expiry = $this->defaultLifetime > 0 ? microtime(true) + $this->defaultLifetime : null; + } elseif ($expiration instanceof \DateTimeInterface) { + $this->expiry = (float) $expiration->format('U.u'); + } else { + throw new InvalidArgumentException(sprintf('Expiration date must implement DateTimeInterface or be null, "%s" given.', \is_object($expiration) ? \get_class($expiration) : \gettype($expiration))); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return $this + */ + public function expiresAfter($time): self + { + if (null === $time) { + $this->expiry = $this->defaultLifetime > 0 ? microtime(true) + $this->defaultLifetime : null; + } elseif ($time instanceof \DateInterval) { + $this->expiry = microtime(true) + \DateTime::createFromFormat('U', 0)->add($time)->format('U.u'); + } elseif (\is_int($time)) { + $this->expiry = $time + microtime(true); + } else { + throw new InvalidArgumentException(sprintf('Expiration date must be an integer, a DateInterval or null, "%s" given.', \is_object($time) ? \get_class($time) : \gettype($time))); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function tag($tags): ItemInterface + { + if (!$this->isTaggable) { + throw new LogicException(sprintf('Cache item "%s" comes from a non tag-aware pool: you cannot tag it.', $this->key)); + } + if (!is_iterable($tags)) { + $tags = [$tags]; + } + foreach ($tags as $tag) { + if (!\is_string($tag)) { + throw new InvalidArgumentException(sprintf('Cache tag must be string, "%s" given.', \is_object($tag) ? \get_class($tag) : \gettype($tag))); + } + if (isset($this->newMetadata[self::METADATA_TAGS][$tag])) { + continue; + } + if ('' === $tag) { + throw new InvalidArgumentException('Cache tag length must be greater than zero.'); + } + if (false !== strpbrk($tag, self::RESERVED_CHARACTERS)) { + throw new InvalidArgumentException(sprintf('Cache tag "%s" contains reserved characters "%s".', $tag, self::RESERVED_CHARACTERS)); + } + $this->newMetadata[self::METADATA_TAGS][$tag] = $tag; + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getMetadata(): array + { + return $this->metadata; + } + + /** + * Returns the list of tags bound to the value coming from the pool storage if any. + * + * @deprecated since Symfony 4.2, use the "getMetadata()" method instead. + */ + public function getPreviousTags(): array + { + @trigger_error(sprintf('The "%s()" method is deprecated since Symfony 4.2, use the "getMetadata()" method instead.', __METHOD__), E_USER_DEPRECATED); + + return $this->metadata[self::METADATA_TAGS] ?? []; + } + + /** + * Validates a cache key according to PSR-6. + * + * @param string $key The key to validate + * + * @throws InvalidArgumentException When $key is not valid + */ + public static function validateKey($key): string + { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + if ('' === $key) { + throw new InvalidArgumentException('Cache key length must be greater than zero.'); + } + if (false !== strpbrk($key, self::RESERVED_CHARACTERS)) { + throw new InvalidArgumentException(sprintf('Cache key "%s" contains reserved characters "%s".', $key, self::RESERVED_CHARACTERS)); + } + + return $key; + } + + /** + * Internal logging helper. + * + * @internal + */ + public static function log(?LoggerInterface $logger, string $message, array $context = []) + { + if ($logger) { + $logger->warning($message, $context); + } else { + $replace = []; + foreach ($context as $k => $v) { + if (is_scalar($v)) { + $replace['{'.$k.'}'] = $v; + } + } + @trigger_error(strtr($message, $replace), E_USER_WARNING); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DataCollector/CacheDataCollector.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DataCollector/CacheDataCollector.php new file mode 100644 index 0000000..ed86fca --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DataCollector/CacheDataCollector.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\DataCollector; + +use Symfony\Component\Cache\Adapter\TraceableAdapter; +use Symfony\Component\Cache\Adapter\TraceableAdapterEvent; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; + +/** + * @author Aaron Scherer + * @author Tobias Nyholm + * + * @final since Symfony 4.4 + */ +class CacheDataCollector extends DataCollector implements LateDataCollectorInterface +{ + /** + * @var TraceableAdapter[] + */ + private $instances = []; + + /** + * @param string $name + */ + public function addInstance($name, TraceableAdapter $instance) + { + $this->instances[$name] = $instance; + } + + /** + * {@inheritdoc} + * + * @param \Throwable|null $exception + */ + public function collect(Request $request, Response $response/*, \Throwable $exception = null*/) + { + $empty = ['calls' => [], 'config' => [], 'options' => [], 'statistics' => []]; + $this->data = ['instances' => $empty, 'total' => $empty]; + foreach ($this->instances as $name => $instance) { + $this->data['instances']['calls'][$name] = $instance->getCalls(); + } + + $this->data['instances']['statistics'] = $this->calculateStatistics(); + $this->data['total']['statistics'] = $this->calculateTotalStatistics(); + } + + public function reset() + { + $this->data = []; + foreach ($this->instances as $instance) { + $instance->clearCalls(); + } + } + + public function lateCollect() + { + $this->data['instances']['calls'] = $this->cloneVar($this->data['instances']['calls']); + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'cache'; + } + + /** + * Method returns amount of logged Cache reads: "get" calls. + * + * @return array + */ + public function getStatistics() + { + return $this->data['instances']['statistics']; + } + + /** + * Method returns the statistic totals. + * + * @return array + */ + public function getTotals() + { + return $this->data['total']['statistics']; + } + + /** + * Method returns all logged Cache call objects. + * + * @return mixed + */ + public function getCalls() + { + return $this->data['instances']['calls']; + } + + private function calculateStatistics(): array + { + $statistics = []; + foreach ($this->data['instances']['calls'] as $name => $calls) { + $statistics[$name] = [ + 'calls' => 0, + 'time' => 0, + 'reads' => 0, + 'writes' => 0, + 'deletes' => 0, + 'hits' => 0, + 'misses' => 0, + ]; + /** @var TraceableAdapterEvent $call */ + foreach ($calls as $call) { + ++$statistics[$name]['calls']; + $statistics[$name]['time'] += $call->end - $call->start; + if ('get' === $call->name) { + ++$statistics[$name]['reads']; + if ($call->hits) { + ++$statistics[$name]['hits']; + } else { + ++$statistics[$name]['misses']; + ++$statistics[$name]['writes']; + } + } elseif ('getItem' === $call->name) { + ++$statistics[$name]['reads']; + if ($call->hits) { + ++$statistics[$name]['hits']; + } else { + ++$statistics[$name]['misses']; + } + } elseif ('getItems' === $call->name) { + $statistics[$name]['reads'] += $call->hits + $call->misses; + $statistics[$name]['hits'] += $call->hits; + $statistics[$name]['misses'] += $call->misses; + } elseif ('hasItem' === $call->name) { + ++$statistics[$name]['reads']; + if (false === $call->result) { + ++$statistics[$name]['misses']; + } else { + ++$statistics[$name]['hits']; + } + } elseif ('save' === $call->name) { + ++$statistics[$name]['writes']; + } elseif ('deleteItem' === $call->name) { + ++$statistics[$name]['deletes']; + } + } + if ($statistics[$name]['reads']) { + $statistics[$name]['hit_read_ratio'] = round(100 * $statistics[$name]['hits'] / $statistics[$name]['reads'], 2); + } else { + $statistics[$name]['hit_read_ratio'] = null; + } + } + + return $statistics; + } + + private function calculateTotalStatistics(): array + { + $statistics = $this->getStatistics(); + $totals = [ + 'calls' => 0, + 'time' => 0, + 'reads' => 0, + 'writes' => 0, + 'deletes' => 0, + 'hits' => 0, + 'misses' => 0, + ]; + foreach ($statistics as $name => $values) { + foreach ($totals as $key => $value) { + $totals[$key] += $statistics[$name][$key]; + } + } + if ($totals['reads']) { + $totals['hit_read_ratio'] = round(100 * $totals['hits'] / $totals['reads'], 2); + } else { + $totals['hit_read_ratio'] = null; + } + + return $totals; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CacheCollectorPass.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CacheCollectorPass.php new file mode 100644 index 0000000..6193d34 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CacheCollectorPass.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\DependencyInjection; + +use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; +use Symfony\Component\Cache\Adapter\TraceableAdapter; +use Symfony\Component\Cache\Adapter\TraceableTagAwareAdapter; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Inject a data collector to all the cache services to be able to get detailed statistics. + * + * @author Tobias Nyholm + */ +class CacheCollectorPass implements CompilerPassInterface +{ + private $dataCollectorCacheId; + private $cachePoolTag; + private $cachePoolRecorderInnerSuffix; + + public function __construct(string $dataCollectorCacheId = 'data_collector.cache', string $cachePoolTag = 'cache.pool', string $cachePoolRecorderInnerSuffix = '.recorder_inner') + { + $this->dataCollectorCacheId = $dataCollectorCacheId; + $this->cachePoolTag = $cachePoolTag; + $this->cachePoolRecorderInnerSuffix = $cachePoolRecorderInnerSuffix; + } + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition($this->dataCollectorCacheId)) { + return; + } + + $collectorDefinition = $container->getDefinition($this->dataCollectorCacheId); + foreach ($container->findTaggedServiceIds($this->cachePoolTag) as $id => $attributes) { + $definition = $container->getDefinition($id); + if ($definition->isAbstract()) { + continue; + } + + $recorder = new Definition(is_subclass_of($definition->getClass(), TagAwareAdapterInterface::class) ? TraceableTagAwareAdapter::class : TraceableAdapter::class); + $recorder->setTags($definition->getTags()); + $recorder->setPublic($definition->isPublic()); + $recorder->setArguments([new Reference($innerId = $id.$this->cachePoolRecorderInnerSuffix)]); + + $definition->setTags([]); + $definition->setPublic(false); + + $container->setDefinition($innerId, $definition); + $container->setDefinition($id, $recorder); + + // Tell the collector to add the new instance + $collectorDefinition->addMethodCall('addInstance', [$id, new Reference($id)]); + $collectorDefinition->setPublic(false); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CachePoolClearerPass.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CachePoolClearerPass.php new file mode 100644 index 0000000..3ca89a3 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CachePoolClearerPass.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @author Nicolas Grekas + */ +class CachePoolClearerPass implements CompilerPassInterface +{ + private $cachePoolClearerTag; + + public function __construct(string $cachePoolClearerTag = 'cache.pool.clearer') + { + $this->cachePoolClearerTag = $cachePoolClearerTag; + } + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container) + { + $container->getParameterBag()->remove('cache.prefix.seed'); + + foreach ($container->findTaggedServiceIds($this->cachePoolClearerTag) as $id => $attr) { + $clearer = $container->getDefinition($id); + $pools = []; + foreach ($clearer->getArgument(0) as $name => $ref) { + if ($container->hasDefinition($ref)) { + $pools[$name] = new Reference($ref); + } + } + $clearer->replaceArgument(0, $pools); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CachePoolPass.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CachePoolPass.php new file mode 100644 index 0000000..f52d027 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CachePoolPass.php @@ -0,0 +1,227 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\DependencyInjection; + +use Symfony\Component\Cache\Adapter\AbstractAdapter; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\ChainAdapter; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @author Nicolas Grekas + */ +class CachePoolPass implements CompilerPassInterface +{ + private $cachePoolTag; + private $kernelResetTag; + private $cacheClearerId; + private $cachePoolClearerTag; + private $cacheSystemClearerId; + private $cacheSystemClearerTag; + + public function __construct(string $cachePoolTag = 'cache.pool', string $kernelResetTag = 'kernel.reset', string $cacheClearerId = 'cache.global_clearer', string $cachePoolClearerTag = 'cache.pool.clearer', string $cacheSystemClearerId = 'cache.system_clearer', string $cacheSystemClearerTag = 'kernel.cache_clearer') + { + $this->cachePoolTag = $cachePoolTag; + $this->kernelResetTag = $kernelResetTag; + $this->cacheClearerId = $cacheClearerId; + $this->cachePoolClearerTag = $cachePoolClearerTag; + $this->cacheSystemClearerId = $cacheSystemClearerId; + $this->cacheSystemClearerTag = $cacheSystemClearerTag; + } + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container) + { + if ($container->hasParameter('cache.prefix.seed')) { + $seed = '.'.$container->getParameterBag()->resolveValue($container->getParameter('cache.prefix.seed')); + } else { + $seed = '_'.$container->getParameter('kernel.project_dir'); + } + $seed .= '.'.$container->getParameter('kernel.container_class'); + + $allPools = []; + $clearers = []; + $attributes = [ + 'provider', + 'name', + 'namespace', + 'default_lifetime', + 'reset', + ]; + foreach ($container->findTaggedServiceIds($this->cachePoolTag) as $id => $tags) { + $adapter = $pool = $container->getDefinition($id); + if ($pool->isAbstract()) { + continue; + } + $class = $adapter->getClass(); + while ($adapter instanceof ChildDefinition) { + $adapter = $container->findDefinition($adapter->getParent()); + $class = $class ?: $adapter->getClass(); + if ($t = $adapter->getTag($this->cachePoolTag)) { + $tags[0] += $t[0]; + } + } + $name = $tags[0]['name'] ?? $id; + if (!isset($tags[0]['namespace'])) { + $namespaceSeed = $seed; + if (null !== $class) { + $namespaceSeed .= '.'.$class; + } + + $tags[0]['namespace'] = $this->getNamespace($namespaceSeed, $name); + } + if (isset($tags[0]['clearer'])) { + $clearer = $tags[0]['clearer']; + while ($container->hasAlias($clearer)) { + $clearer = (string) $container->getAlias($clearer); + } + } else { + $clearer = null; + } + unset($tags[0]['clearer'], $tags[0]['name']); + + if (isset($tags[0]['provider'])) { + $tags[0]['provider'] = new Reference(static::getServiceProvider($container, $tags[0]['provider'])); + } + + if (ChainAdapter::class === $class) { + $adapters = []; + foreach ($adapter->getArgument(0) as $provider => $adapter) { + if ($adapter instanceof ChildDefinition) { + $chainedPool = $adapter; + } else { + $chainedPool = $adapter = new ChildDefinition($adapter); + } + + $chainedTags = [\is_int($provider) ? [] : ['provider' => $provider]]; + $chainedClass = ''; + + while ($adapter instanceof ChildDefinition) { + $adapter = $container->findDefinition($adapter->getParent()); + $chainedClass = $chainedClass ?: $adapter->getClass(); + if ($t = $adapter->getTag($this->cachePoolTag)) { + $chainedTags[0] += $t[0]; + } + } + + if (ChainAdapter::class === $chainedClass) { + throw new InvalidArgumentException(sprintf('Invalid service "%s": chain of adapters cannot reference another chain, found "%s".', $id, $chainedPool->getParent())); + } + + $i = 0; + + if (isset($chainedTags[0]['provider'])) { + $chainedPool->replaceArgument($i++, new Reference(static::getServiceProvider($container, $chainedTags[0]['provider']))); + } + + if (isset($tags[0]['namespace']) && ArrayAdapter::class !== $adapter->getClass()) { + $chainedPool->replaceArgument($i++, $tags[0]['namespace']); + } + + if (isset($tags[0]['default_lifetime'])) { + $chainedPool->replaceArgument($i++, $tags[0]['default_lifetime']); + } + + $adapters[] = $chainedPool; + } + + $pool->replaceArgument(0, $adapters); + unset($tags[0]['provider'], $tags[0]['namespace']); + $i = 1; + } else { + $i = 0; + } + + foreach ($attributes as $attr) { + if (!isset($tags[0][$attr])) { + // no-op + } elseif ('reset' === $attr) { + if ($tags[0][$attr]) { + $pool->addTag($this->kernelResetTag, ['method' => $tags[0][$attr]]); + } + } elseif ('namespace' !== $attr || ArrayAdapter::class !== $class) { + $pool->replaceArgument($i++, $tags[0][$attr]); + } + unset($tags[0][$attr]); + } + if (!empty($tags[0])) { + throw new InvalidArgumentException(sprintf('Invalid "%s" tag for service "%s": accepted attributes are "clearer", "provider", "name", "namespace", "default_lifetime" and "reset", found "%s".', $this->cachePoolTag, $id, implode('", "', array_keys($tags[0])))); + } + + if (null !== $clearer) { + $clearers[$clearer][$name] = new Reference($id, $container::IGNORE_ON_UNINITIALIZED_REFERENCE); + } + + $allPools[$name] = new Reference($id, $container::IGNORE_ON_UNINITIALIZED_REFERENCE); + } + + $notAliasedCacheClearerId = $this->cacheClearerId; + while ($container->hasAlias($this->cacheClearerId)) { + $this->cacheClearerId = (string) $container->getAlias($this->cacheClearerId); + } + if ($container->hasDefinition($this->cacheClearerId)) { + $clearers[$notAliasedCacheClearerId] = $allPools; + } + + foreach ($clearers as $id => $pools) { + $clearer = $container->getDefinition($id); + if ($clearer instanceof ChildDefinition) { + $clearer->replaceArgument(0, $pools); + } else { + $clearer->setArgument(0, $pools); + } + $clearer->addTag($this->cachePoolClearerTag); + + if ($this->cacheSystemClearerId === $id) { + $clearer->addTag($this->cacheSystemClearerTag); + } + } + + if ($container->hasDefinition('console.command.cache_pool_list')) { + $container->getDefinition('console.command.cache_pool_list')->replaceArgument(0, array_keys($allPools)); + } + } + + private function getNamespace(string $seed, string $id) + { + return substr(str_replace('/', '-', base64_encode(hash('sha256', $id.$seed, true))), 0, 10); + } + + /** + * @internal + */ + public static function getServiceProvider(ContainerBuilder $container, $name) + { + $container->resolveEnvPlaceholders($name, null, $usedEnvs); + + if ($usedEnvs || preg_match('#^[a-z]++:#', $name)) { + $dsn = $name; + + if (!$container->hasDefinition($name = '.cache_connection.'.ContainerBuilder::hash($dsn))) { + $definition = new Definition(AbstractAdapter::class); + $definition->setPublic(false); + $definition->setFactory([AbstractAdapter::class, 'createConnection']); + $definition->setArguments([$dsn, ['lazy' => true]]); + $container->setDefinition($name, $definition); + } + } + + return $name; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CachePoolPrunerPass.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CachePoolPrunerPass.php new file mode 100644 index 0000000..e569962 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DependencyInjection/CachePoolPrunerPass.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\DependencyInjection; + +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @author Rob Frawley 2nd + */ +class CachePoolPrunerPass implements CompilerPassInterface +{ + private $cacheCommandServiceId; + private $cachePoolTag; + + public function __construct(string $cacheCommandServiceId = 'console.command.cache_pool_prune', string $cachePoolTag = 'cache.pool') + { + $this->cacheCommandServiceId = $cacheCommandServiceId; + $this->cachePoolTag = $cachePoolTag; + } + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition($this->cacheCommandServiceId)) { + return; + } + + $services = []; + + foreach ($container->findTaggedServiceIds($this->cachePoolTag) as $id => $tags) { + $class = $container->getParameterBag()->resolveValue($container->getDefinition($id)->getClass()); + + if (!$reflection = $container->getReflectionClass($class)) { + throw new InvalidArgumentException(sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); + } + + if ($reflection->implementsInterface(PruneableInterface::class)) { + $services[$id] = new Reference($id); + } + } + + $container->getDefinition($this->cacheCommandServiceId)->replaceArgument(0, new IteratorArgument($services)); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DoctrineProvider.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DoctrineProvider.php new file mode 100644 index 0000000..d7e0bca --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/DoctrineProvider.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache; + +use Doctrine\Common\Cache\CacheProvider; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Contracts\Service\ResetInterface; + +/** + * @author Nicolas Grekas + */ +class DoctrineProvider extends CacheProvider implements PruneableInterface, ResettableInterface +{ + private $pool; + + public function __construct(CacheItemPoolInterface $pool) + { + $this->pool = $pool; + } + + /** + * {@inheritdoc} + */ + public function prune() + { + return $this->pool instanceof PruneableInterface && $this->pool->prune(); + } + + /** + * {@inheritdoc} + */ + public function reset() + { + if ($this->pool instanceof ResetInterface) { + $this->pool->reset(); + } + $this->setNamespace($this->getNamespace()); + } + + /** + * {@inheritdoc} + */ + protected function doFetch($id) + { + $item = $this->pool->getItem(rawurlencode($id)); + + return $item->isHit() ? $item->get() : false; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + protected function doContains($id) + { + return $this->pool->hasItem(rawurlencode($id)); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + protected function doSave($id, $data, $lifeTime = 0) + { + $item = $this->pool->getItem(rawurlencode($id)); + + if (0 < $lifeTime) { + $item->expiresAfter($lifeTime); + } + + return $this->pool->save($item->set($data)); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + protected function doDelete($id) + { + return $this->pool->deleteItem(rawurlencode($id)); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + protected function doFlush() + { + return $this->pool->clear(); + } + + /** + * {@inheritdoc} + * + * @return array|null + */ + protected function doGetStats() + { + return null; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Exception/CacheException.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Exception/CacheException.php new file mode 100644 index 0000000..d2e975b --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Exception/CacheException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Exception; + +use Psr\Cache\CacheException as Psr6CacheInterface; +use Psr\SimpleCache\CacheException as SimpleCacheInterface; + +if (interface_exists(SimpleCacheInterface::class)) { + class CacheException extends \Exception implements Psr6CacheInterface, SimpleCacheInterface + { + } +} else { + class CacheException extends \Exception implements Psr6CacheInterface + { + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Exception/InvalidArgumentException.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..7f9584a --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Exception/InvalidArgumentException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Exception; + +use Psr\Cache\InvalidArgumentException as Psr6CacheInterface; +use Psr\SimpleCache\InvalidArgumentException as SimpleCacheInterface; + +if (interface_exists(SimpleCacheInterface::class)) { + class InvalidArgumentException extends \InvalidArgumentException implements Psr6CacheInterface, SimpleCacheInterface + { + } +} else { + class InvalidArgumentException extends \InvalidArgumentException implements Psr6CacheInterface + { + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Exception/LogicException.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Exception/LogicException.php new file mode 100644 index 0000000..9ffa7ed --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Exception/LogicException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Exception; + +use Psr\Cache\CacheException as Psr6CacheInterface; +use Psr\SimpleCache\CacheException as SimpleCacheInterface; + +if (interface_exists(SimpleCacheInterface::class)) { + class LogicException extends \LogicException implements Psr6CacheInterface, SimpleCacheInterface + { + } +} else { + class LogicException extends \LogicException implements Psr6CacheInterface + { + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/LICENSE b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/LICENSE new file mode 100644 index 0000000..a7ec708 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2016-2020 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/LockRegistry.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/LockRegistry.php new file mode 100644 index 0000000..80601be --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/LockRegistry.php @@ -0,0 +1,154 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache; + +use Psr\Log\LoggerInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; + +/** + * LockRegistry is used internally by existing adapters to protect against cache stampede. + * + * It does so by wrapping the computation of items in a pool of locks. + * Foreach each apps, there can be at most 20 concurrent processes that + * compute items at the same time and only one per cache-key. + * + * @author Nicolas Grekas + */ +final class LockRegistry +{ + private static $openedFiles = []; + private static $lockedFiles = []; + + /** + * The number of items in this list controls the max number of concurrent processes. + */ + private static $files = [ + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'AbstractAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'AbstractTagAwareAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'AdapterInterface.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ApcuAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ArrayAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ChainAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'DoctrineAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'FilesystemAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'FilesystemTagAwareAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'MemcachedAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'NullAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PdoAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PhpArrayAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PhpFilesAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ProxyAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'Psr16Adapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'RedisAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'RedisTagAwareAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'SimpleCacheAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'TagAwareAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'TagAwareAdapterInterface.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'TraceableAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'TraceableTagAwareAdapter.php', + ]; + + /** + * Defines a set of existing files that will be used as keys to acquire locks. + * + * @return array The previously defined set of files + */ + public static function setFiles(array $files): array + { + $previousFiles = self::$files; + self::$files = $files; + + foreach (self::$openedFiles as $file) { + if ($file) { + flock($file, LOCK_UN); + fclose($file); + } + } + self::$openedFiles = self::$lockedFiles = []; + + return $previousFiles; + } + + public static function compute(callable $callback, ItemInterface $item, bool &$save, CacheInterface $pool, \Closure $setMetadata = null, LoggerInterface $logger = null) + { + $key = self::$files ? crc32($item->getKey()) % \count(self::$files) : -1; + + if ($key < 0 || (self::$lockedFiles[$key] ?? false) || !$lock = self::open($key)) { + return $callback($item, $save); + } + + while (true) { + try { + // race to get the lock in non-blocking mode + $locked = flock($lock, LOCK_EX | LOCK_NB, $wouldBlock); + + if ($locked || !$wouldBlock) { + $logger && $logger->info(sprintf('Lock %s, now computing item "{key}"', $locked ? 'acquired' : 'not supported'), ['key' => $item->getKey()]); + self::$lockedFiles[$key] = true; + + $value = $callback($item, $save); + + if ($save) { + if ($setMetadata) { + $setMetadata($item); + } + + $pool->save($item->set($value)); + $save = false; + } + + return $value; + } + // if we failed the race, retry locking in blocking mode to wait for the winner + $logger && $logger->info('Item "{key}" is locked, waiting for it to be released', ['key' => $item->getKey()]); + flock($lock, LOCK_SH); + } finally { + flock($lock, LOCK_UN); + unset(self::$lockedFiles[$key]); + } + static $signalingException, $signalingCallback; + $signalingException = $signalingException ?? unserialize("O:9:\"Exception\":1:{s:16:\"\0Exception\0trace\";a:0:{}}"); + $signalingCallback = $signalingCallback ?? function () use ($signalingException) { throw $signalingException; }; + + try { + $value = $pool->get($item->getKey(), $signalingCallback, 0); + $logger && $logger->info('Item "{key}" retrieved after lock was released', ['key' => $item->getKey()]); + $save = false; + + return $value; + } catch (\Exception $e) { + if ($signalingException !== $e) { + throw $e; + } + $logger && $logger->info('Item "{key}" not found while lock was released, now retrying', ['key' => $item->getKey()]); + } + } + + return null; + } + + private static function open(int $key) + { + if (null !== $h = self::$openedFiles[$key] ?? null) { + return $h; + } + set_error_handler(function () {}); + try { + $h = fopen(self::$files[$key], 'r+'); + } finally { + restore_error_handler(); + } + + return self::$openedFiles[$key] = $h ?: @fopen(self::$files[$key], 'r'); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/DefaultMarshaller.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/DefaultMarshaller.php new file mode 100644 index 0000000..f4cad03 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/DefaultMarshaller.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Marshaller; + +use Symfony\Component\Cache\Exception\CacheException; + +/** + * Serializes/unserializes values using igbinary_serialize() if available, serialize() otherwise. + * + * @author Nicolas Grekas + */ +class DefaultMarshaller implements MarshallerInterface +{ + private $useIgbinarySerialize = true; + + public function __construct(bool $useIgbinarySerialize = null) + { + if (null === $useIgbinarySerialize) { + $useIgbinarySerialize = \extension_loaded('igbinary') && (\PHP_VERSION_ID < 70400 || version_compare('3.1.0', phpversion('igbinary'), '<=')); + } elseif ($useIgbinarySerialize && (!\extension_loaded('igbinary') || (\PHP_VERSION_ID >= 70400 && version_compare('3.1.0', phpversion('igbinary'), '>')))) { + throw new CacheException(\extension_loaded('igbinary') && \PHP_VERSION_ID >= 70400 ? 'Please upgrade the "igbinary" PHP extension to v3.1 or higher.' : 'The "igbinary" PHP extension is not loaded.'); + } + $this->useIgbinarySerialize = $useIgbinarySerialize; + } + + /** + * {@inheritdoc} + */ + public function marshall(array $values, ?array &$failed): array + { + $serialized = $failed = []; + + foreach ($values as $id => $value) { + try { + if ($this->useIgbinarySerialize) { + $serialized[$id] = igbinary_serialize($value); + } else { + $serialized[$id] = serialize($value); + } + } catch (\Exception $e) { + $failed[] = $id; + } + } + + return $serialized; + } + + /** + * {@inheritdoc} + */ + public function unmarshall(string $value) + { + if ('b:0;' === $value) { + return false; + } + if ('N;' === $value) { + return null; + } + static $igbinaryNull; + if ($value === ($igbinaryNull ?? $igbinaryNull = \extension_loaded('igbinary') && (\PHP_VERSION_ID < 70400 || version_compare('3.1.0', phpversion('igbinary'), '<=')) ? igbinary_serialize(null) : false)) { + return null; + } + $unserializeCallbackHandler = ini_set('unserialize_callback_func', __CLASS__.'::handleUnserializeCallback'); + try { + if (':' === ($value[1] ?? ':')) { + if (false !== $value = unserialize($value)) { + return $value; + } + } elseif (false === $igbinaryNull) { + throw new \RuntimeException('Failed to unserialize values, did you forget to install the "igbinary" extension?'); + } elseif (null !== $value = igbinary_unserialize($value)) { + return $value; + } + + throw new \DomainException(error_get_last() ? error_get_last()['message'] : 'Failed to unserialize values.'); + } catch (\Error $e) { + throw new \ErrorException($e->getMessage(), $e->getCode(), E_ERROR, $e->getFile(), $e->getLine()); + } finally { + ini_set('unserialize_callback_func', $unserializeCallbackHandler); + } + } + + /** + * @internal + */ + public static function handleUnserializeCallback($class) + { + throw new \DomainException('Class not found: '.$class); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/DeflateMarshaller.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/DeflateMarshaller.php new file mode 100644 index 0000000..5544806 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/DeflateMarshaller.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Marshaller; + +use Symfony\Component\Cache\Exception\CacheException; + +/** + * Compresses values using gzdeflate(). + * + * @author Nicolas Grekas + */ +class DeflateMarshaller implements MarshallerInterface +{ + private $marshaller; + + public function __construct(MarshallerInterface $marshaller) + { + if (!\function_exists('gzdeflate')) { + throw new CacheException('The "zlib" PHP extension is not loaded.'); + } + + $this->marshaller = $marshaller; + } + + /** + * {@inheritdoc} + */ + public function marshall(array $values, ?array &$failed): array + { + return array_map('gzdeflate', $this->marshaller->marshall($values, $failed)); + } + + /** + * {@inheritdoc} + */ + public function unmarshall(string $value) + { + if (false !== $inflatedValue = @gzinflate($value)) { + $value = $inflatedValue; + } + + return $this->marshaller->unmarshall($value); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/MarshallerInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/MarshallerInterface.php new file mode 100644 index 0000000..cdd6c40 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/MarshallerInterface.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Marshaller; + +/** + * Serializes/unserializes PHP values. + * + * Implementations of this interface MUST deal with errors carefully. They MUST + * also deal with forward and backward compatibility at the storage format level. + * + * @author Nicolas Grekas + */ +interface MarshallerInterface +{ + /** + * Serializes a list of values. + * + * When serialization fails for a specific value, no exception should be + * thrown. Instead, its key should be listed in $failed. + */ + public function marshall(array $values, ?array &$failed): array; + + /** + * Unserializes a single value and throws an exception if anything goes wrong. + * + * @return mixed + * + * @throws \Exception Whenever unserialization fails + */ + public function unmarshall(string $value); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/TagAwareMarshaller.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/TagAwareMarshaller.php new file mode 100644 index 0000000..5d1e303 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Marshaller/TagAwareMarshaller.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Marshaller; + +/** + * A marshaller optimized for data structures generated by AbstractTagAwareAdapter. + * + * @author Nicolas Grekas + */ +class TagAwareMarshaller implements MarshallerInterface +{ + private $marshaller; + + public function __construct(MarshallerInterface $marshaller = null) + { + $this->marshaller = $marshaller ?? new DefaultMarshaller(); + } + + /** + * {@inheritdoc} + */ + public function marshall(array $values, ?array &$failed): array + { + $failed = $notSerialized = $serialized = []; + + foreach ($values as $id => $value) { + if (\is_array($value) && \is_array($value['tags'] ?? null) && \array_key_exists('value', $value) && \count($value) === 2 + (\is_string($value['meta'] ?? null) && 8 === \strlen($value['meta']))) { + // if the value is an array with keys "tags", "value" and "meta", use a compact serialization format + // magic numbers in the form 9D-..-..-..-..-00-..-..-..-5F allow detecting this format quickly in unmarshall() + + $v = $this->marshaller->marshall($value, $f); + + if ($f) { + $f = []; + $failed[] = $id; + } else { + if ([] === $value['tags']) { + $v['tags'] = ''; + } + + $serialized[$id] = "\x9D".($value['meta'] ?? "\0\0\0\0\0\0\0\0").pack('N', \strlen($v['tags'])).$v['tags'].$v['value']; + $serialized[$id][9] = "\x5F"; + } + } else { + // other arbitratry values are serialized using the decorated marshaller below + $notSerialized[$id] = $value; + } + } + + if ($notSerialized) { + $serialized += $this->marshaller->marshall($notSerialized, $f); + $failed = array_merge($failed, $f); + } + + return $serialized; + } + + /** + * {@inheritdoc} + */ + public function unmarshall(string $value) + { + // detect the compact format used in marshall() using magic numbers in the form 9D-..-..-..-..-00-..-..-..-5F + if (13 >= \strlen($value) || "\x9D" !== $value[0] || "\0" !== $value[5] || "\x5F" !== $value[9]) { + return $this->marshaller->unmarshall($value); + } + + // data consists of value, tags and metadata which we need to unpack + $meta = substr($value, 1, 12); + $meta[8] = "\0"; + $tagLen = unpack('Nlen', $meta, 8)['len']; + $meta = substr($meta, 0, 8); + + return [ + 'value' => $this->marshaller->unmarshall(substr($value, 13 + $tagLen)), + 'tags' => $tagLen ? $this->marshaller->unmarshall(substr($value, 13, $tagLen)) : [], + 'meta' => "\0\0\0\0\0\0\0\0" === $meta ? null : $meta, + ]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/PruneableInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/PruneableInterface.php new file mode 100644 index 0000000..4261525 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/PruneableInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache; + +/** + * Interface extends psr-6 and psr-16 caches to allow for pruning (deletion) of all expired cache items. + */ +interface PruneableInterface +{ + /** + * @return bool + */ + public function prune(); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Psr16Cache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Psr16Cache.php new file mode 100644 index 0000000..7358bf5 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Psr16Cache.php @@ -0,0 +1,277 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache; + +use Psr\Cache\CacheException as Psr6CacheException; +use Psr\Cache\CacheItemPoolInterface; +use Psr\SimpleCache\CacheException as SimpleCacheException; +use Psr\SimpleCache\CacheInterface; +use Symfony\Component\Cache\Adapter\AdapterInterface; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Traits\ProxyTrait; + +/** + * Turns a PSR-6 cache into a PSR-16 one. + * + * @author Nicolas Grekas + */ +class Psr16Cache implements CacheInterface, PruneableInterface, ResettableInterface +{ + use ProxyTrait; + + private const METADATA_EXPIRY_OFFSET = 1527506807; + + private $createCacheItem; + private $cacheItemPrototype; + + public function __construct(CacheItemPoolInterface $pool) + { + $this->pool = $pool; + + if (!$pool instanceof AdapterInterface) { + return; + } + $cacheItemPrototype = &$this->cacheItemPrototype; + $createCacheItem = \Closure::bind( + static function ($key, $value, $allowInt = false) use (&$cacheItemPrototype) { + $item = clone $cacheItemPrototype; + $item->key = $allowInt && \is_int($key) ? (string) $key : CacheItem::validateKey($key); + $item->value = $value; + $item->isHit = false; + + return $item; + }, + null, + CacheItem::class + ); + $this->createCacheItem = function ($key, $value, $allowInt = false) use ($createCacheItem) { + if (null === $this->cacheItemPrototype) { + $this->get($allowInt && \is_int($key) ? (string) $key : $key); + } + $this->createCacheItem = $createCacheItem; + + return $createCacheItem($key, null, $allowInt)->set($value); + }; + } + + /** + * {@inheritdoc} + */ + public function get($key, $default = null) + { + try { + $item = $this->pool->getItem($key); + } catch (SimpleCacheException $e) { + throw $e; + } catch (Psr6CacheException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } + if (null === $this->cacheItemPrototype) { + $this->cacheItemPrototype = clone $item; + $this->cacheItemPrototype->set(null); + } + + return $item->isHit() ? $item->get() : $default; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function set($key, $value, $ttl = null) + { + try { + if (null !== $f = $this->createCacheItem) { + $item = $f($key, $value); + } else { + $item = $this->pool->getItem($key)->set($value); + } + } catch (SimpleCacheException $e) { + throw $e; + } catch (Psr6CacheException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } + if (null !== $ttl) { + $item->expiresAfter($ttl); + } + + return $this->pool->save($item); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function delete($key) + { + try { + return $this->pool->deleteItem($key); + } catch (SimpleCacheException $e) { + throw $e; + } catch (Psr6CacheException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function clear() + { + return $this->pool->clear(); + } + + /** + * {@inheritdoc} + * + * @return iterable + */ + public function getMultiple($keys, $default = null) + { + if ($keys instanceof \Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!\is_array($keys)) { + throw new InvalidArgumentException(sprintf('Cache keys must be array or Traversable, "%s" given.', \is_object($keys) ? \get_class($keys) : \gettype($keys))); + } + + try { + $items = $this->pool->getItems($keys); + } catch (SimpleCacheException $e) { + throw $e; + } catch (Psr6CacheException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } + $values = []; + + if (!$this->pool instanceof AdapterInterface) { + foreach ($items as $key => $item) { + $values[$key] = $item->isHit() ? $item->get() : $default; + } + + return $values; + } + + foreach ($items as $key => $item) { + if (!$item->isHit()) { + $values[$key] = $default; + continue; + } + $values[$key] = $item->get(); + + if (!$metadata = $item->getMetadata()) { + continue; + } + unset($metadata[CacheItem::METADATA_TAGS]); + + if ($metadata) { + $values[$key] = ["\x9D".pack('VN', (int) (0.1 + $metadata[CacheItem::METADATA_EXPIRY] - self::METADATA_EXPIRY_OFFSET), $metadata[CacheItem::METADATA_CTIME])."\x5F" => $values[$key]]; + } + } + + return $values; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function setMultiple($values, $ttl = null) + { + $valuesIsArray = \is_array($values); + if (!$valuesIsArray && !$values instanceof \Traversable) { + throw new InvalidArgumentException(sprintf('Cache values must be array or Traversable, "%s" given.', \is_object($values) ? \get_class($values) : \gettype($values))); + } + $items = []; + + try { + if (null !== $f = $this->createCacheItem) { + $valuesIsArray = false; + foreach ($values as $key => $value) { + $items[$key] = $f($key, $value, true); + } + } elseif ($valuesIsArray) { + $items = []; + foreach ($values as $key => $value) { + $items[] = (string) $key; + } + $items = $this->pool->getItems($items); + } else { + foreach ($values as $key => $value) { + if (\is_int($key)) { + $key = (string) $key; + } + $items[$key] = $this->pool->getItem($key)->set($value); + } + } + } catch (SimpleCacheException $e) { + throw $e; + } catch (Psr6CacheException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } + $ok = true; + + foreach ($items as $key => $item) { + if ($valuesIsArray) { + $item->set($values[$key]); + } + if (null !== $ttl) { + $item->expiresAfter($ttl); + } + $ok = $this->pool->saveDeferred($item) && $ok; + } + + return $this->pool->commit() && $ok; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteMultiple($keys) + { + if ($keys instanceof \Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!\is_array($keys)) { + throw new InvalidArgumentException(sprintf('Cache keys must be array or Traversable, "%s" given.', \is_object($keys) ? \get_class($keys) : \gettype($keys))); + } + + try { + return $this->pool->deleteItems($keys); + } catch (SimpleCacheException $e) { + throw $e; + } catch (Psr6CacheException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function has($key) + { + try { + return $this->pool->hasItem($key); + } catch (SimpleCacheException $e) { + throw $e; + } catch (Psr6CacheException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/README.md b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/README.md new file mode 100644 index 0000000..c4ab752 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/README.md @@ -0,0 +1,18 @@ +Symfony PSR-6 implementation for caching +======================================== + +This component provides an extended [PSR-6](http://www.php-fig.org/psr/psr-6/) +implementation for adding cache to your applications. It is designed to have a +low overhead so that caching is fastest. It ships with a few caching adapters +for the most widespread and suited to caching backends. It also provides a +`doctrine/cache` proxy adapter to cover more advanced caching needs and a proxy +adapter for greater interoperability between PSR-6 implementations. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/cache.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/ResettableInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/ResettableInterface.php new file mode 100644 index 0000000..7b0a853 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/ResettableInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache; + +use Symfony\Contracts\Service\ResetInterface; + +/** + * Resets a pool's local state. + */ +interface ResettableInterface extends ResetInterface +{ +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/AbstractCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/AbstractCache.php new file mode 100644 index 0000000..85bbeb6 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/AbstractCache.php @@ -0,0 +1,199 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Psr\Log\LoggerAwareInterface; +use Psr\SimpleCache\CacheInterface as Psr16CacheInterface; +use Symfony\Component\Cache\Adapter\AbstractAdapter; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\AbstractTrait; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', AbstractCache::class, AbstractAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use AbstractAdapter and type-hint for CacheInterface instead. + */ +abstract class AbstractCache implements Psr16CacheInterface, LoggerAwareInterface, ResettableInterface +{ + /** + * @internal + */ + protected const NS_SEPARATOR = ':'; + + use AbstractTrait { + deleteItems as private; + AbstractTrait::deleteItem as delete; + AbstractTrait::hasItem as has; + } + + private $defaultLifetime; + + protected function __construct(string $namespace = '', int $defaultLifetime = 0) + { + $this->defaultLifetime = max(0, $defaultLifetime); + $this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace).':'; + if (null !== $this->maxIdLength && \strlen($namespace) > $this->maxIdLength - 24) { + throw new InvalidArgumentException(sprintf('Namespace must be %d chars max, %d given ("%s").', $this->maxIdLength - 24, \strlen($namespace), $namespace)); + } + } + + /** + * {@inheritdoc} + */ + public function get($key, $default = null) + { + $id = $this->getId($key); + + try { + foreach ($this->doFetch([$id]) as $value) { + return $value; + } + } catch (\Exception $e) { + CacheItem::log($this->logger, 'Failed to fetch key "{key}": '.$e->getMessage(), ['key' => $key, 'exception' => $e]); + } + + return $default; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function set($key, $value, $ttl = null) + { + CacheItem::validateKey($key); + + return $this->setMultiple([$key => $value], $ttl); + } + + /** + * {@inheritdoc} + * + * @return iterable + */ + public function getMultiple($keys, $default = null) + { + if ($keys instanceof \Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!\is_array($keys)) { + throw new InvalidArgumentException(sprintf('Cache keys must be array or Traversable, "%s" given.', \is_object($keys) ? \get_class($keys) : \gettype($keys))); + } + $ids = []; + + foreach ($keys as $key) { + $ids[] = $this->getId($key); + } + try { + $values = $this->doFetch($ids); + } catch (\Exception $e) { + CacheItem::log($this->logger, 'Failed to fetch values: '.$e->getMessage(), ['keys' => $keys, 'exception' => $e]); + $values = []; + } + $ids = array_combine($ids, $keys); + + return $this->generateValues($values, $ids, $default); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function setMultiple($values, $ttl = null) + { + if (!\is_array($values) && !$values instanceof \Traversable) { + throw new InvalidArgumentException(sprintf('Cache values must be array or Traversable, "%s" given.', \is_object($values) ? \get_class($values) : \gettype($values))); + } + $valuesById = []; + + foreach ($values as $key => $value) { + if (\is_int($key)) { + $key = (string) $key; + } + $valuesById[$this->getId($key)] = $value; + } + if (false === $ttl = $this->normalizeTtl($ttl)) { + return $this->doDelete(array_keys($valuesById)); + } + + try { + $e = $this->doSave($valuesById, $ttl); + } catch (\Exception $e) { + } + if (true === $e || [] === $e) { + return true; + } + $keys = []; + foreach (\is_array($e) ? $e : array_keys($valuesById) as $id) { + $keys[] = substr($id, \strlen($this->namespace)); + } + $message = 'Failed to save values'.($e instanceof \Exception ? ': '.$e->getMessage() : '.'); + CacheItem::log($this->logger, $message, ['keys' => $keys, 'exception' => $e instanceof \Exception ? $e : null]); + + return false; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteMultiple($keys) + { + if ($keys instanceof \Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!\is_array($keys)) { + throw new InvalidArgumentException(sprintf('Cache keys must be array or Traversable, "%s" given.', \is_object($keys) ? \get_class($keys) : \gettype($keys))); + } + + return $this->deleteItems($keys); + } + + private function normalizeTtl($ttl) + { + if (null === $ttl) { + return $this->defaultLifetime; + } + if ($ttl instanceof \DateInterval) { + $ttl = (int) \DateTime::createFromFormat('U', 0)->add($ttl)->format('U'); + } + if (\is_int($ttl)) { + return 0 < $ttl ? $ttl : false; + } + + throw new InvalidArgumentException(sprintf('Expiration date must be an integer, a DateInterval or null, "%s" given.', \is_object($ttl) ? \get_class($ttl) : \gettype($ttl))); + } + + private function generateValues(iterable $values, array &$keys, $default): iterable + { + try { + foreach ($values as $id => $value) { + if (!isset($keys[$id])) { + $id = key($keys); + } + $key = $keys[$id]; + unset($keys[$id]); + yield $key => $value; + } + } catch (\Exception $e) { + CacheItem::log($this->logger, 'Failed to fetch values: '.$e->getMessage(), ['keys' => array_values($keys), 'exception' => $e]); + } + + foreach ($keys as $key) { + yield $key => $default; + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/ApcuCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/ApcuCache.php new file mode 100644 index 0000000..f1eb946 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/ApcuCache.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Symfony\Component\Cache\Adapter\ApcuAdapter; +use Symfony\Component\Cache\Traits\ApcuTrait; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', ApcuCache::class, ApcuAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use ApcuAdapter and type-hint for CacheInterface instead. + */ +class ApcuCache extends AbstractCache +{ + use ApcuTrait; + + public function __construct(string $namespace = '', int $defaultLifetime = 0, string $version = null) + { + $this->init($namespace, $defaultLifetime, $version); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/ArrayCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/ArrayCache.php new file mode 100644 index 0000000..a3d4c67 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/ArrayCache.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Psr\Log\LoggerAwareInterface; +use Psr\SimpleCache\CacheInterface as Psr16CacheInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\ArrayTrait; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', ArrayCache::class, ArrayAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use ArrayAdapter and type-hint for CacheInterface instead. + */ +class ArrayCache implements Psr16CacheInterface, LoggerAwareInterface, ResettableInterface +{ + use ArrayTrait { + ArrayTrait::deleteItem as delete; + ArrayTrait::hasItem as has; + } + + private $defaultLifetime; + + /** + * @param bool $storeSerialized Disabling serialization can lead to cache corruptions when storing mutable values but increases performance otherwise + */ + public function __construct(int $defaultLifetime = 0, bool $storeSerialized = true) + { + $this->defaultLifetime = $defaultLifetime; + $this->storeSerialized = $storeSerialized; + } + + /** + * {@inheritdoc} + */ + public function get($key, $default = null) + { + if (!\is_string($key) || !isset($this->expiries[$key])) { + CacheItem::validateKey($key); + } + if (!$isHit = isset($this->expiries[$key]) && ($this->expiries[$key] > microtime(true) || !$this->delete($key))) { + $this->values[$key] = null; + + return $default; + } + if (!$this->storeSerialized) { + return $this->values[$key]; + } + $value = $this->unfreeze($key, $isHit); + + return $isHit ? $value : $default; + } + + /** + * {@inheritdoc} + * + * @return iterable + */ + public function getMultiple($keys, $default = null) + { + if ($keys instanceof \Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!\is_array($keys)) { + throw new InvalidArgumentException(sprintf('Cache keys must be array or Traversable, "%s" given.', \is_object($keys) ? \get_class($keys) : \gettype($keys))); + } + foreach ($keys as $key) { + if (!\is_string($key) || !isset($this->expiries[$key])) { + CacheItem::validateKey($key); + } + } + + return $this->generateItems($keys, microtime(true), function ($k, $v, $hit) use ($default) { return $hit ? $v : $default; }); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteMultiple($keys) + { + if (!\is_array($keys) && !$keys instanceof \Traversable) { + throw new InvalidArgumentException(sprintf('Cache keys must be array or Traversable, "%s" given.', \is_object($keys) ? \get_class($keys) : \gettype($keys))); + } + foreach ($keys as $key) { + $this->delete($key); + } + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function set($key, $value, $ttl = null) + { + if (!\is_string($key)) { + CacheItem::validateKey($key); + } + + return $this->setMultiple([$key => $value], $ttl); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function setMultiple($values, $ttl = null) + { + if (!\is_array($values) && !$values instanceof \Traversable) { + throw new InvalidArgumentException(sprintf('Cache values must be array or Traversable, "%s" given.', \is_object($values) ? \get_class($values) : \gettype($values))); + } + $valuesArray = []; + + foreach ($values as $key => $value) { + if (!\is_int($key) && !(\is_string($key) && isset($this->expiries[$key]))) { + CacheItem::validateKey($key); + } + $valuesArray[$key] = $value; + } + if (false === $ttl = $this->normalizeTtl($ttl)) { + return $this->deleteMultiple(array_keys($valuesArray)); + } + $expiry = 0 < $ttl ? microtime(true) + $ttl : PHP_INT_MAX; + + foreach ($valuesArray as $key => $value) { + if ($this->storeSerialized && null === $value = $this->freeze($value, $key)) { + return false; + } + $this->values[$key] = $value; + $this->expiries[$key] = $expiry; + } + + return true; + } + + private function normalizeTtl($ttl) + { + if (null === $ttl) { + return $this->defaultLifetime; + } + if ($ttl instanceof \DateInterval) { + $ttl = (int) \DateTime::createFromFormat('U', 0)->add($ttl)->format('U'); + } + if (\is_int($ttl)) { + return 0 < $ttl ? $ttl : false; + } + + throw new InvalidArgumentException(sprintf('Expiration date must be an integer, a DateInterval or null, "%s" given.', \is_object($ttl) ? \get_class($ttl) : \gettype($ttl))); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/ChainCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/ChainCache.php new file mode 100644 index 0000000..57a169f --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/ChainCache.php @@ -0,0 +1,271 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Psr\SimpleCache\CacheInterface as Psr16CacheInterface; +use Symfony\Component\Cache\Adapter\ChainAdapter; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Service\ResetInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', ChainCache::class, ChainAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * Chains several caches together. + * + * Cached items are fetched from the first cache having them in its data store. + * They are saved and deleted in all caches at once. + * + * @deprecated since Symfony 4.3, use ChainAdapter and type-hint for CacheInterface instead. + */ +class ChainCache implements Psr16CacheInterface, PruneableInterface, ResettableInterface +{ + private $miss; + private $caches = []; + private $defaultLifetime; + private $cacheCount; + + /** + * @param Psr16CacheInterface[] $caches The ordered list of caches used to fetch cached items + * @param int $defaultLifetime The lifetime of items propagated from lower caches to upper ones + */ + public function __construct(array $caches, int $defaultLifetime = 0) + { + if (!$caches) { + throw new InvalidArgumentException('At least one cache must be specified.'); + } + + foreach ($caches as $cache) { + if (!$cache instanceof Psr16CacheInterface) { + throw new InvalidArgumentException(sprintf('The class "%s" does not implement the "%s" interface.', \get_class($cache), Psr16CacheInterface::class)); + } + } + + $this->miss = new \stdClass(); + $this->caches = array_values($caches); + $this->cacheCount = \count($this->caches); + $this->defaultLifetime = 0 < $defaultLifetime ? $defaultLifetime : null; + } + + /** + * {@inheritdoc} + */ + public function get($key, $default = null) + { + $miss = null !== $default && \is_object($default) ? $default : $this->miss; + + foreach ($this->caches as $i => $cache) { + $value = $cache->get($key, $miss); + + if ($miss !== $value) { + while (0 <= --$i) { + $this->caches[$i]->set($key, $value, $this->defaultLifetime); + } + + return $value; + } + } + + return $default; + } + + /** + * {@inheritdoc} + * + * @return iterable + */ + public function getMultiple($keys, $default = null) + { + $miss = null !== $default && \is_object($default) ? $default : $this->miss; + + return $this->generateItems($this->caches[0]->getMultiple($keys, $miss), 0, $miss, $default); + } + + private function generateItems(iterable $values, int $cacheIndex, $miss, $default): iterable + { + $missing = []; + $nextCacheIndex = $cacheIndex + 1; + $nextCache = isset($this->caches[$nextCacheIndex]) ? $this->caches[$nextCacheIndex] : null; + + foreach ($values as $k => $value) { + if ($miss !== $value) { + yield $k => $value; + } elseif (!$nextCache) { + yield $k => $default; + } else { + $missing[] = $k; + } + } + + if ($missing) { + $cache = $this->caches[$cacheIndex]; + $values = $this->generateItems($nextCache->getMultiple($missing, $miss), $nextCacheIndex, $miss, $default); + + foreach ($values as $k => $value) { + if ($miss !== $value) { + $cache->set($k, $value, $this->defaultLifetime); + yield $k => $value; + } else { + yield $k => $default; + } + } + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function has($key) + { + foreach ($this->caches as $cache) { + if ($cache->has($key)) { + return true; + } + } + + return false; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function clear() + { + $cleared = true; + $i = $this->cacheCount; + + while ($i--) { + $cleared = $this->caches[$i]->clear() && $cleared; + } + + return $cleared; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function delete($key) + { + $deleted = true; + $i = $this->cacheCount; + + while ($i--) { + $deleted = $this->caches[$i]->delete($key) && $deleted; + } + + return $deleted; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteMultiple($keys) + { + if ($keys instanceof \Traversable) { + $keys = iterator_to_array($keys, false); + } + $deleted = true; + $i = $this->cacheCount; + + while ($i--) { + $deleted = $this->caches[$i]->deleteMultiple($keys) && $deleted; + } + + return $deleted; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function set($key, $value, $ttl = null) + { + $saved = true; + $i = $this->cacheCount; + + while ($i--) { + $saved = $this->caches[$i]->set($key, $value, $ttl) && $saved; + } + + return $saved; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function setMultiple($values, $ttl = null) + { + if ($values instanceof \Traversable) { + $valuesIterator = $values; + $values = function () use ($valuesIterator, &$values) { + $generatedValues = []; + + foreach ($valuesIterator as $key => $value) { + yield $key => $value; + $generatedValues[$key] = $value; + } + + $values = $generatedValues; + }; + $values = $values(); + } + $saved = true; + $i = $this->cacheCount; + + while ($i--) { + $saved = $this->caches[$i]->setMultiple($values, $ttl) && $saved; + } + + return $saved; + } + + /** + * {@inheritdoc} + */ + public function prune() + { + $pruned = true; + + foreach ($this->caches as $cache) { + if ($cache instanceof PruneableInterface) { + $pruned = $cache->prune() && $pruned; + } + } + + return $pruned; + } + + /** + * {@inheritdoc} + */ + public function reset() + { + foreach ($this->caches as $cache) { + if ($cache instanceof ResetInterface) { + $cache->reset(); + } + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/DoctrineCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/DoctrineCache.php new file mode 100644 index 0000000..6a6d003 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/DoctrineCache.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Doctrine\Common\Cache\CacheProvider; +use Symfony\Component\Cache\Adapter\DoctrineAdapter; +use Symfony\Component\Cache\Traits\DoctrineTrait; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', DoctrineCache::class, DoctrineAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use DoctrineAdapter and type-hint for CacheInterface instead. + */ +class DoctrineCache extends AbstractCache +{ + use DoctrineTrait; + + public function __construct(CacheProvider $provider, string $namespace = '', int $defaultLifetime = 0) + { + parent::__construct('', $defaultLifetime); + $this->provider = $provider; + $provider->setNamespace($namespace); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/FilesystemCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/FilesystemCache.php new file mode 100644 index 0000000..8891abd --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/FilesystemCache.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Marshaller\DefaultMarshaller; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\Traits\FilesystemTrait; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', FilesystemCache::class, FilesystemAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use FilesystemAdapter and type-hint for CacheInterface instead. + */ +class FilesystemCache extends AbstractCache implements PruneableInterface +{ + use FilesystemTrait; + + public function __construct(string $namespace = '', int $defaultLifetime = 0, string $directory = null, MarshallerInterface $marshaller = null) + { + $this->marshaller = $marshaller ?? new DefaultMarshaller(); + parent::__construct('', $defaultLifetime); + $this->init($namespace, $directory); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/MemcachedCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/MemcachedCache.php new file mode 100644 index 0000000..e193411 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/MemcachedCache.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Symfony\Component\Cache\Adapter\MemcachedAdapter; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\Traits\MemcachedTrait; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', MemcachedCache::class, MemcachedAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use MemcachedAdapter and type-hint for CacheInterface instead. + */ +class MemcachedCache extends AbstractCache +{ + use MemcachedTrait; + + protected $maxIdLength = 250; + + public function __construct(\Memcached $client, string $namespace = '', int $defaultLifetime = 0, MarshallerInterface $marshaller = null) + { + $this->init($client, $namespace, $defaultLifetime, $marshaller); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/NullCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/NullCache.php new file mode 100644 index 0000000..35600fc --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/NullCache.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Psr\SimpleCache\CacheInterface as Psr16CacheInterface; +use Symfony\Component\Cache\Adapter\NullAdapter; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', NullCache::class, NullAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use NullAdapter and type-hint for CacheInterface instead. + */ +class NullCache implements Psr16CacheInterface +{ + /** + * {@inheritdoc} + */ + public function get($key, $default = null) + { + return $default; + } + + /** + * {@inheritdoc} + * + * @return iterable + */ + public function getMultiple($keys, $default = null) + { + foreach ($keys as $key) { + yield $key => $default; + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function has($key) + { + return false; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function clear() + { + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function delete($key) + { + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteMultiple($keys) + { + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function set($key, $value, $ttl = null) + { + return false; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function setMultiple($values, $ttl = null) + { + return false; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/PdoCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/PdoCache.php new file mode 100644 index 0000000..07849e9 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/PdoCache.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Symfony\Component\Cache\Adapter\PdoAdapter; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\Traits\PdoTrait; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', PdoCache::class, PdoAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use PdoAdapter and type-hint for CacheInterface instead. + */ +class PdoCache extends AbstractCache implements PruneableInterface +{ + use PdoTrait; + + protected $maxIdLength = 255; + + /** + * You can either pass an existing database connection as PDO instance or + * a Doctrine DBAL Connection or a DSN string that will be used to + * lazy-connect to the database when the cache is actually used. + * + * When a Doctrine DBAL Connection is passed, the cache table is created + * automatically when possible. Otherwise, use the createTable() method. + * + * List of available options: + * * db_table: The name of the table [default: cache_items] + * * db_id_col: The column where to store the cache id [default: item_id] + * * db_data_col: The column where to store the cache data [default: item_data] + * * db_lifetime_col: The column where to store the lifetime [default: item_lifetime] + * * db_time_col: The column where to store the timestamp [default: item_time] + * * db_username: The username when lazy-connect [default: ''] + * * db_password: The password when lazy-connect [default: ''] + * * db_connection_options: An array of driver-specific connection options [default: []] + * + * @param \PDO|Connection|string $connOrDsn a \PDO or Connection instance or DSN string or null + * + * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string + * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION + * @throws InvalidArgumentException When namespace contains invalid characters + */ + public function __construct($connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], MarshallerInterface $marshaller = null) + { + $this->init($connOrDsn, $namespace, $defaultLifetime, $options, $marshaller); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/PhpArrayCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/PhpArrayCache.php new file mode 100644 index 0000000..92b79e3 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/PhpArrayCache.php @@ -0,0 +1,256 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Psr\SimpleCache\CacheInterface as Psr16CacheInterface; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Cache\Traits\PhpArrayTrait; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', PhpArrayCache::class, PhpArrayAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use PhpArrayAdapter and type-hint for CacheInterface instead. + */ +class PhpArrayCache implements Psr16CacheInterface, PruneableInterface, ResettableInterface +{ + use PhpArrayTrait; + + /** + * @param string $file The PHP file were values are cached + * @param Psr16CacheInterface $fallbackPool A pool to fallback on when an item is not hit + */ + public function __construct(string $file, Psr16CacheInterface $fallbackPool) + { + $this->file = $file; + $this->pool = $fallbackPool; + } + + /** + * This adapter takes advantage of how PHP stores arrays in its latest versions. + * + * @param string $file The PHP file were values are cached + * @param CacheInterface $fallbackPool A pool to fallback on when an item is not hit + * + * @return Psr16CacheInterface + */ + public static function create($file, Psr16CacheInterface $fallbackPool) + { + return new static($file, $fallbackPool); + } + + /** + * {@inheritdoc} + */ + public function get($key, $default = null) + { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + if (null === $this->values) { + $this->initialize(); + } + if (!isset($this->keys[$key])) { + return $this->pool->get($key, $default); + } + $value = $this->values[$this->keys[$key]]; + + if ('N;' === $value) { + return null; + } + if ($value instanceof \Closure) { + try { + return $value(); + } catch (\Throwable $e) { + return $default; + } + } + + return $value; + } + + /** + * {@inheritdoc} + * + * @return iterable + */ + public function getMultiple($keys, $default = null) + { + if ($keys instanceof \Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!\is_array($keys)) { + throw new InvalidArgumentException(sprintf('Cache keys must be array or Traversable, "%s" given.', \is_object($keys) ? \get_class($keys) : \gettype($keys))); + } + foreach ($keys as $key) { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + } + if (null === $this->values) { + $this->initialize(); + } + + return $this->generateItems($keys, $default); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function has($key) + { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + if (null === $this->values) { + $this->initialize(); + } + + return isset($this->keys[$key]) || $this->pool->has($key); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function delete($key) + { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + if (null === $this->values) { + $this->initialize(); + } + + return !isset($this->keys[$key]) && $this->pool->delete($key); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteMultiple($keys) + { + if (!\is_array($keys) && !$keys instanceof \Traversable) { + throw new InvalidArgumentException(sprintf('Cache keys must be array or Traversable, "%s" given.', \is_object($keys) ? \get_class($keys) : \gettype($keys))); + } + + $deleted = true; + $fallbackKeys = []; + + foreach ($keys as $key) { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + + if (isset($this->keys[$key])) { + $deleted = false; + } else { + $fallbackKeys[] = $key; + } + } + if (null === $this->values) { + $this->initialize(); + } + + if ($fallbackKeys) { + $deleted = $this->pool->deleteMultiple($fallbackKeys) && $deleted; + } + + return $deleted; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function set($key, $value, $ttl = null) + { + if (!\is_string($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + if (null === $this->values) { + $this->initialize(); + } + + return !isset($this->keys[$key]) && $this->pool->set($key, $value, $ttl); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function setMultiple($values, $ttl = null) + { + if (!\is_array($values) && !$values instanceof \Traversable) { + throw new InvalidArgumentException(sprintf('Cache values must be array or Traversable, "%s" given.', \is_object($values) ? \get_class($values) : \gettype($values))); + } + + $saved = true; + $fallbackValues = []; + + foreach ($values as $key => $value) { + if (!\is_string($key) && !\is_int($key)) { + throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', \is_object($key) ? \get_class($key) : \gettype($key))); + } + + if (isset($this->keys[$key])) { + $saved = false; + } else { + $fallbackValues[$key] = $value; + } + } + + if ($fallbackValues) { + $saved = $this->pool->setMultiple($fallbackValues, $ttl) && $saved; + } + + return $saved; + } + + private function generateItems(array $keys, $default): iterable + { + $fallbackKeys = []; + + foreach ($keys as $key) { + if (isset($this->keys[$key])) { + $value = $this->values[$this->keys[$key]]; + + if ('N;' === $value) { + yield $key => null; + } elseif ($value instanceof \Closure) { + try { + yield $key => $value(); + } catch (\Throwable $e) { + yield $key => $default; + } + } else { + yield $key => $value; + } + } else { + $fallbackKeys[] = $key; + } + } + + if ($fallbackKeys) { + yield from $this->pool->getMultiple($fallbackKeys, $default); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/PhpFilesCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/PhpFilesCache.php new file mode 100644 index 0000000..060244a --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/PhpFilesCache.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Symfony\Component\Cache\Adapter\PhpFilesAdapter; +use Symfony\Component\Cache\Exception\CacheException; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\Traits\PhpFilesTrait; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', PhpFilesCache::class, PhpFilesAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use PhpFilesAdapter and type-hint for CacheInterface instead. + */ +class PhpFilesCache extends AbstractCache implements PruneableInterface +{ + use PhpFilesTrait; + + /** + * @param $appendOnly Set to `true` to gain extra performance when the items stored in this pool never expire. + * Doing so is encouraged because it fits perfectly OPcache's memory model. + * + * @throws CacheException if OPcache is not enabled + */ + public function __construct(string $namespace = '', int $defaultLifetime = 0, string $directory = null, bool $appendOnly = false) + { + $this->appendOnly = $appendOnly; + self::$startTime = self::$startTime ?? $_SERVER['REQUEST_TIME'] ?? time(); + parent::__construct('', $defaultLifetime); + $this->init($namespace, $directory); + $this->includeHandler = static function ($type, $msg, $file, $line) { + throw new \ErrorException($msg, 0, $type, $file, $line); + }; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/Psr6Cache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/Psr6Cache.php new file mode 100644 index 0000000..090f48c --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/Psr6Cache.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Symfony\Component\Cache\Psr16Cache; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" instead.', Psr6Cache::class, Psr16Cache::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use Psr16Cache instead. + */ +class Psr6Cache extends Psr16Cache +{ +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/RedisCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/RedisCache.php new file mode 100644 index 0000000..9655a75 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/RedisCache.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Symfony\Component\Cache\Adapter\RedisAdapter; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\Traits\RedisTrait; +use Symfony\Contracts\Cache\CacheInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', RedisCache::class, RedisAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use RedisAdapter and type-hint for CacheInterface instead. + */ +class RedisCache extends AbstractCache +{ + use RedisTrait; + + /** + * @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisClient + */ + public function __construct($redisClient, string $namespace = '', int $defaultLifetime = 0, MarshallerInterface $marshaller = null) + { + $this->init($redisClient, $namespace, $defaultLifetime, $marshaller); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/TraceableCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/TraceableCache.php new file mode 100644 index 0000000..2214f1c --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Simple/TraceableCache.php @@ -0,0 +1,258 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Simple; + +use Psr\SimpleCache\CacheInterface as Psr16CacheInterface; +use Symfony\Component\Cache\Adapter\TraceableAdapter; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Service\ResetInterface; + +@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.3, use "%s" and type-hint for "%s" instead.', TraceableCache::class, TraceableAdapter::class, CacheInterface::class), E_USER_DEPRECATED); + +/** + * @deprecated since Symfony 4.3, use TraceableAdapter and type-hint for CacheInterface instead. + */ +class TraceableCache implements Psr16CacheInterface, PruneableInterface, ResettableInterface +{ + private $pool; + private $miss; + private $calls = []; + + public function __construct(Psr16CacheInterface $pool) + { + $this->pool = $pool; + $this->miss = new \stdClass(); + } + + /** + * {@inheritdoc} + */ + public function get($key, $default = null) + { + $miss = null !== $default && \is_object($default) ? $default : $this->miss; + $event = $this->start(__FUNCTION__); + try { + $value = $this->pool->get($key, $miss); + } finally { + $event->end = microtime(true); + } + if ($event->result[$key] = $miss !== $value) { + ++$event->hits; + } else { + ++$event->misses; + $value = $default; + } + + return $value; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function has($key) + { + $event = $this->start(__FUNCTION__); + try { + return $event->result[$key] = $this->pool->has($key); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function delete($key) + { + $event = $this->start(__FUNCTION__); + try { + return $event->result[$key] = $this->pool->delete($key); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function set($key, $value, $ttl = null) + { + $event = $this->start(__FUNCTION__); + try { + return $event->result[$key] = $this->pool->set($key, $value, $ttl); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function setMultiple($values, $ttl = null) + { + $event = $this->start(__FUNCTION__); + $event->result['keys'] = []; + + if ($values instanceof \Traversable) { + $values = function () use ($values, $event) { + foreach ($values as $k => $v) { + $event->result['keys'][] = $k; + yield $k => $v; + } + }; + $values = $values(); + } elseif (\is_array($values)) { + $event->result['keys'] = array_keys($values); + } + + try { + return $event->result['result'] = $this->pool->setMultiple($values, $ttl); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + * + * @return iterable + */ + public function getMultiple($keys, $default = null) + { + $miss = null !== $default && \is_object($default) ? $default : $this->miss; + $event = $this->start(__FUNCTION__); + try { + $result = $this->pool->getMultiple($keys, $miss); + } finally { + $event->end = microtime(true); + } + $f = function () use ($result, $event, $miss, $default) { + $event->result = []; + foreach ($result as $key => $value) { + if ($event->result[$key] = $miss !== $value) { + ++$event->hits; + } else { + ++$event->misses; + $value = $default; + } + yield $key => $value; + } + }; + + return $f(); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function clear() + { + $event = $this->start(__FUNCTION__); + try { + return $event->result = $this->pool->clear(); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteMultiple($keys) + { + $event = $this->start(__FUNCTION__); + if ($keys instanceof \Traversable) { + $keys = $event->result['keys'] = iterator_to_array($keys, false); + } else { + $event->result['keys'] = $keys; + } + try { + return $event->result['result'] = $this->pool->deleteMultiple($keys); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + */ + public function prune() + { + if (!$this->pool instanceof PruneableInterface) { + return false; + } + $event = $this->start(__FUNCTION__); + try { + return $event->result = $this->pool->prune(); + } finally { + $event->end = microtime(true); + } + } + + /** + * {@inheritdoc} + */ + public function reset() + { + if (!$this->pool instanceof ResetInterface) { + return; + } + $event = $this->start(__FUNCTION__); + try { + $this->pool->reset(); + } finally { + $event->end = microtime(true); + } + } + + public function getCalls() + { + try { + return $this->calls; + } finally { + $this->calls = []; + } + } + + private function start(string $name): TraceableCacheEvent + { + $this->calls[] = $event = new TraceableCacheEvent(); + $event->name = $name; + $event->start = microtime(true); + + return $event; + } +} + +class TraceableCacheEvent +{ + public $name; + public $start; + public $end; + public $result; + public $hits = 0; + public $misses = 0; +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/AbstractAdapterTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/AbstractAdapterTrait.php new file mode 100644 index 0000000..8bf9354 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/AbstractAdapterTrait.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Psr\Cache\CacheItemInterface; +use Symfony\Component\Cache\CacheItem; + +/** + * @author Nicolas Grekas + * + * @internal + */ +trait AbstractAdapterTrait +{ + use AbstractTrait; + + /** + * @var \Closure needs to be set by class, signature is function(string , mixed , bool ) + */ + private $createCacheItem; + + /** + * @var \Closure needs to be set by class, signature is function(array , string , array <&expiredIds>) + */ + private $mergeByLifetime; + + /** + * {@inheritdoc} + */ + public function getItem($key) + { + if ($this->deferred) { + $this->commit(); + } + $id = $this->getId($key); + + $f = $this->createCacheItem; + $isHit = false; + $value = null; + + try { + foreach ($this->doFetch([$id]) as $value) { + $isHit = true; + } + + return $f($key, $value, $isHit); + } catch (\Exception $e) { + CacheItem::log($this->logger, 'Failed to fetch key "{key}": '.$e->getMessage(), ['key' => $key, 'exception' => $e]); + } + + return $f($key, null, false); + } + + /** + * {@inheritdoc} + */ + public function getItems(array $keys = []) + { + if ($this->deferred) { + $this->commit(); + } + $ids = []; + + foreach ($keys as $key) { + $ids[] = $this->getId($key); + } + try { + $items = $this->doFetch($ids); + } catch (\Exception $e) { + CacheItem::log($this->logger, 'Failed to fetch items: '.$e->getMessage(), ['keys' => $keys, 'exception' => $e]); + $items = []; + } + $ids = array_combine($ids, $keys); + + return $this->generateItems($items, $ids); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function save(CacheItemInterface $item) + { + if (!$item instanceof CacheItem) { + return false; + } + $this->deferred[$item->getKey()] = $item; + + return $this->commit(); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function saveDeferred(CacheItemInterface $item) + { + if (!$item instanceof CacheItem) { + return false; + } + $this->deferred[$item->getKey()] = $item; + + return true; + } + + public function __sleep() + { + throw new \BadMethodCallException('Cannot serialize '.__CLASS__); + } + + public function __wakeup() + { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + + public function __destruct() + { + if ($this->deferred) { + $this->commit(); + } + } + + private function generateItems(iterable $items, array &$keys): iterable + { + $f = $this->createCacheItem; + + try { + foreach ($items as $id => $value) { + if (!isset($keys[$id])) { + $id = key($keys); + } + $key = $keys[$id]; + unset($keys[$id]); + yield $key => $f($key, $value, true); + } + } catch (\Exception $e) { + CacheItem::log($this->logger, 'Failed to fetch items: '.$e->getMessage(), ['keys' => array_values($keys), 'exception' => $e]); + } + + foreach ($keys as $key) { + yield $key => $f($key, null, false); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/AbstractTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/AbstractTrait.php new file mode 100644 index 0000000..5876c1c --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/AbstractTrait.php @@ -0,0 +1,303 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Psr\Log\LoggerAwareTrait; +use Symfony\Component\Cache\CacheItem; + +/** + * @author Nicolas Grekas + * + * @internal + */ +trait AbstractTrait +{ + use LoggerAwareTrait; + + private $namespace; + private $namespaceVersion = ''; + private $versioningIsEnabled = false; + private $deferred = []; + private $ids = []; + + /** + * @var int|null The maximum length to enforce for identifiers or null when no limit applies + */ + protected $maxIdLength; + + /** + * Fetches several cache items. + * + * @param array $ids The cache identifiers to fetch + * + * @return array|\Traversable The corresponding values found in the cache + */ + abstract protected function doFetch(array $ids); + + /** + * Confirms if the cache contains specified cache item. + * + * @param string $id The identifier for which to check existence + * + * @return bool True if item exists in the cache, false otherwise + */ + abstract protected function doHave($id); + + /** + * Deletes all items in the pool. + * + * @param string $namespace The prefix used for all identifiers managed by this pool + * + * @return bool True if the pool was successfully cleared, false otherwise + */ + abstract protected function doClear($namespace); + + /** + * Removes multiple items from the pool. + * + * @param array $ids An array of identifiers that should be removed from the pool + * + * @return bool True if the items were successfully removed, false otherwise + */ + abstract protected function doDelete(array $ids); + + /** + * Persists several cache items immediately. + * + * @param array $values The values to cache, indexed by their cache identifier + * @param int $lifetime The lifetime of the cached values, 0 for persisting until manual cleaning + * + * @return array|bool The identifiers that failed to be cached or a boolean stating if caching succeeded or not + */ + abstract protected function doSave(array $values, $lifetime); + + /** + * {@inheritdoc} + * + * @return bool + */ + public function hasItem($key) + { + $id = $this->getId($key); + + if (isset($this->deferred[$key])) { + $this->commit(); + } + + try { + return $this->doHave($id); + } catch (\Exception $e) { + CacheItem::log($this->logger, 'Failed to check if key "{key}" is cached: '.$e->getMessage(), ['key' => $key, 'exception' => $e]); + + return false; + } + } + + /** + * {@inheritdoc} + * + * @param string $prefix + * + * @return bool + */ + public function clear(/*string $prefix = ''*/) + { + $this->deferred = []; + if ($cleared = $this->versioningIsEnabled) { + if ('' === $namespaceVersionToClear = $this->namespaceVersion) { + foreach ($this->doFetch([static::NS_SEPARATOR.$this->namespace]) as $v) { + $namespaceVersionToClear = $v; + } + } + $namespaceToClear = $this->namespace.$namespaceVersionToClear; + $namespaceVersion = substr_replace(base64_encode(pack('V', mt_rand())), static::NS_SEPARATOR, 5); + try { + $cleared = $this->doSave([static::NS_SEPARATOR.$this->namespace => $namespaceVersion], 0); + } catch (\Exception $e) { + $cleared = false; + } + if ($cleared = true === $cleared || [] === $cleared) { + $this->namespaceVersion = $namespaceVersion; + $this->ids = []; + } + } else { + $prefix = 0 < \func_num_args() ? (string) func_get_arg(0) : ''; + $namespaceToClear = $this->namespace.$prefix; + } + + try { + return $this->doClear($namespaceToClear) || $cleared; + } catch (\Exception $e) { + CacheItem::log($this->logger, 'Failed to clear the cache: '.$e->getMessage(), ['exception' => $e]); + + return false; + } + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItem($key) + { + return $this->deleteItems([$key]); + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItems(array $keys) + { + $ids = []; + + foreach ($keys as $key) { + $ids[$key] = $this->getId($key); + unset($this->deferred[$key]); + } + + try { + if ($this->doDelete($ids)) { + return true; + } + } catch (\Exception $e) { + } + + $ok = true; + + // When bulk-delete failed, retry each item individually + foreach ($ids as $key => $id) { + try { + $e = null; + if ($this->doDelete([$id])) { + continue; + } + } catch (\Exception $e) { + } + $message = 'Failed to delete key "{key}"'.($e instanceof \Exception ? ': '.$e->getMessage() : '.'); + CacheItem::log($this->logger, $message, ['key' => $key, 'exception' => $e]); + $ok = false; + } + + return $ok; + } + + /** + * Enables/disables versioning of items. + * + * When versioning is enabled, clearing the cache is atomic and doesn't require listing existing keys to proceed, + * but old keys may need garbage collection and extra round-trips to the back-end are required. + * + * Calling this method also clears the memoized namespace version and thus forces a resynchonization of it. + * + * @param bool $enable + * + * @return bool the previous state of versioning + */ + public function enableVersioning($enable = true) + { + $wasEnabled = $this->versioningIsEnabled; + $this->versioningIsEnabled = (bool) $enable; + $this->namespaceVersion = ''; + $this->ids = []; + + return $wasEnabled; + } + + /** + * {@inheritdoc} + */ + public function reset() + { + if ($this->deferred) { + $this->commit(); + } + $this->namespaceVersion = ''; + $this->ids = []; + } + + /** + * Like the native unserialize() function but throws an exception if anything goes wrong. + * + * @param string $value + * + * @return mixed + * + * @throws \Exception + * + * @deprecated since Symfony 4.2, use DefaultMarshaller instead. + */ + protected static function unserialize($value) + { + @trigger_error(sprintf('The "%s::unserialize()" method is deprecated since Symfony 4.2, use DefaultMarshaller instead.', __CLASS__), E_USER_DEPRECATED); + + if ('b:0;' === $value) { + return false; + } + $unserializeCallbackHandler = ini_set('unserialize_callback_func', __CLASS__.'::handleUnserializeCallback'); + try { + if (false !== $value = unserialize($value)) { + return $value; + } + throw new \DomainException('Failed to unserialize cached value.'); + } catch (\Error $e) { + throw new \ErrorException($e->getMessage(), $e->getCode(), E_ERROR, $e->getFile(), $e->getLine()); + } finally { + ini_set('unserialize_callback_func', $unserializeCallbackHandler); + } + } + + private function getId($key): string + { + if ($this->versioningIsEnabled && '' === $this->namespaceVersion) { + $this->ids = []; + $this->namespaceVersion = '1'.static::NS_SEPARATOR; + try { + foreach ($this->doFetch([static::NS_SEPARATOR.$this->namespace]) as $v) { + $this->namespaceVersion = $v; + } + if ('1'.static::NS_SEPARATOR === $this->namespaceVersion) { + $this->namespaceVersion = substr_replace(base64_encode(pack('V', time())), static::NS_SEPARATOR, 5); + $this->doSave([static::NS_SEPARATOR.$this->namespace => $this->namespaceVersion], 0); + } + } catch (\Exception $e) { + } + } + + if (\is_string($key) && isset($this->ids[$key])) { + return $this->namespace.$this->namespaceVersion.$this->ids[$key]; + } + CacheItem::validateKey($key); + $this->ids[$key] = $key; + + if (null === $this->maxIdLength) { + return $this->namespace.$this->namespaceVersion.$key; + } + if (\strlen($id = $this->namespace.$this->namespaceVersion.$key) > $this->maxIdLength) { + // Use MD5 to favor speed over security, which is not an issue here + $this->ids[$key] = $id = substr_replace(base64_encode(hash('md5', $key, true)), static::NS_SEPARATOR, -(\strlen($this->namespaceVersion) + 2)); + $id = $this->namespace.$this->namespaceVersion.$id; + } + + return $id; + } + + /** + * @internal + */ + public static function handleUnserializeCallback($class) + { + throw new \DomainException('Class not found: '.$class); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ApcuTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ApcuTrait.php new file mode 100644 index 0000000..f681c05 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ApcuTrait.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\CacheException; + +/** + * @author Nicolas Grekas + * + * @internal + */ +trait ApcuTrait +{ + public static function isSupported() + { + return \function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN); + } + + private function init(string $namespace, int $defaultLifetime, ?string $version) + { + if (!static::isSupported()) { + throw new CacheException('APCu is not enabled.'); + } + if ('cli' === \PHP_SAPI) { + ini_set('apc.use_request_time', 0); + } + parent::__construct($namespace, $defaultLifetime); + + if (null !== $version) { + CacheItem::validateKey($version); + + if (!apcu_exists($version.'@'.$namespace)) { + $this->doClear($namespace); + apcu_add($version.'@'.$namespace, null); + } + } + } + + /** + * {@inheritdoc} + */ + protected function doFetch(array $ids) + { + $unserializeCallbackHandler = ini_set('unserialize_callback_func', __CLASS__.'::handleUnserializeCallback'); + try { + $values = []; + foreach (apcu_fetch($ids, $ok) ?: [] as $k => $v) { + if (null !== $v || $ok) { + $values[$k] = $v; + } + } + + return $values; + } catch (\Error $e) { + throw new \ErrorException($e->getMessage(), $e->getCode(), E_ERROR, $e->getFile(), $e->getLine()); + } finally { + ini_set('unserialize_callback_func', $unserializeCallbackHandler); + } + } + + /** + * {@inheritdoc} + */ + protected function doHave($id) + { + return apcu_exists($id); + } + + /** + * {@inheritdoc} + */ + protected function doClear($namespace) + { + return isset($namespace[0]) && class_exists('APCuIterator', false) && ('cli' !== \PHP_SAPI || filter_var(ini_get('apc.enable_cli'), FILTER_VALIDATE_BOOLEAN)) + ? apcu_delete(new \APCuIterator(sprintf('/^%s/', preg_quote($namespace, '/')), APC_ITER_KEY)) + : apcu_clear_cache(); + } + + /** + * {@inheritdoc} + */ + protected function doDelete(array $ids) + { + foreach ($ids as $id) { + apcu_delete($id); + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, $lifetime) + { + try { + if (false === $failures = apcu_store($values, null, $lifetime)) { + $failures = $values; + } + + return array_keys($failures); + } catch (\Throwable $e) { + if (1 === \count($values)) { + // Workaround https://github.com/krakjoe/apcu/issues/170 + apcu_delete(key($values)); + } + + throw $e; + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ArrayTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ArrayTrait.php new file mode 100644 index 0000000..21872c5 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ArrayTrait.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Psr\Log\LoggerAwareTrait; +use Symfony\Component\Cache\CacheItem; + +/** + * @author Nicolas Grekas + * + * @internal + */ +trait ArrayTrait +{ + use LoggerAwareTrait; + + private $storeSerialized; + private $values = []; + private $expiries = []; + + /** + * Returns all cached values, with cache miss as null. + * + * @return array + */ + public function getValues() + { + if (!$this->storeSerialized) { + return $this->values; + } + + $values = $this->values; + foreach ($values as $k => $v) { + if (null === $v || 'N;' === $v) { + continue; + } + if (!\is_string($v) || !isset($v[2]) || ':' !== $v[1]) { + $values[$k] = serialize($v); + } + } + + return $values; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function hasItem($key) + { + if (\is_string($key) && isset($this->expiries[$key]) && $this->expiries[$key] > microtime(true)) { + return true; + } + CacheItem::validateKey($key); + + return isset($this->expiries[$key]) && !$this->deleteItem($key); + } + + /** + * {@inheritdoc} + * + * @param string $prefix + * + * @return bool + */ + public function clear(/*string $prefix = ''*/) + { + $prefix = 0 < \func_num_args() ? (string) func_get_arg(0) : ''; + + if ('' !== $prefix) { + foreach ($this->values as $key => $value) { + if (0 === strpos($key, $prefix)) { + unset($this->values[$key], $this->expiries[$key]); + } + } + } else { + $this->values = $this->expiries = []; + } + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function deleteItem($key) + { + if (!\is_string($key) || !isset($this->expiries[$key])) { + CacheItem::validateKey($key); + } + unset($this->values[$key], $this->expiries[$key]); + + return true; + } + + /** + * {@inheritdoc} + */ + public function reset() + { + $this->clear(); + } + + private function generateItems(array $keys, float $now, callable $f): iterable + { + foreach ($keys as $i => $key) { + if (!$isHit = isset($this->expiries[$key]) && ($this->expiries[$key] > $now || !$this->deleteItem($key))) { + $this->values[$key] = $value = null; + } else { + $value = $this->storeSerialized ? $this->unfreeze($key, $isHit) : $this->values[$key]; + } + unset($keys[$i]); + + yield $key => $f($key, $value, $isHit); + } + + foreach ($keys as $key) { + yield $key => $f($key, null, false); + } + } + + private function freeze($value, $key) + { + if (null === $value) { + return 'N;'; + } + if (\is_string($value)) { + // Serialize strings if they could be confused with serialized objects or arrays + if ('N;' === $value || (isset($value[2]) && ':' === $value[1])) { + return serialize($value); + } + } elseif (!is_scalar($value)) { + try { + $serialized = serialize($value); + } catch (\Exception $e) { + $type = \is_object($value) ? \get_class($value) : \gettype($value); + $message = sprintf('Failed to save key "{key}" of type %s: %s', $type, $e->getMessage()); + CacheItem::log($this->logger, $message, ['key' => $key, 'exception' => $e]); + + return null; + } + // Keep value serialized if it contains any objects or any internal references + if ('C' === $serialized[0] || 'O' === $serialized[0] || preg_match('/;[OCRr]:[1-9]/', $serialized)) { + return $serialized; + } + } + + return $value; + } + + private function unfreeze(string $key, bool &$isHit) + { + if ('N;' === $value = $this->values[$key]) { + return null; + } + if (\is_string($value) && isset($value[2]) && ':' === $value[1]) { + try { + $value = unserialize($value); + } catch (\Exception $e) { + CacheItem::log($this->logger, 'Failed to unserialize key "{key}": '.$e->getMessage(), ['key' => $key, 'exception' => $e]); + $value = false; + } + if (false === $value) { + $this->values[$key] = $value = null; + $isHit = false; + } + } + + return $value; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ContractsTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ContractsTrait.php new file mode 100644 index 0000000..06070c9 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ContractsTrait.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Cache\Adapter\AdapterInterface; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\LockRegistry; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\CacheTrait; +use Symfony\Contracts\Cache\ItemInterface; + +/** + * @author Nicolas Grekas + * + * @internal + */ +trait ContractsTrait +{ + use CacheTrait { + doGet as private contractsGet; + } + + private $callbackWrapper = [LockRegistry::class, 'compute']; + private $computing = []; + + /** + * Wraps the callback passed to ->get() in a callable. + * + * @return callable the previous callback wrapper + */ + public function setCallbackWrapper(?callable $callbackWrapper): callable + { + $previousWrapper = $this->callbackWrapper; + $this->callbackWrapper = $callbackWrapper ?? function (callable $callback, ItemInterface $item, bool &$save, CacheInterface $pool, \Closure $setMetadata, ?LoggerInterface $logger) { + return $callback($item, $save); + }; + + return $previousWrapper; + } + + private function doGet(AdapterInterface $pool, string $key, callable $callback, ?float $beta, array &$metadata = null) + { + if (0 > $beta = $beta ?? 1.0) { + throw new InvalidArgumentException(sprintf('Argument "$beta" provided to "%s::get()" must be a positive number, %f given.', static::class, $beta)); + } + + static $setMetadata; + + $setMetadata = $setMetadata ?? \Closure::bind( + static function (CacheItem $item, float $startTime, ?array &$metadata) { + if ($item->expiry > $endTime = microtime(true)) { + $item->newMetadata[CacheItem::METADATA_EXPIRY] = $metadata[CacheItem::METADATA_EXPIRY] = $item->expiry; + $item->newMetadata[CacheItem::METADATA_CTIME] = $metadata[CacheItem::METADATA_CTIME] = (int) ceil(1000 * ($endTime - $startTime)); + } else { + unset($metadata[CacheItem::METADATA_EXPIRY], $metadata[CacheItem::METADATA_CTIME]); + } + }, + null, + CacheItem::class + ); + + return $this->contractsGet($pool, $key, function (CacheItem $item, bool &$save) use ($pool, $callback, $setMetadata, &$metadata, $key) { + // don't wrap nor save recursive calls + if (isset($this->computing[$key])) { + $value = $callback($item, $save); + $save = false; + + return $value; + } + + $this->computing[$key] = $key; + $startTime = microtime(true); + + try { + $value = ($this->callbackWrapper)($callback, $item, $save, $pool, function (CacheItem $item) use ($setMetadata, $startTime, &$metadata) { + $setMetadata($item, $startTime, $metadata); + }, $this->logger ?? null); + $setMetadata($item, $startTime, $metadata); + + return $value; + } finally { + unset($this->computing[$key]); + } + }, $beta, $metadata, $this->logger ?? null); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/DoctrineTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/DoctrineTrait.php new file mode 100644 index 0000000..c87ecab --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/DoctrineTrait.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +/** + * @author Nicolas Grekas + * + * @internal + */ +trait DoctrineTrait +{ + private $provider; + + /** + * {@inheritdoc} + */ + public function reset() + { + parent::reset(); + $this->provider->setNamespace($this->provider->getNamespace()); + } + + /** + * {@inheritdoc} + */ + protected function doFetch(array $ids) + { + $unserializeCallbackHandler = ini_set('unserialize_callback_func', parent::class.'::handleUnserializeCallback'); + try { + return $this->provider->fetchMultiple($ids); + } catch (\Error $e) { + $trace = $e->getTrace(); + + if (isset($trace[0]['function']) && !isset($trace[0]['class'])) { + switch ($trace[0]['function']) { + case 'unserialize': + case 'apcu_fetch': + case 'apc_fetch': + throw new \ErrorException($e->getMessage(), $e->getCode(), E_ERROR, $e->getFile(), $e->getLine()); + } + } + + throw $e; + } finally { + ini_set('unserialize_callback_func', $unserializeCallbackHandler); + } + } + + /** + * {@inheritdoc} + */ + protected function doHave($id) + { + return $this->provider->contains($id); + } + + /** + * {@inheritdoc} + */ + protected function doClear($namespace) + { + $namespace = $this->provider->getNamespace(); + + return isset($namespace[0]) + ? $this->provider->deleteAll() + : $this->provider->flushAll(); + } + + /** + * {@inheritdoc} + */ + protected function doDelete(array $ids) + { + $ok = true; + foreach ($ids as $id) { + $ok = $this->provider->delete($id) && $ok; + } + + return $ok; + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, $lifetime) + { + return $this->provider->saveMultiple($values, $lifetime); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/FilesystemCommonTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/FilesystemCommonTrait.php new file mode 100644 index 0000000..67e2d61 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/FilesystemCommonTrait.php @@ -0,0 +1,185 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Symfony\Component\Cache\Exception\InvalidArgumentException; + +/** + * @author Nicolas Grekas + * + * @internal + */ +trait FilesystemCommonTrait +{ + private $directory; + private $tmp; + + private function init(string $namespace, ?string $directory) + { + if (!isset($directory[0])) { + $directory = sys_get_temp_dir().\DIRECTORY_SEPARATOR.'symfony-cache'; + } else { + $directory = realpath($directory) ?: $directory; + } + if (isset($namespace[0])) { + if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) { + throw new InvalidArgumentException(sprintf('Namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0])); + } + $directory .= \DIRECTORY_SEPARATOR.$namespace; + } else { + $directory .= \DIRECTORY_SEPARATOR.'@'; + } + if (!file_exists($directory)) { + @mkdir($directory, 0777, true); + } + $directory .= \DIRECTORY_SEPARATOR; + // On Windows the whole path is limited to 258 chars + if ('\\' === \DIRECTORY_SEPARATOR && \strlen($directory) > 234) { + throw new InvalidArgumentException(sprintf('Cache directory too long (%s).', $directory)); + } + + $this->directory = $directory; + } + + /** + * {@inheritdoc} + */ + protected function doClear($namespace) + { + $ok = true; + + foreach ($this->scanHashDir($this->directory) as $file) { + if ('' !== $namespace && 0 !== strpos($this->getFileKey($file), $namespace)) { + continue; + } + + $ok = ($this->doUnlink($file) || !file_exists($file)) && $ok; + } + + return $ok; + } + + /** + * {@inheritdoc} + */ + protected function doDelete(array $ids) + { + $ok = true; + + foreach ($ids as $id) { + $file = $this->getFile($id); + $ok = (!file_exists($file) || $this->doUnlink($file) || !file_exists($file)) && $ok; + } + + return $ok; + } + + protected function doUnlink($file) + { + return @unlink($file); + } + + private function write(string $file, string $data, int $expiresAt = null) + { + set_error_handler(__CLASS__.'::throwError'); + try { + if (null === $this->tmp) { + $this->tmp = $this->directory.uniqid('', true); + } + file_put_contents($this->tmp, $data); + + if (null !== $expiresAt) { + touch($this->tmp, $expiresAt); + } + + return rename($this->tmp, $file); + } finally { + restore_error_handler(); + } + } + + private function getFile(string $id, bool $mkdir = false, string $directory = null) + { + // Use MD5 to favor speed over security, which is not an issue here + $hash = str_replace('/', '-', base64_encode(hash('md5', static::class.$id, true))); + $dir = ($directory ?? $this->directory).strtoupper($hash[0].\DIRECTORY_SEPARATOR.$hash[1].\DIRECTORY_SEPARATOR); + + if ($mkdir && !file_exists($dir)) { + @mkdir($dir, 0777, true); + } + + return $dir.substr($hash, 2, 20); + } + + private function getFileKey(string $file): string + { + return ''; + } + + private function scanHashDir(string $directory): \Generator + { + if (!file_exists($directory)) { + return; + } + + $chars = '+-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + for ($i = 0; $i < 38; ++$i) { + if (!file_exists($directory.$chars[$i])) { + continue; + } + + for ($j = 0; $j < 38; ++$j) { + if (!file_exists($dir = $directory.$chars[$i].\DIRECTORY_SEPARATOR.$chars[$j])) { + continue; + } + + foreach (@scandir($dir, SCANDIR_SORT_NONE) ?: [] as $file) { + if ('.' !== $file && '..' !== $file) { + yield $dir.\DIRECTORY_SEPARATOR.$file; + } + } + } + } + } + + /** + * @internal + */ + public static function throwError($type, $message, $file, $line) + { + throw new \ErrorException($message, 0, $type, $file, $line); + } + + /** + * @return array + */ + public function __sleep() + { + throw new \BadMethodCallException('Cannot serialize '.__CLASS__); + } + + public function __wakeup() + { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + + public function __destruct() + { + if (method_exists(parent::class, '__destruct')) { + parent::__destruct(); + } + if (null !== $this->tmp && file_exists($this->tmp)) { + unlink($this->tmp); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/FilesystemTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/FilesystemTrait.php new file mode 100644 index 0000000..acf3ea0 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/FilesystemTrait.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Symfony\Component\Cache\Exception\CacheException; + +/** + * @author Nicolas Grekas + * @author Rob Frawley 2nd + * + * @internal + */ +trait FilesystemTrait +{ + use FilesystemCommonTrait; + + private $marshaller; + + /** + * @return bool + */ + public function prune() + { + $time = time(); + $pruned = true; + + foreach ($this->scanHashDir($this->directory) as $file) { + if (!$h = @fopen($file, 'rb')) { + continue; + } + + if (($expiresAt = (int) fgets($h)) && $time >= $expiresAt) { + fclose($h); + $pruned = @unlink($file) && !file_exists($file) && $pruned; + } else { + fclose($h); + } + } + + return $pruned; + } + + /** + * {@inheritdoc} + */ + protected function doFetch(array $ids) + { + $values = []; + $now = time(); + + foreach ($ids as $id) { + $file = $this->getFile($id); + if (!file_exists($file) || !$h = @fopen($file, 'rb')) { + continue; + } + if (($expiresAt = (int) fgets($h)) && $now >= $expiresAt) { + fclose($h); + @unlink($file); + } else { + $i = rawurldecode(rtrim(fgets($h))); + $value = stream_get_contents($h); + fclose($h); + if ($i === $id) { + $values[$id] = $this->marshaller->unmarshall($value); + } + } + } + + return $values; + } + + /** + * {@inheritdoc} + */ + protected function doHave($id) + { + $file = $this->getFile($id); + + return file_exists($file) && (@filemtime($file) > time() || $this->doFetch([$id])); + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, $lifetime) + { + $expiresAt = $lifetime ? (time() + $lifetime) : 0; + $values = $this->marshaller->marshall($values, $failed); + + foreach ($values as $id => $value) { + if (!$this->write($this->getFile($id, true), $expiresAt."\n".rawurlencode($id)."\n".$value, $expiresAt)) { + $failed[] = $id; + } + } + + if ($failed && !is_writable($this->directory)) { + throw new CacheException(sprintf('Cache directory is not writable (%s).', $this->directory)); + } + + return $failed; + } + + private function getFileKey(string $file): string + { + if (!$h = @fopen($file, 'rb')) { + return ''; + } + + fgets($h); // expiry + $encodedKey = fgets($h); + fclose($h); + + return rawurldecode(rtrim($encodedKey)); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/MemcachedTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/MemcachedTrait.php new file mode 100644 index 0000000..507bc1f --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/MemcachedTrait.php @@ -0,0 +1,325 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Symfony\Component\Cache\Exception\CacheException; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Marshaller\DefaultMarshaller; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; + +/** + * @author Rob Frawley 2nd + * @author Nicolas Grekas + * + * @internal + */ +trait MemcachedTrait +{ + private static $defaultClientOptions = [ + 'persistent_id' => null, + 'username' => null, + 'password' => null, + \Memcached::OPT_SERIALIZER => \Memcached::SERIALIZER_PHP, + ]; + + private $marshaller; + private $client; + private $lazyClient; + + public static function isSupported() + { + return \extension_loaded('memcached') && version_compare(phpversion('memcached'), '2.2.0', '>='); + } + + private function init(\Memcached $client, string $namespace, int $defaultLifetime, ?MarshallerInterface $marshaller) + { + if (!static::isSupported()) { + throw new CacheException('Memcached >= 2.2.0 is required.'); + } + if ('Memcached' === \get_class($client)) { + $opt = $client->getOption(\Memcached::OPT_SERIALIZER); + if (\Memcached::SERIALIZER_PHP !== $opt && \Memcached::SERIALIZER_IGBINARY !== $opt) { + throw new CacheException('MemcachedAdapter: "serializer" option must be "php" or "igbinary".'); + } + $this->maxIdLength -= \strlen($client->getOption(\Memcached::OPT_PREFIX_KEY)); + $this->client = $client; + } else { + $this->lazyClient = $client; + } + + parent::__construct($namespace, $defaultLifetime); + $this->enableVersioning(); + $this->marshaller = $marshaller ?? new DefaultMarshaller(); + } + + /** + * Creates a Memcached instance. + * + * By default, the binary protocol, no block, and libketama compatible options are enabled. + * + * Examples for servers: + * - 'memcached://user:pass@localhost?weight=33' + * - [['localhost', 11211, 33]] + * + * @param array[]|string|string[] $servers An array of servers, a DSN, or an array of DSNs + * + * @return \Memcached + * + * @throws \ErrorException When invalid options or servers are provided + */ + public static function createConnection($servers, array $options = []) + { + if (\is_string($servers)) { + $servers = [$servers]; + } elseif (!\is_array($servers)) { + throw new InvalidArgumentException(sprintf('MemcachedAdapter::createClient() expects array or string as first argument, "%s" given.', \gettype($servers))); + } + if (!static::isSupported()) { + throw new CacheException('Memcached >= 2.2.0 is required.'); + } + set_error_handler(function ($type, $msg, $file, $line) { throw new \ErrorException($msg, 0, $type, $file, $line); }); + try { + $options += static::$defaultClientOptions; + $client = new \Memcached($options['persistent_id']); + $username = $options['username']; + $password = $options['password']; + + // parse any DSN in $servers + foreach ($servers as $i => $dsn) { + if (\is_array($dsn)) { + continue; + } + if (0 !== strpos($dsn, 'memcached:')) { + throw new InvalidArgumentException(sprintf('Invalid Memcached DSN: "%s" does not start with "memcached:".', $dsn)); + } + $params = preg_replace_callback('#^memcached:(//)?(?:([^@]*+)@)?#', function ($m) use (&$username, &$password) { + if (!empty($m[2])) { + list($username, $password) = explode(':', $m[2], 2) + [1 => null]; + } + + return 'file:'.($m[1] ?? ''); + }, $dsn); + if (false === $params = parse_url($params)) { + throw new InvalidArgumentException(sprintf('Invalid Memcached DSN: "%s".', $dsn)); + } + $query = $hosts = []; + if (isset($params['query'])) { + parse_str($params['query'], $query); + + if (isset($query['host'])) { + if (!\is_array($hosts = $query['host'])) { + throw new InvalidArgumentException(sprintf('Invalid Memcached DSN: "%s".', $dsn)); + } + foreach ($hosts as $host => $weight) { + if (false === $port = strrpos($host, ':')) { + $hosts[$host] = [$host, 11211, (int) $weight]; + } else { + $hosts[$host] = [substr($host, 0, $port), (int) substr($host, 1 + $port), (int) $weight]; + } + } + $hosts = array_values($hosts); + unset($query['host']); + } + if ($hosts && !isset($params['host']) && !isset($params['path'])) { + unset($servers[$i]); + $servers = array_merge($servers, $hosts); + continue; + } + } + if (!isset($params['host']) && !isset($params['path'])) { + throw new InvalidArgumentException(sprintf('Invalid Memcached DSN: "%s".', $dsn)); + } + if (isset($params['path']) && preg_match('#/(\d+)$#', $params['path'], $m)) { + $params['weight'] = $m[1]; + $params['path'] = substr($params['path'], 0, -\strlen($m[0])); + } + $params += [ + 'host' => isset($params['host']) ? $params['host'] : $params['path'], + 'port' => isset($params['host']) ? 11211 : null, + 'weight' => 0, + ]; + if ($query) { + $params += $query; + $options = $query + $options; + } + + $servers[$i] = [$params['host'], $params['port'], $params['weight']]; + + if ($hosts) { + $servers = array_merge($servers, $hosts); + } + } + + // set client's options + unset($options['persistent_id'], $options['username'], $options['password'], $options['weight'], $options['lazy']); + $options = array_change_key_case($options, CASE_UPPER); + $client->setOption(\Memcached::OPT_BINARY_PROTOCOL, true); + $client->setOption(\Memcached::OPT_NO_BLOCK, true); + $client->setOption(\Memcached::OPT_TCP_NODELAY, true); + if (!\array_key_exists('LIBKETAMA_COMPATIBLE', $options) && !\array_key_exists(\Memcached::OPT_LIBKETAMA_COMPATIBLE, $options)) { + $client->setOption(\Memcached::OPT_LIBKETAMA_COMPATIBLE, true); + } + foreach ($options as $name => $value) { + if (\is_int($name)) { + continue; + } + if ('HASH' === $name || 'SERIALIZER' === $name || 'DISTRIBUTION' === $name) { + $value = \constant('Memcached::'.$name.'_'.strtoupper($value)); + } + $opt = \constant('Memcached::OPT_'.$name); + + unset($options[$name]); + $options[$opt] = $value; + } + $client->setOptions($options); + + // set client's servers, taking care of persistent connections + if (!$client->isPristine()) { + $oldServers = []; + foreach ($client->getServerList() as $server) { + $oldServers[] = [$server['host'], $server['port']]; + } + + $newServers = []; + foreach ($servers as $server) { + if (1 < \count($server)) { + $server = array_values($server); + unset($server[2]); + $server[1] = (int) $server[1]; + } + $newServers[] = $server; + } + + if ($oldServers !== $newServers) { + $client->resetServerList(); + $client->addServers($servers); + } + } else { + $client->addServers($servers); + } + + if (null !== $username || null !== $password) { + if (!method_exists($client, 'setSaslAuthData')) { + trigger_error('Missing SASL support: the memcached extension must be compiled with --enable-memcached-sasl.'); + } + $client->setSaslAuthData($username, $password); + } + + return $client; + } finally { + restore_error_handler(); + } + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, $lifetime) + { + if (!$values = $this->marshaller->marshall($values, $failed)) { + return $failed; + } + + if ($lifetime && $lifetime > 30 * 86400) { + $lifetime += time(); + } + + $encodedValues = []; + foreach ($values as $key => $value) { + $encodedValues[rawurlencode($key)] = $value; + } + + return $this->checkResultCode($this->getClient()->setMulti($encodedValues, $lifetime)) ? $failed : false; + } + + /** + * {@inheritdoc} + */ + protected function doFetch(array $ids) + { + try { + $encodedIds = array_map('rawurlencode', $ids); + + $encodedResult = $this->checkResultCode($this->getClient()->getMulti($encodedIds)); + + $result = []; + foreach ($encodedResult as $key => $value) { + $result[rawurldecode($key)] = $this->marshaller->unmarshall($value); + } + + return $result; + } catch (\Error $e) { + throw new \ErrorException($e->getMessage(), $e->getCode(), E_ERROR, $e->getFile(), $e->getLine()); + } + } + + /** + * {@inheritdoc} + */ + protected function doHave($id) + { + return false !== $this->getClient()->get(rawurlencode($id)) || $this->checkResultCode(\Memcached::RES_SUCCESS === $this->client->getResultCode()); + } + + /** + * {@inheritdoc} + */ + protected function doDelete(array $ids) + { + $ok = true; + $encodedIds = array_map('rawurlencode', $ids); + foreach ($this->checkResultCode($this->getClient()->deleteMulti($encodedIds)) as $result) { + if (\Memcached::RES_SUCCESS !== $result && \Memcached::RES_NOTFOUND !== $result) { + $ok = false; + break; + } + } + + return $ok; + } + + /** + * {@inheritdoc} + */ + protected function doClear($namespace) + { + return '' === $namespace && $this->getClient()->flush(); + } + + private function checkResultCode($result) + { + $code = $this->client->getResultCode(); + + if (\Memcached::RES_SUCCESS === $code || \Memcached::RES_NOTFOUND === $code) { + return $result; + } + + throw new CacheException(sprintf('MemcachedAdapter client error: %s.', strtolower($this->client->getResultMessage()))); + } + + private function getClient(): \Memcached + { + if ($this->client) { + return $this->client; + } + + $opt = $this->lazyClient->getOption(\Memcached::OPT_SERIALIZER); + if (\Memcached::SERIALIZER_PHP !== $opt && \Memcached::SERIALIZER_IGBINARY !== $opt) { + throw new CacheException('MemcachedAdapter: "serializer" option must be "php" or "igbinary".'); + } + if ('' !== $prefix = (string) $this->lazyClient->getOption(\Memcached::OPT_PREFIX_KEY)) { + throw new CacheException(sprintf('MemcachedAdapter: "prefix_key" option must be empty when using proxified connections, "%s" given.', $prefix)); + } + + return $this->client = $this->lazyClient; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/PdoTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/PdoTrait.php new file mode 100644 index 0000000..9b03f1c --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/PdoTrait.php @@ -0,0 +1,446 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DBALException; +use Doctrine\DBAL\Driver\ServerInfoAwareConnection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Exception\TableNotFoundException; +use Doctrine\DBAL\Schema\Schema; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Marshaller\DefaultMarshaller; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; + +/** + * @internal + */ +trait PdoTrait +{ + private $marshaller; + private $conn; + private $dsn; + private $driver; + private $serverVersion; + private $table = 'cache_items'; + private $idCol = 'item_id'; + private $dataCol = 'item_data'; + private $lifetimeCol = 'item_lifetime'; + private $timeCol = 'item_time'; + private $username = ''; + private $password = ''; + private $connectionOptions = []; + private $namespace; + + private function init($connOrDsn, string $namespace, int $defaultLifetime, array $options, ?MarshallerInterface $marshaller) + { + if (isset($namespace[0]) && preg_match('#[^-+.A-Za-z0-9]#', $namespace, $match)) { + throw new InvalidArgumentException(sprintf('Namespace contains "%s" but only characters in [-+.A-Za-z0-9] are allowed.', $match[0])); + } + + if ($connOrDsn instanceof \PDO) { + if (\PDO::ERRMODE_EXCEPTION !== $connOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) { + throw new InvalidArgumentException(sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)).', __CLASS__)); + } + + $this->conn = $connOrDsn; + } elseif ($connOrDsn instanceof Connection) { + $this->conn = $connOrDsn; + } elseif (\is_string($connOrDsn)) { + $this->dsn = $connOrDsn; + } else { + throw new InvalidArgumentException(sprintf('"%s" requires PDO or Doctrine\DBAL\Connection instance or DSN string as first argument, "%s" given.', __CLASS__, \is_object($connOrDsn) ? \get_class($connOrDsn) : \gettype($connOrDsn))); + } + + $this->table = isset($options['db_table']) ? $options['db_table'] : $this->table; + $this->idCol = isset($options['db_id_col']) ? $options['db_id_col'] : $this->idCol; + $this->dataCol = isset($options['db_data_col']) ? $options['db_data_col'] : $this->dataCol; + $this->lifetimeCol = isset($options['db_lifetime_col']) ? $options['db_lifetime_col'] : $this->lifetimeCol; + $this->timeCol = isset($options['db_time_col']) ? $options['db_time_col'] : $this->timeCol; + $this->username = isset($options['db_username']) ? $options['db_username'] : $this->username; + $this->password = isset($options['db_password']) ? $options['db_password'] : $this->password; + $this->connectionOptions = isset($options['db_connection_options']) ? $options['db_connection_options'] : $this->connectionOptions; + $this->namespace = $namespace; + $this->marshaller = $marshaller ?? new DefaultMarshaller(); + + parent::__construct($namespace, $defaultLifetime); + } + + /** + * Creates the table to store cache items which can be called once for setup. + * + * Cache ID are saved in a column of maximum length 255. Cache data is + * saved in a BLOB. + * + * @throws \PDOException When the table already exists + * @throws DBALException When the table already exists + * @throws \DomainException When an unsupported PDO driver is used + */ + public function createTable() + { + // connect if we are not yet + $conn = $this->getConnection(); + + if ($conn instanceof Connection) { + $types = [ + 'mysql' => 'binary', + 'sqlite' => 'text', + 'pgsql' => 'string', + 'oci' => 'string', + 'sqlsrv' => 'string', + ]; + if (!isset($types[$this->driver])) { + throw new \DomainException(sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $this->driver)); + } + + $schema = new Schema(); + $table = $schema->createTable($this->table); + $table->addColumn($this->idCol, $types[$this->driver], ['length' => 255]); + $table->addColumn($this->dataCol, 'blob', ['length' => 16777215]); + $table->addColumn($this->lifetimeCol, 'integer', ['unsigned' => true, 'notnull' => false]); + $table->addColumn($this->timeCol, 'integer', ['unsigned' => true]); + $table->setPrimaryKey([$this->idCol]); + + foreach ($schema->toSql($conn->getDatabasePlatform()) as $sql) { + $conn->exec($sql); + } + + return; + } + + switch ($this->driver) { + case 'mysql': + // We use varbinary for the ID column because it prevents unwanted conversions: + // - character set conversions between server and client + // - trailing space removal + // - case-insensitivity + // - language processing like é == e + $sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(255) NOT NULL PRIMARY KEY, $this->dataCol MEDIUMBLOB NOT NULL, $this->lifetimeCol INTEGER UNSIGNED, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB"; + break; + case 'sqlite': + $sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)"; + break; + case 'pgsql': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(255) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)"; + break; + case 'oci': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR2(255) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)"; + break; + case 'sqlsrv': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(255) NOT NULL PRIMARY KEY, $this->dataCol VARBINARY(MAX) NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)"; + break; + default: + throw new \DomainException(sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $this->driver)); + } + + $conn->exec($sql); + } + + /** + * {@inheritdoc} + */ + public function prune() + { + $deleteSql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= :time"; + + if ('' !== $this->namespace) { + $deleteSql .= " AND $this->idCol LIKE :namespace"; + } + + try { + $delete = $this->getConnection()->prepare($deleteSql); + } catch (TableNotFoundException $e) { + return true; + } catch (\PDOException $e) { + return true; + } + $delete->bindValue(':time', time(), \PDO::PARAM_INT); + + if ('' !== $this->namespace) { + $delete->bindValue(':namespace', sprintf('%s%%', $this->namespace), \PDO::PARAM_STR); + } + try { + return $delete->execute(); + } catch (TableNotFoundException $e) { + return true; + } catch (\PDOException $e) { + return true; + } + } + + /** + * {@inheritdoc} + */ + protected function doFetch(array $ids) + { + $now = time(); + $expired = []; + + $sql = str_pad('', (\count($ids) << 1) - 1, '?,'); + $sql = "SELECT $this->idCol, CASE WHEN $this->lifetimeCol IS NULL OR $this->lifetimeCol + $this->timeCol > ? THEN $this->dataCol ELSE NULL END FROM $this->table WHERE $this->idCol IN ($sql)"; + $stmt = $this->getConnection()->prepare($sql); + $stmt->bindValue($i = 1, $now, \PDO::PARAM_INT); + foreach ($ids as $id) { + $stmt->bindValue(++$i, $id); + } + $stmt->execute(); + + while ($row = $stmt->fetch(\PDO::FETCH_NUM)) { + if (null === $row[1]) { + $expired[] = $row[0]; + } else { + yield $row[0] => $this->marshaller->unmarshall(\is_resource($row[1]) ? stream_get_contents($row[1]) : $row[1]); + } + } + + if ($expired) { + $sql = str_pad('', (\count($expired) << 1) - 1, '?,'); + $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= ? AND $this->idCol IN ($sql)"; + $stmt = $this->getConnection()->prepare($sql); + $stmt->bindValue($i = 1, $now, \PDO::PARAM_INT); + foreach ($expired as $id) { + $stmt->bindValue(++$i, $id); + } + $stmt->execute(); + } + } + + /** + * {@inheritdoc} + */ + protected function doHave($id) + { + $sql = "SELECT 1 FROM $this->table WHERE $this->idCol = :id AND ($this->lifetimeCol IS NULL OR $this->lifetimeCol + $this->timeCol > :time)"; + $stmt = $this->getConnection()->prepare($sql); + + $stmt->bindValue(':id', $id); + $stmt->bindValue(':time', time(), \PDO::PARAM_INT); + $stmt->execute(); + + return (bool) $stmt->fetchColumn(); + } + + /** + * {@inheritdoc} + */ + protected function doClear($namespace) + { + $conn = $this->getConnection(); + + if ('' === $namespace) { + if ('sqlite' === $this->driver) { + $sql = "DELETE FROM $this->table"; + } else { + $sql = "TRUNCATE TABLE $this->table"; + } + } else { + $sql = "DELETE FROM $this->table WHERE $this->idCol LIKE '$namespace%'"; + } + + try { + $conn->exec($sql); + } catch (TableNotFoundException $e) { + } catch (\PDOException $e) { + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function doDelete(array $ids) + { + $sql = str_pad('', (\count($ids) << 1) - 1, '?,'); + $sql = "DELETE FROM $this->table WHERE $this->idCol IN ($sql)"; + try { + $stmt = $this->getConnection()->prepare($sql); + $stmt->execute(array_values($ids)); + } catch (TableNotFoundException $e) { + } catch (\PDOException $e) { + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, $lifetime) + { + if (!$values = $this->marshaller->marshall($values, $failed)) { + return $failed; + } + + $conn = $this->getConnection(); + $driver = $this->driver; + $insertSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"; + + switch (true) { + case 'mysql' === $driver: + $sql = $insertSql." ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)"; + break; + case 'oci' === $driver: + // DUAL is Oracle specific dummy table + $sql = "MERGE INTO $this->table USING DUAL ON ($this->idCol = ?) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ". + "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?"; + break; + case 'sqlsrv' === $driver && version_compare($this->getServerVersion(), '10', '>='): + // MERGE is only available since SQL Server 2008 and must be terminated by semicolon + // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx + $sql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ". + "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;"; + break; + case 'sqlite' === $driver: + $sql = 'INSERT OR REPLACE'.substr($insertSql, 6); + break; + case 'pgsql' === $driver && version_compare($this->getServerVersion(), '9.5', '>='): + $sql = $insertSql." ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)"; + break; + default: + $driver = null; + $sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id"; + break; + } + + $now = time(); + $lifetime = $lifetime ?: null; + try { + $stmt = $conn->prepare($sql); + } catch (TableNotFoundException $e) { + if (!$conn->isTransactionActive() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) { + $this->createTable(); + } + $stmt = $conn->prepare($sql); + } catch (\PDOException $e) { + if (!$conn->inTransaction() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) { + $this->createTable(); + } + $stmt = $conn->prepare($sql); + } + + if ('sqlsrv' === $driver || 'oci' === $driver) { + $stmt->bindParam(1, $id); + $stmt->bindParam(2, $id); + $stmt->bindParam(3, $data, \PDO::PARAM_LOB); + $stmt->bindValue(4, $lifetime, \PDO::PARAM_INT); + $stmt->bindValue(5, $now, \PDO::PARAM_INT); + $stmt->bindParam(6, $data, \PDO::PARAM_LOB); + $stmt->bindValue(7, $lifetime, \PDO::PARAM_INT); + $stmt->bindValue(8, $now, \PDO::PARAM_INT); + } else { + $stmt->bindParam(':id', $id); + $stmt->bindParam(':data', $data, \PDO::PARAM_LOB); + $stmt->bindValue(':lifetime', $lifetime, \PDO::PARAM_INT); + $stmt->bindValue(':time', $now, \PDO::PARAM_INT); + } + if (null === $driver) { + $insertStmt = $conn->prepare($insertSql); + + $insertStmt->bindParam(':id', $id); + $insertStmt->bindParam(':data', $data, \PDO::PARAM_LOB); + $insertStmt->bindValue(':lifetime', $lifetime, \PDO::PARAM_INT); + $insertStmt->bindValue(':time', $now, \PDO::PARAM_INT); + } + + foreach ($values as $id => $data) { + try { + $stmt->execute(); + } catch (TableNotFoundException $e) { + if (!$conn->isTransactionActive() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) { + $this->createTable(); + } + $stmt->execute(); + } catch (\PDOException $e) { + if (!$conn->inTransaction() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) { + $this->createTable(); + } + $stmt->execute(); + } + if (null === $driver && !$stmt->rowCount()) { + try { + $insertStmt->execute(); + } catch (DBALException $e) { + } catch (\PDOException $e) { + // A concurrent write won, let it be + } + } + } + + return $failed; + } + + /** + * @return \PDO|Connection + */ + private function getConnection() + { + if (null === $this->conn) { + if (strpos($this->dsn, '://')) { + if (!class_exists(DriverManager::class)) { + throw new InvalidArgumentException(sprintf('Failed to parse the DSN "%s". Try running "composer require doctrine/dbal".', $this->dsn)); + } + $this->conn = DriverManager::getConnection(['url' => $this->dsn]); + } else { + $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); + $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + } + } + if (null === $this->driver) { + if ($this->conn instanceof \PDO) { + $this->driver = $this->conn->getAttribute(\PDO::ATTR_DRIVER_NAME); + } else { + switch ($this->driver = $this->conn->getDriver()->getName()) { + case 'mysqli': + throw new \LogicException(sprintf('The adapter "%s" does not support the mysqli driver, use pdo_mysql instead.', static::class)); + case 'pdo_mysql': + case 'drizzle_pdo_mysql': + $this->driver = 'mysql'; + break; + case 'pdo_sqlite': + $this->driver = 'sqlite'; + break; + case 'pdo_pgsql': + $this->driver = 'pgsql'; + break; + case 'oci8': + case 'pdo_oracle': + $this->driver = 'oci'; + break; + case 'pdo_sqlsrv': + $this->driver = 'sqlsrv'; + break; + } + } + } + + return $this->conn; + } + + private function getServerVersion(): string + { + if (null === $this->serverVersion) { + $conn = $this->conn instanceof \PDO ? $this->conn : $this->conn->getWrappedConnection(); + if ($conn instanceof \PDO) { + $this->serverVersion = $conn->getAttribute(\PDO::ATTR_SERVER_VERSION); + } elseif ($conn instanceof ServerInfoAwareConnection) { + $this->serverVersion = $conn->getServerVersion(); + } else { + $this->serverVersion = '0'; + } + } + + return $this->serverVersion; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/PhpArrayTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/PhpArrayTrait.php new file mode 100644 index 0000000..be352e0 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/PhpArrayTrait.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Symfony\Component\Cache\Adapter\AdapterInterface; +use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\VarExporter\VarExporter; + +/** + * @author Titouan Galopin + * @author Nicolas Grekas + * + * @internal + */ +trait PhpArrayTrait +{ + use ProxyTrait; + + private $file; + private $keys; + private $values; + + private static $valuesCache = []; + + /** + * Store an array of cached values. + * + * @param array $values The cached values + */ + public function warmUp(array $values) + { + if (file_exists($this->file)) { + if (!is_file($this->file)) { + throw new InvalidArgumentException(sprintf('Cache path exists and is not a file: "%s".', $this->file)); + } + + if (!is_writable($this->file)) { + throw new InvalidArgumentException(sprintf('Cache file is not writable: "%s".', $this->file)); + } + } else { + $directory = \dirname($this->file); + + if (!is_dir($directory) && !@mkdir($directory, 0777, true)) { + throw new InvalidArgumentException(sprintf('Cache directory does not exist and cannot be created: "%s".', $directory)); + } + + if (!is_writable($directory)) { + throw new InvalidArgumentException(sprintf('Cache directory is not writable: "%s".', $directory)); + } + } + + $dumpedValues = ''; + $dumpedMap = []; + $dump = <<<'EOF' + $value) { + CacheItem::validateKey(\is_int($key) ? (string) $key : $key); + $isStaticValue = true; + + if (null === $value) { + $value = "'N;'"; + } elseif (\is_object($value) || \is_array($value)) { + try { + $value = VarExporter::export($value, $isStaticValue); + } catch (\Exception $e) { + throw new InvalidArgumentException(sprintf('Cache key "%s" has non-serializable "%s" value.', $key, \is_object($value) ? \get_class($value) : 'array'), 0, $e); + } + } elseif (\is_string($value)) { + // Wrap "N;" in a closure to not confuse it with an encoded `null` + if ('N;' === $value) { + $isStaticValue = false; + } + $value = var_export($value, true); + } elseif (!is_scalar($value)) { + throw new InvalidArgumentException(sprintf('Cache key "%s" has non-serializable "%s" value.', $key, \gettype($value))); + } else { + $value = var_export($value, true); + } + + if (!$isStaticValue) { + $value = str_replace("\n", "\n ", $value); + $value = "static function () {\n return {$value};\n}"; + } + $hash = hash('md5', $value); + + if (null === $id = $dumpedMap[$hash] ?? null) { + $id = $dumpedMap[$hash] = \count($dumpedMap); + $dumpedValues .= "{$id} => {$value},\n"; + } + + $dump .= var_export($key, true)." => {$id},\n"; + } + + $dump .= "\n], [\n\n{$dumpedValues}\n]];\n"; + + $tmpFile = uniqid($this->file, true); + + file_put_contents($tmpFile, $dump); + @chmod($tmpFile, 0666 & ~umask()); + unset($serialized, $value, $dump); + + @rename($tmpFile, $this->file); + unset(self::$valuesCache[$this->file]); + + $this->initialize(); + } + + /** + * {@inheritdoc} + * + * @param string $prefix + * + * @return bool + */ + public function clear(/*string $prefix = ''*/) + { + $prefix = 0 < \func_num_args() ? (string) func_get_arg(0) : ''; + $this->keys = $this->values = []; + + $cleared = @unlink($this->file) || !file_exists($this->file); + unset(self::$valuesCache[$this->file]); + + if ($this->pool instanceof AdapterInterface) { + return $this->pool->clear($prefix) && $cleared; + } + + return $this->pool->clear() && $cleared; + } + + /** + * Load the cache file. + */ + private function initialize() + { + if (isset(self::$valuesCache[$this->file])) { + $values = self::$valuesCache[$this->file]; + } elseif (!file_exists($this->file)) { + $this->keys = $this->values = []; + + return; + } else { + $values = self::$valuesCache[$this->file] = (include $this->file) ?: [[], []]; + } + + if (2 !== \count($values) || !isset($values[0], $values[1])) { + $this->keys = $this->values = []; + } else { + list($this->keys, $this->values) = $values; + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/PhpFilesTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/PhpFilesTrait.php new file mode 100644 index 0000000..6f8c45e --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/PhpFilesTrait.php @@ -0,0 +1,313 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Symfony\Component\Cache\Exception\CacheException; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\VarExporter\VarExporter; + +/** + * @author Piotr Stankowski + * @author Nicolas Grekas + * @author Rob Frawley 2nd + * + * @internal + */ +trait PhpFilesTrait +{ + use FilesystemCommonTrait { + doClear as private doCommonClear; + doDelete as private doCommonDelete; + } + + private $includeHandler; + private $appendOnly; + private $values = []; + private $files = []; + + private static $startTime; + private static $valuesCache = []; + + public static function isSupported() + { + self::$startTime = self::$startTime ?? $_SERVER['REQUEST_TIME'] ?? time(); + + return \function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), FILTER_VALIDATE_BOOLEAN) && (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) || filter_var(ini_get('opcache.enable_cli'), FILTER_VALIDATE_BOOLEAN)); + } + + /** + * @return bool + */ + public function prune() + { + $time = time(); + $pruned = true; + $getExpiry = true; + + set_error_handler($this->includeHandler); + try { + foreach ($this->scanHashDir($this->directory) as $file) { + try { + if (\is_array($expiresAt = include $file)) { + $expiresAt = $expiresAt[0]; + } + } catch (\ErrorException $e) { + $expiresAt = $time; + } + + if ($time >= $expiresAt) { + $pruned = $this->doUnlink($file) && !file_exists($file) && $pruned; + } + } + } finally { + restore_error_handler(); + } + + return $pruned; + } + + /** + * {@inheritdoc} + */ + protected function doFetch(array $ids) + { + if ($this->appendOnly) { + $now = 0; + $missingIds = []; + } else { + $now = time(); + $missingIds = $ids; + $ids = []; + } + $values = []; + + begin: + $getExpiry = false; + + foreach ($ids as $id) { + if (null === $value = $this->values[$id] ?? null) { + $missingIds[] = $id; + } elseif ('N;' === $value) { + $values[$id] = null; + } elseif (!\is_object($value)) { + $values[$id] = $value; + } elseif (!$value instanceof LazyValue) { + $values[$id] = $value(); + } elseif (false === $values[$id] = include $value->file) { + unset($values[$id], $this->values[$id]); + $missingIds[] = $id; + } + if (!$this->appendOnly) { + unset($this->values[$id]); + } + } + + if (!$missingIds) { + return $values; + } + + set_error_handler($this->includeHandler); + try { + $getExpiry = true; + + foreach ($missingIds as $k => $id) { + try { + $file = $this->files[$id] ?? $this->files[$id] = $this->getFile($id); + + if (isset(self::$valuesCache[$file])) { + [$expiresAt, $this->values[$id]] = self::$valuesCache[$file]; + } elseif (\is_array($expiresAt = include $file)) { + if ($this->appendOnly) { + self::$valuesCache[$file] = $expiresAt; + } + + [$expiresAt, $this->values[$id]] = $expiresAt; + } elseif ($now < $expiresAt) { + $this->values[$id] = new LazyValue($file); + } + + if ($now >= $expiresAt) { + unset($this->values[$id], $missingIds[$k], self::$valuesCache[$file]); + } + } catch (\ErrorException $e) { + unset($missingIds[$k]); + } + } + } finally { + restore_error_handler(); + } + + $ids = $missingIds; + $missingIds = []; + goto begin; + } + + /** + * {@inheritdoc} + */ + protected function doHave($id) + { + if ($this->appendOnly && isset($this->values[$id])) { + return true; + } + + set_error_handler($this->includeHandler); + try { + $file = $this->files[$id] ?? $this->files[$id] = $this->getFile($id); + $getExpiry = true; + + if (isset(self::$valuesCache[$file])) { + [$expiresAt, $value] = self::$valuesCache[$file]; + } elseif (\is_array($expiresAt = include $file)) { + if ($this->appendOnly) { + self::$valuesCache[$file] = $expiresAt; + } + + [$expiresAt, $value] = $expiresAt; + } elseif ($this->appendOnly) { + $value = new LazyValue($file); + } + } catch (\ErrorException $e) { + return false; + } finally { + restore_error_handler(); + } + if ($this->appendOnly) { + $now = 0; + $this->values[$id] = $value; + } else { + $now = time(); + } + + return $now < $expiresAt; + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, $lifetime) + { + $ok = true; + $expiry = $lifetime ? time() + $lifetime : 'PHP_INT_MAX'; + $allowCompile = self::isSupported(); + + foreach ($values as $key => $value) { + unset($this->values[$key]); + $isStaticValue = true; + if (null === $value) { + $value = "'N;'"; + } elseif (\is_object($value) || \is_array($value)) { + try { + $value = VarExporter::export($value, $isStaticValue); + } catch (\Exception $e) { + throw new InvalidArgumentException(sprintf('Cache key "%s" has non-serializable "%s" value.', $key, \is_object($value) ? \get_class($value) : 'array'), 0, $e); + } + } elseif (\is_string($value)) { + // Wrap "N;" in a closure to not confuse it with an encoded `null` + if ('N;' === $value) { + $isStaticValue = false; + } + $value = var_export($value, true); + } elseif (!is_scalar($value)) { + throw new InvalidArgumentException(sprintf('Cache key "%s" has non-serializable "%s" value.', $key, \gettype($value))); + } else { + $value = var_export($value, true); + } + + $encodedKey = rawurlencode($key); + + if ($isStaticValue) { + $value = "return [{$expiry}, {$value}];"; + } elseif ($this->appendOnly) { + $value = "return [{$expiry}, static function () { return {$value}; }];"; + } else { + // We cannot use a closure here because of https://bugs.php.net/76982 + $value = str_replace('\Symfony\Component\VarExporter\Internal\\', '', $value); + $value = "namespace Symfony\Component\VarExporter\Internal;\n\nreturn \$getExpiry ? {$expiry} : {$value};"; + } + + $file = $this->files[$key] = $this->getFile($key, true); + // Since OPcache only compiles files older than the script execution start, set the file's mtime in the past + $ok = $this->write($file, "directory)) { + throw new CacheException(sprintf('Cache directory is not writable (%s).', $this->directory)); + } + + return $ok; + } + + /** + * {@inheritdoc} + */ + protected function doClear($namespace) + { + $this->values = []; + + return $this->doCommonClear($namespace); + } + + /** + * {@inheritdoc} + */ + protected function doDelete(array $ids) + { + foreach ($ids as $id) { + unset($this->values[$id]); + } + + return $this->doCommonDelete($ids); + } + + protected function doUnlink($file) + { + unset(self::$valuesCache[$file]); + + if (self::isSupported()) { + @opcache_invalidate($file, true); + } + + return @unlink($file); + } + + private function getFileKey(string $file): string + { + if (!$h = @fopen($file, 'rb')) { + return ''; + } + + $encodedKey = substr(fgets($h), 8); + fclose($h); + + return rawurldecode(rtrim($encodedKey)); + } +} + +/** + * @internal + */ +class LazyValue +{ + public $file; + + public function __construct(string $file) + { + $this->file = $file; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ProxyTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ProxyTrait.php new file mode 100644 index 0000000..c86f360 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/ProxyTrait.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Contracts\Service\ResetInterface; + +/** + * @author Nicolas Grekas + * + * @internal + */ +trait ProxyTrait +{ + private $pool; + + /** + * {@inheritdoc} + */ + public function prune() + { + return $this->pool instanceof PruneableInterface && $this->pool->prune(); + } + + /** + * {@inheritdoc} + */ + public function reset() + { + if ($this->pool instanceof ResetInterface) { + $this->pool->reset(); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/RedisClusterProxy.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/RedisClusterProxy.php new file mode 100644 index 0000000..b4cef59 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/RedisClusterProxy.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +/** + * @author Alessandro Chitolina + * + * @internal + */ +class RedisClusterProxy +{ + private $redis; + private $initializer; + + public function __construct(\Closure $initializer) + { + $this->initializer = $initializer; + } + + public function __call($method, array $args) + { + $this->redis ?: $this->redis = $this->initializer->__invoke(); + + return $this->redis->{$method}(...$args); + } + + public function hscan($strKey, &$iIterator, $strPattern = null, $iCount = null) + { + $this->redis ?: $this->redis = $this->initializer->__invoke(); + + return $this->redis->hscan($strKey, $iIterator, $strPattern, $iCount); + } + + public function scan(&$iIterator, $strPattern = null, $iCount = null) + { + $this->redis ?: $this->redis = $this->initializer->__invoke(); + + return $this->redis->scan($iIterator, $strPattern, $iCount); + } + + public function sscan($strKey, &$iIterator, $strPattern = null, $iCount = null) + { + $this->redis ?: $this->redis = $this->initializer->__invoke(); + + return $this->redis->sscan($strKey, $iIterator, $strPattern, $iCount); + } + + public function zscan($strKey, &$iIterator, $strPattern = null, $iCount = null) + { + $this->redis ?: $this->redis = $this->initializer->__invoke(); + + return $this->redis->zscan($strKey, $iIterator, $strPattern, $iCount); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/RedisProxy.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/RedisProxy.php new file mode 100644 index 0000000..2b0b857 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/RedisProxy.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class RedisProxy +{ + private $redis; + private $initializer; + private $ready = false; + + public function __construct(\Redis $redis, \Closure $initializer) + { + $this->redis = $redis; + $this->initializer = $initializer; + } + + public function __call($method, array $args) + { + $this->ready ?: $this->ready = $this->initializer->__invoke($this->redis); + + return $this->redis->{$method}(...$args); + } + + public function hscan($strKey, &$iIterator, $strPattern = null, $iCount = null) + { + $this->ready ?: $this->ready = $this->initializer->__invoke($this->redis); + + return $this->redis->hscan($strKey, $iIterator, $strPattern, $iCount); + } + + public function scan(&$iIterator, $strPattern = null, $iCount = null) + { + $this->ready ?: $this->ready = $this->initializer->__invoke($this->redis); + + return $this->redis->scan($iIterator, $strPattern, $iCount); + } + + public function sscan($strKey, &$iIterator, $strPattern = null, $iCount = null) + { + $this->ready ?: $this->ready = $this->initializer->__invoke($this->redis); + + return $this->redis->sscan($strKey, $iIterator, $strPattern, $iCount); + } + + public function zscan($strKey, &$iIterator, $strPattern = null, $iCount = null) + { + $this->ready ?: $this->ready = $this->initializer->__invoke($this->redis); + + return $this->redis->zscan($strKey, $iIterator, $strPattern, $iCount); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/RedisTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/RedisTrait.php new file mode 100644 index 0000000..d934a5f --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/Traits/RedisTrait.php @@ -0,0 +1,511 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Predis\Connection\Aggregate\ClusterInterface; +use Predis\Connection\Aggregate\RedisCluster; +use Predis\Response\Status; +use Symfony\Component\Cache\Exception\CacheException; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Marshaller\DefaultMarshaller; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; + +/** + * @author Aurimas Niekis + * @author Nicolas Grekas + * + * @internal + */ +trait RedisTrait +{ + private static $defaultConnectionOptions = [ + 'class' => null, + 'persistent' => 0, + 'persistent_id' => null, + 'timeout' => 30, + 'read_timeout' => 0, + 'retry_interval' => 0, + 'tcp_keepalive' => 0, + 'lazy' => null, + 'redis_cluster' => false, + 'redis_sentinel' => null, + 'dbindex' => 0, + 'failover' => 'none', + ]; + private $redis; + private $marshaller; + + /** + * @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisClient + */ + private function init($redisClient, string $namespace, int $defaultLifetime, ?MarshallerInterface $marshaller) + { + parent::__construct($namespace, $defaultLifetime); + + if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) { + throw new InvalidArgumentException(sprintf('RedisAdapter namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0])); + } + + if (!$redisClient instanceof \Redis && !$redisClient instanceof \RedisArray && !$redisClient instanceof \RedisCluster && !$redisClient instanceof \Predis\ClientInterface && !$redisClient instanceof RedisProxy && !$redisClient instanceof RedisClusterProxy) { + throw new InvalidArgumentException(sprintf('"%s()" expects parameter 1 to be Redis, RedisArray, RedisCluster or Predis\ClientInterface, "%s" given.', __METHOD__, \is_object($redisClient) ? \get_class($redisClient) : \gettype($redisClient))); + } + + if ($redisClient instanceof \Predis\ClientInterface && $redisClient->getOptions()->exceptions) { + $options = clone $redisClient->getOptions(); + \Closure::bind(function () { $this->options['exceptions'] = false; }, $options, $options)(); + $redisClient = new $redisClient($redisClient->getConnection(), $options); + } + + $this->redis = $redisClient; + $this->marshaller = $marshaller ?? new DefaultMarshaller(); + } + + /** + * Creates a Redis connection using a DSN configuration. + * + * Example DSN: + * - redis://localhost + * - redis://example.com:1234 + * - redis://secret@example.com/13 + * - redis:///var/run/redis.sock + * - redis://secret@/var/run/redis.sock/13 + * + * @param string $dsn + * @param array $options See self::$defaultConnectionOptions + * + * @throws InvalidArgumentException when the DSN is invalid + * + * @return \Redis|\RedisCluster|\Predis\ClientInterface According to the "class" option + */ + public static function createConnection($dsn, array $options = []) + { + if (0 === strpos($dsn, 'redis:')) { + $scheme = 'redis'; + } elseif (0 === strpos($dsn, 'rediss:')) { + $scheme = 'rediss'; + } else { + throw new InvalidArgumentException(sprintf('Invalid Redis DSN: "%s" does not start with "redis:" or "rediss".', $dsn)); + } + + if (!\extension_loaded('redis') && !class_exists(\Predis\Client::class)) { + throw new CacheException(sprintf('Cannot find the "redis" extension nor the "predis/predis" package: "%s".', $dsn)); + } + + $params = preg_replace_callback('#^'.$scheme.':(//)?(?:(?:[^:@]*+:)?([^@]*+)@)?#', function ($m) use (&$auth) { + if (isset($m[2])) { + $auth = $m[2]; + } + + return 'file:'.($m[1] ?? ''); + }, $dsn); + + if (false === $params = parse_url($params)) { + throw new InvalidArgumentException(sprintf('Invalid Redis DSN: "%s".', $dsn)); + } + + $query = $hosts = []; + + if (isset($params['query'])) { + parse_str($params['query'], $query); + + if (isset($query['host'])) { + if (!\is_array($hosts = $query['host'])) { + throw new InvalidArgumentException(sprintf('Invalid Redis DSN: "%s".', $dsn)); + } + foreach ($hosts as $host => $parameters) { + if (\is_string($parameters)) { + parse_str($parameters, $parameters); + } + if (false === $i = strrpos($host, ':')) { + $hosts[$host] = ['scheme' => 'tcp', 'host' => $host, 'port' => 6379] + $parameters; + } elseif ($port = (int) substr($host, 1 + $i)) { + $hosts[$host] = ['scheme' => 'tcp', 'host' => substr($host, 0, $i), 'port' => $port] + $parameters; + } else { + $hosts[$host] = ['scheme' => 'unix', 'path' => substr($host, 0, $i)] + $parameters; + } + } + $hosts = array_values($hosts); + } + } + + if (isset($params['host']) || isset($params['path'])) { + if (!isset($params['dbindex']) && isset($params['path']) && preg_match('#/(\d+)$#', $params['path'], $m)) { + $params['dbindex'] = $m[1]; + $params['path'] = substr($params['path'], 0, -\strlen($m[0])); + } + + if (isset($params['host'])) { + array_unshift($hosts, ['scheme' => 'tcp', 'host' => $params['host'], 'port' => $params['port'] ?? 6379]); + } else { + array_unshift($hosts, ['scheme' => 'unix', 'path' => $params['path']]); + } + } + + if (!$hosts) { + throw new InvalidArgumentException(sprintf('Invalid Redis DSN: "%s".', $dsn)); + } + + if (isset($params['redis_sentinel']) && !class_exists(\Predis\Client::class)) { + throw new CacheException(sprintf('Redis Sentinel support requires the "predis/predis" package: "%s".', $dsn)); + } + + $params += $query + $options + self::$defaultConnectionOptions; + + if (null === $params['class'] && !isset($params['redis_sentinel']) && \extension_loaded('redis')) { + $class = $params['redis_cluster'] ? \RedisCluster::class : (1 < \count($hosts) ? \RedisArray::class : \Redis::class); + } else { + $class = null === $params['class'] ? \Predis\Client::class : $params['class']; + } + + if (is_a($class, \Redis::class, true)) { + $connect = $params['persistent'] || $params['persistent_id'] ? 'pconnect' : 'connect'; + $redis = new $class(); + + $initializer = function ($redis) use ($connect, $params, $dsn, $auth, $hosts) { + try { + @$redis->{$connect}($hosts[0]['host'] ?? $hosts[0]['path'], $hosts[0]['port'] ?? null, $params['timeout'], (string) $params['persistent_id'], $params['retry_interval']); + } catch (\RedisException $e) { + throw new InvalidArgumentException(sprintf('Redis connection failed (%s): "%s".', $e->getMessage(), $dsn)); + } + + set_error_handler(function ($type, $msg) use (&$error) { $error = $msg; }); + $isConnected = $redis->isConnected(); + restore_error_handler(); + if (!$isConnected) { + $error = preg_match('/^Redis::p?connect\(\): (.*)/', $error, $error) ? sprintf(' (%s)', $error[1]) : ''; + throw new InvalidArgumentException(sprintf('Redis connection failed%s: "%s".', $error, $dsn)); + } + + if ((null !== $auth && !$redis->auth($auth)) + || ($params['dbindex'] && !$redis->select($params['dbindex'])) + || ($params['read_timeout'] && !$redis->setOption(\Redis::OPT_READ_TIMEOUT, $params['read_timeout'])) + ) { + $e = preg_replace('/^ERR /', '', $redis->getLastError()); + throw new InvalidArgumentException(sprintf('Redis connection failed (%s): "%s".', $e, $dsn)); + } + + if (0 < $params['tcp_keepalive'] && \defined('Redis::OPT_TCP_KEEPALIVE')) { + $redis->setOption(\Redis::OPT_TCP_KEEPALIVE, $params['tcp_keepalive']); + } + + return true; + }; + + if ($params['lazy']) { + $redis = new RedisProxy($redis, $initializer); + } else { + $initializer($redis); + } + } elseif (is_a($class, \RedisArray::class, true)) { + foreach ($hosts as $i => $host) { + $hosts[$i] = 'tcp' === $host['scheme'] ? $host['host'].':'.$host['port'] : $host['path']; + } + $params['lazy_connect'] = $params['lazy'] ?? true; + $params['connect_timeout'] = $params['timeout']; + + try { + $redis = new $class($hosts, $params); + } catch (\RedisClusterException $e) { + throw new InvalidArgumentException(sprintf('Redis connection failed (%s): "%s".', $e->getMessage(), $dsn)); + } + + if (0 < $params['tcp_keepalive'] && \defined('Redis::OPT_TCP_KEEPALIVE')) { + $redis->setOption(\Redis::OPT_TCP_KEEPALIVE, $params['tcp_keepalive']); + } + } elseif (is_a($class, \RedisCluster::class, true)) { + $initializer = function () use ($class, $params, $dsn, $hosts) { + foreach ($hosts as $i => $host) { + $hosts[$i] = 'tcp' === $host['scheme'] ? $host['host'].':'.$host['port'] : $host['path']; + } + + try { + $redis = new $class(null, $hosts, $params['timeout'], $params['read_timeout'], (bool) $params['persistent']); + } catch (\RedisClusterException $e) { + throw new InvalidArgumentException(sprintf('Redis connection failed (%s): "%s".', $e->getMessage(), $dsn)); + } + + if (0 < $params['tcp_keepalive'] && \defined('Redis::OPT_TCP_KEEPALIVE')) { + $redis->setOption(\Redis::OPT_TCP_KEEPALIVE, $params['tcp_keepalive']); + } + switch ($params['failover']) { + case 'error': $redis->setOption(\RedisCluster::OPT_SLAVE_FAILOVER, \RedisCluster::FAILOVER_ERROR); break; + case 'distribute': $redis->setOption(\RedisCluster::OPT_SLAVE_FAILOVER, \RedisCluster::FAILOVER_DISTRIBUTE); break; + case 'slaves': $redis->setOption(\RedisCluster::OPT_SLAVE_FAILOVER, \RedisCluster::FAILOVER_DISTRIBUTE_SLAVES); break; + } + + return $redis; + }; + + $redis = $params['lazy'] ? new RedisClusterProxy($initializer) : $initializer(); + } elseif (is_a($class, \Predis\ClientInterface::class, true)) { + if ($params['redis_cluster']) { + $params['cluster'] = 'redis'; + if (isset($params['redis_sentinel'])) { + throw new InvalidArgumentException(sprintf('Cannot use both "redis_cluster" and "redis_sentinel" at the same time: "%s".', $dsn)); + } + } elseif (isset($params['redis_sentinel'])) { + $params['replication'] = 'sentinel'; + $params['service'] = $params['redis_sentinel']; + } + $params += ['parameters' => []]; + $params['parameters'] += [ + 'persistent' => $params['persistent'], + 'timeout' => $params['timeout'], + 'read_write_timeout' => $params['read_timeout'], + 'tcp_nodelay' => true, + ]; + if ($params['dbindex']) { + $params['parameters']['database'] = $params['dbindex']; + } + if (null !== $auth) { + $params['parameters']['password'] = $auth; + } + if (1 === \count($hosts) && !($params['redis_cluster'] || $params['redis_sentinel'])) { + $hosts = $hosts[0]; + } elseif (\in_array($params['failover'], ['slaves', 'distribute'], true) && !isset($params['replication'])) { + $params['replication'] = true; + $hosts[0] += ['alias' => 'master']; + } + $params['exceptions'] = false; + + $redis = new $class($hosts, array_diff_key($params, self::$defaultConnectionOptions)); + if (isset($params['redis_sentinel'])) { + $redis->getConnection()->setSentinelTimeout($params['timeout']); + } + } elseif (class_exists($class, false)) { + throw new InvalidArgumentException(sprintf('"%s" is not a subclass of "Redis", "RedisArray", "RedisCluster" nor "Predis\ClientInterface".', $class)); + } else { + throw new InvalidArgumentException(sprintf('Class "%s" does not exist.', $class)); + } + + return $redis; + } + + /** + * {@inheritdoc} + */ + protected function doFetch(array $ids) + { + if (!$ids) { + return []; + } + + $result = []; + + if ($this->redis instanceof \Predis\ClientInterface && $this->redis->getConnection() instanceof ClusterInterface) { + $values = $this->pipeline(function () use ($ids) { + foreach ($ids as $id) { + yield 'get' => [$id]; + } + }); + } else { + $values = $this->redis->mget($ids); + + if (!\is_array($values) || \count($values) !== \count($ids)) { + return []; + } + + $values = array_combine($ids, $values); + } + + foreach ($values as $id => $v) { + if ($v) { + $result[$id] = $this->marshaller->unmarshall($v); + } + } + + return $result; + } + + /** + * {@inheritdoc} + */ + protected function doHave($id) + { + return (bool) $this->redis->exists($id); + } + + /** + * {@inheritdoc} + */ + protected function doClear($namespace) + { + $cleared = true; + if ($this->redis instanceof \Predis\ClientInterface) { + $evalArgs = [0, $namespace]; + } else { + $evalArgs = [[$namespace], 0]; + } + + foreach ($this->getHosts() as $host) { + if (!isset($namespace[0])) { + $cleared = $host->flushDb() && $cleared; + continue; + } + + $info = $host->info('Server'); + $info = isset($info['Server']) ? $info['Server'] : $info; + + if (!version_compare($info['redis_version'], '2.8', '>=')) { + // As documented in Redis documentation (http://redis.io/commands/keys) using KEYS + // can hang your server when it is executed against large databases (millions of items). + // Whenever you hit this scale, you should really consider upgrading to Redis 2.8 or above. + $cleared = $host->eval("local keys=redis.call('KEYS',ARGV[1]..'*') for i=1,#keys,5000 do redis.call('DEL',unpack(keys,i,math.min(i+4999,#keys))) end return 1", $evalArgs[0], $evalArgs[1]) && $cleared; + continue; + } + + $cursor = null; + do { + $keys = $host instanceof \Predis\ClientInterface ? $host->scan($cursor, 'MATCH', $namespace.'*', 'COUNT', 1000) : $host->scan($cursor, $namespace.'*', 1000); + if (isset($keys[1]) && \is_array($keys[1])) { + $cursor = $keys[0]; + $keys = $keys[1]; + } + if ($keys) { + $this->doDelete($keys); + } + } while ($cursor = (int) $cursor); + } + + return $cleared; + } + + /** + * {@inheritdoc} + */ + protected function doDelete(array $ids) + { + if (!$ids) { + return true; + } + + if ($this->redis instanceof \Predis\ClientInterface && $this->redis->getConnection() instanceof ClusterInterface) { + $this->pipeline(function () use ($ids) { + foreach ($ids as $id) { + yield 'del' => [$id]; + } + })->rewind(); + } else { + $this->redis->del($ids); + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, $lifetime) + { + if (!$values = $this->marshaller->marshall($values, $failed)) { + return $failed; + } + + $results = $this->pipeline(function () use ($values, $lifetime) { + foreach ($values as $id => $value) { + if (0 >= $lifetime) { + yield 'set' => [$id, $value]; + } else { + yield 'setEx' => [$id, $lifetime, $value]; + } + } + }); + + foreach ($results as $id => $result) { + if (true !== $result && (!$result instanceof Status || Status::get('OK') !== $result)) { + $failed[] = $id; + } + } + + return $failed; + } + + private function pipeline(\Closure $generator, $redis = null): \Generator + { + $ids = []; + $redis = $redis ?? $this->redis; + + if ($redis instanceof RedisClusterProxy || $redis instanceof \RedisCluster || ($redis instanceof \Predis\ClientInterface && $redis->getConnection() instanceof RedisCluster)) { + // phpredis & predis don't support pipelining with RedisCluster + // see https://github.com/phpredis/phpredis/blob/develop/cluster.markdown#pipelining + // see https://github.com/nrk/predis/issues/267#issuecomment-123781423 + $results = []; + foreach ($generator() as $command => $args) { + $results[] = $redis->{$command}(...$args); + $ids[] = 'eval' === $command ? ($redis instanceof \Predis\ClientInterface ? $args[2] : $args[1][0]) : $args[0]; + } + } elseif ($redis instanceof \Predis\ClientInterface) { + $results = $redis->pipeline(static function ($redis) use ($generator, &$ids) { + foreach ($generator() as $command => $args) { + $redis->{$command}(...$args); + $ids[] = 'eval' === $command ? $args[2] : $args[0]; + } + }); + } elseif ($redis instanceof \RedisArray) { + $connections = $results = $ids = []; + foreach ($generator() as $command => $args) { + $id = 'eval' === $command ? $args[1][0] : $args[0]; + if (!isset($connections[$h = $redis->_target($id)])) { + $connections[$h] = [$redis->_instance($h), -1]; + $connections[$h][0]->multi(\Redis::PIPELINE); + } + $connections[$h][0]->{$command}(...$args); + $results[] = [$h, ++$connections[$h][1]]; + $ids[] = $id; + } + foreach ($connections as $h => $c) { + $connections[$h] = $c[0]->exec(); + } + foreach ($results as $k => list($h, $c)) { + $results[$k] = $connections[$h][$c]; + } + } else { + $redis->multi(\Redis::PIPELINE); + foreach ($generator() as $command => $args) { + $redis->{$command}(...$args); + $ids[] = 'eval' === $command ? $args[1][0] : $args[0]; + } + $results = $redis->exec(); + } + + foreach ($ids as $k => $id) { + yield $id => $results[$k]; + } + } + + private function getHosts(): array + { + $hosts = [$this->redis]; + if ($this->redis instanceof \Predis\ClientInterface) { + $connection = $this->redis->getConnection(); + if ($connection instanceof ClusterInterface && $connection instanceof \Traversable) { + $hosts = []; + foreach ($connection as $c) { + $hosts[] = new \Predis\Client($c); + } + } + } elseif ($this->redis instanceof \RedisArray) { + $hosts = []; + foreach ($this->redis->_hosts() as $host) { + $hosts[] = $this->redis->_instance($host); + } + } elseif ($this->redis instanceof RedisClusterProxy || $this->redis instanceof \RedisCluster) { + $hosts = []; + foreach ($this->redis->_masters() as $host) { + $hosts[] = $h = new \Redis(); + $h->connect($host[0], $host[1]); + } + } + + return $hosts; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/cache/composer.json b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/composer.json new file mode 100644 index 0000000..0f0033c --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/cache/composer.json @@ -0,0 +1,59 @@ +{ + "name": "symfony/cache", + "type": "library", + "description": "Symfony Cache component with PSR-6, PSR-16, and tags", + "keywords": ["caching", "psr6"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "provide": { + "psr/cache-implementation": "1.0", + "psr/simple-cache-implementation": "1.0", + "symfony/cache-implementation": "1.0" + }, + "require": { + "php": "^7.1.3", + "psr/cache": "~1.0", + "psr/log": "~1.0", + "symfony/cache-contracts": "^1.1.7|^2", + "symfony/service-contracts": "^1.1|^2", + "symfony/var-exporter": "^4.2|^5.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/cache": "~1.6", + "doctrine/dbal": "~2.5", + "predis/predis": "~1.1", + "psr/simple-cache": "^1.0", + "symfony/config": "^4.2|^5.0", + "symfony/dependency-injection": "^3.4|^4.1|^5.0", + "symfony/var-dumper": "^4.4|^5.0" + }, + "conflict": { + "doctrine/dbal": "<2.5", + "symfony/dependency-injection": "<3.4", + "symfony/http-kernel": "<4.4", + "symfony/var-dumper": "<4.4" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Cache\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/.gitignore b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/.gitignore new file mode 100644 index 0000000..c49a5d8 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/CHANGELOG.md b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/CHANGELOG.md new file mode 100644 index 0000000..d00d17c --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/CHANGELOG.md @@ -0,0 +1,12 @@ +CHANGELOG +========= + +2.6.0 +----- + + * Added ExpressionFunction and ExpressionFunctionProviderInterface + +2.4.0 +----- + + * added the component diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Compiler.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Compiler.php new file mode 100644 index 0000000..282e82d --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Compiler.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Compiles a node to PHP code. + * + * @author Fabien Potencier + */ +class Compiler +{ + private $source; + private $functions; + + public function __construct(array $functions) + { + $this->functions = $functions; + } + + public function getFunction($name) + { + return $this->functions[$name]; + } + + /** + * Gets the current PHP code after compilation. + * + * @return string The PHP code + */ + public function getSource() + { + return $this->source; + } + + public function reset() + { + $this->source = ''; + + return $this; + } + + /** + * Compiles a node. + * + * @return $this + */ + public function compile(Node\Node $node) + { + $node->compile($this); + + return $this; + } + + public function subcompile(Node\Node $node) + { + $current = $this->source; + $this->source = ''; + + $node->compile($this); + + $source = $this->source; + $this->source = $current; + + return $source; + } + + /** + * Adds a raw string to the compiled code. + * + * @param string $string The string + * + * @return $this + */ + public function raw($string) + { + $this->source .= $string; + + return $this; + } + + /** + * Adds a quoted string to the compiled code. + * + * @param string $value The string + * + * @return $this + */ + public function string($value) + { + $this->source .= sprintf('"%s"', addcslashes($value, "\0\t\"\$\\")); + + return $this; + } + + /** + * Returns a PHP representation of a given value. + * + * @param mixed $value The value to convert + * + * @return $this + */ + public function repr($value) + { + if (\is_int($value) || \is_float($value)) { + if (false !== $locale = setlocale(LC_NUMERIC, 0)) { + setlocale(LC_NUMERIC, 'C'); + } + + $this->raw($value); + + if (false !== $locale) { + setlocale(LC_NUMERIC, $locale); + } + } elseif (null === $value) { + $this->raw('null'); + } elseif (\is_bool($value)) { + $this->raw($value ? 'true' : 'false'); + } elseif (\is_array($value)) { + $this->raw('['); + $first = true; + foreach ($value as $key => $value) { + if (!$first) { + $this->raw(', '); + } + $first = false; + $this->repr($key); + $this->raw(' => '); + $this->repr($value); + } + $this->raw(']'); + } else { + $this->string($value); + } + + return $this; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Expression.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Expression.php new file mode 100644 index 0000000..ac656cc --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Expression.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Represents an expression. + * + * @author Fabien Potencier + */ +class Expression +{ + protected $expression; + + /** + * @param string $expression An expression + */ + public function __construct($expression) + { + $this->expression = (string) $expression; + } + + /** + * Gets the expression. + * + * @return string The expression + */ + public function __toString() + { + return $this->expression; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ExpressionFunction.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ExpressionFunction.php new file mode 100644 index 0000000..77c88f6 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ExpressionFunction.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Represents a function that can be used in an expression. + * + * A function is defined by two PHP callables. The callables are used + * by the language to compile and/or evaluate the function. + * + * The "compiler" function is used at compilation time and must return a + * PHP representation of the function call (it receives the function + * arguments as arguments). + * + * The "evaluator" function is used for expression evaluation and must return + * the value of the function call based on the values defined for the + * expression (it receives the values as a first argument and the function + * arguments as remaining arguments). + * + * @author Fabien Potencier + */ +class ExpressionFunction +{ + private $name; + private $compiler; + private $evaluator; + + /** + * @param string $name The function name + * @param callable $compiler A callable able to compile the function + * @param callable $evaluator A callable able to evaluate the function + */ + public function __construct($name, callable $compiler, callable $evaluator) + { + $this->name = $name; + $this->compiler = $compiler; + $this->evaluator = $evaluator; + } + + public function getName() + { + return $this->name; + } + + public function getCompiler() + { + return $this->compiler; + } + + public function getEvaluator() + { + return $this->evaluator; + } + + /** + * Creates an ExpressionFunction from a PHP function name. + * + * @param string $phpFunctionName The PHP function name + * @param string|null $expressionFunctionName The expression function name (default: same than the PHP function name) + * + * @return self + * + * @throws \InvalidArgumentException if given PHP function name does not exist + * @throws \InvalidArgumentException if given PHP function name is in namespace + * and expression function name is not defined + */ + public static function fromPhp($phpFunctionName, $expressionFunctionName = null) + { + $phpFunctionName = ltrim($phpFunctionName, '\\'); + if (!\function_exists($phpFunctionName)) { + throw new \InvalidArgumentException(sprintf('PHP function "%s" does not exist.', $phpFunctionName)); + } + + $parts = explode('\\', $phpFunctionName); + if (!$expressionFunctionName && \count($parts) > 1) { + throw new \InvalidArgumentException(sprintf('An expression function name must be defined when PHP function "%s" is namespaced.', $phpFunctionName)); + } + + $compiler = function () use ($phpFunctionName) { + return sprintf('\%s(%s)', $phpFunctionName, implode(', ', \func_get_args())); + }; + + $evaluator = function () use ($phpFunctionName) { + $args = \func_get_args(); + + return \call_user_func_array($phpFunctionName, array_splice($args, 1)); + }; + + return new self($expressionFunctionName ?: end($parts), $compiler, $evaluator); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ExpressionFunctionProviderInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ExpressionFunctionProviderInterface.php new file mode 100644 index 0000000..414b013 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ExpressionFunctionProviderInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * @author Fabien Potencier + */ +interface ExpressionFunctionProviderInterface +{ + /** + * @return ExpressionFunction[] An array of Function instances + */ + public function getFunctions(); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ExpressionLanguage.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ExpressionLanguage.php new file mode 100644 index 0000000..ad06107 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ExpressionLanguage.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheAdapter; +use Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface; + +/** + * Allows to compile and evaluate expressions written in your own DSL. + * + * @author Fabien Potencier + */ +class ExpressionLanguage +{ + private $cache; + private $lexer; + private $parser; + private $compiler; + + protected $functions = []; + + /** + * @param CacheItemPoolInterface $cache + * @param ExpressionFunctionProviderInterface[] $providers + */ + public function __construct($cache = null, array $providers = []) + { + if (null !== $cache) { + if ($cache instanceof ParserCacheInterface) { + @trigger_error(sprintf('Passing an instance of %s as constructor argument for %s is deprecated as of 3.2 and will be removed in 4.0. Pass an instance of %s instead.', ParserCacheInterface::class, self::class, CacheItemPoolInterface::class), E_USER_DEPRECATED); + $cache = new ParserCacheAdapter($cache); + } elseif (!$cache instanceof CacheItemPoolInterface) { + throw new \InvalidArgumentException(sprintf('Cache argument has to implement "%s".', CacheItemPoolInterface::class)); + } + } + + $this->cache = $cache ?: new ArrayAdapter(); + $this->registerFunctions(); + foreach ($providers as $provider) { + $this->registerProvider($provider); + } + } + + /** + * Compiles an expression source code. + * + * @param Expression|string $expression The expression to compile + * @param array $names An array of valid names + * + * @return string The compiled PHP source code + */ + public function compile($expression, $names = []) + { + return $this->getCompiler()->compile($this->parse($expression, $names)->getNodes())->getSource(); + } + + /** + * Evaluate an expression. + * + * @param Expression|string $expression The expression to compile + * @param array $values An array of values + * + * @return mixed The result of the evaluation of the expression + */ + public function evaluate($expression, $values = []) + { + return $this->parse($expression, array_keys($values))->getNodes()->evaluate($this->functions, $values); + } + + /** + * Parses an expression. + * + * @param Expression|string $expression The expression to parse + * @param array $names An array of valid names + * + * @return ParsedExpression A ParsedExpression instance + */ + public function parse($expression, $names) + { + if ($expression instanceof ParsedExpression) { + return $expression; + } + + asort($names); + $cacheKeyItems = []; + + foreach ($names as $nameKey => $name) { + $cacheKeyItems[] = \is_int($nameKey) ? $name : $nameKey.':'.$name; + } + + $cacheItem = $this->cache->getItem(rawurlencode($expression.'//'.implode('|', $cacheKeyItems))); + + if (null === $parsedExpression = $cacheItem->get()) { + $nodes = $this->getParser()->parse($this->getLexer()->tokenize((string) $expression), $names); + $parsedExpression = new ParsedExpression((string) $expression, $nodes); + + $cacheItem->set($parsedExpression); + $this->cache->save($cacheItem); + } + + return $parsedExpression; + } + + /** + * Registers a function. + * + * @param string $name The function name + * @param callable $compiler A callable able to compile the function + * @param callable $evaluator A callable able to evaluate the function + * + * @throws \LogicException when registering a function after calling evaluate(), compile() or parse() + * + * @see ExpressionFunction + */ + public function register($name, callable $compiler, callable $evaluator) + { + if (null !== $this->parser) { + throw new \LogicException('Registering functions after calling evaluate(), compile() or parse() is not supported.'); + } + + $this->functions[$name] = ['compiler' => $compiler, 'evaluator' => $evaluator]; + } + + public function addFunction(ExpressionFunction $function) + { + $this->register($function->getName(), $function->getCompiler(), $function->getEvaluator()); + } + + public function registerProvider(ExpressionFunctionProviderInterface $provider) + { + foreach ($provider->getFunctions() as $function) { + $this->addFunction($function); + } + } + + protected function registerFunctions() + { + $this->addFunction(ExpressionFunction::fromPhp('constant')); + } + + private function getLexer() + { + if (null === $this->lexer) { + $this->lexer = new Lexer(); + } + + return $this->lexer; + } + + private function getParser() + { + if (null === $this->parser) { + $this->parser = new Parser($this->functions); + } + + return $this->parser; + } + + private function getCompiler() + { + if (null === $this->compiler) { + $this->compiler = new Compiler($this->functions); + } + + return $this->compiler->reset(); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/LICENSE b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/LICENSE new file mode 100644 index 0000000..9e936ec --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-2020 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Lexer.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Lexer.php new file mode 100644 index 0000000..e534a56 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Lexer.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Lexes an expression. + * + * @author Fabien Potencier + */ +class Lexer +{ + /** + * Tokenizes an expression. + * + * @param string $expression The expression to tokenize + * + * @return TokenStream A token stream instance + * + * @throws SyntaxError + */ + public function tokenize($expression) + { + $expression = str_replace(["\r", "\n", "\t", "\v", "\f"], ' ', $expression); + $cursor = 0; + $tokens = []; + $brackets = []; + $end = \strlen($expression); + + while ($cursor < $end) { + if (' ' == $expression[$cursor]) { + ++$cursor; + + continue; + } + + if (preg_match('/[0-9]+(?:\.[0-9]+)?/A', $expression, $match, 0, $cursor)) { + // numbers + $number = (float) $match[0]; // floats + if (preg_match('/^[0-9]+$/', $match[0]) && $number <= PHP_INT_MAX) { + $number = (int) $match[0]; // integers lower than the maximum + } + $tokens[] = new Token(Token::NUMBER_TYPE, $number, $cursor + 1); + $cursor += \strlen($match[0]); + } elseif (false !== strpos('([{', $expression[$cursor])) { + // opening bracket + $brackets[] = [$expression[$cursor], $cursor]; + + $tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1); + ++$cursor; + } elseif (false !== strpos(')]}', $expression[$cursor])) { + // closing bracket + if (empty($brackets)) { + throw new SyntaxError(sprintf('Unexpected "%s".', $expression[$cursor]), $cursor, $expression); + } + + list($expect, $cur) = array_pop($brackets); + if ($expression[$cursor] != strtr($expect, '([{', ')]}')) { + throw new SyntaxError(sprintf('Unclosed "%s".', $expect), $cur, $expression); + } + + $tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1); + ++$cursor; + } elseif (preg_match('/"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\'/As', $expression, $match, 0, $cursor)) { + // strings + $tokens[] = new Token(Token::STRING_TYPE, stripcslashes(substr($match[0], 1, -1)), $cursor + 1); + $cursor += \strlen($match[0]); + } elseif (preg_match('/(?<=^|[\s(])not in(?=[\s(])|\!\=\=|(?<=^|[\s(])not(?=[\s(])|(?<=^|[\s(])and(?=[\s(])|\=\=\=|\>\=|(?<=^|[\s(])or(?=[\s(])|\<\=|\*\*|\.\.|(?<=^|[\s(])in(?=[\s(])|&&|\|\||(?<=^|[\s(])matches|\=\=|\!\=|\*|~|%|\/|\>|\||\!|\^|&|\+|\<|\-/A', $expression, $match, 0, $cursor)) { + // operators + $tokens[] = new Token(Token::OPERATOR_TYPE, $match[0], $cursor + 1); + $cursor += \strlen($match[0]); + } elseif (false !== strpos('.,?:', $expression[$cursor])) { + // punctuation + $tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1); + ++$cursor; + } elseif (preg_match('/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A', $expression, $match, 0, $cursor)) { + // names + $tokens[] = new Token(Token::NAME_TYPE, $match[0], $cursor + 1); + $cursor += \strlen($match[0]); + } else { + // unlexable + throw new SyntaxError(sprintf('Unexpected character "%s".', $expression[$cursor]), $cursor, $expression); + } + } + + $tokens[] = new Token(Token::EOF_TYPE, null, $cursor + 1); + + if (!empty($brackets)) { + list($expect, $cur) = array_pop($brackets); + throw new SyntaxError(sprintf('Unclosed "%s".', $expect), $cur, $expression); + } + + return new TokenStream($tokens, $expression); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ArgumentsNode.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ArgumentsNode.php new file mode 100644 index 0000000..e9849a4 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ArgumentsNode.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * @author Fabien Potencier + * + * @internal + */ +class ArgumentsNode extends ArrayNode +{ + public function compile(Compiler $compiler) + { + $this->compileArguments($compiler, false); + } + + public function toArray() + { + $array = []; + + foreach ($this->getKeyValuePairs() as $pair) { + $array[] = $pair['value']; + $array[] = ', '; + } + array_pop($array); + + return $array; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ArrayNode.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ArrayNode.php new file mode 100644 index 0000000..921319a --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ArrayNode.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * @author Fabien Potencier + * + * @internal + */ +class ArrayNode extends Node +{ + protected $index; + + public function __construct() + { + $this->index = -1; + } + + public function addElement(Node $value, Node $key = null) + { + if (null === $key) { + $key = new ConstantNode(++$this->index); + } + + array_push($this->nodes, $key, $value); + } + + /** + * Compiles the node to PHP. + */ + public function compile(Compiler $compiler) + { + $compiler->raw('['); + $this->compileArguments($compiler); + $compiler->raw(']'); + } + + public function evaluate($functions, $values) + { + $result = []; + foreach ($this->getKeyValuePairs() as $pair) { + $result[$pair['key']->evaluate($functions, $values)] = $pair['value']->evaluate($functions, $values); + } + + return $result; + } + + public function toArray() + { + $value = []; + foreach ($this->getKeyValuePairs() as $pair) { + $value[$pair['key']->attributes['value']] = $pair['value']; + } + + $array = []; + + if ($this->isHash($value)) { + foreach ($value as $k => $v) { + $array[] = ', '; + $array[] = new ConstantNode($k); + $array[] = ': '; + $array[] = $v; + } + $array[0] = '{'; + $array[] = '}'; + } else { + foreach ($value as $v) { + $array[] = ', '; + $array[] = $v; + } + $array[0] = '['; + $array[] = ']'; + } + + return $array; + } + + protected function getKeyValuePairs() + { + $pairs = []; + foreach (array_chunk($this->nodes, 2) as $pair) { + $pairs[] = ['key' => $pair[0], 'value' => $pair[1]]; + } + + return $pairs; + } + + protected function compileArguments(Compiler $compiler, $withKeys = true) + { + $first = true; + foreach ($this->getKeyValuePairs() as $pair) { + if (!$first) { + $compiler->raw(', '); + } + $first = false; + + if ($withKeys) { + $compiler + ->compile($pair['key']) + ->raw(' => ') + ; + } + + $compiler->compile($pair['value']); + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/BinaryNode.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/BinaryNode.php new file mode 100644 index 0000000..549161b --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/BinaryNode.php @@ -0,0 +1,170 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * @author Fabien Potencier + * + * @internal + */ +class BinaryNode extends Node +{ + private static $operators = [ + '~' => '.', + 'and' => '&&', + 'or' => '||', + ]; + + private static $functions = [ + '**' => 'pow', + '..' => 'range', + 'in' => 'in_array', + 'not in' => '!in_array', + ]; + + public function __construct($operator, Node $left, Node $right) + { + parent::__construct( + ['left' => $left, 'right' => $right], + ['operator' => $operator] + ); + } + + public function compile(Compiler $compiler) + { + $operator = $this->attributes['operator']; + + if ('matches' == $operator) { + $compiler + ->raw('preg_match(') + ->compile($this->nodes['right']) + ->raw(', ') + ->compile($this->nodes['left']) + ->raw(')') + ; + + return; + } + + if (isset(self::$functions[$operator])) { + $compiler + ->raw(sprintf('%s(', self::$functions[$operator])) + ->compile($this->nodes['left']) + ->raw(', ') + ->compile($this->nodes['right']) + ->raw(')') + ; + + return; + } + + if (isset(self::$operators[$operator])) { + $operator = self::$operators[$operator]; + } + + $compiler + ->raw('(') + ->compile($this->nodes['left']) + ->raw(' ') + ->raw($operator) + ->raw(' ') + ->compile($this->nodes['right']) + ->raw(')') + ; + } + + public function evaluate($functions, $values) + { + $operator = $this->attributes['operator']; + $left = $this->nodes['left']->evaluate($functions, $values); + + if (isset(self::$functions[$operator])) { + $right = $this->nodes['right']->evaluate($functions, $values); + + if ('not in' === $operator) { + return !\in_array($left, $right); + } + $f = self::$functions[$operator]; + + return $f($left, $right); + } + + switch ($operator) { + case 'or': + case '||': + return $left || $this->nodes['right']->evaluate($functions, $values); + case 'and': + case '&&': + return $left && $this->nodes['right']->evaluate($functions, $values); + } + + $right = $this->nodes['right']->evaluate($functions, $values); + + switch ($operator) { + case '|': + return $left | $right; + case '^': + return $left ^ $right; + case '&': + return $left & $right; + case '==': + return $left == $right; + case '===': + return $left === $right; + case '!=': + return $left != $right; + case '!==': + return $left !== $right; + case '<': + return $left < $right; + case '>': + return $left > $right; + case '>=': + return $left >= $right; + case '<=': + return $left <= $right; + case 'not in': + return !\in_array($left, $right); + case 'in': + return \in_array($left, $right); + case '+': + return $left + $right; + case '-': + return $left - $right; + case '~': + return $left.$right; + case '*': + return $left * $right; + case '/': + if (0 == $right) { + throw new \DivisionByZeroError('Division by zero.'); + } + + return $left / $right; + case '%': + if (0 == $right) { + throw new \DivisionByZeroError('Modulo by zero.'); + } + + return $left % $right; + case 'matches': + return preg_match($right, $left); + } + } + + public function toArray() + { + return ['(', $this->nodes['left'], ' '.$this->attributes['operator'].' ', $this->nodes['right'], ')']; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ConditionalNode.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ConditionalNode.php new file mode 100644 index 0000000..ca1b484 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ConditionalNode.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * @author Fabien Potencier + * + * @internal + */ +class ConditionalNode extends Node +{ + public function __construct(Node $expr1, Node $expr2, Node $expr3) + { + parent::__construct( + ['expr1' => $expr1, 'expr2' => $expr2, 'expr3' => $expr3] + ); + } + + public function compile(Compiler $compiler) + { + $compiler + ->raw('((') + ->compile($this->nodes['expr1']) + ->raw(') ? (') + ->compile($this->nodes['expr2']) + ->raw(') : (') + ->compile($this->nodes['expr3']) + ->raw('))') + ; + } + + public function evaluate($functions, $values) + { + if ($this->nodes['expr1']->evaluate($functions, $values)) { + return $this->nodes['expr2']->evaluate($functions, $values); + } + + return $this->nodes['expr3']->evaluate($functions, $values); + } + + public function toArray() + { + return ['(', $this->nodes['expr1'], ' ? ', $this->nodes['expr2'], ' : ', $this->nodes['expr3'], ')']; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ConstantNode.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ConstantNode.php new file mode 100644 index 0000000..670e52e --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/ConstantNode.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * @author Fabien Potencier + * + * @internal + */ +class ConstantNode extends Node +{ + private $isIdentifier; + + public function __construct($value, $isIdentifier = false) + { + $this->isIdentifier = $isIdentifier; + parent::__construct( + [], + ['value' => $value] + ); + } + + public function compile(Compiler $compiler) + { + $compiler->repr($this->attributes['value']); + } + + public function evaluate($functions, $values) + { + return $this->attributes['value']; + } + + public function toArray() + { + $array = []; + $value = $this->attributes['value']; + + if ($this->isIdentifier) { + $array[] = $value; + } elseif (true === $value) { + $array[] = 'true'; + } elseif (false === $value) { + $array[] = 'false'; + } elseif (null === $value) { + $array[] = 'null'; + } elseif (is_numeric($value)) { + $array[] = $value; + } elseif (!\is_array($value)) { + $array[] = $this->dumpString($value); + } elseif ($this->isHash($value)) { + foreach ($value as $k => $v) { + $array[] = ', '; + $array[] = new self($k); + $array[] = ': '; + $array[] = new self($v); + } + $array[0] = '{'; + $array[] = '}'; + } else { + foreach ($value as $v) { + $array[] = ', '; + $array[] = new self($v); + } + $array[0] = '['; + $array[] = ']'; + } + + return $array; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/FunctionNode.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/FunctionNode.php new file mode 100644 index 0000000..90008d5 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/FunctionNode.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * @author Fabien Potencier + * + * @internal + */ +class FunctionNode extends Node +{ + public function __construct($name, Node $arguments) + { + parent::__construct( + ['arguments' => $arguments], + ['name' => $name] + ); + } + + public function compile(Compiler $compiler) + { + $arguments = []; + foreach ($this->nodes['arguments']->nodes as $node) { + $arguments[] = $compiler->subcompile($node); + } + + $function = $compiler->getFunction($this->attributes['name']); + + $compiler->raw(\call_user_func_array($function['compiler'], $arguments)); + } + + public function evaluate($functions, $values) + { + $arguments = [$values]; + foreach ($this->nodes['arguments']->nodes as $node) { + $arguments[] = $node->evaluate($functions, $values); + } + + return \call_user_func_array($functions[$this->attributes['name']]['evaluator'], $arguments); + } + + public function toArray() + { + $array = []; + $array[] = $this->attributes['name']; + + foreach ($this->nodes['arguments']->nodes as $node) { + $array[] = ', '; + $array[] = $node; + } + $array[1] = '('; + $array[] = ')'; + + return $array; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/GetAttrNode.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/GetAttrNode.php new file mode 100644 index 0000000..1cf414a --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/GetAttrNode.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * @author Fabien Potencier + * + * @internal + */ +class GetAttrNode extends Node +{ + const PROPERTY_CALL = 1; + const METHOD_CALL = 2; + const ARRAY_CALL = 3; + + public function __construct(Node $node, Node $attribute, ArrayNode $arguments, $type) + { + parent::__construct( + ['node' => $node, 'attribute' => $attribute, 'arguments' => $arguments], + ['type' => $type] + ); + } + + public function compile(Compiler $compiler) + { + switch ($this->attributes['type']) { + case self::PROPERTY_CALL: + $compiler + ->compile($this->nodes['node']) + ->raw('->') + ->raw($this->nodes['attribute']->attributes['value']) + ; + break; + + case self::METHOD_CALL: + $compiler + ->compile($this->nodes['node']) + ->raw('->') + ->raw($this->nodes['attribute']->attributes['value']) + ->raw('(') + ->compile($this->nodes['arguments']) + ->raw(')') + ; + break; + + case self::ARRAY_CALL: + $compiler + ->compile($this->nodes['node']) + ->raw('[') + ->compile($this->nodes['attribute'])->raw(']') + ; + break; + } + } + + public function evaluate($functions, $values) + { + switch ($this->attributes['type']) { + case self::PROPERTY_CALL: + $obj = $this->nodes['node']->evaluate($functions, $values); + if (!\is_object($obj)) { + throw new \RuntimeException('Unable to get a property on a non-object.'); + } + + $property = $this->nodes['attribute']->attributes['value']; + + return $obj->$property; + + case self::METHOD_CALL: + $obj = $this->nodes['node']->evaluate($functions, $values); + if (!\is_object($obj)) { + throw new \RuntimeException('Unable to get a property on a non-object.'); + } + if (!\is_callable($toCall = [$obj, $this->nodes['attribute']->attributes['value']])) { + throw new \RuntimeException(sprintf('Unable to call method "%s" of object "%s".', $this->nodes['attribute']->attributes['value'], \get_class($obj))); + } + + return \call_user_func_array($toCall, $this->nodes['arguments']->evaluate($functions, $values)); + + case self::ARRAY_CALL: + $array = $this->nodes['node']->evaluate($functions, $values); + if (!\is_array($array) && !$array instanceof \ArrayAccess) { + throw new \RuntimeException('Unable to get an item on a non-array.'); + } + + return $array[$this->nodes['attribute']->evaluate($functions, $values)]; + } + } + + public function toArray() + { + switch ($this->attributes['type']) { + case self::PROPERTY_CALL: + return [$this->nodes['node'], '.', $this->nodes['attribute']]; + + case self::METHOD_CALL: + return [$this->nodes['node'], '.', $this->nodes['attribute'], '(', $this->nodes['arguments'], ')']; + + case self::ARRAY_CALL: + return [$this->nodes['node'], '[', $this->nodes['attribute'], ']']; + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/NameNode.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/NameNode.php new file mode 100644 index 0000000..1c9c277 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/NameNode.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * @author Fabien Potencier + * + * @internal + */ +class NameNode extends Node +{ + public function __construct($name) + { + parent::__construct( + [], + ['name' => $name] + ); + } + + public function compile(Compiler $compiler) + { + $compiler->raw('$'.$this->attributes['name']); + } + + public function evaluate($functions, $values) + { + return $values[$this->attributes['name']]; + } + + public function toArray() + { + return [$this->attributes['name']]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/Node.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/Node.php new file mode 100644 index 0000000..9504590 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/Node.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * Represents a node in the AST. + * + * @author Fabien Potencier + */ +class Node +{ + public $nodes = []; + public $attributes = []; + + /** + * @param array $nodes An array of nodes + * @param array $attributes An array of attributes + */ + public function __construct(array $nodes = [], array $attributes = []) + { + $this->nodes = $nodes; + $this->attributes = $attributes; + } + + public function __toString() + { + $attributes = []; + foreach ($this->attributes as $name => $value) { + $attributes[] = sprintf('%s: %s', $name, str_replace("\n", '', var_export($value, true))); + } + + $repr = [str_replace('Symfony\Component\ExpressionLanguage\Node\\', '', static::class).'('.implode(', ', $attributes)]; + + if (\count($this->nodes)) { + foreach ($this->nodes as $node) { + foreach (explode("\n", (string) $node) as $line) { + $repr[] = ' '.$line; + } + } + + $repr[] = ')'; + } else { + $repr[0] .= ')'; + } + + return implode("\n", $repr); + } + + public function compile(Compiler $compiler) + { + foreach ($this->nodes as $node) { + $node->compile($compiler); + } + } + + public function evaluate($functions, $values) + { + $results = []; + foreach ($this->nodes as $node) { + $results[] = $node->evaluate($functions, $values); + } + + return $results; + } + + public function toArray() + { + throw new \BadMethodCallException(sprintf('Dumping a "%s" instance is not supported yet.', static::class)); + } + + public function dump() + { + $dump = ''; + + foreach ($this->toArray() as $v) { + $dump .= is_scalar($v) ? $v : $v->dump(); + } + + return $dump; + } + + protected function dumpString($value) + { + return sprintf('"%s"', addcslashes($value, "\0\t\"\\")); + } + + protected function isHash(array $value) + { + $expectedKey = 0; + + foreach ($value as $key => $val) { + if ($key !== $expectedKey++) { + return true; + } + } + + return false; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/UnaryNode.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/UnaryNode.php new file mode 100644 index 0000000..497b7fe --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Node/UnaryNode.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * @author Fabien Potencier + * + * @internal + */ +class UnaryNode extends Node +{ + private static $operators = [ + '!' => '!', + 'not' => '!', + '+' => '+', + '-' => '-', + ]; + + public function __construct($operator, Node $node) + { + parent::__construct( + ['node' => $node], + ['operator' => $operator] + ); + } + + public function compile(Compiler $compiler) + { + $compiler + ->raw('(') + ->raw(self::$operators[$this->attributes['operator']]) + ->compile($this->nodes['node']) + ->raw(')') + ; + } + + public function evaluate($functions, $values) + { + $value = $this->nodes['node']->evaluate($functions, $values); + switch ($this->attributes['operator']) { + case 'not': + case '!': + return !$value; + case '-': + return -$value; + } + + return $value; + } + + public function toArray() + { + return ['(', $this->attributes['operator'].' ', $this->nodes['node'], ')']; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParsedExpression.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParsedExpression.php new file mode 100644 index 0000000..a5603fc --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParsedExpression.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +use Symfony\Component\ExpressionLanguage\Node\Node; + +/** + * Represents an already parsed expression. + * + * @author Fabien Potencier + */ +class ParsedExpression extends Expression +{ + private $nodes; + + /** + * @param string $expression An expression + * @param Node $nodes A Node representing the expression + */ + public function __construct($expression, Node $nodes) + { + parent::__construct($expression); + + $this->nodes = $nodes; + } + + public function getNodes() + { + return $this->nodes; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Parser.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Parser.php new file mode 100644 index 0000000..bfc2439 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Parser.php @@ -0,0 +1,380 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Parsers a token stream. + * + * This parser implements a "Precedence climbing" algorithm. + * + * @see http://www.engr.mun.ca/~theo/Misc/exp_parsing.htm + * @see http://en.wikipedia.org/wiki/Operator-precedence_parser + * + * @author Fabien Potencier + */ +class Parser +{ + const OPERATOR_LEFT = 1; + const OPERATOR_RIGHT = 2; + + private $stream; + private $unaryOperators; + private $binaryOperators; + private $functions; + private $names; + + public function __construct(array $functions) + { + $this->functions = $functions; + + $this->unaryOperators = [ + 'not' => ['precedence' => 50], + '!' => ['precedence' => 50], + '-' => ['precedence' => 500], + '+' => ['precedence' => 500], + ]; + $this->binaryOperators = [ + 'or' => ['precedence' => 10, 'associativity' => self::OPERATOR_LEFT], + '||' => ['precedence' => 10, 'associativity' => self::OPERATOR_LEFT], + 'and' => ['precedence' => 15, 'associativity' => self::OPERATOR_LEFT], + '&&' => ['precedence' => 15, 'associativity' => self::OPERATOR_LEFT], + '|' => ['precedence' => 16, 'associativity' => self::OPERATOR_LEFT], + '^' => ['precedence' => 17, 'associativity' => self::OPERATOR_LEFT], + '&' => ['precedence' => 18, 'associativity' => self::OPERATOR_LEFT], + '==' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + '===' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + '!=' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + '!==' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + '<' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + '>' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + '>=' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + '<=' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + 'not in' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + 'in' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + 'matches' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], + '..' => ['precedence' => 25, 'associativity' => self::OPERATOR_LEFT], + '+' => ['precedence' => 30, 'associativity' => self::OPERATOR_LEFT], + '-' => ['precedence' => 30, 'associativity' => self::OPERATOR_LEFT], + '~' => ['precedence' => 40, 'associativity' => self::OPERATOR_LEFT], + '*' => ['precedence' => 60, 'associativity' => self::OPERATOR_LEFT], + '/' => ['precedence' => 60, 'associativity' => self::OPERATOR_LEFT], + '%' => ['precedence' => 60, 'associativity' => self::OPERATOR_LEFT], + '**' => ['precedence' => 200, 'associativity' => self::OPERATOR_RIGHT], + ]; + } + + /** + * Converts a token stream to a node tree. + * + * The valid names is an array where the values + * are the names that the user can use in an expression. + * + * If the variable name in the compiled PHP code must be + * different, define it as the key. + * + * For instance, ['this' => 'container'] means that the + * variable 'container' can be used in the expression + * but the compiled code will use 'this'. + * + * @param TokenStream $stream A token stream instance + * @param array $names An array of valid names + * + * @return Node\Node A node tree + * + * @throws SyntaxError + */ + public function parse(TokenStream $stream, $names = []) + { + $this->stream = $stream; + $this->names = $names; + + $node = $this->parseExpression(); + if (!$stream->isEOF()) { + throw new SyntaxError(sprintf('Unexpected token "%s" of value "%s".', $stream->current->type, $stream->current->value), $stream->current->cursor, $stream->getExpression()); + } + + return $node; + } + + public function parseExpression($precedence = 0) + { + $expr = $this->getPrimary(); + $token = $this->stream->current; + while ($token->test(Token::OPERATOR_TYPE) && isset($this->binaryOperators[$token->value]) && $this->binaryOperators[$token->value]['precedence'] >= $precedence) { + $op = $this->binaryOperators[$token->value]; + $this->stream->next(); + + $expr1 = $this->parseExpression(self::OPERATOR_LEFT === $op['associativity'] ? $op['precedence'] + 1 : $op['precedence']); + $expr = new Node\BinaryNode($token->value, $expr, $expr1); + + $token = $this->stream->current; + } + + if (0 === $precedence) { + return $this->parseConditionalExpression($expr); + } + + return $expr; + } + + protected function getPrimary() + { + $token = $this->stream->current; + + if ($token->test(Token::OPERATOR_TYPE) && isset($this->unaryOperators[$token->value])) { + $operator = $this->unaryOperators[$token->value]; + $this->stream->next(); + $expr = $this->parseExpression($operator['precedence']); + + return $this->parsePostfixExpression(new Node\UnaryNode($token->value, $expr)); + } + + if ($token->test(Token::PUNCTUATION_TYPE, '(')) { + $this->stream->next(); + $expr = $this->parseExpression(); + $this->stream->expect(Token::PUNCTUATION_TYPE, ')', 'An opened parenthesis is not properly closed'); + + return $this->parsePostfixExpression($expr); + } + + return $this->parsePrimaryExpression(); + } + + protected function parseConditionalExpression($expr) + { + while ($this->stream->current->test(Token::PUNCTUATION_TYPE, '?')) { + $this->stream->next(); + if (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ':')) { + $expr2 = $this->parseExpression(); + if ($this->stream->current->test(Token::PUNCTUATION_TYPE, ':')) { + $this->stream->next(); + $expr3 = $this->parseExpression(); + } else { + $expr3 = new Node\ConstantNode(null); + } + } else { + $this->stream->next(); + $expr2 = $expr; + $expr3 = $this->parseExpression(); + } + + $expr = new Node\ConditionalNode($expr, $expr2, $expr3); + } + + return $expr; + } + + public function parsePrimaryExpression() + { + $token = $this->stream->current; + switch ($token->type) { + case Token::NAME_TYPE: + $this->stream->next(); + switch ($token->value) { + case 'true': + case 'TRUE': + return new Node\ConstantNode(true); + + case 'false': + case 'FALSE': + return new Node\ConstantNode(false); + + case 'null': + case 'NULL': + return new Node\ConstantNode(null); + + default: + if ('(' === $this->stream->current->value) { + if (false === isset($this->functions[$token->value])) { + throw new SyntaxError(sprintf('The function "%s" does not exist.', $token->value), $token->cursor, $this->stream->getExpression(), $token->value, array_keys($this->functions)); + } + + $node = new Node\FunctionNode($token->value, $this->parseArguments()); + } else { + if (!\in_array($token->value, $this->names, true)) { + throw new SyntaxError(sprintf('Variable "%s" is not valid.', $token->value), $token->cursor, $this->stream->getExpression(), $token->value, $this->names); + } + + // is the name used in the compiled code different + // from the name used in the expression? + if (\is_int($name = array_search($token->value, $this->names))) { + $name = $token->value; + } + + $node = new Node\NameNode($name); + } + } + break; + + case Token::NUMBER_TYPE: + case Token::STRING_TYPE: + $this->stream->next(); + + return new Node\ConstantNode($token->value); + + default: + if ($token->test(Token::PUNCTUATION_TYPE, '[')) { + $node = $this->parseArrayExpression(); + } elseif ($token->test(Token::PUNCTUATION_TYPE, '{')) { + $node = $this->parseHashExpression(); + } else { + throw new SyntaxError(sprintf('Unexpected token "%s" of value "%s".', $token->type, $token->value), $token->cursor, $this->stream->getExpression()); + } + } + + return $this->parsePostfixExpression($node); + } + + public function parseArrayExpression() + { + $this->stream->expect(Token::PUNCTUATION_TYPE, '[', 'An array element was expected'); + + $node = new Node\ArrayNode(); + $first = true; + while (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ']')) { + if (!$first) { + $this->stream->expect(Token::PUNCTUATION_TYPE, ',', 'An array element must be followed by a comma'); + + // trailing ,? + if ($this->stream->current->test(Token::PUNCTUATION_TYPE, ']')) { + break; + } + } + $first = false; + + $node->addElement($this->parseExpression()); + } + $this->stream->expect(Token::PUNCTUATION_TYPE, ']', 'An opened array is not properly closed'); + + return $node; + } + + public function parseHashExpression() + { + $this->stream->expect(Token::PUNCTUATION_TYPE, '{', 'A hash element was expected'); + + $node = new Node\ArrayNode(); + $first = true; + while (!$this->stream->current->test(Token::PUNCTUATION_TYPE, '}')) { + if (!$first) { + $this->stream->expect(Token::PUNCTUATION_TYPE, ',', 'A hash value must be followed by a comma'); + + // trailing ,? + if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '}')) { + break; + } + } + $first = false; + + // a hash key can be: + // + // * a number -- 12 + // * a string -- 'a' + // * a name, which is equivalent to a string -- a + // * an expression, which must be enclosed in parentheses -- (1 + 2) + if ($this->stream->current->test(Token::STRING_TYPE) || $this->stream->current->test(Token::NAME_TYPE) || $this->stream->current->test(Token::NUMBER_TYPE)) { + $key = new Node\ConstantNode($this->stream->current->value); + $this->stream->next(); + } elseif ($this->stream->current->test(Token::PUNCTUATION_TYPE, '(')) { + $key = $this->parseExpression(); + } else { + $current = $this->stream->current; + + throw new SyntaxError(sprintf('A hash key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "%s" of value "%s".', $current->type, $current->value), $current->cursor, $this->stream->getExpression()); + } + + $this->stream->expect(Token::PUNCTUATION_TYPE, ':', 'A hash key must be followed by a colon (:)'); + $value = $this->parseExpression(); + + $node->addElement($value, $key); + } + $this->stream->expect(Token::PUNCTUATION_TYPE, '}', 'An opened hash is not properly closed'); + + return $node; + } + + public function parsePostfixExpression($node) + { + $token = $this->stream->current; + while (Token::PUNCTUATION_TYPE == $token->type) { + if ('.' === $token->value) { + $this->stream->next(); + $token = $this->stream->current; + $this->stream->next(); + + if ( + Token::NAME_TYPE !== $token->type + && + // Operators like "not" and "matches" are valid method or property names, + // + // In other words, besides NAME_TYPE, OPERATOR_TYPE could also be parsed as a property or method. + // This is because operators are processed by the lexer prior to names. So "not" in "foo.not()" or "matches" in "foo.matches" will be recognized as an operator first. + // But in fact, "not" and "matches" in such expressions shall be parsed as method or property names. + // + // And this ONLY works if the operator consists of valid characters for a property or method name. + // + // Other types, such as STRING_TYPE and NUMBER_TYPE, can't be parsed as property nor method names. + // + // As a result, if $token is NOT an operator OR $token->value is NOT a valid property or method name, an exception shall be thrown. + (Token::OPERATOR_TYPE !== $token->type || !preg_match('/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A', $token->value)) + ) { + throw new SyntaxError('Expected name.', $token->cursor, $this->stream->getExpression()); + } + + $arg = new Node\ConstantNode($token->value, true); + + $arguments = new Node\ArgumentsNode(); + if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '(')) { + $type = Node\GetAttrNode::METHOD_CALL; + foreach ($this->parseArguments()->nodes as $n) { + $arguments->addElement($n); + } + } else { + $type = Node\GetAttrNode::PROPERTY_CALL; + } + + $node = new Node\GetAttrNode($node, $arg, $arguments, $type); + } elseif ('[' === $token->value) { + $this->stream->next(); + $arg = $this->parseExpression(); + $this->stream->expect(Token::PUNCTUATION_TYPE, ']'); + + $node = new Node\GetAttrNode($node, $arg, new Node\ArgumentsNode(), Node\GetAttrNode::ARRAY_CALL); + } else { + break; + } + + $token = $this->stream->current; + } + + return $node; + } + + /** + * Parses arguments. + */ + public function parseArguments() + { + $args = []; + $this->stream->expect(Token::PUNCTUATION_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); + while (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ')')) { + if (!empty($args)) { + $this->stream->expect(Token::PUNCTUATION_TYPE, ',', 'Arguments must be separated by a comma'); + } + + $args[] = $this->parseExpression(); + } + $this->stream->expect(Token::PUNCTUATION_TYPE, ')', 'A list of arguments must be closed by a parenthesis'); + + return new Node\Node($args); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParserCache/ArrayParserCache.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParserCache/ArrayParserCache.php new file mode 100644 index 0000000..8f9d9f4 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParserCache/ArrayParserCache.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\ParserCache; + +@trigger_error('The '.__NAMESPACE__.'\ArrayParserCache class is deprecated since Symfony 3.2 and will be removed in 4.0. Use the Symfony\Component\Cache\Adapter\ArrayAdapter class instead.', E_USER_DEPRECATED); + +use Symfony\Component\ExpressionLanguage\ParsedExpression; + +/** + * @author Adrien Brault + * + * @deprecated ArrayParserCache class is deprecated since version 3.2 and will be removed in 4.0. Use the Symfony\Component\Cache\Adapter\ArrayAdapter class instead. + */ +class ArrayParserCache implements ParserCacheInterface +{ + private $cache = []; + + /** + * {@inheritdoc} + */ + public function fetch($key) + { + return isset($this->cache[$key]) ? $this->cache[$key] : null; + } + + /** + * {@inheritdoc} + */ + public function save($key, ParsedExpression $expression) + { + $this->cache[$key] = $expression; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParserCache/ParserCacheAdapter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParserCache/ParserCacheAdapter.php new file mode 100644 index 0000000..b9b448d --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParserCache/ParserCacheAdapter.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\ParserCache; + +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\CacheItem; + +/** + * @author Alexandre GESLIN + * + * @internal and will be removed in Symfony 4.0. + */ +class ParserCacheAdapter implements CacheItemPoolInterface +{ + private $pool; + private $createCacheItem; + + public function __construct(ParserCacheInterface $pool) + { + $this->pool = $pool; + + $this->createCacheItem = \Closure::bind( + static function ($key, $value, $isHit) { + $item = new CacheItem(); + $item->key = $key; + $item->value = $value; + $item->isHit = $isHit; + + return $item; + }, + null, + CacheItem::class + ); + } + + /** + * {@inheritdoc} + */ + public function getItem($key) + { + $value = $this->pool->fetch($key); + $f = $this->createCacheItem; + + return $f($key, $value, null !== $value); + } + + /** + * {@inheritdoc} + */ + public function save(CacheItemInterface $item) + { + $this->pool->save($item->getKey(), $item->get()); + } + + /** + * {@inheritdoc} + */ + public function getItems(array $keys = []) + { + throw new \BadMethodCallException('Not implemented.'); + } + + /** + * {@inheritdoc} + */ + public function hasItem($key) + { + throw new \BadMethodCallException('Not implemented.'); + } + + /** + * {@inheritdoc} + */ + public function clear() + { + throw new \BadMethodCallException('Not implemented.'); + } + + /** + * {@inheritdoc} + */ + public function deleteItem($key) + { + throw new \BadMethodCallException('Not implemented.'); + } + + /** + * {@inheritdoc} + */ + public function deleteItems(array $keys) + { + throw new \BadMethodCallException('Not implemented.'); + } + + /** + * {@inheritdoc} + */ + public function saveDeferred(CacheItemInterface $item) + { + throw new \BadMethodCallException('Not implemented.'); + } + + /** + * {@inheritdoc} + */ + public function commit() + { + throw new \BadMethodCallException('Not implemented.'); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParserCache/ParserCacheInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParserCache/ParserCacheInterface.php new file mode 100644 index 0000000..ed66b21 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/ParserCache/ParserCacheInterface.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\ParserCache; + +@trigger_error('The '.__NAMESPACE__.'\ParserCacheInterface interface is deprecated since Symfony 3.2 and will be removed in 4.0. Use Psr\Cache\CacheItemPoolInterface instead.', E_USER_DEPRECATED); + +use Symfony\Component\ExpressionLanguage\ParsedExpression; + +/** + * @author Adrien Brault + * + * @deprecated since version 3.2, to be removed in 4.0. Use Psr\Cache\CacheItemPoolInterface instead. + */ +interface ParserCacheInterface +{ + /** + * Saves an expression in the cache. + * + * @param string $key The cache key + * @param ParsedExpression $expression A ParsedExpression instance to store in the cache + */ + public function save($key, ParsedExpression $expression); + + /** + * Fetches an expression from the cache. + * + * @param string $key The cache key + * + * @return ParsedExpression|null + */ + public function fetch($key); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/README.md b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/README.md new file mode 100644 index 0000000..08b310d --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/README.md @@ -0,0 +1,15 @@ +ExpressionLanguage Component +============================ + +The ExpressionLanguage component provides an engine that can compile and +evaluate expressions. An expression is a one-liner that returns a value +(mostly, but not limited to, Booleans). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/expression_language/introduction.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Resources/bin/generate_operator_regex.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Resources/bin/generate_operator_regex.php new file mode 100644 index 0000000..c86e962 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Resources/bin/generate_operator_regex.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +$operators = ['not', '!', 'or', '||', '&&', 'and', '|', '^', '&', '==', '===', '!=', '!==', '<', '>', '>=', '<=', 'not in', 'in', '..', '+', '-', '~', '*', '/', '%', 'matches', '**']; +$operators = array_combine($operators, array_map('strlen', $operators)); +arsort($operators); + +$regex = []; +foreach ($operators as $operator => $length) { + // Collisions of character operators: + // - an operator that begins with a character must have a space or a parenthesis before or starting at the beginning of a string + // - an operator that ends with a character must be followed by a whitespace or a parenthesis + $regex[] = + (ctype_alpha($operator[0]) ? '(?<=^|[\s(])' : '') + .preg_quote($operator, '/') + .(ctype_alpha($operator[$length - 1]) ? '(?=[\s(])' : ''); +} + +echo '/'.implode('|', $regex).'/A'; diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/SerializedParsedExpression.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/SerializedParsedExpression.php new file mode 100644 index 0000000..dd763f7 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/SerializedParsedExpression.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Represents an already parsed expression. + * + * @author Fabien Potencier + */ +class SerializedParsedExpression extends ParsedExpression +{ + private $nodes; + + /** + * @param string $expression An expression + * @param string $nodes The serialized nodes for the expression + */ + public function __construct($expression, $nodes) + { + $this->expression = (string) $expression; + $this->nodes = $nodes; + } + + public function getNodes() + { + return unserialize($this->nodes); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/SyntaxError.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/SyntaxError.php new file mode 100644 index 0000000..3340042 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/SyntaxError.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +class SyntaxError extends \LogicException +{ + public function __construct($message, $cursor = 0, $expression = '', $subject = null, array $proposals = null) + { + $message = sprintf('%s around position %d', rtrim($message, '.'), $cursor); + if ($expression) { + $message = sprintf('%s for expression `%s`', $message, $expression); + } + $message .= '.'; + + if (null !== $subject && null !== $proposals) { + $minScore = INF; + foreach ($proposals as $proposal) { + $distance = levenshtein($subject, $proposal); + if ($distance < $minScore) { + $guess = $proposal; + $minScore = $distance; + } + } + + if (isset($guess) && $minScore < 3) { + $message .= sprintf(' Did you mean "%s"?', $guess); + } + } + + parent::__construct($message); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ExpressionFunctionTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ExpressionFunctionTest.php new file mode 100644 index 0000000..d7e23cb --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ExpressionFunctionTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\ExpressionFunction; + +/** + * Tests ExpressionFunction. + * + * @author Dany Maillard + */ +class ExpressionFunctionTest extends TestCase +{ + public function testFunctionDoesNotExist() + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('PHP function "fn_does_not_exist" does not exist.'); + ExpressionFunction::fromPhp('fn_does_not_exist'); + } + + public function testFunctionNamespaced() + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('An expression function name must be defined when PHP function "Symfony\Component\ExpressionLanguage\Tests\fn_namespaced" is namespaced.'); + ExpressionFunction::fromPhp('Symfony\Component\ExpressionLanguage\Tests\fn_namespaced'); + } +} + +function fn_namespaced() +{ +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ExpressionLanguageTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ExpressionLanguageTest.php new file mode 100644 index 0000000..6c7a802 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ExpressionLanguageTest.php @@ -0,0 +1,308 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\ExpressionFunction; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\ParsedExpression; +use Symfony\Component\ExpressionLanguage\Tests\Fixtures\TestProvider; + +class ExpressionLanguageTest extends TestCase +{ + public function testCachedParse() + { + $cacheMock = $this->getMockBuilder('Psr\Cache\CacheItemPoolInterface')->getMock(); + $cacheItemMock = $this->getMockBuilder('Psr\Cache\CacheItemInterface')->getMock(); + $savedParsedExpression = null; + $expressionLanguage = new ExpressionLanguage($cacheMock); + + $cacheMock + ->expects($this->exactly(2)) + ->method('getItem') + ->with('1%20%2B%201%2F%2F') + ->willReturn($cacheItemMock) + ; + + $cacheItemMock + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(function () use (&$savedParsedExpression) { + return $savedParsedExpression; + }) + ; + + $cacheItemMock + ->expects($this->exactly(1)) + ->method('set') + ->with($this->isInstanceOf(ParsedExpression::class)) + ->willReturnCallback(function ($parsedExpression) use (&$savedParsedExpression) { + $savedParsedExpression = $parsedExpression; + }) + ; + + $cacheMock + ->expects($this->exactly(1)) + ->method('save') + ->with($cacheItemMock) + ; + + $parsedExpression = $expressionLanguage->parse('1 + 1', []); + $this->assertSame($savedParsedExpression, $parsedExpression); + + $parsedExpression = $expressionLanguage->parse('1 + 1', []); + $this->assertSame($savedParsedExpression, $parsedExpression); + } + + /** + * @group legacy + */ + public function testCachedParseWithDeprecatedParserCacheInterface() + { + $cacheMock = $this->getMockBuilder('Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface')->getMock(); + + $savedParsedExpression = null; + $expressionLanguage = new ExpressionLanguage($cacheMock); + + $cacheMock + ->expects($this->exactly(1)) + ->method('fetch') + ->with('1%20%2B%201%2F%2F') + ->willReturn($savedParsedExpression) + ; + + $cacheMock + ->expects($this->exactly(1)) + ->method('save') + ->with('1%20%2B%201%2F%2F', $this->isInstanceOf(ParsedExpression::class)) + ->willReturnCallback(function ($key, $expression) use (&$savedParsedExpression) { + $savedParsedExpression = $expression; + }) + ; + + $parsedExpression = $expressionLanguage->parse('1 + 1', []); + $this->assertSame($savedParsedExpression, $parsedExpression); + } + + public function testWrongCacheImplementation() + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Cache argument has to implement "Psr\Cache\CacheItemPoolInterface".'); + $cacheMock = $this->getMockBuilder('Psr\Cache\CacheItemSpoolInterface')->getMock(); + new ExpressionLanguage($cacheMock); + } + + public function testConstantFunction() + { + $expressionLanguage = new ExpressionLanguage(); + $this->assertEquals(PHP_VERSION, $expressionLanguage->evaluate('constant("PHP_VERSION")')); + + $expressionLanguage = new ExpressionLanguage(); + $this->assertEquals('\constant("PHP_VERSION")', $expressionLanguage->compile('constant("PHP_VERSION")')); + } + + public function testProviders() + { + $expressionLanguage = new ExpressionLanguage(null, [new TestProvider()]); + $this->assertEquals('foo', $expressionLanguage->evaluate('identity("foo")')); + $this->assertEquals('"foo"', $expressionLanguage->compile('identity("foo")')); + $this->assertEquals('FOO', $expressionLanguage->evaluate('strtoupper("foo")')); + $this->assertEquals('\strtoupper("foo")', $expressionLanguage->compile('strtoupper("foo")')); + $this->assertEquals('foo', $expressionLanguage->evaluate('strtolower("FOO")')); + $this->assertEquals('\strtolower("FOO")', $expressionLanguage->compile('strtolower("FOO")')); + $this->assertTrue($expressionLanguage->evaluate('fn_namespaced()')); + $this->assertEquals('\Symfony\Component\ExpressionLanguage\Tests\Fixtures\fn_namespaced()', $expressionLanguage->compile('fn_namespaced()')); + } + + /** + * @dataProvider shortCircuitProviderEvaluate + */ + public function testShortCircuitOperatorsEvaluate($expression, array $values, $expected) + { + $expressionLanguage = new ExpressionLanguage(); + $this->assertEquals($expected, $expressionLanguage->evaluate($expression, $values)); + } + + /** + * @dataProvider shortCircuitProviderCompile + */ + public function testShortCircuitOperatorsCompile($expression, array $names, $expected) + { + $result = null; + $expressionLanguage = new ExpressionLanguage(); + eval(sprintf('$result = %s;', $expressionLanguage->compile($expression, $names))); + $this->assertSame($expected, $result); + } + + public function testParseThrowsInsteadOfNotice() + { + $this->expectException('Symfony\Component\ExpressionLanguage\SyntaxError'); + $this->expectExceptionMessage('Unexpected end of expression around position 6 for expression `node.`.'); + $expressionLanguage = new ExpressionLanguage(); + $expressionLanguage->parse('node.', ['node']); + } + + public function shortCircuitProviderEvaluate() + { + $object = $this->getMockBuilder('stdClass')->setMethods(['foo'])->getMock(); + $object->expects($this->never())->method('foo'); + + return [ + ['false and object.foo()', ['object' => $object], false], + ['false && object.foo()', ['object' => $object], false], + ['true || object.foo()', ['object' => $object], true], + ['true or object.foo()', ['object' => $object], true], + ]; + } + + public function shortCircuitProviderCompile() + { + return [ + ['false and foo', ['foo' => 'foo'], false], + ['false && foo', ['foo' => 'foo'], false], + ['true || foo', ['foo' => 'foo'], true], + ['true or foo', ['foo' => 'foo'], true], + ]; + } + + public function testCachingForOverriddenVariableNames() + { + $expressionLanguage = new ExpressionLanguage(); + $expression = 'a + b'; + $expressionLanguage->evaluate($expression, ['a' => 1, 'b' => 1]); + $result = $expressionLanguage->compile($expression, ['a', 'B' => 'b']); + $this->assertSame('($a + $B)', $result); + } + + public function testStrictEquality() + { + $expressionLanguage = new ExpressionLanguage(); + $expression = '123 === a'; + $result = $expressionLanguage->compile($expression, ['a']); + $this->assertSame('(123 === $a)', $result); + } + + public function testCachingWithDifferentNamesOrder() + { + $cacheMock = $this->getMockBuilder('Psr\Cache\CacheItemPoolInterface')->getMock(); + $cacheItemMock = $this->getMockBuilder('Psr\Cache\CacheItemInterface')->getMock(); + $expressionLanguage = new ExpressionLanguage($cacheMock); + $savedParsedExpression = null; + + $cacheMock + ->expects($this->exactly(2)) + ->method('getItem') + ->with('a%20%2B%20b%2F%2Fa%7CB%3Ab') + ->willReturn($cacheItemMock) + ; + + $cacheItemMock + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(function () use (&$savedParsedExpression) { + return $savedParsedExpression; + }) + ; + + $cacheItemMock + ->expects($this->exactly(1)) + ->method('set') + ->with($this->isInstanceOf(ParsedExpression::class)) + ->willReturnCallback(function ($parsedExpression) use (&$savedParsedExpression) { + $savedParsedExpression = $parsedExpression; + }) + ; + + $cacheMock + ->expects($this->exactly(1)) + ->method('save') + ->with($cacheItemMock) + ; + + $expression = 'a + b'; + $expressionLanguage->compile($expression, ['a', 'B' => 'b']); + $expressionLanguage->compile($expression, ['B' => 'b', 'a']); + } + + public function testOperatorCollisions() + { + $expressionLanguage = new ExpressionLanguage(); + $expression = 'foo.not in [bar]'; + $compiled = $expressionLanguage->compile($expression, ['foo', 'bar']); + $this->assertSame('in_array($foo->not, [0 => $bar])', $compiled); + + $result = $expressionLanguage->evaluate($expression, ['foo' => (object) ['not' => 'test'], 'bar' => 'test']); + $this->assertTrue($result); + } + + /** + * @dataProvider getRegisterCallbacks + */ + public function testRegisterAfterParse($registerCallback) + { + $this->expectException('LogicException'); + $el = new ExpressionLanguage(); + $el->parse('1 + 1', []); + $registerCallback($el); + } + + /** + * @dataProvider getRegisterCallbacks + */ + public function testRegisterAfterEval($registerCallback) + { + $this->expectException('LogicException'); + $el = new ExpressionLanguage(); + $el->evaluate('1 + 1'); + $registerCallback($el); + } + + public function testCallBadCallable() + { + $this->expectException('RuntimeException'); + $this->expectExceptionMessageRegExp('/Unable to call method "\w+" of object "\w+"./'); + $el = new ExpressionLanguage(); + $el->evaluate('foo.myfunction()', ['foo' => new \stdClass()]); + } + + /** + * @dataProvider getRegisterCallbacks + */ + public function testRegisterAfterCompile($registerCallback) + { + $this->expectException('LogicException'); + $el = new ExpressionLanguage(); + $el->compile('1 + 1'); + $registerCallback($el); + } + + public function getRegisterCallbacks() + { + return [ + [ + function (ExpressionLanguage $el) { + $el->register('fn', function () {}, function () {}); + }, + ], + [ + function (ExpressionLanguage $el) { + $el->addFunction(new ExpressionFunction('fn', function () {}, function () {})); + }, + ], + [ + function (ExpressionLanguage $el) { + $el->registerProvider(new TestProvider()); + }, + ], + ]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ExpressionTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ExpressionTest.php new file mode 100644 index 0000000..052ef22 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ExpressionTest.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\Expression; + +class ExpressionTest extends TestCase +{ + public function testSerialization() + { + $expression = new Expression('kernel.boot()'); + + $serializedExpression = serialize($expression); + $unserializedExpression = unserialize($serializedExpression); + + $this->assertEquals($expression, $unserializedExpression); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Fixtures/TestProvider.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Fixtures/TestProvider.php new file mode 100644 index 0000000..8405d4f --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Fixtures/TestProvider.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Fixtures; + +use Symfony\Component\ExpressionLanguage\ExpressionFunction; +use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface; +use Symfony\Component\ExpressionLanguage\ExpressionPhpFunction; + +class TestProvider implements ExpressionFunctionProviderInterface +{ + public function getFunctions() + { + return [ + new ExpressionFunction('identity', function ($input) { + return $input; + }, function (array $values, $input) { + return $input; + }), + + ExpressionFunction::fromPhp('strtoupper'), + + ExpressionFunction::fromPhp('\strtolower'), + + ExpressionFunction::fromPhp('Symfony\Component\ExpressionLanguage\Tests\Fixtures\fn_namespaced', 'fn_namespaced'), + ]; + } +} + +function fn_namespaced() +{ + return true; +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/LexerTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/LexerTest.php new file mode 100644 index 0000000..6c3d1a7 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/LexerTest.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\Lexer; +use Symfony\Component\ExpressionLanguage\Token; +use Symfony\Component\ExpressionLanguage\TokenStream; + +class LexerTest extends TestCase +{ + /** + * @var Lexer + */ + private $lexer; + + protected function setUp() + { + $this->lexer = new Lexer(); + } + + /** + * @dataProvider getTokenizeData + */ + public function testTokenize($tokens, $expression) + { + $tokens[] = new Token('end of expression', null, \strlen($expression) + 1); + $this->assertEquals(new TokenStream($tokens, $expression), $this->lexer->tokenize($expression)); + } + + public function testTokenizeThrowsErrorWithMessage() + { + $this->expectException('Symfony\Component\ExpressionLanguage\SyntaxError'); + $this->expectExceptionMessage('Unexpected character "\'" around position 33 for expression `service(faulty.expression.example\').dummyMethod()`.'); + $expression = "service(faulty.expression.example').dummyMethod()"; + $this->lexer->tokenize($expression); + } + + public function testTokenizeThrowsErrorOnUnclosedBrace() + { + $this->expectException('Symfony\Component\ExpressionLanguage\SyntaxError'); + $this->expectExceptionMessage('Unclosed "(" around position 7 for expression `service(unclosed.expression.dummyMethod()`.'); + $expression = 'service(unclosed.expression.dummyMethod()'; + $this->lexer->tokenize($expression); + } + + public function getTokenizeData() + { + return [ + [ + [new Token('name', 'a', 3)], + ' a ', + ], + [ + [new Token('name', 'a', 1)], + 'a', + ], + [ + [new Token('string', 'foo', 1)], + '"foo"', + ], + [ + [new Token('number', '3', 1)], + '3', + ], + [ + [new Token('operator', '+', 1)], + '+', + ], + [ + [new Token('punctuation', '.', 1)], + '.', + ], + [ + [ + new Token('punctuation', '(', 1), + new Token('number', '3', 2), + new Token('operator', '+', 4), + new Token('number', '5', 6), + new Token('punctuation', ')', 7), + new Token('operator', '~', 9), + new Token('name', 'foo', 11), + new Token('punctuation', '(', 14), + new Token('string', 'bar', 15), + new Token('punctuation', ')', 20), + new Token('punctuation', '.', 21), + new Token('name', 'baz', 22), + new Token('punctuation', '[', 25), + new Token('number', '4', 26), + new Token('punctuation', ']', 27), + ], + '(3 + 5) ~ foo("bar").baz[4]', + ], + [ + [new Token('operator', '..', 1)], + '..', + ], + [ + [new Token('string', '#foo', 1)], + "'#foo'", + ], + [ + [new Token('string', '#foo', 1)], + '"#foo"', + ], + [ + [ + new Token('name', 'foo', 1), + new Token('punctuation', '.', 4), + new Token('name', 'not', 5), + new Token('operator', 'in', 9), + new Token('punctuation', '[', 12), + new Token('name', 'bar', 13), + new Token('punctuation', ']', 16), + ], + 'foo.not in [bar]', + ], + ]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/AbstractNodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/AbstractNodeTest.php new file mode 100644 index 0000000..2975037 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/AbstractNodeTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\Compiler; + +abstract class AbstractNodeTest extends TestCase +{ + /** + * @dataProvider getEvaluateData + */ + public function testEvaluate($expected, $node, $variables = [], $functions = []) + { + $this->assertSame($expected, $node->evaluate($functions, $variables)); + } + + abstract public function getEvaluateData(); + + /** + * @dataProvider getCompileData + */ + public function testCompile($expected, $node, $functions = []) + { + $compiler = new Compiler($functions); + $node->compile($compiler); + $this->assertSame($expected, $compiler->getSource()); + } + + abstract public function getCompileData(); + + /** + * @dataProvider getDumpData + */ + public function testDump($expected, $node) + { + $this->assertSame($expected, $node->dump()); + } + + abstract public function getDumpData(); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ArgumentsNodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ArgumentsNodeTest.php new file mode 100644 index 0000000..9d88b4a --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ArgumentsNodeTest.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ArgumentsNode; + +class ArgumentsNodeTest extends ArrayNodeTest +{ + public function getCompileData() + { + return [ + ['"a", "b"', $this->getArrayNode()], + ]; + } + + public function getDumpData() + { + return [ + ['"a", "b"', $this->getArrayNode()], + ]; + } + + protected function createArrayNode() + { + return new ArgumentsNode(); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ArrayNodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ArrayNodeTest.php new file mode 100644 index 0000000..3d03d83 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ArrayNodeTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ArrayNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class ArrayNodeTest extends AbstractNodeTest +{ + public function testSerialization() + { + $node = $this->createArrayNode(); + $node->addElement(new ConstantNode('foo')); + + $serializedNode = serialize($node); + $unserializedNode = unserialize($serializedNode); + + $this->assertEquals($node, $unserializedNode); + $this->assertNotEquals($this->createArrayNode(), $unserializedNode); + } + + public function getEvaluateData() + { + return [ + [['b' => 'a', 'b'], $this->getArrayNode()], + ]; + } + + public function getCompileData() + { + return [ + ['["b" => "a", 0 => "b"]', $this->getArrayNode()], + ]; + } + + public function getDumpData() + { + yield ['{"b": "a", 0: "b"}', $this->getArrayNode()]; + + $array = $this->createArrayNode(); + $array->addElement(new ConstantNode('c'), new ConstantNode('a"b')); + $array->addElement(new ConstantNode('d'), new ConstantNode('a\b')); + yield ['{"a\\"b": "c", "a\\\\b": "d"}', $array]; + + $array = $this->createArrayNode(); + $array->addElement(new ConstantNode('c')); + $array->addElement(new ConstantNode('d')); + yield ['["c", "d"]', $array]; + } + + protected function getArrayNode() + { + $array = $this->createArrayNode(); + $array->addElement(new ConstantNode('a'), new ConstantNode('b')); + $array->addElement(new ConstantNode('b')); + + return $array; + } + + protected function createArrayNode() + { + return new ArrayNode(); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/BinaryNodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/BinaryNodeTest.php new file mode 100644 index 0000000..b45a1e5 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/BinaryNodeTest.php @@ -0,0 +1,166 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ArrayNode; +use Symfony\Component\ExpressionLanguage\Node\BinaryNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class BinaryNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + $array = new ArrayNode(); + $array->addElement(new ConstantNode('a')); + $array->addElement(new ConstantNode('b')); + + return [ + [true, new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))], + [true, new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))], + [false, new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))], + [false, new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))], + + [0, new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))], + [6, new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))], + [6, new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))], + + [true, new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))], + [true, new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))], + [true, new BinaryNode('<=', new ConstantNode(1), new ConstantNode(1))], + + [false, new BinaryNode('>', new ConstantNode(1), new ConstantNode(2))], + [false, new BinaryNode('>=', new ConstantNode(1), new ConstantNode(2))], + [true, new BinaryNode('>=', new ConstantNode(1), new ConstantNode(1))], + + [true, new BinaryNode('===', new ConstantNode(true), new ConstantNode(true))], + [false, new BinaryNode('!==', new ConstantNode(true), new ConstantNode(true))], + + [false, new BinaryNode('==', new ConstantNode(2), new ConstantNode(1))], + [true, new BinaryNode('!=', new ConstantNode(2), new ConstantNode(1))], + + [-1, new BinaryNode('-', new ConstantNode(1), new ConstantNode(2))], + [3, new BinaryNode('+', new ConstantNode(1), new ConstantNode(2))], + [4, new BinaryNode('*', new ConstantNode(2), new ConstantNode(2))], + [1, new BinaryNode('/', new ConstantNode(2), new ConstantNode(2))], + [1, new BinaryNode('%', new ConstantNode(5), new ConstantNode(2))], + [25, new BinaryNode('**', new ConstantNode(5), new ConstantNode(2))], + ['ab', new BinaryNode('~', new ConstantNode('a'), new ConstantNode('b'))], + + [true, new BinaryNode('in', new ConstantNode('a'), $array)], + [false, new BinaryNode('in', new ConstantNode('c'), $array)], + [true, new BinaryNode('not in', new ConstantNode('c'), $array)], + [false, new BinaryNode('not in', new ConstantNode('a'), $array)], + + [[1, 2, 3], new BinaryNode('..', new ConstantNode(1), new ConstantNode(3))], + + [1, new BinaryNode('matches', new ConstantNode('abc'), new ConstantNode('/^[a-z]+$/'))], + ]; + } + + public function getCompileData() + { + $array = new ArrayNode(); + $array->addElement(new ConstantNode('a')); + $array->addElement(new ConstantNode('b')); + + return [ + ['(true || false)', new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))], + ['(true || false)', new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))], + ['(true && false)', new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))], + ['(true && false)', new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))], + + ['(2 & 4)', new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))], + ['(2 | 4)', new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))], + ['(2 ^ 4)', new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))], + + ['(1 < 2)', new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))], + ['(1 <= 2)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))], + ['(1 <= 1)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(1))], + + ['(1 > 2)', new BinaryNode('>', new ConstantNode(1), new ConstantNode(2))], + ['(1 >= 2)', new BinaryNode('>=', new ConstantNode(1), new ConstantNode(2))], + ['(1 >= 1)', new BinaryNode('>=', new ConstantNode(1), new ConstantNode(1))], + + ['(true === true)', new BinaryNode('===', new ConstantNode(true), new ConstantNode(true))], + ['(true !== true)', new BinaryNode('!==', new ConstantNode(true), new ConstantNode(true))], + + ['(2 == 1)', new BinaryNode('==', new ConstantNode(2), new ConstantNode(1))], + ['(2 != 1)', new BinaryNode('!=', new ConstantNode(2), new ConstantNode(1))], + + ['(1 - 2)', new BinaryNode('-', new ConstantNode(1), new ConstantNode(2))], + ['(1 + 2)', new BinaryNode('+', new ConstantNode(1), new ConstantNode(2))], + ['(2 * 2)', new BinaryNode('*', new ConstantNode(2), new ConstantNode(2))], + ['(2 / 2)', new BinaryNode('/', new ConstantNode(2), new ConstantNode(2))], + ['(5 % 2)', new BinaryNode('%', new ConstantNode(5), new ConstantNode(2))], + ['pow(5, 2)', new BinaryNode('**', new ConstantNode(5), new ConstantNode(2))], + ['("a" . "b")', new BinaryNode('~', new ConstantNode('a'), new ConstantNode('b'))], + + ['in_array("a", [0 => "a", 1 => "b"])', new BinaryNode('in', new ConstantNode('a'), $array)], + ['in_array("c", [0 => "a", 1 => "b"])', new BinaryNode('in', new ConstantNode('c'), $array)], + ['!in_array("c", [0 => "a", 1 => "b"])', new BinaryNode('not in', new ConstantNode('c'), $array)], + ['!in_array("a", [0 => "a", 1 => "b"])', new BinaryNode('not in', new ConstantNode('a'), $array)], + + ['range(1, 3)', new BinaryNode('..', new ConstantNode(1), new ConstantNode(3))], + + ['preg_match("/^[a-z]+/i\$/", "abc")', new BinaryNode('matches', new ConstantNode('abc'), new ConstantNode('/^[a-z]+/i$/'))], + ]; + } + + public function getDumpData() + { + $array = new ArrayNode(); + $array->addElement(new ConstantNode('a')); + $array->addElement(new ConstantNode('b')); + + return [ + ['(true or false)', new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))], + ['(true || false)', new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))], + ['(true and false)', new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))], + ['(true && false)', new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))], + + ['(2 & 4)', new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))], + ['(2 | 4)', new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))], + ['(2 ^ 4)', new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))], + + ['(1 < 2)', new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))], + ['(1 <= 2)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))], + ['(1 <= 1)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(1))], + + ['(1 > 2)', new BinaryNode('>', new ConstantNode(1), new ConstantNode(2))], + ['(1 >= 2)', new BinaryNode('>=', new ConstantNode(1), new ConstantNode(2))], + ['(1 >= 1)', new BinaryNode('>=', new ConstantNode(1), new ConstantNode(1))], + + ['(true === true)', new BinaryNode('===', new ConstantNode(true), new ConstantNode(true))], + ['(true !== true)', new BinaryNode('!==', new ConstantNode(true), new ConstantNode(true))], + + ['(2 == 1)', new BinaryNode('==', new ConstantNode(2), new ConstantNode(1))], + ['(2 != 1)', new BinaryNode('!=', new ConstantNode(2), new ConstantNode(1))], + + ['(1 - 2)', new BinaryNode('-', new ConstantNode(1), new ConstantNode(2))], + ['(1 + 2)', new BinaryNode('+', new ConstantNode(1), new ConstantNode(2))], + ['(2 * 2)', new BinaryNode('*', new ConstantNode(2), new ConstantNode(2))], + ['(2 / 2)', new BinaryNode('/', new ConstantNode(2), new ConstantNode(2))], + ['(5 % 2)', new BinaryNode('%', new ConstantNode(5), new ConstantNode(2))], + ['(5 ** 2)', new BinaryNode('**', new ConstantNode(5), new ConstantNode(2))], + ['("a" ~ "b")', new BinaryNode('~', new ConstantNode('a'), new ConstantNode('b'))], + + ['("a" in ["a", "b"])', new BinaryNode('in', new ConstantNode('a'), $array)], + ['("c" in ["a", "b"])', new BinaryNode('in', new ConstantNode('c'), $array)], + ['("c" not in ["a", "b"])', new BinaryNode('not in', new ConstantNode('c'), $array)], + ['("a" not in ["a", "b"])', new BinaryNode('not in', new ConstantNode('a'), $array)], + + ['(1 .. 3)', new BinaryNode('..', new ConstantNode(1), new ConstantNode(3))], + + ['("abc" matches "/^[a-z]+/i$/")', new BinaryNode('matches', new ConstantNode('abc'), new ConstantNode('/^[a-z]+/i$/'))], + ]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ConditionalNodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ConditionalNodeTest.php new file mode 100644 index 0000000..13b7cbd --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ConditionalNodeTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ConditionalNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class ConditionalNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return [ + [1, new ConditionalNode(new ConstantNode(true), new ConstantNode(1), new ConstantNode(2))], + [2, new ConditionalNode(new ConstantNode(false), new ConstantNode(1), new ConstantNode(2))], + ]; + } + + public function getCompileData() + { + return [ + ['((true) ? (1) : (2))', new ConditionalNode(new ConstantNode(true), new ConstantNode(1), new ConstantNode(2))], + ['((false) ? (1) : (2))', new ConditionalNode(new ConstantNode(false), new ConstantNode(1), new ConstantNode(2))], + ]; + } + + public function getDumpData() + { + return [ + ['(true ? 1 : 2)', new ConditionalNode(new ConstantNode(true), new ConstantNode(1), new ConstantNode(2))], + ['(false ? 1 : 2)', new ConditionalNode(new ConstantNode(false), new ConstantNode(1), new ConstantNode(2))], + ]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ConstantNodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ConstantNodeTest.php new file mode 100644 index 0000000..fee9f5b --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/ConstantNodeTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; + +class ConstantNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return [ + [false, new ConstantNode(false)], + [true, new ConstantNode(true)], + [null, new ConstantNode(null)], + [3, new ConstantNode(3)], + [3.3, new ConstantNode(3.3)], + ['foo', new ConstantNode('foo')], + [[1, 'b' => 'a'], new ConstantNode([1, 'b' => 'a'])], + ]; + } + + public function getCompileData() + { + return [ + ['false', new ConstantNode(false)], + ['true', new ConstantNode(true)], + ['null', new ConstantNode(null)], + ['3', new ConstantNode(3)], + ['3.3', new ConstantNode(3.3)], + ['"foo"', new ConstantNode('foo')], + ['[0 => 1, "b" => "a"]', new ConstantNode([1, 'b' => 'a'])], + ]; + } + + public function getDumpData() + { + return [ + ['false', new ConstantNode(false)], + ['true', new ConstantNode(true)], + ['null', new ConstantNode(null)], + ['3', new ConstantNode(3)], + ['3.3', new ConstantNode(3.3)], + ['"foo"', new ConstantNode('foo')], + ['foo', new ConstantNode('foo', true)], + ['{0: 1, "b": "a", 1: true}', new ConstantNode([1, 'b' => 'a', true])], + ['{"a\\"b": "c", "a\\\\b": "d"}', new ConstantNode(['a"b' => 'c', 'a\\b' => 'd'])], + ['["c", "d"]', new ConstantNode(['c', 'd'])], + ['{"a": ["b"]}', new ConstantNode(['a' => ['b']])], + ]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/FunctionNodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/FunctionNodeTest.php new file mode 100644 index 0000000..c6cb02c --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/FunctionNodeTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; +use Symfony\Component\ExpressionLanguage\Node\FunctionNode; +use Symfony\Component\ExpressionLanguage\Node\Node; + +class FunctionNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return [ + ['bar', new FunctionNode('foo', new Node([new ConstantNode('bar')])), [], ['foo' => $this->getCallables()]], + ]; + } + + public function getCompileData() + { + return [ + ['foo("bar")', new FunctionNode('foo', new Node([new ConstantNode('bar')])), ['foo' => $this->getCallables()]], + ]; + } + + public function getDumpData() + { + return [ + ['foo("bar")', new FunctionNode('foo', new Node([new ConstantNode('bar')])), ['foo' => $this->getCallables()]], + ]; + } + + protected function getCallables() + { + return [ + 'compiler' => function ($arg) { + return sprintf('foo(%s)', $arg); + }, + 'evaluator' => function ($variables, $arg) { + return $arg; + }, + ]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/GetAttrNodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/GetAttrNodeTest.php new file mode 100644 index 0000000..c790f33 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/GetAttrNodeTest.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ArrayNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; +use Symfony\Component\ExpressionLanguage\Node\GetAttrNode; +use Symfony\Component\ExpressionLanguage\Node\NameNode; + +class GetAttrNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return [ + ['b', new GetAttrNode(new NameNode('foo'), new ConstantNode(0), $this->getArrayNode(), GetAttrNode::ARRAY_CALL), ['foo' => ['b' => 'a', 'b']]], + ['a', new GetAttrNode(new NameNode('foo'), new ConstantNode('b'), $this->getArrayNode(), GetAttrNode::ARRAY_CALL), ['foo' => ['b' => 'a', 'b']]], + + ['bar', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo'), $this->getArrayNode(), GetAttrNode::PROPERTY_CALL), ['foo' => new Obj()]], + + ['baz', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo'), $this->getArrayNode(), GetAttrNode::METHOD_CALL), ['foo' => new Obj()]], + ['a', new GetAttrNode(new NameNode('foo'), new NameNode('index'), $this->getArrayNode(), GetAttrNode::ARRAY_CALL), ['foo' => ['b' => 'a', 'b'], 'index' => 'b']], + ]; + } + + public function getCompileData() + { + return [ + ['$foo[0]', new GetAttrNode(new NameNode('foo'), new ConstantNode(0), $this->getArrayNode(), GetAttrNode::ARRAY_CALL)], + ['$foo["b"]', new GetAttrNode(new NameNode('foo'), new ConstantNode('b'), $this->getArrayNode(), GetAttrNode::ARRAY_CALL)], + + ['$foo->foo', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo'), $this->getArrayNode(), GetAttrNode::PROPERTY_CALL), ['foo' => new Obj()]], + + ['$foo->foo(["b" => "a", 0 => "b"])', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo'), $this->getArrayNode(), GetAttrNode::METHOD_CALL), ['foo' => new Obj()]], + ['$foo[$index]', new GetAttrNode(new NameNode('foo'), new NameNode('index'), $this->getArrayNode(), GetAttrNode::ARRAY_CALL)], + ]; + } + + public function getDumpData() + { + return [ + ['foo[0]', new GetAttrNode(new NameNode('foo'), new ConstantNode(0), $this->getArrayNode(), GetAttrNode::ARRAY_CALL)], + ['foo["b"]', new GetAttrNode(new NameNode('foo'), new ConstantNode('b'), $this->getArrayNode(), GetAttrNode::ARRAY_CALL)], + + ['foo.foo', new GetAttrNode(new NameNode('foo'), new NameNode('foo'), $this->getArrayNode(), GetAttrNode::PROPERTY_CALL), ['foo' => new Obj()]], + + ['foo.foo({"b": "a", 0: "b"})', new GetAttrNode(new NameNode('foo'), new NameNode('foo'), $this->getArrayNode(), GetAttrNode::METHOD_CALL), ['foo' => new Obj()]], + ['foo[index]', new GetAttrNode(new NameNode('foo'), new NameNode('index'), $this->getArrayNode(), GetAttrNode::ARRAY_CALL)], + ]; + } + + protected function getArrayNode() + { + $array = new ArrayNode(); + $array->addElement(new ConstantNode('a'), new ConstantNode('b')); + $array->addElement(new ConstantNode('b')); + + return $array; + } +} + +class Obj +{ + public $foo = 'bar'; + + public function foo() + { + return 'baz'; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/NameNodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/NameNodeTest.php new file mode 100644 index 0000000..a30c27b --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/NameNodeTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\NameNode; + +class NameNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return [ + ['bar', new NameNode('foo'), ['foo' => 'bar']], + ]; + } + + public function getCompileData() + { + return [ + ['$foo', new NameNode('foo')], + ]; + } + + public function getDumpData() + { + return [ + ['foo', new NameNode('foo')], + ]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/NodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/NodeTest.php new file mode 100644 index 0000000..351da05 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/NodeTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; +use Symfony\Component\ExpressionLanguage\Node\Node; + +class NodeTest extends TestCase +{ + public function testToString() + { + $node = new Node([new ConstantNode('foo')]); + + $this->assertEquals(<<<'EOF' +Node( + ConstantNode(value: 'foo') +) +EOF + , (string) $node); + } + + public function testSerialization() + { + $node = new Node(['foo' => 'bar'], ['bar' => 'foo']); + + $serializedNode = serialize($node); + $unserializedNode = unserialize($serializedNode); + + $this->assertEquals($node, $unserializedNode); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/UnaryNodeTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/UnaryNodeTest.php new file mode 100644 index 0000000..bac75d2 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/Node/UnaryNodeTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; +use Symfony\Component\ExpressionLanguage\Node\UnaryNode; + +class UnaryNodeTest extends AbstractNodeTest +{ + public function getEvaluateData() + { + return [ + [-1, new UnaryNode('-', new ConstantNode(1))], + [3, new UnaryNode('+', new ConstantNode(3))], + [false, new UnaryNode('!', new ConstantNode(true))], + [false, new UnaryNode('not', new ConstantNode(true))], + ]; + } + + public function getCompileData() + { + return [ + ['(-1)', new UnaryNode('-', new ConstantNode(1))], + ['(+3)', new UnaryNode('+', new ConstantNode(3))], + ['(!true)', new UnaryNode('!', new ConstantNode(true))], + ['(!true)', new UnaryNode('not', new ConstantNode(true))], + ]; + } + + public function getDumpData() + { + return [ + ['(- 1)', new UnaryNode('-', new ConstantNode(1))], + ['(+ 3)', new UnaryNode('+', new ConstantNode(3))], + ['(! true)', new UnaryNode('!', new ConstantNode(true))], + ['(not true)', new UnaryNode('not', new ConstantNode(true))], + ]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ParsedExpressionTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ParsedExpressionTest.php new file mode 100644 index 0000000..d28101f --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ParsedExpressionTest.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; +use Symfony\Component\ExpressionLanguage\ParsedExpression; + +class ParsedExpressionTest extends TestCase +{ + public function testSerialization() + { + $expression = new ParsedExpression('25', new ConstantNode('25')); + + $serializedExpression = serialize($expression); + $unserializedExpression = unserialize($serializedExpression); + + $this->assertEquals($expression, $unserializedExpression); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ParserCache/ParserCacheAdapterTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ParserCache/ParserCacheAdapterTest.php new file mode 100644 index 0000000..2e92633 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ParserCache/ParserCacheAdapterTest.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\Node\Node; +use Symfony\Component\ExpressionLanguage\ParsedExpression; +use Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheAdapter; + +/** + * @group legacy + */ +class ParserCacheAdapterTest extends TestCase +{ + public function testGetItem() + { + $poolMock = $this->getMockBuilder('Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface')->getMock(); + + $key = 'key'; + $value = 'value'; + $parserCacheAdapter = new ParserCacheAdapter($poolMock); + + $poolMock + ->expects($this->once()) + ->method('fetch') + ->with($key) + ->willReturn($value) + ; + + $cacheItem = $parserCacheAdapter->getItem($key); + + $this->assertEquals($cacheItem->get(), $value); + $this->assertEquals($cacheItem->isHit(), true); + } + + public function testSave() + { + $poolMock = $this->getMockBuilder('Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface')->getMock(); + $cacheItemMock = $this->getMockBuilder('Psr\Cache\CacheItemInterface')->getMock(); + $key = 'key'; + $value = new ParsedExpression('1 + 1', new Node([], [])); + $parserCacheAdapter = new ParserCacheAdapter($poolMock); + + $poolMock + ->expects($this->once()) + ->method('save') + ->with($key, $value) + ; + + $cacheItemMock + ->expects($this->once()) + ->method('getKey') + ->willReturn($key) + ; + + $cacheItemMock + ->expects($this->once()) + ->method('get') + ->willReturn($value) + ; + + $parserCacheAdapter->save($cacheItemMock); + } + + public function testGetItems() + { + $poolMock = $this->getMockBuilder('Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface')->getMock(); + $parserCacheAdapter = new ParserCacheAdapter($poolMock); + $this->expectException(\BadMethodCallException::class); + + $parserCacheAdapter->getItems(); + } + + public function testHasItem() + { + $poolMock = $this->getMockBuilder('Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface')->getMock(); + $key = 'key'; + $parserCacheAdapter = new ParserCacheAdapter($poolMock); + $this->expectException(\BadMethodCallException::class); + + $parserCacheAdapter->hasItem($key); + } + + public function testClear() + { + $poolMock = $this->getMockBuilder('Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface')->getMock(); + $parserCacheAdapter = new ParserCacheAdapter($poolMock); + $this->expectException(\BadMethodCallException::class); + + $parserCacheAdapter->clear(); + } + + public function testDeleteItem() + { + $poolMock = $this->getMockBuilder('Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface')->getMock(); + $key = 'key'; + $parserCacheAdapter = new ParserCacheAdapter($poolMock); + $this->expectException(\BadMethodCallException::class); + + $parserCacheAdapter->deleteItem($key); + } + + public function testDeleteItems() + { + $poolMock = $this->getMockBuilder('Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface')->getMock(); + $keys = ['key']; + $parserCacheAdapter = new ParserCacheAdapter($poolMock); + $this->expectException(\BadMethodCallException::class); + + $parserCacheAdapter->deleteItems($keys); + } + + public function testSaveDeferred() + { + $poolMock = $this->getMockBuilder('Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface')->getMock(); + $parserCacheAdapter = new ParserCacheAdapter($poolMock); + $cacheItemMock = $this->getMockBuilder('Psr\Cache\CacheItemInterface')->getMock(); + $this->expectException(\BadMethodCallException::class); + + $parserCacheAdapter->saveDeferred($cacheItemMock); + } + + public function testCommit() + { + $poolMock = $this->getMockBuilder('Symfony\Component\ExpressionLanguage\ParserCache\ParserCacheInterface')->getMock(); + $parserCacheAdapter = new ParserCacheAdapter($poolMock); + $this->expectException(\BadMethodCallException::class); + + $parserCacheAdapter->commit(); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ParserTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ParserTest.php new file mode 100644 index 0000000..2d5a0a6 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Tests/ParserTest.php @@ -0,0 +1,237 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\Lexer; +use Symfony\Component\ExpressionLanguage\Node; +use Symfony\Component\ExpressionLanguage\Parser; + +class ParserTest extends TestCase +{ + public function testParseWithInvalidName() + { + $this->expectException('Symfony\Component\ExpressionLanguage\SyntaxError'); + $this->expectExceptionMessage('Variable "foo" is not valid around position 1 for expression `foo`.'); + $lexer = new Lexer(); + $parser = new Parser([]); + $parser->parse($lexer->tokenize('foo')); + } + + public function testParseWithZeroInNames() + { + $this->expectException('Symfony\Component\ExpressionLanguage\SyntaxError'); + $this->expectExceptionMessage('Variable "foo" is not valid around position 1 for expression `foo`.'); + $lexer = new Lexer(); + $parser = new Parser([]); + $parser->parse($lexer->tokenize('foo'), [0]); + } + + /** + * @dataProvider getParseData + */ + public function testParse($node, $expression, $names = []) + { + $lexer = new Lexer(); + $parser = new Parser([]); + $this->assertEquals($node, $parser->parse($lexer->tokenize($expression), $names)); + } + + public function getParseData() + { + $arguments = new Node\ArgumentsNode(); + $arguments->addElement(new Node\ConstantNode('arg1')); + $arguments->addElement(new Node\ConstantNode(2)); + $arguments->addElement(new Node\ConstantNode(true)); + + $arrayNode = new Node\ArrayNode(); + $arrayNode->addElement(new Node\NameNode('bar')); + + return [ + [ + new Node\NameNode('a'), + 'a', + ['a'], + ], + [ + new Node\ConstantNode('a'), + '"a"', + ], + [ + new Node\ConstantNode(3), + '3', + ], + [ + new Node\ConstantNode(false), + 'false', + ], + [ + new Node\ConstantNode(true), + 'true', + ], + [ + new Node\ConstantNode(null), + 'null', + ], + [ + new Node\UnaryNode('-', new Node\ConstantNode(3)), + '-3', + ], + [ + new Node\BinaryNode('-', new Node\ConstantNode(3), new Node\ConstantNode(3)), + '3 - 3', + ], + [ + new Node\BinaryNode('*', + new Node\BinaryNode('-', new Node\ConstantNode(3), new Node\ConstantNode(3)), + new Node\ConstantNode(2) + ), + '(3 - 3) * 2', + ], + [ + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true), new Node\ArgumentsNode(), Node\GetAttrNode::PROPERTY_CALL), + 'foo.bar', + ['foo'], + ], + [ + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL), + 'foo.bar()', + ['foo'], + ], + [ + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('not', true), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL), + 'foo.not()', + ['foo'], + ], + [ + new Node\GetAttrNode( + new Node\NameNode('foo'), + new Node\ConstantNode('bar', true), + $arguments, + Node\GetAttrNode::METHOD_CALL + ), + 'foo.bar("arg1", 2, true)', + ['foo'], + ], + [ + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode(3), new Node\ArgumentsNode(), Node\GetAttrNode::ARRAY_CALL), + 'foo[3]', + ['foo'], + ], + [ + new Node\ConditionalNode(new Node\ConstantNode(true), new Node\ConstantNode(true), new Node\ConstantNode(false)), + 'true ? true : false', + ], + [ + new Node\BinaryNode('matches', new Node\ConstantNode('foo'), new Node\ConstantNode('/foo/')), + '"foo" matches "/foo/"', + ], + + // chained calls + [ + $this->createGetAttrNode( + $this->createGetAttrNode( + $this->createGetAttrNode( + $this->createGetAttrNode(new Node\NameNode('foo'), 'bar', Node\GetAttrNode::METHOD_CALL), + 'foo', Node\GetAttrNode::METHOD_CALL), + 'baz', Node\GetAttrNode::PROPERTY_CALL), + '3', Node\GetAttrNode::ARRAY_CALL), + 'foo.bar().foo().baz[3]', + ['foo'], + ], + + [ + new Node\NameNode('foo'), + 'bar', + ['foo' => 'bar'], + ], + + // Operators collisions + [ + new Node\BinaryNode( + 'in', + new Node\GetAttrNode( + new Node\NameNode('foo'), + new Node\ConstantNode('not', true), + new Node\ArgumentsNode(), + Node\GetAttrNode::PROPERTY_CALL + ), + $arrayNode + ), + 'foo.not in [bar]', + ['foo', 'bar'], + ], + [ + new Node\BinaryNode( + 'or', + new Node\UnaryNode('not', new Node\NameNode('foo')), + new Node\GetAttrNode( + new Node\NameNode('foo'), + new Node\ConstantNode('not', true), + new Node\ArgumentsNode(), + Node\GetAttrNode::PROPERTY_CALL + ) + ), + 'not foo or foo.not', + ['foo'], + ], + ]; + } + + private function createGetAttrNode($node, $item, $type) + { + return new Node\GetAttrNode($node, new Node\ConstantNode($item, Node\GetAttrNode::ARRAY_CALL !== $type), new Node\ArgumentsNode(), $type); + } + + /** + * @dataProvider getInvalidPostfixData + */ + public function testParseWithInvalidPostfixData($expr, $names = []) + { + $this->expectException('Symfony\Component\ExpressionLanguage\SyntaxError'); + $lexer = new Lexer(); + $parser = new Parser([]); + $parser->parse($lexer->tokenize($expr), $names); + } + + public function getInvalidPostfixData() + { + return [ + [ + 'foo."#"', + ['foo'], + ], + [ + 'foo."bar"', + ['foo'], + ], + [ + 'foo.**', + ['foo'], + ], + [ + 'foo.123', + ['foo'], + ], + ]; + } + + public function testNameProposal() + { + $this->expectException('Symfony\Component\ExpressionLanguage\SyntaxError'); + $this->expectExceptionMessage('Did you mean "baz"?'); + $lexer = new Lexer(); + $parser = new Parser([]); + + $parser->parse($lexer->tokenize('foo > bar'), ['foo', 'baz']); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Token.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Token.php new file mode 100644 index 0000000..4517335 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/Token.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Represents a Token. + * + * @author Fabien Potencier + */ +class Token +{ + public $value; + public $type; + public $cursor; + + const EOF_TYPE = 'end of expression'; + const NAME_TYPE = 'name'; + const NUMBER_TYPE = 'number'; + const STRING_TYPE = 'string'; + const OPERATOR_TYPE = 'operator'; + const PUNCTUATION_TYPE = 'punctuation'; + + /** + * @param string $type The type of the token (self::*_TYPE) + * @param string|int|float|null $value The token value + * @param int $cursor The cursor position in the source + */ + public function __construct($type, $value, $cursor) + { + $this->type = $type; + $this->value = $value; + $this->cursor = $cursor; + } + + /** + * Returns a string representation of the token. + * + * @return string A string representation of the token + */ + public function __toString() + { + return sprintf('%3d %-11s %s', $this->cursor, strtoupper($this->type), $this->value); + } + + /** + * Tests the current token for a type and/or a value. + * + * @param array|int $type The type to test + * @param string|null $value The token value + * + * @return bool + */ + public function test($type, $value = null) + { + return $this->type === $type && (null === $value || $this->value == $value); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/TokenStream.php b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/TokenStream.php new file mode 100644 index 0000000..09d3854 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/TokenStream.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage; + +/** + * Represents a token stream. + * + * @author Fabien Potencier + */ +class TokenStream +{ + public $current; + + private $tokens; + private $position = 0; + private $expression; + + /** + * @param array $tokens An array of tokens + * @param string $expression + */ + public function __construct(array $tokens, $expression = '') + { + $this->tokens = $tokens; + $this->current = $tokens[0]; + $this->expression = $expression; + } + + /** + * Returns a string representation of the token stream. + * + * @return string + */ + public function __toString() + { + return implode("\n", $this->tokens); + } + + /** + * Sets the pointer to the next token and returns the old one. + */ + public function next() + { + ++$this->position; + + if (!isset($this->tokens[$this->position])) { + throw new SyntaxError('Unexpected end of expression.', $this->current->cursor, $this->expression); + } + + $this->current = $this->tokens[$this->position]; + } + + /** + * Tests a token. + * + * @param array|int $type The type to test + * @param string|null $value The token value + * @param string|null $message The syntax error message + */ + public function expect($type, $value = null, $message = null) + { + $token = $this->current; + if (!$token->test($type, $value)) { + throw new SyntaxError(sprintf('%sUnexpected token "%s" of value "%s" ("%s" expected%s).', $message ? $message.'. ' : '', $token->type, $token->value, $type, $value ? sprintf(' with value "%s"', $value) : ''), $token->cursor, $this->expression); + } + $this->next(); + } + + /** + * Checks if end of stream was reached. + * + * @return bool + */ + public function isEOF() + { + return Token::EOF_TYPE === $this->current->type; + } + + /** + * @internal + * + * @return string + */ + public function getExpression() + { + return $this->expression; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/composer.json b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/composer.json new file mode 100644 index 0000000..ee44185 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/composer.json @@ -0,0 +1,35 @@ +{ + "name": "symfony/expression-language", + "type": "library", + "description": "Symfony ExpressionLanguage Component", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^5.5.9|>=7.0.8", + "symfony/cache": "~3.1|~4.0", + "symfony/polyfill-php70": "~1.6" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\ExpressionLanguage\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "3.4-dev" + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/phpunit.xml.dist b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/phpunit.xml.dist new file mode 100644 index 0000000..ae84dcd --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/expression-language/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + + diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/LICENSE b/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/LICENSE new file mode 100644 index 0000000..4cd8bdd --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2015-2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/Php70.php b/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/Php70.php new file mode 100644 index 0000000..7f1ad08 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/Php70.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Php70; + +/** + * @author Nicolas Grekas + * + * @internal + */ +final class Php70 +{ + public static function intdiv($dividend, $divisor) + { + $dividend = self::intArg($dividend, __FUNCTION__, 1); + $divisor = self::intArg($divisor, __FUNCTION__, 2); + + if (0 === $divisor) { + throw new \DivisionByZeroError('Division by zero'); + } + if (-1 === $divisor && ~PHP_INT_MAX === $dividend) { + throw new \ArithmeticError('Division of PHP_INT_MIN by -1 is not an integer'); + } + + return ($dividend - ($dividend % $divisor)) / $divisor; + } + + public static function preg_replace_callback_array(array $patterns, $subject, $limit = -1, &$count = 0) + { + $count = 0; + $result = (string) $subject; + if (0 === $limit = self::intArg($limit, __FUNCTION__, 3)) { + return $result; + } + + foreach ($patterns as $pattern => $callback) { + $result = preg_replace_callback($pattern, $callback, $result, $limit, $c); + $count += $c; + } + + return $result; + } + + public static function error_clear_last() + { + static $handler; + if (!$handler) { + $handler = function () { return false; }; + } + set_error_handler($handler); + @trigger_error(''); + restore_error_handler(); + } + + private static function intArg($value, $caller, $pos) + { + if (\is_int($value)) { + return $value; + } + if (!\is_numeric($value) || PHP_INT_MAX <= ($value += 0) || ~PHP_INT_MAX >= $value) { + throw new \TypeError(sprintf('%s() expects parameter %d to be integer, %s given', $caller, $pos, \gettype($value))); + } + + return (int) $value; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/README.md b/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/README.md new file mode 100644 index 0000000..abd5488 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/README.md @@ -0,0 +1,28 @@ +Symfony Polyfill / Php70 +======================== + +This component provides features unavailable in releases prior to PHP 7.0: + +- [`intdiv`](https://php.net/intdiv) +- [`preg_replace_callback_array`](https://php.net/preg_replace_callback_array) +- [`error_clear_last`](https://php.net/error_clear_last) +- `random_bytes` and `random_int` (from [paragonie/random_compat](https://github.com/paragonie/random_compat)) +- [`*Error` throwable classes](https://php.net/Error) +- [`PHP_INT_MIN`](https://php.net/reserved.constants#constant.php-int-min) +- `SessionUpdateTimestampHandlerInterface` + +More information can be found in the +[main Polyfill README](https://github.com/symfony/polyfill/blob/master/README.md). + +Compatibility notes +=================== + +To write portable code between PHP5 and PHP7, some care must be taken: +- `\*Error` exceptions must be caught before `\Exception`; +- after calling `error_clear_last()`, the result of `$e = error_get_last()` must be + verified using `isset($e['message'][0])` instead of `null !== $e`. + +License +======= + +This library is released under the [MIT license](LICENSE). diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/Resources/stubs/ArithmeticError.php b/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/Resources/stubs/ArithmeticError.php new file mode 100644 index 0000000..6819124 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/Resources/stubs/ArithmeticError.php @@ -0,0 +1,5 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Php70 as p; + +if (PHP_VERSION_ID < 70000) { + if (!defined('PHP_INT_MIN')) { + define('PHP_INT_MIN', ~PHP_INT_MAX); + } + if (!function_exists('intdiv')) { + function intdiv($dividend, $divisor) { return p\Php70::intdiv($dividend, $divisor); } + } + if (!function_exists('preg_replace_callback_array')) { + function preg_replace_callback_array(array $patterns, $subject, $limit = -1, &$count = 0) { return p\Php70::preg_replace_callback_array($patterns, $subject, $limit, $count); } + } + if (!function_exists('error_clear_last')) { + function error_clear_last() { return p\Php70::error_clear_last(); } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/composer.json b/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/composer.json new file mode 100644 index 0000000..98599f0 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/polyfill-php70/composer.json @@ -0,0 +1,33 @@ +{ + "name": "symfony/polyfill-php70", + "type": "library", + "description": "Symfony polyfill backporting some PHP 7.0+ features to lower PHP versions", + "keywords": ["polyfill", "shim", "compatibility", "portable"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=5.3.3", + "paragonie/random_compat": "~1.0|~2.0|~9.99" + }, + "autoload": { + "psr-4": { "Symfony\\Polyfill\\Php70\\": "" }, + "files": [ "bootstrap.php" ], + "classmap": [ "Resources/stubs" ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "1.15-dev" + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/.gitignore b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/.gitignore new file mode 100644 index 0000000..c49a5d8 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/LICENSE b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/LICENSE new file mode 100644 index 0000000..3f853aa --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018-2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/README.md b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/README.md new file mode 100644 index 0000000..d033a43 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/README.md @@ -0,0 +1,9 @@ +Symfony Service Contracts +========================= + +A set of abstractions extracted out of the Symfony components. + +Can be used to build on semantics that the Symfony components proved useful - and +that already have battle tested implementations. + +See https://github.com/symfony/contracts/blob/master/README.md for more information. diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ResetInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ResetInterface.php new file mode 100644 index 0000000..1af1075 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ResetInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Service; + +/** + * Provides a way to reset an object to its initial state. + * + * When calling the "reset()" method on an object, it should be put back to its + * initial state. This usually means clearing any internal buffers and forwarding + * the call to internal dependencies. All properties of the object should be put + * back to the same state it had when it was first ready to use. + * + * This method could be called, for example, to recycle objects that are used as + * services, so that they can be used to handle several requests in the same + * process loop (note that we advise making your services stateless instead of + * implementing this interface when possible.) + */ +interface ResetInterface +{ + public function reset(); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceLocatorTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceLocatorTrait.php new file mode 100644 index 0000000..4ec6eb4 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceLocatorTrait.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Service; + +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; + +/** + * A trait to help implement ServiceProviderInterface. + * + * @author Robin Chalas + * @author Nicolas Grekas + */ +trait ServiceLocatorTrait +{ + private $factories; + private $loading = []; + private $providedTypes; + + /** + * @param callable[] $factories + */ + public function __construct(array $factories) + { + $this->factories = $factories; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + public function has($id) + { + return isset($this->factories[$id]); + } + + /** + * {@inheritdoc} + */ + public function get($id) + { + if (!isset($this->factories[$id])) { + throw $this->createNotFoundException($id); + } + + if (isset($this->loading[$id])) { + $ids = array_values($this->loading); + $ids = \array_slice($this->loading, array_search($id, $ids)); + $ids[] = $id; + + throw $this->createCircularReferenceException($id, $ids); + } + + $this->loading[$id] = $id; + try { + return $this->factories[$id]($this); + } finally { + unset($this->loading[$id]); + } + } + + /** + * {@inheritdoc} + */ + public function getProvidedServices(): array + { + if (null === $this->providedTypes) { + $this->providedTypes = []; + + foreach ($this->factories as $name => $factory) { + if (!\is_callable($factory)) { + $this->providedTypes[$name] = '?'; + } else { + $type = (new \ReflectionFunction($factory))->getReturnType(); + + $this->providedTypes[$name] = $type ? ($type->allowsNull() ? '?' : '').$type->getName() : '?'; + } + } + } + + return $this->providedTypes; + } + + private function createNotFoundException(string $id): NotFoundExceptionInterface + { + if (!$alternatives = array_keys($this->factories)) { + $message = 'is empty...'; + } else { + $last = array_pop($alternatives); + if ($alternatives) { + $message = sprintf('only knows about the "%s" and "%s" services.', implode('", "', $alternatives), $last); + } else { + $message = sprintf('only knows about the "%s" service.', $last); + } + } + + if ($this->loading) { + $message = sprintf('The service "%s" has a dependency on a non-existent service "%s". This locator %s', end($this->loading), $id, $message); + } else { + $message = sprintf('Service "%s" not found: the current service locator %s', $id, $message); + } + + return new class($message) extends \InvalidArgumentException implements NotFoundExceptionInterface { + }; + } + + private function createCircularReferenceException(string $id, array $path): ContainerExceptionInterface + { + return new class(sprintf('Circular reference detected for service "%s", path: "%s".', $id, implode(' -> ', $path))) extends \RuntimeException implements ContainerExceptionInterface { + }; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceProviderInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceProviderInterface.php new file mode 100644 index 0000000..c60ad0b --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceProviderInterface.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Service; + +use Psr\Container\ContainerInterface; + +/** + * A ServiceProviderInterface exposes the identifiers and the types of services provided by a container. + * + * @author Nicolas Grekas + * @author Mateusz Sip + */ +interface ServiceProviderInterface extends ContainerInterface +{ + /** + * Returns an associative array of service types keyed by the identifiers provided by the current container. + * + * Examples: + * + * * ['logger' => 'Psr\Log\LoggerInterface'] means the object provides a service named "logger" that implements Psr\Log\LoggerInterface + * * ['foo' => '?'] means the container provides service name "foo" of unspecified type + * * ['bar' => '?Bar\Baz'] means the container provides a service "bar" of type Bar\Baz|null + * + * @return string[] The provided service types, keyed by service names + */ + public function getProvidedServices(): array; +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceSubscriberInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceSubscriberInterface.php new file mode 100644 index 0000000..8bb320f --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceSubscriberInterface.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Service; + +/** + * A ServiceSubscriber exposes its dependencies via the static {@link getSubscribedServices} method. + * + * The getSubscribedServices method returns an array of service types required by such instances, + * optionally keyed by the service names used internally. Service types that start with an interrogation + * mark "?" are optional, while the other ones are mandatory service dependencies. + * + * The injected service locators SHOULD NOT allow access to any other services not specified by the method. + * + * It is expected that ServiceSubscriber instances consume PSR-11-based service locators internally. + * This interface does not dictate any injection method for these service locators, although constructor + * injection is recommended. + * + * @author Nicolas Grekas + */ +interface ServiceSubscriberInterface +{ + /** + * Returns an array of service types required by such instances, optionally keyed by the service names used internally. + * + * For mandatory dependencies: + * + * * ['logger' => 'Psr\Log\LoggerInterface'] means the objects use the "logger" name + * internally to fetch a service which must implement Psr\Log\LoggerInterface. + * * ['loggers' => 'Psr\Log\LoggerInterface[]'] means the objects use the "loggers" name + * internally to fetch an iterable of Psr\Log\LoggerInterface instances. + * * ['Psr\Log\LoggerInterface'] is a shortcut for + * * ['Psr\Log\LoggerInterface' => 'Psr\Log\LoggerInterface'] + * + * otherwise: + * + * * ['logger' => '?Psr\Log\LoggerInterface'] denotes an optional dependency + * * ['loggers' => '?Psr\Log\LoggerInterface[]'] denotes an optional iterable dependency + * * ['?Psr\Log\LoggerInterface'] is a shortcut for + * * ['Psr\Log\LoggerInterface' => '?Psr\Log\LoggerInterface'] + * + * @return array The required service types, optionally keyed by service names + */ + public static function getSubscribedServices(); +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceSubscriberTrait.php b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceSubscriberTrait.php new file mode 100644 index 0000000..5d9d456 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/ServiceSubscriberTrait.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Service; + +use Psr\Container\ContainerInterface; + +/** + * Implementation of ServiceSubscriberInterface that determines subscribed services from + * private method return types. Service ids are available as "ClassName::methodName". + * + * @author Kevin Bond + */ +trait ServiceSubscriberTrait +{ + /** @var ContainerInterface */ + protected $container; + + public static function getSubscribedServices(): array + { + static $services; + + if (null !== $services) { + return $services; + } + + $services = \is_callable(['parent', __FUNCTION__]) ? parent::getSubscribedServices() : []; + + foreach ((new \ReflectionClass(self::class))->getMethods() as $method) { + if ($method->isStatic() || $method->isAbstract() || $method->isGenerator() || $method->isInternal() || $method->getNumberOfRequiredParameters()) { + continue; + } + + if (self::class === $method->getDeclaringClass()->name && ($returnType = $method->getReturnType()) && !$returnType->isBuiltin()) { + $services[self::class.'::'.$method->name] = '?'.$returnType->getName(); + } + } + + return $services; + } + + /** + * @required + */ + public function setContainer(ContainerInterface $container) + { + $this->container = $container; + + if (\is_callable(['parent', __FUNCTION__])) { + return parent::setContainer($container); + } + + return null; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/Test/ServiceLocatorTest.php b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/Test/ServiceLocatorTest.php new file mode 100644 index 0000000..5ed9149 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/Test/ServiceLocatorTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Service\Test; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Contracts\Service\ServiceLocatorTrait; + +abstract class ServiceLocatorTest extends TestCase +{ + protected function getServiceLocator(array $factories) + { + return new class($factories) implements ContainerInterface { + use ServiceLocatorTrait; + }; + } + + public function testHas() + { + $locator = $this->getServiceLocator([ + 'foo' => function () { return 'bar'; }, + 'bar' => function () { return 'baz'; }, + function () { return 'dummy'; }, + ]); + + $this->assertTrue($locator->has('foo')); + $this->assertTrue($locator->has('bar')); + $this->assertFalse($locator->has('dummy')); + } + + public function testGet() + { + $locator = $this->getServiceLocator([ + 'foo' => function () { return 'bar'; }, + 'bar' => function () { return 'baz'; }, + ]); + + $this->assertSame('bar', $locator->get('foo')); + $this->assertSame('baz', $locator->get('bar')); + } + + public function testGetDoesNotMemoize() + { + $i = 0; + $locator = $this->getServiceLocator([ + 'foo' => function () use (&$i) { + ++$i; + + return 'bar'; + }, + ]); + + $this->assertSame('bar', $locator->get('foo')); + $this->assertSame('bar', $locator->get('foo')); + $this->assertSame(2, $i); + } + + public function testThrowsOnUndefinedInternalService() + { + if (!$this->getExpectedException()) { + $this->expectException('Psr\Container\NotFoundExceptionInterface'); + $this->expectExceptionMessage('The service "foo" has a dependency on a non-existent service "bar". This locator only knows about the "foo" service.'); + } + $locator = $this->getServiceLocator([ + 'foo' => function () use (&$locator) { return $locator->get('bar'); }, + ]); + + $locator->get('foo'); + } + + public function testThrowsOnCircularReference() + { + $this->expectException('Psr\Container\ContainerExceptionInterface'); + $this->expectExceptionMessage('Circular reference detected for service "bar", path: "bar -> baz -> bar".'); + $locator = $this->getServiceLocator([ + 'foo' => function () use (&$locator) { return $locator->get('bar'); }, + 'bar' => function () use (&$locator) { return $locator->get('baz'); }, + 'baz' => function () use (&$locator) { return $locator->get('bar'); }, + ]); + + $locator->get('foo'); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/composer.json b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/composer.json new file mode 100644 index 0000000..cbd491b --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/service-contracts/composer.json @@ -0,0 +1,34 @@ +{ + "name": "symfony/service-contracts", + "type": "library", + "description": "Generic abstractions related to writing services", + "keywords": ["abstractions", "contracts", "decoupling", "interfaces", "interoperability", "standards"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.2.5", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "autoload": { + "psr-4": { "Symfony\\Contracts\\Service\\": "" } + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/CHANGELOG.md b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/CHANGELOG.md new file mode 100644 index 0000000..9aa4a8b --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +4.2.0 +----- + + * added the component diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Exception/ClassNotFoundException.php b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Exception/ClassNotFoundException.php new file mode 100644 index 0000000..4cebe44 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Exception/ClassNotFoundException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Exception; + +class ClassNotFoundException extends \Exception implements ExceptionInterface +{ + public function __construct(string $class, \Throwable $previous = null) + { + parent::__construct(sprintf('Class "%s" not found.', $class), 0, $previous); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Exception/ExceptionInterface.php b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Exception/ExceptionInterface.php new file mode 100644 index 0000000..adfaed4 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Exception/ExceptionInterface.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Exception; + +interface ExceptionInterface extends \Throwable +{ +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Exception/NotInstantiableTypeException.php b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Exception/NotInstantiableTypeException.php new file mode 100644 index 0000000..7ca4884 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Exception/NotInstantiableTypeException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Exception; + +class NotInstantiableTypeException extends \Exception implements ExceptionInterface +{ + public function __construct(string $type) + { + parent::__construct(sprintf('Type "%s" is not instantiable.', $type)); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Instantiator.php b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Instantiator.php new file mode 100644 index 0000000..9fd8cf3 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Instantiator.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter; + +use Symfony\Component\VarExporter\Exception\ExceptionInterface; +use Symfony\Component\VarExporter\Exception\NotInstantiableTypeException; +use Symfony\Component\VarExporter\Internal\Hydrator; +use Symfony\Component\VarExporter\Internal\Registry; + +/** + * A utility class to create objects without calling their constructor. + * + * @author Nicolas Grekas + */ +final class Instantiator +{ + /** + * Creates an object and sets its properties without calling its constructor nor any other methods. + * + * For example: + * + * // creates an empty instance of Foo + * Instantiator::instantiate(Foo::class); + * + * // creates a Foo instance and sets one of its properties + * Instantiator::instantiate(Foo::class, ['propertyName' => $propertyValue]); + * + * // creates a Foo instance and sets a private property defined on its parent Bar class + * Instantiator::instantiate(Foo::class, [], [ + * Bar::class => ['privateBarProperty' => $propertyValue], + * ]); + * + * Instances of ArrayObject, ArrayIterator and SplObjectHash can be created + * by using the special "\0" property name to define their internal value: + * + * // creates an SplObjectHash where $info1 is attached to $obj1, etc. + * Instantiator::instantiate(SplObjectStorage::class, ["\0" => [$obj1, $info1, $obj2, $info2...]]); + * + * // creates an ArrayObject populated with $inputArray + * Instantiator::instantiate(ArrayObject::class, ["\0" => [$inputArray]]); + * + * @param string $class The class of the instance to create + * @param array $properties The properties to set on the instance + * @param array $privateProperties The private properties to set on the instance, + * keyed by their declaring class + * + * @return object The created instance + * + * @throws ExceptionInterface When the instance cannot be created + */ + public static function instantiate(string $class, array $properties = [], array $privateProperties = []): object + { + $reflector = Registry::$reflectors[$class] ?? Registry::getClassReflector($class); + + if (Registry::$cloneable[$class]) { + $wrappedInstance = [clone Registry::$prototypes[$class]]; + } elseif (Registry::$instantiableWithoutConstructor[$class]) { + $wrappedInstance = [$reflector->newInstanceWithoutConstructor()]; + } elseif (null === Registry::$prototypes[$class]) { + throw new NotInstantiableTypeException($class); + } elseif ($reflector->implementsInterface('Serializable') && (\PHP_VERSION_ID < 70400 || !method_exists($class, '__unserialize'))) { + $wrappedInstance = [unserialize('C:'.\strlen($class).':"'.$class.'":0:{}')]; + } else { + $wrappedInstance = [unserialize('O:'.\strlen($class).':"'.$class.'":0:{}')]; + } + + if ($properties) { + $privateProperties[$class] = isset($privateProperties[$class]) ? $properties + $privateProperties[$class] : $properties; + } + + foreach ($privateProperties as $class => $properties) { + if (!$properties) { + continue; + } + foreach ($properties as $name => $value) { + // because they're also used for "unserialization", hydrators + // deal with array of instances, so we need to wrap values + $properties[$name] = [$value]; + } + (Hydrator::$hydrators[$class] ?? Hydrator::getHydrator($class))($properties, $wrappedInstance); + } + + return $wrappedInstance[0]; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Exporter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Exporter.php new file mode 100644 index 0000000..38f7fbf --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Exporter.php @@ -0,0 +1,405 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Internal; + +use Symfony\Component\VarExporter\Exception\NotInstantiableTypeException; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class Exporter +{ + /** + * Prepares an array of values for VarExporter. + * + * For performance this method is public and has no type-hints. + * + * @param array &$values + * @param \SplObjectStorage $objectsPool + * @param array &$refsPool + * @param int &$objectsCount + * @param bool &$valuesAreStatic + * + * @throws NotInstantiableTypeException When a value cannot be serialized + */ + public static function prepare($values, $objectsPool, &$refsPool, &$objectsCount, &$valuesAreStatic): array + { + $refs = $values; + foreach ($values as $k => $value) { + if (\is_resource($value)) { + throw new NotInstantiableTypeException(get_resource_type($value).' resource'); + } + $refs[$k] = $objectsPool; + + if ($isRef = !$valueIsStatic = $values[$k] !== $objectsPool) { + $values[$k] = &$value; // Break hard references to make $values completely + unset($value); // independent from the original structure + $refs[$k] = $value = $values[$k]; + if ($value instanceof Reference && 0 > $value->id) { + $valuesAreStatic = false; + ++$value->count; + continue; + } + $refsPool[] = [&$refs[$k], $value, &$value]; + $refs[$k] = $values[$k] = new Reference(-\count($refsPool), $value); + } + + if (\is_array($value)) { + if ($value) { + $value = self::prepare($value, $objectsPool, $refsPool, $objectsCount, $valueIsStatic); + } + goto handle_value; + } elseif (!\is_object($value) && !$value instanceof \__PHP_Incomplete_Class) { + goto handle_value; + } + + $valueIsStatic = false; + if (isset($objectsPool[$value])) { + ++$objectsCount; + $value = new Reference($objectsPool[$value][0]); + goto handle_value; + } + + $class = \get_class($value); + $reflector = Registry::$reflectors[$class] ?? Registry::getClassReflector($class); + + if ($reflector->hasMethod('__serialize')) { + if (!$reflector->getMethod('__serialize')->isPublic()) { + throw new \Error(sprintf('Call to %s method "%s::__serialize()".', $reflector->getMethod('__serialize')->isProtected() ? 'protected' : 'private', $class)); + } + + if (!\is_array($properties = $value->__serialize())) { + throw new \Typerror($class.'::__serialize() must return an array'); + } + + goto prepare_value; + } + + $properties = []; + $sleep = null; + $arrayValue = (array) $value; + $proto = Registry::$prototypes[$class]; + + if (($value instanceof \ArrayIterator || $value instanceof \ArrayObject) && null !== $proto) { + // ArrayIterator and ArrayObject need special care because their "flags" + // option changes the behavior of the (array) casting operator. + $properties = self::getArrayObjectProperties($value, $arrayValue, $proto); + + // populates Registry::$prototypes[$class] with a new instance + Registry::getClassReflector($class, Registry::$instantiableWithoutConstructor[$class], Registry::$cloneable[$class]); + } elseif ($value instanceof \SplObjectStorage && Registry::$cloneable[$class] && null !== $proto) { + // By implementing Serializable, SplObjectStorage breaks + // internal references; let's deal with it on our own. + foreach (clone $value as $v) { + $properties[] = $v; + $properties[] = $value[$v]; + } + $properties = ['SplObjectStorage' => ["\0" => $properties]]; + } elseif ($value instanceof \Serializable || $value instanceof \__PHP_Incomplete_Class) { + ++$objectsCount; + $objectsPool[$value] = [$id = \count($objectsPool), serialize($value), [], 0]; + $value = new Reference($id); + goto handle_value; + } + + if (method_exists($class, '__sleep')) { + if (!\is_array($sleep = $value->__sleep())) { + trigger_error('serialize(): __sleep should return an array only containing the names of instance-variables to serialize', E_USER_NOTICE); + $value = null; + goto handle_value; + } + foreach ($sleep as $name) { + if (property_exists($value, $name) && !$reflector->hasProperty($name)) { + $arrayValue[$name] = $value->$name; + } + } + $sleep = array_flip($sleep); + } + + $proto = (array) $proto; + + foreach ($arrayValue as $name => $v) { + $i = 0; + $n = (string) $name; + if ('' === $n || "\0" !== $n[0]) { + $c = 'stdClass'; + } elseif ('*' === $n[1]) { + $n = substr($n, 3); + $c = $reflector->getProperty($n)->class; + if ('Error' === $c) { + $c = 'TypeError'; + } elseif ('Exception' === $c) { + $c = 'ErrorException'; + } + } else { + $i = strpos($n, "\0", 2); + $c = substr($n, 1, $i - 1); + $n = substr($n, 1 + $i); + } + if (null !== $sleep) { + if (!isset($sleep[$n]) || ($i && $c !== $class)) { + continue; + } + $sleep[$n] = false; + } + if (!\array_key_exists($name, $proto) || $proto[$name] !== $v || "\x00Error\x00trace" === $name || "\x00Exception\x00trace" === $name) { + $properties[$c][$n] = $v; + } + } + if ($sleep) { + foreach ($sleep as $n => $v) { + if (false !== $v) { + trigger_error(sprintf('serialize(): "%s" returned as member variable from __sleep() but does not exist', $n), E_USER_NOTICE); + } + } + } + + prepare_value: + $objectsPool[$value] = [$id = \count($objectsPool)]; + $properties = self::prepare($properties, $objectsPool, $refsPool, $objectsCount, $valueIsStatic); + ++$objectsCount; + $objectsPool[$value] = [$id, $class, $properties, method_exists($class, '__unserialize') ? -$objectsCount : (method_exists($class, '__wakeup') ? $objectsCount : 0)]; + + $value = new Reference($id); + + handle_value: + if ($isRef) { + unset($value); // Break the hard reference created above + } elseif (!$valueIsStatic) { + $values[$k] = $value; + } + $valuesAreStatic = $valueIsStatic && $valuesAreStatic; + } + + return $values; + } + + public static function export($value, string $indent = '') + { + switch (true) { + case \is_int($value) || \is_float($value): return var_export($value, true); + case [] === $value: return '[]'; + case false === $value: return 'false'; + case true === $value: return 'true'; + case null === $value: return 'null'; + case '' === $value: return "''"; + } + + if ($value instanceof Reference) { + if (0 <= $value->id) { + return '$o['.$value->id.']'; + } + if (!$value->count) { + return self::export($value->value, $indent); + } + $value = -$value->id; + + return '&$r['.$value.']'; + } + $subIndent = $indent.' '; + + if (\is_string($value)) { + $code = sprintf("'%s'", addcslashes($value, "'\\")); + + $code = preg_replace_callback('/([\0\r\n]++)(.)/', function ($m) use ($subIndent) { + $m[1] = sprintf('\'."%s".\'', str_replace( + ["\0", "\r", "\n", '\n\\'], + ['\0', '\r', '\n', '\n"'."\n".$subIndent.'."\\'], + $m[1] + )); + + if ("'" === $m[2]) { + return substr($m[1], 0, -2); + } + + if ('n".\'' === substr($m[1], -4)) { + return substr_replace($m[1], "\n".$subIndent.".'".$m[2], -2); + } + + return $m[1].$m[2]; + }, $code, -1, $count); + + if ($count && 0 === strpos($code, "''.")) { + $code = substr($code, 3); + } + + return $code; + } + + if (\is_array($value)) { + $j = -1; + $code = ''; + foreach ($value as $k => $v) { + $code .= $subIndent; + if (!\is_int($k) || 1 !== $k - $j) { + $code .= self::export($k, $subIndent).' => '; + } + if (\is_int($k) && $k > $j) { + $j = $k; + } + $code .= self::export($v, $subIndent).",\n"; + } + + return "[\n".$code.$indent.']'; + } + + if ($value instanceof Values) { + $code = $subIndent."\$r = [],\n"; + foreach ($value->values as $k => $v) { + $code .= $subIndent.'$r['.$k.'] = '.self::export($v, $subIndent).",\n"; + } + + return "[\n".$code.$indent.']'; + } + + if ($value instanceof Registry) { + return self::exportRegistry($value, $indent, $subIndent); + } + + if ($value instanceof Hydrator) { + return self::exportHydrator($value, $indent, $subIndent); + } + + throw new \UnexpectedValueException(sprintf('Cannot export value of type "%s".', \is_object($value) ? \get_class($value) : \gettype($value))); + } + + private static function exportRegistry(Registry $value, string $indent, string $subIndent): string + { + $code = ''; + $serializables = []; + $seen = []; + $prototypesAccess = 0; + $factoriesAccess = 0; + $r = '\\'.Registry::class; + $j = -1; + + foreach ($value as $k => $class) { + if (':' === ($class[1] ?? null)) { + $serializables[$k] = $class; + continue; + } + if (!Registry::$instantiableWithoutConstructor[$class]) { + if (is_subclass_of($class, 'Serializable') && !method_exists($class, '__unserialize')) { + $serializables[$k] = 'C:'.\strlen($class).':"'.$class.'":0:{}'; + } else { + $serializables[$k] = 'O:'.\strlen($class).':"'.$class.'":0:{}'; + } + if (is_subclass_of($class, 'Throwable')) { + $eol = is_subclass_of($class, 'Error') ? "\0Error\0" : "\0Exception\0"; + $serializables[$k] = substr_replace($serializables[$k], '1:{s:'.(5 + \strlen($eol)).':"'.$eol.'trace";a:0:{}}', -4); + } + continue; + } + $code .= $subIndent.(1 !== $k - $j ? $k.' => ' : ''); + $j = $k; + $eol = ",\n"; + $c = '['.self::export($class).']'; + + if ($seen[$class] ?? false) { + if (Registry::$cloneable[$class]) { + ++$prototypesAccess; + $code .= 'clone $p'.$c; + } else { + ++$factoriesAccess; + $code .= '$f'.$c.'()'; + } + } else { + $seen[$class] = true; + if (Registry::$cloneable[$class]) { + $code .= 'clone ('.($prototypesAccess++ ? '$p' : '($p = &'.$r.'::$prototypes)').$c.' ?? '.$r.'::p'; + } else { + $code .= '('.($factoriesAccess++ ? '$f' : '($f = &'.$r.'::$factories)').$c.' ?? '.$r.'::f'; + $eol = '()'.$eol; + } + $code .= '('.substr($c, 1, -1).'))'; + } + $code .= $eol; + } + + if (1 === $prototypesAccess) { + $code = str_replace('($p = &'.$r.'::$prototypes)', $r.'::$prototypes', $code); + } + if (1 === $factoriesAccess) { + $code = str_replace('($f = &'.$r.'::$factories)', $r.'::$factories', $code); + } + if ('' !== $code) { + $code = "\n".$code.$indent; + } + + if ($serializables) { + $code = $r.'::unserialize(['.$code.'], '.self::export($serializables, $indent).')'; + } else { + $code = '['.$code.']'; + } + + return '$o = '.$code; + } + + private static function exportHydrator(Hydrator $value, string $indent, string $subIndent): string + { + $code = ''; + foreach ($value->properties as $class => $properties) { + $code .= $subIndent.' '.self::export($class).' => '.self::export($properties, $subIndent.' ').",\n"; + } + + $code = [ + self::export($value->registry, $subIndent), + self::export($value->values, $subIndent), + '' !== $code ? "[\n".$code.$subIndent.']' : '[]', + self::export($value->value, $subIndent), + self::export($value->wakeups, $subIndent), + ]; + + return '\\'.\get_class($value)."::hydrate(\n".$subIndent.implode(",\n".$subIndent, $code)."\n".$indent.')'; + } + + /** + * @param \ArrayIterator|\ArrayObject $value + * @param \ArrayIterator|\ArrayObject $proto + */ + private static function getArrayObjectProperties($value, array &$arrayValue, $proto): array + { + $reflector = $value instanceof \ArrayIterator ? 'ArrayIterator' : 'ArrayObject'; + $reflector = Registry::$reflectors[$reflector] ?? Registry::getClassReflector($reflector); + + $properties = [ + $arrayValue, + $reflector->getMethod('getFlags')->invoke($value), + $value instanceof \ArrayObject ? $reflector->getMethod('getIteratorClass')->invoke($value) : 'ArrayIterator', + ]; + + $reflector = $reflector->getMethod('setFlags'); + $reflector->invoke($proto, \ArrayObject::STD_PROP_LIST); + + if ($properties[1] & \ArrayObject::STD_PROP_LIST) { + $reflector->invoke($value, 0); + $properties[0] = (array) $value; + } else { + $reflector->invoke($value, \ArrayObject::STD_PROP_LIST); + $arrayValue = (array) $value; + } + $reflector->invoke($value, $properties[1]); + + if ([[], 0, 'ArrayIterator'] === $properties) { + $properties = []; + } else { + if ('ArrayIterator' === $properties[2]) { + unset($properties[2]); + } + $properties = [$reflector->class => ["\0" => $properties]]; + } + + return $properties; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Hydrator.php b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Hydrator.php new file mode 100644 index 0000000..364d292 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Hydrator.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Internal; + +use Symfony\Component\VarExporter\Exception\ClassNotFoundException; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class Hydrator +{ + public static $hydrators = []; + + public $registry; + public $values; + public $properties; + public $value; + public $wakeups; + + public function __construct(?Registry $registry, ?Values $values, array $properties, $value, array $wakeups) + { + $this->registry = $registry; + $this->values = $values; + $this->properties = $properties; + $this->value = $value; + $this->wakeups = $wakeups; + } + + public static function hydrate($objects, $values, $properties, $value, $wakeups) + { + foreach ($properties as $class => $vars) { + (self::$hydrators[$class] ?? self::getHydrator($class))($vars, $objects); + } + foreach ($wakeups as $k => $v) { + if (\is_array($v)) { + $objects[-$k]->__unserialize($v); + } else { + $objects[$v]->__wakeup(); + } + } + + return $value; + } + + public static function getHydrator($class) + { + if ('stdClass' === $class) { + return self::$hydrators[$class] = static function ($properties, $objects) { + foreach ($properties as $name => $values) { + foreach ($values as $i => $v) { + $objects[$i]->$name = $v; + } + } + }; + } + + if (!class_exists($class) && !interface_exists($class, false) && !trait_exists($class, false)) { + throw new ClassNotFoundException($class); + } + $classReflector = new \ReflectionClass($class); + + if (!$classReflector->isInternal()) { + return self::$hydrators[$class] = (self::$hydrators['stdClass'] ?? self::getHydrator('stdClass'))->bindTo(null, $class); + } + + if ($classReflector->name !== $class) { + return self::$hydrators[$classReflector->name] ?? self::getHydrator($classReflector->name); + } + + switch ($class) { + case 'ArrayIterator': + case 'ArrayObject': + $constructor = \Closure::fromCallable([$classReflector->getConstructor(), 'invokeArgs']); + + return self::$hydrators[$class] = static function ($properties, $objects) use ($constructor) { + foreach ($properties as $name => $values) { + if ("\0" !== $name) { + foreach ($values as $i => $v) { + $objects[$i]->$name = $v; + } + } + } + foreach ($properties["\0"] ?? [] as $i => $v) { + $constructor($objects[$i], $v); + } + }; + + case 'ErrorException': + return self::$hydrators[$class] = (self::$hydrators['stdClass'] ?? self::getHydrator('stdClass'))->bindTo(null, new class() extends \ErrorException { + }); + + case 'TypeError': + return self::$hydrators[$class] = (self::$hydrators['stdClass'] ?? self::getHydrator('stdClass'))->bindTo(null, new class() extends \Error { + }); + + case 'SplObjectStorage': + return self::$hydrators[$class] = static function ($properties, $objects) { + foreach ($properties as $name => $values) { + if ("\0" === $name) { + foreach ($values as $i => $v) { + for ($j = 0; $j < \count($v); ++$j) { + $objects[$i]->attach($v[$j], $v[++$j]); + } + } + continue; + } + foreach ($values as $i => $v) { + $objects[$i]->$name = $v; + } + } + }; + } + + $propertySetters = []; + foreach ($classReflector->getProperties() as $propertyReflector) { + if (!$propertyReflector->isStatic()) { + $propertyReflector->setAccessible(true); + $propertySetters[$propertyReflector->name] = \Closure::fromCallable([$propertyReflector, 'setValue']); + } + } + + if (!$propertySetters) { + return self::$hydrators[$class] = self::$hydrators['stdClass'] ?? self::getHydrator('stdClass'); + } + + return self::$hydrators[$class] = static function ($properties, $objects) use ($propertySetters) { + foreach ($properties as $name => $values) { + if ($setValue = $propertySetters[$name] ?? null) { + foreach ($values as $i => $v) { + $setValue($objects[$i], $v); + } + continue; + } + foreach ($values as $i => $v) { + $objects[$i]->$name = $v; + } + } + }; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Reference.php b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Reference.php new file mode 100644 index 0000000..e371c07 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Reference.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Internal; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class Reference +{ + public $id; + public $value; + public $count = 0; + + public function __construct(int $id, $value = null) + { + $this->id = $id; + $this->value = $value; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Registry.php b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Registry.php new file mode 100644 index 0000000..19d91c9 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Registry.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Internal; + +use Symfony\Component\VarExporter\Exception\ClassNotFoundException; +use Symfony\Component\VarExporter\Exception\NotInstantiableTypeException; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class Registry +{ + public static $reflectors = []; + public static $prototypes = []; + public static $factories = []; + public static $cloneable = []; + public static $instantiableWithoutConstructor = []; + + public function __construct(array $classes) + { + foreach ($classes as $i => $class) { + $this->$i = $class; + } + } + + public static function unserialize($objects, $serializables) + { + $unserializeCallback = ini_set('unserialize_callback_func', __CLASS__.'::getClassReflector'); + + try { + foreach ($serializables as $k => $v) { + $objects[$k] = unserialize($v); + } + } finally { + ini_set('unserialize_callback_func', $unserializeCallback); + } + + return $objects; + } + + public static function p($class) + { + self::getClassReflector($class, true, true); + + return self::$prototypes[$class]; + } + + public static function f($class) + { + $reflector = self::$reflectors[$class] ?? self::getClassReflector($class, true, false); + + return self::$factories[$class] = \Closure::fromCallable([$reflector, 'newInstanceWithoutConstructor']); + } + + public static function getClassReflector($class, $instantiableWithoutConstructor = false, $cloneable = null) + { + if (!($isClass = class_exists($class)) && !interface_exists($class, false) && !trait_exists($class, false)) { + throw new ClassNotFoundException($class); + } + $reflector = new \ReflectionClass($class); + + if ($instantiableWithoutConstructor) { + $proto = $reflector->newInstanceWithoutConstructor(); + } elseif (!$isClass || $reflector->isAbstract()) { + throw new NotInstantiableTypeException($class); + } elseif ($reflector->name !== $class) { + $reflector = self::$reflectors[$name = $reflector->name] ?? self::getClassReflector($name, false, $cloneable); + self::$cloneable[$class] = self::$cloneable[$name]; + self::$instantiableWithoutConstructor[$class] = self::$instantiableWithoutConstructor[$name]; + self::$prototypes[$class] = self::$prototypes[$name]; + + return self::$reflectors[$class] = $reflector; + } else { + try { + $proto = $reflector->newInstanceWithoutConstructor(); + $instantiableWithoutConstructor = true; + } catch (\ReflectionException $e) { + $proto = $reflector->implementsInterface('Serializable') && !method_exists($class, '__unserialize') ? 'C:' : 'O:'; + if ('C:' === $proto && !$reflector->getMethod('unserialize')->isInternal()) { + $proto = null; + } elseif (false === $proto = @unserialize($proto.\strlen($class).':"'.$class.'":0:{}')) { + throw new NotInstantiableTypeException($class); + } + } + if (null !== $proto && !$proto instanceof \Throwable && !$proto instanceof \Serializable && !method_exists($class, '__sleep') && (\PHP_VERSION_ID < 70400 || !method_exists($class, '__serialize'))) { + try { + serialize($proto); + } catch (\Exception $e) { + throw new NotInstantiableTypeException($class, $e); + } + } + } + + if (null === $cloneable) { + if (($proto instanceof \Reflector || $proto instanceof \ReflectionGenerator || $proto instanceof \ReflectionType || $proto instanceof \IteratorIterator || $proto instanceof \RecursiveIteratorIterator) && (!$proto instanceof \Serializable && !method_exists($proto, '__wakeup') && (\PHP_VERSION_ID < 70400 || !method_exists($class, '__unserialize')))) { + throw new NotInstantiableTypeException($class); + } + + $cloneable = $reflector->isCloneable() && !$reflector->hasMethod('__clone'); + } + + self::$cloneable[$class] = $cloneable; + self::$instantiableWithoutConstructor[$class] = $instantiableWithoutConstructor; + self::$prototypes[$class] = $proto; + + if ($proto instanceof \Throwable) { + static $setTrace; + + if (null === $setTrace) { + $setTrace = [ + new \ReflectionProperty(\Error::class, 'trace'), + new \ReflectionProperty(\Exception::class, 'trace'), + ]; + $setTrace[0]->setAccessible(true); + $setTrace[1]->setAccessible(true); + $setTrace[0] = \Closure::fromCallable([$setTrace[0], 'setValue']); + $setTrace[1] = \Closure::fromCallable([$setTrace[1], 'setValue']); + } + + $setTrace[$proto instanceof \Exception]($proto, []); + } + + return self::$reflectors[$class] = $reflector; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Values.php b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Values.php new file mode 100644 index 0000000..21ae04e --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/Internal/Values.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Internal; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class Values +{ + public $values; + + public function __construct(array $values) + { + $this->values = $values; + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/LICENSE b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/LICENSE new file mode 100644 index 0000000..69d925b --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018-2020 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/README.md b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/README.md new file mode 100644 index 0000000..bb13960 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/README.md @@ -0,0 +1,38 @@ +VarExporter Component +===================== + +The VarExporter component allows exporting any serializable PHP data structure to +plain PHP code. While doing so, it preserves all the semantics associated with +the serialization mechanism of PHP (`__wakeup`, `__sleep`, `Serializable`, +`__serialize`, `__unserialize`). + +It also provides an instantiator that allows creating and populating objects +without calling their constructor nor any other methods. + +The reason to use this component *vs* `serialize()` or +[igbinary](https://github.com/igbinary/igbinary) is performance: thanks to +OPcache, the resulting code is significantly faster and more memory efficient +than using `unserialize()` or `igbinary_unserialize()`. + +Unlike `var_export()`, this works on any serializable PHP value. + +It also provides a few improvements over `var_export()`/`serialize()`: + + * the output is PSR-2 compatible; + * the output can be re-indented without messing up with `\r` or `\n` in the data + * missing classes throw a `ClassNotFoundException` instead of being unserialized to + `PHP_Incomplete_Class` objects; + * references involving `SplObjectStorage`, `ArrayObject` or `ArrayIterator` + instances are preserved; + * `Reflection*`, `IteratorIterator` and `RecursiveIteratorIterator` classes + throw an exception when being serialized (their unserialized version is broken + anyway, see https://bugs.php.net/76737). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/var_exporter.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/VarExporter.php b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/VarExporter.php new file mode 100644 index 0000000..da9a8d4 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/VarExporter.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter; + +use Symfony\Component\VarExporter\Exception\ExceptionInterface; +use Symfony\Component\VarExporter\Internal\Exporter; +use Symfony\Component\VarExporter\Internal\Hydrator; +use Symfony\Component\VarExporter\Internal\Registry; +use Symfony\Component\VarExporter\Internal\Values; + +/** + * Exports serializable PHP values to PHP code. + * + * VarExporter allows serializing PHP data structures to plain PHP code (like var_export()) + * while preserving all the semantics associated with serialize() (unlike var_export()). + * + * By leveraging OPcache, the generated PHP code is faster than doing the same with unserialize(). + * + * @author Nicolas Grekas + */ +final class VarExporter +{ + /** + * Exports a serializable PHP value to PHP code. + * + * @param mixed $value The value to export + * @param bool &$isStaticValue Set to true after execution if the provided value is static, false otherwise + * + * @return string The value exported as PHP code + * + * @throws ExceptionInterface When the provided value cannot be serialized + */ + public static function export($value, bool &$isStaticValue = null): string + { + $isStaticValue = true; + + if (!\is_object($value) && !(\is_array($value) && $value) && !$value instanceof \__PHP_Incomplete_Class && !\is_resource($value)) { + return Exporter::export($value); + } + + $objectsPool = new \SplObjectStorage(); + $refsPool = []; + $objectsCount = 0; + + try { + $value = Exporter::prepare([$value], $objectsPool, $refsPool, $objectsCount, $isStaticValue)[0]; + } finally { + $references = []; + foreach ($refsPool as $i => $v) { + if ($v[0]->count) { + $references[1 + $i] = $v[2]; + } + $v[0] = $v[1]; + } + } + + if ($isStaticValue) { + return Exporter::export($value); + } + + $classes = []; + $values = []; + $states = []; + foreach ($objectsPool as $i => $v) { + list(, $classes[], $values[], $wakeup) = $objectsPool[$v]; + if (0 < $wakeup) { + $states[$wakeup] = $i; + } elseif (0 > $wakeup) { + $states[-$wakeup] = [$i, array_pop($values)]; + $values[] = []; + } + } + ksort($states); + + $wakeups = [null]; + foreach ($states as $k => $v) { + if (\is_array($v)) { + $wakeups[-$v[0]] = $v[1]; + } else { + $wakeups[] = $v; + } + } + + if (null === $wakeups[0]) { + unset($wakeups[0]); + } + + $properties = []; + foreach ($values as $i => $vars) { + foreach ($vars as $class => $values) { + foreach ($values as $name => $v) { + $properties[$class][$name][$i] = $v; + } + } + } + + if ($classes || $references) { + $value = new Hydrator(new Registry($classes), $references ? new Values($references) : null, $properties, $value, $wakeups); + } else { + $isStaticValue = true; + } + + return Exporter::export($value); + } +} diff --git a/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/composer.json b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/composer.json new file mode 100644 index 0000000..6ccfc63 --- /dev/null +++ b/user/plugins/admin-addon-user-manager/vendor/symfony/var-exporter/composer.json @@ -0,0 +1,36 @@ +{ + "name": "symfony/var-exporter", + "type": "library", + "description": "A blend of var_export() + serialize() to turn any serializable data structure to plain PHP code", + "keywords": ["export", "serialize", "instantiate", "hydrate", "construct", "clone"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.2.5" + }, + "require-dev": { + "symfony/var-dumper": "^4.4|^5.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\VarExporter\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + } +} diff --git a/user/plugins/admin/.editorconfig b/user/plugins/admin/.editorconfig new file mode 100644 index 0000000..3b95e6d --- /dev/null +++ b/user/plugins/admin/.editorconfig @@ -0,0 +1,17 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +# 2 space indentation +[*.{yaml,.yml}] +indent_size = 2 diff --git a/user/plugins/admin/.gitattributes b/user/plugins/admin/.gitattributes new file mode 100644 index 0000000..e3cf70c --- /dev/null +++ b/user/plugins/admin/.gitattributes @@ -0,0 +1,8 @@ +# Linguist Normalizer +*.yaml linguistic-language=PHP +*.twig linguistic-language=PHP +**/gulpfile.js linguist-vendored +**/webpack.conf.js linguist-vendored +**/js/*.js linguist-vendored +**/js/*.json linguist-vendored +**/css-compiled/*.css linguist-vendored diff --git a/user/plugins/admin/.github/FUNDING.yml b/user/plugins/admin/.github/FUNDING.yml new file mode 100644 index 0000000..e84f52b --- /dev/null +++ b/user/plugins/admin/.github/FUNDING.yml @@ -0,0 +1,8 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: grav +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +custom: # Replace with a single custom sponsorship URL diff --git a/user/plugins/admin/.gitignore b/user/plugins/admin/.gitignore new file mode 100644 index 0000000..fcd7381 --- /dev/null +++ b/user/plugins/admin/.gitignore @@ -0,0 +1,15 @@ +themes/grav/.sass-cache +.DS_Store +crowdin.yaml + +# Node Modules +**/node_modules/** +themes/grav/js/admin.js +themes/grav/js/vendor.js +themes/grav/js/*.map +.idea + +tests/_output/* +tests/_support/_generated/* +tests/cache/* +tests/error.log diff --git a/user/plugins/admin/CHANGELOG.md b/user/plugins/admin/CHANGELOG.md new file mode 100644 index 0000000..1a078d6 --- /dev/null +++ b/user/plugins/admin/CHANGELOG.md @@ -0,0 +1,1756 @@ +# v1.9.15 +## 06/08/2020 + +1. [](#bugfix) + * Support markdown in `fieldset.text` [#2934](https://github.com/getgrav/grav/issues/2934) + * Fix data URLs in avatar images [#1889](https://github.com/getgrav/grav/issues/1889) + * Fix for deleting files in plugin configurations + +# v1.9.14 +## 04/27/2020 + +1. [](#improved) + * Added `slug` and `type` to blueprints +1. [](#bugfix) + * Support markdown in `fieldset.text` [#2934](https://github.com/getgrav/grav/issues/2934) + +# v1.9.13 +## 03/05/2020 + +1. [](#improved) + * Updated vendor libs +1. [](#bugfix) + * Fixed toggleable buttons no longer holding false state [form#406](ttps://github.com/getgrav/grav-plugin-form/issues/406) + +# v1.9.12 +## 12/04/2019 + +1. [](#bugfix) + * Fixed saving configuration in PHP 7.4 + +# v1.9.11 +## 11/06/2019 + +1. [](#improved) + * Added new "secure delete" functionality [#1752](https://github.com/getgrav/grav-plugin-admin/issues/1752) + * Center text logo [#1751](https://github.com/getgrav/grav-plugin-admin/issues/1751) + * Added required span to editor field [#1748](https://github.com/getgrav/grav-plugin-admin/issues/1748) + * Warn users if JS is disabled [#1722](https://github.com/getgrav/grav-plugin-admin/issues/1722) + * Added target rule to quick links [#1518](https://github.com/getgrav/grav-plugin-admin/issues/1518) +1. [](#bugfix) + * Fixed `Badly encoded JSON data` warning when uploading files [grav#2663](https://github.com/getgrav/grav/issues/2663) + * Fixed `accept` for SVG in `file` uploaders [#1732](https://github.com/getgrav/grav-plugin-admin/issues/1732) + +# v1.9.10 +## 09/19/2019 + +1. [](#bugfix) + * Fixed `Badly encoded JSON data` warning when uploading files [grav#2663](https://github.com/getgrav/grav/issues/2663) + +# v1.9.9 +## 08/21/2019 + +1. [](#bugfix) + * Fixed regression with files in admin not allowing types other than images [#1737](https://github.com/getgrav/grav-plugin-admin/issues/1737) + * Fixed preview link for non-images files in **Page Media** [#1727](https://github.com/getgrav/grav-plugin-admin/issues/1727) + +# v1.9.8 +## 08/11/2019 + +1. [](#improved) + * Better support for `array` field into `list` field + * Attach `_list_index` to fields within list items so that the index/key is available +1. [](#bugfix) + * Fixed 2FA regenerate for Flex Users + * Added missing closing in language loops + * Fixed issue with nested `list` fields both utilizing the custom `key` functionality + * Fixed issue with `array` field nested in `list` that were losing their index order when the list reordered + * Fixed file form field failing resolution checks in certain circumstances + * Fixed issue with deleting files in config based YAML files + +# v1.9.7 +## 06/21/2019 + +1. [](#bugfix) + * Fixed issue with charts in dashboard where label would cut off [#1700](https://github.com/getgrav/grav-plugin-admin/issues/1700) + * Resetting a user's password clears the user's site access [grav#2528](https://github.com/getgrav/grav/issues/2528) + * Fixed issue with permissions toggle [#1702](https://github.com/getgrav/grav-plugin-admin/issues/1702) + +# v1.9.6 +## 06/15/2019 + +1. [](#bugfix) + * Fixed regression issue with `parents_levels` defaulting to `2` + +# v1.9.5 +## 06/14/2019 + +1. [](#improved) + * Display error message if GPM class fails to initialize + * Better append/prepend logic that was breaking some layouts + * Default `backups` to an array if used outside of tools + * PSR 7 fixes + +# v1.9.4 +## 05/09/2019 + +1. [](#new) + * Added support for `field.copy-to-clipboard` on Text input fields +1. [](#improved) + * Only invalidate cache on creating new/deleting page to speed up the recovery + * Updated language strings from https://crowdin.com/project/grav-admin + * Use `plugins://` stream rather than `user://plugins` [#1674](https://github.com/getgrav/grav-plugin-admin/issues/1674) +1. [](#bugfix) + * Fixed admin cache to detect moved and deleted pages + * Fixed avatar URLs with `?` in them being broken + * Fixed issue saving page with language that was not exactly `2` or `5` chars long [#1667](https://github.com/getgrav/grav-plugin-admin/issues/1667) + * Fixed admin not detecting any existing users when Flex users are being used + * Fixed issue with append/prepend not respecting `size:` + * Fixed issue with `unset` on file fields [#1427](https://github.com/getgrav/grav/issues/1427), [#1670](https://github.com/getgrav/grav/issues/1670), [#1982](https://github.com/getgrav/grav/issues/1982) + +# v1.9.3 +## 04/22/2019 + +1. [](#new) + * Added a new **YAML Linter** report to the `Tools - Reports` section +1. [](#improved) + * Updated package.json scripts to properly use gulp compiler + +# v1.9.2 +## 04/15/2019 + +1. [](#bugfix) + * Fix for homepage admin preview [#2426](https://github.com/getgrav/grav/issues/2426) + * Uploaded Avatar removed from user's yaml when editing the user [#1647](https://github.com/getgrav/grav-plugin-admin/issues/1647) + +# v1.9.1 +## 04/13/2019 + +1. [](#bugfix) + * Fix for Page saving issues [#1648](https://github.com/getgrav/grav-plugin-admin/issues/1648) + * Remove status message when picking folder for move [#1650](https://github.com/getgrav/grav-plugin-admin/issues/1650) + +# v1.9.0 +## 04/11/2019 + +1. [](#new) + * New `Scheduler` configuration panel in tools + * New `Backups` configuration panel in tools + * New `Cache::purge()` option in cache drop-down to clear out old cache only + * New `Tools - Reports` section with event `onAdminGenerateReports()` for 3rd party plugin support + * Added support for the new `Flex User` object + * Allow admin forms to use `Form` classes + * Added new `Logs` section to tools to allow quick view of Grav log files +1. [](#improved) + * Improved the UI for the Parent Page Route dropdown when adding a new Page / Folder + * Use `$grav['accounts']` instead of `$grav['users']` + * Improved image background overlay and tools + * Better unauthorized user rendering + * Update all Form classes to rely on `PageInterface` instead of `Page` class + * Removed `media.upload_limit` references + * Improve error when upload exceeds `upload_max_filesize` + * Delegate Dropzone for checking maximum file size and avoid uploading if not necessary + * Low level unauthorized user handling in `base-root.html.twig` + * Refactored "NewsFeeds" and "Notifications" for better performance and to address CORS issues + * Flex user profile now uses Flex Form + * Moved dashboard `notifications` logic to server-side for increased performance (1 request instead of 3) + * Refactored feeds logic for better performance + * Better logic for delete action to support Ajax. Fixes Flex lists + * Cleanly handle session corruption due to changing Flex object types + * Implemented [ForkAwesome](https://forkawesome.github.io/Fork-Awesome/) and removed FontAwesome + LineAwesome + * Various default admin theme improvements and cleanup + * Make new System Config layout responsive [#1579](https://github.com/getgrav/grav-plugin-admin/issues/1579) + * Homepage link should be `https://` [#1564](https://github.com/getgrav/grav-plugin-admin/issues/1564) + * Improve lang string to describe XSS security settings [#1566](https://github.com/getgrav/grav-plugin-admin/issues/1566) + * Take admin setting for 2FA into account when showing user 2FA badge [#1568](https://github.com/getgrav/grav-plugin-admin/issues/1568) + * Moved `ignore` and `key` field into form plugin + * Improved usability of `System` configuration blueprint with side-tabs + * Cleaned up UI in `Scheduler` tools page + * Updated languages +1. [](#bugfix) + * Fixed user edit links if Flex Objects plugin is installed but user isn't Flex User + * Fixed deprecated `sameas()` Twig test + * Regression: Fixed lost user access when saving user profile without super user permissions [#1639](https://github.com/getgrav/grav-plugin-admin/issues/1639) + * Fixed `Page.menu` displaying in edit view rather than `Page.title` [#1642](https://github.com/getgrav/grav-plugin-admin/issues/1642) + * Regression from beta.8: Deleting files other than from plugins/themes fail on error + * Fixed issue with Safari browser and blueprint fields with `toggleable: true` [#1643](https://github.com/getgrav/grav-plugin-admin/issues/1643) + * Incorrect 2FA lang code [#1618](https://github.com/getgrav/grav-plugin-admin/issues/1618) + * Fixed potential undefined property in `onPageNotFound` event handling + * Proper fix for `vUndefined` when updating plugins/themes + * Text in Tab Tools/Direct install disappears [#1613](https://github.com/getgrav/grav-plugin-admin/issues/1613) + * Fallback to page `slug` in Pages list if title is empty [grav#2267](https://github.com/getgrav/grav/issues/2267) + * Fixes backup button issues with `;` param separator [#1602](https://github.com/getgrav/grav-plugin-admin/issues/1602) [#1502](https://github.com/getgrav/grav-plugin-admin/issues/1502) + * Set default state for `show_modular` to `true` [#1599](https://github.com/getgrav/grav-plugin-admin/issues/1599) + * Removed `tabs`, `tab`, and `toggle` fields as they are now in Form plugin + * Fix issue with new page always showing modular page templates [#1573](https://github.com/getgrav/grav-plugin-admin/issues/1573) + * Fixed issue deleting files in plugins/themes/config + * Fixed array support in admin languages, e.g. `DAYS_OF_THE_WEEK` + * Fixed user login / remember me triggering before admin gets initialized + * Fixed a bug when deleting files via AJAX + * Fixed error page not to be the frontend version + * Added `merge_items` option for `field.selectize` to allow storing custom items [#1461](https://github.com/getgrav/grav-plugin-admin/issues/1461) + * Better handling of unset in uploaded files [#1427](https://github.com/getgrav/grav-plugin-admin/issues/1427) + * Prefix Backup/Scheduler titles with `Tools` + * Regression: Media settings have bad layout [#1529](https://github.com/getgrav/grav-plugin-admin/issues/1529) + * Fixed Direct Install Uploader, failing to validate the uploaded files + * Regression: Editing interface does not keep settings properly without manual intervention on each edit [#1527](https://github.com/getgrav/grav-plugin-admin/issues/1527) + * Removed duplicate language strings + * Fixed default `job_at` so it does not fail if missing + * Minor JS group `bottom` fix + +# v1.8.20 +## 03/20/2019 + +1. [](#improved) + * Added security field to column [#1622](https://github.com/getgrav/grav-plugin-admin/pull/1622) + +# v1.8.19 +## 02/13/2019 + +1. [](#bugfix) + * Moved `show_modular` to proper place - Doh! [grav#2362](https://github.com/getgrav/grav/issues/2362) + +# v1.8.18 +## 02/12/2019 + +1. [](#bugfix) + * Set default value for `show_modular` [grav#2362](https://github.com/getgrav/grav/issues/2362) + +# v1.8.17 +## 02/07/2019 + +1. [](#improved) + * Improved Grav Core installer/updater to run installer script (if available) + * Added `unauthorized.html.twig` file that was missing [#1609](https://github.com/getgrav/grav-plugin-admin/pull/1609) +1. [](#bugfix) + * Fixed direct install deleting backups and logs if used with full Grav package instead of with update package + +# v1.8.16 +## 01/25/2019 + +1. [](#improved) + * IP pseudonymization for rate limiter [#1589](https://github.com/getgrav/grav-plugin-admin/pull/1589) + * Add option to hide modular pages in parent select [#1571](https://github.com/getgrav/grav-plugin-admin/pull/1571) + * Added `admin.tools` permission [#1550](https://github.com/getgrav/grav-plugin-admin/pull/1550) +1. [](#bugfix) + * Fixed calendar js module not properly loading for datetime field [#1581](https://github.com/getgrav/grav-plugin-admin/issues/1581) + * Fixed deleting file when using file field type [#1558](https://github.com/getgrav/grav-plugin-admin/issues/1558) + * Unset state from user if not super or user admin + +# v1.8.15 +## 12/14/2018 + +1. [](#improved) + * Fire `onAdminSave()` event during `AdminController::taskSaveAs()` [#1544](https://github.com/getgrav/grav-plugin-admin/issues/1544) +1. [](#bugfix) + * Clean user post to ensure dynamically added form fields are not saved + +# v1.8.14 +## 11/12/2018 + +1. [](#bugfix) + * Fixed Grav core update potentially spinning forever because of an error which happens after a successful upgrade + * Saving in expert mode can cause `undefined index: header` error [#1537](https://github.com/getgrav/grav-plugin-admin/issues/1537) + +# v1.8.13 +## 11/05/2018 + +1. [](#new) + * Added new `|nested()` Twig filter to access array objects with dot notation syntax +1. [](#bugfix) + * Fixed issue with complex lists structure and nested dot-notation [admin#2236](https://github.com/getgrav/grav/issues/2236) + +# v1.8.12 +## 10/24/2018 + +1. [](#improved) + * Updated various lang strings + * Removed duplicate lang strings +1. [](#bugfix) + * Fix XSS checking when empty content [#1533](https://github.com/getgrav/grav-plugin-admin/issues/1533) + * Fix DirectInstall not working [#1535](https://github.com/getgrav/grav-plugin-admin/issues/1535) + +# v1.8.11 +## 10/08/2018 + +1. [](#improved) + * Change usage of basename where possible [#1480](https://github.com/getgrav/grav-plugin-admin/pull/1480) + * Improved filename validation (requires Grav 1.5.3) + * Updated various lang codes +1. [](#bugfix) + * File Uploads: Do not trust mimetype sent by the browser + * Fixed file extension detection + * Fix for HTML entities in page slug [#1524](https://github.com/getgrav/grav-plugin-admin/issues/1524) + * Fix for port in backup download links [#1521](https://github.com/getgrav/grav-plugin-admin/issues/1521) + +# v1.8.10 +## 10/01/2018 + +1. [](#new) + * IMPORTANT: Non `admin.super` users are now subject to XSS validation in Page content. Configurable via Configuration / Security + * New XSS content warnings and integration into page save + * Added new event `onAdminPage()` which allows plugins to customize `Page` object in `$event['page']` +1. [](#improved) + * Use `Url:post()` to get the `$_POST` variable (allows common security checks/filtering for the POST data) + * Requires Grav 1.5.2 +1. [](#bugfix) + * Fixed redirect to correct URL after failed login + * Fixed issue in `filepicker` where missing images would cause a loop to try to load them + * Twig 2 compatibility fixes for macros + * Updated `composer.json` to better match Grav 1.5 + * Remove `package-lock.json` as it was referencing an insecure JS package + +# v1.8.9 +## 08/23/2018 + +1. [](#improved) + * Make order field to use context, not data + * Switched to new Grav Yaml class to support Native + Fallback YAML libraries + * Minor fix for `file` thumbnails display + * Requires Grav 1.5.1 + +# v1.8.8 +## 08/17/2018 + +1. [](#improved) + * Support URI Params and Query attributes in Login redirect + * Added support for textarea value type in `array` field + * Added some new lang strings for Grav 1.5.0 +1. [](#bugfix) + * Support params and querystring in login redirect + * Added field name nesting with tab field + +# v1.8.7 +## 07/31/2018 + +1. [](#bugfix) + * Fix for deleting 'extra' media files [grav#2100](https://githubcom/getgrav/grav/issues/2100) + +# v1.8.6 +## 07/13/2018 + +1. [](#bugfix) + * Force `html` for markdown preview [grav#2066](https://github.com/getgrav/grav/issues/2066) + * Add missing `authorizeTask()` checks in controller [#1483](https://github.com/getgrav/grav/issues/1483) + * Add support for `force_ssl` to admin URLs [#1479](https://github.com/getgrav/grav-plugin-admin/issues/1479) + +# v1.8.5 +## 06/20/2018 + +1. [](#bugfix) + * Fixed broken folder attribute on filepicker [#1465](https://github.com/getgrav/grav-plugin-admin/issues/1465) + * Added translation for system.session.initialize + * Slight updates on new translation strings + +# v1.8.4 +## 06/11/2018 + +1. [](#improved) + * Including EXIF JS library in the modules dependencies to fix orientation when uploading images +1. [](#bugfix) + * Initialize session on setup [#1451](https://github.com/getgrav/grav-plugin-admin/issues/1451) + * Force a `null` order when empty in the post request + * Fixed some 2FA form styling issues + +# v1.8.3 +## 05/31/2018 + +1. [](#new) + * Added support for selectize plugins as options in the selectize field +1. [](#bugfix) + * Fixed deep linking in admin after login [#1456](https://github.com/getgrav/grav-plugin-admin/issues/1456) + * Fixed Undefined property: `stdClass::$image` in v1.8.2 [#1454](https://github.com/getgrav/grav-plugin-admin/issues/1454) + * Pass media order when calling `task:listmedia` + +# v1.8.2 +## 05/24/2018 + +1. [](#new) + * Added custom object support for filepicker field + * Don't allow saving of a user with no local account file + * Controls for `list` field were not in sync between top and bottom +1. [](#improved) + * More subtle `fieldset` styling +1. [](#bugfix) + * Check if `$object->blueprints()` exists in `onAdminAfterSave` + * When creating first user, check `admin.login` not `site.login` + * Fix admin login redirects for multisite setups + * Fixed issue with filepicker field where images wouldn't properly merge with the current value if in a page header + * Fixed media delete for streams + +# v1.8.1 +## 05/15/2018 + +1. [](#improved) + * use SHA1 hashing of IP addressed to support GDPR rules [#1436](https://github.com/getgrav/grav-plugin-admin/pull/1436) +1. [](#bugfix) + * Fixed 2FA form showing up even if user has not turned on the feature [#1442](https://github.com/getgrav/grav-plugin-admin/issues/1442) + * Fixed previews of images in Pagemedia field not properly URI encoded [#1438](https://github.com/getgrav/grav-plugin-admin/issues/1438) + +# v1.8.0 +## 05/11/2018 + +1. [](#new) + * Moved 2FA authentication to login plugin + * Admin login now uses login plugin events + * Added new decoupled `pagemedia` field that is no longer tied to just pages + * Updated plugin dependencies (Grav >= 1.4.4, Form >=2.14.0, Login >=2.7.0, Email >=2.7.0) +1. [](#improved) + * Added support for JavaScript `bottom` block [#1425](https://github.com/getgrav/grav-plugin-admin/pull/1425) + * Added better typography styling for blockquote and markdown in `display` field + * Vendor updates +1. [](#bugfix) + * Added missing MarkdownExtra strings [#1385](https://github.com/getgrav/grav-plugin-admin/pull/1385) + * Updated `blueprints.yaml` with missing `step` attribute [#1415](https://github.com/getgrav/grav-plugin-admin/pull/1415) + * Fixed preview target setting [#1430](https://github.com/getgrav/grav-plugin-admin/pull/1430) + * Added new modular string [#1433](https://github.com/getgrav/grav-plugin-admin/pull/1433) + * Fixed Firefox issue with the Regenerate button for 2FA. Forcing the page to reload + * Fixed jumpiness behavior for Regenerate button when on active state. + * Prevent the prompt for unsaved state when Regenerating a 2FA code and trying to reload/leave the page. + +# v1.7.4 +## 04/02/2018 + +1. [](#bugfix) + * Fixed a bug for page copy caused by last release [#1409](https://github.com/getgrav/grav-plugin-admin/pull/1409) + * Fixed collapsible `list` option [#1410](https://github.com/getgrav/grav-plugin-admin/pull/1410) + * Fixed a minor typo in a label [#1397](https://github.com/getgrav/grav-plugin-admin/pull/1397) + +# v1.7.3 +## 04/01/2018 + +1. [](#new) + * Implemented Resize Media and Resolution ('resizeWidth', 'resizeHeight', 'resizeQuality', 'resolution') + * Updated Dropzone to latest +1. [](#bugfix) + * Implemented workaround for required text fields [#1390](https://github.com/getgrav/grav-plugin-admin/issues/1390) + * Fixed highlight color in Firefox [getgrav/grav#1949](https://github.com/getgrav/grav/issues/1949) + * Fix for bad redirect on saving simplesearch (possibly others) + +# v1.7.2 +## 03/21/2018 + +1. [](#improved) + * Table CSS improvements for use in 3rd party plugins + * Translatable `add_modals` button labels [#1388](https://github.com/getgrav/grav-plugin-admin/issues/1388) + * Check for `SHIFT` key on editor save shortcut [#1383](https://github.com/getgrav/grav-plugin-admin/issues/1383) + * Fixed User permissions responsive UI [#1379](https://github.com/getgrav/grav-plugin-admin/issues/1379) + * Optimization to stop admin for looking for pages in disabled plugins + * Added configuration option to choose if you want to use new 'inline' preview or `new tab' +1. [](#bugfix) + * Fix redirect bug when changing admin route to `admin-*` + * Changed Twig `|count` to `|length` filter [#1391](https://github.com/getgrav/grav-plugin-admin/issues/1391) + * Fix for page preview when `HTTP_REFERRER` is not set [grav#1930](https://github.com/getgrav/grav/issues/1930) + +# v1.7.1 +## 03/11/2018 + +1. [](#new) + * New built-in page preview system +1. [](#improved) + * Added `CTRL+K` / `CMD+K` shortcuts for editor links [#1279](https://github.com/getgrav/grav-plugin-admin/issues/1279) +1. [](#bugfix) + * Automatically redirect to new `admin_route` after changing it [#1371](https://github.com/getgrav/grav-plugin-admin/issues/1371) + * Remove bad-shadows on alerts + * Fixed notifications titles not html escaped [#1272](https://github.com/getgrav/grav-plugin-admin/issues/1272) + * Fixed extra horizontal scrollbar with `Editor` field + * Fixed `mediapicker` field in lists [#1369](https://github.com/getgrav/grav-plugin-admin/issues/1369) + +# v1.7.0 +## 03/09/2018 + +1. [](#new) + * Added styling and lang for **Route Overrides** in the default page blueprint + * Added clear cache permanently to quick-tray [#1353](https://github.com/getgrav/grav-plugin-admin/issues/1353) +1. [](#improved) + * Added option to toggle between `line-awesome` and `font-awesome` icon sets [#1334](https://github.com/getgrav/grav-plugin-admin/issues/1334) + * Added preview from page list view [#1250](https://github.com/getgrav/grav-plugin-admin/pull/1250) + * Added `Add` plugins button to plugins details page [#1352](https://github.com/getgrav/grav-plugin-admin/pull/1352) + * Added support for `default` and `options` fields in taxonomy field [#1364](https://github.com/getgrav/grav-plugin-admin/issues/1364) + * Added support to limit parent field levels [#1298](https://github.com/getgrav/grav-plugin-admin/issues/1298) +1. [](#bugfix) + * Fixed issue with custom logo text overlapping the sidebar toggle [#1334](https://github.com/getgrav/grav-plugin-admin/issues/1334) + * Fixed issues with minimum PHP versions in resource upgrades + * Fixed issue with default lang translation in admin [#1361](https://github.com/getgrav/grav-plugin-admin/issues/1361) + * Typos in `Tools` -> `Direct Install` page [#1345](https://github.com/getgrav/grav-plugin-admin/issues/1345) + * Fixed bug with frontmatter being killed when in `Expert Mode` [#1354](https://github.com/getgrav/grav-plugin-admin/issues/1354) + +# v1.7.0-rc.3 +## 02/15/2018 + +1. [](#improved) + * Tab optimization with fixes for 'onpage' tabs + * Stopped Chrome from auto-completing admin user profile form [grav#1847](https://github.com/getgrav/grav/issues/1847) + * Added a fixed `ga-theme-17x` body class to help styling compatibility + * Outputs an iterable field as a string if `yaml: true` or `validate: type: yaml` set in blueprint +1. [](#bugfix) + * Rolled back JS to known working versions [#1323](https://github.com/getgrav/grav-plugin-admin/issues/1323) + * Fixed missing translation in order field [#1324](https://github.com/getgrav/grav-plugin-admin/issues/1324) + * Fixed UI issue with last drop-down in button group [1325](https://github.com/getgrav/grav-plugin-admin/issues/1325) + * Fixed fieldset field outdated rendering [#1313](https://github.com/getgrav/grav-plugin-admin/issues/1313) + +# v1.7.0-rc.2 +## 01/24/2018 + +1. [](#new) + * Moved to LineAwesome icons rather than FontAwesome (still compatible w/FA 4.7.0) +1. [](#improved) + * Simplified open/close nav button + * Tidied Tools panel and added translations + * Tooltip and new icon for site preview + * Updated JS library dependencies + * Changed CodeMirror editor to use sans-serif font for readability +1. [](#bugfix) + * Fixed z-index issue in fullscreen mode [#1317](https://github.com/getgrav/grav-plugin-admin/issues/1317) + +# v1.7.0-rc.1 +## 01/22/2018 + +1. [](#new) + * Added support for markdown in all form fields for `label`, `help`, and `description` when `markdown: true` is set on field + * Changed "made by" to Trilby Media from RocketTheme +1. [](#improved) + * Lightened tabs in new theme + * Sort languages by key [#1303](https://github.com/getgrav/grav-plugin-admin/issues/1303) + * Add limit to Parent Levels [#1298](https://github.com/getgrav/grav-plugin-admin/pull/1298) +1. [](#bugfix) + * Fixed alignment issue with language drop-down + * Fixed a z-index issue with fullscreen editor [#1302](https://github.com/getgrav/grav-plugin-admin/issues/1302) + * Fixed missing background on register [#1307](https://github.com/getgrav/grav-plugin-admin/issues/1307) + * Fixed some style issues with field descriptions + * Fixed an issue with `File` field losing download size setting + * Fixed distorted thumbnails in `File` field by using `object-fit: cover` + +# v1.7.0-beta.1 +## 12/29/2017 + +1. [](#new) + * New lighter-and-tighter admin theme developed +1. [](#improved) + * Added simple value support for list field type + * Added checks to automatically hide collapse buttons when there's only single value in list type + +# v1.6.7 +## 12/05/2017 + +1. [](#new) + * Logout of admin goes straight to login form with a message (that then fades out) + * Added `sl`, `id`, `he`, `eu`, `et` languages +1. [](#improved) + * Added code to use new `GPM::loadRemoteGrav` if it exists in Gav [grav#1746](https://github.com/getgrav/grav/pull/1746) + * Add vertical style for order field [#1253](https://github.com/getgrav/grav-plugin-admin/pull/1253) + * Added classes to pagemedia field [#1274](https://github.com/getgrav/grav-plugin-admin/issues/1274) + * Fixed selectize field not properly updating value when `option` is provided [#1236](https://github.com/getgrav/grav-plugin-admin/pull/1236) + * Tab layout tweaks + * Updated all language files with latest from [Crowdin](https://crowdin.com/project/grav-admin) +1. [](#bugfix) + * Manual image metadata can now display in pagemedia when auto-generation is disabled [#1275](https://github.com/getgrav/grav-plugin-admin/issues/1275) + * Removed broken `home.hide_in_urls` code in `AdminBaseController::save()` that was throwing move errors + * Security fix to ensure file uploads are not manipulated mid-post - thnx @FLH! + +# v1.6.6 +## 10/27/2017 + +1. [](#new) + * Fixed issue where sortable media in expert mode would reset frontmatter [#1252](https://github.com/getgrav/grav-plugin-admin/issues/1252) + +# v1.6.5 +## 10/26/2017 + +1. [](#new) + * Added ability to **order** page media (requires latest Grav update) + +# v1.6.4 +## 10/11/2017 + +1. [](#improved) + * Use system PHP size for upload limit rather than `system.media.upload_limit` or `file.filesize` plugin options +1. [](#bugfix) + * Fixed Dropzone timeout to address slow internet connections [#1239](https://github.com/getgrav/grav-plugin-admin/pull/1239) + +# v1.6.3 +## 10/02/2017 + +1. [](#bugfix) + * Fixed chart labels not parsing HTML [#1234](https://github.com/getgrav/grav-plugin-admin/issues/1234) + +# v1.6.2 +## 09/29/2017 + +1. [](#improved) + * Removed extraneous files in vendor folder for smaller download package + +# v1.6.1 +## 09/29/2017 + +1. [](#improved) + * Added support for Latin Extended fonts [#1211](https://github.com/getgrav/grav-plugin-admin/pull/1221) + * Added collapsible attribute to lists [#1231](https://github.com/getgrav/grav-plugin-admin/pull/1231) +1. [](#bugfix) + * Fix editor not clickable in list field [#1224](https://github.com/getgrav/grav-plugin-admin/pull/1124) + * Updated Google Font URLs to always connect over HTTPS. [#1106](https://github.com/getgrav/grav-plugin-admin/pull/1106) + * Fixed fieldset field not allowing to properly save when contained within a list [#1225](https://github.com/getgrav/grav-plugin-admin/issues/1225) + * Fixed Video markdown syntax when drag & dropping in the content editor [#1160](https://github.com/getgrav/grav-plugin-admin/issues/1160) + * Fixed headers drop-down in editor to properly align + * Fixed fields not working in Microsoft Edge with Selectize.js [#1222](https://github.com/getgrav/grav-plugin-admin/pull/1222) + * Replaced a left-over "is empty" check [#1232](https://github.com/getgrav/grav-plugin-admin/pull/1232) + * Fixed headers drop-down in editor to align properly + + +# v1.6.0 +## 09/07/2017 + +1. [](#new) + * **Added 2-Factor Authentication support to the admin!** + * **Added rate-limiting for "failed login attempts" and "forgot password"** +1. [](#improved) + * Revamped the toggle switch CSS so it's more flexible and works better [#1198](https://github.com/getgrav/grav-plugin-admin/issues/1198) + * Improved toggle/button alignment on Page edit view +1. [](#bugfix) + * Fixed an issue where icon-picker style was hiding field elements [#1199](https://github.com/getgrav/grav-plugin-admin/issues/1199) + * Fixed https -> http redirect issue [#1195](https://github.com/getgrav/grav-plugin-admin/issues/1195) + * Also check `/.` for home route [#1191](https://github.com/getgrav/grav-plugin-admin/issues/1191) + * Fixed administration being broken in multi-site environments with plugin overrides + * Fixed lang-switcher broken in MS Edge browser [#1213](https://github.com/getgrav/grav-plugin-admin/pull/1213) + * Added custom `form_id` attribute for modal forms [#1216](https://github.com/getgrav/grav-plugin-admin/issues/1216) + * Fixed partially cropped line in Markdown editor for MS Edge/Firefox [#1219](https://github.com/getgrav/grav-plugin-admin/pull/1219) + * Downgraded Babel libraries to v6.x for compatibility with webpack [#1218](https://github.com/getgrav/grav-plugin-admin/pull/1218) + +# v1.5.2 +## 08/16/2017 + +1. [](#new) + * Added a new icon quick-tray in side navigation that plugins can utilize + * Added ability to set and retrieve temporary admin messages +1. [](#improved) + * Allow different field to be used as page label in list of pages [#1122](https://github.com/getgrav/grav-plugin-admin/pull/1122) + * Updated `en` language for `cache-control` + `clear_images_by_default` system settings + * Allow sorting of page based on custom ordering [#1182](https://github.com/getgrav/grav-plugin-admin/pull/1182) + * Search for pages by slug and folder name [#1183](https://github.com/getgrav/grav-plugin-admin/pull/1183) + * Allow all page data to be used during `onAdminCreatePageFrontmatter()` event [#1175](https://github.com/getgrav/grav-plugin-admin/pull/1175) + * Remove single quotes when slugifying title [#1178](https://github.com/getgrav/grav-plugin-admin/pull/1178) +1. [](#bugfix) + * Ignore missing Twig files [#1169](https://github.com/getgrav/grav-plugin-admin/issues/1169) + * If from is already defined, don't override it [#1129](https://github.com/getgrav/grav-plugin-admin/issues/1129) + * Fixed SelectUnique field not working with files with spaces + +# v1.5.1 +## 07/19/2017 + +1. [](#bugfix) + * Fixes issue when saving pages without a `folder` element [#1163](https://github.com/getgrav/grav-plugin-admin/issues/1163) + * Fixed mediapicker field inside lists not properly updating the value on the target input [#1157](https://github.com/getgrav/grav-plugin-admin/issues/1157) + +# v1.5.0 +## 07/16/2017 + +1. [](#new) + * Implemented Offline mode. Notifies in the admin when disconnected. +1. [](#bugfix) + * Fixed fetch issue throwing error when request not completed and while unloading the page [#1301](https://github.com/getgrav/grav-plugin-admin/issues/1301) + * Fixed ordering when > 100 pages [grav#1564](https://github.com/getgrav/grav/pull/1564) + * Fixed Lists issue when reindexing, causing Radio fields to potentially lose their `checked` status ([#1154](https://github.com/getgrav/grav-plugin-admin/issues/1154) | related: [1d55ffc](https://github.com/getgrav/grav-plugin-admin/commit/1d55ffc616125047f245efe9f2180ef2c16b4949)) + +# v1.5.0-rc.4 +## 07/05/2017 + +1. [](#new) + * New `multilevel` field, useful for defining collections definitions, metadata and other complex YAML data [#1135](https://github.com/getgrav/grav-plugin-admin/pull/1135) - (EXPERIMENTAL) + * Fix plugins hooked nav authorize not working with array of permissions [#1148](https://github.com/getgrav/grav-plugin-admin/pull/1148) +1. [](#improved) + * Add badge to plugins hooked into nav [#1147](https://github.com/getgrav/grav-plugin-admin/pull/1147) + * Added `field.outerclasses` to default form field [#1124](https://github.com/getgrav/grav-plugin-admin/pull/1124) + * Reverted back to textarea/YAML for `media.yaml` image options + * Fixed color of textarea fields in admin +1. [](#bugfix) + * Fix for bad referenced to `shouldLoadAdditionalFilesInBackground()` [#1145](https://github.com/getgrav/grav-plugin-admin/pull/1145) + * Expose Page Media instance to Grav Admin JS API + * Fixed mediapicker issue where newly added list items would not work + * Fixed issue with min/max setting of list collections. Removing a list item would not refresh properly the count + * If folder is empty/not sent, fallback to page slug [#1146](https://github.com/getgrav/grav-plugin-admin/issues/1146) + * Escape the URI basename before using it in Twig + * Ignore missing Twig file in the Tools page + +# v1.5.0-rc.3 +## 06/22/2017 + +1. [](#new) + * New `Admin::getPageMedia()` static method that can be used in blueprints + * Added a new `mediapicker` form field which allows to select a media from any page [#1125](https://github.com/getgrav/grav-plugin-admin/pull/1125) + * Added info metadata button for images to view EXIF and other useful details about an image +1. [](#improved) + * Pass original image filename via the `AdminController::taskListedia()` task + * Various form styling improvements + * Provided an option to control how parent select field displays +1. [](#bugfix) + * Fix referencing DI element when not initialized [#1141](https://github.com/getgrav/grav-plugin-admin/pull/1141) + +# v1.5.0-rc.2 +## 05/22/2017 + +1. [](#improved) + * Remove save button and save location notification on Config Info tab [#1116](https://github.com/getgrav/grav-plugin-admin/pull/1116) + * Allow taxonomy field to just list one or more specific taxonomies if the `taxonomies` field is filled in the blueprint + * `File` field now renders thumbnail previews of the selected value on load + * Use new unified `Utils::getPagePathFromToken()` method rather +1. [](#bugfix) + * Fix for undefined `include_metadata` error + + +# v1.5.0-rc.1 +## 05/16/2017 + +1. [](#new) + * Add support for a single array field in forms + * Added Prev/Next support on page editing view [#1112](https://github.com/getgrav/grav-plugin-admin/pull/1112) +1. [](#improved) + * Improved full-screen editor for better browser compatibility [#1093](https://github.com/getgrav/grav-plugin-admin/pull/1093) + * Added ability to choose how you want the preview button to open [#1096](https://github.com/getgrav/grav-plugin-admin/pull/1096) + * `base.html.twig` now extends a `base-root.html.twig` file + * Add month+date indication to the stats graph to avoid confusion when there are days without visits + * Added `min` and `max` options for `list` form field [#1113](https://github.com/getgrav/grav-plugin-admin/pull/1113) + * Remove page metadata file on deletion of media + * Improved layout on pages list for pages with long titles [#1102](https://github.com/getgrav/grav-plugin-admin/pull/1102) + * Added option to make custom "Add page" dropdown entries [#1104](https://github.com/getgrav/grav-plugin-admin/pull/1104) +1. [](#bugfix) + * Fixed issue with tab widths on Pages overlapping non-english toggle switch [#1089](https://github.com/getgrav/grav-plugin-admin/issues/1089) + * Added `vendor` to ignores for direct install of Grav + * Translated `field.default` for `editor` form field + * Fixed an quote error in `en.yaml` + * Resolved z-index issues with mobile nav and pages form elements + * Fixed issue with file picker where the selected file preview would not show + * Refresh page media on media upload + * Default to config file slug if translation is missing, otherwise use translation also in the tab title, not just in the page heading [#1039](https://github.com/getgrav/grav-plugin-admin/issues/1039) + * Fix language toggle button in admin top bar visible also in fullscreen mode [#1110](https://github.com/getgrav/grav-plugin-admin/issues/1110) + * Fix for editor padding [#1111](https://github.com/getgrav/grav-plugin-admin/issues/1111) + * Fix tabs inside blueprint overlapping above content [#1115](https://github.com/getgrav/grav-plugin-admin/pull/1115) + +# v1.4.2 +## 04/24/2017 + +1. [](#new) + * Added a new `Content Padding` option to tighten up UI padding space (default `true`) +1. [](#bugfix) + * Added back `Admin::initTheme()` relying on Grav fix [#1069](https://github.com/getgrav/grav-plugin-admin/pull/1069) as it conflicts ith Gantry5 + * Fix for missing scrollbar when in full-size editor for Firefox [#1077](https://github.com/getgrav/grav-plugin-admin/issues/1077) + * Fix for overlay of Add-Page button in full-size editor [#1077](https://github.com/getgrav/grav-plugin-admin/issues/1077) + * Better fix for session-based parent overriding root page parents [#1078](https://github.com/getgrav/grav-plugin-admin/issues/1078) + * Allow support for `Pages::getList()` with `show_modular` option [#1080](https://github.com/getgrav/grav-plugin-admin/issues/1080) + * Added `[tmp,user]` ignores for direct install of Grav [grav#1447](https://github.com/getgrav/grav/issues/1447) + +# v1.4.1 +## 04/19/2017 + +1. [](#bugfix) + * Reverted [#1069](https://github.com/getgrav/grav-plugin-admin/pull/1069) as it conflicts ith Gantry5 + +# v1.4.0 +## 04/19/2017 + +1. [](#new) + * Added ability to add new pages/folders while editing existing page +1. [](#improved) + * Initialize theme in Admin Plugin [#1069](https://github.com/getgrav/grav-plugin-admin/pull/1069) + * Use new system configuration entries for username and password format + * Reworked Page parent field to use `Pages::getList()` rather than logic in Twig field itself + * More robust styling of admin themes page [#1067](https://github.com/getgrav/grav-plugin-admin/pull/1067) + * Fix fullscreen editor height [#1065](https://github.com/getgrav/grav-plugin-admin/pull/1065) + * Fix small UI issue in the editor with `codemirror.lineNumbers` && `codemirror.styleActiveLine` enabled + * Fix UI performance issue in the dashboard [#1064](https://github.com/getgrav/grav-plugin-admin/issues/1064) +1. [](#bugfix) + * Fixed issue with parent not working with custom slug [#1068](https://github.com/getgrav/grav-plugin-admin/issues/1068) + * Fixed issue with new page modal not remembering last choice [#1072](https://github.com/getgrav/grav-plugin-admin/issues/1072) + +# v1.3.3 +## 04/12/2017 + +1. [](#bugfix) + * Fix for regression introduced in the automatic page template switch when changing page parent [#1059](https://github.com/getgrav/grav-plugin-admin/issues/1059) [grav#1403](https://github.com/getgrav/grav/issues/1403) [#1062](https://github.com/getgrav/grav-plugin-admin/issues/1062) + * Fix issue with editor field in lists [#1037](https://github.com/getgrav/grav-plugin-admin/issues/1037) + +# v1.3.2 +## 04/10/2017 + +1. [](#improved) + * Added new 'parents' field and switched Page blueprints to use this +1. [](#bugfix) + * Fix for regression in h3 style in the Spacer field [#267](https://github.com/getgrav/grav-plugin-admin/issues/267) + * Fix missing preview in page media for SVG images [#1051](https://github.com/getgrav/grav-plugin-admin/issues/1051) + * Fix missing check when reordering [#1053](https://github.com/getgrav/grav-plugin-admin/issues/1053) + * Fix for editors not getting refreshed when changing tab [#1052](https://github.com/getgrav/grav-plugin-admin/issues/1052) + * Fix for mobile tabs in page editing [#1057](https://github.com/getgrav/grav-plugin-admin/issues/1057) + +# v1.3.1 +## 03/31/2017 + +1. [](#bugfix) + * Fix for `Undefined index: file_path` error with Direct Install [#1043](https://github.com/getgrav/grav-plugin-admin/issues/1043) + +# v1.3.0 +## 03/31/2017 + +1. [](#new) + * User uploadable avatar (still falls back to Gravatar if not provided) +1. [](#improved) + * Improved tabs CSS to handle long titles [#1036](https://github.com/getgrav/grav-plugin-admin/issues/1036) + * Fixed `step` in range field [Form#136](https://github.com/getgrav/grav-plugin-form/issues/136) +1. [](#bugfix) + * Fixed issue with exception thrown when `copying` and `moving` a page [#1042](https://github.com/getgrav/grav-plugin-admin/issues/1042) + * Automatically calculate the *next* numeric folder prefix [Core#1386](https://github.com/getgrav/grav/issues/1386) + +# v1.3.0-rc.3 +## 03/22/2017 + +1. [](#new) + * All new `Page Ordering` implementation. Completely revamped and will only reorder with folder-prefix enabled. You can now reorder all siblings at the same time. + * Added a new `Advanced - Override` to allow option to display pages by folder name (default) or Collection definition + * Improved `range` form field with touch and counter support [#1016](https://github.com/getgrav/grav-plugin-admin/pull/1016) +1. [](#bugfix) + * Cleanup package files via GPM install to make them more windows-friendly [#1361](https://github.com/getgrav/grav/pull/1361) + +# v1.3.0-rc.2 +## 03/17/2017 + +1. [](#improved) + * Do not attempt to fetch any notification if settings are disabled [#942](https://github.com/getgrav/grav-plugin-admin/issues/942) + +# v1.3.0-rc.1 +## 03/13/2017 + +1. [](#new) + * New flex-based/js Tabs system for better flexibility and improved UX. + * Added new **toolbox** with `Direct-Install` option via ZIP or URL. + * Added an option to reinstall a plugin/theme already installed [#984](https://github.com/getgrav/grav-plugin-admin/issues/984) + * Added a new **range field** [#995](https://github.com/getgrav/grav-plugin-admin/issues/995) + * When creating a new page, automatically select the Page Template based on Parent Page Child Type [#1008](https://github.com/getgrav/grav-plugin-admin/issues/1008) +1. [](#improved) + * Page Media field now is available when folder is created, not just markdown file [#1000](https://github.com/getgrav/grav-plugin-admin/issues/1000) + * Separated user details and avatar in separate twig to allow more granular overriding in plugins [#989](https://github.com/getgrav/grav-plugin-admin/issues/989) + * Nicer layout of themes list on wider screen + * Editor full-screen option displays title/save options [#948](https://github.com/getgrav/grav-plugin-admin/issues/948) + * Use native OS highlight colors for the editor [#977](https://github.com/getgrav/grav-plugin-admin/issues/977) + * Force admin pages to set `Page::expires(0)` so it's not cached [#1009](https://github.com/getgrav/grav-plugin-admin/issues/1009) + * Added support for up to 15 tabs (was 10) [#954](https://github.com/getgrav/grav-plugin-admin/issues/954) + * Only reorder pages in the admin if collection uses `@self` and `order.by` + * Improved configuration tab sizes when you have lots of tabs + * Modified default media select size from 150px x 100px to 200px x 150px +1. [](#bugfix) + * Fixed rendering issue with Chrome and sortables collections [#1002](https://github.com/getgrav/grav-plugin-admin/issues/1002) + * Fixed issue with removal of file that has been just uploaded and stored in the session + + +# v1.2.14 +## 02/17/2017 + +1. [](#bugfix) + * Fixed bad bug with `GPM::install()` from a change in Admin v1.2.13 + +# v1.2.13 +## 02/17/2017 + +1. [](#bugfix) + * Fix issue with validating page when switching language [#963](https://github.com/getgrav/grav-plugin-admin/issues/963) + * Fix issue with quotes in Admin strings used in JS [#965](https://github.com/getgrav/grav-plugin-admin/issues/965) + * Refactored `AdminController::taskGetUpdates` to use standard task/response [#980](https://github.com/getgrav/grav-plugin-admin/issues/980) + * Sync Admin pages blueprints with core [core#212d35221a9bbcc242508ba49a551b3f6e62af8e](https://github.com/getgrav/grav/commit/212d35221a9bbcc242508ba49a551b3f6e62af8e) + +# v1.2.12 +## 02/12/2017 + +1. [](#bugfix) + * Rebuilt the JS bundle to address various JS-related issues that cropped up in `v1.2.11` + * Fixed Firefox Network Error issue when updating multiple plugins/themes at concurrently [#1301](https://github.com/getgrav/grav/issues/1301) + +# v1.2.11 +## 02/10/2017 + +1. [](#new) + * Added lang strings for `CLI_COMPATIBILITY` which is new in Grav v1.1.16 +1. [](#improved) + * Allow plugin to set custom 'authorize' and 'location' in `onAdminMenu()` event + * Updated all language files with latest from [Crowdin](https://crowdin.com/project/grav-admin) +1. [](#bugfix) + * Fixed issue `admin.super` or `admin.users` users changing the account when saving another user [#713](https://github.com/getgrav/grav-plugin-admin/issues/713) + * Fix issue where non `admin.super`/`admin.users` users could see other users profiles [#713](https://github.com/getgrav/grav-plugin-admin/issues/713) + * Fix removing responsive image from page media [#111](https://github.com/getgrav/grav-plugin-admin/issues/111) [#952](https://github.com/getgrav/grav-plugin-admin/issues/952) + * Use @2x & @3x fallback images in the filepicker. [#952](https://github.com/getgrav/grav-plugin-admin/issues/952) + +# v1.2.10 +## 1/30/2017 + +1. [](#improved) + * It is now possible to manually specify a format for the `datetime` field [#1261](https://github.com/getgrav/grav/issues/1261) + * Allow to see plugins and themes list without internet connection. Also add a more helpful message in the "add" view [grav#1008](https://github.com/getgrav/grav/issues/1008) +1. [](#bugfix) + * Fixed issue with downloaded package when installing a testing release + * Allow non admin.super users to change their account information. Allow `admin.super` and `admin.users` to change other users information. [#943](https://github.com/getgrav/grav/issues/943) + * Handle removing a media file also if it's not a json request. Was not working after https://github.com/getgrav/grav-plugin-admin/commit/6b343365996ce838759d80fa3917d4d994f1aeb4 + +# v1.2.9 +## 01/18/2017 + +1. [](#improved) + * Added lang strings for `ALLOW_WEBSERVER_GZIP` in System configuration + +# v1.2.8 +## 01/17/2017 + +1. [](#improved) + * Allow the ability to clear the cache if `admin.maintenance`, as stated in the docs [#908](https://github.com/getgrav/grav-plugin-admin/issues/908) + * Added lang strings for `DEFAULT_LANG` in Site configuration + * Added lang strings for `NEVER_CACHE_TWIG` in System and Page configuration +1. [](#bugfix) + * Fixed saving the configuration if not `admin.super` + * Show the clear cache buttons if the user has `admin.cache` permissions [#908](https://github.com/getgrav/grav-plugin-admin/issues/908#issuecomment-270748616) + * Fix colorpicker validation when transparency is set to 1.00 [#921](https://github.com/getgrav/grav-plugin-admin/issues/921) + * Fix html markup in section twig [#922](https://github.com/getgrav/grav-plugin-admin/pull/922) + * Fix bug in deleting a file uploaded with the `file` field [#920](github.com/getgrav/grav-plugin-admin/issues/920) + * Fix for plugin throwing event-based errors when plugin is removed and no longer available to process said event + +# v1.2.7 +## 12/22/2016 + +1. [](#improved) + * Fixed an issue with non `.html` extensions not setting application type properly when fallback template not found. +1. [](#bugfix) + * Fix plugins and themes json calls after the introduction of [HTML fallback for templates not found](https://github.com/getgrav/grav/commit/364209a27da0f5dfba5fde9c4b07b6d5844cda47) + + +# v1.2.6 +## 12/21/2016 + +1. [](#improved) + * Added a delay before reloading the page when a plugin or theme get installed + * Fix prompting to remove Grav itself when removing a package that requires a specific Grav version + * Remove cli-server exception since we now have compatibility with a custom router in Grav [#1219](https://github.com/getgrav/grav/pull/1219) +1. [](#bugfix) + * Fix issue with array field and `value_only: true` + +# v1.2.5 +## 12/13/2016 + +1. [](#new) + * RC released as stable +1. [](#bugfix) + * YAML syntax fixes + +# v1.2.5-rc.4 +## 12/07/2016 + +1. [](#new) + * Added a new `permissions` form field, used in the user profile to simplify editing permissions + * Added several new `onAdminAfter...()` events to allow for more 3rd party plugin interaction +1. [](#bugfix) + * Updated admin-user-details to allow longer user names in the sidebar [#879](https://github.com/getgrav/grav-plugin-admin/issues/879) + * Redirect to a 404 page when accessing nonexistent plugins and themes [#880](https://github.com/getgrav/grav-plugin-admin/issues/880) + +# v1.2.5-rc.3 +## 11/26/2016 + +1. [](#bugfix) + * Update class namespace for Admin class [#874](https://github.com/getgrav/grav-plugin-admin/issues/874) + * Fix updating/installing packages from admin + +# v1.2.5-rc.2 +## 11/19/2016 + +1. [](#bugfix) + * Make default value work for filepicker [#859](https://github.com/getgrav/grav-plugin-admin/issues/859) + +# v1.2.5-rc.1 +## 11/09/2016 + +1. [](#new) + * Updated to FontAwesome 4.7.0 with [Grav icon](http://fontawesome.io/icon/grav/) +1. [](#improved) + * Always delete image alternatives in AdminController#taskDelmedia [#814](https://github.com/getgrav/grav-plugin-admin/issues/814) + * Use Media class to retrieve files in AdminController#taskGetFilesInFolder [#842](https://github.com/getgrav/grav-plugin-admin/issues/842) + * Increased specificity for Colorpicker field to prevent 3rd party conflicts +1. [](#bugfix) + * Editor link button doesn't prefix links with `http://` anymore [#813](https://github.com/getgrav/grav-plugin-admin/issues/813) + * Dashboard Charts now always refresh no matter what [#753](https://github.com/getgrav/grav-plugin-admin/issues/753) + * Use rawRoute for parent too when saving [#843](https://github.com/getgrav/grav-plugin-admin/issues/843) + * Avoid different output when users exist or not in password recovery [#849](https://github.com/getgrav/grav/issues/849) + * Fix login to admin with permission inherited from group [#857](https://github.com/getgrav/grav-plugin-admin/issues/857) + + +# v1.2.4 +## 10/22/2016 + +1. [](#bugfix) + * Fix for accented media files [#833](https://github.com/getgrav/grav-plugin-admin/issues/833) + * Fix for `CTRL + s` not saving in editor [#832](https://github.com/getgrav/grav-plugin-admin/pull/832) + * Fix for missing REDIS translations in admin [#1123](https://github.com/getgrav/grav/issues/1123) + +# v1.2.3 +## 10/19/2016 + +1. [](#new) + * Added new `onAdminCreatePageFrontmatter()` event to support plugins such as `auto-date` by allowing frontmatter to be modified by plugins. + * Added a new independent `cache_enabled` option for admin plugin (default is `false`). Should fix various sync issues. + * Add an `onAdminData` event to allow plugins to add additional blueprints data +1. [](#improved) + * Handle errors when a resource fails to install + * Page media and File field images thumbnail are now properly proportionate and 150x100 + * Added the Codeception testing suite with an initial test +1. [](#bugfix) + * Fix [#1034](https://github.com/getgrav/grav/issues/1034) redirect of page creation procedure when system.home.hide_in_urls is enabled + * Media (Page): Do not extend parent metehod for sending files since Safari and IE API for FormData don’t implement `delete` ([#772](https://github.com/getgrav/grav-plugin-admin/issues/772)) + * Clean up POST keys containing square brackets, allows for regex ranges in routes ([#776](https://github.com/getgrav/grav-plugin-admin/issues/776)) + * Fix [#773](https://github.com/getgrav/grav-plugin-admin/issues/773) allow filepicker work inside lists, respond to mutation event + * Better error handling for Feed when unable to connect + * Fixed UI for Pagemedia note when files cannot yet be uploaded ([#798](https://github.com/getgrav/grav-plugin-admin/issues/798)) + * Fixed Submit buttons getting disabled in case of form invalidity disallowing to submit again ([#802](https://github.com/getgrav/grav-plugin-admin/issues/802)) + * Fixed issue when reading the file size setting if set to `0` (in Pagemedia and File fields) + * Fixed issue with `file` field in collections that caused unexpected duplication of items ([#775](https://github.com/getgrav/grav-plugin-admin/issues/775)) + * Dramatically improved `filepicker` performance. Data is only ever loaded when the drop-down is on focus, as it was supposed to be. Image preview of a selected item won't be rendered unless the field gains focus to avoid wasting resources. ([#788](https://github.com/getgrav/grav-plugin-admin/issues/788)) + * Allow `filepicker` field to peak at the pending uploaded files and optimistically select them ([#792](https://github.com/getgrav/grav-plugin-admin/issues/792)) + * Fix [#821](https://github.com/getgrav/grav-plugin-admin/issues/821) issue in saving a page to a new language when the filename does not contain the filename yet. + +# v1.2.2 +## 09/08/2016 + +1. [](#bugfix) + * Fix [#767](https://github.com/getgrav/grav-plugin-admin/issues/767) Add styling for new HTML5 input field types + * Fix issue with checking the package dependencies when more than one package is being inspected + +# v1.2.1 +## 09/07/2016 + +1. [](#bugfix) + * Fixed `tmp://` stream issue with Admin updated to 1.2 before Grav updated 1.1.4 + +# v1.2.0 +## 09/07/2016 + +1. [](#new) + * All new `file` field. All files get uploaded via Ajax and are stored upon Save. This improves the Save task tremendously as now there is no longer the need of waiting for the files to finish uploading. Fully backward compatible, `file` field now includes also a `limit` and `filesize` option in the blueprints. The former determines how many files are allowed to be uploaded when in combination with `multiple: true` (default: 10), the latter determines the file size limit (in MB) allowed for each file (default: 5MB) + * Added a new `filepicker` field, which allows to pick any file from an ajax-powered select box. The `pagemediaselect` field now internally uses the `filepicker` field to live-reload the available files, and to show image previews. +1. [](#improved) + * Better error handling for 500 Internal Server Errors, when Fetch fails + * Various notifications style and other CSS fixes + * More language strings added + * Added `clear-tmp` to cache clear drop-down + * Unified JSON twig templates + * Better error handling for 500 Internal Server Errors, when Fetch fails. + * Updated vendor Libraries +1. [](#bugfix) + * Curl fix for invalid cert errors with News Feed + * Avoid requiring `admin.super` for ajax calls [#739](https://github.com/getgrav/grav-plugin-admin/issues/739) + * Fix showing HTML in notifications, in the feed + * Fixed broken page type filtering + * Fixed `beforeunload` event not prompting to offer the choice to stay on the page in case of unsaved changes + * Fixed click-away detection for preventing loss of changes, that would get ignored in some circumstances (ie, from modal confirmation) + * Fixed issue with `_json` elements where nested fields merging would get stored in an unexpected way + * Fixed composer dependencies missing error message + +# v1.1.4 +## 08/14/2016 + +1. [](#bugfix) + * Fixed Firefox News Feed dashboard widget layout + +# v1.1.3 +## 08/10/2016 + +1. [](#new) + * Admin notifications system. Admin will pull and cache notifications. This will be used to announce important updates, security vulnerabilities, and general interest news. + * Ability to disable widgets in the dashboard + * Added news feed widget to the dashboard +1. [](#improved) + * Updated FontAwesome to v4.6.3 + * Use new List functionality for Media Configuration + * Get fresh media list for `Controller::getListMedia()` rather that cache so always latest. + * Add translation strings for the new system.force_ssl option + * Reworked List UI to better handle drag & drop sort. To sort it is now required to use the left drag handle [#724](https://github.com/getgrav/grav-plugin-admin/issues/724) + * Lists now features a new YAML option `controls: [top|bottom|both]` (default: bottom) which will display the "Add Item" button at the Top and/or Bottom position relative to the list. When the Top button is pressed, a new item will be added at the beginning of the list, when the Bottom button is pressed, a new item will be appended to the list. + * Lists now features two new YAML options `sortby: [field]` (default: disabled) and `sortby_dir: [asc|desc]` (default: asc) which will display a new Sorting button in the list allowing to automatically reindex the collection based on the given sort field set. + * Lists now features a new YAML option `collapsed: [true|false]` (default: false) and a new UI/UX that allows for collapsing / expanding collection items, allowing to better managing long lists of items. It is advised to always put as first field the most significant one, so that when a list is collapsed it can be still easily browsed. + * It is now possible to sort Array fields via drag & drop [#950](https://github.com/getgrav/grav/issues/950) +1. [](#bugfix) + * Fixed issue in Admin favicon URL [#704](https://github.com/getgrav/grav-plugin-admin/issues/704) + * Fixed issue in `selfupgrade` where the package would get downloaded in the wrong destination + * Hide tab when user is not authorized to access it [#712](https://github.com/getgrav/grav-plugin-admin/issues/712) + * Fixed Lists issue when reindexing, causing Radio fields to potentially lose their `checked` status + * Avoid overwriting a file when uploaded with the same filename through the Admin blueprint `file` field type if `avoid_overwriting` is enabled on the field + * Fixed issue with Array field in `value_only` mode, improperly displaying the key when no value was set + * Translate the description of a blueprint field [#729](https://github.com/getgrav/grav-plugin-admin/issues/729) + +# v1.1.2 +## 07/16/2016 + +1. [](#improved) + * Forcing limit of upload files based on System settings +1. [](#bugfix) + * Definitive fix for multi form submission in Microsoft Edge causing the Save to not work [#694](https://github.com/getgrav/grav-plugin-admin/issues/694) + * Fix issue with calculating the `theme_url` with `open_basedir` restrictions [#699](https://github.com/getgrav/grav-plugin-admin/issues/699) + * Check for null payload before going on [#526](https://github.com/getgrav/grav-plugin-admin/issues/526) + * Redraw Dashboard Charts when collapsing/expanding the sidebar + * Fix for `cache/compiled` errors resulting from page media uploads [getgrav/grav#938](https://github.com/getgrav/grav/issues/938) + +# v1.1.1 +## 07/14/2016 + +1. [](#bugfix) + * Fixed issue with forms causing creation of new pages not to work [#698](https://github.com/getgrav/grav-plugin-admin/issues/698) and [getgrav/grav#934](https://github.com/getgrav/grav/issues/934) + +# v1.1.0 +## 07/14/2016 + +1. [](#improved) + * Added the ability to login with the email in addition to the username. [#674](https://github.com/getgrav/grav-plugin-admin/issues/674) + * It is now possible to sort the Plugins and Themes views by 'Name', 'Author', 'GravTeam', 'Release Date', 'Updates Available' and 'Testing' releases (if in Testing Channel), both Ascending and Descending. [#583](https://github.com/getgrav/grav-plugin-admin/issues/583) + * Prevent external links (like the Preview button) to trigger the "Changes Detected" notice [#689](https://github.com/getgrav/grav-plugin-admin/issues/689) + * Added a filter field in Plugins and Themes list views, to allow for quick search of a particular resource + * Added new `Enabled` sorting option for Plugins list view +1. [](#bugfix) + * Fixed an issue that prevented removing more than one page, in the pages listng [#672](https://github.com/getgrav/grav-plugin-admin/issues/672) + * Fixed toggleables in lists that were always loading as checked even when not stored [#688](https://github.com/getgrav/grav-plugin-admin/issues/688) + * Fixed Fullscreen tooltip in Editor displaying off screen (when in fullscreen mode) [#677](https://github.com/getgrav/grav-plugin-admin/issues/677) + * Fixed inconsistency in the way selectized fields would be rendered [#692](https://github.com/getgrav/grav-plugin-admin/issues/692) + * Fixed issue with Save in Microsoft Edge [#694](https://github.com/getgrav/grav-plugin-admin/issues/694) + +# v1.1.0-rc.4 +## 06/21/2016 + +1. [](#bugfix) + * Fix for 'front-end' shortcut showing in mobile sidebar incorrectly. + * Append progressive number to the copied page title. [#394](https://github.com/getgrav/grav-plugin-admin/issues/394) + * Add field description to forms [#667](https://github.com/getgrav/grav-plugin-admin/pull/667) + * Fix clearing all cache [#658](https://github.com/getgrav/grav-plugin-admin/issues/658) + * Assign the correct ordering when saving a page that didn't have ordering set before [#628](https://github.com/getgrav/grav-plugin-admin/issues/628) + * Fix issue when saving a modular child folder as 05.somethin and being reset to 01.something upon save [#628](https://github.com/getgrav/grav-plugin-admin/issues/628) + +# v1.1.0-rc.3 +## 06/14/2016 + +1. [](#bugfix) + * Fix for Gemini Scrollbar CSS breaking layout in IE 9+ [#644](https://github.com/getgrav/grav-plugin-admin/issues/644) + * Fall back to english for UI language if admin's language is not set [#641](https://github.com/getgrav/grav-plugin-admin/issues/641) + * List field has the wrong label/field width. Switched to "1/3 | 2/3" like all other fields. + * Correctly set the page slug on page copy. Avoids having two pages with the same slug [#394](https://github.com/getgrav/grav-plugin-admin/issues/394) + * When copying a page, if there's a page prefix (used for ordering), update the value to avoid having two pages with the same order number [#429](https://github.com/getgrav/grav-plugin-admin/issues/429) + * Fixed size of dropdown text in responsive views to be readable [#647](https://github.com/getgrav/grav-plugin-admin/issues/647) + * Fixed issue with checkbox in toggleables getting submitted with the form even when disabled (fixes #646) + +# v1.1.0-rc.2 +## 06/02/2016 + +1. [](#improved) + * Cleaned up the Page Preview CSS to make it more 'standard' [#634](https://github.com/getgrav/grav-plugin-admin/issues/634) + * Added a legend with the Page colors explained [#637](https://github.com/getgrav/grav-plugin-admin/issues/637) + * Hide email output when sending forgot password instructions [#571](https://github.com/getgrav/grav-plugin-admin/issues/571) +1. [](#bugfix) + * Fixed "Data type `System` doesn't exist!" error when activating a theme [#635](https://github.com/getgrav/grav-plugin-admin/issues/635) + * Fixed issue with custom media types not deleting on save [#633](https://github.com/getgrav/grav-plugin-admin/issues/633) + * Fixed issue when saving `List` field type in plugins + pages + * Fixed JS error on login/logout page due to jQuery not being loaded + + +# v1.1.0-rc.1 +## 06/01/2016 + +1. [](#new) + * Major improvements with the **File Upload** (`file`) field type. Now fully supports themes, plugins, configuration + pages +1. [](#improved) + * Updated with latest languages via [Crowdin](https://crowdin.com/project/grav-admin/) + * Provide security options for single tabs [#615](https://github.com/getgrav/grav-plugin-admin/issues/615) + * Disable double clicking on Save/Delete/Copy page actions [#611](https://github.com/getgrav/grav-plugin-admin/issues/611) + * Tweaked the avatar alignment in sidebar [#592](https://github.com/getgrav/grav-plugin-admin/issues/592) + * Added page name to delete dialog [#511](https://github.com/getgrav/grav-plugin-admin/issues/511) + * Enabling / Disabling a Plugin doesn't trigger the expand / collapse details anymore [#614](https://github.com/getgrav/grav-plugin-admin/issues/614) + * Added hover on plugins list rows to match pages [#619](https://github.com/getgrav/grav-plugin-admin/issues/619) + * Translate media configuration [#608](https://github.com/getgrav/grav-plugin-admin/issues/608) + * Use raw routes in blueprints to better support multi-language [#798](https://github.com/getgrav/grav-plugin-admin/issues/798) + * Updated NPM modules dependencies +1. [](#bugfix) + * Fix double "Removed successfully" appearing when removing a package [#609](https://github.com/getgrav/grav-plugin-admin/issues/609) + * Prevent removing required plugins dependencies when removing a package [#613](https://github.com/getgrav/grav-plugin-admin/issues/613) + * Show page title in Delete Confirmation modal if this information is available + * Don't try to uninstall admin/form/login/email plugins + * Only check for updates if not `admin.maintenance` or `admin.super` [#557](https://github.com/getgrav/grav-plugin-admin/issues/557) + * Always submit checkboxes that are not checked and force a 0 value [#616](https://github.com/getgrav/grav-plugin-admin/issues/616) + * Fix encoding in tooltips again [#622](https://github.com/getgrav/grav-plugin-admin/issues/622) + * Do not show `move` cursor for Collections that aren't sortable [#624](https://github.com/getgrav/grav-plugin-admin/issues/624) + * Properly handle Collections that specify a custom key, rather than falling back to indexed list [#632](https://github.com/getgrav/grav-plugin-admin/issues/632) + +# v1.1.0-beta.5 +## 05/23/2016 + +1. [](#improved) + * Set sidebar navigation defaults back to "Tab Activation" and "Auto Width" + * Custom logo text is displayed as first letter in small sidebar view [#829](https://github.com/getgrav/grav/issues/829) + * Copied admin-only blueprints from Grav core to the Admin plugin + * Allow `field.label` to have HTML in it [#601](https://github.com/getgrav/grav-plugin-admin/issues/601) +1. [](#bugfix) + * Fixed Togggle field with doubled `checked="checked"` when `toggleable: true` [#579](https://github.com/getgrav/grav-plugin-admin/issues/579) + * Strip HTML tags and lowercase username from login/reset forms [#577](https://github.com/getgrav/grav-plugin-admin/issues/577) + * Fixed issue with version numbers not showing up for dependencies [#581](https://github.com/getgrav/grav-plugin-admin/issues/581) + * Fixed editor tooltips in fullscreen mode and tablet devices rendering [#566](https://github.com/getgrav/grav-plugin-admin/issues/566) + * Fixed issue with `file` form field not functioning [#838](https://github.com/getgrav/grav/issues/838) + * Fixed issue with creating pages [#595](https://github.com/getgrav/grav-plugin-admin/issues/595) + +# v1.1.0-beta.4 +## 05/09/2016 + +1. [](#new) + * Implemented Quickopen functionality to automatically open / close the Sidebar when mouseover +1. [](#improved) + * Better error handling when `obj->validate()` fails with exception [#594](https://github.com/getgrav/grav-plugin-admin/issues/564) + * Improve markup of update and add package dependencies in update modal [#560](https://github.com/getgrav/grav-plugin-admin/issues/560) +1. [](#bugfix) + * Fix for admin translation filter (`|tu`) not substituting text - [#567](https://github.com/getgrav/grav-plugin-admin/issues/567) + * Translated "Publishing" tab text [#561](https://github.com/getgrav/grav-plugin-admin/issues/561) + * Fix invalid argument supplied in foreach [#563](https://github.com/getgrav/grav-plugin-admin/issues/563) + * CSS fixes for editor button alignment + * Fix for forgot password not finding anyone + * Fix UI issue with update button on a package page in Firefox + * Fix issue with update button when automatic check for updates is disabled + * Fix issue caused by clicking "Check for updates" multiple times + * Added missing translations + * Fix for Themes with an array of keywords [#823](https://github.com/getgrav/grav/issues/823) + +# v1.1.0-beta.3 +## 05/04/2016 + +1. [](#new) + * Added a `|adminNicetime` Twig filter to show 'nicetime' in admin user's language + * Added a `prepend` and `append` field option for text input type + * Added a WIP `onAdminRegisterPermissions` event + * Added several new languages: Arabic, Danish, Greek, Farsi, Korean, Romanian, Thai. Huge thanks to the [translation teams](https://crowdin.com/project/grav-admin) +1. [](#improved) + * Fixed UI issue with Backup / Update buttons positioning + * Tweaked placeholders color in login/new user panels [#542](https://github.com/getgrav/grav-plugin-admin/issues/542) +1. [](#bugfix) + * Fixed several untranslated strings + * Fix the version information after updating Grav from Admin + * Fix a Twig autoescape issue on Plugins descriptions + * Fix for showing empty drop-down with only one supported language [#522](https://github.com/getgrav/grav-plugin-admin/issues/522) + * Fix for visibility toggle on new page not working [#551](https://github.com/getgrav/grav-plugin-admin/issues/551) + * Page tooltips usability issue [#496](https://github.com/getgrav/grav-plugin-admin/issues/496) + * Fix removed title attribute from editor toolbar buttons [#539](https://github.com/getgrav/grav-plugin-admin/issues/539) + * Allow Incognito / Private browsing to still function in Safari [#527](https://github.com/getgrav/grav-plugin-admin/issues/527) + +# v1.1.0-beta.2 +## 04/27/2016 + +1. [](#new) + * Added `grav ~1.1` to dependencies + * Added a persistent message if you try to run Admin 1.1 on Grav 1.0 +1. [](#improved) + * Used locator instead of `CACHE_DIR` + * Added a better way to get Admin version + * Show account page for users with certain ACL [#524](https://github.com/getgrav/grav-plugin-admin/pull/524) +1. [](#bugfix) + * Fixed Editor Preview using wrong parameters for the ajax call + * Fixed toggle for stable/testing channel + * Fixed blueprint JSON fields + * If not logged in redirect to base path [#445](https://github.com/getgrav/grav-plugin-admin/pull/445) + * Various autoescape fixes + * ColorPicker CSS fixes + * Fix for translation of admin login [#500](https://github.com/getgrav/grav-plugin-admin/issues/500) + * Fix list not applying `toggleable: true` and `style: vertical` [#518](https://github.com/getgrav/grav-plugin-admin/pull/518) + * Fixed issue with update for wrong plugin displaying on plugin details pages + * Fixed error with the **close sidebar** toggle in some browsers (Firefox, iOS Safari) + +# v1.1.0-beta.1 +## 04/20/2016 + +1. [](#new) + * JavaScript Rewrite. Admin is now built in ES6 + * Lists can now be nested and 'fancy fields' (such as editor, datetime picker, selectize, other lists) get automatically initialized so they are always available no matter if you add or remove items from the lists + * The Editor has been reworked to be more flexible. In fact you can now pass any CodeMirror setting via blueprints, through the codemirror: attribute. The buttons have also a new API that allow to add or ignore buttons and behaviors into the toolbar from any plugin (see grav-plugin-editor-buttons). We also added the headers buttons (H1-H6) and Undo / Redo buttons, due to popular demand + * We introduced a new colorpicker field. You can now add more colors to your admin plugins :) + * Along with the versioning support added in the Grav Core for 1.1, the admin plugin can now install dependencies with the same versioning requirements as the GPM CLI commands. + * New System configuration field for toggling GPM release version (testing/stable) + * Several new system configuration options for new functionality such as `Process frontmatter Twig` + * Ability to collapse the sidebar to a smaller icon view if you need more room. +1. [](#improved) + * The default Grav theme has been tweaked and in many places completely rewritten to ensure that it's as flexible as possible. The primary reason for this was to ensure theming and customization compatibility for the upcoming Admin Pro plugin, but a key benefit includes greatly improved mobile compatibility. + * We reworked the Datetimepicker, you will notice a new refreshed UI with a much better support for translations + * Tabs are now persistent. In views such as Page editing, when switching tab and saving or refreshing, would cause the tab to be reset to the initial one. + * When editing a page in Expert mode, the frontmatter editor is now more friendly. You will now get line numbers, undo/redo and YAML linter. + * Behind the scenes we have reworked how the form and toggleables work. This added a lot more reliability and consistency across the whole admin. + * The Pages view has more persistent states. It will now remember your expanded/collapsed states as well as filtering. + * Lists can now accept a custom button label with the 'btnLabel' property + * After login to Admin, redirect to the original URL called + * Admin now has an unique cache key compared to the 'site' so pages can be cached independently + * Improved the layout of the User Profile page. + * Set cache key uniquely for admin so cache does not colide with site +1. [](#bugfix) + * Fix for modular preview - [#254](https://github.com/getgrav/grav-plugin-admin/issues/254) + * Fix for long content and page tabs - [#441](https://github.com/getgrav/grav-plugin-admin/issues/441) + * Fix for clear cache after adding new folder - [#393](https://github.com/getgrav/grav-plugin-admin/issues/393) + +# v1.0.9 +## 02/11/2016 + +1. [](#bugfix) + * Fix language translation files + +# v1.0.8 +## 02/05/2016 + +1. [](#new) + * Added a logout button when not authorized to access a page in Admin + * Added the option to hide a tab from an extended blueprint (https://github.com/getgrav/grav/issues/620) + * Many new languages and updates to existing languages from the Translation team. +1. [](#improved) + * Check frontmatter for validity prior to saving + * Add noindex, nofollow across the entire admin theme if no other robots headers are set on a page + * Allow to hide a configuration blueprint section / tab and still save its values + * Allow to show user defined blueprints in configuration + * Updated FontAwesome to latest 4.5.0 version +1. [](#bugfix) + * Fixed an issue with user registration on Linux caused by `glob()` possibly returning false. + * Fixed an issue preventing Admin to work correctly in a multisite configuration + * Fixed preview and insertion of images with non-lowercase extension + * Fixed an incorrect number of pages being displayed in the sidebar in some cases + * [Security] Don't reveal Grav filesystem path when trying to delete non-existing images + * [Security] Fix PHP error happening when uploading file without extension if the JS dropzone uploader is configured to allow empty file extensions + * [Security] Ensure correct escaping in various Twig files + +# v1.0.7 +## 01/15/2016 + +1. [](#new) + * Added onAdminDashboard event + * Added onAdminSave event + * New lang strings for reverse proxy toggle +1. [](#improved) + * More robust YAML file checking in config folders + * Removed deprecated menu event + * Removed old logs code + * Used new onAdminDashboard event for current dashboard widgets +1. [](#bugfix) + * Fix for missing access checks on config pages #397 + * Fix parent not loaded on admin form save #587 + * When no route field is added to a page blueprint, add it as page root + * Fix for wrong page count (will show dynamic added pages in count too - Need to fix this) + * Fix for IE/Edge saving forms #391 + +# v1.0.6 +## 01/07/2016 + +1. [](#bugfix) + * Fix for forms appending `_json` fields on every save + +# v1.0.5 +## 01/07/2016 + +1. [](#new) + * Added a pointer to Grav's contributing guide + * Handle the optional logic to strip home from Page routes and urls + * The Configuration page now shows any blueprint found in the user/blueprints/config/ folder, thus allowing to add custom configurations +1. [](#improved) + * Allow the nonce for a POST action to be set in the query url + * Add a fallback twig template to use in case Twig cannot find a template file + * Modified update Theme and Plugin buttons to use more reliably markup +1. [](#bugfix) + * Fix additional `on` parameter when saving plugins configs that contain tabs in their blueprint + * Fixes for the `pagemediaselect` form field + * Fix an untranslated message in the logout form when `system.languages.translations` is disabled + * Fixed a hardcoded `http://` reference throwing warnings under HTTPS + * Ensure download package has `.zip` extension, just in case + +# v1.0.4 +## 12/22/2015 + +1. [](#improved) + * Improved File input field for admin + * Restore file inputs functionality and process form via JS if no inputs found +1. [](#bugfix) + * Fix for the image preview in the file field on multi-lang sites + * Fix problem in form code introduced by fix to allow file uploads + * Fix redirect in deleting page media + +# v1.0.3 +## 12/20/2015 + +1. [](#new) + * Added `pagemediaselect` field for use in pages +1. [](#improved) + * Updated various languages + * Check for method `meetsRequirements()` prior to using + * Enable `file` form field to be used in plugins and theme blueprints + +# v1.0.2 +## 12/18/2015 + +1. [](#bugfix) + * Fixed issue with user edit page causing error due to individual language files + +# v1.0.1 +## 12/18/2015 + +1. [](#new) + * Moved languages into individual files under `languages/` folder + * Added a check for PHP version + * Dutch translation added +1. [](#improved) + * Let forms work with file inputs + * Various file input improvements + * Language updates + * Better checks for existence of Popularity JSON data + * Add file processing to admin forms + * More Admin Pro integration fixes +1. [](#bugfix) + * Set form to multipart if it contains a file field + * `cleanFilesData()` now returns just the filename + +# v1.0.0 +## 12/11/2015 + +1. [](#new) + * New built-in admin registration process + * Added security check to `section` form field + * Added new RocketTheme font with various icons + * Add `onAdminThemeInitialized()` event to admin `Themes::init()` + * Force timestamp on CSS/JS assets based on `GRAV_VERSION` + * Additions for Gantry5 support +1. [](#improved) + * Force lowercase `username` when logging in + * Hide markdown preview except for pages + * Added a notice if you don't have permission to see dashboard + * Updated admin login page logic + * Return "Invalid Security Token" instead of "Unauthorized" + * Throw exception if you used with built-in PHP web server + * Updated languages + * Removed `noreply@getgrav.org` default email address + * Use new methods to disable CSS/JS pipeline if available + * Various code cleanups +1. [](#bugfix) + * Handle case when email `from` is not configured + * Fix tabs support in plugin/themes settings + * Fix param separator in page media Ajax call + * Fix favicon base URL + +# v1.0.0-rc.7 +## 12/01/2015 + +1. [](#new) + * Display error page if page does not exist in admin + * Removed Beta message option and added toggle for GitHub message + * Added functionality to support Admin Pro plugin (in development) +1. [](#improved) + * Added support for Markdown editor in lists #239 + * Better Markdown Editor API with dynamic initialization + * Various language updates + * Removed some unused variables + * Added admin check for pages existence + * Prevent the admin to cause an error when an Ajax action is in progress + * Force translations to be active even when disabled in site #299 + * Do not reinitialize `Selectize` if already available +1. [](#bugfix) + * Fixed full-screen markdown Editor + * Fix modular preview not working reliably #254 + * **Nonce fixes** (hopefully the last of them!) + * Fix broken plugin enable/disable + * Fix issue where `_redirect: /plugins` was getting stored in the plugin configuration + * Replace default them service with admin one + * Fix saving array fields #304 + * Fix missing translations when default language is not english + * Fix title variables not translated #310 + +# v1.0.0-rc.6 +## 11/21/2015 + +1. [](#improved) + * Implemented logic to detect when offline and suppress Ajax calls + * Added nonce logic to be used by JS +1. [](#bugfix) + * Nonce fix for updating themes + * Nonce fix for deleting pages + +# v1.0.0-rc.5 +## 11/20/2015 + +1. [](#new) + * Use **Nonce** mechanism for form security + * Added Hungarian translation + * Add support for Markdown labels #271 + * Added support for Markdown Editor in all the things + * Implemented save keyboard shortcut (Ctrl + S / CMD + S) +1. [](#improved) + * Better error for "Internal Server Error" when accessing GPM + * Updated French translation + * Updated Russian translation + * Load Gravatar image with protocol-less `//:` syntax + * Improved header UI in mobile browsers #265 + * Dropped unused version of JQuery + * More visible Preview link icon + * Hide **Latest pages** if there are none + * Improved toggle to better support different length strings +1. [](#bugfix) + * Force rescanning fields when submitting a form #243 + * Set default lang for pages on fresh session + * Escaped values in `array.html.twig` + * Fix saving in IE Edge + * Fixed various typos + * Fixed JS button issues #370 + * Fixed JS error in private browsing #272 + * Fixed date field border + * Fixed multiple instance of Markdown Editor #285 + * Fixed Spacer CSS #267 + +# v1.0.0-rc.4 +## 10/29/2015 + +1. [](#improved) + * Changed admin menu event hook to `onAdminMenu()` + * Minor improvements for admin page location + * Additional lang strings for Grav 1.0.0-rc.3 + +# v1.0.0-rc.3 +## 10/27/2015 + +1. [](#improved) + * Rely on context-language for active language + * Improved some Russian translations + * Only show login if not already logged in +1. [](#bugfix) + * Disable asset pipeline in admin only + * Fix Editor cursor insertion point when text is selected in some actions + +# v1.0.0-rc.2 +## 10/23/2015 + +1. [](#bugfix) + * Reverted lang redirect code. Needs to be reworked to be more reliable + +# v1.0.0-rc.1 +## 10/23/2015 + +1. [](#new) + * Redirect to non-language URL except for `pages/` +1. [](#improved) + * New language strings for new `system.yaml` fields + * Improved Russian translations + * Improved compatibility with PECL Yaml parser +1. [](#bugfix) + * Redirect to correct page if you change folder/slug + * Fix issue with Asset pipeline not being disabled in admin + * Fix for HTML in text input fields + * Fixed various icons in headers + +# v0.6.2 +## 10/15/2015 + +1. [](#improved) + * Use `title` rather than `menu` in Page listing + * Wrapped language strings in double-quotes + * New language strings for new fields +1. [](#bugfix) + * Fixed issue with IE not able to save pages + +# v0.6.1 +## 10/07/2015 + +1. [](#new) + * Added the ability to render front-end templates in markdown preview + * Option to disable Google-based fonts. Useful for Cyrillic languages. + * Couple of new static helper methods used by new page blueprints + * New `fieldset` form field (thanks @Sommerregen!) +1. [](#improved) + * Hide editor buttons in preview mode + * Improved support for admin when offline + * Use relative URL in Login form + * Added some more missing lang strings + * Improved German translation + * Compressed CSS files for improved performance + * Only get last 7 days in week count calculation +1. [](#bugfix) + * Fix saving pages in local-specific languages + * Only track 'human' page hits in statistics + * Responsive fixes for 'wordy' languages + * Fixed delete issue with array field type + * Fixed some hardcoded `admin` references to allow admin path change + * Fix for issue with lang code being added twice + * Fix language name in admin buttons + +# v0.6.0 +## 09/16/2015 + +1. [](#new) + * Support for custom markdown editor buttons! + * Added Russian translations + * Added Japanese translations + * Ajax session keep-alive when editing forms +1. [](#improved) + * Added missing Italian translations + * Added additional options field into the pages form field +1. [](#bugfix) + * Fix GPM errors in offline mode + * Fix for duplicate status messages + +# v0.5.0 +## 09/11/2015 + +1. [](#new) + * Responsive layout for mobile compatibility (thanks @Vivalldi!) + * Added page type and many other new filters to Page list view + * Added granular ACL requirements to admin pages + * Ability to define page date format + * Added `onAdminTemplateNavPluginHook` to allow for plugins to hook into sidebar + * Added YAML Twig filters (to and from) + * Support for nested metadata + * Added ability to disable automatic update checks via admin plugin configuration + * Initial Spanish translation +1. [](#improved) + * Check for existence of a user account + * Various language additions + * Refactored form fields to remove duplicates from form plugin + * Improved date picker + * Improved display field + * Add page template type to page list view + * Various UI fixes + * Added some default field 'focus' to save clicking + * Only allow "Add Modular" if the theme has modular templates + * Updated `chartist.js` library + * Updated 'fontawesome' fonts to the latest v4.4 +1. [](#bugfix) + * Fix for "drag-n-drop" of non-image media + * Fix a fatal error in GPM when offline + * Fix a z-index bug with tooltips + * Fix a z-index bug in lang dropdowns + * Don't allow deleting of last empty array field + * Fix for images with parenthesis in filenames + * Fix for page title visualization when not set + * Fix for cursor position in folder/array fields + +# v0.4.3 +## 08/31/2015 + +1. [](#new) + * Added Japanese translation + * Support for independent file name and template override +1. [](#improved) + * Improved slug generation using `slugify.js` + * Allow the `title` twig variables to set the page title + * Improved Page media handling with several bugfixes + * Prevent error when there are no pages on a site + * If all updates are applied, show "Fully Updated" text in dashboard + * Better preview link (requires `rtrim` filter from Grav 0.9.40) + * Order all plugins and themes alphabetically + * Removed duplicate language entries +1. [](#bugfix) + * Fix for redirect after saving when multilang not enabled + * Fix for deleting responsive media + * Fix for HTML encoding in markdown field + +# v0.4.2 +## 08/25/2015 + +1. [](#bugfix) + * Fix for current admin lang not showing up in page lang dropdown + * Fix for incorrect NAME/CONTENT lang keys + * Fix for incorrect site link + +# v0.4.1 +## 08/24/2015 + +1. [](#bugfix) + * Fix for broken **Add Page** - Doh! + * Fix for empty site link when at root + +# v0.4.0 +## 08/24/2015 + +1. [](#new) + * Multi-language Page support!!! + * Admin languages configurable per user + * Toastr messages for `check updates` + * new `tu` filter for admin translations + * Italian and German admin translations + * Added a save location in system and site configuration + * Page metadata now uses flexible array field +1. [](#improved) + * Allow subpages of modular pages to display in pages list + * Open external pages in new tabs + * Reworked `visibility` of pages + * Use `PLUGIN_ADMIN` prefix for translations + * Added link to gravatar.com to avoid confusion on avatar + * Limit page count to 200 in ordering field + * Fixed various Safari _flex_ issues + * Use `rawRoute()` for page links + * Minor `param separator` fixes + * Various CSS fixes + * Improved CodeMirror to force spaces + * Added **Selectize** dropdowns to various forms and modals +1. [](#bugfix) + * Fix for `Call to a member function path() on non-object` error + * Fixed dropdown z-index issues + * Correctly set the filename including language if set + * Fix for empty taxonomies on page save + * Fix for page not redirecting properly on folder change + * Fix for table headers styling + * Added missing translation strings + * Unique page counting in total page counts + * Fixed JS warning with page filtering and deleting + + +# v0.3.0 +## 08/11/2015 + +1. [](#new) + * Show current date in form date format fields + * Added a new **check for updates** button to flush GPM + * Added session timeout configuration for admin + * Added `isSymlink` logic for Grav + * Added new `phpinfo` page +1. [](#improved) + * Improved toggleables + * Support `param_separator` for Apache on windows + * Logout now goes to interstitial to provide session messages + * Updated hints and improved formatting + * Encoding URI for images in editor preview + * Create user `system.yaml` and `site.yaml` if they are missing + * Open external links in new tab by default + * Set edit mode to `normal` by default + * Disable CSS/JS pipelining in the admin +1. [](#bugfix) + * Fixed form submission not working in IE + * Fix fatal error when deleting homepage + * Prevent admin plugin activating when the URL of a page contains partial route + +# v0.2.0 +## 08/06/2015 + +1. [](#new) + * Added multiple **clear cache** types + * Added back to themes link when adding new themes + * Properly handles visibility and ordering and guesses best option on new + * Added new templates field with support for custom (unsupported) template type + * Added new display field for displaying simple text value + * **Update Grav** button now works + * Added spanish translation + * Added german translation +1. [](#improved) + * Improved page order handling logic + * Implemented 2-step theme switching logic with warning + * Force `modular` page class for modular template + * Clear page cache on page delete (ghost pages still showing) + * Clears route on page save so changes such as `slug` are picked up + * Fix dashboard layout in Safari + * Added tooltips for official 'Team Grav' themes/plugins +1. [](#bugfix) + * Handle modular page templates on create + * Fixed Firefox JS error for arrays + * Ensure we don't change page type to empty and save (causing page to be deleted) + * Fixed some minor CSS issues with editor + * Fixed link to RocketTheme.com + * Disabled fields now stay properly disabled + +# v0.1.1 +## 08/04/2015 + +1. [](#bugfix) + * Fixed GitHub URLs + * Hiding toggle for disabling Admin plugin + * Removed extra text not needed + +# v0.1.0 +## 08/04/2015 + +1. [](#new) + * ChangeLog started... diff --git a/user/plugins/admin/CONTRIBUTING.md b/user/plugins/admin/CONTRIBUTING.md new file mode 100644 index 0000000..ef2bc6f --- /dev/null +++ b/user/plugins/admin/CONTRIBUTING.md @@ -0,0 +1 @@ +Please read the Contributing Guidelines of the Grav Project \ No newline at end of file diff --git a/user/plugins/admin/LICENSE b/user/plugins/admin/LICENSE new file mode 100644 index 0000000..f7d4bb6 --- /dev/null +++ b/user/plugins/admin/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Grav + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/user/plugins/admin/README.md b/user/plugins/admin/README.md new file mode 100644 index 0000000..430509d --- /dev/null +++ b/user/plugins/admin/README.md @@ -0,0 +1,111 @@ +# Grav Standard Administration Panel Plugin + +This **admin plugin** for [Grav](http://github.com/getgrav/grav) is an HTML user interface that provides a convenient way to configure Grav and easily create and modify pages. This will remain a totally optional plugin, and is not in any way required or needed to use Grav effectively. In fact, the admin provides an intentionally limited view to ensure it remains easy to use and not overwhelming. I'm sure power users will still prefer to work with the configuration files directly. + +![](assets/admin-dashboard.png) + +# Features + +* User login with automatic password encryption +* Forgot password functionality +* Logged-in-user management +* One click Grav core updates +* Dashboard with maintenance status, site activity and latest page updates +* Notifications system for latest news, blogs, and announcements +* Ajax-powered backup capability +* Ajax-powered clear-cache capability +* System configuration management +* Site configuration management +* Normal and Expert modes which allow editing via forms or YAML +* Page listing with filtering and search +* Page creation, editing, moving, copying, and deleting +* Powerful syntax highlighting code editor with instant Grav-powered preview +* Editor features, hot keys, toolbar, and distraction-free fullscreen mode +* Drag-n-drop upload of page media files including drag-n-drop placement in the editor +* One click theme and plugin updates +* Plugin manager that allows listing and configuration of installed plugins +* Theme manager that allows listing and configuration of installed themes +* GPM-powered installation of new plugins and themes + +# Support + +#### Support + +We have tested internally, but we hope to use this public beta phase to identify, isolate, and fix issues related to the plugin to ensure it is as solid and reliable as possible. + +For **live chatting**, please use the dedicated [Slack Chat Room](https://getgrav.org/slack) for discussions directly related to Grav. + +For **bugs, features, improvements**, please ensure you [create issues in the admin plugin GitHub repository](https://github.com/getgrav/grav-plugin-admin). + +# Installation + +First ensure you are running the latest **Grav 1.6.7 or later**. This is required for the admin plugin to run properly (`-f` forces a refresh of the GPM index). + +``` +$ bin/gpm selfupgrade -f +``` + +The admin plugin actually requires the help of 3 other plugins, so to get the admin plugin to work you first need to install **admin**, **login**, **forms**, and **email** plugins. These are available via GPM, and because the plugin has dependencies you just need to proceed and install the admin plugin, and agree when prompted to install the others: + +``` +$ bin/gpm install admin +``` + +### Manual Installation + +Manual installation is not the recommended method of installation, however, it is still possible to install the admin plugin manually. Basically, you need to download each of the following plugins individually: + +* [admin](https://github.com/getgrav/grav-plugin-admin/archive/develop.zip) +* [login](https://github.com/getgrav/grav-plugin-login/archive/develop.zip) +* [form](https://github.com/getgrav/grav-plugin-form/archive/develop.zip) +* [email](https://github.com/getgrav/grav-plugin-email/archive/develop.zip) + +Extract each archive file into your `user/plugins` folder, then ensure the folders are renamed to just `admin/`, `login/`, `form/`, and `email/`. Then proceed with the **Usage instructions below**. + +# Usage + +### Create User with CLI + +After this you need to create a user account with admin privileges: + +``` +$ bin/plugin login new-user +``` + +### Create User Manually + +Alternatively, you can create a user account manually, in a file called `user/accounts/admin.yaml`. This **filename** is actually the **username** that you will use to login. The contents will contain the other information for the user. + +``` +password: 'password' +email: 'youremail@mail.com' +fullname: 'Johnny Appleseed' +title: 'Site Administrator' +access: + admin: + login: true + super: true +``` + +Of course you should edit your `email`, `password`, `fullname`, and `title` to suit your needs. + +> You can use any password when you manually put it in this `.yaml` file. However, when you change your password in the admin, it must contain at least one number and one uppercase and lowercase letter, and at least 8 or more characters. + +# Accessing the Admin + +By default, you can access the admin by pointing your browser to `http://yoursite.com/admin`. You can simply log in with the `username` and `password` set in the YAML file you configured earlier. + +> After logging in, your **plaintext password** will be removed and replaced by an **encrypted** one. + +# Standard Free & Paid Pro Versions + +If you have been following the [blog](http://getgrav.org/blog), [Twitter](https://twitter.com/getgrav), [Slack chat](https://getgrav.org/slack), etc., you probably already know now that our intention is to provide two versions of this plugin. + +The **standard free version**, is very powerful, and has more functionality than most commercial flat-file CMS systems. + +We also intend to release in the near future a more feature-rich **pro version** that will include enhanced functionality, as well as some additional nice-to-have capabilities. This pro version will be a **paid** plugin the price of which is not yet 100% finalized. + +# Running Tests + +First install the dev dependencies by running `composer update` from the Grav root. +Then `composer test` will run the Unit Tests, which should be always executed successfully on any site. diff --git a/user/plugins/admin/admin.php b/user/plugins/admin/admin.php new file mode 100644 index 0000000..0ecc2d0 --- /dev/null +++ b/user/plugins/admin/admin.php @@ -0,0 +1,1014 @@ + 1000, + ]; + + /** @var bool */ + protected $active = false; + + /** @var string */ + protected $template; + + /** @var string */ + protected $theme; + + /** @var string */ + protected $route; + + /** @var string */ + protected $admin_route; + + /** @var Uri */ + protected $uri; + + /** @var Admin */ + protected $admin; + + /** @var Session */ + protected $session; + + /** @var Popularity */ + protected $popularity; + + /** @var string */ + protected $base; + + /** @var string */ + protected $version; + + /** + * @return array + */ + public static function getSubscribedEvents() + { + return [ + 'onPluginsInitialized' => [ + ['autoload', 100001], + ['setup', 100000], + ['onPluginsInitialized', 1001] + ], + 'onPageInitialized' => ['onPageInitialized', 0], + 'onFormProcessed' => ['onFormProcessed', 0], + 'onShutdown' => ['onShutdown', 1000], + 'onAdminDashboard' => ['onAdminDashboard', 0], + 'onAdminTools' => ['onAdminTools', 0], + ]; + } + + /** + * Get list of form field types specified in this plugin. Only special types needs to be listed. + * + * @return array + */ + public function getFormFieldTypes() + { + return [ + 'column' => [ + 'input@' => false + ], + 'columns' => [ + 'input@' => false + ], + 'fieldset' => [ + 'input@' => false + ], + 'section' => [ + 'input@' => false + ], + 'list' => [ + 'array' => true + ], + 'file' => [ + 'array' => true + ] + ]; + } + + /** + * [onPluginsInitialized:100000] Composer autoload. + * + * @return ClassLoader + */ + public function autoload() + { + return require __DIR__ . '/vendor/autoload.php'; + } + + /** + * [onPluginsInitialized:100000] + * + * If the admin path matches, initialize the Login plugin configuration and set the admin + * as active. + */ + public function setup() + { + $route = $this->config->get('plugins.admin.route'); + if (!$route) { + return; + } + + $this->base = '/' . trim($route, '/'); + $this->admin_route = rtrim($this->grav['pages']->base(), '/') . $this->base; + $this->uri = $this->grav['uri']; + + $users_exist = Admin::doAnyUsersExist(); + + // If no users found, go to register + if (!$users_exist) { + if (!$this->isAdminPath()) { + $this->grav->redirect($this->admin_route); + } + $this->template = 'register'; + } + + // Only activate admin if we're inside the admin path. + if ($this->isAdminPath()) { + try { + $this->grav['session']->init(); + } catch (SessionException $e) { + $this->grav['session']->init(); + $message = 'Session corruption detected, restarting session...'; + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage($message); + + $this->grav['messages']->add($message, 'error'); + } + $this->active = true; + + // Set cache based on admin_cache option + $this->grav['cache']->setEnabled($this->config->get('plugins.admin.cache_enabled')); + $pages = $this->grav['pages']; + if (method_exists($pages, 'setCheckMethod')) { + // Force file hash checks to fix caching on moved and deleted pages. + $pages->setCheckMethod('hash'); + } + } + } + + /** + * [onPluginsInitialized:1001] + * + * If the admin plugin is set as active, initialize the admin + */ + public function onPluginsInitialized() + { + // Only activate admin if we're inside the admin path. + if ($this->active) { + // Store this version. + $this->version = $this->getBlueprint()->get('version'); + + // Have a unique Admin-only Cache key + if (method_exists($this->grav['cache'], 'setKey')) { + /** @var Cache $cache */ + $cache = $this->grav['cache']; + $cache_key = $cache->getKey(); + $cache->setKey($cache_key . '$'); + } + + // Turn on Twig autoescaping + if (method_exists($this->grav['twig'], 'setAutoescape') && $this->grav['uri']->param('task') !== 'processmarkdown') { + $this->grav['twig']->setAutoescape(true); + } + + $this->grav['debugger']->addMessage('Admin v' . $this->version); + $this->initializeAdmin(); + + // Disable Asset pipelining (old method - remove this after Grav is updated) + if (!method_exists($this->grav['assets'], 'setJsPipeline')) { + $this->config->set('system.assets.css_pipeline', false); + $this->config->set('system.assets.js_pipeline', false); + } + + // Replace themes service with admin. + $this->grav['themes'] = function () { + return new Themes($this->grav); + }; + } + + // We need popularity no matter what + $this->popularity = new Popularity(); + + // Fire even to register permissions from other plugins + $this->grav->fireEvent('onAdminRegisterPermissions', new Event(['admin' => $this->admin])); + } + + /** + * [onPageInitialized:0] + */ + public function onPageInitialized() + { + $page = $this->grav['page']; + + $template = $this->grav['uri']->param('tmpl'); + + if ($template) { + $page->template($template); + } + } + + /** + * [onFormProcessed:0] + * + * Process the admin registration form. + * + * @param Event $event + */ + public function onFormProcessed(Event $event) + { + $form = $event['form']; + $action = $event['action']; + + switch ($action) { + case 'register_admin_user': + + if (Admin::doAnyUsersExist()) { + throw new \RuntimeException('A user account already exists, please create an admin account manually.'); + } + + if (!$this->config->get('plugins.login.enabled')) { + throw new \RuntimeException($this->grav['language']->translate('PLUGIN_LOGIN.PLUGIN_LOGIN_DISABLED')); + } + + $data = []; + $username = $form->value('username'); + + if ($form->value('password1') !== $form->value('password2')) { + $this->grav->fireEvent('onFormValidationError', new Event([ + 'form' => $form, + 'message' => $this->grav['language']->translate('PLUGIN_LOGIN.PASSWORDS_DO_NOT_MATCH') + ])); + $event->stopPropagation(); + + return; + } + + $data['password'] = $form->value('password1'); + + $fields = [ + 'email', + 'fullname', + 'title' + ]; + + foreach ($fields as $field) { + // Process value of field if set in the page process.register_user + if (!isset($data[$field]) && $form->value($field)) { + $data[$field] = $form->value($field); + } + } + + // Don't store plain text password or username (part of the filename). + unset($data['password1'], $data['password2'], $data['username']); + + // Extra lowercase to ensure file is saved lowercase + $username = strtolower($username); + + $inflector = new Inflector(); + + $data['fullname'] = $data['fullname'] ?? $inflector->titleize($username); + $data['title'] = $data['title'] ?? 'Administrator'; + $data['state'] = 'enabled'; + $data['access'] = ['admin' => ['login' => true, 'super' => true], 'site' => ['login' => true]]; + + /** @var UserCollectionInterface $users */ + $users = $this->grav['accounts']; + + // Create user object and save it + $user = $users->load($username); + $user->update($data); + $user->save(); + + //Login user + $this->grav['session']->user = $user; + unset($this->grav['user']); + $this->grav['user'] = $user; + $user->authenticated = true; + $user->authorized = $user->authorize('admin.login'); + + $messages = $this->grav['messages']; + $messages->add($this->grav['language']->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN'), 'info'); + $this->grav->redirect($this->admin_route); + + break; + } + } + + /** + * [onShutdown:1000] + * + * Handles the shutdown + */ + public function onShutdown() + { + if ($this->active) { + //only activate when Admin is active + if ($this->admin->shouldLoadAdditionalFilesInBackground()) { + $this->admin->loadAdditionalFilesInBackground(); + } + } else { + //if popularity is enabled, track non-admin hits + if ($this->config->get('plugins.admin.popularity.enabled')) { + $this->popularity->trackHit(); + } + } + } + + /** + * [onAdminDashboard:0] + */ + public function onAdminDashboard() + { + $this->grav['twig']->plugins_hooked_dashboard_widgets_top[] = ['template' => 'dashboard-maintenance']; + $this->grav['twig']->plugins_hooked_dashboard_widgets_top[] = ['template' => 'dashboard-statistics']; + $this->grav['twig']->plugins_hooked_dashboard_widgets_top[] = ['template' => 'dashboard-notifications']; + $this->grav['twig']->plugins_hooked_dashboard_widgets_top[] = ['template' => 'dashboard-feed']; + $this->grav['twig']->plugins_hooked_dashboard_widgets_main[] = ['template' => 'dashboard-pages']; + } + + + /** + * [onAdminTools:0] + * + * Provide the tools for the Tools page, currently only direct install + * + * @return Event + */ + public function onAdminTools(Event $event) + { + $event['tools'] = array_merge($event['tools'], [ + 'backups' => [['admin.maintenance', 'admin.super'], $this->grav['language']->translate('PLUGIN_ADMIN.BACKUPS')], + 'scheduler' => [['admin.super'], $this->grav['language']->translate('PLUGIN_ADMIN.SCHEDULER')], + 'logs' => [['admin.super'], $this->grav['language']->translate('PLUGIN_ADMIN.LOGS')], + 'reports' => [['admin.super'], $this->grav['language']->translate('PLUGIN_ADMIN.REPORTS')], + 'direct-install' => [['admin.super'], $this->grav['language']->translate('PLUGIN_ADMIN.DIRECT_INSTALL')], + ]); + + return $event; + } + + /** + * Sets longer path to the home page allowing us to have list of pages when we enter to pages section. + */ + public function onPagesInitialized() + { + $config = $this->config; + + // Force SSL with redirect if required + if ($config->get('system.force_ssl')) { + if (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') { + $url = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $this->grav->redirect($url); + } + } + + $this->session = $this->grav['session']; + + // Set original route for the home page. + $home = '/' . trim($this->config->get('system.home.alias'), '/'); + + // set session variable if it's passed via the url + if ($this->uri->param('mode') === 'expert') { + $this->session->expert = true; + } elseif ($this->uri->param('mode') === 'normal') { + $this->session->expert = false; + } else { + // set the default if not set before + $this->session->expert = $this->session->expert ?? false; + } + + /** @var Pages $pages */ + $pages = $this->grav['pages']; + + $this->grav['admin']->routes = $pages->routes(); + + // Remove default route from routes. + if (isset($this->grav['admin']->routes['/'])) { + unset($this->grav['admin']->routes['/']); + } + + $page = $pages->dispatch('/', true); + + // If page is null, the default page does not exist, and we cannot route to it + if ($page) { + $page->route($home); + } + + // Make local copy of POST. + $post = $this->grav['uri']->post(); + + // Initialize Page Types + Pages::types(); + + // Handle tasks. + $this->admin->task = $task = $this->grav['task']; + if ($task) { + $this->initializeController($task, $post); + } elseif ($this->template === 'logs' && $this->route) { + // Display RAW error message. + echo $this->admin->logEntry(); + exit(); + } + + $self = $this; + + // make sure page is not frozen! + unset($this->grav['page']); + + $this->admin->pagesCount(); + + // Replace page service with admin. + $this->grav['page'] = function () use ($self) { + $page = new Page(); + $page->expires(0); + + if ($this->grav['user']->authorize('admin.login')) { + $event = new Event(['page' => $page]); + $event = $this->grav->fireEvent('onAdminPage', $event); + $page = $event['page']; + + if ($page->slug()) { + return $page; + } + } + + // Look in the pages provided by the Admin plugin itself + if (file_exists(__DIR__ . "/pages/admin/{$self->template}.md")) { + $page->init(new \SplFileInfo(__DIR__ . "/pages/admin/{$self->template}.md")); + $page->slug(basename($self->template)); + return $page; + } + + // If not provided by Admin, lookup pages added by other plugins + $plugins = $this->grav['plugins']; + $locator = $this->grav['locator']; + + foreach ($plugins as $plugin) { + if ($this->config->get("plugins.{$plugin->name}.enabled") !== true) { + continue; + } + + $path = $locator->findResource("plugins://{$plugin->name}/admin/pages/{$self->template}.md"); + + if ($path) { + $page->init(new \SplFileInfo($path)); + $page->slug(basename($self->template)); + + return $page; + } + } + + return null; + }; + + if (empty($this->grav['page'])) { + if ($this->grav['user']->authenticated) { + $event = new Event(['page' => null]); + $event->page = null; + $event = $this->grav->fireEvent('onPageNotFound', $event); + /** @var PageInterface $page */ + $page = $event->page; + + if (!$page || !$page->routable()) { + $error_file = $this->grav['locator']->findResource('plugins://admin/pages/admin/error.md'); + $page = new Page(); + $page->init(new \SplFileInfo($error_file)); + $page->slug(basename($this->route)); + $page->routable(true); + } + + unset($this->grav['page']); + $this->grav['page'] = $page; + } else { + // Not Found and not logged in: Display login page. + $login_file = $this->grav['locator']->findResource('plugins://admin/pages/admin/login.md'); + $page = new Page(); + $page->init(new \SplFileInfo($login_file)); + $page->slug(basename($this->route)); + unset($this->grav['page']); + $this->grav['page'] = $page; + } + } + + // Explicitly set a timestamp on assets + $this->grav['assets']->setTimestamp(substr(md5(GRAV_VERSION . $this->grav['config']->checksum()), 0, 10)); + } + + /** + * Handles initializing the assets + */ + public function onAssetsInitialized() + { + // Disable Asset pipelining + $assets = $this->grav['assets']; + $assets->setJsPipeline(false); + $assets->setCssPipeline(false); + } + + /** + * Add twig paths to plugin templates. + */ + public function onTwigTemplatePaths() + { + $twig_paths = []; + $this->grav->fireEvent('onAdminTwigTemplatePaths', new Event(['paths' => &$twig_paths])); + + $twig_paths[] = __DIR__ . '/themes/' . $this->theme . '/templates'; + + $this->grav['twig']->twig_paths = $twig_paths; + } + + /** + * Set all twig variables for generating output. + */ + public function onTwigSiteVariables() + { + $twig = $this->grav['twig']; + $page = $this->grav['page']; + + $twig->twig_vars['location'] = $this->template; + $twig->twig_vars['base_url_relative_frontend'] = $twig->twig_vars['base_url_relative'] ?: '/'; + $twig->twig_vars['admin_route'] = trim($this->admin_route, '/'); + $twig->twig_vars['template_route'] = $this->template; + $twig->twig_vars['current_route'] = '/' . $twig->twig_vars['admin_route'] . '/' . $this->template . '/' . $this->route; + $twig->twig_vars['base_url_relative'] = $twig->twig_vars['base_url_simple'] . '/' . $twig->twig_vars['admin_route']; + $twig->twig_vars['current_url'] = rtrim($twig->twig_vars['base_url_relative'] . '/' . $this->template . '/' . $this->route, '/'); + $theme_url = '/' . ltrim($this->grav['locator']->findResource('plugin://admin/themes/' . $this->theme, + false), '/'); + $twig->twig_vars['theme_url'] = $theme_url; + $twig->twig_vars['preset_url'] = $twig->twig_vars['preset_url'] ?? $theme_url; + $twig->twig_vars['base_url'] = $twig->twig_vars['base_url_relative']; + $twig->twig_vars['base_path'] = GRAV_ROOT; + $twig->twig_vars['admin'] = $this->admin; + $twig->twig_vars['admin_version'] = $this->version; + $twig->twig_vars['logviewer'] = new LogViewer(); + $twig->twig_vars['form_max_filesize'] = Utils::getUploadLimit() / 1024 / 1024; + + $fa_icons_file = CompiledYamlFile::instance($this->grav['locator']->findResource('plugin://admin/themes/grav/templates/forms/fields/iconpicker/icons' . YAML_EXT)); + $fa_icons = $fa_icons_file->content(); + $fa_icons = array_map(function ($icon) { + //only pick used values + return ['id' => $icon['id'], 'unicode' => $icon['unicode']]; + }, $fa_icons['icons']); + + $twig->twig_vars['fa_icons'] = $fa_icons; + + // add form if it exists in the page + $header = $page->header(); + + $forms = []; + if (isset($header->forms)) foreach ($header->forms as $key => $form) { + $forms[$key] = new Form($page, null, $form); + } + $twig->twig_vars['forms'] = $forms; + + // preserve form validation + if (!isset($twig->twig_vars['form'])) { + if (isset($header->form)) { + $twig->twig_vars['form'] = new Form($page); + } elseif (isset($header->forms)) { + $twig->twig_vars['form'] = new Form($page, null, reset($header->forms)); + } + } + + // Gather Plugin-hooked nav items + $this->grav->fireEvent('onAdminMenu'); + + switch ($this->template) { + case 'dashboard': + $twig->twig_vars['popularity'] = $this->popularity; + + // Gather Plugin-hooked dashboard items + $this->grav->fireEvent('onAdminDashboard'); + + break; + } + + $flashData = $this->grav['session']->getFlashCookieObject(Admin::TMP_COOKIE_NAME); + + if (isset($flashData->message)) { + $this->grav['messages']->add($flashData->message, $flashData->status); + } + } + + // Add images to twig template paths to allow inclusion of SVG files + public function onTwigLoader() + { + $theme_paths = Grav::instance()['locator']->findResources('plugins://admin/themes/' . $this->theme . '/images'); + foreach($theme_paths as $images_path) { + $this->grav['twig']->addPath($images_path, 'admin-images'); + } + } + + /** + * Add the Admin Twig Extensions + */ + public function onTwigExtensions() + { + require_once __DIR__ . '/classes/Twig/AdminTwigExtension.php'; + + $this->grav['twig']->twig->addExtension(new AdminTwigExtension); + } + + public function onAdminAfterSave(Event $event) + { + // Special case to redirect after changing the admin route to avoid 'breaking' + $obj = $event['object']; + + if (null !== $obj && method_exists($obj, 'blueprints')) { + $blueprint = $obj->blueprints()->getFilename(); + + if ($blueprint === 'admin/blueprints' && isset($obj->route) && $this->admin_route !== $obj->route) { + $redirect = preg_replace('/^' . str_replace('/','\/',$this->admin_route) . '/',$obj->route,$this->uri->path()); + $this->grav->redirect($redirect); + } + } + } + + /** + * Convert some types where we want to process out of the standard config path + * + * @param Event $e + */ + public function onAdminData(Event $e) + { + $type = $e['type'] ?? null; + switch ($type) { + case 'tools/scheduler': + $e['type'] = 'config/scheduler'; + break; + case 'tools': + case 'tools/backups': + $e['type'] = 'config/backups'; + break; + } + } + + public function onOutputGenerated() + { + // Clear flash objects for previously uploaded files whenever the user switches page or reloads + // ignoring any JSON / extension call + if ($this->admin->task !== 'save' && empty($this->uri->extension())) { + // Discard any previously uploaded files session and remove all uploaded files. + if ($flash = $this->session->getFlashObject('files-upload')) { + $flash = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($flash)); + foreach ($flash as $key => $value) { + if ($key !== 'tmp_name') { + continue; + } + @unlink($value); + } + } + } + } + + /** + * Initial stab at registering permissions (WIP) + * + * @param Event $e + */ + public function onAdminRegisterPermissions(Event $e) + { + $admin = $e['admin']; + $permissions = [ + 'admin.super' => 'boolean', + 'admin.login' => 'boolean', + 'admin.cache' => 'boolean', + 'admin.configuration' => 'boolean', + 'admin.configuration_system' => 'boolean', + 'admin.configuration_site' => 'boolean', + 'admin.configuration_media' => 'boolean', + 'admin.configuration_info' => 'boolean', + 'admin.settings' => 'boolean', + 'admin.pages' => 'boolean', + 'admin.maintenance' => 'boolean', + 'admin.statistics' => 'boolean', + 'admin.plugins' => 'boolean', + 'admin.themes' => 'boolean', + 'admin.tools' => 'boolean', + 'admin.users' => 'boolean', + ]; + $admin->addPermissions($permissions); + } + + /** + * Check if the current route is under the admin path + * + * @return bool + */ + public function isAdminPath() + { + $route = $this->uri->route(); + + return $route === $this->base || 0 === strpos($route, $this->base . '/'); + } + + /** + * Helper function to replace Pages::Types() + * and to provide an event to manipulate the data + * + * Dispatches 'onAdminPageTypes' event + * with 'types' data member which is a + * reference to the data + */ + public static function pagesTypes() + { + $types = Pages::types(); + + // First filter by configuration + $hideTypes = Grav::instance()['config']->get('plugins.admin.hide_page_types', []); + foreach ((array) $hideTypes as $type) { + unset($types[$type]); + } + + // Allow manipulating of the data by event + $e = new Event(['types' => &$types]); + Grav::instance()->fireEvent('onAdminPageTypes', $e); + + return $types; + } + + /** + * Helper function to replace Pages::modularTypes() + * and to provide an event to manipulate the data + * + * Dispatches 'onAdminModularPageTypes' event + * with 'types' data member which is a + * reference to the data + */ + public static function pagesModularTypes() + { + $types = Pages::modularTypes(); + + // First filter by configuration + $hideTypes = (array) Grav::instance()['config']->get('plugins.admin.hide_modular_page_types', []); + foreach ($hideTypes as $type) { + unset($types[$type]); + } + + // Allow manipulating of the data by event + $e = new Event(['types' => &$types]); + Grav::instance()->fireEvent('onAdminModularPageTypes', $e); + + return $types; + } + + /** + * Validate a value. Currently validates + * + * - 'user' for username format and username availability. + * - 'password1' for password format + * - 'password2' for equality to password1 + * + * @param string $type The field type + * @param string $value The field value + * @param string $extra Any extra value required + * + * @return bool + */ + protected function validate($type, $value, $extra = '') + { + /** @var Login $login */ + $login = $this->grav['login']; + + return $login->validateField($type, $value, $extra); + } + + protected function initializeController($task, $post) + { + $controller = new AdminController(); + $controller->initialize($this->grav, $this->template, $task, $this->route, $post); + $controller->execute(); + $controller->redirect(); + } + + /** + * Initialize the admin. + * + * @throws \RuntimeException + */ + protected function initializeAdmin() + { + $this->enable([ + 'onTwigExtensions' => ['onTwigExtensions', 1000], + 'onPagesInitialized' => ['onPagesInitialized', 1000], + 'onTwigLoader' => ['onTwigLoader', 1000], + 'onTwigTemplatePaths' => ['onTwigTemplatePaths', 1000], + 'onTwigSiteVariables' => ['onTwigSiteVariables', 1000], + 'onAssetsInitialized' => ['onAssetsInitialized', 1000], + 'onAdminRegisterPermissions' => ['onAdminRegisterPermissions', 0], + 'onOutputGenerated' => ['onOutputGenerated', 0], + 'onAdminAfterSave' => ['onAdminAfterSave', 0], + 'onAdminData' => ['onAdminData', 0], + ]); + + // Autoload classes + require_once __DIR__ . '/vendor/autoload.php'; + + + // Check for required plugins + if (!$this->grav['config']->get('plugins.login.enabled') || !$this->grav['config']->get('plugins.form.enabled') || !$this->grav['config']->get('plugins.email.enabled')) { + throw new \RuntimeException('One of the required plugins is missing or not enabled'); + } + + // Initialize Admin Language if needed + /** @var Language $language */ + $language = $this->grav['language']; + if ($language->enabled() && empty($this->grav['session']->admin_lang)) { + $this->grav['session']->admin_lang = $language->getLanguage(); + } + + // Decide admin template and route. + $path = trim(substr($this->uri->route(), strlen($this->base)), '/'); + + if (empty($this->template)) { + $this->template = 'dashboard'; + } + + // Can't access path directly... + if ($path && $path !== 'register') { + $array = explode('/', $path, 2); + $this->template = array_shift($array); + $this->route = array_shift($array); + } + + // Initialize admin class (also registers it to Grav services). + $this->admin = new Admin($this->grav, $this->admin_route, $this->template, $this->route); + + // Double check we have system.yaml, site.yaml etc + $config_path = $this->grav['locator']->findResource('user://config'); + foreach ($this->admin::configurations() as $config_file) { + $config_file = "{$config_path}/{$config_file}.yaml"; + if (!file_exists($config_file)) { + touch($config_file); + } + } + + // Get theme for admin + $this->theme = $this->config->get('plugins.admin.theme', 'grav'); + + $assets = $this->grav['assets']; + $translations = 'this.GravAdmin = this.GravAdmin || {}; if (!this.GravAdmin.translations) this.GravAdmin.translations = {}; ' . PHP_EOL . 'this.GravAdmin.translations.PLUGIN_ADMIN = {'; + + // Enable language translations + $translations_actual_state = $this->config->get('system.languages.translations'); + $this->config->set('system.languages.translations', true); + + $strings = [ + 'EVERYTHING_UP_TO_DATE', + 'UPDATES_ARE_AVAILABLE', + 'IS_AVAILABLE_FOR_UPDATE', + 'AND', + 'IS_NOW_AVAILABLE', + 'CURRENT', + 'UPDATE_GRAV_NOW', + 'TASK_COMPLETED', + 'UPDATE', + 'UPDATING_PLEASE_WAIT', + 'GRAV_SYMBOLICALLY_LINKED', + 'OF_YOUR', + 'OF_THIS', + 'HAVE_AN_UPDATE_AVAILABLE', + 'UPDATE_AVAILABLE', + 'UPDATES_AVAILABLE', + 'FULLY_UPDATED', + 'DAYS', + 'PAGE_MODES', + 'PAGE_TYPES', + 'ACCESS_LEVELS', + 'NOTHING_TO_SAVE', + 'FILE_UNSUPPORTED', + 'FILE_ERROR_ADD', + 'FILE_ERROR_UPLOAD', + 'DROP_FILES_HERE_TO_UPLOAD', + 'DELETE', + 'UNSET', + 'INSERT', + 'METADATA', + 'VIEW', + 'UNDO', + 'REDO', + 'HEADERS', + 'BOLD', + 'ITALIC', + 'STRIKETHROUGH', + 'SUMMARY_DELIMITER', + 'LINK', + 'IMAGE', + 'BLOCKQUOTE', + 'UNORDERED_LIST', + 'ORDERED_LIST', + 'EDITOR', + 'PREVIEW', + 'FULLSCREEN', + 'MODULAR', + 'NON_MODULAR', + 'VISIBLE', + 'NON_VISIBLE', + 'ROUTABLE', + 'NON_ROUTABLE', + 'PUBLISHED', + 'NON_PUBLISHED', + 'PLUGINS', + 'THEMES', + 'ALL', + 'FROM', + 'TO', + 'DROPZONE_CANCEL_UPLOAD', + 'DROPZONE_CANCEL_UPLOAD_CONFIRMATION', + 'DROPZONE_DEFAULT_MESSAGE', + 'DROPZONE_FALLBACK_MESSAGE', + 'DROPZONE_FALLBACK_TEXT', + 'DROPZONE_FILE_TOO_BIG', + 'DROPZONE_INVALID_FILE_TYPE', + 'DROPZONE_MAX_FILES_EXCEEDED', + 'DROPZONE_REMOVE_FILE', + 'DROPZONE_RESPONSE_ERROR' + ]; + + foreach ($strings as $string) { + $separator = (end($strings) === $string) ? '' : ','; + $translations .= '"' . $string . '": "' . htmlspecialchars($this->admin::translate('PLUGIN_ADMIN.' . $string)) . '"' . $separator; + } + + $translations .= '};'; + + $translations .= 'this.GravAdmin.translations.PLUGIN_FORM = {'; + $strings = ['RESOLUTION_MIN', 'RESOLUTION_MAX']; + foreach ($strings as $string) { + $separator = (end($strings) === $string) ? '' : ','; + $translations .= '"' . $string . '": "' . $this->admin::translate('PLUGIN_FORM.' . $string) . '"' . $separator; + } + $translations .= '};'; + + $translations .= 'this.GravAdmin.translations.GRAV_CORE = {'; + $strings = [ + 'NICETIME.SECOND', + 'NICETIME.MINUTE', + 'NICETIME.HOUR', + 'NICETIME.DAY', + 'NICETIME.WEEK', + 'NICETIME.MONTH', + 'NICETIME.YEAR', + 'CRON.EVERY', + 'CRON.EVERY_HOUR', + 'CRON.EVERY_MINUTE', + 'CRON.EVERY_DAY_OF_WEEK', + 'CRON.EVERY_DAY_OF_MONTH', + 'CRON.EVERY_MONTH', + 'CRON.TEXT_PERIOD', + 'CRON.TEXT_MINS', + 'CRON.TEXT_TIME', + 'CRON.TEXT_DOW', + 'CRON.TEXT_MONTH', + 'CRON.TEXT_DOM', + 'CRON.ERROR1', + 'CRON.ERROR2', + 'CRON.ERROR3', + 'CRON.ERROR4', + 'MONTHS_OF_THE_YEAR', + 'DAYS_OF_THE_WEEK' + ]; + foreach ($strings as $string) { + $separator = (end($strings) === $string) ? '' : ','; + $translations .= '"' . $string . '": ' . json_encode($this->admin::translate('GRAV.'.$string)) . $separator; + } + $translations .= '};'; + + // set the actual translations state back + $this->config->set('system.languages.translations', $translations_actual_state); + + $assets->addInlineJs($translations); + } +} diff --git a/user/plugins/admin/admin.yaml b/user/plugins/admin/admin.yaml new file mode 100644 index 0000000..56b3977 --- /dev/null +++ b/user/plugins/admin/admin.yaml @@ -0,0 +1,48 @@ +enabled: true +route: '/admin' +cache_enabled: false +theme: grav +logo_text: '' +body_classes: '' +content_padding: true +twofa_enabled: true +log_viewer_files: ['grav', 'email'] +sidebar: + activate: tab + hover_delay: 100 + size: auto +dashboard: + days_of_stats: 7 +widgets: + dashboard-maintenance: true + dashboard-statistics: true + dashboard-notifications: true + dashboard-feed: true + dashboard-pages: true +pages: + show_parents: both + show_modular: true +session: + timeout: 1800 +warnings: + delete_page: true + secure_delete: false +edit_mode: normal +frontend_preview_target: inline +show_github_msg: true +pages_list_display_field: title +google_fonts: false +admin_icons: line-awesome +enable_auto_updates_check: true +notifications: + feed: true + dashboard: true + plugins: true + themes: true +popularity: + enabled: true + ignore: ['/test*','/modular'] + history: + daily: 30 + monthly: 12 + visitors: 20 diff --git a/user/plugins/admin/assets/admin-dashboard.png b/user/plugins/admin/assets/admin-dashboard.png new file mode 100644 index 0000000..599743e Binary files /dev/null and b/user/plugins/admin/assets/admin-dashboard.png differ diff --git a/user/plugins/admin/blueprints.yaml b/user/plugins/admin/blueprints.yaml new file mode 100644 index 0000000..f00d935 --- /dev/null +++ b/user/plugins/admin/blueprints.yaml @@ -0,0 +1,528 @@ +name: Admin Panel +slug: admin +type: plugin +version: 1.9.15 +testing: false +description: Adds an advanced administration panel to manage your site +icon: empire +author: + name: Team Grav + email: devs@getgrav.org + url: http://getgrav.org +homepage: https://github.com/getgrav/grav-plugin-admin +keywords: admin, plugin, manager, panel +bugs: https://github.com/getgrav/grav-plugin-admin/issues +docs: https://github.com/getgrav/grav-plugin-admin/blob/develop/README.md +license: MIT + +dependencies: + - { name: grav, version: '>=1.6.22' } + - { name: form, version: '>=3.0.0' } + - { name: login, version: '>=3.0.0' } + - { name: email, version: '>=3.0.0' } + +form: + validation: loose + fields: + Basics: + type: section + title: Basics + underline: false + + enabled: + type: hidden + label: PLUGIN_ADMIN.PLUGIN_STATUS + highlight: 1 + default: 0 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + cache_enabled: + type: toggle + label: PLUGIN_ADMIN.ADMIN_CACHING + help: PLUGIN_ADMIN.ADMIN_CACHING_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + twofa_enabled: + type: toggle + label: PLUGIN_LOGIN.2FA_TITLE + help: PLUGIN_LOGIN.2FA_ENABLED_HELP + default: 1 + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + route: + type: text + label: Administrator path + size: medium + placeholder: "Default route for administrator (relative to base)" + help: If you want to change the URL for the administrator, you can provide a path here + + logo_text: + type: text + label: Logo text + size: medium + placeholder: "Grav" + help: Text to display in place of the default Grav logo + + content_padding: + type: toggle + label: PLUGIN_ADMIN.CONTENT_PADDING + help: PLUGIN_ADMIN.CONTENT_PADDING_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + body_classes: + type: text + label: Body classes + size: medium + help: Add a space separated name of custom body classes + + sidebar.activate: + type: select + label: Sidebar Activation + help: Control how the sidebar is activated + size: small + default: tab + options: + tab: Tab + hover: Hover + + sidebar.hover_delay: + type: text + size: x-small + append: millseconds + label: Hover delay + default: 500 + validate: + type: number + min: 1 + + + sidebar.size: + type: select + label: Sidebar Size + help: Control the width of the sidebar + size: medium + default: auto + options: + auto: Automatic width + small: Small width + + theme: + type: hidden + label: Theme + default: grav + + edit_mode: + type: select + label: Edit mode + size: small + default: normal + options: + normal: Normal + expert: Expert + help: Auto will use blueprint if available, if none found, it will use "Expert" mode. + + frontend_preview_target: + type: select + label: Preview pages target + size: medium + default: inline + options: + inline: Inline in Admin + _blank: New tab + _self: Current tab + + pages.show_parents: + type: select + size: medium + label: Parent dropdown + highlight: 1 + options: + both: Show slug and folder + folder: Show folder + fullpath: Show fullpath + + pages.parents_levels: + type: text + label: Parents Levels + size: small + help: The number of levels to show in parent select list + + pages.show_modular: + type: toggle + label: Modular parents + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Show modular pages in the parent select list + + google_fonts: + type: toggle + label: Use Google Fonts + highlight: 0 + default: 0 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Use Google custom fonts. Disable this to use Helvetica. Useful when using Cyrillic and other languages with unsupported characters. + + show_beta_msg: + type: hidden + + show_github_msg: + type: toggle + label: Show GitHub Link + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Show the "Found an issue? Please report it on GitHub." message. + + pages_list_display_field: + type: text + size: small + label: Pages List Display Field + help: "Field of the page to use in the list of pages if present. Defaults/Fallback to title." + + enable_auto_updates_check: + type: toggle + label: Automatically check for updates + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Shows an informative message, in the admin panel, when an update is available. + + session.timeout: + type: text + size: small + label: Session Timeout + append: secs + help: "Sets the session timeout in seconds" + validate: + type: number + min: 1 + + warnings.delete_page: + type: toggle + label: Warn on page delete + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Ask the user confirmation when deleting a page + + warnings.secure_delete: + type: toggle + label: Secure Delete + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Shows the user a field to enter the word DELETE and enable the confirm delete button. + + hide_page_types: + type: array + label: Hide page types in Admin + value_only: true + + hide_modular_page_types: + type: array + label: Hide modular page types in Admin + value_only: true + + log_viewer_files: + type: selectize + size: medium + label: PLUGIN_ADMIN.LOG_VIEWER_FILES + help: PLUGIN_ADMIN.LOG_VIEWER_FILES_HELP + classes: fancy + validate: + type: commalist + + MediaResize: + type: section + title: Page Media Image Resizer + underline: true + + MediaResizeNote: + type: spacer + text: PLUGIN_ADMIN.PAGEMEDIA_RESIZER + markdown: true + + pagemedia.resize_width: + type: number + size: x-small + append: pixels + label: Resize Width + default: 0 + validate: + type: number + help: Resize wide images down to the set value + + pagemedia.resize_height: + type: number + size: x-small + append: pixels + label: Resize Height + default: 0 + validate: + type: number + help: Resize tall images down to the set value + + pagemedia.res_min_width: + type: number + size: x-small + append: pixels + label: Resolution Min Width + default: 0 + validate: + type: number + help: The minimum width allowed for an image to be added + + pagemedia.res_min_height: + type: number + size: x-small + append: pixels + label: Resolution Min Height + default: 0 + validate: + type: number + help: The minimum height allowed for an image to be added + + pagemedia.res_max_width: + type: number + size: x-small + append: pixels + label: Resolution Max Width + default: 0 + validate: + type: number + help: The maximum width allowed for an image to be added + + pagemedia.res_max_height: + type: number + size: x-small + append: pixels + label: Resolution Max Height + default: 0 + validate: + type: number + help: The maximum height allowed for an image to be added + + + pagemedia.resize_quality: + type: number + size: x-small + append: 0...1 + label: Resize Quality + default: 0.8 + validate: + type: number + step: 0.01 + help: The quality to use when resizing an image. Between 0 and 1 value. + + Dashboard: + type: section + title: Dashboard + underline: true + + widgets.dashboard-maintenance: + type: toggle + label: Maintenance Widget + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Display dashboard maintenance widget + + widgets.dashboard-statistics: + type: toggle + label: Statistics Widget + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Display dashboard statistics widget + + widgets.dashboard-notifications: + type: toggle + label: Notifications Feed Widget + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Display dashboard notifications feed widget + + widgets.dashboard-feed: + type: toggle + label: News Feed Widget + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Display dashboard news feed widget + + widgets.dashboard-pages: + type: toggle + label: Latest Pages Widget + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Display dashboard latest pages widget + + Notifications: + type: section + title: Notifications + underline: true + + notifications.feed: + type: toggle + label: Feed Notifications + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Display feed-based notifications + + notifications.dashboard: + type: toggle + label: Dashboard Notifications + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Display dashboard-based notifications + + notifications.plugins: + type: toggle + label: Plugins Notifications + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Display plugins-targeted notifications + + notifications.themes: + type: toggle + label: Themes Notifications + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Display themes-targeted notifications + + Popularity: + type: section + title: Popularity + underline: true + + popularity.enabled: + type: toggle + label: Visitor tracking + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + help: Enable the visitors stats collecting feature + + dashboard.days_of_stats: + type: text + label: Days of stats + append: days + size: x-small + default: 7 + help: Keep stats for the specified number of days, then drop them + validate: + type: int + + popularity.ignore: + type: array + label: Ignore + size: large + help: "URLs to ignore" + default: ['/test*','/modular'] + value_only: true + placeholder_value: /ignore-this-route + + popularity.history.daily: + type: hidden + label: Daily history + default: 30 + + popularity.history.monthly: + type: hidden + label: Monthly history + default: 12 + + popularity.history.visitors: + type: hidden + label: Visitors history + default: 20 diff --git a/user/plugins/admin/blueprints/admin/pages/modular_new.yaml b/user/plugins/admin/blueprints/admin/pages/modular_new.yaml new file mode 100644 index 0000000..85aa7b0 --- /dev/null +++ b/user/plugins/admin/blueprints/admin/pages/modular_new.yaml @@ -0,0 +1,52 @@ +rules: + slug: + pattern: '[a-zA-Zа-яA-Я0-9_\-]+' + min: 1 + max: 200 + +form: + validation: loose + fields: + + section: + type: section + title: PLUGIN_ADMIN.ADD_MODULAR_CONTENT + + title: + type: text + label: PLUGIN_ADMIN.PAGE_TITLE + validate: + required: true + + folder: + type: text + label: PLUGIN_ADMIN.FOLDER_NAME + validate: + rule: slug + required: true + + route: + type: parents + label: PLUGIN_ADMIN.PAGE + classes: fancy + validate: + required: true + + name: + type: select + classes: fancy + label: PLUGIN_ADMIN.MODULAR_TEMPLATE + help: PLUGIN_ADMIN.PAGE_FILE_HELP + default: default + data-options@: '\Grav\Plugin\AdminPlugin::pagesModularTypes' + validate: + required: true + + modular: + type: hidden + default: 1 + validate: + type: bool + + blueprint: + type: blueprint diff --git a/user/plugins/admin/blueprints/admin/pages/modular_raw.yaml b/user/plugins/admin/blueprints/admin/pages/modular_raw.yaml new file mode 100644 index 0000000..abd2193 --- /dev/null +++ b/user/plugins/admin/blueprints/admin/pages/modular_raw.yaml @@ -0,0 +1,101 @@ +rules: + slug: + pattern: '[a-zA-Zа-яA-Я0-9_\-]+' + min: 1 + max: 200 + +form: + validation: loose + fields: + + tabs: + type: tabs + active: 1 + + fields: + content: + type: tab + title: PLUGIN_ADMIN.CONTENT + + fields: + frontmatter: + classes: frontmatter + type: editor + label: PLUGIN_ADMIN.FRONTMATTER + autofocus: true + codemirror: + mode: 'yaml' + indentUnit: 4 + autofocus: true + indentWithTabs: false + lineNumbers: true + styleActiveLine: true + gutters: ['CodeMirror-lint-markers'] + lint: true + + content: + type: markdown + + header.media_order: + type: pagemedia + label: PLUGIN_ADMIN.PAGE_MEDIA + + options: + type: tab + title: PLUGIN_ADMIN.OPTIONS + + fields: + + columns: + type: columns + + fields: + column1: + type: column + + fields: + + ordering: + type: toggle + label: PLUGIN_ADMIN.FOLDER_NUMERIC_PREFIX + help: PLUGIN_ADMIN.FOLDER_NUMERIC_PREFIX_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + folder: + type: text + label: PLUGIN_ADMIN.FILENAME + validate: + rule: slug + required: true + + route: + type: parents + label: PLUGIN_ADMIN.PARENT + classes: fancy + validate: + required: true + + name: + type: select + classes: fancy + label: PLUGIN_ADMIN.MODULAR_TEMPLATE + default: default + data-options@: '\Grav\Plugin\AdminPlugin::pagesModularTypes' + validate: + required: true + + column2: + type: column + + fields: + order: + type: order + label: PLUGIN_ADMIN.ORDERING + + blueprint: + type: blueprint diff --git a/user/plugins/admin/blueprints/admin/pages/move.yaml b/user/plugins/admin/blueprints/admin/pages/move.yaml new file mode 100644 index 0000000..89fa414 --- /dev/null +++ b/user/plugins/admin/blueprints/admin/pages/move.yaml @@ -0,0 +1,7 @@ +form: + validation: loose + fields: + route: + type: parents + label: PLUGIN_ADMIN.PARENT + classes: fancy diff --git a/user/plugins/admin/blueprints/admin/pages/new.yaml b/user/plugins/admin/blueprints/admin/pages/new.yaml new file mode 100644 index 0000000..d62c6ae --- /dev/null +++ b/user/plugins/admin/blueprints/admin/pages/new.yaml @@ -0,0 +1,62 @@ +rules: + slug: + pattern: '[a-zA-Zа-яA-Я0-9_\-]+' + min: 1 + max: 200 + +form: + validation: loose + fields: + + section: + type: section + title: PLUGIN_ADMIN.ADD_PAGE + + title: + type: text + label: PLUGIN_ADMIN.PAGE_TITLE + help: PLUGIN_ADMIN.PAGE_TITLE_HELP + validate: + required: true + + folder: + type: text + label: PLUGIN_ADMIN.FOLDER_NAME + help: PLUGIN_ADMIN.FOLDER_NAME_HELP + validate: + rule: slug + required: true + + route: + type: parents + label: PLUGIN_ADMIN.PARENT_PAGE + classes: fancy + validate: + required: true + + name: + type: select + classes: fancy + label: PLUGIN_ADMIN.PAGE_FILE + help: PLUGIN_ADMIN.PAGE_FILE_HELP + data-options@: '\Grav\Plugin\AdminPlugin::pagesTypes' + data-default@: '\Grav\Plugin\Admin\Admin::getLastPageName' + validate: + required: true + + visible: + type: toggle + label: PLUGIN_ADMIN.VISIBLE + help: PLUGIN_ADMIN.VISIBLE_HELP + highlight: '' + default: '' + options: + '': Auto + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + required: true + + blueprint: + type: blueprint diff --git a/user/plugins/admin/blueprints/admin/pages/new_folder.yaml b/user/plugins/admin/blueprints/admin/pages/new_folder.yaml new file mode 100644 index 0000000..9db58f2 --- /dev/null +++ b/user/plugins/admin/blueprints/admin/pages/new_folder.yaml @@ -0,0 +1,31 @@ +rules: + slug: + pattern: '[a-zA-Zа-яA-Я0-9_\-]+' + min: 1 + max: 200 + +form: + validation: loose + fields: + + section: + type: section + title: PLUGIN_ADMIN.ADD_FOLDER + + folder: + type: text + label: PLUGIN_ADMIN.FOLDER_NAME + help: PLUGIN_ADMIN.FOLDER_NAME_HELP + validate: + rule: slug + required: true + + route: + type: parents + label: PLUGIN_ADMIN.PARENT_PAGE + classes: fancy + validate: + required: true + + blueprint: + type: blueprint diff --git a/user/plugins/admin/blueprints/admin/pages/raw.yaml b/user/plugins/admin/blueprints/admin/pages/raw.yaml new file mode 100644 index 0000000..788e9e2 --- /dev/null +++ b/user/plugins/admin/blueprints/admin/pages/raw.yaml @@ -0,0 +1,106 @@ +rules: + slug: + pattern: '[a-zA-Zа-яA-Я0-9_\-]+' + min: 1 + max: 200 + +form: + validation: loose + fields: + + tabs: + type: tabs + active: 1 + + fields: + content: + type: tab + title: PLUGIN_ADMIN.CONTENT + + fields: + xss_check: + type: xss + + frontmatter: + classes: frontmatter + type: editor + label: PLUGIN_ADMIN.FRONTMATTER + autofocus: true + codemirror: + mode: 'yaml' + indentUnit: 4 + autofocus: true + indentWithTabs: false + lineNumbers: true + styleActiveLine: true + gutters: ['CodeMirror-lint-markers'] + lint: true + + content: + type: markdown + + header.media_order: + type: pagemedia + label: PLUGIN_ADMIN.PAGE_MEDIA + + options: + type: tab + title: PLUGIN_ADMIN.OPTIONS + + fields: + + columns: + type: columns + + fields: + column1: + type: column + + fields: + + ordering: + type: toggle + label: PLUGIN_ADMIN.FOLDER_NUMERIC_PREFIX + help: PLUGIN_ADMIN.FOLDER_NUMERIC_PREFIX_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + folder: + type: text + label: PLUGIN_ADMIN.FOLDER_NAME + help: PLUGIN_ADMIN.FOLDER_NAME_HELP + validate: + rule: slug + required: true + + route: + type: parents + label: PLUGIN_ADMIN.PARENT + classes: fancy + validate: + required: true + + name: + type: select + classes: fancy + label: PLUGIN_ADMIN.DISPLAY_TEMPLATE + help: PLUGIN_ADMIN.DISPLAY_TEMPLATE_HELP + default: default + data-options@: '\Grav\Plugin\AdminPlugin::pagesTypes' + validate: + required: true + + column2: + type: column + + fields: + order: + type: order + label: PLUGIN_ADMIN.ORDERING + + blueprint: + type: blueprint diff --git a/user/plugins/admin/blueprints/config/media.yaml b/user/plugins/admin/blueprints/config/media.yaml new file mode 100644 index 0000000..ba55868 --- /dev/null +++ b/user/plugins/admin/blueprints/config/media.yaml @@ -0,0 +1,36 @@ +title: PLUGIN_ADMIN.MEDIA +form: + validation: loose + fields: + 'types': + name: medias + type: list + label: PLUGIN_ADMIN.MEDIA_TYPES + style: vertical + key: extension + controls: both + collapsed: true + + fields: + .extension: + type: key + label: PLUGIN_ADMIN.FILE_EXTENSION + .type: + type: text + label: PLUGIN_ADMIN.TYPE + .thumb: + type: text + label: PLUGIN_ADMIN.THUMB + .mime: + type: text + label: PLUGIN_ADMIN.MIME_TYPE + validate: + type: lower + .image: + type: textarea + yaml: true + label: PLUGIN_ADMIN.IMAGE_OPTIONS + validate: + type: yaml + + diff --git a/user/plugins/admin/classes/Twig/AdminTwigExtension.php b/user/plugins/admin/classes/Twig/AdminTwigExtension.php new file mode 100644 index 0000000..073aef2 --- /dev/null +++ b/user/plugins/admin/classes/Twig/AdminTwigExtension.php @@ -0,0 +1,118 @@ +grav = Grav::instance(); + $this->lang = $this->grav['user']->language; + } + + public function getFilters() + { + return [ + new \Twig_SimpleFilter('tu', [$this, 'tuFilter']), + new \Twig_SimpleFilter('toYaml', [$this, 'toYamlFilter']), + new \Twig_SimpleFilter('fromYaml', [$this, 'fromYamlFilter']), + new \Twig_SimpleFilter('adminNicetime', [$this, 'adminNicetimeFilter']), + new \Twig_SimpleFilter('nested', [$this, 'nestedFilter']), + ]; + } + + public function getFunctions() + { + return [ + new \Twig_SimpleFunction('getPageUrl', [$this, 'getPageUrl'], ['needs_context' => true]), + new \Twig_SimpleFunction('clone', [$this, 'cloneFunc']), + ]; + } + + public function nestedFilter($current, $name) + { + $path = explode('.', trim($name, '.')); + + 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; + } + + public function cloneFunc($obj) + { + return clone $obj; + } + + public function getPageUrl($context, PageInterface $page) + { + $page_route = trim($page->rawRoute(), '/'); + $page_lang = $page->language(); + $base_url = $context['base_url']; + $base_url_simple = $context['base_url_simple']; + $admin_lang = Grav::instance()['session']->admin_lang ?: 'en'; + + if ($page_lang && $page_lang !== $admin_lang) { + $page_url = $base_url_simple . '/' . $page_lang . '/' . $context['admin_route'] . '/pages/' . $page_route; + } else { + $page_url = $base_url . '/pages/' . $page_route; + } + + return $page_url; + } + + public static function tuFilter() + { + $args = func_get_args(); + $numargs = count($args); + $lang = null; + + if (($numargs === 3 && is_array($args[1])) || ($numargs === 2 && !is_array($args[1]))) { + $lang = array_pop($args); + } elseif ($numargs === 2 && is_array($args[1])) { + $subs = array_pop($args); + $args = array_merge($args, $subs); + } + + return Grav::instance()['admin']->translate($args, $lang); + } + + public function toYamlFilter($value, $inline = null) + { + return Yaml::dump($value, $inline); + + } + + public function fromYamlFilter($value) + { + return Yaml::parse($value); + } + + public function adminNicetimeFilter($date, $long_strings = true) + { + return Grav::instance()['admin']->adminNiceTime($date, $long_strings); + } + +} diff --git a/user/plugins/admin/classes/admin.php b/user/plugins/admin/classes/admin.php new file mode 100644 index 0000000..7e746f5 --- /dev/null +++ b/user/plugins/admin/classes/admin.php @@ -0,0 +1,1971 @@ +grav = $grav; + $this->base = $base; + $this->location = $location; + $this->route = $route; + $this->uri = $grav['uri']; + $this->session = $grav['session']; + $this->user = $grav['user']; + $this->permissions = []; + $language = $grav['language']; + + // Load utility class + if ($language->enabled()) { + $this->multilang = true; + $this->languages_enabled = (array)$this->grav['config']->get('system.languages.supported', []); + + //Set the currently active language for the admin + $language = $this->grav['uri']->param('lang'); + if (!$language) { + if (!$this->session->admin_lang) { + $this->session->admin_lang = $this->grav['language']->getLanguage(); + } + $language = $this->session->admin_lang; + } + $this->grav['language']->setActive($language ?: 'en'); + } else { + $this->grav['language']->setActive('en'); + $this->multilang = false; + } + + } + + /** + * Return the languages available in the admin + * + * @return array + */ + public static function adminLanguages() + { + $languages = []; + + $path = Grav::instance()['locator']->findResource('plugins://admin/languages'); + + /** @var \DirectoryIterator $directory */ + foreach (new \DirectoryIterator($path) as $file) { + if ($file->isDir() || $file->isDot() || Utils::startsWith($file->getFilename(), '.')) { + continue; + } + + $lang = $file->getBasename('.yaml'); + + $languages[$lang] = LanguageCodes::getNativeName($lang); + + } + + // sort languages + asort($languages); + + return $languages; + } + + /** + * Return the found configuration blueprints + * + * @return array + */ + public static function configurations() + { + $configurations = []; + + /** @var UniformResourceIterator $iterator */ + $iterator = Grav::instance()['locator']->getIterator('blueprints://config'); + + foreach ($iterator as $file) { + if ($file->isDir() || !preg_match('/^[^.].*.yaml$/', $file->getFilename())) { + continue; + } + $configurations[] = $file->getBasename('.yaml'); + } + + return $configurations; + } + + /** + * Return the tools found + * + * @return array + */ + public static function tools() + { + $tools = []; + Grav::instance()->fireEvent('onAdminTools', new Event(['tools' => &$tools])); + + return $tools; + } + + public static function toolsPermissions() + { + $tools = static::tools(); + $perms = []; + + foreach ($tools as $tool) { + $perms = array_merge($perms, $tool[0]); + } + + return array_unique($perms); + } + + /** + * Return the languages available in the site + * + * @return array + */ + public static function siteLanguages() + { + $languages = []; + $lang_data = (array) Grav::instance()['config']->get('system.languages.supported', []); + + foreach ($lang_data as $index => $lang) { + $languages[$lang] = LanguageCodes::getNativeName($lang); + } + + return $languages; + } + + /** + * Static helper method to return the admin form nonce + * + * @return string + */ + public static function getNonce() + { + $action = 'admin-form'; + + return Utils::getNonce($action); + } + + /** + * Static helper method to return the last used page name + * + * @return string + */ + public static function getLastPageName() + { + return Grav::instance()['session']->lastPageName ?: 'default'; + } + + /** + * Static helper method to return the last used page route + * + * @return string + */ + public static function getLastPageRoute() + { + return Grav::instance()['session']->lastPageRoute ?: self::route(); + } + + /** + * Static helper method to return current route. + * + * @return string + */ + public static function route() + { + $pages = Grav::instance()['pages']; + $route = '/' . ltrim(Grav::instance()['admin']->route, '/'); + + /** @var PageInterface $page */ + $page = $pages->dispatch($route); + $parent_route = null; + if ($page) { + /** @var PageInterface $parent */ + $parent = $page->parent(); + $parent_route = $parent->rawRoute(); + } + + return $parent_route; + } + + public static function getTempDir() + { + try { + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); + } catch (\Exception $e) { + $tmp_dir = Grav::instance()['locator']->findResource('cache://', true, true) . '/tmp'; + } + + return $tmp_dir; + } + + public static function getPageMedia() + { + $files = []; + $grav = Grav::instance(); + + $pages = $grav['pages']; + $route = '/' . ltrim($grav['admin']->route, '/'); + + /** @var PageInterface $page */ + $page = $pages->dispatch($route); + $parent_route = null; + if ($page) { + $media = $page->media()->all(); + $files = array_keys($media); + } + return $files; + + } + + /** + * Get current session. + * + * @return Session + */ + public function session() + { + return $this->session; + } + + /** + * Fetch and delete messages from the session queue. + * + * @param string $type + * + * @return array + */ + public function messages($type = null) + { + /** @var Message $messages */ + $messages = $this->grav['messages']; + + return $messages->fetch($type); + } + + /** + * Authenticate user. + * + * @param array $credentials User credentials. + */ + public function authenticate($credentials, $post) + { + /** @var Login $login */ + $login = $this->grav['login']; + + // Remove login nonce from the form. + $credentials = array_diff_key($credentials, ['admin-nonce' => true]); + $twofa = $this->grav['config']->get('plugins.admin.twofa_enabled', false); + + $rateLimiter = $login->getRateLimiter('login_attempts'); + + $userKey = (string)($credentials['username'] ?? ''); + $ipKey = Uri::ip(); + $redirect = $post['redirect'] ?? $this->base . $this->route; + + // Pseudonymization of the IP + $ipKey = sha1($ipKey . $this->grav['config']->get('security.salt')); + + // Check if the current IP has been used in failed login attempts. + $attempts = count($rateLimiter->getAttempts($ipKey, 'ip')); + + $rateLimiter->registerRateLimitedAction($ipKey, 'ip')->registerRateLimitedAction($userKey); + + // Check rate limit for both IP and user, but allow each IP a single try even if user is already rate limited. + if ($rateLimiter->isRateLimited($ipKey, 'ip') || ($attempts && $rateLimiter->isRateLimited($userKey))) { + $this->setMessage(static::translate(['PLUGIN_LOGIN.TOO_MANY_LOGIN_ATTEMPTS', $rateLimiter->getInterval()]), 'error'); + + $this->grav->redirect('/'); + } + + // Fire Login process. + $event = $login->login( + $credentials, + ['admin' => true, 'twofa' => $twofa], + ['authorize' => 'admin.login', 'return_event' => true] + ); + $user = $event->getUser(); + + if ($user->authenticated) { + $rateLimiter->resetRateLimit($ipKey, 'ip')->resetRateLimit($userKey); + if ($user->authorized) { + $event->defMessage('PLUGIN_ADMIN.LOGIN_LOGGED_IN', 'info'); + + $event->defRedirect($post['redirect'] ?? $redirect); + } else { + $this->session->redirect = $redirect; + } + } else { + if ($user->authorized) { + $event->defMessage('PLUGIN_LOGIN.ACCESS_DENIED', 'error'); + } else { + $event->defMessage('PLUGIN_LOGIN.LOGIN_FAILED', 'error'); + } + } + + $event->defRedirect($redirect); + + $message = $event->getMessage(); + if ($message) { + $this->setMessage(static::translate($message), $event->getMessageType()); + } + + $redirect = $event->getRedirect(); + + $this->grav->redirect($redirect, $event->getRedirectCode()); + } + + /** + * Check Two-Factor Authentication. + */ + public function twoFa($data, $post) + { + /** @var Login $login */ + $login = $this->grav['login']; + + /** @var TwoFactorAuth $twoFa */ + $twoFa = $login->twoFactorAuth(); + $user = $this->grav['user']; + + $code = $data['2fa_code'] ?? null; + + $secret = $user->twofa_secret ?? null; + + if (!$code || !$secret || !$twoFa->verifyCode($secret, $code)) { + $login->logout(['admin' => true]); + + $this->grav['session']->setFlashCookieObject(Admin::TMP_COOKIE_NAME, ['message' => $this->translate('PLUGIN_ADMIN.2FA_FAILED'), 'status' => 'error']); + + $this->grav->redirect($this->uri->route(), 303); + } + + $this->setMessage($this->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN'), 'info'); + + $user->authorized = true; + + $this->grav->redirect($post['redirect']); + } + + /** + * Logout from admin. + */ + public function logout($data, $post) + { + /** @var Login $login */ + $login = $this->grav['login']; + + $event = $login->logout(['admin' => true], ['return_event' => true]); + + $event->defMessage('PLUGIN_ADMIN.LOGGED_OUT', 'info'); + $message = $event->getMessage(); + if ($message) { + $this->grav['session']->setFlashCookieObject(Admin::TMP_COOKIE_NAME, ['message' => $this->translate($message), 'status' => $event->getMessageType()]); + } + + $this->grav->redirect($this->base); + } + + /** + * @return bool + */ + public static function doAnyUsersExist() + { + $accounts = Grav::instance()['accounts'] ?? null; + if ($accounts instanceof \Countable) { + return $accounts->count() > 0; + } + + // TODO: remove old way to check for existence of a user account (Grav < v1.6.9) + $account_dir = $file_path = Grav::instance()['locator']->findResource('account://'); + $user_check = glob($account_dir . '/*.yaml'); + + return $user_check; + } + + /** + * Add message into the session queue. + * + * @param string $msg + * @param string $type + */ + public function setMessage($msg, $type = 'info') + { + /** @var Message $messages */ + $messages = $this->grav['messages']; + $messages->add($msg, $type); + } + + public function addTempMessage($msg, $type) + { + $this->temp_messages[] = ['message' => $msg, 'scope' => $type]; + } + + public function getTempMessages() + { + return $this->temp_messages; + } + + /** + * Translate a string to the user-defined language + * + * @param array|mixed $args + * + * @param mixed $languages + * + * @return string + */ + public static function translate($args, $languages = null) + { + $grav = Grav::instance(); + + if (is_array($args)) { + $lookup = array_shift($args); + } else { + $lookup = $args; + $args = []; + } + + if (!$languages) { + if ($grav['config']->get('system.languages.translations_fallback', true)) { + $languages = $grav['language']->getFallbackLanguages(); + } else { + $languages = (array)$grav['language']->getDefault(); + } + $languages = $grav['user']->authenticated ? [ $grav['user']->language ] : $languages; + } else { + $languages = (array)$languages; + } + + foreach ((array)$languages as $lang) { + $translation = $grav['language']->getTranslation($lang, $lookup, true); + + if (!$translation) { + $language = $grav['language']->getDefault() ?: 'en'; + $translation = $grav['language']->getTranslation($language, $lookup, true); + } + + if (!$translation) { + $language = 'en'; + $translation = $grav['language']->getTranslation($language, $lookup, true); + } + + if ($translation) { + if (count($args) >= 1) { + return vsprintf($translation, $args); + } + + return $translation; + } + } + + return $lookup; + } + + /** + * Checks user authorisation to the action. + * + * @param string|string[] $action + * + * @return bool + */ + public function authorize($action = 'admin.login') + { + $action = (array)$action; + + foreach ($action as $a) { + if ($this->user->authorize($a)) { + return true; + } + } + + return false; + } + + /** + * Gets configuration data. + * + * @param string $type + * @param array $post + * + * @return mixed + * @throws \RuntimeException + */ + public function data($type, array $post = []) + { + static $data = []; + + if (isset($data[$type])) { + return $data[$type]; + } + + if (!$post) { + $post = $this->grav['uri']->post(); + $post = $post['data'] ?? []; + } + + // Check to see if a data type is plugin-provided, before looking into core ones + $event = $this->grav->fireEvent('onAdminData', new Event(['type' => &$type])); + if ($event) { + if (isset($event['data_type'])) { + return $event['data_type']; + } + + if (is_string($event['type'])) { + $type = $event['type']; + } + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $filename = $locator->findResource("config://{$type}.yaml", true, true); + $file = CompiledYamlFile::instance($filename); + + if (preg_match('|plugins/|', $type)) { + /** @var Plugins $plugins */ + $plugins = $this->grav['plugins']; + $obj = $plugins->get(preg_replace('|plugins/|', '', $type)); + + if (!$obj) { + return []; + } + + $obj->merge($post); + $obj->file($file); + + $data[$type] = $obj; + } elseif (preg_match('|themes/|', $type)) { + /** @var Themes $themes */ + $themes = $this->grav['themes']; + $obj = $themes->get(preg_replace('|themes/|', '', $type)); + + if (!$obj) { + return []; + } + + $obj->merge($post); + $obj->file($file); + + $data[$type] = $obj; + } elseif (preg_match('|users?/|', $type)) { + /** @var UserCollectionInterface $users */ + $users = $this->grav['accounts']; + + $obj = $users->load(preg_replace('|users?/|', '', $type)); + $obj->update($this->cleanUserPost($post)); + + $data[$type] = $obj; + } elseif (preg_match('|config/|', $type)) { + $type = preg_replace('|config/|', '', $type); + $blueprints = $this->blueprints("config/{$type}"); + $config = $this->grav['config']; + $obj = new Data\Data($config->get($type, []), $blueprints); + $obj->merge($post); + + // FIXME: We shouldn't allow user to change configuration files in system folder! + $filename = $this->grav['locator']->findResource("config://{$type}.yaml") + ?: $this->grav['locator']->findResource("config://{$type}.yaml", true, true); + $file = CompiledYamlFile::instance($filename); + $obj->file($file); + $data[$type] = $obj; + } elseif (preg_match('|media-manager/|', $type)) { + $filename = base64_decode(preg_replace('|media-manager/|', '', $type)); + + $file = File::instance($filename); + + $obj = new \stdClass(); + $obj->title = $file->basename(); + $obj->path = $file->filename(); + $obj->file = $file; + $obj->page = $this->grav['pages']->get(dirname($obj->path)); + + $fileInfo = pathinfo($obj->title); + $filename = str_replace(['@3x', '@2x'], '', $fileInfo['filename']); + if (isset($fileInfo['extension'])) { + $filename .= '.' . $fileInfo['extension']; + } + + if ($obj->page && isset($obj->page->media()[$filename])) { + $obj->metadata = new Data\Data($obj->page->media()[$filename]->metadata()); + } + + $data[$type] = $obj; + } else { + throw new \RuntimeException("Data type '{$type}' doesn't exist!"); + } + + return $data[$type]; + } + + /** + * Clean user form post and remove extra stuff that may be passed along + * + * @param array $post + * @return array + */ + public function cleanUserPost($post) + { + // Clean fields for all users + unset($post['hashed_password']); + + // Clean field for users who shouldn't be able to modify these fields + if (!$this->authorize(['admin.user', 'admin.super'])) { + unset($post['access'], $post['state']); + } + + return $post; + } + + protected function hasErrorMessage() + { + $msgs = $this->grav['messages']->all(); + foreach ($msgs as $msg) { + if (isset($msg['scope']) && $msg['scope'] === 'error') { + return true; + } + } + return false; + } + + /** + * Returns blueprints for the given type. + * + * @param string $type + * + * @return Data\Blueprint + */ + public function blueprints($type) + { + if ($this->blueprints === null) { + $this->blueprints = new Data\Blueprints('blueprints://'); + } + + return $this->blueprints->get($type); + } + + /** + * Converts dot notation to array notation. + * + * @param string $name + * + * @return string + */ + public function field($name) + { + $path = explode('.', $name); + + return array_shift($path) . ($path ? '[' . implode('][', $path) . ']' : ''); + } + + /** + * Get all routes. + * + * @param bool $unique + * + * @return array + */ + public function routes($unique = false) + { + /** @var Pages $pages */ + $pages = $this->grav['pages']; + + if ($unique) { + $routes = array_unique($pages->routes()); + } else { + $routes = $pages->routes(); + } + + return $routes; + } + + /** + * Count the pages + * + * @return int + */ + public function pagesCount() + { + if (!$this->pages_count) { + $this->pages_count = count($this->grav['pages']->all()); + } + + return $this->pages_count; + } + + /** + * Get all template types + * + * @return array + */ + public function types() + { + return Pages::types(); + } + + /** + * Get all modular template types + * + * @return array + */ + public function modularTypes() + { + return Pages::modularTypes(); + } + + /** + * Get all access levels + * + * @return array + */ + public function accessLevels() + { + if (method_exists($this->grav['pages'], 'accessLevels')) { + return $this->grav['pages']->accessLevels(); + } + + return []; + } + + public function license($package_slug) + { + return Licenses::get($package_slug); + } + + /** + * Generate an array of dependencies for a package, used to generate a list of + * packages that can be removed when removing a package. + * + * @param string $slug The package slug + * + * @return array|bool + */ + public function dependenciesThatCanBeRemovedWhenRemoving($slug) + { + $gpm = $this->gpm(); + if (!$gpm) { + return false; + } + + $dependencies = []; + + $package = $this->getPackageFromGPM($slug); + + if ($package) { + if ($package->dependencies) { + foreach ($package->dependencies as $dependency) { +// if (count($gpm->getPackagesThatDependOnPackage($dependency)) > 1) { +// continue; +// } + if (isset($dependency['name'])) { + $dependency = $dependency['name']; + } + + if (!in_array($dependency, $dependencies, true)) { + if (!in_array($dependency, ['admin', 'form', 'login', 'email', 'php'])) { + $dependencies[] = $dependency; + } + } + } + } + } + + return $dependencies; + } + + /** + * Get the GPM instance + * + * @return GPM The GPM instance + */ + public function gpm() + { + if (!$this->gpm) { + try { + $this->gpm = new GPM(); + } catch (\Exception $e) { + $this->setMessage($e->getMessage(), 'error'); + } + } + + return $this->gpm; + } + + public function getPackageFromGPM($package_slug) + { + $package = $this->plugins(true)[$package_slug]; + if (!$package) { + $package = $this->themes(true)[$package_slug]; + } + + return $package; + } + + /** + * Get all plugins. + * + * @param bool $local + * + * @return mixed + */ + public function plugins($local = true) + { + $gpm = $this->gpm(); + + if (!$gpm) { + return false; + } + + if ($local) { + return $gpm->getInstalledPlugins(); + } + + $plugins = $gpm->getRepositoryPlugins(); + if ($plugins) { + return $plugins->filter(function ($package, $slug) use ($gpm) { + return !$gpm->isPluginInstalled($slug); + }); + } + + return []; + } + + /** + * Get all themes. + * + * @param bool $local + * + * @return mixed + */ + public function themes($local = true) + { + $gpm = $this->gpm(); + + if (!$gpm) { + return false; + } + + if ($local) { + return $gpm->getInstalledThemes(); + } + + $themes = $gpm->getRepositoryThemes(); + if ($themes) { + return $themes->filter(function ($package, $slug) use ($gpm) { + return !$gpm->isThemeInstalled($slug); + }); + } + + return []; + } + + /** + * Get list of packages that depend on the passed package slug + * + * @param string $slug The package slug + * + * @return array|bool + */ + public function getPackagesThatDependOnPackage($slug) + { + $gpm = $this->gpm(); + if (!$gpm) { + return false; + } + + return $gpm->getPackagesThatDependOnPackage($slug); + } + + /** + * Check the passed packages list can be updated + * + * @param array $packages + * + * @throws \Exception + * @return bool + */ + public function checkPackagesCanBeInstalled($packages) + { + $gpm = $this->gpm(); + if (!$gpm) { + return false; + } + + $this->gpm->checkPackagesCanBeInstalled($packages); + + return true; + } + + /** + * Get an array of dependencies needed to be installed or updated for a list of packages + * to be installed. + * + * @param array $packages The packages slugs + * + * @return array|bool + */ + public function getDependenciesNeededToInstall($packages) + { + $gpm = $this->gpm(); + if (!$gpm) { + return false; + } + + return $this->gpm->getDependencies($packages); + } + + /** + * Used by the Dashboard in the admin to display the X latest pages + * that have been modified + * + * @param integer $count number of pages to pull back + * + * @return array|null + */ + public function latestPages($count = 10) + { + /** @var Pages $pages */ + $pages = $this->grav['pages']; + + $latest = []; + + if (null === $pages->routes()) { + return null; + } + + foreach ($pages->routes() as $url => $path) { + $page = $pages->dispatch($url, true); + if ($page && $page->routable()) { + $latest[$page->route()] = ['modified' => $page->modified(), 'page' => $page]; + } + } + + // sort based on modified + uasort($latest, function ($a, $b) { + if ($a['modified'] == $b['modified']) { + return 0; + } + + return ($a['modified'] > $b['modified']) ? -1 : 1; + }); + + // build new array with just pages in it + $list = []; + foreach ($latest as $item) { + $list[] = $item['page']; + } + + return array_slice($list, 0, $count); + } + + /** + * Get log file for fatal errors. + * + * @return string + */ + public function logEntry() + { + $file = File::instance($this->grav['locator']->findResource("log://{$this->route}.html")); + $content = $file->content(); + $file->free(); + + return $content; + } + + /** + * Search in the logs when was the latest backup made + * + * @return array Array containing the latest backup information + */ + public function lastBackup() + { + $file = JsonFile::instance($this->grav['locator']->findResource('log://backup.log')); + $content = $file->content(); + if (empty($content)) { + return [ + 'days' => '∞', + 'chart_fill' => 100, + 'chart_empty' => 0 + ]; + } + + $backup = new \DateTime(); + $backup->setTimestamp($content['time']); + $diff = $backup->diff(new \DateTime()); + + $days = $diff->days; + $chart_fill = $days > 30 ? 100 : round($days / 30 * 100); + + return [ + 'days' => $days, + 'chart_fill' => $chart_fill, + 'chart_empty' => 100 - $chart_fill + ]; + } + + /** + * Determine if the plugin or theme info passed is from Team Grav + * + * @param object $info Plugin or Theme info object + * + * @return bool + */ + public function isTeamGrav($info) + { + return isset($info['author']['name']) && ($info['author']['name'] === 'Team Grav' || Utils::contains($info['author']['name'], 'Trilby Media')); + } + + /** + * Determine if the plugin or theme info passed is premium + * + * @param object $info Plugin or Theme info object + * + * @return bool + */ + public function isPremiumProduct($info) + { + return isset($info['premium']); + } + + /** + * Renders phpinfo + * + * @return string The phpinfo() output + */ + function phpinfo() + { + if (function_exists('phpinfo')) { + ob_start(); + phpinfo(); + $pinfo = ob_get_contents(); + ob_end_clean(); + + $pinfo = preg_replace('%^.*(.*).*$%ms', '$1', $pinfo); + + return $pinfo; + } + + return 'phpinfo() method is not available on this server.'; + } + + /** + * Guest date format based on euro/US + * + * @param string $date + * + * @return string + */ + public function guessDateFormat($date) + { + static $guess; + + $date_formats = [ + 'm/d/y', + 'm/d/Y', + 'n/d/y', + 'n/d/Y', + 'd-m-Y', + 'd-m-y', + ]; + + $time_formats = [ + 'H:i', + 'G:i', + 'h:ia', + 'g:ia' + ]; + + if (!isset($guess[$date])) { + foreach ($date_formats as $date_format) { + foreach ($time_formats as $time_format) { + if ($this->validateDate($date, "$date_format $time_format")) { + $guess[$date] = "$date_format $time_format"; + break 2; + } + if ($this->validateDate($date, "$time_format $date_format")) { + $guess[$date] = "$time_format $date_format"; + break 2; + } + } + } + + if (!isset($guess[$date])) { + $guess[$date] = 'd-m-Y H:i'; + } + } + + return $guess[$date]; + } + + public function validateDate($date, $format) + { + $d = DateTime::createFromFormat($format, $date); + + return $d && $d->format($format) == $date; + } + + /** + * @param string $php_format + * + * @return string + */ + public function dateformatToMomentJS($php_format) + { + $SYMBOLS_MATCHING = [ + // Day + 'd' => 'DD', + 'D' => 'ddd', + 'j' => 'D', + 'l' => 'dddd', + 'N' => 'E', + 'S' => 'Do', + 'w' => 'd', + 'z' => 'DDD', + // Week + 'W' => 'W', + // Month + 'F' => 'MMMM', + 'm' => 'MM', + 'M' => 'MMM', + 'n' => 'M', + 't' => '', + // Year + 'L' => '', + 'o' => 'GGGG', + 'Y' => 'YYYY', + 'y' => 'yy', + // Time + 'a' => 'a', + 'A' => 'A', + 'B' => 'SSS', + 'g' => 'h', + 'G' => 'H', + 'h' => 'hh', + 'H' => 'HH', + 'i' => 'mm', + 's' => 'ss', + 'u' => '', + // Timezone + 'e' => '', + 'I' => '', + 'O' => 'ZZ', + 'P' => 'Z', + 'T' => 'z', + 'Z' => '', + // Full Date/Time + 'c' => '', + 'r' => 'llll ZZ', + 'U' => 'X' + ]; + $js_format = ''; + $escaping = false; + $len = strlen($php_format); + for ($i = 0; $i < $len; $i++) { + $char = $php_format[$i]; + if ($char === '\\') // PHP date format escaping character + { + $i++; + if ($escaping) { + $js_format .= $php_format[$i]; + } else { + $js_format .= '\'' . $php_format[$i]; + } + $escaping = true; + } else { + if ($escaping) { + $js_format .= "'"; + $escaping = false; + } + if (isset($SYMBOLS_MATCHING[$char])) { + $js_format .= $SYMBOLS_MATCHING[$char]; + } else { + $js_format .= $char; + } + } + } + + return $js_format; + } + + /** + * Gets the entire permissions array + * + * @return array + */ + public function getPermissions() + { + return $this->permissions; + } + + /** + * Sets the entire permissions array + * + * @param array $permissions + */ + public function setPermissions($permissions) + { + $this->permissions = $permissions; + } + + /** + * Adds a permission to the permissions array + * + * @param array $permissions + */ + public function addPermissions($permissions) + { + $this->permissions = array_merge($this->permissions, $permissions); + } + + public function getNotifications($force = false) + { + $last_checked = null; + $filename = $this->grav['locator']->findResource('user://data/notifications/' . md5($this->grav['user']->username) . YAML_EXT, true, true); + + $notifications_file = CompiledYamlFile::instance($filename); + $notifications_content = (array)$notifications_file->content(); + + $last_checked = $notifications_content['last_checked'] ?? null; + $notifications = $notifications_content['data'] ?? array(); + $timeout = $this->grav['config']->get('system.session.timeout', 1800); + + if ($force || !$last_checked || empty($notifications) || (time() - $last_checked > $timeout)) { + $body = Response::get('https://getgrav.org/notifications.json?' . time()); +// $body = Response::get('http://localhost/notifications.json?' . time()); + $notifications = json_decode($body, true); + + // Sort by date + usort($notifications, function ($a, $b) { + return strcmp($a['date'], $b['date']); + }); + + // Reverse order and create a new array + $notifications = array_reverse($notifications); + $cleaned_notifications = []; + + foreach ($notifications as $key => $notification) { + + if (isset($notification['permissions']) && !$this->authorize($notification['permissions'])) { + continue; + } + + if (isset($notification['dependencies'])) { + foreach ($notification['dependencies'] as $dependency => $constraints) { + if ($dependency === 'grav') { + if (!Semver::satisfies(GRAV_VERSION, $constraints)) { + continue; + } + } else { + $packages = array_merge($this->plugins()->toArray(), $this->themes()->toArray()); + if (!isset($packages[$dependency])) { + continue; + } else { + $version = $packages[$dependency]['version']; + if (!Semver::satisfies($version, $constraints)) { + continue; + } + } + } + } + } + + $cleaned_notifications[] = $notification; + + } + + // reset notifications + $notifications = []; + + foreach($cleaned_notifications as $notification) { + foreach ($notification['location'] as $location) { + $notifications = array_merge_recursive($notifications, [$location => [$notification]]); + } + } + + + $notifications_file->content(['last_checked' => time(), 'data' => $notifications]); + $notifications_file->save(); + } + + + return $notifications; + } + + /** + * Get https://getgrav.org news feed + * + * @return mixed + * @throws MalformedXmlException + */ + public function getFeed($force = false) + { + $last_checked = null; + $filename = $this->grav['locator']->findResource('user://data/feed/' . md5($this->grav['user']->username) . YAML_EXT, true, true); + + $feed_file = CompiledYamlFile::instance($filename); + $feed_content = (array)$feed_file->content(); + + $last_checked = $feed_content['last_checked'] ?? null; + $feed = $feed_content['data'] ?? array(); + $timeout = $this->grav['config']->get('system.session.timeout', 1800); + + if ($force || !$last_checked || empty($feed) || ($last_checked && (time() - $last_checked > $timeout))) { + $feed_url = 'https://getgrav.org/blog.atom'; + $body = Response::get($feed_url); + + $reader = new Reader(); + $parser = $reader->getParser($feed_url, $body, 'utf-8'); + $data = $parser->execute()->getItems(); + + // Get top 10 + $data = array_slice($data, 0, 10); + + $feed = array_map(function ($entry) { + $simple_entry['title'] = $entry->getTitle(); + $simple_entry['url'] = $entry->getUrl(); + $simple_entry['date'] = $entry->getDate()->getTimestamp(); + $simple_entry['nicetime'] = $this->adminNiceTime($simple_entry['date']); + return $simple_entry; + }, $data); + + $feed_file->content(['last_checked' => time(), 'data' => $feed]); + $feed_file->save(); + } + + return $feed; + + } + + public function adminNiceTime($date, $long_strings = true) + { + if (empty($date)) { + return $this->translate('GRAV.NICETIME.NO_DATE_PROVIDED', null); + } + + 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->translate('GRAV.NICETIME.BAD_DATE', null); + } + + // is it future date or past date + if ($now > $unix_date) { + $difference = $now - $unix_date; + $tense = $this->translate('GRAV.NICETIME.AGO', null); + + } else { + $difference = $unix_date - $now; + $tense = $this->translate('GRAV.NICETIME.FROM_NOW', null); + } + + $len = count($lengths) - 1; + for ($j = 0; $difference >= $lengths[$j] && $j < $len; $j++) { + $difference /= $lengths[$j]; + } + + $difference = round($difference); + + if ($difference !== 1) { + $periods[$j] .= '_PLURAL'; + } + + if ($this->grav['language']->getTranslation($this->grav['user']->language, + $periods[$j] . '_MORE_THAN_TWO') + ) { + if ($difference > 2) { + $periods[$j] .= '_MORE_THAN_TWO'; + } + } + + $periods[$j] = $this->translate('GRAV.'.$periods[$j], null); + + return "{$difference} {$periods[$j]} {$tense}"; + } + + public function findFormFields($type, $fields, $found_fields = []) + { + foreach ($fields as $key => $field) { + + if (isset($field['type']) && $field['type'] == $type) { + $found_fields[$key] = $field; + } elseif (isset($field['fields'])) { + $result = $this->findFormFields($type, $field['fields'], $found_fields); + if (!empty($result)) { + $found_fields = array_merge($found_fields, $result); + } + } + } + + return $found_fields; + } + + public function getPagePathFromToken($path, $page = null) + { + return Utils::getPagePathFromToken($path, $page ?: $this->page(true)); + } + + /** + * Returns edited page. + * + * @param bool $route + * + * @param null $path + * + * @return PageInterface + */ + public function page($route = false, $path = null) + { + if (!$path) { + $path = $this->route; + } + + if ($route && !$path) { + $path = '/'; + } + + if (!isset($this->pages[$path])) { + $this->pages[$path] = $this->getPage($path); + } + + return $this->pages[$path]; + } + + /** + * Returns the page creating it if it does not exist. + * + * @param string $path + * + * @return PageInterface|null + */ + public function getPage($path) + { + /** @var Pages $pages */ + $pages = $this->grav['pages']; + + if ($path && $path[0] !== '/') { + $path = "/{$path}"; + } + + // Fix for entities in path causing looping... + $path = urldecode($path); + + $page = $path ? $pages->dispatch($path, true) : $pages->root(); + + if (!$page) { + $slug = basename($path); + + if ($slug === '') { + return null; + } + + $ppath = str_replace('\\', '/', dirname($path)); + + // Find or create parent(s). + $parent = $this->getPage($ppath !== '/' ? $ppath : ''); + + // Create page. + $page = new Page(); + $page->parent($parent); + $page->filePath($parent->path() . '/' . $slug . '/' . $page->name()); + + // Add routing information. + $pages->addPage($page, $path); + + // Set if Modular + $page->modularTwig($slug[0] === '_'); + + // Determine page type. + if (isset($this->session->{$page->route()})) { + // Found the type and header from the session. + $data = $this->session->{$page->route()}; + + // Set the key header value + $header = ['title' => $data['title']]; + + if (isset($data['visible'])) { + if ($data['visible'] === '' || $data['visible']) { + // if auto (ie '') + $pageParent = $page->parent(); + $children = $pageParent ? $pageParent->children() : []; + foreach ($children as $child) { + if ($child->order()) { + // set page order + $page->order(AdminController::getNextOrderInFolder($pageParent->path())); + break; + } + } + } + if ((int)$data['visible'] === 1 && !$page->order()) { + $header['visible'] = $data['visible']; + } + + } + + if ($data['name'] === 'modular') { + $header['body_classes'] = 'modular'; + } + + $name = $page->modular() ? str_replace('modular/', '', $data['name']) : $data['name']; + $page->name($name . '.md'); + + // Fire new event to allow plugins to manipulate page frontmatter + $this->grav->fireEvent('onAdminCreatePageFrontmatter', new Event(['header' => &$header, + 'data' => $data])); + + $page->header($header); + $page->frontmatter(Yaml::dump((array)$page->header(), 20)); + } else { + // Find out the type by looking at the parent. + $type = $parent->childType() ?: $parent->blueprints()->get('child_type', 'default'); + $page->name($type . CONTENT_EXT); + $page->header(); + } + } + + return $page; + } + + public function generateReports() + { + $reports = new ArrayCollection(); + + /** @var Pages $pages */ + $pages = $this->grav['pages']; + + // Default to XSS Security Report + $result = Security::detectXssFromPages($pages, true); + + $reports['Grav Security Check'] = $this->grav['twig']->processTemplate('reports/security.html.twig', [ + 'result' => $result, + ]); + + // Linting Issues + + $result = YamlLinter::lint(); + + $reports['Grav Yaml Linter'] = $this->grav['twig']->processTemplate('reports/yamllinter.html.twig', [ + 'result' => $result, + ]); + + // Fire new event to allow plugins to manipulate page frontmatter + $this->grav->fireEvent('onAdminGenerateReports', new Event(['reports' => $reports])); + + return $reports; + } + + public function getRouteDetails() + { + return [$this->base, $this->location, $this->route]; + } + + /** + * Get the files list + * + * @param bool $filtered + * @param int $page_index + * @return array|null + * @todo allow pagination + */ + public function files($filtered = true, $page_index = 0) + { + $param_type = $this->grav['uri']->param('type'); + $param_date = $this->grav['uri']->param('date'); + $param_page = $this->grav['uri']->param('page'); + $param_page = str_replace('\\', '/', $param_page); + + $files_cache_key = 'media-manager-files'; + + if ($param_type) { + $files_cache_key .= "-{$param_type}"; + } + if ($param_date) { + $files_cache_key .= "-{$param_date}"; + } + if ($param_page) { + $files_cache_key .= "-{$param_page}"; + } + + $page_files = null; + + $cache_enabled = $this->grav['config']->get('plugins.admin.cache_enabled'); + if (!$cache_enabled) { + $this->grav['cache']->setEnabled(true); + } + + $page_files = $this->grav['cache']->fetch(md5($files_cache_key)); + + if (!$cache_enabled) { + $this->grav['cache']->setEnabled(false); + } + + if (!$page_files) { + $page_files = []; + $pages = $this->grav['pages']; + + if ($param_page) { + $page = $pages->dispatch($param_page); + + $page_files = $this->getFiles('images', $page, $page_files, $filtered); + $page_files = $this->getFiles('videos', $page, $page_files, $filtered); + $page_files = $this->getFiles('audios', $page, $page_files, $filtered); + $page_files = $this->getFiles('files', $page, $page_files, $filtered); + } else { + $allPages = $pages->all(); + + if ($allPages) foreach ($allPages as $page) { + $page_files = $this->getFiles('images', $page, $page_files, $filtered); + $page_files = $this->getFiles('videos', $page, $page_files, $filtered); + $page_files = $this->getFiles('audios', $page, $page_files, $filtered); + $page_files = $this->getFiles('files', $page, $page_files, $filtered); + } + } + + if (count($page_files) >= self::MEDIA_PAGINATION_INTERVAL) { + $this->shouldLoadAdditionalFilesInBackground(true); + } + + if (!$cache_enabled) { + $this->grav['cache']->setEnabled(true); + } + $this->grav['cache']->save(md5($files_cache_key), $page_files, 600); //cache for 10 minutes + if (!$cache_enabled) { + $this->grav['cache']->setEnabled(false); + } + + } + + if (count($page_files) >= self::MEDIA_PAGINATION_INTERVAL) { + $page_files = array_slice($page_files, $page_index * self::MEDIA_PAGINATION_INTERVAL, self::MEDIA_PAGINATION_INTERVAL); + } + + return $page_files; + } + + public function shouldLoadAdditionalFilesInBackground($status = null) + { + if ($status) { + $this->load_additional_files_in_background = true; + } + + return $this->load_additional_files_in_background; + } + + public function loadAdditionalFilesInBackground($status = null) + { + if (!$this->loading_additional_files_in_background) { + $this->loading_additional_files_in_background = true; + $this->files(false, false); + $this->shouldLoadAdditionalFilesInBackground(false); + $this->loading_additional_files_in_background = false; + } + } + + private function getFiles($type, $page, $page_files, $filtered) + { + $page_files = $this->getMediaOfType($type, $page, $page_files); + + if ($filtered) { + $page_files = $this->filterByType($page_files); + $page_files = $this->filterByDate($page_files); + } + + return $page_files; + } + + /** + * Get all the media of a type ('images' | 'audios' | 'videos' | 'files') + * + * @param string $type + * @param PageInterface|null $page + * @param array $files + * + * @return array + */ + private function getMediaOfType($type, ?PageInterface $page, array $files) + { + if ($page) { + $media = $page->media(); + $mediaOfType = $media->$type(); + + foreach($mediaOfType as $title => $file) { + $files[] = [ + 'title' => $title, + 'type' => $type, + 'page_route' => $page->route(), + 'file' => $file->higherQualityAlternative() + ]; + } + + return $files; + } + + return []; + } + + /** + * Filter media by type + * + * @param array $filesFiltered + * + * @return array + */ + private function filterByType($filesFiltered) + { + $filter_type = $this->grav['uri']->param('type'); + if (!$filter_type) { + return $filesFiltered; + } + + $filesFiltered = array_filter($filesFiltered, function ($file) use ($filter_type) { + return $file['type'] == $filter_type; + }); + + return $filesFiltered; + } + + /** + * Filter media by date + * + * @param array $filesFiltered + * + * @return array + */ + private function filterByDate($filesFiltered) + { + $filter_date = $this->grav['uri']->param('date'); + if (!$filter_date) { + return $filesFiltered; + } + + $year = substr($filter_date, 0, 4); + $month = substr($filter_date, 5, 2); + + $filesFilteredByDate = []; + + foreach($filesFiltered as $file) { + $filedate = $this->fileDate($file['file']); + $fileYear = $filedate->format('Y'); + $fileMonth = $filedate->format('m'); + + if ($fileYear == $year && $fileMonth == $month) { + $filesFilteredByDate[] = $file; + } + } + + return $filesFilteredByDate; + } + + /** + * Return the DateTime object representation of a file modified date + * + * @param File $file + * + * @return DateTime + */ + private function fileDate($file) { + $datetime = new \DateTime(); + $datetime->setTimestamp($file->toArray()['modified']); + return $datetime; + } + + /** + * Get the files dates list to be used in the Media Files filter + * + * @return array + */ + public function filesDates() + { + $files = $this->files(false); + $dates = []; + + foreach ($files as $file) { + $datetime = $this->fileDate($file['file']); + $year = $datetime->format('Y'); + $month = $datetime->format('m'); + + if (!isset($dates[$year])) { + $dates[$year] = []; + } + + if (!isset($dates[$year][$month])) { + $dates[$year][$month] = 1; + } else { + $dates[$year][$month]++; + } + } + + return $dates; + } + + /** + * Get the pages list to be used in the Media Files filter + * + * @return array + */ + public function pages() + { + /** @var Collection $pages */ + $pages = $this->grav['pages']->all(); + + $pagesWithFiles = []; + foreach ($pages as $page) { + if (count($page->media()->all())) { + $pagesWithFiles[] = $page; + } + } + + return $pagesWithFiles; + } + + /** + * Return HTTP_REFERRER if set + * + * @return null + */ + public function getReferrer() + { + return $_SERVER['HTTP_REFERER'] ?? null; + } +} diff --git a/user/plugins/admin/classes/adminbasecontroller.php b/user/plugins/admin/classes/adminbasecontroller.php new file mode 100644 index 0000000..e3828a5 --- /dev/null +++ b/user/plugins/admin/classes/adminbasecontroller.php @@ -0,0 +1,1122 @@ + 'There is no error, the file uploaded with success', + 1 => 'The uploaded file exceeds the max upload size', + 2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML', + 3 => 'The uploaded file was only partially uploaded', + 4 => 'No file was uploaded', + 6 => 'Missing a temporary folder', + 7 => 'Failed to write file to disk', + 8 => 'A PHP extension stopped the file upload' + ]; + + /** @var array */ + public $blacklist_views = []; + + /** + * Performs a task. + * + * @return bool True if the action was performed successfully. + */ + public function execute() + { + if (in_array($this->view, $this->blacklist_views, true)) { + return false; + } + +// if (!$this->validateNonce()) { +// return false; +// } + + $method = 'task' . ucfirst($this->task); + + if (method_exists($this, $method)) { + try { + $success = $this->{$method}(); + } catch (\RuntimeException $e) { + $success = true; + $this->admin->setMessage($e->getMessage(), 'error'); + } + } else { + $success = $this->grav->fireEvent('onAdminTaskExecute', + new Event(['controller' => $this, 'method' => $method])); + } + + // Grab redirect parameter. + $redirect = $this->post['_redirect'] ?? null; + unset($this->post['_redirect']); + + // Redirect if requested. + if ($redirect) { + $this->setRedirect($redirect); + } + + return $success; + } + + protected function validateNonce() + { + if (strtolower($_SERVER['REQUEST_METHOD']) === 'post') { + if (isset($this->post['admin-nonce'])) { + $nonce = $this->post['admin-nonce']; + } else { + $nonce = $this->grav['uri']->param('admin-nonce'); + } + + if (!$nonce || !Utils::verifyNonce($nonce, 'admin-form')) { + if ($this->task === 'addmedia') { + + $message = sprintf($this->admin::translate('PLUGIN_ADMIN.FILE_TOO_LARGE', null), + ini_get('post_max_size')); + + //In this case it's more likely that the image is too big than POST can handle. Show message + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $message + ]; + + return false; + } + + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error'); + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN') + ]; + + return false; + } + unset($this->post['admin-nonce']); + } else { + if ($this->task === 'logout') { + $nonce = $this->grav['uri']->param('logout-nonce'); + if (null === $nonce || !Utils::verifyNonce($nonce, 'logout-form')) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), + 'error'); + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN') + ]; + + return false; + } + } else { + $nonce = $this->grav['uri']->param('admin-nonce'); + if (null === $nonce || !Utils::verifyNonce($nonce, 'admin-form')) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), + 'error'); + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN') + ]; + + return false; + } + } + } + + return true; + } + + /** + * Sets the page redirect. + * + * @param string $path The path to redirect to + * @param int $code The HTTP redirect code + */ + public function setRedirect($path, $code = 303) + { + $this->redirect = $path; + $this->redirectCode = $code; + } + + /** + * Sends JSON response and terminates the call. + * + * @param array $response + * @param int $code + * @return bool + */ + protected function sendJsonResponse(array $response, $code = 200) + { + // Make sure nothing extra gets written to the response. + while (ob_get_level()) { + ob_end_clean(); + } + + // JSON response. + http_response_code($code); + header('Content-Type: application/json'); + header('Cache-Control: no-cache, no-store, must-revalidate'); + + echo json_encode($response); + exit(); + } + + /** + * Handles ajax upload for files. + * Stores in a flash object the temporary file and deals with potential file errors. + * + * @return bool True if the action was performed. + */ + public function taskFilesUpload() + { + if (null === $_FILES || !$this->authorizeTask('save', $this->dataPermissions())) { + return false; + } + + /** @var Config $config */ + $config = $this->grav['config']; + $data = $this->view === 'pages' ? $this->admin->page(true) : $this->prepareData([]); + $settings = $data->blueprints()->schema()->getProperty($this->post['name']); + $settings = (object)array_merge([ + 'avoid_overwriting' => false, + 'random_name' => false, + 'accept' => ['image/*'], + 'limit' => 10, + 'filesize' => Utils::getUploadLimit() + ], (array)$settings, ['name' => $this->post['name']]); + + $upload = $this->normalizeFiles($_FILES['data'], $settings->name); + + $filename = $upload->file->name; + + // Handle bad filenames. + if (!Utils::checkFilename($filename)) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD', null), + $filename, 'Bad filename') + ]; + + return false; + } + + if (!isset($settings->destination)) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED', null) + ]; + + return false; + } + + // Do not use self@ outside of pages + if ($this->view !== 'pages' && in_array($settings->destination, ['@self', 'self@', '@self@'])) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_PREVENT_SELF', null), + $settings->destination) + ]; + + return false; + } + + // Handle errors and breaks without proceeding further + if ($upload->file->error !== UPLOAD_ERR_OK) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD', null), + $filename, $this->upload_errors[$upload->file->error]) + ]; + + return false; + } + + // Handle file size limits + $settings->filesize *= 1048576; // 2^20 [MB in Bytes] + if ($settings->filesize > 0 && $upload->file->size > $settings->filesize) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT') + ]; + + return false; + } + + // Handle Accepted file types + // Accept can only be mime types (image/png | image/*) or file extensions (.pdf|.jpg) + $accepted = false; + $errors = []; + + // Do not trust mimetype sent by the browser + $mime = Utils::getMimeByFilename($filename); + + 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) { + $errors[] = 'The MIME type "' . $mime . '" for the file "' . $filename . '" is not an accepted.'; + } else { + $accepted = true; + break; + } + } else { + $match = preg_match('#' . $find . '$#', $filename); + if (!$match) { + $errors[] = 'The File Extension for the file "' . $filename . '" is not an accepted.'; + } else { + $accepted = true; + break; + } + } + } + + if (!$accepted) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => implode('
    ', $errors) + ]; + + return false; + } + + // Remove the error object to avoid storing it + unset($upload->file->error); + + // we need to move the file at this stage or else + // it won't be available upon save later on + // since php removes it from the upload location + $tmp_dir = Admin::getTempDir(); + $tmp_file = $upload->file->tmp_name; + $tmp = $tmp_dir . '/uploaded-files/' . basename($tmp_file); + + Folder::create(dirname($tmp)); + if (!move_uploaded_file($tmp_file, $tmp)) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_MOVE', null), '', + $tmp) + ]; + + return false; + } + + $upload->file->tmp_name = $tmp; + + // Retrieve the current session of the uploaded files for the field + // and initialize it if it doesn't exist + $sessionField = base64_encode($this->grav['uri']->url()); + $flash = $this->admin->session()->getFlashObject('files-upload'); + if (!$flash) { + $flash = []; + } + if (!isset($flash[$sessionField])) { + $flash[$sessionField] = []; + } + if (!isset($flash[$sessionField][$upload->field])) { + $flash[$sessionField][$upload->field] = []; + } + + // Set destination + if ($this->grav['locator']->isStream($settings->destination)) { + $destination = $this->grav['locator']->findResource($settings->destination, false, true); + } else { + $destination = Folder::getRelativePath(rtrim($settings->destination, '/')); + $destination = $this->admin->getPagePathFromToken($destination); + } + + // Create destination if needed + if (!is_dir($destination)) { + Folder::mkdir($destination); + } + + // Generate random name if required + if ($settings->random_name) { // TODO: document + $extension = pathinfo($upload->file->name, PATHINFO_EXTENSION); + $upload->file->name = Utils::generateRandomString(15) . '.' . $extension; + } + + // Handle conflicting name if needed + if ($settings->avoid_overwriting) { // TODO: document + if (file_exists($destination . '/' . $upload->file->name)) { + $upload->file->name = date('YmdHis') . '-' . $upload->file->name; + } + } + + // Prepare object for later save + $path = $destination . '/' . $upload->file->name; + $upload->file->path = $path; + // $upload->file->route = $page ? $path : null; + + // Prepare data to be saved later + $flash[$sessionField][$upload->field][$path] = (array)$upload->file; + + // Finally store the new uploaded file in the field session + $this->admin->session()->setFlashObject('files-upload', $flash); + $this->admin->json_response = [ + 'status' => 'success', + 'session' => \json_encode([ + 'sessionField' => base64_encode($this->grav['uri']->url()), + 'path' => $upload->file->path, + 'field' => $settings->name + ]) + ]; + + return true; + } + + /** + * Checks if the user is allowed to perform the given task with its associated permissions + * + * @param string $task The task to execute + * @param array $permissions The permissions given + * + * @return bool True if authorized. False if not. + */ + public function authorizeTask($task = '', $permissions = []) + { + if (!$this->admin->authorize($permissions)) { + if ($this->grav['uri']->extension() === 'json') { + $this->admin->json_response = [ + 'status' => 'unauthorized', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') . ' ' . $task . '.' + ]; + } else { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') . ' ' . $task . '.', + 'error'); + } + + return false; + } + + return true; + } + + /** + * Gets the permissions needed to access a given view + * + * @return array An array of permissions + */ + protected function dataPermissions() + { + $type = $this->view; + $permissions = ['admin.super']; + + switch ($type) { + case 'configuration': + case 'config': + case 'system': + $permissions[] = 'admin.configuration'; + break; + case 'settings': + case 'site': + $permissions[] = 'admin.settings'; + break; + case 'plugins': + $permissions[] = 'admin.plugins'; + break; + case 'themes': + $permissions[] = 'admin.themes'; + break; + case 'users': + $permissions[] = 'admin.users'; + break; + case 'user': + $permissions[] = 'admin.login'; + $permissions[] = 'admin.users'; + break; + case 'pages': + $permissions[] = 'admin.pages'; + break; + } + + return $permissions; + } + + /** + * Gets the configuration data for a given view & post + * + * @param array $data + * + * @return array + */ + protected function prepareData(array $data) + { + return $data; + } + + /** + * Internal method to normalize the $_FILES array + * + * @param array $data $_FILES starting point data + * @param string $key + * + * @return object a new Object with a normalized list of files + */ + protected function normalizeFiles($data, $key = '') + { + $files = new \stdClass(); + $files->field = $key; + $files->file = new \stdClass(); + + foreach ($data as $fieldName => $fieldValue) { + // Since Files Upload are always happening via Ajax + // we are not interested in handling `multiple="true"` + // because they are always handled one at a time. + // For this reason we normalize the value to string, + // in case it is arriving as an array. + $value = (array)Utils::getDotNotation($fieldValue, $key); + $files->file->{$fieldName} = array_shift($value); + } + + return $files; + } + + /** + * Removes a file from the flash object session, before it gets saved + * + * @return bool True if the action was performed. + */ + public function taskFilesSessionRemove() + { + if (!$this->authorizeTask('save', $this->dataPermissions())) { + return false; + } + + // Retrieve the current session of the uploaded files for the field + // and initialize it if it doesn't exist + $sessionField = base64_encode($this->grav['uri']->url()); + $request = \json_decode($this->post['session']); + + // Ensure the URI requested matches the current one, otherwise fail + if ($request->sessionField !== $sessionField) { + return false; + } + + // Retrieve the flash object and remove the requested file from it + $flash = $this->admin->session()->getFlashObject('files-upload'); + $endpoint = $flash[$request->sessionField][$request->field][$request->path]; + + if (isset($endpoint)) { + if (file_exists($endpoint['tmp_name'])) { + unlink($endpoint['tmp_name']); + } + + unset($endpoint); + } + + // Walk backward to cleanup any empty field that's left + // Field + if (isset($flash[$request->sessionField][$request->field][$request->path])) { + unset($flash[$request->sessionField][$request->field][$request->path]); + } + + // Field + if (isset($flash[$request->sessionField][$request->field]) && empty($flash[$request->sessionField][$request->field])) { + unset($flash[$request->sessionField][$request->field]); + } + + // Session Field + if (isset($flash[$request->sessionField]) && empty($flash[$request->sessionField])) { + unset($flash[$request->sessionField]); + } + + + // If there's anything left to restore in the flash object, do so + if (count($flash)) { + $this->admin->session()->setFlashObject('files-upload', $flash); + } + + $this->admin->json_response = ['status' => 'success']; + + return true; + } + + /** + * Redirect to the route stored in $this->redirect + */ + public function redirect() + { + if (!$this->redirect) { + return; + } + + $base = $this->admin->base; + $this->redirect = '/' . ltrim($this->redirect, '/'); + $multilang = $this->isMultilang(); + + $redirect = ''; + if ($multilang) { + // if base path does not already contain the lang code, add it + $langPrefix = '/' . $this->grav['session']->admin_lang; + if (!Utils::startsWith($base, $langPrefix . '/')) { + $base = $langPrefix . $base; + } + + // now the first 4 chars of base contain the lang code. + // if redirect path already contains the lang code, and is != than the base lang code, then use redirect path as-is + if (Utils::pathPrefixedByLangCode($base) && Utils::pathPrefixedByLangCode($this->redirect) + && !Utils::startsWith($this->redirect, $base) + ) { + $redirect = $this->redirect; + } else { + if (!Utils::startsWith($this->redirect, $base)) { + $this->redirect = $base . $this->redirect; + } + } + + } else { + if (!Utils::startsWith($this->redirect, $base)) { + $this->redirect = $base . $this->redirect; + } + } + + if (!$redirect) { + $redirect = $this->redirect; + } + + $this->grav->redirect($redirect, $this->redirectCode); + } + + /** + * Prepare and return POST data. + * + * @param array $post + * + * @return array + */ + protected function getPost($post) + { + if (!is_array($post)) { + return []; + } + + unset($post['task']); + + // Decode JSON encoded fields and merge them to data. + if (isset($post['_json'])) { + $post = array_replace_recursive($post, $this->jsonDecode($post['_json'])); + unset($post['_json']); + } + + $post = $this->cleanDataKeys($post); + + return $post; + } + + /** + * Recursively JSON decode data. + * + * @param array $data + * + * @return array + */ + protected function jsonDecode(array $data) + { + foreach ($data as &$value) { + if (is_array($value)) { + $value = $this->jsonDecode($value); + } else { + $value = json_decode($value, true); + } + } + + return $data; + } + + protected function cleanDataKeys($source = []) + { + $out = []; + + if (is_array($source)) { + foreach ($source as $key => $value) { + $key = str_replace(['%5B', '%5D'], ['[', ']'], $key); + if (is_array($value)) { + $out[$key] = $this->cleanDataKeys($value); + } else { + $out[$key] = $value; + } + } + } + + return $out; + } + + /** + * Return true if multilang is active + * + * @return bool True if multilang is active + */ + protected function isMultilang() + { + return count($this->grav['config']->get('system.languages.supported', [])) > 1; + } + + /** + * @param PageInterface|UserInterface|Data $obj + * + * @return PageInterface|UserInterface|Data + */ + protected function storeFiles($obj) + { + // Process previously uploaded files for the current URI + // and finally store them. Everything else will get discarded + $queue = $this->admin->session()->getFlashObject('files-upload'); + if (is_array($queue)) { + $queue = $queue[base64_encode($this->grav['uri']->url())]; + foreach ($queue as $key => $files) { + foreach ($files as $destination => $file) { + if (!rename($file['tmp_name'], $destination)) { + throw new \RuntimeException(sprintf($this->admin->translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_MOVE', + null), '"' . $file['tmp_name'] . '"', $destination)); + } + + unset($files[$destination]['tmp_name']); + } + + if ($this->view === 'pages') { + $keys = explode('.', preg_replace('/^header./', '', $key)); + $init_key = array_shift($keys); + if (count($keys) > 0) { + $new_data = $obj->header()->{$init_key} ?? []; + Utils::setDotNotation($new_data, implode('.', $keys), $files, true); + } else { + $new_data = $files; + } + if (isset($obj->header()->{$init_key})) { + $obj->modifyHeader($init_key, + array_replace_recursive([], $obj->header()->{$init_key}, $new_data)); + } else { + $obj->modifyHeader($init_key, $new_data); + } + } elseif ($obj instanceof UserInterface and $key === 'avatar') { + $obj->set($key, $files); + } else { + // TODO: [this is JS handled] if it's single file, remove existing and use set, if it's multiple, use join + $obj->join($key, $files); // stores + } + + } + } + + return $obj; + } + + /** + * Used by the filepicker field to get a list of files in a folder. + */ + protected function taskGetFilesInFolder() + { + if (!$this->authorizeTask('save', $this->dataPermissions())) { + return false; + } + + $data = $this->view === 'pages' ? $this->admin->page(true) : $this->prepareData([]); + + if (null === $data) { + return false; + } + + if (method_exists($data, 'blueprints')) { + $settings = $data->blueprints()->schema()->getProperty($this->post['name']); + } elseif (method_exists($data, 'getBlueprint')) { + $settings = $data->getBlueprint()->schema()->getProperty($this->post['name']); + } + + if (isset($settings['folder'])) { + $folder = $settings['folder']; + } else { + $folder = 'self@'; + } + + // Do not use self@ outside of pages + if ($this->view !== 'pages' && in_array($folder, ['@self', 'self@', '@self@'])) { + if (!$data instanceof MediaInterface) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_PREVENT_SELF', null), $folder) + ]; + + return false; + } + + $media = $data->getMedia(); + } else { + // Set destination + $folder = Folder::getRelativePath(rtrim($folder, '/')); + $folder = $this->admin->getPagePathFromToken($folder); + + $media = new Media($folder); + } + + $available_files = []; + $metadata = []; + $thumbs = []; + + + foreach ($media->all() as $name => $medium) { + + $available_files[] = $name; + + if (isset($settings['include_metadata'])) { + $img_metadata = $medium->metadata(); + if ($img_metadata) { + $metadata[$name] = $img_metadata; + } + } + + } + + // Peak in the flashObject for optimistic filepicker updates + $pending_files = []; + $sessionField = base64_encode($this->grav['uri']->url()); + $flash = $this->admin->session()->getFlashObject('files-upload'); + + if ($flash && isset($flash[$sessionField])) { + foreach ($flash[$sessionField] as $field => $data) { + foreach ($data as $file) { + if (dirname($file['path']) === $folder) { + $pending_files[] = $file['name']; + } + } + } + } + + $this->admin->session()->setFlashObject('files-upload', $flash); + + // Handle Accepted file types + // Accept can only be file extensions (.pdf|.jpg) + if (isset($settings['accept'])) { + $available_files = array_filter($available_files, function ($file) use ($settings) { + return $this->filterAcceptedFiles($file, $settings); + }); + + $pending_files = array_filter($pending_files, function ($file) use ($settings) { + return $this->filterAcceptedFiles($file, $settings); + }); + } + + // Generate thumbs if needed + if (isset($settings['preview_images']) && $settings['preview_images'] === true) { + foreach ($available_files as $filename) { + $thumbs[$filename] = $media[$filename]->zoomCrop(100,100)->url(); + } + } + + $this->admin->json_response = [ + 'status' => 'success', + 'files' => array_values($available_files), + 'pending' => array_values($pending_files), + 'folder' => $folder, + 'metadata' => $metadata, + 'thumbs' => $thumbs + ]; + + return true; + } + + protected function filterAcceptedFiles($file, $settings) + { + $valid = false; + + foreach ((array)$settings['accept'] as $type) { + $find = str_replace('*', '.*', $type); + $valid |= preg_match('#' . $find . '$#', $file); + } + + return $valid; + } + + /** + * Handle deleting a file from a blueprint + * + * @return bool True if the action was performed. + */ + protected function taskRemoveFileFromBlueprint() + { + /** @var Uri $uri */ + $uri = $this->grav['uri']; + $blueprint = base64_decode($uri->param('blueprint')); + $path = base64_decode($uri->param('path')); + $filename = basename($this->post['filename'] ?? ''); + $proute = base64_decode($uri->param('proute')); + $type = $uri->param('type'); + $field = $uri->param('field'); + + if ($filename === '') { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => 'Filename is empty' + ]; + + return false; + } + + // Get Blueprint + if ($type === 'pages' || strpos($blueprint, 'pages/') === 0) { + $page = $this->admin->page(true, $proute); + if (!$page) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => 'Page not found' + ]; + + return false; + } + $blueprints = $page->blueprints(); + $path = Folder::getRelativePath($page->path()); + $settings = (object)$blueprints->schema()->getProperty($field); + } else { + $page = null; + if ($type === 'themes' || $type === 'plugins') { + $obj = $this->grav[$type]->get(Utils::substrToString($blueprint, '/')); //here + $settings = (object) $obj->blueprints()->schema()->getProperty($field); + } else { + $settings = (object)$this->admin->blueprints($blueprint)->schema()->getProperty($field); + } + } + + // Get destination + if ($this->grav['locator']->isStream($settings->destination)) { + $destination = $this->grav['locator']->findResource($settings->destination, false, true); + + } else { + $destination = Folder::getRelativePath(rtrim($settings->destination, '/')); + $destination = $this->admin->getPagePathFromToken($destination, $page); + } + + // Not in path + if (!Utils::startsWith($path, $destination)) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => 'Path not valid for this data type' + ]; + + return false; + } + + // Only remove files from correct destination... + $this->taskRemoveMedia($destination . '/' . $filename); + + if ($page) { + $keys = explode('.', preg_replace('/^header./', '', $field)); + $header = (array)$page->header(); + $data_path = implode('.', $keys); + $data = Utils::getDotNotation($header, $data_path); + + if (isset($data[$path])) { + unset($data[$path]); + Utils::setDotNotation($header, $data_path, $data); + $page->header($header); + } + + $page->save(); + } else { + + $blueprint_prefix = $type === 'config' ? '' : $type . '.'; + $blueprint_name = str_replace(['config/', '/blueprints'], '', $blueprint); + $blueprint_field = $blueprint_prefix . $blueprint_name . '.' . $field; + + $files = $this->grav['config']->get($blueprint_field); + + if ($files) { + foreach ($files as $key => $value) { + if ($key == $path) { + unset($files[$key]); + } + } + } + + $this->grav['config']->set($blueprint_field, $files); + + switch ($type) { + case 'config': + $data = $this->grav['config']->get($blueprint_name); + $config = $this->admin->data($blueprint, $data); + $config->save(); + break; + case 'themes': + Theme::saveConfig($blueprint_name); + break; + case 'plugins': + Plugin::saveConfig($blueprint_name); + break; + } + } + + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate('PLUGIN_ADMIN.REMOVE_SUCCESSFUL') + ]; + + return true; + } + + /** + * Handles removing a media file + * + * @return bool True if the action was performed + */ + public function taskRemoveMedia($filename = null) + { + if (!$this->canEditMedia()) { + return false; + } + + if (null === $filename) { + $filename = base64_decode($this->grav['uri']->param('route')); + if (!$filename) { + $filename = base64_decode($this->route); + } + } + + $file = File::instance($filename); + $resultRemoveMedia = false; + + if ($file->exists()) { + $resultRemoveMedia = $file->delete(); + + $fileParts = pathinfo($filename); + + foreach (scandir($fileParts['dirname']) as $file) { + $regex_pattern = '/' . preg_quote($fileParts['filename'], '/') . "@\d+x\." . $fileParts['extension'] . "(?:\.meta\.yaml)?$|" . preg_quote($fileParts['basename'], '/') . "\.meta\.yaml$/"; + if (preg_match($regex_pattern, $file)) { + $path = $fileParts['dirname'] . '/' . $file; + @unlink($path); + } + } + + } + + if ($resultRemoveMedia) { + if ($this->grav['uri']->extension() === 'json') { + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate('PLUGIN_ADMIN.REMOVE_SUCCESSFUL') + ]; + } else { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.REMOVE_SUCCESSFUL'), 'info'); + $this->clearMediaCache(); + $this->setRedirect('/media-manager'); + } + + return true; + } + + if ($this->grav['uri']->extension() === 'json') { + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate('PLUGIN_ADMIN.REMOVE_FAILED') + ]; + } else { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.REMOVE_FAILED'), 'error'); + } + + return false; + } + + /** + * Handles clearing the media cache + * + * @return bool True if the action was performed + */ + protected function clearMediaCache() + { + $key = 'media-manager-files'; + $cache = $this->grav['cache']; + $cache->delete(md5($key)); + + return true; + } + + /** + * Determine if the user can edit media + * + * @param string $type + * + * @return bool True if the media action is allowed + */ + protected function canEditMedia($type = 'media') + { + if (!$this->authorizeTask('edit media', ['admin.' . $type, 'admin.super'])) { + return false; + } + + return true; + } +} diff --git a/user/plugins/admin/classes/admincontroller.php b/user/plugins/admin/classes/admincontroller.php new file mode 100644 index 0000000..17e0b6a --- /dev/null +++ b/user/plugins/admin/classes/admincontroller.php @@ -0,0 +1,2394 @@ +grav = $grav; + $this->view = $view; + $this->task = $task ?: 'display'; + if (isset($post['data'])) { + $this->data = $this->getPost($post['data']); + unset($post['data']); + } else { + // Backwards compatibility for Form plugin <= 1.2 + $this->data = $this->getPost($post); + } + $this->post = $this->getPost($post); + $this->route = $route; + $this->admin = $this->grav['admin']; + + $this->grav->fireEvent('onAdminControllerInit', new Event(['controller' => &$this])); + } + + /** + * Handle login. + * + * @return bool True if the action was performed. + */ + protected function taskLogin() + { + $this->admin->authenticate($this->data, $this->post); + + return true; + } + + /** + * @return bool True if the action was performed. + */ + protected function taskTwofa() + { + $this->admin->twoFa($this->data, $this->post); + + return true; + } + + /** + * Handle logout. + * + * @return bool True if the action was performed. + */ + protected function taskLogout() + { + $this->admin->logout($this->data, $this->post); + + return true; + } + + /** + * @return bool + */ + public function taskRegenerate2FASecret() + { + if (!$this->authorizeTask('regenerate 2FA Secret', ['admin.login'])) { + return false; + } + + try { + /** @var User $user */ + $user = $this->grav['user']; + + /** @var TwoFactorAuth $twoFa */ + $twoFa = $this->grav['login']->twoFactorAuth(); + $secret = $twoFa->createSecret(); + $image = $twoFa->getQrImageData($user->username, $secret); + + $user->set('twofa_secret', $secret); + + // TODO: data user can also use save, but please test it before removing this code. + if ($user instanceof \Grav\Common\User\DataUser\User) { + // Save secret into the user file. + $file = $user->file(); + if ($file->exists()) { + $content = (array)$file->content(); + $content['twofa_secret'] = $secret; + $file->save($content); + $file->free(); + } + } else { + $user->save(); + } + + $this->admin->json_response = ['status' => 'success', 'image' => $image, 'secret' => preg_replace('|(\w{4})|', '\\1 ', $secret)]; + } catch (\Exception $e) { + $this->admin->json_response = ['status' => 'error', 'message' => $e->getMessage()]; + return false; + } + + return true; + } + + /** + * Handle the reset password action. + * + * @return bool True if the action was performed. + */ + public function taskReset() + { + $data = $this->data; + + if (isset($data['password'])) { + /** @var UserCollectionInterface $users */ + $users = $this->grav['accounts']; + + $username = isset($data['username']) ? strip_tags(strtolower($data['username'])) : null; + $user = $username ? $users->load($username) : null; + $password = $data['password'] ?? null; + $token = $data['token'] ?? null; + + if ($user && $user->exists() && !empty($user->get('reset'))) { + list($good_token, $expire) = explode('::', $user->get('reset')); + + if ($good_token === $token) { + if (time() > $expire) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESET_LINK_EXPIRED'), 'error'); + $this->setRedirect('/forgot'); + + return true; + } + + $user->undef('hashed_password'); + $user->undef('reset'); + $user->set('password', $password); + $user->save(); + + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESET_PASSWORD_RESET'), 'info'); + $this->setRedirect('/'); + + return true; + } + } + + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error'); + $this->setRedirect('/forgot'); + + return true; + + } + + $user = $this->grav['uri']->param('user'); + $token = $this->grav['uri']->param('token'); + + if (empty($user) || empty($token)) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error'); + $this->setRedirect('/forgot'); + + return true; + } + + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESET_NEW_PASSWORD'), 'info'); + + $this->admin->forgot = ['username' => $user, 'token' => $token]; + + return true; + } + + /** + * Handle the email password recovery procedure. + * + * @return bool True if the action was performed. + */ + protected function taskForgot() + { + $param_sep = $this->grav['config']->get('system.param_sep', ':'); + $post = $this->post; + $data = $this->data; + $login = $this->grav['login']; + + /** @var UserCollectionInterface $users */ + $users = $this->grav['accounts']; + + $username = isset($data['username']) ? strip_tags(strtolower($data['username'])) : ''; + $user = !empty($username) ? $users->load($username) : null; + + if (!isset($this->grav['Email'])) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_EMAIL_NOT_CONFIGURED'), 'error'); + $this->setRedirect($post['redirect']); + + return true; + } + + if (!$user || !$user->exists()) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'), + 'info'); + $this->setRedirect($post['redirect']); + + return true; + } + + if (empty($user->email)) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'), + 'info'); + $this->setRedirect($post['redirect']); + + return true; + } + + $count = $this->grav['config']->get('plugins.login.max_pw_resets_count', 0); + $interval =$this->grav['config']->get('plugins.login.max_pw_resets_interval', 2); + + if ($login->isUserRateLimited($user, 'pw_resets', $count, $interval)) { + $this->admin->setMessage($this->admin::translate(['PLUGIN_LOGIN.FORGOT_CANNOT_RESET_IT_IS_BLOCKED', $user->email, $interval]), 'error'); + $this->setRedirect($post['redirect']); + + return true; + } + + $token = md5(uniqid(mt_rand(), true)); + $expire = time() + 604800; // next week + + $user->set('reset', $token . '::' . $expire); + $user->save(); + + $author = $this->grav['config']->get('site.author.name', ''); + $fullname = $user->fullname ?: $username; + $reset_link = rtrim($this->grav['uri']->rootUrl(true), '/') . '/' . trim($this->admin->base, + '/') . '/reset/task' . $param_sep . 'reset/user' . $param_sep . $username . '/token' . $param_sep . $token . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form'); + + $sitename = $this->grav['config']->get('site.title', 'Website'); + $from = $this->grav['config']->get('plugins.email.from'); + + if (empty($from)) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_EMAIL_NOT_CONFIGURED'), 'error'); + $this->setRedirect($post['redirect']); + + return true; + } + + $to = $user->email; + + $subject = $this->admin::translate(['PLUGIN_ADMIN.FORGOT_EMAIL_SUBJECT', $sitename]); + $content = $this->admin::translate([ + 'PLUGIN_ADMIN.FORGOT_EMAIL_BODY', + $fullname, + $reset_link, + $author, + $sitename + ]); + + $body = $this->grav['twig']->processTemplate('email/base.html.twig', ['content' => $content]); + + $message = $this->grav['Email']->message($subject, $body, 'text/html')->setFrom($from)->setTo($to); + + $sent = $this->grav['Email']->send($message); + + if ($sent < 1) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_FAILED_TO_EMAIL'), 'error'); + } else { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'), + 'info'); + } + + $this->setRedirect('/'); + + return true; + } + + /** + * Enable a plugin. + * + * @return bool True if the action was performed. + */ + public function taskEnable() + { + if (!$this->authorizeTask('enable plugin', ['admin.plugins', 'admin.super'])) { + return false; + } + + if ($this->view !== 'plugins') { + return false; + } + + // Filter value and save it. + $this->post = ['enabled' => true]; + $obj = $this->prepareData($this->post); + $obj->save(); + + $this->post = ['_redirect' => 'plugins']; + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_ENABLED_PLUGIN'), 'info'); + + return true; + } + + /** + * Gets the configuration data for a given view & post + * + * @param array $data + * + * @return object + */ + protected function prepareData(array $data) + { + $type = trim("{$this->view}/{$this->admin->route}", '/'); + $data = $this->admin->data($type, $data); + + return $data; + } + + /** + * Disable a plugin. + * + * @return bool True if the action was performed. + */ + public function taskDisable() + { + if (!$this->authorizeTask('disable plugin', ['admin.plugins', 'admin.super'])) { + return false; + } + + if ($this->view !== 'plugins') { + return false; + } + + // Filter value and save it. + $this->post = ['enabled' => false]; + $obj = $this->prepareData($this->post); + $obj->save(); + + $this->post = ['_redirect' => 'plugins']; + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_DISABLED_PLUGIN'), 'info'); + + return true; + } + + /** + * Set the default theme. + * + * @return bool True if the action was performed. + */ + public function taskActivate() + { + if (!$this->authorizeTask('activate theme', ['admin.themes', 'admin.super'])) { + return false; + } + + if ($this->view !== 'themes') { + return false; + } + + $this->post = ['_redirect' => 'themes']; + + // Make sure theme exists (throws exception) + $name = $this->route; + $this->grav['themes']->get($name); + + // Store system configuration. + $system = $this->admin->data('config/system'); + $system->set('pages.theme', $name); + $system->save(); + + // Force configuration reload and save. + /** @var Config $config */ + $config = $this->grav['config']; + $config->reload()->save(); + + $config->set('system.pages.theme', $name); + + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_CHANGED_THEME'), 'info'); + + return true; + } + + /** + * Handles updating Grav + * + * @return bool False if user has no permissions. + */ + public function taskUpdategrav() + { + if (!$this->authorizeTask('install grav', ['admin.super'])) { + return false; + } + + $gpm = Gpm::GPM(); + $version = $gpm->grav->getVersion(); + $result = Gpm::selfupgrade(); + + if ($result) { + $json_response = [ + 'status' => 'success', + 'type' => 'updategrav', + 'version' => $version, + 'message' => $this->admin::translate('PLUGIN_ADMIN.GRAV_WAS_SUCCESSFULLY_UPDATED_TO') . ' ' . $version + ]; + } else { + $json_response = [ + 'status' => 'error', + 'type' => 'updategrav', + 'version' => GRAV_VERSION, + 'message' => $this->admin::translate('PLUGIN_ADMIN.GRAV_UPDATE_FAILED') . '
    ' . Installer::lastErrorMsg() + ]; + } + + return $this->sendJsonResponse($json_response); + } + + /** + * Handles uninstalling plugins and themes + * + * @deprecated + * + * @return bool True if the action was performed + */ + public function taskUninstall() + { + $type = $this->view === 'plugins' ? 'plugins' : 'themes'; + if (!$this->authorizeTask('uninstall ' . $type, ['admin.' . $type, 'admin.super'])) { + return false; + } + + $package = $this->route; + + $result = Gpm::uninstall($package, []); + + if ($result) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.UNINSTALL_SUCCESSFUL'), 'info'); + } else { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.UNINSTALL_FAILED'), 'error'); + } + + $this->post = ['_redirect' => $this->view]; + + return true; + } + + /** + * Handles creating an empty page folder (without markdown file) + * + * @return bool True if the action was performed. + */ + public function taskSaveNewFolder() + { + if (!$this->authorizeTask('save', $this->dataPermissions())) { + return false; + } + + $data = (array)$this->data; + + if ($data['route'] === '/') { + $path = $this->grav['locator']->findResource('page://'); + } else { + $path = $this->grav['page']->find($data['route'])->path(); + } + + $orderOfNewFolder = static::getNextOrderInFolder($path); + $new_path = $path . '/' . $orderOfNewFolder . '.' . $data['folder']; + + Folder::create($new_path); + Cache::clearCache('invalidate'); + + $this->grav->fireEvent('onAdminAfterSaveAs', new Event(['path' => $new_path])); + + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_SAVED'), 'info'); + + $multilang = $this->isMultilang(); + $admin_route = $this->admin->base; + $redirect_url = '/' . ($multilang ? ($this->grav['session']->admin_lang) : '') . $admin_route . '/' . $this->view; + $this->setRedirect($redirect_url); + + return true; + } + + /** + * Get the next available ordering number in a folder + * + * @param string $path + * + * @return string the correct order string to prepend + */ + public static function getNextOrderInFolder($path) + { + $files = Folder::all($path, ['recursive' => false]); + + $highestOrder = 0; + foreach ($files as $file) { + preg_match(PAGE_ORDER_PREFIX_REGEX, $file, $order); + + if (isset($order[0])) { + $theOrder = (int)trim($order[0], '.'); + } else { + $theOrder = 0; + } + + if ($theOrder >= $highestOrder) { + $highestOrder = $theOrder; + } + } + + $orderOfNewFolder = $highestOrder + 1; + + if ($orderOfNewFolder < 10) { + $orderOfNewFolder = '0' . $orderOfNewFolder; + } + + return $orderOfNewFolder; + } + + /** + * Handles form and saves the input data if its valid. + * + * @return bool True if the action was performed. + */ + public function taskSave() + { + if (!$this->authorizeTask('save', $this->dataPermissions())) { + return false; + } + + $this->grav['twig']->twig_vars['current_form_data'] = (array)$this->data; + + switch ($this->view) { + case 'pages': + return $this->taskSavePage(); + case 'user': + return $this->taskSaveUser(); + default: + return $this->taskSaveDefault(); + } + } + + protected function taskSavePage() + { + $reorder = true; + + $data = (array)$this->data; + $this->grav['twig']->twig_vars['current_form_data'] = $data; + + /** @var Pages $pages */ + $pages = $this->grav['pages']; + + // Find new parent page in order to build the path. + $route = $data['route'] ?? dirname($this->admin->route); + + /** @var PageInterface $obj */ + $obj = $this->admin->page(true); + + if (!isset($data['folder']) || !$data['folder']) { + $data['folder'] = $obj->slug(); + $this->data['folder'] = $obj->slug(); + } + + // Ensure route is prefixed with a forward slash. + $route = '/' . ltrim($route, '/'); + + // Check for valid frontmatter + if (isset($data['frontmatter']) && !$this->checkValidFrontmatter($data['frontmatter'])) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INVALID_FRONTMATTER_COULD_NOT_SAVE'), + 'error'); + return false; + } + + // XSS Checks for page content + $xss_whitelist = $this->grav['config']->get('security.xss_whitelist', 'admin.super'); + if (!$this->admin->authorize($xss_whitelist)) { + $check_what = ['header' => $data['header'] ?? '', 'frontmatter' => $data['frontmatter'] ?? '', 'content' => $data['content'] ?? '']; + $results = Security::detectXssFromArray($check_what); + if (!empty($results)) { + $this->admin->setMessage(' ' . $this->admin::translate('PLUGIN_ADMIN.XSS_ONSAVE_ISSUE'), + 'error'); + return false; + } + } + + $parent = $route && $route !== '/' && $route !== '.' && $route !== '/.' ? $pages->dispatch($route, true) : $pages->root(); + $original_order = (int)trim($obj->order(), '.'); + + try { + // Change parent if needed and initialize move (might be needed also on ordering/folder change). + $obj = $obj->move($parent); + $this->preparePage($obj, false, $obj->language()); + $obj->validate(); + + } catch (\Exception $e) { + $this->admin->setMessage($e->getMessage(), 'error'); + + return false; + } + $obj->filter(); + + // rename folder based on visible + if ($original_order === 1000) { + // increment order to force reshuffle + $obj->order($original_order + 1); + } + + if (isset($data['order']) && !empty($data['order'])) { + $reorder = explode(',', $data['order']); + } + + // add or remove numeric prefix based on ordering value + if (isset($data['ordering'])) { + if ($data['ordering'] && !$obj->order()) { + $obj->order(static::getNextOrderInFolder($obj->parent()->path())); + $reorder = false; + } elseif (!$data['ordering'] && $obj->order()) { + $obj->folder($obj->slug()); + } + } + + $obj = $this->storeFiles($obj); + + if ($obj) { + // Event to manipulate data before saving the object + $this->grav->fireEvent('onAdminSave', new Event(['object' => &$obj])); + $obj->save($reorder); + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_SAVED'), 'info'); + $this->grav->fireEvent('onAdminAfterSave', new Event(['object' => $obj])); + } + + if (method_exists($obj, 'unsetRouteSlug')) { + $obj->unsetRouteSlug(); + } + + $multilang = $this->isMultilang(); + + if ($multilang) { + if (!$obj->language()) { + $obj->language($this->grav['session']->admin_lang); + } + } + $admin_route = $this->admin->base; + + $route = $obj->rawRoute(); + $redirect_url = ($multilang ? '/' . $obj->language() : '') . $admin_route . '/' . $this->view . $route; + $this->setRedirect($redirect_url); + + return true; + } + + protected function taskSaveUser() + { + /** @var UserCollectionInterface $users */ + $users = $this->grav['accounts']; + + $user = $users->load($this->admin->route); + + if (!$this->admin->authorize(['admin.super', 'admin.users'])) { + // no user file or not admin.super or admin.users + if ($user->username !== $this->grav['user']->username) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') . ' save.','error'); + + return false; + } + } + + /** @var Data\Blueprint $blueprint */ + $blueprint = $user->blueprints(); + $data = $blueprint->processForm($this->admin->cleanUserPost((array)$this->data)); + $data = new Data\Data($data, $blueprint); + + try { + $data->validate(); + $data->filter(); + } catch (\Exception $e) { + $this->admin->setMessage($e->getMessage(), 'error'); + + return false; + } + + $user->update($data->toArray()); + + $user = $this->storeFiles($user); + + if ($user) { + // Event to manipulate data before saving the object + $this->grav->fireEvent('onAdminSave', new Event(['object' => &$user])); + $user->save(); + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_SAVED'), 'info'); + $this->grav->fireEvent('onAdminAfterSave', new Event(['object' => $user])); + } + + if ($user->username === $this->grav['user']->username) { + /** @var UserCollectionInterface $users */ + $users = $this->grav['accounts']; + + //Editing current user. Reload user object + $this->grav['user']->undef('avatar'); + $this->grav['user']->merge($users->load($this->admin->route)->toArray()); + } + + return true; + } + + protected function taskSaveDefault() + { + // Handle standard data types. + $obj = $this->prepareData((array)$this->data); + + try { + $obj->validate(); + } catch (\Exception $e) { + $this->admin->setMessage($e->getMessage(), 'error'); + + return false; + } + + $obj->filter(); + + $obj = $this->storeFiles($obj); + + if ($obj) { + // Event to manipulate data before saving the object + $this->grav->fireEvent('onAdminSave', new Event(['object' => &$obj])); + $obj->save(); + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_SAVED'), 'info'); + $this->grav->fireEvent('onAdminAfterSave', new Event(['object' => $obj])); + } + + // Force configuration reload. + /** @var Config $config */ + $config = $this->grav['config']; + $config->reload(); + + return true; + } + + /** + * @param string $frontmatter + * + * @return bool + */ + public function checkValidFrontmatter($frontmatter) + { + try { + Yaml::parse($frontmatter); + } catch (\RuntimeException $e) { + return false; + } + + return true; + } + + /** + * Continue to the new page. + * + * @return bool True if the action was performed. + */ + public function taskContinue() + { + $data = (array)$this->data; + + if ($this->view === 'users') { + $username = strip_tags(strtolower($data['username'])); + $this->setRedirect("{$this->view}/{$username}"); + + return true; + } + + if ($this->view === 'groups') { + $this->setRedirect("{$this->view}/{$data['groupname']}"); + + return true; + } + + if ($this->view !== 'pages') { + return false; + } + + $route = $data['route'] !== '/' ? $data['route'] : ''; + $folder = $data['folder']; + // Handle @slugify-{field} value, automatically slugifies the specified field + if (0 === strpos($folder, '@slugify-')) { + $folder = \Grav\Plugin\Admin\Utils::slug($data[substr($folder, 9)]); + } + $folder = ltrim($folder, '_'); + if (!empty($data['modular'])) { + $folder = '_' . $folder; + } + $path = $route . '/' . $folder; + + $this->admin->session()->{$path} = $data; + + // Store the name and route of a page, to be used pre-filled defaults of the form in the future + $this->admin->session()->lastPageName = $data['name']; + $this->admin->session()->lastPageRoute = $data['route']; + + $this->setRedirect("{$this->view}/" . ltrim($path, '/')); + + return true; + } + + /** + * Toggle the gpm.releases setting + */ + protected function taskGpmRelease() + { + if (!$this->authorizeTask('configuration', ['admin.configuration', 'admin.super'])) { + return false; + } + + // Default release state + $release = 'stable'; + $reload = false; + + // Get the testing release value if set + if ($this->post['release'] === 'testing') { + $release = 'testing'; + } + + $config = $this->grav['config']; + $current_release = $config->get('system.gpm.releases'); + + // If the releases setting is different, save it in the system config + if ($current_release !== $release) { + $data = new Data\Data($config->get('system')); + $data->set('gpm.releases', $release); + + // Get the file location + $file = CompiledYamlFile::instance($this->grav['locator']->findResource('config://system.yaml')); + $data->file($file); + + // Save the configuration + $data->save(); + $config->reload(); + $reload = true; + } + + $this->admin->json_response = ['status' => 'success', 'reload' => $reload]; + + return true; + } + + /** + * Keep alive + */ + protected function taskKeepAlive() + { + exit(); + } + + /** + * Get Notifications + * + */ + protected function taskGetNotifications() + { + if (!$this->authorizeTask('dashboard', ['admin.login', 'admin.super'])) { + $this->sendJsonResponse(['status' => 'error', 'message' => 'unauthorized']); + } + + // do we need to force a reload + $refresh = $this->data['refresh'] === 'true'; + $filter = $this->data['filter'] ?? ''; + $filter_types = !empty($filter) ? array_map('trim', explode(',', $filter)) : []; + + try { + $notifications = $this->admin->getNotifications($refresh); + $notification_data = []; + + foreach ($notifications as $type => $type_notifications) { + if ($filter_types && in_array($type, $filter_types, true)) { + $twig_template = 'partials/notification-' . $type . '-block.html.twig'; + $notification_data[$type] = $this->grav['twig']->processTemplate($twig_template, ['notifications' => $type_notifications]); + } + } + + $json_response = [ + 'status' => 'success', + 'notifications' => $notification_data + ]; + } catch (\Exception $e) { + $json_response = ['status' => 'error', 'message' => $e->getMessage()]; + } + + $this->sendJsonResponse($json_response); + } + + /** Get Newsfeeds */ + protected function taskGetNewsFeed() + { + if (!$this->authorizeTask('dashboard', ['admin.login', 'admin.super'])) { + $this->sendJsonResponse(['status' => 'error', 'message' => 'unauthorized']); + } + + $refresh = $this->data['refresh'] === 'true' ? true : false; + + try { + $feed = $this->admin->getFeed($refresh); + $feed_data = $this->grav['twig']->processTemplate('partials/feed-block.html.twig', ['feed' => $feed]); + + $json_response = [ + 'status' => 'success', + 'feed_data' => $feed_data + ]; + } catch (MalformedXmlException $e) { + $json_response = ['status' => 'error', 'message' => $e->getMessage()]; + } + + $this->sendJsonResponse($json_response); + } + + /** + * Get update status from GPM + */ + protected function taskGetUpdates() + { + if (!$this->authorizeTask('dashboard', ['admin.login', 'admin.super'])) { + return false; + } + + $data = $this->post; + $flush = !empty($data['flush']); + + if (isset($this->grav['session'])) { + $this->grav['session']->close(); + } + + try { + $gpm = new GravGPM($flush); + + $resources_updates = $gpm->getUpdatable(); + foreach ($resources_updates as $key => $update) { + if (!is_iterable($update)) { + continue; + } + + foreach ($update as $slug => $item) { + $resources_updates[$key][$slug] = $item; + } + } + + if ($gpm->grav !== null) { + $grav_updates = [ + 'isUpdatable' => $gpm->grav->isUpdatable(), + 'assets' => $gpm->grav->getAssets(), + 'version' => GRAV_VERSION, + 'available' => $gpm->grav->getVersion(), + 'date' => $gpm->grav->getDate(), + 'isSymlink' => $gpm->grav->isSymlink() + ]; + + $this->admin->json_response = [ + 'status' => 'success', + 'payload' => [ + 'resources' => $resources_updates, + 'grav' => $grav_updates, + 'installed' => $gpm->countInstalled(), + 'flushed' => $flush + ] + ]; + } else { + $this->admin->json_response = ['status' => 'error', 'message' => 'Cannot connect to the GPM']; + + return false; + } + + } catch (\Exception $e) { + $this->admin->json_response = ['status' => 'error', 'message' => $e->getMessage()]; + + return false; + } + + return true; + } + + /** + * Handle getting a new package dependencies needed to be installed + * + * @return bool + */ + protected function taskGetPackagesDependencies() + { + $data = $this->post; + $packages = isset($data['packages']) ? explode(',', $data['packages']) : ''; + $packages = (array)$packages; + + try { + $this->admin->checkPackagesCanBeInstalled($packages); + $dependencies = $this->admin->getDependenciesNeededToInstall($packages); + } catch (\Exception $e) { + $this->admin->json_response = ['status' => 'error', 'message' => $e->getMessage()]; + + return false; + } + + $this->admin->json_response = ['status' => 'success', 'dependencies' => $dependencies]; + + return true; + } + + protected function taskInstallDependenciesOfPackages() + { + $data = $this->post; + $packages = isset($data['packages']) ? explode(',', $data['packages']) : ''; + $packages = (array)$packages; + + $type = $data['type'] ?? ''; + + if (!$this->authorizeTask('install ' . $type, ['admin.' . $type, 'admin.super'])) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') + ]; + + return false; + } + + try { + $dependencies = $this->admin->getDependenciesNeededToInstall($packages); + } catch (\Exception $e) { + $this->admin->json_response = ['status' => 'error', 'message' => $e->getMessage()]; + + return false; + } + + $result = Gpm::install(array_keys($dependencies), ['theme' => $type === 'theme']); + + if ($result) { + $this->admin->json_response = ['status' => 'success', 'message' => 'Dependencies installed successfully']; + } else { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INSTALLATION_FAILED') + ]; + } + + return true; + } + + protected function taskInstallPackage($reinstall = false) + { + $data = $this->post; + $package = $data['package'] ?? ''; + $type = $data['type'] ?? ''; + + if (!$this->authorizeTask('install ' . $type, ['admin.' . $type, 'admin.super'])) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') + ]; + + return false; + } + + try { + $result = Gpm::install($package, ['theme' => $type === 'theme']); + } catch (\Exception $e) { + $this->admin->json_response = ['status' => 'error', 'message' => $e->getMessage()]; + + return false; + } + + if ($result) { + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate(is_string($result) ? $result : sprintf($this->admin::translate($reinstall ?: 'PLUGIN_ADMIN.PACKAGE_X_REINSTALLED_SUCCESSFULLY', + null), $package)) + ]; + } else { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate($reinstall ?: 'PLUGIN_ADMIN.INSTALLATION_FAILED') + ]; + } + + return true; + } + + /** + * Handle removing a package + * + * @return bool + */ + protected function taskRemovePackage() + { + $data = $this->post; + $package = $data['package'] ?? ''; + $type = $data['type'] ?? ''; + + if (!$this->authorizeTask('uninstall ' . $type, ['admin.' . $type, 'admin.super'])) { + $json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') + ]; + + return $this->sendJsonResponse($json_response, 403); + } + + //check if there are packages that have this as a dependency. Abort and show which ones + $dependent_packages = $this->admin->getPackagesThatDependOnPackage($package); + if (count($dependent_packages) > 0) { + if (count($dependent_packages) > 1) { + $message = 'The installed packages ' . implode(', ', + $dependent_packages) . ' depends on this package. Please remove those first.'; + } else { + $message = 'The installed package ' . implode(', ', + $dependent_packages) . ' depends on this package. Please remove it first.'; + } + + $json_response = ['status' => 'error', 'message' => $message]; + + return $this->sendJsonResponse($json_response, 200); + } + + try { + $dependencies = $this->admin->dependenciesThatCanBeRemovedWhenRemoving($package); + $result = Gpm::uninstall($package, []); + } catch (\Exception $e) { + $json_response = ['status' => 'error', 'message' => $e->getMessage()]; + + return $this->sendJsonResponse($json_response, 200); + } + + if ($result) { + $json_response = [ + 'status' => 'success', + 'dependencies' => $dependencies, + 'message' => $this->admin::translate(is_string($result) ? $result : 'PLUGIN_ADMIN.UNINSTALL_SUCCESSFUL') + ]; + + return $this->sendJsonResponse($json_response, 200); + } + + $json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.UNINSTALL_FAILED') + ]; + + return $this->sendJsonResponse($json_response, 200); + } + + /** + * Handle reinstalling a package + */ + protected function taskReinstallPackage() + { + $data = $this->post; + + $slug = $data['slug'] ?? ''; + $type = $data['type'] ?? ''; + $package_name = $data['package_name'] ?? ''; + $current_version = $data['current_version'] ?? ''; + + if (!$this->authorizeTask('install ' . $type, ['admin.' . $type, 'admin.super'])) { + $json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') + ]; + + $this->sendJsonResponse($json_response, 403); + } + + $url = "https://getgrav.org/download/{$type}s/$slug/$current_version"; + + $result = Gpm::directInstall($url); + + if ($result === true) { + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate(sprintf($this->admin::translate('PLUGIN_ADMIN.PACKAGE_X_REINSTALLED_SUCCESSFULLY', + null), $package_name)) + ]; + } else { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.REINSTALLATION_FAILED') + ]; + } + } + + /** + * Clear the cache. + * + * @return bool True if the action was performed. + */ + protected function taskClearCache() + { + if (!$this->authorizeTask('clear cache', ['admin.cache', 'admin.super', 'admin.maintenance'])) { + return false; + } + + // get optional cleartype param + $clear_type = $this->grav['uri']->param('cleartype'); + + if ($clear_type) { + $clear = $clear_type; + } else { + $clear = 'standard'; + } + + if ($clear === 'purge') { + $msg = Cache::purgeJob(); + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $msg, + ]; + } else { + $results = Cache::clearCache($clear); + if (count($results) > 0) { + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate('PLUGIN_ADMIN.CACHE_CLEARED') . '
    ' . $this->admin::translate('PLUGIN_ADMIN.METHOD') . ': ' . $clear . '' + ]; + } else { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.ERROR_CLEARING_CACHE') + ]; + } + } + + + + return true; + } + + /** + * Clear the cache. + * + * @return bool True if the action was performed. + */ + protected function taskHideNotification() + { + if (!$this->authorizeTask('hide notification', ['admin.login'])) { + return false; + } + + $notification_id = $this->grav['uri']->param('notification_id'); + + if (!$notification_id) { + $this->admin->json_response = [ + 'status' => 'error' + ]; + + return false; + } + + $filename = $this->grav['locator']->findResource('user://data/notifications/' . $this->grav['user']->username . YAML_EXT, + true, true); + $file = CompiledYamlFile::instance($filename); + $data = (array)$file->content(); + $data[] = $notification_id; + $file->save($data); + + $this->admin->json_response = [ + 'status' => 'success' + ]; + + return true; + } + + /** + * Handle the backup action + * + * @return bool True if the action was performed. + */ + protected function taskBackup() + { + $param_sep = $this->grav['config']->get('system.param_sep', ':'); + if (!$this->authorizeTask('backup', ['admin.maintenance', 'admin.super'])) { + return false; + } + + $download = $this->grav['uri']->param('download'); + + try { + if ($download) { + $file = base64_decode(urldecode($download)); + $backups_root_dir = $this->grav['locator']->findResource('backup://', true); + + if (0 !== strpos($file, $backups_root_dir)) { + header('HTTP/1.1 401 Unauthorized'); + exit(); + } + + Utils::download($file, true); + } + + $id = $this->grav['uri']->param('id', 0); + $backup = Backups::backup($id); + } catch (\Exception $e) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.AN_ERROR_OCCURRED') . '. ' . $e->getMessage() + ]; + + return true; + } + + $download = urlencode(base64_encode($backup)); + $url = rtrim($this->grav['uri']->rootUrl(false), '/') . '/' . trim($this->admin->base, + '/') . '/task' . $param_sep . 'backup/download' . $param_sep . $download . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form'); + + + + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate('PLUGIN_ADMIN.YOUR_BACKUP_IS_READY_FOR_DOWNLOAD') . '. ' . $this->admin::translate('PLUGIN_ADMIN.DOWNLOAD_BACKUP') . '', + 'toastr' => [ + 'timeOut' => 0, + 'extendedTimeOut' => 0, + 'closeButton' => true + ] + ]; + + return true; + } + + /** + * Handle delete backup action + * + * @return bool + */ + protected function taskBackupDelete() + { + $param_sep = $this->grav['config']->get('system.param_sep', ':'); + if (!$this->authorizeTask('backup', ['admin.maintenance', 'admin.super'])) { + return false; + } + + $backup = $this->grav['uri']->param('backup', null); + + if (null !== $backup) { + $file = base64_decode(urldecode($backup)); + $backups_root_dir = $this->grav['locator']->findResource('backup://', true); + + $backup_path = $backups_root_dir . '/' . $file; + + if (file_exists($backup_path)) { + unlink($backup_path); + + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate('PLUGIN_ADMIN.BACKUP_DELETED'), + 'toastr' => [ + 'closeButton' => true + ] + ]; + } else { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.BACKUP_NOT_FOUND'), + ]; + } + } + return true; + } + + protected function taskGetChildTypes() + { + if (!$this->authorizeTask('get childtypes', ['admin.pages', 'admin.super'])) { + return false; + } + + $data = $this->post; + + $rawroute = $data['rawroute'] ?? null; + + if ($rawroute) { + /** @var PageInterface $page */ + $page = $this->grav['pages']->dispatch($rawroute); + + if ($page) { + $child_type = $page->childType(); + + if ($child_type !== '') { + $this->admin->json_response = [ + 'status' => 'success', + 'child_type' => $child_type + ]; + return true; + } + } + } + + $this->admin->json_response = [ + 'status' => 'success', + 'child_type' => '', +// 'message' => $this->admin::translate('PLUGIN_ADMIN.NO_CHILD_TYPE') + ]; + + return true; + } + + /** + * Handles filtering the page by modular/visible/routable in the pages list. + */ + protected function taskFilterPages() + { + if (!$this->authorizeTask('filter pages', ['admin.pages', 'admin.super'])) { + return; + } + + $data = $this->post; + + $flags = !empty($data['flags']) ? array_map('strtolower', explode(',', $data['flags'])) : []; + $queries = !empty($data['query']) ? explode(',', $data['query']) : []; + + /** @var Collection $collection */ + $collection = $this->grav['pages']->all(); + + if (count($flags)) { + // Filter by state + $pageStates = [ + 'modular', + 'nonmodular', + 'visible', + 'nonvisible', + 'routable', + 'nonroutable', + 'published', + 'nonpublished' + ]; + + if (count(array_intersect($pageStates, $flags)) > 0) { + if (in_array('modular', $flags, true)) { + $collection = $collection->modular(); + } + + if (in_array('nonmodular', $flags, true)) { + $collection = $collection->nonModular(); + } + + if (in_array('visible', $flags, true)) { + $collection = $collection->visible(); + } + + if (in_array('nonvisible', $flags, true)) { + $collection = $collection->nonVisible(); + } + + if (in_array('routable', $flags, true)) { + $collection = $collection->routable(); + } + + if (in_array('nonroutable', $flags, true)) { + $collection = $collection->nonRoutable(); + } + + if (in_array('published', $flags, true)) { + $collection = $collection->published(); + } + + if (in_array('nonpublished', $flags, true)) { + $collection = $collection->nonPublished(); + } + } + foreach ($pageStates as $pageState) { + if (($pageState = array_search($pageState, $flags, true)) !== false) { + unset($flags[$pageState]); + } + } + + // Filter by page type + if ($flags) { + $types = []; + + $pageTypes = array_keys(Pages::pageTypes()); + foreach ($pageTypes as $pageType) { + if (($pageKey = array_search($pageType, $flags, true)) !== false) { + $types[] = $pageType; + unset($flags[$pageKey]); + } + } + + if (count($types)) { + $collection = $collection->ofOneOfTheseTypes($types); + } + } + + // Filter by page type + if ($flags) { + $accessLevels = $flags; + $collection = $collection->ofOneOfTheseAccessLevels($accessLevels); + } + } + + if (!empty($queries)) { + foreach ($collection as $page) { + foreach ($queries as $query) { + $query = trim($query); + if (stripos($page->getRawContent(), $query) === false + && stripos($page->title(), $query) === false + && stripos($page->folder(), $query) === false + && stripos($page->slug(), \Grav\Plugin\Admin\Utils::slug($query)) === false + ) { + $collection->remove($page); + } + } + } + } + + $results = []; + foreach ($collection as $path => $page) { + $results[] = $page->route(); + } + + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate('PLUGIN_ADMIN.PAGES_FILTERED'), + 'results' => $results + ]; + $this->admin->collection = $collection; + } + + /** + * Determines the file types allowed to be uploaded + * + * @return bool True if the action was performed. + */ + protected function taskListmedia() + { + if (!$this->authorizeTask('list media', ['admin.pages', 'admin.super'])) { + return false; + } + + $media = $this->getMedia(); + if (!$media) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.NO_PAGE_FOUND') + ]; + + return false; + } + + $media_list = []; + /** + * @var string $name + * @var Medium|ImageMedium $medium + */ + foreach ($media->all() as $name => $medium) { + + $metadata = []; + $img_metadata = $medium->metadata(); + if ($img_metadata) { + $metadata = $img_metadata; + } + + // Get original name + /** @var ImageMedium $source */ + $source = method_exists($medium, 'higherQualityAlternative') ? $medium->higherQualityAlternative() : null; + + $media_list[$name] = [ + 'url' => $medium->display($medium->get('extension') === 'svg' ? 'source' : 'thumbnail')->cropZoom(400, 300)->url(), + 'size' => $medium->get('size'), + 'metadata' => $metadata, + 'original' => $source ? $source->get('filename') : null + ]; + } + + $this->admin->json_response = ['status' => 'success', 'results' => $media_list]; + + return true; + } + + /** + * @return Media + */ + protected function getMedia() + { + $this->uri = $this->uri ?? $this->grav['uri']; + $uri = $this->uri->post('uri'); + $order = $this->uri->post('order') ?: null; + + if ($uri) { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + $media_path = $locator->isStream($uri) ? $uri : null; + } else { + $page = $this->admin->page(true); + + $media_path = $page ? $page->path() : null; + } + if ($order) { + $order = array_map('trim', explode(',', $order)); + } + + return $media_path ? new Media($media_path, $order) : null; + } + + /** + * Handles adding a media file to a page + * + * @return bool True if the action was performed. + */ + protected function taskAddmedia() + { + if (!$this->authorizeTask('add media', ['admin.pages', 'admin.super'])) { + return false; + } + + /** @var Config $config */ + $config = $this->grav['config']; + + if (empty($_FILES)) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.EXCEEDED_POSTMAX_LIMIT') + ]; + + return false; + } + + if (!isset($_FILES['file']['error']) || is_array($_FILES['file']['error'])) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INVALID_PARAMETERS') + ]; + + return false; + } + + // Check $_FILES['file']['error'] value. + switch ($_FILES['file']['error']) { + case UPLOAD_ERR_OK: + break; + case UPLOAD_ERR_NO_FILE: + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.NO_FILES_SENT') + ]; + + return false; + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.EXCEEDED_FILESIZE_LIMIT') + ]; + + return false; + case UPLOAD_ERR_NO_TMP_DIR: + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.UPLOAD_ERR_NO_TMP_DIR') + ]; + + return false; + default: + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.UNKNOWN_ERRORS') + ]; + + return false; + } + + $filename = $_FILES['file']['name']; + + // Handle bad filenames. + if (!Utils::checkFilename($filename)) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD'), + $filename, 'Bad filename') + ]; + + return false; + } + + // You should also check filesize here. + $grav_limit = Utils::getUploadLimit(); + if ($grav_limit > 0 && $_FILES['file']['size'] > $grav_limit) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT') + ]; + + return false; + } + + + // Check extension + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + // If not a supported type, return + if (!$extension || !$config->get("media.types.{$extension}")) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension + ]; + + return false; + } + + + $media = $this->getMedia(); + if (!$media) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.NO_PAGE_FOUND') + ]; + + return false; + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $path = $media->getPath(); + if ($locator->isStream($path)) { + $path = $locator->findResource($path, true, true); + } + + // Upload it + if (!move_uploaded_file($_FILES['file']['tmp_name'], sprintf('%s/%s', $path, $filename))) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE') + ]; + + return false; + } + + // Add metadata if needed + $include_metadata = Grav::instance()['config']->get('system.media.auto_metadata_exif', false); + $basename = str_replace(['@3x', '@2x'], '', pathinfo($filename, PATHINFO_BASENAME)); + + $metadata = []; + + if ($include_metadata && isset($media[$basename])) { + $img_metadata = $media[$basename]->metadata(); + if ($img_metadata) { + $metadata = $img_metadata; + } + } + + $page = $this->admin->page(true); + if ($page) { + $this->grav->fireEvent('onAdminAfterAddMedia', new Event(['page' => $page])); + } + + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'), + 'metadata' => $metadata, + ]; + + return true; + } + + /** + * Handles deleting a media file from a page + * + * @return bool True if the action was performed. + */ + protected function taskDelmedia() + { + if (!$this->authorizeTask('delete media', ['admin.pages', 'admin.super'])) { + return false; + } + + $media = $this->getMedia(); + if (!$media) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.NO_PAGE_FOUND') + ]; + + return false; + } + + $filename = !empty($this->post['filename']) ? $this->post['filename'] : null; + + // Handle bad filenames. + if (!Utils::checkFilename($filename)) { + $filename = null; + } + + if (!$filename) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.NO_FILE_FOUND') + ]; + + return false; + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + $targetPath = $media->getPath() . '/' . $filename; + if ($locator->isStream($targetPath)) { + $targetPath = $locator->findResource($targetPath, true, true); + } + $fileParts = pathinfo($filename); + + $found = false; + + if (file_exists($targetPath)) { + $found = true; + $result = unlink($targetPath); + + if (!$result) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename + ]; + + return false; + } + } + + // Remove Extra Files + foreach (scandir($media->getPath(), SCANDIR_SORT_NONE) as $file) { + if (preg_match("/{$fileParts['filename']}@\d+x\.{$fileParts['extension']}(?:\.meta\.yaml)?$|{$filename}\.meta\.yaml$/", $file)) { + + $targetPath = $media->getPath() . '/' . $file; + if ($locator->isStream($targetPath)) { + $targetPath = $locator->findResource($targetPath, true, true); + } + + $result = unlink($targetPath); + + if (!$result) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename + ]; + + return false; + } + + $found = true; + } + } + + if (!$found) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.FILE_NOT_FOUND') . ': ' . $filename + ]; + + return false; + } + + $page = $this->admin->page(true); + if ($page) { + $this->grav->fireEvent('onAdminAfterDelMedia', new Event(['page' => $page])); + } + + $this->admin->json_response = [ + 'status' => 'success', + 'message' => $this->admin::translate('PLUGIN_ADMIN.FILE_DELETED') . ': ' . $filename + ]; + + return true; + } + + /** + * Process the page Markdown + * + * @return bool True if the action was performed. + */ + protected function taskProcessMarkdown() + { + if (!$this->authorizeTask('process markdown', ['admin.pages', 'admin.super'])) { + return false; + } + + try { + $page = $this->admin->page(true); + + if (!$page) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.NO_PAGE_FOUND') + ]; + + return false; + } + + $this->preparePage($page, true); + $page->header(); + $page->templateFormat('html'); + + // Add theme template paths to Twig loader + $template_paths = $this->grav['locator']->findResources('theme://templates'); + $this->grav['twig']->twig->getLoader()->addLoader(new \Twig_Loader_Filesystem($template_paths)); + + $html = $page->content(); + + $this->admin->json_response = ['status' => 'success', 'preview' => $html]; + } catch (\Exception $e) { + $this->admin->json_response = ['status' => 'error', 'message' => $e->getMessage()]; + + return false; + } + + return true; + } + + /** + * Prepare a page to be stored: update its folder, name, template, header and content + * + * @param PageInterface $page + * @param bool $clean_header + * @param string $language + */ + protected function preparePage(PageInterface $page, $clean_header = false, $language = '') + { + $input = (array)$this->data; + + if (isset($input['folder']) && $input['folder'] !== $page->value('folder')) { + $order = $page->value('order'); + $ordering = $order ? sprintf('%02d.', $order) : ''; + $page->folder($ordering . $input['folder']); + } + + if (isset($input['name']) && !empty($input['name'])) { + $type = strtolower($input['name']); + $name = preg_replace('|.*/|', '', $type); + if ($language) { + $name .= '.' . $language; + } else { + $language = $this->grav['language']; + if ($language->enabled()) { + $name .= '.' . $language->getLanguage(); + } + } + $name .= '.md'; + $page->name($name); + $page->template($type); + } + + // Special case for Expert mode: build the raw, unset content + if (isset($input['frontmatter'], $input['content'])) { + $page->raw("---\n" . (string)$input['frontmatter'] . "\n---\n" . (string)$input['content']); + unset($input['content']); + // Handle header normally + } elseif (isset($input['header'])) { + $header = $input['header']; + + foreach ($header as $key => $value) { + if ($key === 'metadata' && is_array($header[$key])) { + foreach ($header['metadata'] as $key2 => $value2) { + if (isset($input['toggleable_header']['metadata'][$key2]) && !$input['toggleable_header']['metadata'][$key2]) { + $header['metadata'][$key2] = ''; + } + } + } elseif ($key === 'taxonomy' && is_array($header[$key])) { + foreach ($header[$key] as $taxkey => $taxonomy) { + if (is_array($taxonomy) && \count($taxonomy) === 1 && trim($taxonomy[0]) === '') { + unset($header[$key][$taxkey]); + } + } + } else { + if (isset($input['toggleable_header'][$key]) && !$input['toggleable_header'][$key]) { + $header[$key] = null; + } + } + } + if ($clean_header) { + $header = Utils::arrayFilterRecursive($header, function ($k, $v) { + return !(null === $v || $v === ''); + }); + } + $page->header((object)$header); + $page->frontmatter(Yaml::dump((array)$page->header(), 20)); + } + // Fill content last because it also renders the output. + if (isset($input['content'])) { + $page->rawMarkdown((string)$input['content']); + } + } + + /** + * Save page as a new copy. + * + * @return bool True if the action was performed. + * @throws \RuntimeException + */ + protected function taskCopy() + { + if (!$this->authorizeTask('copy page', ['admin.pages', 'admin.super'])) { + return false; + } + + // Only applies to pages. + if ($this->view !== 'pages') { + return false; + } + + try { + /** @var Pages $pages */ + $pages = $this->grav['pages']; + + // Get the current page. + $original_page = $this->admin->page(true); + + // Find new parent page in order to build the path. + $parent = $original_page->parent() ?: $pages->root(); + // Make a copy of the current page and fill the updated information into it. + $page = $original_page->copy($parent); + + $order = 0; + if ($page->order()) { + $order = $this->getNextOrderInFolder($page->parent()->path()); + } + + // Make sure the header is loaded in case content was set through raw() (expert mode) + $page->header(); + + if ($page->order()) { + $page->order($order); + } + + $folder = $this->findFirstAvailable('folder', $page); + $slug = $this->findFirstAvailable('slug', $page); + + $page->path($page->parent()->path() . DS . $page->order() . $folder); + $page->route($page->parent()->route() . '/' . $slug); + $page->rawRoute($page->parent()->rawRoute() . '/' . $slug); + + // Append progressive number to the copied page title + $match = preg_split('/(\d+)(?!.*\d)/', $original_page->title(), 2, PREG_SPLIT_DELIM_CAPTURE); + $header = $page->header(); + if (!isset($match[1])) { + $header->title = $match[0] . ' 2'; + } else { + $header->title = $match[0] . ((int)$match[1] + 1); + } + + $page->header($header); + $page->save(false); + + $redirect = $this->view . $page->rawRoute(); + $header = $page->header(); + + if (isset($header->slug)) { + $match = preg_split('/-(\d+)$/', $header->slug, 2, PREG_SPLIT_DELIM_CAPTURE); + $header->slug = $match[0] . '-' . (isset($match[1]) ? (int)$match[1] + 1 : 2); + } + + $page->header($header); + + $page->save(); + + $this->grav->fireEvent('onAdminAfterSave', new Event(['page' => $page])); + + // Enqueue message and redirect to new location. + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_COPIED'), 'info'); + $this->setRedirect($redirect); + + } catch (\Exception $e) { + throw new \RuntimeException('Copying page failed on error: ' . $e->getMessage()); + } + + return true; + } + + /** + * Find the first available $item ('slug' | 'folder') for a page + * Used when copying a page, to determine the first available slot + * + * @param string $item + * @param PageInterface $page + * + * @return string The first available slot + */ + protected function findFirstAvailable($item, PageInterface $page) + { + if (!$page->parent()->children()) { + return $page->{$item}(); + } + + $withoutPrefix = function ($string) { + $match = preg_split('/^[0-9]+\./u', $string, 2, PREG_SPLIT_DELIM_CAPTURE); + + return $match[1] ?? $match[0]; + }; + + $withoutPostfix = function ($string) { + $match = preg_split('/-(\d+)$/', $string, 2, PREG_SPLIT_DELIM_CAPTURE); + + return $match[0]; + }; + + /* $appendedNumber = function ($string) { + $match = preg_split('/-(\d+)$/', $string, 2, PREG_SPLIT_DELIM_CAPTURE); + $append = (isset($match[1]) ? (int)$match[1] + 1 : 2); + + return $append; + };*/ + + $highest = 1; + $siblings = $page->parent()->children(); + $findCorrectAppendedNumber = function ($item, $page_item, $highest) use ( + $siblings, + &$findCorrectAppendedNumber, + &$withoutPrefix + ) { + foreach ($siblings as $sibling) { + if ($withoutPrefix($sibling->{$item}()) == ($highest === 1 ? $page_item : $page_item . '-' . $highest)) { + $highest = $findCorrectAppendedNumber($item, $page_item, $highest + 1); + + return $highest; + } + } + + return $highest; + }; + + $base = $withoutPrefix($withoutPostfix($page->$item())); + + $return = $base; + $highest = $findCorrectAppendedNumber($item, $base, $highest); + + if ($highest > 1) { + $return .= '-' . $highest; + } + + return $return; + } + + /** + * Reorder pages. + * + * @return bool True if the action was performed. + */ + protected function taskReorder() + { + if (!$this->authorizeTask('reorder pages', ['admin.pages', 'admin.super'])) { + return false; + } + + // Only applies to pages. + if ($this->view !== 'pages') { + return false; + } + + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.REORDERING_WAS_SUCCESSFUL'), 'info'); + + return true; + } + + /** + * Delete page. + * + * @return bool True if the action was performed. + * @throws \RuntimeException + */ + protected function taskDelete() + { + if (!$this->authorizeTask('delete page', ['admin.pages', 'admin.super'])) { + return false; + } + + // Only applies to pages. + if ($this->view !== 'pages') { + return false; + } + + try { + $page = $this->admin->page(); + + if (count($page->translatedLanguages()) > 1) { + $page->file()->delete(); + } else { + Folder::delete($page->path()); + } + + $this->grav->fireEvent('onAdminAfterDelete', new Event(['page' => $page])); + + Cache::clearCache('invalidate'); + + // Set redirect to pages list. + $redirect = 'pages'; + + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_DELETED'), 'info'); + $this->setRedirect($redirect); + + } catch (\Exception $e) { + throw new \RuntimeException('Deleting page failed on error: ' . $e->getMessage()); + } + + return true; + } + + /** + * Switch the content language. Optionally redirect to a different page. + * + */ + protected function taskSwitchlanguage() + { + if (!$this->authorizeTask('switch language', ['admin.pages', 'admin.super'])) { + return false; + } + + $data = (array)$this->data; + + if (isset($data['lang'])) { + $language = $data['lang']; + } else { + $language = $this->grav['uri']->param('lang'); + } + + if (isset($data['redirect'])) { + $redirect = 'pages/' . $data['redirect']; + } else { + $redirect = 'pages'; + } + + + if ($language) { + $this->grav['session']->admin_lang = $language ?: 'en'; + } + + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_SWITCHED_LANGUAGE'), 'info'); + + $admin_route = $this->admin->base; + $this->setRedirect('/' . $language . $admin_route . '/' . $redirect); + + return true; + } + + /** + * Handle direct install. + */ + protected function taskDirectInstall() + { + if (!$this->authorizeTask('install', ['admin.super'])) { + return false; + } + + $file_path = $this->data['file_path'] ?? null; + + if (isset($_FILES['uploaded_file'])) { + + // Check $_FILES['file']['error'] value. + switch ($_FILES['uploaded_file']['error']) { + case UPLOAD_ERR_OK: + break; + case UPLOAD_ERR_NO_FILE: + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.NO_FILES_SENT'), 'error'); + return false; + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.EXCEEDED_FILESIZE_LIMIT'), 'error'); + return false; + case UPLOAD_ERR_NO_TMP_DIR: + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.UPLOAD_ERR_NO_TMP_DIR'), 'error'); + return false; + default: + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.UNKNOWN_ERRORS'), 'error'); + return false; + } + + $file_name = $_FILES['uploaded_file']['name']; + $file_path = $_FILES['uploaded_file']['tmp_name']; + + // Handle bad filenames. + if (!Utils::checkFilename($file_name)) { + $this->admin->json_response = [ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.UNKNOWN_ERRORS') + ]; + + return false; + } + } + + + $result = Gpm::directInstall($file_path); + + if ($result === true) { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INSTALLATION_SUCCESSFUL'), 'info'); + } else { + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INSTALLATION_FAILED') . ': ' . $result, + 'error'); + } + + $this->setRedirect('/tools'); + + return true; + } + + /** + * Save the current page in a different language. Automatically switches to that language. + * + * @return bool True if the action was performed. + */ + protected function taskSaveas() + { + if (!$this->authorizeTask('save', $this->dataPermissions())) { + return false; + } + + $data = (array)$this->data; + $language = $data['lang']; + + if ($language) { + $this->grav['session']->admin_lang = $language ?: 'en'; + } + + $uri = $this->grav['uri']; + $obj = $this->admin->page($uri->route()); + $this->preparePage($obj, false, $language); + + $file = $obj->file(); + if ($file) { + $filename = $this->determineFilenameIncludingLanguage($obj->name(), $language); + + $path = $obj->path() . DS . $filename; + $aFile = File::instance($path); + $aFile->save(); + + $aPage = new Page(); + $aPage->init(new \SplFileInfo($path), $language . '.md'); + $aPage->header($obj->header()); + $aPage->rawMarkdown($obj->rawMarkdown()); + $aPage->template($obj->template()); + $aPage->validate(); + $aPage->filter(); + + $this->grav->fireEvent('onAdminSave', new Event(['page' => &$aPage])); + $aPage->save(); + $this->grav->fireEvent('onAdminAfterSave', new Event(['page' => $aPage])); + } + + $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.SUCCESSFULLY_SWITCHED_LANGUAGE'), 'info'); + $this->setRedirect('/' . $language . $uri->route()); + + return true; + } + + /** + * The what should be the new filename when saving as a new language + * + * @param string $current_filename the current file name, including .md. Example: default.en.md + * @param string $language The new language it will be saved as. Example: 'it' or 'en-GB'. + * + * @return string The new filename. Example: 'default.it' + */ + public function determineFilenameIncludingLanguage($current_filename, $language) + { + $ext = '.md'; + $filename = substr($current_filename, 0, -strlen($ext)); + $languages_enabled = $this->grav['config']->get('system.languages.supported', []); + + $parts = explode('.', trim($filename, '.')); + $lang = array_pop($parts); + + if ($lang === $language) { + return $filename . $ext; + } elseif (in_array($lang, $languages_enabled)) { + $filename = implode('.', $parts); + } + + return $filename . '.' . $language . $ext; + } +} diff --git a/user/plugins/admin/classes/gpm.php b/user/plugins/admin/classes/gpm.php new file mode 100644 index 0000000..dbaafba --- /dev/null +++ b/user/plugins/admin/classes/gpm.php @@ -0,0 +1,430 @@ + GRAV_ROOT, + 'overwrite' => true, + 'ignore_symlinks' => true, + 'skip_invalid' => true, + 'install_deps' => true, + 'theme' => false + ]; + + /** + * @param Package[]|string[]|string $packages + * @param array $options + * + * @return string|bool + */ + public static function install($packages, array $options) + { + $options = array_merge(self::$options, $options); + + if (!Installer::isGravInstance($options['destination']) || !Installer::isValidDestination($options['destination'], + [Installer::EXISTS, Installer::IS_LINK]) + ) { + return false; + } + + $packages = is_array($packages) ? $packages : [$packages]; + $count = count($packages); + + $packages = array_filter(array_map(function ($p) { + return !is_string($p) ? $p instanceof Package ? $p : false : self::GPM()->findPackage($p); + }, $packages)); + + if (!$options['skip_invalid'] && $count !== count($packages)) { + return false; + } + + $messages = ''; + + foreach ($packages as $package) { + if (isset($package->dependencies) && $options['install_deps']) { + $result = static::install($package->dependencies, $options); + + if (!$result) { + return false; + } + } + + // Check destination + Installer::isValidDestination($options['destination'] . DS . $package->install_path); + + if (!$options['overwrite'] && Installer::lastErrorCode() === Installer::EXISTS) { + return false; + } + + if (!$options['ignore_symlinks'] && Installer::lastErrorCode() === Installer::IS_LINK) { + return false; + } + + $license = Licenses::get($package->slug); + $local = static::download($package, $license); + + Installer::install($local, $options['destination'], + ['install_path' => $package->install_path, 'theme' => $options['theme']]); + Folder::delete(dirname($local)); + + $errorCode = Installer::lastErrorCode(); + if ($errorCode) { + $msg = Installer::lastErrorMsg(); + throw new \RuntimeException($msg); + } + + if (count($packages) === 1) { + $message = Installer::getMessage(); + if ($message) { + return $message; + } + + $messages .= $message; + } + } + + return $messages ?: true; + } + + /** + * @param Package[]|string[]|string $packages + * @param array $options + * + * @return string|bool + */ + public static function update($packages, array $options) + { + $options['overwrite'] = true; + + return static::install($packages, $options); + } + + /** + * @param Package[]|string[]|string $packages + * @param array $options + * + * @return string|bool + */ + public static function uninstall($packages, array $options) + { + $options = array_merge(self::$options, $options); + + $packages = (array)$packages; + $count = count($packages); + + $packages = array_filter(array_map(function ($p) { + + if (is_string($p)) { + $p = strtolower($p); + $plugin = static::GPM()->getInstalledPlugin($p); + $p = $plugin ?: static::GPM()->getInstalledTheme($p); + } + + return $p instanceof Package ? $p : false; + + }, $packages)); + + if (!$options['skip_invalid'] && $count !== count($packages)) { + return false; + } + + foreach ($packages as $package) { + + $location = Grav::instance()['locator']->findResource($package->package_type . '://' . $package->slug); + + // Check destination + Installer::isValidDestination($location); + + if (!$options['ignore_symlinks'] && Installer::lastErrorCode() === Installer::IS_LINK) { + return false; + } + + Installer::uninstall($location); + + $errorCode = Installer::lastErrorCode(); + if ($errorCode && $errorCode !== Installer::IS_LINK && $errorCode !== Installer::EXISTS) { + $msg = Installer::lastErrorMsg(); + throw new \RuntimeException($msg); + } + + if (count($packages) === 1) { + $message = Installer::getMessage(); + if ($message) { + return $message; + } + } + } + + return true; + } + + /** + * Direct install a file + * + * @param string $package_file + * + * @return string|bool + */ + public static function directInstall($package_file) + { + if (!$package_file) { + return Admin::translate('PLUGIN_ADMIN.NO_PACKAGE_NAME'); + } + + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); + $tmp_zip = $tmp_dir . '/Grav-' . uniqid('', false); + + if (Response::isRemote($package_file)) { + $zip = GravGPM::downloadPackage($package_file, $tmp_zip); + } else { + $zip = GravGPM::copyPackage($package_file, $tmp_zip); + } + + if (file_exists($zip)) { + $tmp_source = $tmp_dir . '/Grav-' . uniqid('', false); + $extracted = Installer::unZip($zip, $tmp_source); + + if (!$extracted) { + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + return Admin::translate('PLUGIN_ADMIN.PACKAGE_EXTRACTION_FAILED'); + } + + $type = GravGPM::getPackageType($extracted); + + if (!$type) { + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + return Admin::translate('PLUGIN_ADMIN.NOT_VALID_GRAV_PACKAGE'); + } + + if ($type === 'grav') { + Installer::isValidDestination(GRAV_ROOT . '/system'); + if (Installer::IS_LINK === Installer::lastErrorCode()) { + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + return Admin::translate('PLUGIN_ADMIN.CANNOT_OVERWRITE_SYMLINKS'); + } + + static::upgradeGrav($zip, $extracted); + } else { + $name = GravGPM::getPackageName($extracted); + + if (!$name) { + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + return Admin::translate('PLUGIN_ADMIN.NAME_COULD_NOT_BE_DETERMINED'); + } + + $install_path = GravGPM::getInstallPath($type, $name); + $is_update = file_exists($install_path); + + Installer::isValidDestination(GRAV_ROOT . DS . $install_path); + if (Installer::lastErrorCode() === Installer::IS_LINK) { + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + return Admin::translate('PLUGIN_ADMIN.CANNOT_OVERWRITE_SYMLINKS'); + } + + Installer::install($zip, GRAV_ROOT, + ['install_path' => $install_path, 'theme' => $type === 'theme', 'is_update' => $is_update], + $extracted); + } + + Folder::delete($tmp_source); + + if (Installer::lastErrorCode()) { + return Installer::lastErrorMsg(); + } + + } else { + return Admin::translate('PLUGIN_ADMIN.ZIP_PACKAGE_NOT_FOUND'); + } + + Folder::delete($tmp_zip); + + return true; + } + + /** + * @param Package $package + * + * @return string + */ + private static function download(Package $package, $license = null) + { + $query = ''; + + if ($package->premium) { + $query = \json_encode(array_merge($package->premium, [ + 'slug' => $package->slug, + 'filename' => $package->premium['filename'], + 'license_key' => $license + ])); + + $query = '?d=' . base64_encode($query); + } + + try { + $contents = Response::get($package->zipball_url . $query, []); + } catch (\Exception $e) { + throw new \RuntimeException($e->getMessage()); + } + + $tmp_dir = Admin::getTempDir() . '/Grav-' . uniqid('', false); + Folder::mkdir($tmp_dir); + + $bad_chars = array_merge(array_map('chr', range(0, 31)), ['<', '>', ':', '"', '/', '\\', '|', '?', '*']); + + $filename = $package->slug . str_replace($bad_chars, '', basename($package->zipball_url)); + $filename = preg_replace('/[\\\\\/:"*?&<>|]+/m', '-', $filename); + + file_put_contents($tmp_dir . DS . $filename . '.zip', $contents); + + return $tmp_dir . DS . $filename . '.zip'; + } + + /** + * @param array $package + * @param string $tmp + * + * @return string + */ + private static function _downloadSelfupgrade(array $package, $tmp) + { + $output = Response::get($package['download'], []); + Folder::mkdir($tmp); + file_put_contents($tmp . DS . $package['name'], $output); + + return $tmp . DS . $package['name']; + } + + /** + * @return bool + */ + public static function selfupgrade() + { + $upgrader = new Upgrader(); + + if (!Installer::isGravInstance(GRAV_ROOT)) { + return false; + } + + if (is_link(GRAV_ROOT . DS . 'index.php')) { + Installer::setError(Installer::IS_LINK); + + return false; + } + + if (method_exists($upgrader, 'meetsRequirements') && + method_exists($upgrader, 'minPHPVersion') && + !$upgrader->meetsRequirements()) { + $error = []; + $error[] = '

    Grav has increased the minimum PHP requirement.
    '; + $error[] = 'You are currently running PHP ' . phpversion() . ''; + $error[] = ', but PHP ' . $upgrader->minPHPVersion() . ' is required.

    '; + $error[] = '

    Additional information

    '; + + Installer::setError(implode("\n", $error)); + + return false; + } + + $update = $upgrader->getAssets()['grav-update']; + $tmp = Admin::getTempDir() . '/Grav-' . uniqid('', false); + if ($tmp) { + $file = self::_downloadSelfupgrade($update, $tmp); + $folder = Installer::unZip($file, $tmp . '/zip'); + $keepFolder = false; + } else { + // If you make $tmp empty, you can install your local copy of Grav (for testing purposes only). + $file = 'grav.zip'; + $folder = '~/phpstorm/grav-clones/grav'; + //$folder = '/home/matias/phpstorm/rockettheme/grav-devtools/grav-clones/grav'; + $keepFolder = true; + } + + static::upgradeGrav($file, $folder, $keepFolder); + + $errorCode = Installer::lastErrorCode(); + + if ($tmp) { + Folder::delete($tmp); + } + + return !(is_string($errorCode) || ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR))); + } + + private static function upgradeGrav($zip, $folder, $keepFolder = false) + { + static $ignores = [ + 'backup', + 'cache', + 'images', + 'logs', + 'tmp', + 'user', + '.htaccess', + 'robots.txt' + ]; + + if (!is_dir($folder)) { + Installer::setError('Invalid source folder'); + } + + try { + $script = $folder . '/system/install.php'; + /** Install $installer */ + if ((file_exists($script) && $install = include $script) && is_callable($install)) { + $install($zip); + } else { + Installer::install( + $zip, + GRAV_ROOT, + ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $ignores], + $folder, + $keepFolder + ); + + Cache::clearCache(); + } + } catch (\Exception $e) { + Installer::setError($e->getMessage()); + } + } +} diff --git a/user/plugins/admin/classes/popularity.php b/user/plugins/admin/classes/popularity.php new file mode 100644 index 0000000..7eef0cb --- /dev/null +++ b/user/plugins/admin/classes/popularity.php @@ -0,0 +1,303 @@ +config = Grav::instance()['config']; + + $this->data_path = Grav::instance()['locator']->findResource('log://popularity', true, true); + $this->daily_file = $this->data_path . '/' . self::DAILY_FILE; + $this->monthly_file = $this->data_path . '/' . self::MONTHLY_FILE; + $this->totals_file = $this->data_path . '/' . self::TOTALS_FILE; + $this->visitors_file = $this->data_path . '/' . self::VISITORS_FILE; + + } + + public function trackHit() + { + // Don't track bot or crawler requests + if (!Grav::instance()['browser']->isHuman()) { + return; + } + + // Respect visitors "do not track" setting + if (!Grav::instance()['browser']->isTrackable()) { + return; + } + + /** @var PageInterface $page */ + $page = Grav::instance()['page']; + $relative_url = str_replace(Grav::instance()['base_url_relative'], '', $page->url()); + + // Don't track error pages or pages that have no route + if ($page->template() === 'error' || !$page->route()) { + return; + } + + // Make sure no 'widcard-style' ignore matches this url + foreach ((array)$this->config->get('plugins.admin.popularity.ignore') as $ignore) { + if (fnmatch($ignore, $relative_url)) { + return; + } + } + + // initial creation if it doesn't exist + if (!file_exists($this->data_path)) { + mkdir($this->data_path); + $this->flushPopularity(); + } + + // Update the data we want to track + $this->updateDaily(); + $this->updateMonthly(); + $this->updateTotals($page->route()); + $this->updateVisitors(Grav::instance()['uri']->ip()); + + } + + protected function updateDaily() + { + + if (!$this->daily_data) { + $this->daily_data = $this->getData($this->daily_file); + } + + $day_month_year = date(self::DAILY_FORMAT); + + // get the daily access count + if (array_key_exists($day_month_year, $this->daily_data)) { + $this->daily_data[$day_month_year] = (int)$this->daily_data[$day_month_year] + 1; + } else { + $this->daily_data[$day_month_year] = 1; + } + + // keep correct number as set by history + $count = (int)$this->config->get('plugins.admin.popularity.history.daily', 30); + $total = count($this->daily_data); + + if ($total > $count) { + $this->daily_data = array_slice($this->daily_data, -$count, $count, true); + } + + file_put_contents($this->daily_file, json_encode($this->daily_data)); + } + + /** + * @return array + */ + public function getDailyChartData() + { + if (!$this->daily_data) { + $this->daily_data = $this->getData($this->daily_file); + } + + $limit = (int)$this->config->get('plugins.admin.popularity.dashboard.days_of_stats', 7); + $chart_data = array_slice($this->daily_data, -$limit, $limit); + + $labels = []; + $data = []; + + /** @var Admin $admin */ + $admin = Grav::instance()['admin']; + foreach ($chart_data as $date => $count) { + $labels[] = $admin::translate([ + 'PLUGIN_ADMIN.' . strtoupper(date('D', strtotime($date)))]) . + '
    ' . date('M d', strtotime($date)); + $data[] = $count; + } + + return ['labels' => $labels, 'data' => $data]; + } + + /** + * @return int + */ + public function getDailyTotal() + { + if (!$this->daily_data) { + $this->daily_data = $this->getData($this->daily_file); + } + + if (isset($this->daily_data[date(self::DAILY_FORMAT)])) { + return $this->daily_data[date(self::DAILY_FORMAT)]; + } + + return 0; + } + + /** + * @return int + */ + public function getWeeklyTotal() + { + if (!$this->daily_data) { + $this->daily_data = $this->getData($this->daily_file); + } + + $day = 0; + $total = 0; + foreach (array_reverse($this->daily_data) as $daily) { + $total += $daily; + $day++; + if ($day === 7) { + break; + } + } + + return $total; + } + + /** + * @return int + */ + public function getMonthlyTotal() + { + if (!$this->monthly_data) { + $this->monthly_data = $this->getData($this->monthly_file); + } + if (isset($this->monthly_data[date(self::MONTHLY_FORMAT)])) { + return $this->monthly_data[date(self::MONTHLY_FORMAT)]; + } + + return 0; + } + + protected function updateMonthly() + { + + if (!$this->monthly_data) { + $this->monthly_data = $this->getData($this->monthly_file); + } + + $month_year = date(self::MONTHLY_FORMAT); + + // get the monthly access count + if (array_key_exists($month_year, $this->monthly_data)) { + $this->monthly_data[$month_year] = (int)$this->monthly_data[$month_year] + 1; + } else { + $this->monthly_data[$month_year] = 1; + } + + // keep correct number as set by history + $count = (int)$this->config->get('plugins.admin.popularity.history.monthly', 12); + $total = count($this->monthly_data); + $this->monthly_data = array_slice($this->monthly_data, $total - $count, $count); + + + file_put_contents($this->monthly_file, json_encode($this->monthly_data)); + } + + /** + * @return array + */ + protected function getMonthyChartData() + { + if (!$this->monthly_data) { + $this->monthly_data = $this->getData($this->monthly_file); + } + + $labels = []; + $data = []; + + foreach ($this->monthly_data as $date => $count) { + $labels[] = date('M', strtotime($date)); + $data[] = $count; + } + + return ['labels' => $labels, 'data' => $data]; + } + + /** + * @param string $url + */ + protected function updateTotals($url) + { + if (!$this->totals_data) { + $this->totals_data = $this->getData($this->totals_file); + } + + // get the totals for this url + if (array_key_exists($url, $this->totals_data)) { + $this->totals_data[$url] = (int)$this->totals_data[$url] + 1; + } else { + $this->totals_data[$url] = 1; + } + + file_put_contents($this->totals_file, json_encode($this->totals_data)); + } + + /** + * @param string $ip + */ + protected function updateVisitors($ip) + { + if (!$this->visitors_data) { + $this->visitors_data = $this->getData($this->visitors_file); + } + + // update with current timestamp + $this->visitors_data[hash('sha1', $ip)] = time(); + $visitors = $this->visitors_data; + arsort($visitors); + + $count = (int)$this->config->get('plugins.admin.popularity.history.visitors', 20); + $this->visitors_data = array_slice($visitors, 0, $count, true); + + file_put_contents($this->visitors_file, json_encode($this->visitors_data)); + } + + /** + * @param string $path + * + * @return array + */ + protected function getData($path) + { + if (file_exists($path)) { + return (array)json_decode(file_get_contents($path), true); + } + + return []; + } + + + public function flushPopularity() + { + file_put_contents($this->daily_file, []); + file_put_contents($this->monthly_file, []); + file_put_contents($this->totals_file, []); + file_put_contents($this->visitors_file, []); + } +} diff --git a/user/plugins/admin/classes/themes.php b/user/plugins/admin/classes/themes.php new file mode 100644 index 0000000..43883bb --- /dev/null +++ b/user/plugins/admin/classes/themes.php @@ -0,0 +1,22 @@ +grav['themes']; + $themes->configure(); + $themes->initTheme(); + + $this->grav->fireEvent('onAdminThemeInitialized'); + } +} diff --git a/user/plugins/admin/classes/utils.php b/user/plugins/admin/classes/utils.php new file mode 100644 index 0000000..067764b --- /dev/null +++ b/user/plugins/admin/classes/utils.php @@ -0,0 +1,54 @@ +find($email, ['email']); + } + + /** + * Generates a slug of the given string + * + * @param string $str + * @return string + */ + public static function slug(string $str) + { + if (function_exists('transliterator_transliterate')) { + $str = transliterator_transliterate('Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; [:Punctuation:] Remove;', $str); + } else { + $str = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $str); + } + + $str = strtolower($str); + $str = preg_replace('/[-\s]+/', '-', $str); + $str = preg_replace('/[^a-z0-9-]/i', '', $str); + $str = trim($str, '-'); + + return $str; + } +} diff --git a/user/plugins/admin/codeception.yml b/user/plugins/admin/codeception.yml new file mode 100644 index 0000000..7d317a5 --- /dev/null +++ b/user/plugins/admin/codeception.yml @@ -0,0 +1,18 @@ +actor: Tester +paths: + tests: tests + log: tests/_output + data: tests/_data + support: tests/_support + envs: tests/_envs +settings: + bootstrap: _bootstrap.php + colors: true + memory_limit: 1024M +extensions: + enabled: + - Codeception\Extension\RunFailed + # - Codeception\Extension\Recorder + +modules: + config: diff --git a/user/plugins/admin/composer.json b/user/plugins/admin/composer.json new file mode 100644 index 0000000..86ebc21 --- /dev/null +++ b/user/plugins/admin/composer.json @@ -0,0 +1,51 @@ +{ + "name": "getgrav/grav-plugin-admin", + "type": "grav-plugin", + "description": "Admin plugin for Grav CMS", + "keywords": ["admin", "plugin", "manager", "panel"], + "homepage": "https://github.com/getgrav/grav-plugin-admin", + "license": "MIT", + "authors": [ + { + "name": "Team Grav", + "email": "devs@getgrav.org", + "homepage": "https://getgrav.org", + "role": "Developer" + } + ], + "support": { + "issues": "https://github.com/getgrav/grav-plugin-admin/issues", + "irc": "https://chat.getgrav.org", + "forum": "https://discourse.getgrav.org", + "docs": "https://github.com/getgrav/grav-plugin-admin/blob/master/README.md" + }, + "require": { + "php": ">=7.1.3", + "ext-json": "*", + "composer/semver": "^1.4", + "p3k/picofeed": "@stable" + }, + "require-dev": { + "codeception/codeception": "^2.4", + "fzaninotto/faker": "^1.8", + "symfony/yaml": "~4.1", + "symfony/console": "~4.1", + "symfony/finder": "~4.1", + "symfony/event-dispatcher": "~4.1" + }, + "autoload": { + "classmap": [ + "classes/", + "admin.php" + ] + }, + "config": { + "platform": { + "php": "7.1.3" + } + }, + "scripts": { + "test": "vendor/bin/codecept run unit", + "test-windows": "vendor\\bin\\codecept run unit" + } +} diff --git a/user/plugins/admin/composer.lock b/user/plugins/admin/composer.lock new file mode 100644 index 0000000..72bf42d --- /dev/null +++ b/user/plugins/admin/composer.lock @@ -0,0 +1,3028 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "8b3c07286e8c9baf049b2da482136b3a", + "packages": [ + { + "name": "composer/semver", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "c6bea70230ef4dd483e6bbcab6005f682ed3a8de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/c6bea70230ef4dd483e6bbcab6005f682ed3a8de", + "reference": "c6bea70230ef4dd483e6bbcab6005f682ed3a8de", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.5 || ^5.0.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "time": "2020-01-13T12:06:48+00:00" + }, + { + "name": "p3k/picofeed", + "version": "v0.1.38", + "source": { + "type": "git", + "url": "https://github.com/aaronpk/picoFeed.git", + "reference": "989c0bcf2eac016a4104abce1aadff791fc287ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aaronpk/picoFeed/zipball/989c0bcf2eac016a4104abce1aadff791fc287ab", + "reference": "989c0bcf2eac016a4104abce1aadff791fc287ab", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "php": ">=5.3.0", + "zendframework/zendxml": "^1.0" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "2.0.4", + "phpunit/phpunit": "4.8.26", + "symfony/yaml": "2.8.7" + }, + "suggest": { + "ext-curl": "PicoFeed will use cURL if present" + }, + "bin": [ + "picofeed" + ], + "type": "library", + "autoload": { + "psr-0": { + "PicoFeed": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frédéric Guillot" + } + ], + "description": "Modern library to handle RSS/Atom feeds", + "homepage": "https://github.com/miniflux/picoFeed", + "time": "2017-11-30T00:16:58+00:00" + }, + { + "name": "zendframework/zendxml", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/zendframework/ZendXml.git", + "reference": "eceab37a591c9e140772a1470338258857339e00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zendframework/ZendXml/zipball/eceab37a591c9e140772a1470338258857339e00", + "reference": "eceab37a591c9e140772a1470338258857339e00", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1.4", + "zendframework/zend-coding-standard": "~1.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev", + "dev-develop": "1.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ZendXml\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Utility library for XML usage, best practices, and security in PHP", + "keywords": [ + "ZendFramework", + "security", + "xml", + "zf" + ], + "abandoned": "laminas/laminas-xml", + "time": "2019-01-22T19:42:14+00:00" + } + ], + "packages-dev": [ + { + "name": "behat/gherkin", + "version": "v4.6.1", + "source": { + "type": "git", + "url": "https://github.com/Behat/Gherkin.git", + "reference": "25bdcaf37898b4a939fa3031d5d753ced97e4759" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/25bdcaf37898b4a939fa3031d5d753ced97e4759", + "reference": "25bdcaf37898b4a939fa3031d5d753ced97e4759", + "shasum": "" + }, + "require": { + "php": ">=5.3.1" + }, + "require-dev": { + "phpunit/phpunit": "~4.5|~5", + "symfony/phpunit-bridge": "~2.7|~3|~4", + "symfony/yaml": "~2.3|~3|~4" + }, + "suggest": { + "symfony/yaml": "If you want to parse features, represented in YAML files" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-0": { + "Behat\\Gherkin": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Gherkin DSL parser for PHP 5.3", + "homepage": "http://behat.org/", + "keywords": [ + "BDD", + "Behat", + "Cucumber", + "DSL", + "gherkin", + "parser" + ], + "time": "2020-02-27T11:29:57+00:00" + }, + { + "name": "codeception/codeception", + "version": "2.5.6", + "source": { + "type": "git", + "url": "https://github.com/Codeception/Codeception.git", + "reference": "b83a9338296e706fab2ceb49de8a352fbca3dc98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Codeception/Codeception/zipball/b83a9338296e706fab2ceb49de8a352fbca3dc98", + "reference": "b83a9338296e706fab2ceb49de8a352fbca3dc98", + "shasum": "" + }, + "require": { + "behat/gherkin": "^4.4.0", + "codeception/phpunit-wrapper": "^6.0.9|^7.0.6", + "codeception/stub": "^2.0", + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "facebook/webdriver": ">=1.1.3 <2.0", + "guzzlehttp/guzzle": ">=4.1.4 <7.0", + "guzzlehttp/psr7": "~1.0", + "php": ">=5.6.0 <8.0", + "symfony/browser-kit": ">=2.7 <5.0", + "symfony/console": ">=2.7 <5.0", + "symfony/css-selector": ">=2.7 <5.0", + "symfony/dom-crawler": ">=2.7 <5.0", + "symfony/event-dispatcher": ">=2.7 <5.0", + "symfony/finder": ">=2.7 <5.0", + "symfony/yaml": ">=2.7 <5.0" + }, + "require-dev": { + "codeception/specify": "~0.3", + "facebook/graph-sdk": "~5.3", + "flow/jsonpath": "~0.2", + "monolog/monolog": "~1.8", + "pda/pheanstalk": "~3.0", + "php-amqplib/php-amqplib": "~2.4", + "predis/predis": "^1.0", + "squizlabs/php_codesniffer": "~2.0", + "symfony/process": ">=2.7 <5.0", + "vlucas/phpdotenv": "^3.0" + }, + "suggest": { + "aws/aws-sdk-php": "For using AWS Auth in REST module and Queue module", + "codeception/phpbuiltinserver": "Start and stop PHP built-in web server for your tests", + "codeception/specify": "BDD-style code blocks", + "codeception/verify": "BDD-style assertions", + "flow/jsonpath": "For using JSONPath in REST module", + "league/factory-muffin": "For DataFactory module", + "league/factory-muffin-faker": "For Faker support in DataFactory module", + "phpseclib/phpseclib": "for SFTP option in FTP Module", + "stecman/symfony-console-completion": "For BASH autocompletion", + "symfony/phpunit-bridge": "For phpunit-bridge support" + }, + "bin": [ + "codecept" + ], + "type": "library", + "extra": { + "branch-alias": [] + }, + "autoload": { + "psr-4": { + "Codeception\\": "src/Codeception", + "Codeception\\Extension\\": "ext" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Bodnarchuk", + "email": "davert@mail.ua", + "homepage": "http://codegyre.com" + } + ], + "description": "BDD-style testing framework", + "homepage": "http://codeception.com/", + "keywords": [ + "BDD", + "TDD", + "acceptance testing", + "functional testing", + "unit testing" + ], + "time": "2019-04-24T11:28:19+00:00" + }, + { + "name": "codeception/phpunit-wrapper", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/Codeception/phpunit-wrapper.git", + "reference": "bc847bd4f8f6d09012543e2a856f19fe4ecdcf3a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Codeception/phpunit-wrapper/zipball/bc847bd4f8f6d09012543e2a856f19fe4ecdcf3a", + "reference": "bc847bd4f8f6d09012543e2a856f19fe4ecdcf3a", + "shasum": "" + }, + "require": { + "phpunit/php-code-coverage": "^6.0", + "phpunit/phpunit": "7.5.*", + "sebastian/comparator": "^3.0", + "sebastian/diff": "^3.0" + }, + "require-dev": { + "codeception/specify": "*", + "vlucas/phpdotenv": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Codeception\\PHPUnit\\": "src\\" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Davert", + "email": "davert.php@resend.cc" + } + ], + "description": "PHPUnit classes used by Codeception", + "time": "2019-12-23T06:55:58+00:00" + }, + { + "name": "codeception/stub", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/Codeception/Stub.git", + "reference": "853657f988942f7afb69becf3fd0059f192c705a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Codeception/Stub/zipball/853657f988942f7afb69becf3fd0059f192c705a", + "reference": "853657f988942f7afb69becf3fd0059f192c705a", + "shasum": "" + }, + "require": { + "codeception/phpunit-wrapper": ">6.0.15 <6.1.0 | ^6.6.1 | ^7.7.1 | ^8.0.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Codeception\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Flexible Stub wrapper for PHPUnit's Mock Builder", + "time": "2019-03-02T15:35:10+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "ae466f726242e637cebdd526a7d991b9433bacf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/ae466f726242e637cebdd526a7d991b9433bacf1", + "reference": "ae466f726242e637cebdd526a7d991b9433bacf1", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.13", + "phpstan/phpstan-phpunit": "^0.11", + "phpstan/phpstan-shim": "^0.11", + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "http://ocramius.github.com/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "time": "2019-10-21T16:45:58+00:00" + }, + { + "name": "facebook/webdriver", + "version": "1.7.1", + "source": { + "type": "git", + "url": "https://github.com/php-webdriver/php-webdriver-archive.git", + "reference": "e43de70f3c7166169d0f14a374505392734160e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver-archive/zipball/e43de70f3c7166169d0f14a374505392734160e5", + "reference": "e43de70f3c7166169d0f14a374505392734160e5", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-zip": "*", + "php": "^5.6 || ~7.0", + "symfony/process": "^2.8 || ^3.1 || ^4.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.0", + "jakub-onderka/php-parallel-lint": "^0.9.2", + "php-coveralls/php-coveralls": "^2.0", + "php-mock/php-mock-phpunit": "^1.1", + "phpunit/phpunit": "^5.7", + "sebastian/environment": "^1.3.4 || ^2.0 || ^3.0", + "squizlabs/php_codesniffer": "^2.6", + "symfony/var-dumper": "^3.3 || ^4.0" + }, + "suggest": { + "ext-SimpleXML": "For Firefox profile creation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-community": "1.5-dev" + } + }, + "autoload": { + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "A PHP client for Selenium WebDriver", + "homepage": "https://github.com/facebook/php-webdriver", + "keywords": [ + "facebook", + "php", + "selenium", + "webdriver" + ], + "abandoned": "php-webdriver/webdriver", + "time": "2019-06-13T08:02:18+00:00" + }, + { + "name": "fzaninotto/faker", + "version": "v1.9.1", + "source": { + "type": "git", + "url": "https://github.com/fzaninotto/Faker.git", + "reference": "fc10d778e4b84d5bd315dad194661e091d307c6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/fc10d778e4b84d5bd315dad194661e091d307c6f", + "reference": "fc10d778e4b84d5bd315dad194661e091d307c6f", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0" + }, + "require-dev": { + "ext-intl": "*", + "phpunit/phpunit": "^4.8.35 || ^5.7", + "squizlabs/php_codesniffer": "^2.9.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "time": "2019-12-12T13:22:17+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "6.5.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "43ece0e75098b7ecd8d13918293029e555a50f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/43ece0e75098b7ecd8d13918293029e555a50f82", + "reference": "43ece0e75098b7ecd8d13918293029e555a50f82", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.0", + "guzzlehttp/psr7": "^1.6.1", + "php": ">=5.5" + }, + "require-dev": { + "ext-curl": "*", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0", + "psr/log": "^1.1" + }, + "suggest": { + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.5-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "homepage": "http://guzzlephp.org/", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "rest", + "web service" + ], + "time": "2019-12-23T11:57:10+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "v1.3.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "shasum": "" + }, + "require": { + "php": ">=5.5.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "time": "2016-12-20T10:07:11+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "1.6.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "psr/http-message": "~1.0", + "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "ext-zlib": "*", + "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" + }, + "suggest": { + "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Schultze", + "homepage": "https://github.com/Tobion" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "time": "2019-07-01T23:21:34+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "b2c28789e80a97badd14145fda39b545d83ca3ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/b2c28789e80a97badd14145fda39b545d83ca3ef", + "reference": "b2c28789e80a97badd14145fda39b545d83ca3ef", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "replace": { + "myclabs/deep-copy": "self.version" + }, + "require-dev": { + "doctrine/collections": "^1.0", + "doctrine/common": "^2.6", + "phpunit/phpunit": "^7.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + }, + "files": [ + "src/DeepCopy/deep_copy.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "time": "2020-01-17T21:11:47+00:00" + }, + { + "name": "phar-io/manifest", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-phar": "*", + "phar-io/version": "^2.0", + "php": "^5.6 || ^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "time": "2018-07-08T19:23:20+00:00" + }, + { + "name": "phar-io/version", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "time": "2018-07-08T19:19:57+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a", + "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "~6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "time": "2018-08-07T13:53:10+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "4.3.4", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "da3fd972d6bafd628114f7e7e036f45944b62e9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/da3fd972d6bafd628114f7e7e036f45944b62e9c", + "reference": "da3fd972d6bafd628114f7e7e036f45944b62e9c", + "shasum": "" + }, + "require": { + "php": "^7.0", + "phpdocumentor/reflection-common": "^1.0.0 || ^2.0.0", + "phpdocumentor/type-resolver": "~0.4 || ^1.0.0", + "webmozart/assert": "^1.0" + }, + "require-dev": { + "doctrine/instantiator": "^1.0.5", + "mockery/mockery": "^1.0", + "phpdocumentor/type-resolver": "0.4.*", + "phpunit/phpunit": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "time": "2019-12-28T18:55:12+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/2e32a6d48972b2c1976ed5d8967145b6cec4a4a9", + "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9", + "shasum": "" + }, + "require": { + "php": "^7.1", + "phpdocumentor/reflection-common": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "^7.1", + "mockery/mockery": "~1", + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "time": "2019-08-22T18:11:29+00:00" + }, + { + "name": "phpspec/prophecy", + "version": "v1.10.3", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy.git", + "reference": "451c3cd1418cf640de218914901e51b064abb093" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093", + "reference": "451c3cd1418cf640de218914901e51b064abb093", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "php": "^5.3|^7.0", + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0", + "sebastian/comparator": "^1.2.3|^2.0|^3.0|^4.0", + "sebastian/recursion-context": "^1.0|^2.0|^3.0|^4.0" + }, + "require-dev": { + "phpspec/phpspec": "^2.5 || ^3.2", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Prophecy\\": "src/Prophecy" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "email": "marcello.duarte@gmail.com" + } + ], + "description": "Highly opinionated mocking framework for PHP 5.3+", + "homepage": "https://github.com/phpspec/prophecy", + "keywords": [ + "Double", + "Dummy", + "fake", + "mock", + "spy", + "stub" + ], + "time": "2020-03-05T15:02:03+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "6.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlwriter": "*", + "php": "^7.1", + "phpunit/php-file-iterator": "^2.0", + "phpunit/php-text-template": "^1.2.1", + "phpunit/php-token-stream": "^3.0", + "sebastian/code-unit-reverse-lookup": "^1.0.1", + "sebastian/environment": "^3.1 || ^4.0", + "sebastian/version": "^2.0.1", + "theseer/tokenizer": "^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.0" + }, + "suggest": { + "ext-xdebug": "^2.6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "time": "2018-10-31T16:06:48+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "050bedf145a257b1ff02746c31894800e5122946" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946", + "reference": "050bedf145a257b1ff02746c31894800e5122946", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "time": "2018-09-13T20:33:42+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "time": "2015-06-21T13:50:34+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e", + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "time": "2019-06-07T04:22:29+00:00" + }, + { + "name": "phpunit/php-token-stream", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-token-stream.git", + "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/995192df77f63a59e47f025390d2d1fdf8f425ff", + "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Wrapper around PHP's tokenizer extension.", + "homepage": "https://github.com/sebastianbergmann/php-token-stream/", + "keywords": [ + "tokenizer" + ], + "time": "2019-09-17T06:23:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "7.5.20", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "9467db479d1b0487c99733bb1e7944d32deded2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9467db479d1b0487c99733bb1e7944d32deded2c", + "reference": "9467db479d1b0487c99733bb1e7944d32deded2c", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.1", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "myclabs/deep-copy": "^1.7", + "phar-io/manifest": "^1.0.2", + "phar-io/version": "^2.0", + "php": "^7.1", + "phpspec/prophecy": "^1.7", + "phpunit/php-code-coverage": "^6.0.7", + "phpunit/php-file-iterator": "^2.0.1", + "phpunit/php-text-template": "^1.2.1", + "phpunit/php-timer": "^2.1", + "sebastian/comparator": "^3.0", + "sebastian/diff": "^3.0", + "sebastian/environment": "^4.0", + "sebastian/exporter": "^3.1", + "sebastian/global-state": "^2.0", + "sebastian/object-enumerator": "^3.0.3", + "sebastian/resource-operations": "^2.0", + "sebastian/version": "^2.0.1" + }, + "conflict": { + "phpunit/phpunit-mock-objects": "*" + }, + "require-dev": { + "ext-pdo": "*" + }, + "suggest": { + "ext-soap": "*", + "ext-xdebug": "*", + "phpunit/php-invoker": "^2.0" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.5-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "time": "2020-01-08T08:45:45+00:00" + }, + { + "name": "psr/container", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "time": "2017-02-14T16:28:37+00:00" + }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/4419fcdb5eabb9caa61a27c7a1db532a6b55dd18", + "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.7 || ^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "time": "2017-03-04T06:30:41+00:00" + }, + { + "name": "sebastian/comparator", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "shasum": "" + }, + "require": { + "php": "^7.1", + "sebastian/diff": "^3.0", + "sebastian/exporter": "^3.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "time": "2018-07-12T15:12:46+00:00" + }, + { + "name": "sebastian/diff", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29", + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.0", + "symfony/process": "^2 || ^3.3 || ^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "time": "2019-02-04T06:01:07+00:00" + }, + { + "name": "sebastian/environment", + "version": "4.2.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/464c90d7bdf5ad4e8a6aea15c091fec0603d4368", + "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.5" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "time": "2019-11-20T08:46:58+00:00" + }, + { + "name": "sebastian/exporter", + "version": "3.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/68609e1261d215ea5b21b7987539cbfbe156ec3e", + "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e", + "shasum": "" + }, + "require": { + "php": "^7.0", + "sebastian/recursion-context": "^3.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "http://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "time": "2019-09-14T09:02:43+00:00" + }, + { + "name": "sebastian/global-state", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4", + "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4", + "shasum": "" + }, + "require": { + "php": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "time": "2017-04-27T15:39:26+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/7cfd9e65d11ffb5af41198476395774d4c8a84c5", + "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5", + "shasum": "" + }, + "require": { + "php": "^7.0", + "sebastian/object-reflector": "^1.1.1", + "sebastian/recursion-context": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "time": "2017-08-03T12:35:26+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "773f97c67f28de00d397be301821b06708fca0be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/773f97c67f28de00d397be301821b06708fca0be", + "reference": "773f97c67f28de00d397be301821b06708fca0be", + "shasum": "" + }, + "require": { + "php": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "time": "2017-03-29T09:07:27+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8", + "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8", + "shasum": "" + }, + "require": { + "php": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "time": "2017-03-03T06:23:57+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9", + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "time": "2018-10-04T04:07:39+00:00" + }, + { + "name": "sebastian/version", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019", + "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "time": "2016-10-03T07:35:21+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v4.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "090ce406505149d6852a7c03b0346dec3b8cf612" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/090ce406505149d6852a7c03b0346dec3b8cf612", + "reference": "090ce406505149d6852a7c03b0346dec3b8cf612", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "symfony/dom-crawler": "^3.4|^4.0|^5.0" + }, + "require-dev": { + "symfony/css-selector": "^3.4|^4.0|^5.0", + "symfony/http-client": "^4.3|^5.0", + "symfony/mime": "^4.3|^5.0", + "symfony/process": "^3.4|^4.0|^5.0" + }, + "suggest": { + "symfony/process": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony BrowserKit Component", + "homepage": "https://symfony.com", + "time": "2020-02-23T10:00:59+00:00" + }, + { + "name": "symfony/console", + "version": "v4.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "4fa15ae7be74e53f6ec8c83ed403b97e23b665e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/4fa15ae7be74e53f6ec8c83ed403b97e23b665e9", + "reference": "4fa15ae7be74e53f6ec8c83ed403b97e23b665e9", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/service-contracts": "^1.1|^2" + }, + "conflict": { + "symfony/dependency-injection": "<3.4", + "symfony/event-dispatcher": "<4.3|>=5", + "symfony/lock": "<4.4", + "symfony/process": "<3.3" + }, + "provide": { + "psr/log-implementation": "1.0" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "^3.4|^4.0|^5.0", + "symfony/dependency-injection": "^3.4|^4.0|^5.0", + "symfony/event-dispatcher": "^4.3", + "symfony/lock": "^4.4|^5.0", + "symfony/process": "^3.4|^4.0|^5.0", + "symfony/var-dumper": "^4.3|^5.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Console Component", + "homepage": "https://symfony.com", + "time": "2020-02-24T13:10:00+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v4.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "d0a6dd288fa8848dcc3d1f58b94de6a7cc5d2d22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/d0a6dd288fa8848dcc3d1f58b94de6a7cc5d2d22", + "reference": "d0a6dd288fa8848dcc3d1f58b94de6a7cc5d2d22", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony CssSelector Component", + "homepage": "https://symfony.com", + "time": "2020-02-04T09:01:01+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v4.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "11dcf08f12f29981bf770f097a5d64d65bce5929" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/11dcf08f12f29981bf770f097a5d64d65bce5929", + "reference": "11dcf08f12f29981bf770f097a5d64d65bce5929", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "masterminds/html5": "<2.6" + }, + "require-dev": { + "masterminds/html5": "^2.6", + "symfony/css-selector": "^3.4|^4.0|^5.0" + }, + "suggest": { + "symfony/css-selector": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DomCrawler Component", + "homepage": "https://symfony.com", + "time": "2020-02-29T10:05:28+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v4.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "4ad8e149799d3128621a3a1f70e92b9897a8930d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4ad8e149799d3128621a3a1f70e92b9897a8930d", + "reference": "4ad8e149799d3128621a3a1f70e92b9897a8930d", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "symfony/event-dispatcher-contracts": "^1.1" + }, + "conflict": { + "symfony/dependency-injection": "<3.4" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "1.1" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "^3.4|^4.0|^5.0", + "symfony/dependency-injection": "^3.4|^4.0|^5.0", + "symfony/expression-language": "^3.4|^4.0|^5.0", + "symfony/http-foundation": "^3.4|^4.0|^5.0", + "symfony/service-contracts": "^1.1|^2", + "symfony/stopwatch": "^3.4|^4.0|^5.0" + }, + "suggest": { + "symfony/dependency-injection": "", + "symfony/http-kernel": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony EventDispatcher Component", + "homepage": "https://symfony.com", + "time": "2020-02-04T09:32:40+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v1.1.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c43ab685673fb6c8d84220c77897b1d6cdbe1d18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c43ab685673fb6c8d84220c77897b1d6cdbe1d18", + "reference": "c43ab685673fb6c8d84220c77897b1d6cdbe1d18", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "suggest": { + "psr/event-dispatcher": "", + "symfony/event-dispatcher-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-09-17T09:54:03+00:00" + }, + { + "name": "symfony/finder", + "version": "v4.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "ea69c129aed9fdeca781d4b77eb20b62cf5d5357" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/ea69c129aed9fdeca781d4b77eb20b62cf5d5357", + "reference": "ea69c129aed9fdeca781d4b77eb20b62cf5d5357", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Finder Component", + "homepage": "https://symfony.com", + "time": "2020-02-14T07:42:58+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "fbdeaec0df06cf3d51c93de80c7eb76e271f5a38" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/fbdeaec0df06cf3d51c93de80c7eb76e271f5a38", + "reference": "fbdeaec0df06cf3d51c93de80c7eb76e271f5a38", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.14-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "time": "2020-01-13T11:15:53+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "34094cfa9abe1f0f14f48f490772db7a775559f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/34094cfa9abe1f0f14f48f490772db7a775559f2", + "reference": "34094cfa9abe1f0f14f48f490772db7a775559f2", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.14-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "time": "2020-01-13T11:15:53+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "5e66a0fa1070bf46bec4bea7962d285108edd675" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/5e66a0fa1070bf46bec4bea7962d285108edd675", + "reference": "5e66a0fa1070bf46bec4bea7962d285108edd675", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.14-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2020-01-13T11:15:53+00:00" + }, + { + "name": "symfony/process", + "version": "v4.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "bf9166bac906c9e69fb7a11d94875e7ced97bcd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/bf9166bac906c9e69fb7a11d94875e7ced97bcd7", + "reference": "bf9166bac906c9e69fb7a11d94875e7ced97bcd7", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Process Component", + "homepage": "https://symfony.com", + "time": "2020-02-07T20:06:44+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v1.1.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/ffc7f5692092df31515df2a5ecf3b7302b3ddacf", + "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-10-14T12:27:06+00:00" + }, + { + "name": "symfony/yaml", + "version": "v4.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "94d005c176db2080e98825d98e01e8b311a97a88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/94d005c176db2080e98825d98e01e8b311a97a88", + "reference": "94d005c176db2080e98825d98e01e8b311a97a88", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/console": "<3.4" + }, + "require-dev": { + "symfony/console": "^3.4|^4.0|^5.0" + }, + "suggest": { + "symfony/console": "For validating YAML files using the lint command" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Yaml Component", + "homepage": "https://symfony.com", + "time": "2020-02-03T10:46:43+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9", + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "time": "2019-06-13T22:48:21+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.7.0", + "source": { + "type": "git", + "url": "https://github.com/webmozart/assert.git", + "reference": "aed98a490f9a8f78468232db345ab9cf606cf598" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozart/assert/zipball/aed98a490f9a8f78468232db345ab9cf606cf598", + "reference": "aed98a490f9a8f78468232db345ab9cf606cf598", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "vimeo/psalm": "<3.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36 || ^7.5.13" + }, + "type": "library", + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "time": "2020-02-14T12:15:55+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "p3k/picofeed": 0 + }, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=7.1.3", + "ext-json": "*" + }, + "platform-dev": [], + "platform-overrides": { + "php": "7.1.3" + } +} diff --git a/user/plugins/admin/hebe.json b/user/plugins/admin/hebe.json new file mode 100644 index 0000000..b99bbbb --- /dev/null +++ b/user/plugins/admin/hebe.json @@ -0,0 +1,15 @@ +{ + "project":"grav-plugin-admin", + "platforms":{ + "grav":{ + "nodes":{ + "plugin":[ + { + "source":"/", + "destination":"/user/plugins/admin" + } + ] + } + } + } +} diff --git a/user/plugins/admin/languages/ar.yaml b/user/plugins/admin/languages/ar.yaml new file mode 100644 index 0000000..cbb42ed --- /dev/null +++ b/user/plugins/admin/languages/ar.yaml @@ -0,0 +1,308 @@ +--- +PLUGIN_ADMIN: + ADMIN_BETA_MSG: "هذا إصدار بيتا! استخدم هذا في الإنتاج على مسؤوليتك الخاصة..." + ADMIN_REPORT_ISSUE: "وجدت مشكلة؟ الرجاء الإبلاغ عن GitHub." + EMAIL_FOOTER: "Powered by Grav - The Modern Flat File CMS" + LOGIN_BTN: "تسجل الدخول" + LOGIN_BTN_FORGOT: "نسيت" + LOGIN_BTN_RESET: "إعادة تعيين كلمة المرور" + LOGIN_BTN_SEND_INSTRUCTIONS: "إرسال إرشادات إعادة تعيين" + LOGIN_BTN_CLEAR: "مسح النموذج" + LOGIN_BTN_CREATE_USER: "أنشاء مستخدم جديد" + LOGIN_LOGGED_IN: "لقد تم تسجيل بنجاح" + LOGIN_FAILED: "فشل تسجيل الخول" + LOGGED_OUT: "لقد قمت بتسجيل الخروج" + RESET_NEW_PASSWORD: "إدخال كلمة سر جديدة رجاءً …" + RESET_LINK_EXPIRED: "انتهت مدة صلاحية إعادة الارتباط، الرجاء المحاولة مرة أخرى" + RESET_PASSWORD_RESET: "لقد تم إعادة تعيين كلمة المرور" + RESET_INVALID_LINK: "اللينك خاطئ ، الرجاء المحاولة مرة أخرى" + FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL: "تم إرسال إرشادات إعادة تعيين كلمة المرور الخاصة بك عبر البريد الإلكتروني إلى %s" + FORGOT_FAILED_TO_EMAIL: "فشل في تعليمات البريد الإلكتروني، الرجاء المحاولة مرة أخرى لاحقاً" + FORGOT_CANNOT_RESET_EMAIL_NO_EMAIL: "لا يمكن إعادة تعيين كلمة المرور ل %s، لم يتم تعيين عنوان البريد الإلكتروني" + FORGOT_USERNAME_DOES_NOT_EXIST: "لا يوجد المستخدم مع اسم المستخدم %s" + FORGOT_EMAIL_NOT_CONFIGURED: "لا يمكن إعادة تعيين كلمة المرور. لم يتم تكوين هذا الموقع لإرسال رسائل البريد الإلكتروني" + FORGOT_EMAIL_SUBJECT: "طلب إعادة تعيين كلمة المرور %s" + FORGOT_EMAIL_BODY: "

    \"إعادة تعيين كلمة المرور\"

    عزيزي %1$s،

    طلبا قدم في %4$s لإعادة تعيين كلمة المرور الخاصة بك.


    انقر فوق هذا الخيار لإعادة تعيين الخاص بك كلمة مرور

    بدلاً من ذلك، نسخ عنوان URL التالي في شريط العناوين في المستعرض الخاص بك:

    %2$s


    أطيب التحيات،

    %3$s

    " + MANAGE_PAGES: "إدارة الصفحات" + CONFIGURATION: "الإعدادات" + PAGES: "الصفحات" + PLUGINS: "الملحقات" + PLUGIN: "البرنامج الإضافي" + THEMES: "المواضيع" + LOGOUT: "تسجيل الخروج" + BACK: "الرجوع" + NEXT: "التالي" + PREVIOUS: "السابق" + ADD_PAGE: "إضافة صفحة" + ADD_MODULAR: "إضافة وحدة" + MOVE: "انقل" + DELETE: "حذف" + UNSET: "تراجع عن التعيين" + VIEW: "عرض" + SAVE: "حفظ" + NORMAL: "عادي" + EXPERT: "خبير" + EXPAND_ALL: "عرض الكل" + COLLAPSE_ALL: "طي الكل" + ERROR: "خطأ" + CLOSE: "أغلق" + CANCEL: "إلغاء" + CONTINUE: "المتابعة" + MODAL_DELETE_PAGE_CONFIRMATION_REQUIRED_TITLE: "التأكيد مطلوب" + MODAL_CHANGED_DETECTED_TITLE: "تم الكشف عن التغييرات" + MODAL_CHANGED_DETECTED_DESC: "وقد تغييرات غير محفوظة. هل أنت متأكد من أنك تريد ترك دون الحفظ؟" + MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_TITLE: "التأكيد مطلوب" + MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_DESC: "هل أنت متأكد من حذف هذا الملف؟ لا يمكن التراجع عن هذا الإجراء." + ADD_FILTERS: "إضافة عامل تصفية" + SEARCH_PAGES: "صفحات البحث" + VERSION: "النسخة" + WAS_MADE_WITH: "تم عمله مع" + BY: "بواسطة" + UPDATE_THEME: "تحديث الموضوع" + UPDATE_PLUGIN: "تحديث البرنامج الإضافي" + OF_THIS_THEME_IS_NOW_AVAILABLE: "من هذه السمة الآن متوفرة" + OF_THIS_PLUGIN_IS_NOW_AVAILABLE: "من هذه الإضافة متوفرة الآن" + AUTHOR: "المؤلّف" + HOMEPAGE: "الصفحة الرئيسية" + DEMO: "عرض تجريبي" + BUG_TRACKER: "متتبع الأخطاء" + KEYWORDS: "الكلمات الرئيسية" + LICENSE: "الرخصة" + DESCRIPTION: "الوصف" + README: "الملف التمهيدي" + REMOVE_THEME: "إزالة الموضوع" + INSTALL_THEME: "تثبيت الموضوع" + THEME: "الموضوع" + BACK_TO_THEMES: "العودة إلى المواضيع" + BACK_TO_PLUGINS: "العودة إلى البرامج الإضافية" + CHECK_FOR_UPDATES: "التحقق من وجود تحديثات" + ADD: "أَضِف" + CLEAR_CACHE: "مسح ذاكرة التخزين المؤقتة" + CLEAR_CACHE_ALL_CACHE: "كافة المحفوظات" + CLEAR_CACHE_ASSETS_ONLY: "الاصول فقط" + CLEAR_CACHE_IMAGES_ONLY: "الصور فقط" + CLEAR_CACHE_CACHE_ONLY: "المحفوظات فقط" + CLEAR_CACHE_TMP_ONLY: "المؤقتة فقط" + DASHBOARD: "لوحة المعلومات" + UPDATES_AVAILABLE: "تحديثات متوفّرة" + DAYS: "أيام" + UPDATE: "تحديث" + BACKUP: "نسخة احتياطية" + BACKUPS: "النسخ الاحتياطية" + BACKUP_NOW: "قم بحفض نسخة احتياطية" + BACKUPS_STATS: "قم بحفض نسخة احتياطية لاحصائيات" + BACKUPS_HISTORY: "قم بحفض نسخة احتياطية للسجل" + BACKUPS_PROFILES: "قم بحفض نسخة احتياطي للحسابات" + BACKUPS_COUNT: "عدد النسخ الاحتياطية" + BACKUPS_PROFILES_COUNT: "عدد الحسابات" + BACKUPS_TOTAL_SIZE: "المساحة المستعملة" + BACKUPS_NEWEST: "النسخ الاحتياطية الحديثة" + BACKUPS_OLDEST: "النسخ الاحتياطية القديمة" + BACKUPS_PURGE: "تطهير" + BACKUPS_NOT_GENERATED: "لم يتم إنشاء نسخ احتياطية بعد..." + BACKUPS_PURGE_NUMBER: "استخدام %s من %s من فتحات النسخ الاحتياطي" + BACKUPS_PURGE_TIME: "%s ايام من النسخ الاحتياطية بقية" + BACKUPS_PURGE_SPACE: "استعمال %s من %s" + BACKUP_DELETED: "تم حذف النسخة الاحتياطية بنجاح" + BACKUP_NOT_FOUND: "لم يتم العثور على نسخة احتياطية" + BACKUP_DATE: "تاريخ النسخ الاحتياطي" + STATISTICS: "إحصائيات" + TODAY: "اليوم" + WEEK: "اسبوع" + MONTH: "شهر" + LATEST_PAGE_UPDATES: "آخر التحديثات" + MAINTENANCE: "الصيانه" + UPDATED: "تم تحديثه" + MON: "الإثنين" + TUE: "الثلاثاء" + WED: "الإربعاء" + THU: "الخميس" + FRI: "الجمعة" + SAT: "السبت" + SUN: "الأحد" + COPY: "نسخ" + EDIT: "تحرير" + CREATE: "انشاء" + GRAV_ADMIN: "مدير جريف" + GRAV_OFFICIAL_PLUGIN: "اضاف رسمية ل جريف" + GRAV_OFFICIAL_THEME: "سمة جريف رسمية" + PLUGIN_SYMBOLICALLY_LINKED: "هذه الاضافه مرتبطة بمرجع. لن يام اكتشاف التحديثات." + THEME_SYMBOLICALLY_LINKED: "هذه السمة مرتبطة بمرجع. لم يتم اكتشاف التحديثات" + REMOVE_PLUGIN: "حذف الإضافة" + INSTALL_PLUGIN: "تثبيت الإضافة" + AVAILABLE: "متوفر" + INSTALLED: "مثبت" + INSTALL: "تثبيت" + ACTIVE_THEME: "سمة نشطة" + SWITCHING_TO: "التبديل إلى" + SWITCHING_TO_DESCRIPTION: "بالتبديل إلى سمة مختلفة, لايوجد أي ضمانات بأن صفحات الواجهة مدعومة, من المحتمل أن تحدث أخطاء عند محاولة تحميل الصفحات المذكورة." + SWITCHING_TO_CONFIRMATION: "هل تريد التبديل الى السمة والمتابعة" + CREATE_NEW_USER: "إنشاء مستخدم جديد" + REMOVE_USER: "حذف المستخدم" + ACCESS_DENIED: "الدخول ممنوع" + ACCOUNT_NOT_ADMIN: "لا يملك حسابك أي صلاحيات للإدارة" + PHP_INFO: "معلومات بي إش بي" + INSTALLER: "المثبت" + AVAILABLE_THEMES: "سمات متوفرة" + AVAILABLE_PLUGINS: "إضافات متوفرة" + INSTALLED_THEMES: "السمات المثبتة" + INSTALLED_PLUGINS: "الإضافات المثبتة" + BROWSE_ERROR_LOGS: "سجل أخطاء التصفح" + SITE: "موقع" + INFO: "معلومات" + SYSTEM: "النظام" + USER: "المستخدم" + ADD_ACCOUNT: "إضافة حساب" + SWITCH_LANGUAGE: "تبديل اللغة" + SUCCESSFULLY_ENABLED_PLUGIN: "تم تفعيل الإضافة بنجاح" + SUCCESSFULLY_DISABLED_PLUGIN: "تم إيقاف الإضافة بنجاح" + SUCCESSFULLY_CHANGED_THEME: "تم تبديل السمة الإفتراضية بنجاح" + INSTALLATION_FAILED: "فشل التثبيت" + INSTALLATION_SUCCESSFUL: "تم التثبيت بنجاح" + UNINSTALL_FAILED: "فشل الغاء التثبيت" + UNINSTALL_SUCCESSFUL: "تم إلغاء التثبيت بنجاح" + SUCCESSFULLY_SAVED: "حفظ بنجاح" + SUCCESSFULLY_COPIED: "نسخ بنجاح" + REORDERING_WAS_SUCCESSFUL: "إعادة ترتيب تم بنجاح" + SUCCESSFULLY_DELETED: "تم الحذف بنجاح" + SUCCESSFULLY_SWITCHED_LANGUAGE: "تم تغيير اللغة بنجاح" + INSUFFICIENT_PERMISSIONS_FOR_TASK: "لديك أذونات غير كافية للقيام بالمهمة" + CACHE_CLEARED: "تم مسح الذاكرة المؤقتة" + METHOD: "الأسلوب" + ERROR_CLEARING_CACHE: "خطأ مسح ذاكرة التخزين المؤقت" + AN_ERROR_OCCURRED: "حدث خطأ ما" + YOUR_BACKUP_IS_READY_FOR_DOWNLOAD: "النسخة الاحتياطية جاهزة للتحميل" + DOWNLOAD_BACKUP: "تنزيل النسخة الاحتياطية" + PAGES_FILTERED: "الصفحات التي تمت تصفيتها" + NO_PAGE_FOUND: "لم يتم العثور على أي صفحة" + INVALID_PARAMETERS: "متغيرات غير صالحة" + NO_FILES_SENT: "لا توجد ملفات أرسلت حتى الآن" + EXCEEDED_FILESIZE_LIMIT: "تجاوز الحد الأقصى لحجم ملف التكوين بي إتش بي" + UNKNOWN_ERRORS: "خطأ غير معروف" + EXCEEDED_GRAV_FILESIZE_LIMIT: "تجاوز الحد الأقصى لحجم ملف التكوين بي إتش بي" + UNSUPPORTED_FILE_TYPE: "نوع ملف غير معتمد" + FAILED_TO_MOVE_UPLOADED_FILE: "فشل في تحميل الملف" + FILE_UPLOADED_SUCCESSFULLY: "تم رفع الملف بنجاح" + FILE_DELETED: "تم حذف الملف" + FILE_COULD_NOT_BE_DELETED: "لا يمكن حذف الملف" + FILE_NOT_FOUND: "لم يتم العثور على الملف" + NO_FILE_FOUND: "لا يوجد ملف" + GRAV_WAS_SUCCESSFULLY_UPDATED_TO: "تم تحديث النظام بنجاح الى" + GRAV_UPDATE_FAILED: "فشل تحديث نظام جراف" + EVERYTHING_UPDATED: "تم تحديث كل شيء" + UPDATES_FAILED: "فشل التحديث" + AVATAR_BY: "الصورة الرمزية التي" + AVATAR_UPLOAD_OWN: "أو قم بتحميل الخاصة بك..." + LAST_BACKUP: "آخر نسخ احتياطي" + FULL_NAME: "الإسم الكامل" + USERNAME: "إسم المستخدم" + EMAIL: "البريد الإلكتروني" + USERNAME_EMAIL: "إسم المستخدم أو البريد الإلكتروني" + PASSWORD: "كلمة السر" + PASSWORD_CONFIRM: "تأكيد كلمة السر" + TITLE: "العنوان" + LANGUAGE: "اللّغة" + ACCOUNT: "الحساب" + EMAIL_VALIDATION_MESSAGE: "يجب أن يكون البريد الإلكتروني صحيحاً" + PASSWORD_VALIDATION_MESSAGE: "يجب أن تحتوي كلمة المرور على الأقل على رقم وعلى حرف كبير وعلى حرف صغير، و أن تكون مكونة على الأقل من 8 أحرف أو أكثر" + LANGUAGE_HELP: "تعيين اللغة المفضلة" + MEDIA: "وسائط" + DEFAULTS: "الإعدادات الافتراضية" + SITE_TITLE: "عنوان الموقع" + SITE_TITLE_PLACEHOLDER: "عنوان الموقع العريض" + SITE_TITLE_HELP: "العنوان الإفتراضي لموقعك, غالبا يستخدم في السمات" + SITE_DEFAULT_LANG: "اللغة الإفتراضية" + SITE_DEFAULT_LANG_PLACEHOLDER: "اللغة الإفتراضية المستخدمة في السمات وسم " + SITE_DEFAULT_LANG_HELP: "اللغة الإفتراضية المستخدمة في السمات وسم " + DEFAULT_AUTHOR: "المؤلف الافتراضي" + DEFAULT_AUTHOR_HELP: "إسم المؤلف الإفتراضي, يستخدم عادة في السمات او محتوى الصفحة" + DEFAULT_EMAIL: "البريد الإلكتروني الإفتراضي" + DEFAULT_EMAIL_HELP: "البريد الإلكتروني للإشارة في المواضيع أو صفحات" + TAXONOMY_TYPES: "أنواع التصنيف" + TAXONOMY_TYPES_HELP: "يجب أن يتم تعريف أنواع التصنيف هنا إذا كنت ترغب في استخدامها في صفحات" + PAGE_SUMMARY: "ملخص الصفحة" + ENABLED: "فعال" + ENABLED_HELP: "تمكين صفحة الموجز (الملخص ترجع نفس محتوى الصفحة)" + 'YES': "نعم" + 'NO': "لا" + SUMMARY_SIZE: "حجم الملخص" + SUMMARY_SIZE_HELP: "مقدار أحرف صفحة لاستخدامها كمحتوى موجز" + FORMAT: "الشكل" + FORMAT_HELP: "اختصار = الاستخدام الأولى لوقوع محدد أو الحجم؛ = فترة طويلة سيتم تجاهل موجز محدد" + SHORT: "قصير" + LONG: "طويل" + DELIMITER: "الفاصل" + DELIMITER_HELP: "موجز محدد (الافتراضي '= = =')" + METADATA: "البيانات الوصفية" + METADATA_HELP: "قيم بيانات التعريف الافتراضية التي سيتم عرضها في كل صفحة ما لم يتم تجاوز الصفحة" + NAME: "الإسم" + CONTENT: "المحتوى" + SIZE: "حجم" + ACTION: "اجراء" + REDIRECTS_AND_ROUTES: "اعادة التوجيه و المسارات" + CUSTOM_REDIRECTS: "تخصيص اعادة التوجيه" + CUSTOM_REDIRECTS_HELP: "مسارات لإعادة التوجيه إلى صفحات أخرى. الاستبدال الاساسي ل Regex صحيح" + CUSTOM_REDIRECTS_PLACEHOLDER_KEY: "/ الخاص / الاسم المستعار" + CUSTOM_REDIRECTS_PLACEHOLDER_VALUE: "/ الخاص /إعادة التوجيه" + CUSTOM_ROUTES: "تخصيص المسارات" + CUSTOM_ROUTES_HELP: "مسارات لاسماء المستعارة إلى صفحات أخرى. الاستبدال الاساسي ل Regex صحيح" + CUSTOM_ROUTES_PLACEHOLDER_KEY: "/ الخاص / الاسم المستعار" + CUSTOM_ROUTES_PLACEHOLDER_VALUE: "/ الخاص / المسار" + FILE_STREAMS: "تيارات الملف" + DEFAULT: "الإعدادات العامة" + PAGE_MEDIA: "صور و فيديوهات الصفحة" + OPTIONS: "الخيارات" + PUBLISHED: "نُشِرَ" + PUBLISHED_HELP: "بشكل افتراضي، يتم نشر صفحة إلا إذا قمت بتعيين المنشور: false أو عن طريق publish_date يجري في المستقبل، أو unpublish_date في الماضي" + DATE: "التاريخ" + ROBOTS: "الروبوتات" + ADVANCED: "خيارات متقدمة" + SETTINGS: "الإعدادات" + FOLDER_NAME: "إسم المجلد" + MENU: "القائمة" + USE_GLOBAL: "الاستخدام العام" + ROUTABLE: "قابل للتوجيه" + ROUTABLE_HELP: "إذا أمكن ولوج هذه الصفحة عبر عنوان URL" + VISIBLE: "مرئي" + ASCENDING: "تصاعدي" + DESCENDING: "تنازلي" + PAGE_TITLE: "موضوع عنوان الصفحة" + PAGE_TITLE_HELP: "عنوان الصفحة" + PAGE: "صفحة" + MODULAR_TEMPLATE: "قالب متعدد الفقرات" + FILENAME: "اسم الملف" + PARENT_PAGE: "الصفحة الأصل" + HOME_PAGE: "الصفحة الرئيسية" + TIMEZONE: "المنطقة الزمنية" + LANGUAGES: "اللغات" + EXPIRES: "انتهاء الصلاحية" + LAST_MODIFIED: "آخر تعديل" + SESSION: "الجلسة" + CURRENT: "الحالي" + SAVE_AS: "حفظ كـ" + AND: "و" + FULLY_UPDATED: "تم تحديث النظام بالكامل" + GROUPS: "الفِرَق" + SESSION_SECURE: "آمن" + ADD_FOLDER: "إضافة مجلد" + ADD_ITEM: "إضافة عنصر" + LOADING: "جار التحميل …" + PACKAGES_SUCCESSFULLY_UPDATED: "تم تحديث حزمة أو حزمات بنجاح." + INSERT: "إدراج" + UNDO: "تراجع" + REDO: "إعادة" + HEADERS: "العناوين الرأسية" + ITALIC: "مائل" + LINK: "رابط" + IMAGE: "صورة" + PREVIEW: "معاينة" + PUBLISHING: "النشر" + IMAGE_OPTIONS: "خيارات الصورة" + ALL: "الكل" + FROM: "من" + TO: "إلى" + RELEASE_DATE: "تاريخ الإصدار" + DROPZONE_REMOVE_FILE: "إزالة الملف" + TOOLS: "الأدوات" + 2FA_CODE_INPUT: "000000" + 2FA_REGENERATE: "إعادة التوليد" diff --git a/user/plugins/admin/languages/bg.yaml b/user/plugins/admin/languages/bg.yaml new file mode 100644 index 0000000..35da781 --- /dev/null +++ b/user/plugins/admin/languages/bg.yaml @@ -0,0 +1,361 @@ +--- +PLUGIN_ADMIN: + ADMIN_BETA_MSG: "Това е Бета версия! Използвате на ваша отговорност..." + ADMIN_REPORT_ISSUE: "Открили сте проблем? Моля, съобщете за него в GitHub." + EMAIL_FOOTER: "Задвижван от Grav - Модерният Флат Файл CMS" + LOGIN_BTN: "Вход" + LOGIN_BTN_FORGOT: "Забравена парола" + LOGIN_BTN_RESET: "Промяна на паролата" + LOGIN_BTN_SEND_INSTRUCTIONS: "Изпращане на инструкциите за възстановяването" + LOGIN_BTN_CLEAR: "Изтриване на формуляра" + LOGIN_BTN_CREATE_USER: "Създаване на потребител" + LOGIN_LOGGED_IN: "Влязохте успешно" + LOGIN_FAILED: "Влизането не е успешно" + LOGGED_OUT: "Излязохте от системата" + RESET_NEW_PASSWORD: "Въведете нова парола …" + RESET_LINK_EXPIRED: "Връзката за нулиране е изтекла, опитайте отново" + RESET_PASSWORD_RESET: "Паролата е променена" + RESET_INVALID_LINK: "Използвана невалидна връзка за нулиране, моля опитайте отново" + FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL: "На Вашия имейл, бяха изпратени инструкции за възстановяване на паролата" + FORGOT_FAILED_TO_EMAIL: "Неуспешно изпращане на имейл с инструкции, опитайте по-късно" + FORGOT_CANNOT_RESET_EMAIL_NO_EMAIL: "Паролата на %s не може да бъде обновена, няма въведен имейл адрес" + FORGOT_USERNAME_DOES_NOT_EXIST: "Потребител с име %s не съществува" + FORGOT_EMAIL_NOT_CONFIGURED: "Не може да обнови паролата. Този сайт не е конфигуриран да изпраща имейли" + FORGOT_EMAIL_SUBJECT: "%s - искане за смяна на парола" + FORGOT_EMAIL_BODY: "

    Смяна на парола

    Уважаеми %1$s

    На %4$s бе направено искане за смяна на парола.


    Натиснете тук за да обновите паролата си

    Като алтернатива, можете да копирате линка в адресната лента на браузъра си:

    %2$s


    С уважение,

    %3$s

    " + MANAGE_PAGES: "Управление на страниците" + CONFIGURATION: "Настройки" + PAGES: "Страници" + PLUGINS: "Разширения" + PLUGIN: "Разширение" + THEMES: "Теми" + LOGOUT: "Изход" + BACK: "Назад" + NEXT: "Напред" + PREVIOUS: "Назад" + ADD_PAGE: "Добавяне на страница" + ADD_MODULAR: "Добавяне на модулна страница" + MOVE: "Преместване" + DELETE: "Изтриване" + UNSET: "Незададен" + VIEW: "Виж" + SAVE: "Запазване" + NORMAL: "Обикновен" + EXPERT: "Експертен" + EXPAND_ALL: "Разгъване на всички" + COLLAPSE_ALL: "Свиване на всички" + ERROR: "Грешка" + CLOSE: "Затваряне" + CANCEL: "Отказ" + CONTINUE: "Продължаване" + MODAL_DELETE_PAGE_CONFIRMATION_REQUIRED_TITLE: "Изисква се потвърждение" + MODAL_CHANGED_DETECTED_TITLE: "Засечени са промени" + MODAL_CHANGED_DETECTED_DESC: "Имате незапазени промени. Наистина ли искате да излезете без да сте ги запазили?" + MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_TITLE: "Изисква се потвърждение" + MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_DESC: "Наистина ли искате да изтриете този файл? Това действие не може да бъде отменено." + ADD_FILTERS: "Добавяне на филтри" + SEARCH_PAGES: "Търсене" + VERSION: "Версия" + WAS_MADE_WITH: "е създаден с" + BY: "от" + UPDATE_THEME: "Актуализация на тема" + UPDATE_PLUGIN: "Актуализация на разширение" + OF_THIS_THEME_IS_NOW_AVAILABLE: "на тази тема е наличен" + OF_THIS_PLUGIN_IS_NOW_AVAILABLE: "на този плъгин е вече наличен" + AUTHOR: "Автор" + HOMEPAGE: "Страница" + DEMO: "Демо" + BUG_TRACKER: "Докладване за грешки" + KEYWORDS: "Ключови думи" + LICENSE: "Лиценз" + DESCRIPTION: "Описание" + README: "Документация" + REMOVE_THEME: "Премахване на тема" + INSTALL_THEME: "Инсталиране на тема" + THEME: "Тема" + BACK_TO_THEMES: "Обратно към темите" + BACK_TO_PLUGINS: "Обратно към разширенията" + CHECK_FOR_UPDATES: "Проверка за актуализации" + ADD: "Добавяне" + CLEAR_CACHE: "Изтриване на временните файлове" + CLEAR_CACHE_ALL_CACHE: "Всички" + CLEAR_CACHE_ASSETS_ONLY: "Само Assets" + CLEAR_CACHE_IMAGES_ONLY: "Само изображенията" + CLEAR_CACHE_CACHE_ONLY: "Само временните файлове" + CLEAR_CACHE_TMP_ONLY: "само временен" + DASHBOARD: "Контролен панел" + UPDATES_AVAILABLE: "Налични актуализации" + DAYS: "Дни" + UPDATE: "Актуализация" + BACKUP: "Резервно копие" + BACKUPS: "Резервно копие" + BACKUP_NOW: "Създай резервно копие" + BACKUPS_STATS: "Статистика резервни копия" + BACKUPS_HISTORY: "История резервни копия" + BACKUPS_PURGE_CONFIG: "Настройка изтриване на резервно копие" + BACKUPS_PROFILES: "Профили на резервни копия" + BACKUPS_COUNT: "Брой резервни копия" + BACKUPS_PROFILES_COUNT: "Брой профили" + BACKUPS_TOTAL_SIZE: "Използвано пространство" + BACKUPS_NEWEST: "Най-ново резервно копие" + BACKUPS_OLDEST: "Най-старо резервно копие" + BACKUPS_PURGE: "Изтриване" + BACKUPS_NOT_GENERATED: "Все още не са генериране резервни копия..." + BACKUPS_PURGE_NUMBER: "Използва %s от %s слота за резервни копия" + BACKUPS_PURGE_TIME: "Остават %s дни за резервно копие" + BACKUPS_PURGE_SPACE: "Използва %s от %s" + BACKUP_DELETED: "Успешно изтрито резервно копие" + BACKUP_NOT_FOUND: "Не е намерено резервно копие" + BACKUP_DATE: "Данни за резервно копие" + STATISTICS: "Статистика" + TODAY: "Днес" + WEEK: "Седмица" + MONTH: "Месец" + LATEST_PAGE_UPDATES: "Скоро актуализирани страници" + MAINTENANCE: "Техническа поддръжка" + UPDATED: "Актуализиран" + MON: "пон" + TUE: "вт" + WED: "ср" + THU: "чт" + FRI: "пт" + SAT: "сб" + SUN: "нд" + COPY: "Копиране" + EDIT: "Редактиране" + CREATE: "Създаване" + GRAV_ADMIN: "Grav Admin" + GRAV_OFFICIAL_PLUGIN: "Официално разширение на Grav" + GRAV_OFFICIAL_THEME: "Официална тема на Grav" + PLUGIN_SYMBOLICALLY_LINKED: "Този плъгин е символично свързан. Актуализации няма да бъдат отразени." + THEME_SYMBOLICALLY_LINKED: "Тази тема е символично свързана. Актуализации няма да бъдат отразени." + REMOVE_PLUGIN: "Премахване на разширение" + INSTALL_PLUGIN: "Инсталиране на разширение" + AVAILABLE: "Налични" + INSTALLED: "Инсталирани" + INSTALL: "Инсталиране" + ACTIVE_THEME: "Активна тема" + SWITCHING_TO: "Превключване към" + SWITCHING_TO_DESCRIPTION: "При превключването към различна тема няма гаранция, че всички страници са поддържани, което може да доведе до потенциални грешки при опит за зареждане на тези страници." + SWITCHING_TO_CONFIRMATION: "Искате ли да продължите и да превключите към темата" + CREATE_NEW_USER: "Създаване на нов потребител" + REMOVE_USER: "Премахване на потребител" + ACCESS_DENIED: "Нямате достъп" + ACCOUNT_NOT_ADMIN: "вашият профил няма администраторски права" + PHP_INFO: "Информация за PHP" + INSTALLER: "Инсталатор" + AVAILABLE_THEMES: "Налични теми" + AVAILABLE_PLUGINS: "Налични разширения" + INSTALLED_THEMES: "Инсталирани теми" + INSTALLED_PLUGINS: "Инсталирани разширения" + BROWSE_ERROR_LOGS: "Преглед на дневниците за грешки" + SITE: "Сайт" + INFO: "Информация" + SYSTEM: "Система" + USER: "Потребител" + ADD_ACCOUNT: "Добавяне на профил" + SWITCH_LANGUAGE: "Превключване на езика" + SUCCESSFULLY_ENABLED_PLUGIN: "Разширението е активирано успешно" + SUCCESSFULLY_DISABLED_PLUGIN: "Разширението е спряно" + SUCCESSFULLY_CHANGED_THEME: "Промяната на подразбиращата се тема е успешно" + INSTALLATION_FAILED: "Неуспешна инсталация" + INSTALLATION_SUCCESSFUL: "Инсталацията е успешна" + UNINSTALL_FAILED: "Неуспешно деинсталиране" + UNINSTALL_SUCCESSFUL: "Деинсталирането е успешно" + SUCCESSFULLY_SAVED: "Успешно запазено" + SUCCESSFULLY_COPIED: "Успешно копирано" + REORDERING_WAS_SUCCESSFUL: "Записът бе успешен" + SUCCESSFULLY_DELETED: "Успешно изтрити" + SUCCESSFULLY_SWITCHED_LANGUAGE: "Езикът е променен успешно" + INSUFFICIENT_PERMISSIONS_FOR_TASK: "Нямате достатъчно права за тази задача" + CACHE_CLEARED: "Временните файлове са изчистени" + METHOD: "Метод" + ERROR_CLEARING_CACHE: "Грешка при изтриването на временните файлове" + AN_ERROR_OCCURRED: "Възникна грешка" + YOUR_BACKUP_IS_READY_FOR_DOWNLOAD: "Резервното копие е готово за изтегляне" + DOWNLOAD_BACKUP: "Изтегляне на резервното копие" + PAGES_FILTERED: "Филтрирани страници" + NO_PAGE_FOUND: "Няма намерени страници" + INVALID_PARAMETERS: "Невалидни параметри" + NO_FILES_SENT: "Няма изпратени файлове" + EXCEEDED_FILESIZE_LIMIT: "Надхвърлен лимит за размер на PHP конфигурационен файл" + UNKNOWN_ERRORS: "Неизвестни грешки" + EXCEEDED_GRAV_FILESIZE_LIMIT: "Превишен лимит за размера на конфигурационен GRAV файл" + UNSUPPORTED_FILE_TYPE: "Този файлов формат не се поддържа" + FAILED_TO_MOVE_UPLOADED_FILE: "Преместването на качения файл не е успешно." + FILE_UPLOADED_SUCCESSFULLY: "Файлът е качен успешно" + FILE_DELETED: "Файлът е изтрит" + FILE_COULD_NOT_BE_DELETED: "Файлът не може да бъде изтрит" + FILE_NOT_FOUND: "Файлът не е намерен" + NO_FILE_FOUND: "Няма намерени файлове" + GRAV_WAS_SUCCESSFULLY_UPDATED_TO: "Grav беше успешно актуализиран до" + GRAV_UPDATE_FAILED: "Актуализацията на Grav е неуспешна" + EVERYTHING_UPDATED: "Всичко е актуализирано" + UPDATES_FAILED: "Актуализациите не бяха успшено" + AVATAR_BY: "Аватар от" + AVATAR_UPLOAD_OWN: "Или качи собствени..." + LAST_BACKUP: "Последно резервно копие" + FULL_NAME: "Пълно име" + USERNAME: "Потребителско име" + EMAIL: "Ел. поща" + USERNAME_EMAIL: "Потребителско име или инейл" + PASSWORD: "Парола" + PASSWORD_CONFIRM: "Потвърждение на паролата" + TITLE: "Титла" + LANGUAGE: "Език" + ACCOUNT: "Профил" + EMAIL_VALIDATION_MESSAGE: "Ел. поща трябва да бъде валидна" + PASSWORD_VALIDATION_MESSAGE: "Паролата трябва да съдържа поне един номер, една главна буква, една малка буква и да съдържа поне 8 или повече знака" + LANGUAGE_HELP: "Задаване на любим език" + MEDIA: "Медиа" + DEFAULTS: "По подразбиране" + SITE_TITLE: "Заглавие на сайта" + SITE_TITLE_PLACEHOLDER: "Заглавие за всички страници" + SITE_TITLE_HELP: "Подразбиращо се заглавие за вашият сайт, често се използва от темите" + SITE_DEFAULT_LANG: "Език по подразбиране" + SITE_DEFAULT_LANG_PLACEHOLDER: "Език по подразбиране, използван от тага на темите" + SITE_DEFAULT_LANG_HELP: "Език по подразбиране, използван от тага на темите" + DEFAULT_AUTHOR: "Подразбиращ се автор" + DEFAULT_AUTHOR_HELP: "Име на автор по подразбиране, често използвано в теми или страници" + DEFAULT_EMAIL: "Имейл по подразбиране" + DEFAULT_EMAIL_HELP: "Имейл по подразбиране, използван в теми или страници" + TAXONOMY_TYPES: "Видове таксономии" + TAXONOMY_TYPES_HELP: "Типовете таксономия трябва да бъдат дефинирани тук, ако искате да ги използвате в страници" + PAGE_SUMMARY: "Резюме на страницата" + ENABLED: "Включен" + ENABLED_HELP: "Разреши извлечение (извлечението връща същото съдържание, като в страницата)" + 'YES': "Да" + 'NO': "Не" + SUMMARY_SIZE: "Размер на резюмето" + SUMMARY_SIZE_HELP: "Брой знаци, които да бъдат използвани при създаването на резюме за страницата" + FORMAT: "Формат" + SHORT: "Къс" + LONG: "Дълъг" + DELIMITER: "Делител" + DELIMITER_HELP: "Разделител на извлечението (по подразбиране '===')" + METADATA: "Мета-данни" + METADATA_HELP: "Ще бъдат показани метадата стойностите по подразбиране на всяка страница, освен ако не са отхвърлени от страницата" + NAME: "Име" + CONTENT: "Съдържание" + SIZE: "Размер" + ACTION: "Действие" + REDIRECTS_AND_ROUTES: "Пренасочвания и пътища" + CUSTOM_REDIRECTS: "Потребителски пренасочвания" + CUSTOM_REDIRECTS_HELP: "маршрути за пренасочване към сдруга страница. Подмяна със стандартен Regex е валидна" + CUSTOM_REDIRECTS_PLACEHOLDER_KEY: "/your/alias" + CUSTOM_REDIRECTS_PLACEHOLDER_VALUE: "/your/redirect" + CUSTOM_ROUTES: "Потребителски пренасочвания" + CUSTOM_ROUTES_PLACEHOLDER_KEY: "/your/alias" + CUSTOM_ROUTES_PLACEHOLDER_VALUE: "/your/route" + FILE_STREAMS: "Потоци файлове" + DEFAULT: "По подразбиране" + PAGE_MEDIA: "Страница с медия" + OPTIONS: "Опции" + PUBLISHED: "Публикувано" + PUBLISHED_HELP: "По подразбиране страницата се публикува, освен ако не е зададео published: false или publish_date е в бъдеще или unpublish_date е в миналото" + DATE: "Дата" + DATE_HELP: "Променливата за дата позволява да се зададе дата асоциирана със страницата." + PUBLISHED_DATE: "Дата на публикуване" + PUBLISHED_DATE_HELP: "Дата, на която автоматично ще се публикува." + UNPUBLISHED_DATE: "Дата на непубликуване" + UNPUBLISHED_DATE_HELP: "Може да зададе дата за автоматично непубликуване." + ROBOTS: "Роботи" + TAXONOMIES: "Таксономии" + TAXONOMY: "Таксономия" + ADVANCED: "Разширено" + SETTINGS: "Настройки" + FOLDER_NUMERIC_PREFIX: "Цифров префикс на папка" + FOLDER_NUMERIC_PREFIX_HELP: "Цифров префикс осигуряващ ръчно подреждане и по-добра видимост" + FOLDER_NAME: "Име на папка" + FOLDER_NAME_HELP: "Името на папката, която ще се съхрани във файлова система за тази страница" + PARENT: "Родител" + DEFAULT_OPTION_ROOT: "- Коренова папка -" + DEFAULT_OPTION_SELECT: "- Избор -" + DISPLAY_TEMPLATE: "Показване на шаблон" + BODY_CLASSES: "Класове на тялото" + ORDERING: "Подреждане" + PAGE_ORDER: "Подредба на страниците" + OVERRIDES: "Замени" + MENU: "Меню" + MENU_HELP: "Стрингът, който ще се използва в меню. Ако не се зададе, ще се използва Title." + SLUG: "Слъг" + SLUG_HELP: "Слъг променливата Ви позволява да зададете конкретна порция от URL на страницата" + SLUG_VALIDATE_MESSAGE: "Слъговете могат да съдържат само малки букви, цифри и тирета" + PROCESS: "Обработване" + PROCESS_HELP: "Контролирайте обработката на страниците. Може да бъде за отделна страница или глобално" + DEFAULT_CHILD_TYPE: "Тип по подразбиране" + USE_GLOBAL: "Използвай глобални" + ROUTABLE: "Маршрутизируем" + ROUTABLE_HELP: "Ако страницата е достъпна през URL" + CACHING: "Създаване на временни файлове" + VISIBLE: "Видим" + VISIBLE_HELP: "Определя дали една страница е видима в навигацията." + DISABLED: "Изключено" + ITEMS: "Елементи" + ORDER_BY: "Подреждане по" + ORDER: "Подреждане" + FOLDER: "Папка" + ASCENDING: "Възходящо" + DESCENDING: "Низходящо" + ADD_MODULAR_CONTENT: "Добави модулно съдържание" + PAGE_TITLE: "Заглавие на страницата" + PAGE_TITLE_HELP: "Заглавието на страницата" + PAGE: "Страница" + MODULAR_TEMPLATE: "Модулен шаблон" + FRONTMATTER: "Встъпление" + FILENAME: "Име на файла" + PARENT_PAGE: "Родителска страница" + HOME_PAGE: "Начална страница" + HOME_PAGE_HELP: "Страницата, която Grav ще използва по подразбиране за начална страница" + DEFAULT_THEME: "Тема по подразбиране" + DEFAULT_THEME_HELP: "Задаване на темата по подразбиране, която Grav ще използва (по подразбиране това е Antimatter)" + TIMEZONE: "Часова зона" + TIMEZONE_HELP: "Презаписване на времевата зона на сървъра" + SHORT_DATE_FORMAT: "Кратък формат дата" + SHORT_DATE_FORMAT_HELP: "Задай кратък формат дата, който може да се използва от темите" + LONG_DATE_FORMAT: "Пълен формат дата" + LONG_DATE_FORMAT_HELP: "Задай пълен формат дата, който може да се използва от темите" + DEFAULT_ORDERING: "По подразбиране" + DEFAULT_ORDERING_DEFAULT: "По подразбиране - според име на папка" + DEFAULT_ORDERING_FOLDER: "Папка - според името на папката без префикс" + DEFAULT_ORDERING_TITLE: "Заглавие - според заглавно поле в главата" + DEFAULT_ORDERING_DATE: "Дата - според поле за дата в главата" + DEFAULT_ORDER_DIRECTION: "Подреждане по подразбиране" + DEFAULT_ORDER_DIRECTION_HELP: "Посоката на страниците в списък" + DEFAULT_PAGE_COUNT: "Брой страници по подразбиране" + DEFAULT_PAGE_COUNT_HELP: "Максимален брой страници в списък по подразбиране" + DATE_BASED_PUBLISHING: "Публикуване според датата" + DATE_BASED_PUBLISHING_HELP: "Автоматично (не)публикувай постове според датата" + EVENTS: "Събития" + EVENTS_HELP: "Пускане или спиране на специфични събития. Спирането на някои събития може да счупи определени приставки" + REDIRECT_DEFAULT_ROUTE: "Пренасочване на пътя по подразбиране" + REDIRECT_DEFAULT_ROUTE_HELP: "Автоматично пренасочване към пътя по подразбиране на страницата" + LANGUAGES: "Езици" + SUPPORTED: "Поддържани" + SUPPORTED_HELP: "Списък от двубуквени езикови кодове, отделени със запетая (пример 'bg,en,de')" + TRANSLATIONS_ENABLED: "Преводите са включен" + HTTP_HEADERS: "HTTP заглавки" + EXPIRES: "Изтича на" + CACHE_CONTROL: "HTTP кеш-контрол" + LAST_MODIFIED: "Последна промяна" + CACHE_CHECK_METHOD: "Мотод проверка на кеша" + CACHE_DRIVER: "Кеш драйвър" + CACHE_PREFIX: "Кеш представка" + CACHE_PURGE: "Изтриване на стр кеш" + LIFETIME: "Продължителност на живот" + GZIP_COMPRESSION: "Gzip компресия" + GZIP_COMPRESSION_HELP: "Разреши GZip компресия на Grav страницата, за оптимизация." + CSS_PIPELINE: "CSS pipeline" + JAVASCRIPT_PIPELINE: "JavaScript pipeline" + LOG_HANDLER: "Обработка на лога" + DEBUGGER: "Дибъгър" + SESSION: "Сесия" + TIMEOUT: "Таймаут" + CURRENT: "Текущ" + SAVE_AS: "Запази като" + AND: "и" + UPDATE_AVAILABLE: "Налична актуализация" + FULLY_UPDATED: "Напълно обновен" + SAVE_LOCATION: "Местоположение за запис" + IGNORE_HIDDEN_HELP: "Игнорирай всички файлове и папки, които започват с точка" + WRAPPED_SITE: "Опаковани сайт" diff --git a/user/plugins/admin/languages/br.yaml b/user/plugins/admin/languages/br.yaml new file mode 100644 index 0000000..cdd75df --- /dev/null +++ b/user/plugins/admin/languages/br.yaml @@ -0,0 +1,600 @@ +--- +PLUGIN_ADMIN: + ADMIN_BETA_MSG: "Un ermaeziadenn beta an hini eo! Arverit en endro produadur gant evezh..." + ADMIN_REPORT_ISSUE: "Kavet hoc'h eus ur gudenn? Danevellit anezhi war Github." + EMAIL_FOOTER: "Lusket gant Grav - Ar CMS Restr plad modern" + LOGIN_BTN: "Anv arveriad" + LOGIN_BTN_FORGOT: "Ankouaet" + LOGIN_BTN_RESET: "Adderaouekaat ar ger-tremen" + LOGIN_BTN_SEND_INSTRUCTIONS: "Kas an ditouroù adderaouekaat" + LOGIN_BTN_CLEAR: "Skarzhañ ar furmskrid" + LOGIN_BTN_CREATE_USER: "Krouiñ an arveriad" + LOGIN_LOGGED_IN: "Kennasket oc'h gant berzh" + LOGIN_FAILED: "C'hwitadenn war ar c'hennask" + LOGGED_OUT: "Digennasket oc'h" + RESET_NEW_PASSWORD: "Enankit ur ger-tremen nevez …" + RESET_LINK_EXPIRED: "Diamzeret eo an ere adderaouekaat, klaskit en-dro" + RESET_PASSWORD_RESET: "Adderaouekaet eo bet ar ger-tremen" + RESET_INVALID_LINK: "Ere adderaouekaat didalvoudek, klaskit en-dro" + FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL: "Kaset eo bet an ditouroù da adderaouekaat ho ker-tremen d'ho chmolec'h postel" + FORGOT_FAILED_TO_EMAIL: "C'hwitadenn en ur gas an ditouroù, klaskit en-dro diwezhatoc'h" + FORGOT_CANNOT_RESET_EMAIL_NO_EMAIL: "N'haller ket adderaouekaat ar ger-tremen evit %s, chomlec'h postel ebet arventennet" + FORGOT_USERNAME_DOES_NOT_EXIST: "N'eus ket eus an arveriad gant an anv %s" + FORGOT_EMAIL_NOT_CONFIGURED: "N'haller ket adderaouekaat ar ger-tremen. N'eo ket kefluniet al lec'hienn evit kas posteloù" + FORGOT_EMAIL_SUBJECT: "%s Goulenn adderaouekaat ar ger-tremen" + FORGOT_EMAIL_BODY: "

    Adderaouekaat ar ger-tremen/h1>

    %1$s,

    Graet eo bet un azgoulenn war %4$s evit adderaouekaat ho ker-tremen.


    Klikit amañ da adderaouekaat ho ker-tremen

    Gallout a rit ivez eilañ an URL da heul er varrenn chomlec'h en ho merdeer:

    %2$s


    A galon,

    %3$s

    " + MANAGE_PAGES: "Ardeiñ ar pajennoù" + CONFIGURATION: "Kefluniadur" + PAGES: "Pajennoù" + PLUGINS: "Enlugelladoù" + PLUGIN: "Enlugellad" + THEMES: "Neuzioù" + LOGOUT: "Digennaskañ" + BACK: "Distreiñ" + NEXT: "War-lerc'h" + PREVIOUS: "Diaraog" + ADD_PAGE: "Ouzhpennañ ur bajenn" + ADD_MODULAR: "Ouzhpennañ ur mollad" + MOVE: "Dilec'hiañ" + DELETE: "Dilemel" + VIEW: "Gwel" + SAVE: "Enrollañ" + NORMAL: "Reoliek" + EXPERT: "Kemplezhoc'h" + EXPAND_ALL: "Astenn an holl" + COLLAPSE_ALL: "Bihanaat an holl" + ERROR: "Fazi" + CLOSE: "Serriñ" + CANCEL: "Nullañ" + CONTINUE: "Kenderc'hel" + MODAL_DELETE_PAGE_CONFIRMATION_REQUIRED_TITLE: "Kadarnadur azgoulennet" + MODAL_CHANGED_DETECTED_TITLE: "Kemmoù dinoet" + MODAL_CHANGED_DETECTED_DESC: "Kemmoù dienrollet a zo. Sur oc'h e fell deoc'h kuitaat hep enrollañ?" + MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_TITLE: "Kadarnadur azgoulennet" + MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_DESC: "Sur oc'h e fell deoc'h dilemel ar restr-mañ? N'haller ket dizober ar gwered-mañ." + ADD_FILTERS: "Ouzhpennañ siloù" + SEARCH_PAGES: "Klask pajennoù" + VERSION: "Handelv" + WAS_MADE_WITH: "Savet eo bet gant" + BY: "Gant" + UPDATE_THEME: "Hizivaat an neuz" + UPDATE_PLUGIN: "Hizivaat an enlugellad" + OF_THIS_THEME_IS_NOW_AVAILABLE: "an neuz-mañ a zo hegerz" + OF_THIS_PLUGIN_IS_NOW_AVAILABLE: "an enlugellad-mañ a zo hegerz" + AUTHOR: "Aozer" + HOMEPAGE: "Pennbajenn" + DEMO: "Tañva" + BUG_TRACKER: "Heulier beugoù" + KEYWORDS: "Gerioù-alc'hwez" + LICENSE: "Lañvaz" + DESCRIPTION: "Deskrivadur" + README: "Skoazell" + REMOVE_THEME: "Dilemel an neuz" + INSTALL_THEME: "Staliañ an neuz" + THEME: "Neuz" + BACK_TO_THEMES: "Distreiñ d'an neuzioù" + BACK_TO_PLUGINS: "Distreiñ d'an enlugelladoù" + CHECK_FOR_UPDATES: "Klask hizivadennoù" + ADD: "Ouzhpenañ" + CLEAR_CACHE: "Skarzhañ ar c'hrubuilh" + CLEAR_CACHE_ALL_CACHE: "Ar c'hrubuilh a-bezh" + CLEAR_CACHE_ASSETS_ONLY: "Loazioù nemetken" + CLEAR_CACHE_IMAGES_ONLY: "Skeudennoù nemetken" + CLEAR_CACHE_CACHE_ONLY: "Krubuilh nemetken" + CLEAR_CACHE_TMP_ONLY: "Padennek hepken" + DASHBOARD: "Taolenn labour" + UPDATES_AVAILABLE: "Hizivadennoù hegerz" + DAYS: "Devezhioù" + UPDATE: "Hizivadenn" + BACKUP: "Gwared" + STATISTICS: "Stadegoù" + TODAY: "Hiziv" + WEEK: "Sizhun" + MONTH: "Miz" + LATEST_PAGE_UPDATES: "Hizivadennoù diwezhañ ar bajenn" + MAINTENANCE: "Trezalc'h" + UPDATED: "Hizivaet" + MON: "Lun" + TUE: "Meu" + WED: "Mer" + THU: "Yao" + FRI: "Gwe" + SAT: "Sad" + SUN: "Sul" + COPY: "Eilañ" + EDIT: "Kemmañ" + CREATE: "Krouiñ" + GRAV_ADMIN: "Merour Grav" + GRAV_OFFICIAL_PLUGIN: "Enlugellad Kefridiel Grav" + GRAV_OFFICIAL_THEME: "Neuz Kefridiel Grav" + PLUGIN_SYMBOLICALLY_LINKED: "Gant un ere arouezus eo lakaet an enlugellad. Ne vo ket dinoet an hizivadennoù." + THEME_SYMBOLICALLY_LINKED: "Gant un ere arouezus eo lakaet an neuz. Ne vo ket dinoet an hizivadennoù" + REMOVE_PLUGIN: "Dilemel an enlugellad" + INSTALL_PLUGIN: "Staliañ an enlugellad" + AVAILABLE: "Hegerz" + INSTALLED: "Staliet" + INSTALL: "Staliañ" + ACTIVE_THEME: "Neuz oberiant" + SWITCHING_TO: "Kemmañ da" + SWITCHING_TO_DESCRIPTION: "En ur gemmañ d'un neuz disheñvel n'eus gwarant ebet e vo skoret an holl frammoù pajenn, ar pezh a zegasfe fazioù en ur gargañ ar pajennoù-mañ." + SWITCHING_TO_CONFIRMATION: "Fellout a ra deoc'h kenderc'hel ha kemmañ an neuz" + CREATE_NEW_USER: "Krouiñ un arveriad nevez" + REMOVE_USER: "Dilemel an arveriad" + ACCESS_DENIED: "Haeziñ nac'het" + ACCOUNT_NOT_ADMIN: "n'hoc'h eus ket an aotreoù a-zere" + PHP_INFO: "Titouroù PHP" + INSTALLER: "Stalier" + AVAILABLE_THEMES: "Neuzioù hegerz" + AVAILABLE_PLUGINS: "Enlugelladoù hegerz" + INSTALLED_THEMES: "Neuzioù staliet" + INSTALLED_PLUGINS: "Enlugelladoù staliet" + BROWSE_ERROR_LOGS: "Furchal er c'herzhlevr fazioù" + SITE: "Lec'hienn" + INFO: "Titouroù" + SYSTEM: "Reizhiad" + USER: "Arveriad" + ADD_ACCOUNT: "Ouzhpennañ ur gont" + SWITCH_LANGUAGE: "Kemmañ ar yezh" + SUCCESSFULLY_ENABLED_PLUGIN: "Gweredekaet an enlugellad gant berzh" + SUCCESSFULLY_DISABLED_PLUGIN: "Diweredekaet an enlugellad gant berzh" + SUCCESSFULLY_CHANGED_THEME: "Kemmet an neuz dre ziouer gant berzh" + INSTALLATION_FAILED: "C'hwitadenn war ar staliadur" + INSTALLATION_SUCCESSFUL: "Berzh war ar staliadur" + UNINSTALL_FAILED: "C'hwitadenn war an distaliadur" + UNINSTALL_SUCCESSFUL: "Berzh war an distaliadur" + SUCCESSFULLY_SAVED: "Enrollet gant berzh" + SUCCESSFULLY_COPIED: "Eilet gant berzh" + REORDERING_WAS_SUCCESSFUL: "Adurzhiet gant berzh" + SUCCESSFULLY_DELETED: "Dilamet gant berzh" + SUCCESSFULLY_SWITCHED_LANGUAGE: "Kemmet ar yezh gant berzh" + INSUFFICIENT_PERMISSIONS_FOR_TASK: "N'ho peus ket trawalc'h a aotreoù evit ar gwered" + CACHE_CLEARED: "Skarzhet ar c'hrubuilh" + METHOD: "Hentenn" + ERROR_CLEARING_CACHE: "Fazi en ur skarzhañ ar c'hrubuilh" + AN_ERROR_OCCURRED: "Degouezhet ez eus bet ur fazi" + YOUR_BACKUP_IS_READY_FOR_DOWNLOAD: "Prest eo ho kwared da vezañ pellgarget" + DOWNLOAD_BACKUP: "Pellgargañ ar gwared" + PAGES_FILTERED: "Pajennoù silet" + NO_PAGE_FOUND: "Pajenn ebet kavet" + INVALID_PARAMETERS: "Arventennoù didalvoudek" + NO_FILES_SENT: "Restr ebet kaset" + UNKNOWN_ERRORS: "Fazioù dianav" + UNSUPPORTED_FILE_TYPE: "Doare restr amskor" + FAILED_TO_MOVE_UPLOADED_FILE: "C'hwitadenn en ur zilec'hiañ ar restr pellgaset." + FILE_UPLOADED_SUCCESSFULLY: "Restr pellgaset gant berzh" + FILE_DELETED: "Restr dilamet" + FILE_COULD_NOT_BE_DELETED: "N'haller ket dilemel ar restr" + FILE_NOT_FOUND: "N'eus ket bet kavet ar restr" + NO_FILE_FOUND: "Restr ebet kavet" + GRAV_WAS_SUCCESSFULLY_UPDATED_TO: "Hizivaet eo bet Grav da" + GRAV_UPDATE_FAILED: "C'hwitadenn war hizivadenn Grav" + EVERYTHING_UPDATED: "Hizivaet pep tra" + UPDATES_FAILED: "C'hwitadenn war a hizivadennoù" + AVATAR_BY: "Avatar gant" + LAST_BACKUP: "Gwared diwezhañ" + FULL_NAME: "Anv klok" + USERNAME: "Anv arveriad" + EMAIL: "Chomlec'h postel" + PASSWORD: "Ger-tremen" + PASSWORD_CONFIRM: "Kadarnat ar ger-tremen" + TITLE: "Titl" + LANGUAGE: "Yezh" + ACCOUNT: "Kont" + EMAIL_VALIDATION_MESSAGE: "Ret eo reiñ ur chomlec'h postel talvoudek" + PASSWORD_VALIDATION_MESSAGE: "Ret eo d'ar ger-tremen enderc'hel ur niverenn, ul lizherenn vras hag ul lizherenn vihan hag 8 arouezenn d'an nebeutañ" + LANGUAGE_HELP: "Dibabit ar yezh" + MEDIA: "Media" + DEFAULTS: "Dre ziouer" + SITE_TITLE: "Titl al lec'hienn" + SITE_TITLE_PLACEHOLDER: "Titl ledan al lec'hienn" + SITE_TITLE_HELP: "Titl dre ziouer ho lec'hienn, arveret en neuzioù" + SITE_DEFAULT_LANG: "Yezh defot" + DEFAULT_AUTHOR: "Aozer dre ziouer" + DEFAULT_AUTHOR_HELP: "Un anv aozer dre ziouer, arveret en neuzioù pe er pajennoù" + DEFAULT_EMAIL: "Chomlec'h postel dre ziouer" + DEFAULT_EMAIL_HELP: "Ur chomlec'h postel dre ziouer, arveret en neuze pe er pajennoù" + TAXONOMY_TYPES: "Doareoù rummadoù" + TAXONOMY_TYPES_HELP: "An doareoù rummadoù a rank bezañ erspizet amañ ma fell deoc'h arverañ anezho er pajennoù" + PAGE_SUMMARY: "Berradenn ar bajenn" + ENABLED: "Gweredekaet" + ENABLED_HELP: "Gweredekaat berradenn ar bajenn (ar verradenn a zistro an hevelep tra hag endalc'had ar bajenn)" + 'YES': "Ya" + 'NO': "Ket" + SUMMARY_SIZE: "Ment ar verradenn" + SUMMARY_SIZE_HELP: "An niverenn a arouezenn da arverañ evel berradenn ur bajenn" + FORMAT: "Mentrezh" + FORMAT_HELP: "berr = arverañ degouezh kentañ an disranner pe ment; hir = laosket e vo an disranner berradenn a-gostez" + SHORT: "Berr" + LONG: "Hir" + DELIMITER: "Disranner" + DELIMITER_HELP: "Disranner ar verradenn (diouer '===')" + METADATA: "Metaroadennoù" + METADATA_HELP: "Skrammet e vo ar gwerzhioù metaroadennoù dre ziouer war an holl bajennoù war-bouez m'eo flastret gant ar bajenn" + NAME: "Anv" + CONTENT: "Endalc'had" + REDIRECTS_AND_ROUTES: "Adheñchañ ha treugoù" + CUSTOM_REDIRECTS: "Adheñchañ personelaet" + CUSTOM_REDIRECTS_HELP: "treugoù da adheñchañ davet pajennoù all. Talvoudek eo an amsaviñ regex" + CUSTOM_REDIRECTS_PLACEHOLDER_KEY: "/un/anv" + CUSTOM_REDIRECTS_PLACEHOLDER_VALUE: "/un/adeñchañ" + CUSTOM_ROUTES: "Treugoù personelaet" + CUSTOM_ROUTES_HELP: "treugoù da adheñchañ davet pajennoù all. Talvoudek eo an amsaviñ Regex" + CUSTOM_ROUTES_PLACEHOLDER_KEY: "/ho/anv" + CUSTOM_ROUTES_PLACEHOLDER_VALUE: "/ho/treug" + FILE_STREAMS: "Lanvioù restroù" + DEFAULT: "Dre ziouer" + PAGE_MEDIA: "Media ar bajenn" + OPTIONS: "Dibarzhioù" + PUBLISHED: "Embannet" + PUBLISHED_HELP: "Dre ziouer eo embannet ur bajenn war-bouez m'eo lakaet da \"Embannet: ket\" pe dre un deiziad embann en dazont, pe un deiziad diembannañ tremenet" + DATE: "Deiziad" + DATE_HELP: "Ar vaezienn deiziad a laosk ac'hanoc'h da arventennañ un deiziad liammet gant ar bajenn." + PUBLISHED_DATE: "Deiziad embann" + PUBLISHED_DATE_HELP: "Gallout a rit reiñ un deiziad da embann ent emgefreek." + UNPUBLISHED_DATE: "Deiziad diembannañ" + UNPUBLISHED_DATE_HELP: "Gallout a rit reiñ un deiziad evit diembannañ ent emgefreek." + ROBOTS: "Robotoù" + TAXONOMIES: "Rummadoù" + TAXONOMY: "Rummad" + ADVANCED: "Kempleshoc'h" + SETTINGS: "Arventennoù" + FOLDER_NUMERIC_PREFIX: "Rakger niverel an teuliad" + FOLDER_NUMERIC_PREFIX_HELP: "Rakgerioù niverel evit urzhiañ gant an dorn ha emplegañ ar gwelusted" + FOLDER_NAME: "Anv an teuliad" + FOLDER_NAME_HELP: "Anv an teuliad a vo kadavet er reizhiad restroù evit ar bajenn" + PARENT: "Kar" + DEFAULT_OPTION_ROOT: "- Gwrizienn -" + DEFAULT_OPTION_SELECT: "- Diuzañ -" + DISPLAY_TEMPLATE: "Skrammañ ar patrom" + DISPLAY_TEMPLATE_HELP: "An doare pajenn a ziviz peseurt patrom twig a zeznaouo ar bajenn" + BODY_CLASSES: "Klasoù korf" + ORDERING: "Urzh" + PAGE_ORDER: "Urzh ar pajennoù" + OVERRIDES: "Flastrañ" + MENU: "Lañser" + MENU_HELP: "Ar chadennoù da arverañ el lañser. Ma n'eo ket arventennet, Titl a vo arveret." + SLUG: "Slug" + SLUG_HELP: "An argemenn slug a aotren ac'hanoc'h da arventennañ URL lodenn ar bajenn" + SLUG_VALIDATE_MESSAGE: "Lizherennoù bihan, sifroù ha tiredoù a c'hall bezañ er slug hepken" + PROCESS: "Keweriañ" + PROCESS_HELP: "Reoliañ penaos eo keweriet ar pajennoù. Gallout a ra bezañ lakaet dre bajenn kentoc'h eget en un doare hollek" + DEFAULT_CHILD_TYPE: "Doare bugel dre ziouer" + USE_GLOBAL: "Arverañ Hollek" + ROUTABLE: "Treugus" + ROUTABLE_HELP: "M'eo haezadus ar bajenn dre un URL" + CACHING: "Krubuilhiñ" + VISIBLE: "Gwelus" + VISIBLE_HELP: "Despizañ a ra gwelusted ur bajenn er merdeiñ." + DISABLED: "Diweredekaet" + ITEMS: "Ergorennoù" + ORDER_BY: "Urzhiañ dre" + ORDER: "Urzh" + FOLDER: "Teuliad" + ASCENDING: "War-gresk" + DESCENDING: "War-zigresk" + ADD_MODULAR_CONTENT: "Ouzhpennañ un endalc'had molladel" + PAGE_TITLE: "Titl ar bajenn" + PAGE_TITLE_HELP: "Titl ar bajenn" + PAGE: "Pajenn" + MODULAR_TEMPLATE: "Patrom molladel" + FRONTMATTER: "Frontmatter" + FILENAME: "Anv ar restr" + PARENT_PAGE: "Pajenn gar" + HOME_PAGE: "Pennbajenn" + HOME_PAGE_HELP: "Pajenn arveret gant Grav evel pajenn degemer dre ziouer" + DEFAULT_THEME: "Neuz dre ziouer" + DEFAULT_THEME_HELP: "Arventennañ an neuz arveret gant Grav dre ziouer (Antimatter dre ziouer)" + TIMEZONE: "Gwerzhid-eur" + TIMEZONE_HELP: "Flastrañ gwerzhid-eur dre ziouer an dafariad" + SHORT_DATE_FORMAT: "Mentrezh skrammañ an deiziad berr" + SHORT_DATE_FORMAT_HELP: "Arventennan ar mentrezh deiziad berr da arverañ gant an neuzioù" + LONG_DATE_FORMAT: "Mentrezh deiziad hir" + LONG_DATE_FORMAT_HELP: "Arventennañ ar mentrezh deiziad hir a vo arveret en neuzioù" + DEFAULT_ORDERING: "Urzh dre ziouer" + DEFAULT_ORDERING_HELP: "Pajennoù er roll a vo skrammet en urzh-mañ war-bouez m'eo flastret" + DEFAULT_ORDERING_DEFAULT: "Dre ziouer - diazezet war anv an teuliad" + DEFAULT_ORDERING_FOLDER: "Teuliad - diazezet war anv an teuliad hep rakger" + DEFAULT_ORDERING_TITLE: "Titl - diazezet war vaezienn ditl an talbenn" + DEFAULT_ORDERING_DATE: "Deiziad - diazezet war vaezienn deiziad an talbenn" + DEFAULT_ORDER_DIRECTION: "Tu an urzh dre ziouer" + DEFAULT_ORDER_DIRECTION_HELP: "Tu ar pajennoù er roll" + DEFAULT_PAGE_COUNT: "Niver a bajennoù dre ziouer" + DEFAULT_PAGE_COUNT_HELP: "Niver a bajennoù en ur roll d'ar muiañ" + DATE_BASED_PUBLISHING: "Embannadenn diazezet war un deiziad" + DATE_BASED_PUBLISHING_HELP: "(Di)embann pennadoù ent emgefreek hervez o deiziad" + EVENTS: "Darvoudoù" + EVENTS_HELP: "(Di)weredekaat darvoudoù resis. Diweredekaat anezho a c'hall terriñ enlugelladoù" + REDIRECT_DEFAULT_ROUTE: "Adheñchañ an treug dre ziouer" + REDIRECT_DEFAULT_ROUTE_HELP: "Adheñchañ ent emgefreek d'un treug pajenn dre ziouer" + LANGUAGES: "Yezhoù" + SUPPORTED: "Skoret" + SUPPORTED_HELP: "Roll bonegoù yezh 2 lizherenn ennañ disrannet gant skejoù (skouer: 'br, cy, en')" + TRANSLATIONS_ENABLED: "Troidigezhioù gweredekaet" + TRANSLATIONS_ENABLED_HELP: "Skor an troidigezhioù e Grav, an enlugelladoù hag an askouezhioù" + TRANSLATIONS_FALLBACK: "Troidigezh dre ziouer" + TRANSLATIONS_FALLBACK_HELP: "Arverañ un droidigezh all ma n'eus ket eus ar tezh oberiant" + ACTIVE_LANGUAGE_IN_SESSION: "Yezhoù oberiant en estez" + ACTIVE_LANGUAGE_IN_SESSION_HELP: "Kadaviñ ar yezh oberiant en estez" + HTTP_HEADERS: "Talbennoù HTTP" + EXPIRES: "Diamzer" + EXPIRES_HELP: "Arventennañ an talbenn diamzeriñ e eilennoù." + LAST_MODIFIED: "Kemmet da ziwezhañ" + LAST_MODIFIED_HELP: "Arventennañ an talbenn kemmet da ziwezhañ a c'hall skoazell da wellaat ar proksi ha krubuilh ar merdeer" + ETAG: "ETag" + ETAG_HELP: "Arventennañ an talbenn etag evit skoazell da c'houzout peur eo bet kemmet ur bajenn" + VARY_ACCEPT_ENCODING: "Vary accept encoding" + VARY_ACCEPT_ENCODING_HELP: "Arventennañ a ra an talbenn `Vary: Accept Encoding` evit skoazell gant ar proksi hag ar c'hrubuilh CDN" + MARKDOWN_EXTRA_HELP: "Gweredekaat ar skor dre ziouer evit Markdown Ectra - https://michelf.ca/projects/php-markdown/extra/" + AUTO_LINE_BREAKS: "Tremen d'al linenn ent emgefreek" + AUTO_LINE_BREAKS_HELP: "Gweredekaat skor tremen al linenn ent emgefreek e Markdown" + AUTO_URL_LINKS: "Ereoù URL emgefreek" + AUTO_URL_LINKS_HELP: "Gweredekaat amdroadur emgefreek an URLoù da ereoù HTML" + ESCAPE_MARKUP: "Gwareziñ an HTML" + ESCAPE_MARKUP_HELP: "Gwareziñ ar c'hlavioù e elfennoù HTML" + CACHING_HELP: "Trec'haoler hollek evit (di)weredekaat krubuilh Grav" + CACHE_CHECK_METHOD: "Hentenn gwiriekaat ar c'hrubuilh" + CACHE_CHECK_METHOD_HELP: "Dibab an hentenn arveret gant Grav evit gwiriekaat m'eo bet kemmer ar restoù pajenn." + CACHE_DRIVER: "Sturier Krubuilh" + CACHE_DRIVER_HELP: "Dibab pe sturier krubuilh a zo arveret Grav. 'Dinoiñ emgefreek' a glask kavout pe zoare a zo an hini gwellañ" + CACHE_PREFIX: "Rakger ar c'hrubuilh" + CACHE_PREFIX_HELP: "Lodenn naoudi an alc'hwez Grav. Na gemmit anezhi ma n'ouzit ket petra rit." + CACHE_PREFIX_PLACEHOLDER: "Deveret eus an URL diazez (flastret en un enkañ ur chadenn dargouezhek)" + LIFETIME: "Padelezh buhez" + LIFETIME_HELP: "Arventennañ padelezh ar c'hrubuilh e eilennoù. 0 = anvevenn" + GZIP_COMPRESSION: "Koazhadur Gzip" + GZIP_COMPRESSION_HELP: "Gweredekaat koazhadur Gzip ar bajenn Grav evit kreskiñ an digonusted." + TWIG_TEMPLATING: "Patromiñ Twig" + TWIG_CACHING: "Krubuilh Twig" + TWIG_CACHING_HELP: "Reoliañ wikefre krubuilh Twig. Laoskit gweredekaet evit an digonusted gwellañ." + TWIG_DEBUG: "Diveugañ Twig" + TWIG_DEBUG_HELP: "Aotren an dibarzh evit chom hep kargañ an askouezh diveugañ Twig" + DETECT_CHANGES: "Dinoiñ ar c'hemmoù" + DETECT_CHANGES_HELP: "Adkempunet e vo krubuilh Twig ent emgefreek ma vez dinoet kemmoù er patromoù Twig" + AUTOESCAPE_VARIABLES: "Gwareziñ an argemennoù ent emgefreek" + AUTOESCAPE_VARIABLES_HELP: "Gwareziñ an holl argemennoù ent emgefreek. Moarvat e torro ho lec'hienn" + ASSETS: "Madoù" + CSS_PIPELINE: "Arrevellañ CSS" + CSS_PIPELINE_HELP: "Arrevellañ ar CSS a zo unvanadur meur a loaz CSS en ur restr hepken" + CSS_PIPELINE_INCLUDE_EXTERNALS: "Ebarzhiñ restroù estren en arrevellañ CSS" + CSS_PIPELINE_INCLUDE_EXTERNALS_HELP: "URLoù diavaez a zo gant daveoù restroù daveel a-wechoù ha ne rankont ket bezañ arrevellet" + CSS_PIPELINE_BEFORE_EXCLUDES: "Deoueziñ an arrevellañ CSS da gentañ" + CSS_PIPELINE_BEFORE_EXCLUDES_HELP: "Deoueziñ an arrevellañ CSS a-raok kement dave CSS all ha n'int ket enkorfet" + CSS_MINIFY: "Bihanadur CSS" + CSS_MINIFY_HELP: "Bihanaat ar CSS e-pad an arrevellañ" + CSS_MINIFY_WINDOWS_OVERRIDE: "Amsaviñ bihanadur ar CSS Windows" + CSS_MINIFY_WINDOWS_OVERRIDE_HELP: "Amsaviñ ar bihanadur evit savennoù Windows. Faos dre ziouer abalamour da ThreadStackSize" + CSS_REWRITE: "Adskrivañ CSS" + CSS_REWRITE_HELP: "Adskrivañ kement URL daveel CSS e-pad an arrevellañ" + JAVASCRIPT_PIPELINE: "Arrevellañ Javascript" + JAVASCRIPT_PIPELINE_HELP: "An arrevellañ JS a zo unvanadur meur a restr JS en ur restr hepken" + JAVASCRIPT_PIPELINE_INCLUDE_EXTERNALS: "Enkorfañ ar JS diavaez evit an arrevellañ" + JAVASCRIPT_PIPELINE_INCLUDE_EXTERNALS_HELP: "Urloù diavaez o deus daveoù restroù daveel a-wechoù ha ne rankont ket bezañ arrevellet" + JAVASCRIPT_PIPELINE_BEFORE_EXCLUDES: "Arrevellañ JS da gentañ" + JAVASCRIPT_PIPELINE_BEFORE_EXCLUDES_HELP: "Deoueziñ an arrevellañ JS a-raok kement dave JS all ha n'int ket enkorfet" + JAVASCRIPT_MINIFY: "Bihanat ar javascript" + JAVASCRIPT_MINIFY_HELP: "Bihanaat ar JS e-pad an arrevellañ" + ENABLED_TIMESTAMPS_ON_ASSETS: "Gweredekaat ar boneg-amzer war al loazioù" + ENABLED_TIMESTAMPS_ON_ASSETS_HELP: "Gweredekaat bonegoù-amzer al loazioù" + COLLECTIONS: "Dastumadegoù" + ERROR_HANDLER: "Dornataour fazioù" + DISPLAY_ERRORS: "Skrammañ ar fazioù" + DISPLAY_ERRORS_HELP: "Skrammañ ur bajenn fazi gant munudoù" + LOG_ERRORS: "Kerzhlevr ar fazioù" + LOG_ERRORS_HELP: "Lakaat kerzhlevr ar fazioù en teuliad /logs" + DEBUGGER: "Diveuger" + DEBUGGER_HELP: "Gweredekaat diveuger Grav hag an arventennoù da heul" + DEBUG_TWIG: "Diveugañ Twig" + DEBUG_TWIG_HELP: "Gweredekaat diveugañ ar patromoù Twig" + SHUTDOWN_CLOSE_CONNECTION: "Shutdown a serr ar c'hennask" + SHUTDOWN_CLOSE_CONNECTION_HELP: "Serriñ ar c'hennask a-raok gervel onShutdown(). 'false' evit diveugañ" + DEFAULT_IMAGE_QUALITY: "Perzhded skeudenn dre ziouer" + DEFAULT_IMAGE_QUALITY_HELP: "Perzhded skeudenn dre ziouer da arverañ e-pad adstandilhonañ ar skeudennoù (85%)" + CACHE_ALL: "Lakaat an holl skeudennoù er c'hrubuilh" + CACHE_ALL_HELP: "Lakaat an holl skeudennoù da dremen dre reizhiad krubuilh Grav zoken ma n'o deus dornatadur media ebet" + IMAGES_DEBUG: "Rouedigell diveugañ ar skeudenn" + IMAGES_DEBUG_HELP: "Diskouez un diflugell a-us d'ar skeudennoù a ziskouez an donder piksel pa labourer war Retina da skouer" + UPLOAD_LIMIT: "Bevenn ment ar restroù da bellgas" + UPLOAD_LIMIT_HELP: "Lakaat ar ment restroù uhelañ e eizhbitoù (0 a zo anvevenn)" + ENABLE_MEDIA_TIMESTAMP: "Gweredekaat ar boneg-amzer war ar media" + ENABLE_MEDIA_TIMESTAMP_HELP: "Ouzhpennañ ur boneg-amzer diazezet war an deiziad kemmadur evit pep elfenn media" + SESSION: "Estez" + SESSION_ENABLED_HELP: "Gweredekaat skor an estez evit Grav" + TIMEOUT: "Diamzeriñ" + TIMEOUT_HELP: "Lakaat an amzer diamzeriñ e eilennoù" + SESSION_NAME_HELP: "Un naoudi arveret da stummañ anv toupin an estez" + ABSOLUTE_URLS: "URL dizave" + ABSOLUTE_URLS_HELP: "URLoù dizave pe daveel evit 'base_url'" + PARAMETER_SEPARATOR: "Disranner arventenn" + PARAMETER_SEPARATOR_HELP: "An disranner evit an arventennoù tremenet a c'hall bezañ kemmet evit Apache war Windows" + TASK_COMPLETED: "Trevell echuet" + EVERYTHING_UP_TO_DATE: "Pep tra a zo hizivaet" + UPDATES_ARE_AVAILABLE: "hizivadennoù hegerz" + IS_AVAILABLE_FOR_UPDATE: "a zo gant un hizivadenn hegerz" + IS_NOW_AVAILABLE: "a zo hegerz" + CURRENT: "Bremanel" + UPDATE_GRAV_NOW: "Hizivaat Grav bremañ" + GRAV_SYMBOLICALLY_LINKED: "Gant un ere arouezel eo staliet Grav. Dihegerz eo an hizivadenn" + UPDATING_PLEASE_WAIT: "Oc'h hizivaat... gortozit, emañ o pellgargañ" + OF_THIS: "eus an" + OF_YOUR: "eus ho" + HAVE_AN_UPDATE_AVAILABLE: "en deus un hizivadenn hegerz" + SAVE_AS: "Enrollañ evel" + MODAL_DELETE_PAGE_CONFIRMATION_REQUIRED_DESC: "Sur oc'h e fell deoc'h dilemel ar bajenn-mañ hag holl he bugale? M'eo troet ar bajenn en ur yezh all e vo miret an troidigezhioù a rankout a reot o dilemel en un doare distag. E mod all e vo dilamet teuliad ar bajenn gant an is-pajennoù. N'haller ket dizober ar gwered-mañ." + AND: "ha" + UPDATE_AVAILABLE: "Hizivadenn hegerz" + METADATA_KEY: "Alc'hwez (sk. 'Gerioù-alc'hwez')" + METADATA_VALUE: "Gwerzh (sk. 'Blog, Grav')" + USERNAME_HELP: "Etre 3 ha 16 arouezenn e rank an anv arveriad bezañ o kontañ al lizherennoù bihan, an niverennoù, an islinennoù hag ar barrennigoù. N'eo ket aotreet al lizherennoù bras, an esaouennoù hag an arouezennoù arbennik" + FULLY_UPDATED: "Hizivaet" + SAVE_LOCATION: "Lec'hiadur enrollañ" + PAGE_FILE: "Patrom pajenn" + PAGE_FILE_HELP: "Anv restr patrom ar bajenn, ha patrom skrammañ ar bajenn dre ziouer" + NO_USER_ACCOUNTS: "Kont arveriad ebet kavet, krouit unan da gentañ..." + REDIRECT_TRAILING_SLASH: "Adheñchañ ar veskell dibenn" + REDIRECT_TRAILING_SLASH_HELP: "Ober un adheñchañ 301 e-lerc'h merañ an beskell dibenn an URI en un doare treuzwelus." + DEFAULT_DATE_FORMAT: "Mentrezh deiziad ar bajenn" + DEFAULT_DATE_FORMAT_HELP: "Mentrezh deiziad ar bajenn arveret gant Grav. Dre ziouer, Grav a glask divinout mentrezh an deiziad met gallout a rit erspizañ unan gant kevreadur deiziad PHP (sk.: Y-m-d H:i)" + DEFAULT_DATE_FORMAT_PLACEHOLDER: "Divinout en emgefreek" + IGNORE_FILES: "Leuskel restroù a-gostez" + IGNORE_FILES_HELP: "Restroù da leuskel a-gostez e-pad keweriañ ar pajennoù" + IGNORE_FOLDERS: "Leuskel teuliadoù a-gostez" + IGNORE_FOLDERS_HELP: "Teuliadoù resis da leuskel a-gostez e-pad keweriañ ar pajennoù" + HTTP_ACCEPT_LANGUAGE: "Lakaat yezh ar merdeer" + HTTP_ACCEPT_LANGUAGE_HELP: "Gallout a rit klask arventennañ ar yezh gant hini ar talbenn `http_accept_language` ar merdeer" + OVERRIDE_LOCALE: "Flastrañ ar yezh" + OVERRIDE_LOCALE_HELP: "Flastrañ arventenn yezh PHP diazezet war ar yezh vremanel" + REDIRECT: "Adheñchañ ar bajenn" + REDIRECT_HELP: "Enankit hent ur bajenn pe un URL diavaez da adheñchañ ar bajenn. Sk. '/un/hent' pe 'http://ulload.bzh'" + PLUGIN_STATUS: "Stad an elugellad" + INCLUDE_DEFAULT_LANG: "Enkorfañ ar yezh dre ziouer" + INCLUDE_DEFAULT_LANG_HELP: "Ouzhpennañ a raio ar yezh dre ziouer en holl URLoù er yezh dre ziouer. Sk. '/br/blog/post'" + ALLOW_URL_TAXONOMY_FILTERS: "URL siloù rummad" + ALLOW_URL_TAXONOMY_FILTERS_HELP: "Dastumadegoù pajennoù a aotren ac'hanoc'h da silañ dre '/rummad:gwerzh'." + REDIRECT_DEFAULT_CODE: "Boneg adheñchan dre ziouer" + REDIRECT_DEFAULT_CODE_HELP: "Boneg stad HTTP da arverañ evit adheñchañ" + IGNORE_HIDDEN: "Leuskel ar re kuzhet a-gostez" + IGNORE_HIDDEN_HELP: "Leuskel an holl restroù ha teuliadoù a grog gant ur POENT" + WRAPPED_SITE: "Lec'hienn enkorfet" + WRAPPED_SITE_HELP: "Evit ma ouife an neuzioù/enlugelladoù m'eo enkorfet Grav en ur savenn all" + FALLBACK_TYPES: "Aotren doareoù fallback" + FALLBACK_TYPES_HELP: "Doareoù restr aotreet a c'hall bezañ kavet m'int haezet dre hent ar bajenn. An holl zoareoù media skoret dre ziouer." + INLINE_TYPES: "Doareoù fallback enkorfet" + INLINE_TYPES_HELP: "Ur roll doareoù restroù a rank bezañ skrammet en un doare enkorfet kentoc'h eget pellgarget" + APPEND_URL_EXT: "Ouzhpennañ an astenn d'an URL" + APPEND_URL_EXT_HELP: "Ouzhpennañ a raio un astenn personelaet da URL ar bajenn. Talvezout a ra e glasko Grav ur patrom anvet `..twig`" + PAGE_MODES: "Modoù pajenn" + PAGE_TYPES: "Doareoù pajenn" + ACCESS_LEVELS: "Liveoù haeziñ" + GROUPS: "Strolladoù" + GROUPS_HELP: "Roll ar strolladoù gant an arveriad enno" + ADMIN_ACCESS: "Haeziñ ardoer" + SITE_ACCESS: "Haeziñ d'al lec'hienn" + INVALID_SECURITY_TOKEN: "Reveziadenn diogelroez didalvoudek" + ACTIVATE: "Gweredekaat" + TWIG_UMASK_FIX: "Ratreadur Umask" + TWIG_UMASK_FIX_HELP: "Twig a grou ar restroù krubuilh gant 0755 dre ziouer, ar ratreañ a lak anezho da 0755" + CACHE_PERMS: "Aotreoù ar c'hrubuilh" + CACHE_PERMS_HELP: "Aotreoù dre ziouer teuliad ar c'hrubuilh. 0755 pe 0775 peurvuiañ, hervez ar c'hefluniadur" + REMOVE_SUCCESSFUL: "Dilamet gant berzh" + REMOVE_FAILED: "C'hwitadenn war an dilemel" + HIDE_HOME_IN_URLS: "Kuzhat hent ar pennbajenn en URL" + HIDE_HOME_IN_URLS_HELP: "Gwiriekaat a raio n'eo ket daveet hent skoueriek an degemer gant hentoù dre ziouer ar pajennoù dindan an degemer" + TWIG_FIRST: "Keweriañ an Twig da gentañ" + TWIG_FIRST_HELP: "M'ho peus gweredekaat keweriañ ar bajenn Twig e c'hallit kefluniañ Twig evit e geweriañ a-raok pe goude ar Markdown" + SESSION_SECURE: "Diogel" + SESSION_SECURE_HELP: "M'eo gwir, diskouez a ra eo ret d'ar c'hehentiñ evit an toupin-mañ bezañ graet war un treuzkas diogel. DIWALLIT: Gweredekait an dra-se war lec'hiennoù e HTTPS nemetken" + SESSION_HTTPONLY: "HTTP nemetken" + SESSION_HTTPONLY_HELP: "M'eo gwir, diskouez a ra eo ret d'ar c'hehentiñ evit an toupin-mañ bezañ graet war un treuzkas HTTP ha n'eo ket aotreet kemmañ ar Javascript" + REVERSE_PROXY: "Proksi en tu-gin" + REVERSE_PROXY_HELP: "Gweredekait an dra-se m'hoc'h a-dreñv ur proksi en tu-gin hag ho peus diaesterioù gant an URLoù oc'h enderc'hel ur porzh didalvoudek" + INVALID_FRONTMATTER_COULD_NOT_SAVE: "Frontmatter didalvoudek, n'haller ket enrollan" + ADD_FOLDER: "Ouzhpennañ un teuliad" + PROXY_URL: "URL ar proksi" + PROXY_URL_HELP: "Enankit HERBERC'HIER pe IP ar proksi hag ar PORZH" + NOTHING_TO_SAVE: "Netra da enrollañ" + FILE_ERROR_ADD: "Degouezhet ez eus bet ur fazi en ur glask enrollañ ar restr" + FILE_ERROR_UPLOAD: "Degouezhet ez eus bet ur fazi en ur glask pellgas ar restr" + FILE_UNSUPPORTED: "Doare restr anskor" + ADD_ITEM: "Ouzhpennañ un elfenn" + FILE_TOO_LARGE: "Re leden eo ar restr evit bezañ pellgaset. %s eo an uhelañ aotreet hervez
    hoc'h arventennoù PHP. Kreskit an arventenn PHP`post_max_size`" + INSTALLING: "O staliañ" + LOADING: "O kargañ.." + DEPENDENCIES_NOT_MET_MESSAGE: "Ret eo deoc'h staliañ an amzalc'hoù da-heul a-raok:" + ERROR_INSTALLING_PACKAGES: "Fazi en ur staliañ ar pakad(où)" + INSTALLING_DEPENDENCIES: "O staliañ an amzalc'hoù..." + INSTALLING_PACKAGES: "O staliañ ar pakad(où).." + PACKAGES_SUCCESSFULLY_INSTALLED: "Pakad(où) staliet gant berzh." + READY_TO_INSTALL_PACKAGES: "Prest da staliañ ar pakad(où)" + PACKAGES_NOT_INSTALLED: "N'eo ket stalied ar pakadoù" + PACKAGES_NEED_UPDATE: "Staliet eo ar pakadoù endeo, met re gozh eo" + PACKAGES_SUGGESTED_UPDATE: "Staliet eo ar pakadoù endeo, dereat eo an handelv, met hizivaet e vint evit ma vefec'h en handelv diwezhañ" + REMOVE_THE: "Dilemel an %s" + CONFIRM_REMOVAL: "Sur oc'h e fell deoc'h dilemel %s?" + REMOVED_SUCCESSFULLY: "%s dilamet gant berzh" + ERROR_REMOVING_THE: "Fazi en ur zilemel %s" + ADDITIONAL_DEPENDENCIES_CAN_BE_REMOVED: "An amzalc'hoù da heul a zo azgoulennet gant %s, met n'eo ket azgoulennet gant ur pakad all. Ma ne arverit ket anezho e c'hallit o dilemel adalek amañ." + READY_TO_UPDATE_PACKAGES: "Prest da hizivaat ar pakad(où)" + ERROR_UPDATING_PACKAGES: "Fazi en ur hizivaat ar pakad(où)" + UPDATING_PACKAGES: "Oc'h hizivaat ar pakad(où).." + PACKAGES_SUCCESSFULLY_UPDATED: "Pakad(où) hizivaet gant berzh." + UPDATING: "Hizivaet" + GPM_RELEASES: "Ermaeziadennoù GPM" + GPM_RELEASES_HELP: "Dibabit 'Amprouiñ' evit staliañ an handelv beta pe amprouiñ" + AUTO: "Oto" + FOPEN: "fopen" + CURL: "cURL" + STABLE: "Stabil" + TESTING: "Amprouiñ" + FRONTMATTER_PROCESS_TWIG: "Keweriañ frontmatter Twig" + FRONTMATTER_PROCESS_TWIG_HELP: "P'eo oberiant e c'hallit arverañ argemennoù kefluniañ Twig e frontmatter ar bajenn" + FRONTMATTER_IGNORE_FIELDS: "Leuskel maeziennoù Frontmatter a-gostez" + FRONTMATTER_IGNORE_FIELDS_HELP: "Maeziennoù Frontmatter a c'hall enderc'hel Twig met ne rankont ket bezañ keweriet, evel 'forms'" + PACKAGE_X_INSTALLED_SUCCESSFULLY: "Pakad %s staliet gant berzh" + ORDERING_DISABLED_BECAUSE_PARENT_SETTING_ORDER: "Urzh ar c'har, diweredekaet eo an urzhiañ" + ORDERING_DISABLED_BECAUSE_PAGE_NOT_VISIBLE: "Diwelus eo ar bajenn, diweredekaet eo an urzhiañ" + ORDERING_DISABLED_BECAUSE_TOO_MANY_SIBLINGS: "N'eo ket skoret an urzhiañ dre an ardeiñ dre ma zo ouzhpenn 200 c'hoar" + CANNOT_ADD_MEDIA_FILES_PAGE_NOT_SAVED: "EVEZHIADENN: n'hallit ket ouzhpennañ restroù media evit enrollañ ar bajenn. Klikit war 'Enrollañ' a-us" + CANNOT_ADD_FILES_PAGE_NOT_SAVED: "EVEZHIADENN: ret eo enrollañ ar bajenn a-raok pellgas restroù dezhi." + DROP_FILES_HERE_TO_UPLOAD: "Lakait ho restroù amañ pe klikit amañ" + INSERT: "Enlakaat" + UNDO: "Dizober" + REDO: "Adober" + HEADERS: "Talbennoù" + BOLD: "Tev" + ITALIC: "Stouet" + STRIKETHROUGH: "Barrennet" + SUMMARY_DELIMITER: "Bonner diverrad" + LINK: "Liamm" + IMAGE: "Skeudenn" + BLOCKQUOTE: "Meneg" + UNORDERED_LIST: "Roll dizurzh" + ORDERED_LIST: "Roll urzhiet" + EDITOR: "Embanner" + PREVIEW: "Alberz" + FULLSCREEN: "Skramm a-bezh" + MODULAR: "Modulel" + NON_ROUTABLE: "Dihentus" + NON_MODULAR: "Divodulel" + NON_VISIBLE: "Diwelus" + NON_PUBLISHED: "Diembannet" + CHARACTERS: "arouezioù" + PUBLISHING: "Embann" + NOTIFICATIONS: "Notifiadenn" + MEDIA_TYPES: "Doareoù media" + IMAGE_OPTIONS: "Opsionoù skeudenn" + MIME_TYPE: "Doare Mime" + THUMB: "Miniaturenn" + TYPE: "Doare" + FILE_EXTENSION: "Astenn ar fichenn" + LEGEND: "Alc'hwez ar bajenn" + MEMCACHE_SERVER: "Servijer Memcached" + MEMCACHE_SERVER_HELP: "Chomlec'h ar servijer Memcached" + MEMCACHE_PORT: "Porzh Memcached" + MEMCACHE_PORT_HELP: "Porzh ar servijer Memcached" + MEMCACHED_SERVER: "Servijer Memcached" + MEMCACHED_SERVER_HELP: "Chomlec'h ar servijer Memcached" + MEMCACHED_PORT: "Porzh Memcached" + MEMCACHED_PORT_HELP: "Porzh ar servijer Memcached" + REDIS_SERVER: "Servijer Redis" + REDIS_SERVER_HELP: "Chomlec'h ar servijer Redis" + REDIS_PORT: "Porzh Redis" + REDIS_PORT_HELP: "Porzh ar servijer Redis" + ALL: "Tout" + FROM: "eus" + TO: "da" + RESOURCE_FILTER: "Sil..." + FORCE_SSL: "Forsañ SSL" + DROPZONE_CANCEL_UPLOAD: 'Nullañ ar gargamant' + DROPZONE_REMOVE_FILE: "Dilemel ar fichenn" + PREMIUM_PRODUCT: "Premium" + ERROR_SIMPLE: "Fazi simpl" + ERROR_SYSTEM: "Fazi Sistem" + NOT_SET: "Pas termenet" + PERMISSIONS: "Permisionoù" + REINSTALL_PLUGIN: "Adstaliañ ar Plugin" + REINSTALL_THEME: "Adstaliañ an Tem" + REINSTALL_THE: "Adstaliañ ar %s" + REINSTALLATION_FAILED: "Adstaliañ c'hwitet" + TOOLS: "Ostilhoù" + DIRECT_INSTALL: "Staliañ war-eeun" + 2FA_CODE_INPUT: "000000" diff --git a/user/plugins/admin/languages/ca.yaml b/user/plugins/admin/languages/ca.yaml new file mode 100644 index 0000000..8a848cd --- /dev/null +++ b/user/plugins/admin/languages/ca.yaml @@ -0,0 +1,641 @@ +--- +PLUGIN_ADMIN: + ADMIN_BETA_MSG: "Aquesta és una versió beta! Utilitza-la en producció sota el teu propi risc..." + ADMIN_REPORT_ISSUE: "Has trobat algun problema? Sisplau, reporta'l a GitHub." + EMAIL_FOOTER: "Funcionant amb Grav - El CMS de fitxers plans modern" + LOGIN_BTN: "Inicia sessió" + LOGIN_BTN_FORGOT: "Ho he oblidat" + LOGIN_BTN_RESET: "Restablir contrasenya" + LOGIN_BTN_SEND_INSTRUCTIONS: "Envia instruccions pel reset" + LOGIN_BTN_CLEAR: "Netejar formulari" + LOGIN_BTN_CREATE_USER: "Crear usuari" + LOGIN_LOGGED_IN: "S'ha iniciat sessió correctament" + LOGIN_FAILED: "No s'ha pogut iniciar sessió" + LOGGED_OUT: "S'ha tancat la sessió" + RESET_NEW_PASSWORD: "Sisplau introdueix una nova contasenya …" + RESET_LINK_EXPIRED: "L'enllaç per a restablir contrasenya ha expirat, torna a provar" + RESET_PASSWORD_RESET: "S'ha restablert la contrasenya" + RESET_INVALID_LINK: "L'enllaç per a restablir contrasenya invàl·lid, torna a provar" + FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL: "Les instruccions per a restablir la contrasenya s'han enviat per correu electrònic a %s" + FORGOT_FAILED_TO_EMAIL: "S'ha fallat al enviar les instruccions, sisplau torna-ho a provar" + FORGOT_CANNOT_RESET_EMAIL_NO_EMAIL: "No es pot restablir la contrasenya per a %s, no té cap email assignat" + FORGOT_USERNAME_DOES_NOT_EXIST: "L'usuari %s no existeix" + FORGOT_EMAIL_NOT_CONFIGURED: "No es pot restablir la contrasenya. Aquest lloc no està configurat per enviar missatges de correu electrònic" + FORGOT_EMAIL_SUBJECT: "%s Petició de restabliment de contrasenya" + FORGOT_EMAIL_BODY: "

    Restabliment de contrasenya

    Benvolgut/da %1$s,

    S'ha fet una petició a %4$s per a restablir la contrasenya.


    Fes clic aquí per a restablir la contrasenya

    Altrament, copia el següent URL al teu navegador:

    %2$s


    Atentament,

    %3$s

    " + MANAGE_PAGES: "Gestiona pàgines" + CONFIGURATION: "Configuració" + PAGES: "Pàgines" + PLUGINS: "Plugins" + PLUGIN: "Plugin" + THEMES: "Temes" + LOGOUT: "Tanca sessió" + BACK: "Enrere" + ADD_PAGE: "Afegeix pàgina" + ADD_MODULAR: "Afegeix modular" + MOVE: "Mou" + DELETE: "Esborra" + SAVE: "Desa" + NORMAL: "Normal" + EXPERT: "Expert" + EXPAND_ALL: "Expandeix tot" + COLLAPSE_ALL: "Col·lapsa tot" + ERROR: "Error" + CLOSE: "Tanca" + CANCEL: "Cancel·la" + CONTINUE: "Continua" + MODAL_DELETE_PAGE_CONFIRMATION_REQUIRED_TITLE: "Confirmació requerida" + MODAL_CHANGED_DETECTED_TITLE: "Canvis detectats" + MODAL_CHANGED_DETECTED_DESC: "Tens canvis no desats. Estàs segur que vols sortir sense desar?" + MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_TITLE: "Confirmació requerida" + MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_DESC: "Estàs segur que vols eliminar aquest fitxer? Aquesta acció no es pot desfer." + ADD_FILTERS: "Afegeix filtres" + SEARCH_PAGES: "Cerca pàgines" + VERSION: "Versió" + WAS_MADE_WITH: "Es va fer amb" + BY: "Per" + UPDATE_THEME: "Actualitza tema" + UPDATE_PLUGIN: "Actualitza plugin" + OF_THIS_THEME_IS_NOW_AVAILABLE: "d'aquest tema està disponible" + OF_THIS_PLUGIN_IS_NOW_AVAILABLE: "d'aquest plugin està disponible" + AUTHOR: "Autor/a" + HOMEPAGE: "Pàgina d'inici" + DEMO: "Demo" + BUG_TRACKER: "Rastrejador d'errors" + KEYWORDS: "Paraules clau" + LICENSE: "Llicència" + DESCRIPTION: "Descripció" + README: "Llegiu-me" + REMOVE_THEME: "Elimina tema" + INSTALL_THEME: "Instal·la tema" + THEME: "Tema" + BACK_TO_THEMES: "Torna a Temes" + BACK_TO_PLUGINS: "Torna a Plugins" + CHECK_FOR_UPDATES: "Cerca actualitzacions" + ADD: "Afegeix" + CLEAR_CACHE: "Neteja la memòria cache" + CLEAR_CACHE_ALL_CACHE: "Tota la cache" + CLEAR_CACHE_ASSETS_ONLY: "Només assets" + CLEAR_CACHE_IMAGES_ONLY: "Només imatges" + CLEAR_CACHE_CACHE_ONLY: "Només cache" + CLEAR_CACHE_TMP_ONLY: "Només tmp" + DASHBOARD: "Panell de control" + UPDATES_AVAILABLE: "Hi ha actualizacions disponibles" + DAYS: "Dies" + UPDATE: "Actualitza" + BACKUP: "Còpia de seguretat" + STATISTICS: "Estadístiques" + TODAY: "Avui" + WEEK: "Setmana" + MONTH: "Mes" + LATEST_PAGE_UPDATES: "Últimes pàgines actualitzades" + MAINTENANCE: "Manteniment" + UPDATED: "Actualitzat" + MON: "Dl." + TUE: "Dt." + WED: "Dc." + THU: "Dj." + FRI: "Dv." + SAT: "Ds." + SUN: "Dg." + COPY: "Copia" + EDIT: "Edita" + CREATE: "Crea" + GRAV_ADMIN: "Administració Grav" + GRAV_OFFICIAL_PLUGIN: "Plugin oficial de Grav" + GRAV_OFFICIAL_THEME: "Tema oficial de Grav" + PLUGIN_SYMBOLICALLY_LINKED: "Aquest plugin està lligat simbòlicament. Les actualitzacions no seran detectades." + THEME_SYMBOLICALLY_LINKED: "Aquest tema està lligat simbòlicament. Les actualitzacions no seran detectades" + REMOVE_PLUGIN: "Elimina plugin" + INSTALL_PLUGIN: "Instal·la plugin" + AVAILABLE: "Disponible" + INSTALLED: "Instal·lat" + INSTALL: "Instal·la" + ACTIVE_THEME: "Tema actiu" + SWITCHING_TO: "Canviant a" + SWITCHING_TO_DESCRIPTION: "Al canviar a un tema diferent, no es garanteix que tots els estils de pàgina siguin compatibles, potencialment, pot haver-hi errors al intentar carregar aquestes pàgines." + SWITCHING_TO_CONFIRMATION: "Vols continuar i canviar de tema" + CREATE_NEW_USER: "Crea nou usuari" + REMOVE_USER: "Elimina usuari" + ACCESS_DENIED: "Accés denegat" + ACCOUNT_NOT_ADMIN: "el teu compte no té permisos d'administrador" + PHP_INFO: "Informació PHP" + INSTALLER: "Instal·lador" + AVAILABLE_THEMES: "Temes disponibles" + AVAILABLE_PLUGINS: "Plugins disponibles" + INSTALLED_THEMES: "Temes instal·lats" + INSTALLED_PLUGINS: "Plugins instal·lats" + BROWSE_ERROR_LOGS: "Examina registres d'errors" + SITE: "Lloc web" + INFO: "Info" + SYSTEM: "Sistema" + USER: "Usuari" + ADD_ACCOUNT: "Afegeix compte" + SWITCH_LANGUAGE: "Canvia l'idioma" + SUCCESSFULLY_ENABLED_PLUGIN: "El plugin s'ha activat correctament" + SUCCESSFULLY_DISABLED_PLUGIN: "El plugin s'ha desactivat correctament" + SUCCESSFULLY_CHANGED_THEME: "S'ha canviat el tema per defecte correctament" + INSTALLATION_FAILED: "La instal·lació ha fallat" + INSTALLATION_SUCCESSFUL: "Instal·lació satisfactòria" + UNINSTALL_FAILED: "Desinstal·lació fallida" + UNINSTALL_SUCCESSFUL: "Desinstal·lació satisfactòria" + SUCCESSFULLY_SAVED: "Desat satisfactòriament" + SUCCESSFULLY_COPIED: "Copiat satisfactòriament" + REORDERING_WAS_SUCCESSFUL: "Reordenació satisfactòria" + SUCCESSFULLY_DELETED: "Eliminat satisfactòriament" + SUCCESSFULLY_SWITCHED_LANGUAGE: "Idioma canviat satisfactòriament" + INSUFFICIENT_PERMISSIONS_FOR_TASK: "No tens permisos suficients per a la tasca" + CACHE_CLEARED: "Memòria cache esborrada" + METHOD: "Mètode" + ERROR_CLEARING_CACHE: "Error al esborrar cache" + AN_ERROR_OCCURRED: "S'ha produït un error" + YOUR_BACKUP_IS_READY_FOR_DOWNLOAD: "La teva còpia de seguretat està llesta per a descarregar" + DOWNLOAD_BACKUP: "Descarrega còpia de seguretat" + PAGES_FILTERED: "Pàgines filtrades" + NO_PAGE_FOUND: "No s'han trobat pàgines" + INVALID_PARAMETERS: "Els paràmetres són invàlids" + NO_FILES_SENT: "No s'han enviat fitxers" + EXCEEDED_FILESIZE_LIMIT: "S'ha excedit el límit de tamany de fitxer de la configuració de PHP" + UNKNOWN_ERRORS: "Hi ha hagut errors desconeguts" + EXCEEDED_GRAV_FILESIZE_LIMIT: "S'ha excedit el límit de tamany de fitxer de la configuració de Grav" + UNSUPPORTED_FILE_TYPE: "Tipus de fitxer no suportat" + FAILED_TO_MOVE_UPLOADED_FILE: "S'ha fallat al moure el fitxer carregat." + FILE_UPLOADED_SUCCESSFULLY: "S'ha carregat el fitxer amb èxit" + FILE_DELETED: "S'ha esborrat el fitxer" + FILE_COULD_NOT_BE_DELETED: "No s'ha pogut esborrar el fitxer" + FILE_NOT_FOUND: "No s'ha trobat el fitxer" + NO_FILE_FOUND: "No s'han trobat fitxers" + GRAV_WAS_SUCCESSFULLY_UPDATED_TO: "Grav ha estat actualitzat amb èxit a" + GRAV_UPDATE_FAILED: "Ha fallat l'actualització de Grav" + EVERYTHING_UPDATED: "Tot està actualitzat" + UPDATES_FAILED: "Han fallat les actualitzacions" + AVATAR_BY: "Avatar per" + LAST_BACKUP: "Última còpia de seguretat" + FULL_NAME: "Nom complet" + USERNAME: "Nom d'usuari" + EMAIL: "Email" + USERNAME_EMAIL: "Nom d'usuari o correu electrònic" + PASSWORD: "Contrasenya" + PASSWORD_CONFIRM: "Confirma contrasenya" + TITLE: "Títol" + LANGUAGE: "Llengua" + ACCOUNT: "Compte d'usuari" + EMAIL_VALIDATION_MESSAGE: "Ha de ser una adreça de correu electrònic vàl·lida" + PASSWORD_VALIDATION_MESSAGE: "La contrasenya ha de contenir almenys un número i una lletra majúscula i minúscula, i almenys 8 o més caràcters" + LANGUAGE_HELP: "Estableix la llengua preferida" + MEDIA: "Mèdia" + DEFAULTS: "Per defecte" + SITE_TITLE: "Títol del lloc" + SITE_TITLE_PLACEHOLDER: "Títol a tot el lloc" + SITE_TITLE_HELP: "Títol per defecte pel teu lloc, sovint utilitzat en els temes" + SITE_DEFAULT_LANG: "Llenguatge per defecte" + SITE_DEFAULT_LANG_PLACEHOLDER: "Llenguatge per defecte per a ser utilitzat per l'etiqueta del tema " + SITE_DEFAULT_LANG_HELP: "Llenguatge per defecte per a ser utilitzat per l'etiqueta del tema " + DEFAULT_AUTHOR: "Autor/a per defecte" + DEFAULT_AUTHOR_HELP: "Un nom d'autor/a per defecte, algunes vegades utilitzat en temes o contingut de les pàgines" + DEFAULT_EMAIL: "Email per defecte" + DEFAULT_EMAIL_HELP: "Un correu electrònic per referenciar en temes o pàgines" + TAXONOMY_TYPES: "Tipus de taxonomia" + TAXONOMY_TYPES_HELP: "Els tipus de taxonomia han de definir-se aquí si es desitja utilitzar-les en pàgines" + PAGE_SUMMARY: "Resum de pàgina" + ENABLED: "Habilitat" + ENABLED_HELP: "Habilita resum de pàgina (el resum retorna el mateix que el contingut de la pàgina)" + 'YES': "Sí" + 'NO': "No" + SUMMARY_SIZE: "Mida del resum" + SUMMARY_SIZE_HELP: "La quantitat de caràcters d'una pàgina a utilitzar com a resum del contingut" + FORMAT: "Format" + FORMAT_HELP: "curt = utilitza la primera ocurrència del delimitador o mida; llarg = s'ignorarà el delimitador del resum" + SHORT: "Curt" + LONG: "Llarg" + DELIMITER: "Delimitador" + DELIMITER_HELP: "El delimitador de resum (per defecte '===')" + METADATA: "Metadades" + METADATA_HELP: "Valors de metadades per defecte que es mostraran a cada pàgina excepte si es sobreescriuen a la pàgina" + NAME: "Nom" + CONTENT: "Contingut" + REDIRECTS_AND_ROUTES: "Redireccions i rutes" + CUSTOM_REDIRECTS: "Redireccions personalitzades" + CUSTOM_REDIRECTS_HELP: "rutes per redirigir a altres pàgines. Substitució de Regex estàndard és vàl·lida" + CUSTOM_REDIRECTS_PLACEHOLDER_KEY: "/el/teu/àlies" + CUSTOM_REDIRECTS_PLACEHOLDER_VALUE: "/la/teva/redirecció" + CUSTOM_ROUTES: "Rutes personalitzades" + CUSTOM_ROUTES_HELP: "rutes a àlies a altres pàgines. Substitució de Regex estàndard és vàl·lida" + CUSTOM_ROUTES_PLACEHOLDER_KEY: "/el/teu/àlies" + CUSTOM_ROUTES_PLACEHOLDER_VALUE: "/la/teva/ruta" + FILE_STREAMS: "Streams de fitxers" + DEFAULT: "Per defecte" + PAGE_MEDIA: "Contingut multimèdia de la pàgina" + OPTIONS: "Opcions" + PUBLISHED: "Publicat" + PUBLISHED_HELP: "Per defect, una pàgina es publica excepte que estableixis explícitament 'published': false o posant una publish_date al futur o una unpublish_date al passat" + DATE: "Data" + DATE_HELP: "La variable data permet definir específicament una data associada a aquesta pàgina." + PUBLISHED_DATE: "Data de publicació" + PUBLISHED_DATE_HELP: "Pots proporcionar una data per provocar automàticament la publicació." + UNPUBLISHED_DATE: "Data de despublicació" + UNPUBLISHED_DATE_HELP: "Pots proporcionar una data per provocar automàticament la despublicació." + ROBOTS: "Robots" + TAXONOMIES: "Taxonomies" + TAXONOMY: "Taxonomia" + ADVANCED: "Avançat" + SETTINGS: "Configuració" + FOLDER_NUMERIC_PREFIX: "Prefix numèric carpeta" + FOLDER_NUMERIC_PREFIX_HELP: "Prefix numèric que proporciona ordenació manual i implica la visibilitat" + FOLDER_NAME: "Nom de la carpeta" + FOLDER_NAME_HELP: "El nom de carpeta que s'emmagatzemarà al sistema de fitxers per a aquesta pàgina" + PARENT: "Pare" + DEFAULT_OPTION_ROOT: "-Root-" + DEFAULT_OPTION_SELECT: "- selecciona -" + DISPLAY_TEMPLATE: "Plantilla a mostrar" + DISPLAY_TEMPLATE_HELP: "El tipus de pàgina que es tradueix a la plantilla de Twig en què es renderitza la pàgina" + BODY_CLASSES: "Classes del Body" + ORDERING: "Ordenació" + PAGE_ORDER: "Ordre de pàgines" + OVERRIDES: "Sobreescriu" + MENU: "Menú" + MENU_HELP: "El text a utilitzar en un menú. Si no s'estableix, s'utilitzarà el Títol." + SLUG: "Slug" + SLUG_HELP: "La variable slug permet definir específicament la part de l'URL que correspon a la pàgina" + SLUG_VALIDATE_MESSAGE: "L'slug ha de contenir només caràcters alfanumèrics en minúscules i guions" + PROCESS: "Processa" + PROCESS_HELP: "Control de com es processen les pàgines. Configurable per a pàgina enlloc de globalment" + DEFAULT_CHILD_TYPE: "Tipus de fill per defecte" + USE_GLOBAL: "Ús global" + ROUTABLE: "Accessible" + ROUTABLE_HELP: "Si aquesta pàgina és accessible des d'una URL" + CACHING: "Caching" + VISIBLE: "Visible" + VISIBLE_HELP: "Determina si una pàgina és visible en la navegació." + DISABLED: "Deshabilitat" + ITEMS: "Ítems" + ORDER_BY: "Ordena per" + ORDER: "Ordena" + FOLDER: "Carpeta" + ASCENDING: "Ascendent" + DESCENDING: "Descendent" + ADD_MODULAR_CONTENT: "Afegeix contingut modular" + PAGE_TITLE: "Títol de pàgina" + PAGE_TITLE_HELP: "El títol de la pàgina" + PAGE: "Pàgina" + MODULAR_TEMPLATE: "Plantilla modular" + FRONTMATTER: "Frontmatter" + FILENAME: "Nom de fitxer" + PARENT_PAGE: "Pàgina pare" + HOME_PAGE: "Pàgina d'inici" + HOME_PAGE_HELP: "La pàgina que utilitzarà Grav com a destinació per defecte" + DEFAULT_THEME: "Tema per defecte" + DEFAULT_THEME_HELP: "Estableix el tema per defecte que utilitzarà Grav (per defecte és Antimatter)" + TIMEZONE: "Zona horària" + TIMEZONE_HELP: "Sobreescriu la zona horària del servidor" + SHORT_DATE_FORMAT: "Format de data curt" + SHORT_DATE_FORMAT_HELP: "Estableix el format de data curt que poden utilitzar els temes" + LONG_DATE_FORMAT: "Format de data llarg" + LONG_DATE_FORMAT_HELP: "Estableix el format de data llarg que poden utilitzar els temes" + DEFAULT_ORDERING: "Ordre per defecte" + DEFAULT_ORDERING_HELP: "Les pàgines de la llista seran generades utilitzant aquest ordre excepte que se sobreescrigui" + DEFAULT_ORDERING_DEFAULT: "Per defecte - basat en el nom de la carpeta" + DEFAULT_ORDERING_FOLDER: "Carpeta - basat en el nom de carpeta sense prefix" + DEFAULT_ORDERING_TITLE: "Títol - basat en el camp de títol de la capçalera" + DEFAULT_ORDERING_DATE: "Data - basat en el camp data de la capçalera" + DEFAULT_ORDER_DIRECTION: "Direcció d'ordre per defecte" + DEFAULT_ORDER_DIRECTION_HELP: "La direcció de les pàgines en una llista" + DEFAULT_PAGE_COUNT: "Compte de pàgina per defecte" + DEFAULT_PAGE_COUNT_HELP: "Nombre màxim de compte de pàgines en una llista" + DATE_BASED_PUBLISHING: "Publicació basada en la data" + DATE_BASED_PUBLISHING_HELP: "(Des)publicar automàticament posts basant-se en la seva data" + EVENTS: "Esdeveniments" + EVENTS_HELP: "Habilita o inhabilita esdeveniments concrets. Deshabilitant-los pot trencar plugins" + REDIRECT_DEFAULT_ROUTE: "Ruta de redirecció per defecte" + REDIRECT_DEFAULT_ROUTE_HELP: "Redirigir automàticament a la ruta per defecte d'una pàgina" + LANGUAGES: "Idiomes" + SUPPORTED: "Suportat" + SUPPORTED_HELP: "Llista separada per comes de codis d'idioma de 2 lletres (per exemple 'en,fr,de')" + TRANSLATIONS_ENABLED: "Traduccions habilitades" + TRANSLATIONS_ENABLED_HELP: "Suport a traduccions a Grav, plugins i extensions" + TRANSLATIONS_FALLBACK: "Fallback de traduccions" + TRANSLATIONS_FALLBACK_HELP: "Fallback en traduccions suportades si l'idioma actiu no existeix" + ACTIVE_LANGUAGE_IN_SESSION: "Idioma actiu a la sessió" + ACTIVE_LANGUAGE_IN_SESSION_HELP: "Emmagatzema l'idioma actiu a la sessió" + HTTP_HEADERS: "Capçaleres HTTP" + EXPIRES: "Caduca" + EXPIRES_HELP: "Estableix la capçalera d'expiració. El valor és en segons." + LAST_MODIFIED: "Darrera modificació" + LAST_MODIFIED_HELP: "Estableix la capçalera de darrera modificació que pot optimitzar el proxy i la cache del navegador" + ETAG: "ETag" + ETAG_HELP: "Estableix la capçalera d'etag per ajudar a identificar quan una pàgina ha estat modificada" + VARY_ACCEPT_ENCODING: "Variar accept encoding" + VARY_ACCEPT_ENCODING_HELP: "Estableix la capçalera 'Vary: Accept Encoding' per ajudar amb el proxy i la cache CDN" + MARKDOWN_EXTRA_HELP: "Habilita suport per defecte per a Markdown Extra - https://michelf.ca/projects/php-markdown/extra/" + AUTO_LINE_BREAKS: "Salts de línia automàtics" + AUTO_LINE_BREAKS_HELP: "Habilita el suport per a salts de línia automàtics a Markdown" + AUTO_URL_LINKS: "Enllaços URL automàtics" + AUTO_URL_LINKS_HELP: "Habilita l'autoconversió d'URLs a hyperlinks HTML" + ESCAPE_MARKUP: "Escape markup" + ESCAPE_MARKUP_HELP: "Escape markup tags en entitats HTML" + CACHING_HELP: "Interruptor ON/OFF global per habilitar/deshabilitar cache de Grav" + CACHE_CHECK_METHOD: "Mètode de verificació de cache" + CACHE_CHECK_METHOD_HELP: "Seleccionar el mètode que utilitza Grav per comprovar si s'han modificat arxius de pàgina." + CACHE_DRIVER: "Contolador de cache" + CACHE_DRIVER_HELP: "Selecciona quin controlador de cache de Grav cal usar. 'Auto Detect' intenta trobar el millor per a tu" + CACHE_PREFIX: "Prefix de cache" + CACHE_PREFIX_HELP: "Un identificador per part de la clau de Grav. No la canviïs excepte que sàpigues el que estàs fent." + CACHE_PREFIX_PLACEHOLDER: "Derivat de l'URL base (sobreescriu introduint strings aleatòris)" + LIFETIME: "Cicle de vida" + LIFETIME_HELP: "Defineix el cicle de vida de la cache en segons. 0 = infinit" + GZIP_COMPRESSION: "Compressió gzip" + GZIP_COMPRESSION_HELP: "Habilita la compressió GZip de la pàgina de Grav per augmentar el rendiment." + TWIG_TEMPLATING: "Plantilles de Twig" + TWIG_CACHING: "Cache de Twig" + TWIG_CACHING_HELP: "Controla el mecanisme de cache de Twig. Deixa'l habilitat per a millor rendiment." + TWIG_DEBUG: "Depuració de Twig" + TWIG_DEBUG_HELP: "Permet l'opció de no carregar l'extensió de depuració de Twig" + DETECT_CHANGES: "Detectar canvis" + DETECT_CHANGES_HELP: "Twig recompilarà automàticament la cache de Twig si detecta canvis a les plantilles de Twig" + AUTOESCAPE_VARIABLES: "Variables d'autoescape" + AUTOESCAPE_VARIABLES_HELP: "Autoescapa totes les variables. Això probablement trencarà el teu lloc" + ASSETS: "Assets" + CSS_PIPELINE: "CSS pipeline" + CSS_PIPELINE_HELP: "El CSS pipeline és l'unificació de diversos recursos CSS en un sol fitxer" + CSS_PIPELINE_INCLUDE_EXTERNALS: "Inclou fitxers externs en el CSS pipeline" + CSS_PIPELINE_INCLUDE_EXTERNALS_HELP: "A vegades, algunes URLs externes tenen referències relatives de fitxer i no s'hi hauria de fer pipelining" + CSS_PIPELINE_BEFORE_EXCLUDES: "Processar pimer el CSS pipeline" + CSS_PIPELINE_BEFORE_EXCLUDES_HELP: "Intrepreta el CSS pipeline abans de qualsevol altra referència CSS que no estigui inclosa" + CSS_MINIFY: "Minifica CSS" + CSS_MINIFY_HELP: "Minifica el CSS durant el pipelining" + CSS_MINIFY_WINDOWS_OVERRIDE: "Sobreesciu la minificació de CSS a Windows" + CSS_MINIFY_WINDOWS_OVERRIDE_HELP: "Sobreescriu la minificació en plataformes Windows. Fals per defecte degut a ThreadStackSize" + CSS_REWRITE: "Reescriptura CSS" + CSS_REWRITE_HELP: "Reescriu qualsevol URL relativa de CSS durant el pipelining" + JAVASCRIPT_PIPELINE: "JavaScript pipeline" + JAVASCRIPT_PIPELINE_HELP: "El JS pipeline és l'unificació de diversos recursos JS en un sol fitxer" + JAVASCRIPT_PIPELINE_INCLUDE_EXTERNALS: "Inclou fitxers externs en el JS pipeline" + JAVASCRIPT_PIPELINE_INCLUDE_EXTERNALS_HELP: "A vegades, les URLs externes tenen referències d'arxiu i no s'hi hauria de fer pipelining" + JAVASCRIPT_PIPELINE_BEFORE_EXCLUDES: "Interpreta primer el JS pipeline" + JAVASCRIPT_PIPELINE_BEFORE_EXCLUDES_HELP: "Interpreta el JS pipeline abans de qualsevol altra referència JS que no estigui inclosa" + JAVASCRIPT_MINIFY: "Minificació JavaScript" + JAVASCRIPT_MINIFY_HELP: "Minifica el JS durant el pipelining" + ENABLED_TIMESTAMPS_ON_ASSETS: "Habilita les marques de temps als assets" + ENABLED_TIMESTAMPS_ON_ASSETS_HELP: "Habilita les marques de temps a assets" + COLLECTIONS: "Col·leccions" + ERROR_HANDLER: "Controlador d'errors" + DISPLAY_ERRORS: "Mostra errors" + DISPLAY_ERRORS_HELP: "Mostra pàgina d'error full backstrace-style" + LOG_ERRORS: "Registre d'errors" + LOG_ERRORS_HELP: "Registre d'errors a la carpeta /logs" + DEBUGGER: "Depurador" + DEBUGGER_HELP: "Habilita depurador de Grav i les configuracions següents" + DEBUG_TWIG: "Depuració de Twig" + DEBUG_TWIG_HELP: "Habilita la depuració de plantilles de Twig" + SHUTDOWN_CLOSE_CONNECTION: "Al apagar tanca la connexió" + SHUTDOWN_CLOSE_CONNECTION_HELP: "Tanca la connexió abans de cridar onShutdown(). False per a la depuració" + DEFAULT_IMAGE_QUALITY: "Qualitat d'imatge per defecte" + DEFAULT_IMAGE_QUALITY_HELP: "Qualitat d'imatge per defecte per a ser utilitzada quan es remostri o es guardi a cache les imatges (85%)" + CACHE_ALL: "Guardar totes les imatges a cache" + CACHE_ALL_HELP: "Guarda totes les imatges al sistema cache de Grav fins i tot si no tenen cap manipulació de mèdia" + IMAGES_DEBUG: "Marca d'aigua de depuració" + IMAGES_DEBUG_HELP: "Mostra un overlay sobre les imatges indicant la profunditat de píxels quan es treballa amb retina, per exemple" + UPLOAD_LIMIT: "Límit de tamany de fitxer" + UPLOAD_LIMIT_HELP: "Defineix el tamany màxim de càrrega en bytes (0 = il·limitat)" + ENABLE_MEDIA_TIMESTAMP: "Permet timestamps en fitxers multimèdia" + ENABLE_MEDIA_TIMESTAMP_HELP: "Afegeix un timestamp basat en la data d'última modificació a cada element multimèdia" + SESSION: "Sessió" + SESSION_ENABLED_HELP: "Habilita suport de sessions a Grav" + TIMEOUT: "Temps d'espera" + TIMEOUT_HELP: "Estableix el temps d'espera de la sessió en segons" + SESSION_NAME_HELP: "Un identificador usat per formar el nom de la galeta de sessió" + ABSOLUTE_URLS: "URLs absolutes" + ABSOLUTE_URLS_HELP: "URLs absolutes o relatives per a 'base_url'" + PARAMETER_SEPARATOR: "Separador de paràmetres" + PARAMETER_SEPARATOR_HELP: "Separador per a paràmetres passats que es poden canviar d'Apache a Windows" + TASK_COMPLETED: "Tasca completada" + EVERYTHING_UP_TO_DATE: "Tot està actualitzat" + UPDATES_ARE_AVAILABLE: "hi ha actualitzacions disponibles" + IS_AVAILABLE_FOR_UPDATE: "està disponible per a l'actualització" + IS_NOW_AVAILABLE: "ja està disponible" + CURRENT: "Actual" + UPDATE_GRAV_NOW: "Actualitza Grav ara" + GRAV_SYMBOLICALLY_LINKED: "Grav està lligat simbòlicament. Les actualitzacions no estaran disponibles" + UPDATING_PLEASE_WAIT: "Actualitzant... descarregant, espera sisplau" + OF_THIS: "d'aquest/a" + OF_YOUR: "del teu" + HAVE_AN_UPDATE_AVAILABLE: "té disponible una actualització" + SAVE_AS: "Desa com a" + MODAL_DELETE_PAGE_CONFIRMATION_REQUIRED_DESC: "Esteu segur que voleu suprimir aquesta pàgina i tots els seus fills? Si la pàgina es tradueix en altres llengües, les traduccions es mantindran i han de ser esborrades per separat. En cas contrari la carpeta de la pàgina serà eliminada juntament amb les seves subpàgines. Aquesta acció no es pot desfer." + AND: "i" + UPDATE_AVAILABLE: "Actualització disponible" + METADATA_KEY: "Clau (p. ex. 'paraules clau')" + METADATA_VALUE: "Valor (per exemple, \"Blog, Grav\")" + USERNAME_HELP: "El nom d'usuari ha de tenir entre 3 i 16 caràcters, incloent minúscules, números, guions i guions baixos. Lletres majúscules, espais i caràcters especials no estan permesos" + FULLY_UPDATED: "Completament actualitzat" + SAVE_LOCATION: "Desa a la ubicació" + PAGE_FILE: "Plantilla de pàgina" + PAGE_FILE_HELP: "Nom d'arxiu de la plantilla de pàgina i plantilla de visualització per defecte" + NO_USER_ACCOUNTS: "No s'han trobat comptes d'usuari, sisplau crea'n una..." + REDIRECT_TRAILING_SLASH: "Redirigeix barra final" + REDIRECT_TRAILING_SLASH_HELP: "Realitza una redirecció 301 en lloc de manteniment transparent de barra final." + DEFAULT_DATE_FORMAT: "Format de data de pàgina" + DEFAULT_DATE_FORMAT_HELP: "Format de data de pàgina utilitzat per Grav. Per defecte, Grav intenta endivinar el format de data, tot i això pots especificar un format utilitzant la sintaxi de data de PHP (p.e., Y-m-d H:i)" + DEFAULT_DATE_FORMAT_PLACEHOLDER: "Endevina automàticament" + IGNORE_FILES: "Ignora fitxers" + IGNORE_FILES_HELP: "Arxius específics a ignorar al processar pàgines" + IGNORE_FOLDERS: "Ignora carpetes" + IGNORE_FOLDERS_HELP: "Carpetes específiques a ignorar al processar pàgines" + HTTP_ACCEPT_LANGUAGE: "Estableix la llengua a partir del navegador" + HTTP_ACCEPT_LANGUAGE_HELP: "Pots optar per intentar establir la llengua basant-se en l'etiqueta de la capçalera 'http_accept_language' en el navegador" + OVERRIDE_LOCALE: "Sobreescriu la configuració local" + OVERRIDE_LOCALE_HELP: "Sobreescriu la configuració local en PHP basant-se en la llengua actual" + REDIRECT: "Redirecció de pàgina" + REDIRECT_HELP: "Escriu una ruta de pàgina o URL externa per aqueta pàgina a redirigir a per exemple, '/alguna/ruta' o 'https://algunlloc.com'" + PLUGIN_STATUS: "Estat del plugin" + INCLUDE_DEFAULT_LANG: "Inclou llengua predeterminada" + INCLUDE_DEFAULT_LANG_HELP: "Això sobreposarà totes les URLs amb la llengua per defecte. Per exemple, 'ca/blog/el-meu-post'" + ALLOW_URL_TAXONOMY_FILTERS: "Filtres de taxonomia d'URL" + ALLOW_URL_TAXONOMY_FILTERS_HELP: "Col·lecions basades en pàgines que et permeten filtrar per '/taxonomia:valor'." + REDIRECT_DEFAULT_CODE: "Codi de redirecció per defecte" + REDIRECT_DEFAULT_CODE_HELP: "El codi d'estat HTTP per utilitzar en redireccions" + IGNORE_HIDDEN: "Ignora ocults" + IGNORE_HIDDEN_HELP: "Ignora tots els fitxers i carpetes que comencin amb un punt" + WRAPPED_SITE: "Lloc encapsulat" + WRAPPED_SITE_HELP: "Per a que els temes/plugins sàpiguen si Grav està encapsulat en una altra plataforma" + FALLBACK_TYPES: "Tipus de fallback permesos" + FALLBACK_TYPES_HELP: "Tipus de fitxers permesos que es poden trobar si s'accedeix per la ruta de la pàgina. Per defecte, qualsevol tipus de mèdia compatible." + INLINE_TYPES: "Tipus de fallback en línia" + INLINE_TYPES_HELP: "Una llista de tipus d'arxius que han de ser mostrats en línia enlloc de descarregats" + APPEND_URL_EXT: "Afegeix extensió de direcció URL" + APPEND_URL_EXT_HELP: "Afegirà una extensió personalitzarà a l'URL de la pàgina. Tingues en compte que això farà que Grav busqui la plantilla '