diff --git a/CHANGELOG.md b/CHANGELOG.md index e418939db..238c9cfb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ * Added `FormFlash` class to contain AJAX uploaded files in more reliable way * Added `FormFlashFile` class which implements `UploadedFileInterface` from PSR-7 1. [](#improved) - * Improve Flex storage + * Improved Flex storage classes + * Improved `Grav\Framework\File\Formatter` classes to have abstract parent class and some useful methods 1. [](#bugfix) * Fixed handling of `append_url_extension` inside of `Page::templateFormat()` [#2264](https://github.com/getgrav/grav/issues/2264) * Fixed a broken language string [#2261](https://github.com/getgrav/grav/issues/2261) diff --git a/system/src/Grav/Framework/File/Formatter/AbstractFormatter.php b/system/src/Grav/Framework/File/Formatter/AbstractFormatter.php new file mode 100644 index 000000000..852f4e5b2 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/AbstractFormatter.php @@ -0,0 +1,106 @@ +config = $config; + } + + /** + * @return string + */ + public function serialize(): string + { + return serialize($this->doSerialize()); + } + + /** + * @param string $serialized + */ + public function unserialize($serialized): void + { + $this->doUnserialize(unserialize($serialized, false)); + } + + /** + * {@inheritdoc} + */ + public function getDefaultFileExtension(): string + { + $extensions = $this->getSupportedFileExtensions(); + + // Call fails on bad configuration. + return reset($extensions); + } + + /** + * {@inheritdoc} + */ + public function getSupportedFileExtensions(): array + { + // Call fails on bad configuration. + return $this->getConfig('file_extension'); + } + + /** + * {@inheritdoc} + */ + abstract public function encode($data): string; + + /** + * {@inheritdoc} + */ + 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 index 7bc89bbcc..6c4421ffa 100644 --- a/system/src/Grav/Framework/File/Formatter/CsvFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/CsvFormatter.php @@ -1,5 +1,7 @@ config = $config + [ - 'file_extension' => ['.csv', '.tsv'], - 'delimiter' => ',' - ]; + $config += [ + 'file_extension' => ['.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} */ - public function getDefaultFileExtension() - { - $extensions = $this->getSupportedFileExtensions(); - - return (string) reset($extensions); - } - - /** - * {@inheritdoc} - */ - public function getSupportedFileExtensions() - { - return (array) $this->config['file_extension']; - } - - public function getDelimiter() - { - return $this->config['delimiter']; - } - - /** - * {@inheritdoc} - */ - public function encode($data, $delimiter = null) + public function encode($data, $delimiter = null): string { $delimiter = $delimiter ?? $this->getDelimiter(); $header = array_keys(reset($lines)); @@ -71,7 +60,7 @@ class CsvFormatter implements FormatterInterface /** * {@inheritdoc} */ - public function decode($data, $delimiter = null) + public function decode($data, $delimiter = null): array { $delimiter = $delimiter ?? $this->getDelimiter(); $lines = preg_split('/\r\n|\r|\n/', $data); diff --git a/system/src/Grav/Framework/File/Formatter/FormatterInterface.php b/system/src/Grav/Framework/File/Formatter/FormatterInterface.php index 1149eb23e..04793461a 100644 --- a/system/src/Grav/Framework/File/Formatter/FormatterInterface.php +++ b/system/src/Grav/Framework/File/Formatter/FormatterInterface.php @@ -1,7 +1,9 @@ config = $config + [ - 'file_extension' => '.ini' - ]; + $config += [ + 'file_extension' => '.ini' + ]; + + parent::__construct($config); } /** * {@inheritdoc} */ - public function getDefaultFileExtension() - { - $extensions = $this->getSupportedFileExtensions(); - - return (string) reset($extensions); - } - - /** - * {@inheritdoc} - */ - public function getSupportedFileExtensions() - { - return (array) $this->config['file_extension']; - } - - /** - * {@inheritdoc} - */ - public function encode($data) + public function encode($data): string { $string = ''; foreach ($data as $key => $value) { @@ -63,7 +46,7 @@ class IniFormatter implements FormatterInterface /** * {@inheritdoc} */ - public function decode($data) + public function decode($data): array { $decoded = @parse_ini_string($data); diff --git a/system/src/Grav/Framework/File/Formatter/JsonFormatter.php b/system/src/Grav/Framework/File/Formatter/JsonFormatter.php index 3ff3adfe7..7e1ac8e8d 100644 --- a/system/src/Grav/Framework/File/Formatter/JsonFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/JsonFormatter.php @@ -1,5 +1,7 @@ config = $config + [ + $config += [ 'file_extension' => '.json', 'encode_options' => 0, - 'decode_assoc' => true + '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} */ - public function getDefaultFileExtension() + public function encode($data): string { - $extensions = $this->getSupportedFileExtensions(); + $encoded = @json_encode($data, $this->getEncodeOptions()); - return (string) reset($extensions); - } - - /** - * {@inheritdoc} - */ - public function getSupportedFileExtensions() - { - return (array) $this->config['file_extension']; - } - - /** - * {@inheritdoc} - */ - public function encode($data) - { - $encoded = @json_encode($data, $this->config['encode_options']); - - if ($encoded === false) { - throw new \RuntimeException('Encoding JSON failed'); + if ($encoded === false && json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('Encoding JSON failed: ' . json_last_error_msg()); } return $encoded; @@ -60,10 +85,10 @@ class JsonFormatter implements FormatterInterface */ public function decode($data) { - $decoded = @json_decode($data, $this->config['decode_assoc']); + $decoded = @json_decode($data, $this->getDecodeAssoc(), $this->getDecodeDepth(), $this->getDecodeOptions()); - if ($decoded === false) { - throw new \RuntimeException('Decoding JSON failed'); + 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 index 181e4477e..348c84890 100644 --- a/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php @@ -1,5 +1,7 @@ config = $config + [ + $config += [ 'file_extension' => '.md', 'header' => 'header', 'body' => 'markdown', @@ -26,34 +28,58 @@ class MarkdownFormatter implements FormatterInterface 'yaml' => ['inline' => 20] ]; - $this->headerFormatter = $headerFormatter ?: new YamlFormatter($this->config['yaml']); + 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} */ - public function getDefaultFileExtension() + public function encode($data): string { - $extensions = $this->getSupportedFileExtensions(); - - return (string) reset($extensions); - } - - /** - * {@inheritdoc} - */ - public function getSupportedFileExtensions() - { - return (array) $this->config['file_extension']; - } - - /** - * {@inheritdoc} - */ - public function encode($data) - { - $headerVar = $this->config['header']; - $bodyVar = $this->config['body']; + $headerVar = $this->getHeaderField(); + $bodyVar = $this->getBodyField(); $header = isset($data[$headerVar]) ? (array) $data[$headerVar] : []; $body = isset($data[$bodyVar]) ? (string) $data[$bodyVar] : ''; @@ -61,7 +87,7 @@ class MarkdownFormatter implements FormatterInterface // Create Markdown file with YAML header. $encoded = ''; if ($header) { - $encoded = "---\n" . trim($this->headerFormatter->encode($data['header'])) . "\n---\n\n"; + $encoded = "---\n" . trim($this->getHeaderFormatter()->encode($data['header'])) . "\n---\n\n"; } $encoded .= $body; @@ -74,12 +100,13 @@ class MarkdownFormatter implements FormatterInterface /** * {@inheritdoc} */ - public function decode($data) + public function decode($data): array { - $headerVar = $this->config['header']; - $bodyVar = $this->config['body']; - $rawVar = $this->config['raw']; + $headerVar = $this->getHeaderField(); + $bodyVar = $this->getBodyField(); + $rawVar = $this->getRawField(); + // Define empty content $content = [ $headerVar => [], $bodyVar => '' @@ -100,7 +127,7 @@ class MarkdownFormatter implements FormatterInterface if ($rawVar) { $content[$rawVar] = $frontmatter; } - $content[$headerVar] = $this->headerFormatter->decode($frontmatter); + $content[$headerVar] = $this->getHeaderFormatter()->decode($frontmatter); $content[$bodyVar] = $matches[2]; } diff --git a/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php b/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php index 4e9ef9773..d1c4fa970 100644 --- a/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php @@ -1,5 +1,7 @@ config = $config + [ - 'file_extension' => '.ser' - ]; + $config += [ + 'file_extension' => '.ser', + 'decode_options' => ['allowed_classes' => [\stdClass::class]] + ]; + + parent::__construct($config); } /** - * {@inheritdoc} + * Returns options used in decode(). + * + * By default only allow stdClass class. + * + * @return array|bool */ - public function getDefaultFileExtension() + public function getOptions() { - $extensions = $this->getSupportedFileExtensions(); - - return (string) reset($extensions); + return $this->getConfig('decode_options'); } /** * {@inheritdoc} */ - public function getSupportedFileExtensions() - { - return (array) $this->config['file_extension']; - } - - /** - * {@inheritdoc} - */ - public function encode($data) + public function encode($data): string { return serialize($this->preserveLines($data, ["\n", "\r"], ['\\n', '\\r'])); } @@ -56,9 +52,9 @@ class SerializeFormatter implements FormatterInterface */ public function decode($data) { - $decoded = @unserialize($data); + $decoded = @unserialize($data, $this->getOptions()); - if ($decoded === false) { + if ($decoded === false && $data !== serialize(false)) { throw new \RuntimeException('Decoding serialized data failed'); } @@ -73,7 +69,7 @@ class SerializeFormatter implements FormatterInterface * @param array $replace * @return mixed */ - protected function preserveLines($data, $search, $replace) + protected function preserveLines($data, array $search, array $replace) { if (\is_string($data)) { $data = str_replace($search, $replace, $data); diff --git a/system/src/Grav/Framework/File/Formatter/YamlFormatter.php b/system/src/Grav/Framework/File/Formatter/YamlFormatter.php index 0a2ea04c1..78fde2b4a 100644 --- a/system/src/Grav/Framework/File/Formatter/YamlFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/YamlFormatter.php @@ -1,5 +1,7 @@ config = $config + [ + $config += [ 'file_extension' => '.yaml', 'inline' => 5, 'indent' => 2, 'native' => true, 'compat' => true ]; + + parent::__construct($config); } /** - * {@inheritdoc} + * @return int */ - public function getDefaultFileExtension() + public function getInlineOption(): int { - $extensions = $this->getSupportedFileExtensions(); - - return (string) reset($extensions); + return $this->getConfig('inline'); } /** - * {@inheritdoc} + * @return int */ - public function getSupportedFileExtensions() + public function getIndentOption(): int { - return (array) $this->config['file_extension']; + 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} */ - public function encode($data, $inline = null, $indent = null) + public function encode($data, $inline = null, $indent = null): string { try { - return (string) YamlParser::dump( + return YamlParser::dump( $data, - $inline ? (int) $inline : $this->config['inline'], - $indent ? (int) $indent : $this->config['indent'], + $inline ? (int) $inline : $this->getInlineOption(), + $indent ? (int) $indent : $this->getIndentOption(), YamlParser::DUMP_EXCEPTION_ON_INVALID_TYPE ); } catch (DumpException $e) { @@ -68,10 +83,10 @@ class YamlFormatter implements FormatterInterface /** * {@inheritdoc} */ - public function decode($data) + public function decode($data): array { // Try native PECL YAML PHP extension first if available. - if (\function_exists('yaml_parse') && $this->config['native']) { + if (\function_exists('yaml_parse') && $this->useNativeDecoder()) { // Safely decode YAML. $saved = @ini_get('yaml.decode_php'); @ini_set('yaml.decode_php', 0); @@ -79,14 +94,14 @@ class YamlFormatter implements FormatterInterface @ini_set('yaml.decode_php', $saved); if ($decoded !== false) { - return (array) $decoded; + return $decoded; } } try { - return (array) YamlParser::parse($data); + return YamlParser::parse($data); } catch (ParseException $e) { - if ($this->config['compat']) { + if ($this->useCompatibleDecoder()) { return (array) FallbackYamlParser::parse($data); }