diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f10d52d6..ee5fd1cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,34 +4,40 @@ 1. [](#new) * Added `select()` and `unselect()` methods to `CollectionInterface` and its base classes * Added `orderBy()` and `limit()` methods to `ObjectCollectionInterface` and its base classes - * Flex: Added support for custom object index classes (API compatibility break) * Added `user-data://` which is a writable stream (`user://data` is not and should be avoided) - * Added support for `/action:{$action}` (like task but works without nonce, used only for getting data) + * Added support for `/action:{$action}` (like task but used without nonce when only receiving data) * Added `onAction.{$action}` event + * Added `Grav\Framework\Form\FormFlash` class to contain AJAX uploaded files in more reliable way + * Added `Grav\Framework\Form\FormFlashFile` class which implements `UploadedFileInterface` from PSR-7 + * Added `Grav\Framework\Filesystem\Filesystem` class with methods to manipulate stream URLs + * Grav 1.6: Flex: Added support for custom object index classes (API compatibility break) 1. [](#improved) - * Improve Flex storage + * Improved `Grav\Framework\File\Formatter` classes to have abstract parent class and some useful methods + * Grav 1.6: Improved Flex storage classes + * Grav 1.6: Improved `Grav\Framework\File` classes to use better type hints and the new `Filesystem` class 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) * Fixed clearing cache having no effect on Doctrine cache * Fixed `Medium::relativePath()` for streams - * Fixed `FlexObject::update()` call with partial object update + * Fixed `Object` serialization breaking if overriding `jsonSerialize()` method + * Grav 1.6: Fixed `FlexObject::update()` call with partial object update # v1.6.0-beta.6 ## 11/12/2018 1. [](#new) - * Added `CsvFormatter` and `CsvFile` classes * Added `$grav->setup()` to simplify CLI and custom access points + * Grav 1.6: Added `CsvFormatter` and `CsvFile` classes 1. [](#improved) * Support negotiated content types set via the Request `Accept:` header * Support negotiated language types set via the Request `Accept-Language:` header - * Allow custom Flex form views * Cleaned up and sorted the Service `idMap` + * Grav 1.6: Allow custom Flex form views 1. [](#bugfix) * Fixed `Uri::hasStandardPort()` to support reverse proxy configurations [#1786](https://github.com/getgrav/grav/issues/1786) * Use `append_url_extension` from page header to set template format if set [#2604](https://github.com/getgrav/grav/pull/2064) - * Fixed some bugs in environment selection + * Fixed some bugs in Grav environment selection logic # v1.6.0-beta.5 ## 11/05/2018 @@ -50,7 +56,7 @@ * Set session name based on `security.salt` rather than `GRAV_ROOT` [#2242](https://github.com/getgrav/grav/issues/2242) * Added option to configure list of `xss_invalid_protocols` in `Security` config [#2250](https://github.com/getgrav/grav/issues/2250) * Smarter `security.salt` checking now we use `security.yaml` for other options - * Merged Grav 1.5.4 fixes in + * Grav 1.6: Merged Grav 1.5.4 fixes in # v1.6.0-beta.4 ## 10/24/2018 @@ -79,15 +85,15 @@ ## 10/09/2018 1. [](#new) - * Added Flex support for custom media tasks + * Grav 1.6: Added Flex support for custom media tasks 1. [](#improved) * Added support for syslog and syslog facility logging (default: 'file') * Improved usability of `System` configuration blueprint with side-tabs 1. [](#bugfix) * Fixed asset manager to not add empty assets when they don't exist in the filesystem - * Regression: Fixed asset manager methods with default legacy attributes * Update `script` and `style` Twig tags to use the new `Assets` classes * Fixed asset pipeline to rewrite remote URLs as well as local [#2216](https://github.com/getgrav/grav/issues/2216) + * Grav 1.6: Regression: Fixed asset manager methods with default legacy attributes # v1.6.0-beta.1 ## 10/01/2018 diff --git a/system/src/Grav/Framework/DI/Container.php b/system/src/Grav/Framework/DI/Container.php index ef71a3bb6..8d52cceef 100644 --- a/system/src/Grav/Framework/DI/Container.php +++ b/system/src/Grav/Framework/DI/Container.php @@ -20,7 +20,7 @@ class Container extends \Pimple\Container implements ContainerInterface return $this->offsetGet($id); } - public function has($id) + public function has($id): bool { return $this->offsetExists($id); } diff --git a/system/src/Grav/Framework/File/AbstractFile.php b/system/src/Grav/Framework/File/AbstractFile.php index 0023d48ce..9317a6949 100644 --- a/system/src/Grav/Framework/File/AbstractFile.php +++ b/system/src/Grav/Framework/File/AbstractFile.php @@ -12,9 +12,13 @@ declare(strict_types=1); namespace Grav\Framework\File; use Grav\Framework\File\Interfaces\FileInterface; +use Grav\Framework\Filesystem\Filesystem; class AbstractFile implements FileInterface { + /** @var Filesystem */ + private $filesystem; + /** @var string */ private $filepath; @@ -36,8 +40,13 @@ class AbstractFile implements FileInterface /** @var bool */ private $locked = false; - public function __construct($filepath) + /** + * @param string $filepath + * @param Filesystem|null $filesystem + */ + public function __construct(string $filepath, Filesystem $filesystem = null) { + $this->filesystem = $filesystem ?? new Filesystem(); $this->setFilepath($filepath); } @@ -51,11 +60,26 @@ class AbstractFile implements FileInterface } } - /** - * Prevent cloning. - */ - private function __clone() + 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])); } /** @@ -63,7 +87,7 @@ class AbstractFile implements FileInterface * * @return string */ - public function getFilePath() : string + public function getFilePath(): string { return $this->filepath; } @@ -73,7 +97,7 @@ class AbstractFile implements FileInterface * * @return string */ - public function getPath() : string + public function getPath(): string { if (null === $this->path) { $this->setPathInfo(); @@ -87,7 +111,7 @@ class AbstractFile implements FileInterface * * @return string */ - public function getFilename() : string + public function getFilename(): string { if (null === $this->filename) { $this->setPathInfo(); @@ -101,7 +125,7 @@ class AbstractFile implements FileInterface * * @return string */ - public function getBasename() : string + public function getBasename(): string { if (null === $this->basename) { $this->setPathInfo(); @@ -113,10 +137,10 @@ class AbstractFile implements FileInterface /** * Return file extension. * - * @param $withDot + * @param bool $withDot * @return string */ - public function getExtension($withDot = false) : string + public function getExtension(bool $withDot = false): string { if (null === $this->extension) { $this->setPathInfo(); @@ -130,7 +154,7 @@ class AbstractFile implements FileInterface * * @return bool */ - public function exists() : bool + public function exists(): bool { return is_file($this->filepath); } @@ -138,21 +162,21 @@ class AbstractFile implements FileInterface /** * Return file modification time. * - * @return int|bool Timestamp or false if file doesn't exist. + * @return int Unix timestamp. If file does not exist, method returns current time. */ - public function getCreationTime() + public function getCreationTime(): int { - return is_file($this->filepath) ? filectime($this->filepath) : false; + return is_file($this->filepath) ? filectime($this->filepath) : time(); } /** * Return file modification time. * - * @return int|bool Timestamp or false if file doesn't exist. + * @return int Unix timestamp. If file does not exist, method returns current time. */ - public function getModificationTime() + public function getModificationTime(): int { - return is_file($this->filepath) ? filemtime($this->filepath) : false; + return is_file($this->filepath) ? filemtime($this->filepath) : time(); } /** @@ -162,7 +186,7 @@ class AbstractFile implements FileInterface * @return bool * @throws \RuntimeException */ - public function lock($block = true) : bool + public function lock(bool $block = true): bool { if (!$this->handle) { if (!$this->mkdir($this->getPath())) { @@ -184,7 +208,7 @@ class AbstractFile implements FileInterface * * @return bool */ - public function unlock() : bool + public function unlock(): bool { if (!$this->handle) { return false; @@ -204,25 +228,39 @@ class AbstractFile implements FileInterface * * @return bool True = locked, false = not locked. */ - public function isLocked() : bool + public function isLocked(): bool { return $this->locked; } + /** + * Check if file exists and can be read. + * + * @return bool + */ + public function isReadable(): bool + { + return is_readable($this->filepath) && is_file($this->filepath); + } + /** * Check if file can be written. * * @return bool */ - public function isWritable() : bool + public function isWritable(): bool { - return is_writable($this->filepath) || $this->isWritableDir($this->getPath()); + if (!file_exists($this->filepath)) { + return $this->isWritablePath($this->getPath()); + } + + return is_writable($this->filepath) && is_file($this->filepath); } /** - * (Re)Load a file and return RAW file contents. + * (Re)Load a file and return file contents. * - * @return string + * @return string|array|false */ public function load() { @@ -235,7 +273,7 @@ class AbstractFile implements FileInterface * @param mixed $data * @throws \RuntimeException */ - public function save($data) + public function save($data): void { $lock = false; if (!$this->locked) { @@ -266,7 +304,7 @@ class AbstractFile implements FileInterface * @param string $path * @return bool */ - public function rename($path) : bool + public function rename(string $path): bool { if ($this->exists() && !@rename($this->filepath, $path)) { return false; @@ -282,7 +320,7 @@ class AbstractFile implements FileInterface * * @return bool */ - public function delete() : bool + public function delete(): bool { return @unlink($this->filepath); } @@ -293,7 +331,7 @@ class AbstractFile implements FileInterface * @throws \RuntimeException * @internal */ - protected function mkdir($dir) : bool + protected function mkdir(string $dir): bool { // Silence error for open_basedir; should fail in mkdir instead. if (!@is_dir($dir)) { @@ -310,20 +348,27 @@ class AbstractFile implements FileInterface } /** - * @param string $dir - * @return bool - * @internal + * @return array */ - protected function isWritableDir($dir) : bool + protected function doSerialize(): array { - if ($dir && !file_exists($dir)) { - return $this->isWritableDir(\dirname($dir)); - } - - return $dir && is_dir($dir) && is_writable($dir); + return [ + 'filepath' => $this->filepath + ]; } - protected function setFilepath($filepath) : void + /** + * @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; @@ -332,64 +377,32 @@ class AbstractFile implements FileInterface $this->extension = null; } - protected function setPathInfo() : void + protected function setPathInfo(): void { - $pathInfo = static::pathinfo($this->filepath); - $this->filename = $pathInfo['filename']; - $this->basename = $pathInfo['basename']; - $this->path = $pathInfo['dirname']; - $this->extension = $pathInfo['extension']; + $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; } /** - * Multi-byte-safe pathinfo replacement. - * Replacement for pathinfo(), but stream, multibyte and cross-platform safe. - * - * @see http://www.php.net/manual/en/function.pathinfo.php - * - * @param string $path A filename or path, does not need to exist as a file - * @param int|string $options Either a PATHINFO_* constant, - * or a string name to return only the specified piece - * - * @return string|array + * @param string $dir + * @return bool + * @internal */ - public static function pathinfo($path, $options = null) + protected function isWritablePath(string $dir): bool { - $ret = ['scheme' => '', 'dirname' => '', 'basename' => '', 'extension' => '', 'filename' => '']; - $pathinfo = []; - if (preg_match('#^((.*?)://)?(.*?)[\\\\/]*(([^/\\\\]*?)(\.([^\.\\\\/]+?)|))[\\\\/\.]*$#um', $path, $pathinfo)) { - if (array_key_exists(1, $pathinfo)) { - $ret['scheme'] = $pathinfo[2]; - $ret['dirname'] = $pathinfo[1]; - } - if (array_key_exists(3, $pathinfo)) { - $ret['dirname'] .= $pathinfo[3]; - } - if (array_key_exists(4, $pathinfo)) { - $ret['basename'] = $pathinfo[4]; - } - if (array_key_exists(7, $pathinfo)) { - $ret['extension'] = $pathinfo[7]; - } - if (array_key_exists(5, $pathinfo)) { - $ret['filename'] = $pathinfo[5]; - } + if ($dir === '') { + return false; } - switch ($options) { - case PATHINFO_DIRNAME: - case 'dirname': - return $ret['dirname']; - case PATHINFO_BASENAME: - case 'basename': - return $ret['basename']; - case PATHINFO_EXTENSION: - case 'extension': - return $ret['extension']; - case PATHINFO_FILENAME: - case 'filename': - return $ret['filename']; - default: - return $ret; + + 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); } } diff --git a/system/src/Grav/Framework/File/DataFile.php b/system/src/Grav/Framework/File/DataFile.php index fd6b8957f..cd8fe1c89 100644 --- a/system/src/Grav/Framework/File/DataFile.php +++ b/system/src/Grav/Framework/File/DataFile.php @@ -34,7 +34,7 @@ class DataFile extends AbstractFile /** * (Re)Load a file and return RAW file contents. * - * @return array + * @return array|false * @throws RuntimeException */ public function load() @@ -42,7 +42,7 @@ class DataFile extends AbstractFile $raw = parent::load(); try { - return $this->formatter->decode($raw); + 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); } @@ -54,9 +54,10 @@ class DataFile extends AbstractFile * @param string|array $data Data to be saved. * @throws RuntimeException */ - public function save($data) + 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) { diff --git a/system/src/Grav/Framework/File/File.php b/system/src/Grav/Framework/File/File.php index bcac43941..31072b43c 100644 --- a/system/src/Grav/Framework/File/File.php +++ b/system/src/Grav/Framework/File/File.php @@ -16,11 +16,11 @@ class File extends AbstractFile /** * Load a file from the filesystem. * - * @return string + * @return string|false */ public function load() { - return (string) parent::load(); + return parent::load(); } /** @@ -29,8 +29,12 @@ class File extends AbstractFile * @param string $data * @throws \RuntimeException */ - public function save($data) + public function save($data): void { + if (!\is_string($data)) { + throw new \RuntimeException('Cannot save data, string required'); + } + parent::save($data); } } 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); } diff --git a/system/src/Grav/Framework/File/Interfaces/FileInterface.php b/system/src/Grav/Framework/File/Interfaces/FileInterface.php index 3aa8745ea..9c727398b 100644 --- a/system/src/Grav/Framework/File/Interfaces/FileInterface.php +++ b/system/src/Grav/Framework/File/Interfaces/FileInterface.php @@ -11,64 +11,64 @@ declare(strict_types=1); namespace Grav\Framework\File\Interfaces; -interface FileInterface +interface FileInterface extends \Serializable { /** * Get full path to the file. * * @return string */ - public function getFilePath() : string; + public function getFilePath(): string; /** * Get path to the file. * * @return string */ - public function getPath() : string; + public function getPath(): string; /** * Get filename. * * @return string */ - public function getFilename() : string; + public function getFilename(): string; /** * Return name of the file without extension. * * @return string */ - public function getBasename() : string; + public function getBasename(): string; /** * Return file extension. * - * @param $withDot + * @param bool $withDot * @return string */ - public function getExtension($withDot = false) : string; + public function getExtension(bool $withDot = false): string; /** * Check if file exits. * * @return bool */ - public function exists() : bool; + public function exists(): bool; /** * Return file modification time. * - * @return int|bool Timestamp or false if file doesn't exist. + * @return int Unix timestamp. If file does not exist, method returns current time. */ - public function getCreationTime(); + public function getCreationTime(): int; /** * Return file modification time. * - * @return int|bool Timestamp or false if file doesn't exist. + * @return int Unix timestamp. If file does not exist, method returns current time. */ - public function getModificationTime(); + public function getModificationTime(): int; /** * Lock file for writing. You need to manually unlock(). @@ -77,33 +77,40 @@ interface FileInterface * @return bool * @throws \RuntimeException */ - public function lock($block = true) : bool; + public function lock(bool $block = true): bool; /** * Unlock file. * * @return bool */ - public function unlock() : bool; + public function unlock(): bool; /** * Returns true if file has been locked for writing. * * @return bool True = locked, false = not locked. */ - public function isLocked() : bool; + public function isLocked(): bool; + + /** + * Check if file exists and can be read. + * + * @return bool + */ + public function isReadable(): bool; /** * Check if file can be written. * * @return bool */ - public function isWritable() : bool; + public function isWritable(): bool; /** * (Re)Load a file and return RAW file contents. * - * @return string + * @return string|array|false */ public function load(); @@ -113,7 +120,7 @@ interface FileInterface * @param mixed $data * @throws \RuntimeException */ - public function save($data); + public function save($data): void; /** * Rename file in the filesystem if it exists. @@ -121,26 +128,12 @@ interface FileInterface * @param string $path * @return bool */ - public function rename($path) : bool; + public function rename(string $path): bool; /** * Delete file from filesystem. * * @return bool */ - public function delete() : bool; - - /** - * Multi-byte-safe pathinfo replacement. - * Replacement for pathinfo(), but stream, multibyte and cross-platform safe. - * - * @see http://www.php.net/manual/en/function.pathinfo.php - * - * @param string $path A filename or path, does not need to exist as a file - * @param int|string $options Either a PATHINFO_* constant, - * or a string name to return only the specified piece - * - * @return string|array - */ - public static function pathinfo($path, $options = null); + public function delete(): bool; } diff --git a/system/src/Grav/Framework/Filesystem/Filesystem.php b/system/src/Grav/Framework/Filesystem/Filesystem.php new file mode 100644 index 000000000..01a1cece3 --- /dev/null +++ b/system/src/Grav/Framework/Filesystem/Filesystem.php @@ -0,0 +1,268 @@ +normalizeNext = true; + + return $this; + } + + /** + * Tell that all the paths are save to be used (already normalized) in the next call. + * + * Note: Normalizing option will be reset after the next method call. + * + * @return $this + */ + public function safe() + { + $this->normalizeNext = false; + + return $this; + } + + /** + * Returns parent path. Empty path is returned if there are no segments remaining. + * + * Can be used recursively to get towards the root directory. + * + * @param string $path + * @param int $levels + * @return string + * @throws \RuntimeException + */ + public function parent(string $path, int $levels = 1): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalizeNext !== false) { + $path = $this->normalizePathPart($path); + } + $this->normalizeNext = null; + + if ($path === '') { + return ''; + } + + [$scheme, $parent] = $this->dirnameInternal($scheme, $path, $levels); + + return $parent !== $path ? $this->toString($scheme, $parent) : ''; + } + + /** + * Normalize path by cleaning up \ , /./ , // and /../ + * + * @param string $path + * @return string + * @throws \RuntimeException + */ + public function normalize(string $path): string + { + if ($this->normalizeNext === false) { + $this->normalizeNext = null; + + return $path; + } + + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + $path = $this->normalizePathPart($path); + + return $this->toString($scheme, $path); + } + + /** + * Stream safe dirname() replacement. + * + * @see http://php.net/manual/en/function.dirname.php + * + * @param string $path + * @param int $levels + * @return string + * @throws \RuntimeException + */ + public function dirname(string $path, int $levels = 1): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalizeNext || ($scheme && null === $this->normalizeNext)) { + $path = $this->normalizePathPart($path); + } + $this->normalizeNext = null; + + [$scheme, $path] = $this->dirnameInternal($scheme, $path, $levels); + + return $this->toString($scheme, $path); + } + + /** + * Multi-byte and stream-safe pathinfo() replacement. + * + * Replacement for pathinfo(), but stream, multibyte and cross-platform safe. + * + * @see http://www.php.net/manual/en/function.pathinfo.php + * + * @param string $path A filename or path, does not need to exist as a file + * @param int|string $options Either a PATHINFO_* constant, or a string name to return only the specified piece + * + * @return array|string + */ + public function pathinfo(string $path, int $options = null) + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalizeNext || ($scheme && null === $this->normalizeNext)) { + $path = $this->normalizePathPart($path); + } + $this->normalizeNext = null; + + 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 + */ + 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/Flex/FlexForm.php b/system/src/Grav/Framework/Flex/FlexForm.php index 193d87a4a..ab3a84c07 100644 --- a/system/src/Grav/Framework/Flex/FlexForm.php +++ b/system/src/Grav/Framework/Flex/FlexForm.php @@ -16,6 +16,7 @@ use Grav\Common\Grav; use Grav\Common\Utils; use Grav\Framework\Flex\Interfaces\FlexFormInterface; use Grav\Framework\Flex\Interfaces\FlexObjectInterface; +use Grav\Framework\Form\FormFlash; use Grav\Framework\Route\Route; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UploadedFileInterface; @@ -36,31 +37,33 @@ class FlexForm implements FlexFormInterface private $submitted; /** @var string[] */ private $errors; - /** @var Data */ + /** @var Data|FlexObjectInterface */ private $data; - /** @var UploadedFileInterface[] */ + /** @var array|UploadedFileInterface[] */ private $files; /** @var FlexObjectInterface */ private $object; + /** @var FormFlash */ + private $flash; /** * FlexForm constructor. * @param string $name - * @param FlexObjectInterface|null $object + * @param FlexObjectInterface $object */ - public function __construct(string $name = '', FlexObjectInterface $object = null) + public function __construct(string $name, FlexObjectInterface $object) { - $this->reset(); - - if ($object) { - $this->setObject($object); - } - $this->name = $name; - $this->id = $this->getName(); + $this->setObject($object); + $this->setId($this->getName()); + $this->reset(); } /** + * Get HTML id="..." attribute. + * + * Defaults to 'flex-[type]-[name]', where 'type' is object type and 'name' is the first parameter given in constructor. + * * @return string */ public function getId(): string @@ -69,6 +72,8 @@ class FlexForm implements FlexFormInterface } /** + * Sets HTML id="" attribute. + * * @param string $id */ public function setId(string $id): void @@ -77,34 +82,10 @@ class FlexForm implements FlexFormInterface } /** - * @return string - */ - public function getName(): string - { - $object = $this->object; - $name = $this->name ?: 'object'; - - return "flex-{$object->getType(false)}-{$name}"; - } - - - /** - * @return string - */ - public function getNonceName(): string - { - return 'nonce'; - } - - /** - * @return string - */ - public function getNonceAction(): string - { - return 'flex-object'; - } - - /** + * Get unique id for the current form instance. By default regenerated on every page reload. + * + * This id is used to load the saved form state, if available. + * * @return string */ public function getUniqueId(): string @@ -116,6 +97,51 @@ class FlexForm implements FlexFormInterface return $this->uniqueid; } + /** + * Sets unique form id allowing you to attach the form state to the object for example. + * + * @param string $uniqueId + */ + public function setUniqueId(string $uniqueId): void + { + $this->uniqueid = $uniqueId; + } + + /** + * @return string + */ + public function getName(): string + { + $object = $this->getObject(); + $name = $this->name ?: 'object'; + + return "flex-{$object->getType(false)}-{$name}"; + } + + /** + * @return string + */ + public function getFormName(): string + { + return $this->name; + } + + /** + * @return string + */ + public function getNonceName(): string + { + return 'form-nonce'; + } + + /** + * @return string + */ + public function getNonceAction(): string + { + return 'form'; + } + /** * @return string */ @@ -125,29 +151,20 @@ class FlexForm implements FlexFormInterface return ''; } - /** - * @return array - */ - public function getButtons(): array - { - return [ - [ - 'type' => 'submit', - 'value' => 'Save' - ] - ]; - } - /** * @return Data|FlexObjectInterface */ public function getData() { - if (null === $this->data) { - $this->data = $this->getObject(); - } + return $this->data ?? $this->getObject(); + } - return $this->data; + /** + * @return array|UploadedFileInterface[] + */ + public function getFiles(): array + { + return $this->files; } /** @@ -160,57 +177,11 @@ class FlexForm implements FlexFormInterface */ public function getValue(string $name) { - $data = $this->getData(); - return $data instanceof FlexObject ? $data->getNestedProperty($name) : $data->get($name); - } - - /** - * @return UploadedFileInterface[] - */ - public function getFiles(): array - { - return $this->files; - } - - /** - * @return Route|null - */ - public function getFileUploadAjaxRoute(): ?Route - { - $object = $this->getObject(); - if (!method_exists($object, 'route')) { - return null; + if (null === $this->data) { + return $this->getObject()->getNestedProperty($name); } - return $object->route('/edit.json/task:media.upload'); - } - - /** - * @param $field - * @param $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'); - } - - /** - * Note: this method clones the object. - * - * @param FlexObjectInterface $object - * @return $this - */ - public function setObject(FlexObjectInterface $object): FlexFormInterface - { - $this->object = clone $object; - - return $this; + return $this->data->get($name); } /** @@ -218,10 +189,6 @@ class FlexForm implements FlexFormInterface */ public function getObject(): FlexObjectInterface { - if (!$this->object) { - throw new \RuntimeException('FlexForm: Object is not defined'); - } - return $this->object; } @@ -285,26 +252,14 @@ class FlexForm implements FlexFormInterface } $this->files = $files ?? []; - $this->data = new Data($this->decodeData($data['data'] ?? [])); + $this->data = new Data($this->decodeData($data['data'] ?? []), $this->getBlueprint()); if ($this->getErrors()) { return $this; } - $this->validate(); + $this->doSubmit($this->data->toArray(), $this->files); $this->submitted = true; - - $object = clone $this->object; - $object->update($this->data->toArray()); - $object->triggerEvent('onSave'); - - if (method_exists($object, 'upload')) { - $object->upload($this->files); - } - $object->save(); - - $this->object = $object; - $this->valid = true; } catch (ValidationException $e) { $list = []; foreach ($e->getMessages() as $field => $errors) { @@ -386,6 +341,33 @@ class FlexForm implements FlexFormInterface $this->object = $data['object']; } + /** + * @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 $field + * @param $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(): string { @@ -405,6 +387,33 @@ class FlexForm implements FlexFormInterface return '/' . $this->object->getKey(); } + /** + * Note: this method clones the object. + * + * @param FlexObjectInterface $object + * @return $this + */ + protected function setObject(FlexObjectInterface $object): FlexFormInterface + { + $this->object = clone $object; + + return $this; + } + + /** + * Get flash object + * + * @return FormFlash + */ + protected function getFlash() + { + if (null === $this->flash) { + $this->flash = new FormFlash($this->getName(), $this->getUniqueId()); + } + + return $this->flash; + } + /** * @throws \Exception */ @@ -415,6 +424,41 @@ class FlexForm implements FlexFormInterface $this->checkUploads($this->files); } + protected function setErrors(array $errors): void + { + $this->errors = array_merge($this->errors, $errors); + } + + protected function setError(string $error): void + { + $this->errors[] = $error; + } + + /** + * @param array $data + * @param array $files + * @throws \Exception + */ + protected function doSubmit(array $data, array $files) + { + $this->validate(); + + $object = clone $this->object; + $object->update($data); + + if (method_exists($object, 'triggerEvent')) { + $object->triggerEvent('onSave'); + } + + if (method_exists($object, 'upload')) { + $object->upload($files); + } + + $object->save(); + + $this->object = $object; + } + protected function checkUploads(array $files): void { foreach ($files as $file) { diff --git a/system/src/Grav/Framework/Flex/FlexObject.php b/system/src/Grav/Framework/Flex/FlexObject.php index e5cc56018..af38f40c0 100644 --- a/system/src/Grav/Framework/Flex/FlexObject.php +++ b/system/src/Grav/Framework/Flex/FlexObject.php @@ -18,6 +18,7 @@ use Grav\Common\Page\Medium\MediumFactory; use Grav\Common\Twig\Twig; use Grav\Framework\ContentBlock\HtmlBlock; use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface; +use Grav\Framework\Flex\Interfaces\FlexFormInterface; use Grav\Framework\Flex\Traits\FlexAuthorizeTrait; use Grav\Framework\Object\Access\NestedArrayAccessTrait; use Grav\Framework\Object\Access\NestedPropertyTrait; @@ -170,7 +171,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface public function getForm(string $name = '') { if (!isset($this->_forms[$name])) { - $this->_forms[$name] = new FlexForm($name, $this); + $this->_forms[$name] = $this->createFormObject($name); } return $this->_forms[$name]; @@ -662,4 +663,15 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface unset ($elements['storage_key'], $elements['storage_timestamp']); } + + /** + * This methods allows you to override form objects in child classes. + * + * @param string $name Form name + * @return FlexFormInterface + */ + protected function createFormObject(string $name): FlexFormInterface + { + return new FlexForm($name, $this); + } } diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php index 1a6bc7e46..dedf059dc 100644 --- a/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php @@ -31,6 +31,16 @@ interface FlexFormInterface extends \Serializable */ public function setId(string $id): void; + /** + * @return string + */ + public function getUniqueId(): string; + + /** + * @param string $uniqueId + */ + public function setUniqueId(string $uniqueId): void; + /** * @return string */ @@ -47,21 +57,11 @@ interface FlexFormInterface extends \Serializable */ public function getNonceAction(): string; - /** - * @return string - */ - public function getUniqueId(): string; - /** * @return string */ public function getAction(): string; - /** - * @return array - */ - public function getButtons() : array; - /** * @return Data|FlexObjectInterface */ @@ -94,14 +94,6 @@ interface FlexFormInterface extends \Serializable */ public function getFileDeleteAjaxRoute($field, $filename): ?Route; - /** - * Note: this method clones the object. - * - * @param FlexObjectInterface $object - * @return $this - */ - public function setObject(FlexObjectInterface $object): self; - /** * @return FlexObjectInterface */ @@ -152,20 +144,6 @@ interface FlexFormInterface extends \Serializable */ public function getBlueprint(): Blueprint; - /** - * Implements \Serializable::serialize(). - * - * @return string - */ - public function serialize(): string; - - /** - * Implements \Serializable::unserialize(). - * - * @param string $data - */ - public function unserialize($data): void; - /** * @return string */ diff --git a/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php b/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php index 76dae57b9..ed08e4608 100644 --- a/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php +++ b/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php @@ -15,8 +15,6 @@ use Grav\Common\File\CompiledJsonFile; use Grav\Common\File\CompiledMarkdownFile; use Grav\Common\File\CompiledYamlFile; use Grav\Common\Grav; -use Grav\Common\Helpers\Base32; -use Grav\Common\Utils; use Grav\Framework\File\Formatter\FormatterInterface; use Grav\Framework\File\Formatter\JsonFormatter; use Grav\Framework\File\Formatter\MarkdownFormatter; diff --git a/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php index c9a6c7428..907eccec0 100644 --- a/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php +++ b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php @@ -14,7 +14,7 @@ use Grav\Common\Filesystem\Folder; use Grav\Common\Grav; use Grav\Common\Media\Traits\MediaTrait; use Grav\Common\Utils; -use Grav\Plugin\Form\FormFlashFile; +use Grav\Framework\Form\FormFlashFile; use Psr\Http\Message\UploadedFileInterface; use RocketTheme\Toolbox\File\YamlFile; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; @@ -80,7 +80,7 @@ trait FlexMediaTrait } } - public function uploadMediaFile(UploadedFileInterface $uploadedFile, string $filename = null) : void + public function uploadMediaFile(UploadedFileInterface $uploadedFile, string $filename = null, string $field = null) : void { $this->checkUploadedMediaFile($uploadedFile); @@ -121,7 +121,7 @@ trait FlexMediaTrait $this->clearMediaCache(); } - public function deleteMediaFile(string $filename) : void + public function deleteMediaFile(string $filename, string $field = null) : void { $grav = Grav::instance(); $language = $grav['language']; diff --git a/system/src/Grav/Framework/Form/FormFlash.php b/system/src/Grav/Framework/Form/FormFlash.php new file mode 100644 index 000000000..7396f96ab --- /dev/null +++ b/system/src/Grav/Framework/Form/FormFlash.php @@ -0,0 +1,400 @@ +form = $form; + $this->uniqueId = $uniqueId; + + $file = $this->getTmpIndex(); + $this->exists = $file->exists(); + + $data = $this->exists ? (array)$file->content() : []; + $this->url = $data['url'] ?? null; + $this->user = $data['user'] ?? null; + $this->uploads = $data['uploads'] ?? []; + } + + /** + * @return string + */ + public function getFormName() : string + { + return $this->form; + } + + /** + * @return string + */ + public function getUniqieId() : string + { + return $this->uniqueId ?? $this->getFormName(); + } + + /** + * @return bool + */ + public function exists() : bool + { + return $this->exists; + } + + /** + * @return $this + */ + public function save() : self + { + $file = $this->getTmpIndex(); + $file->save($this->jsonSerialize()); + $this->exists = true; + + return $this; + } + + public function delete() : self + { + $this->removeTmpDir(); + $this->uploads = []; + $this->exists = false; + + return $this; + } + + /** + * @return string + */ + public function getUrl() : string + { + return $this->url ?? ''; + } + + /** + * @param string $url + * @return $this + */ + public function setUrl(string $url) : self + { + $this->url = $url; + + return $this; + } + + /** + * @return string + */ + public function getUsername() : string + { + return $this->user['username'] ?? ''; + } + + /** + * @return string + */ + public function getUserEmail() : string + { + return $this->user['email'] ?? ''; + } + + /** + * @param User|null $user + * @return $this + */ + public function setUser(?User $user = null) : self + { + if ($user && $user->username) { + $this->user = [ + 'username' => $user->username, + 'email' => $user->email + ]; + } else { + $this->user = null; + } + + return $this; + } + + + /** + * @param string $field + * @return array + */ + public function getFilesByField(string $field) : array + { + if (!isset($this->uploadObjects[$field])) { + $objects = []; + foreach ($this->uploads[$field] ?? [] as $filename => $upload) { + $objects[$filename] = new FormFlashFile($field, $upload, $this); + } + $this->uploadObjects[$field] = $objects; + } + + return $this->uploadObjects[$field]; + } + + /** + * @return array + */ + public function getFilesByFields() : array + { + $list = []; + foreach ($this->uploads as $field => $values) { + if (strpos($field, '/')) { + continue; + } + $list[$field] = $this->getFilesByField($field); + } + + return $list; + } + + /** + * @return array + * @deprecated 1.6 For backwards compatibility only, do not use. + */ + public function getLegacyFiles() : array + { + $fields = []; + foreach ($this->uploads as $field => $files) { + if (strpos($field, '/')) { + continue; + } + foreach ($files as $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 + */ + public function uploadFile(string $field, string $filename, array $upload) : bool + { + $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; + + if (!isset($this->uploads[$field])) { + $this->uploads[$field] = []; + } + + // Prepare object for later save + $upload['file']['name'] = $filename; + + // Replace old file, including original + $oldUpload = $this->uploads[$field][$filename] ?? null; + if (isset($oldUpload['tmp_name'])) { + $this->removeTmpFile($oldUpload['tmp_name']); + } + + $originalUpload = $this->uploads[$field . '/original'][$filename] ?? null; + if (isset($originalUpload['tmp_name'])) { + $this->removeTmpFile($originalUpload['tmp_name']); + unset($this->uploads[$field . '/original'][$filename]); + } + + // Prepare data to be saved later + $this->uploads[$field][$filename] = $upload['file']; + + return true; + } + + /** + * @param string $field + * @param string $filename + * @param array $upload + * @param array $crop + * @return bool + */ + public function cropFile(string $field, string $filename, array $upload, array $crop) : bool + { + $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; + + if (!isset($this->uploads[$field])) { + $this->uploads[$field] = []; + } + + // Prepare object for later save + $upload['file']['name'] = $filename; + + $oldUpload = $this->uploads[$field][$filename] ?? null; + if ($oldUpload) { + $originalUpload = $this->uploads[$field . '/original'][$filename] ?? null; + if ($originalUpload) { + $this->removeTmpFile($oldUpload['tmp_name']); + } else { + $oldUpload['crop'] = $crop; + $this->uploads[$field . '/original'][$filename] = $oldUpload; + } + } + + // Prepare data to be saved later + $this->uploads[$field][$filename] = $upload['file']; + + return true; + } + + /** + * @param string $field + * @param string $filename + * @return bool + */ + public function removeFile(string $field, string $filename) : bool + { + if (!$field || !$filename) { + return false; + } + + $file = $this->getTmpIndex(); + if (!$file->exists()) { + return false; + } + + $upload = $this->uploads[$field][$filename] ?? null; + if (null !== $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + $upload = $this->uploads[$field . '/original'][$filename] ?? null; + if (null !== $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + + // Walk backward to cleanup any empty field that's left + unset( + $this->uploadObjects[$field][$filename], + $this->uploads[$field][$filename], + $this->uploadObjects[$field . '/original'][$filename], + $this->uploads[$field . '/original'][$filename] + ); + if (empty($this->uploads[$field])) { + unset($this->uploads[$field]); + } + if (empty($this->uploads[$field . '/original'])) { + unset($this->uploads[$field . '/original']); + } + + return true; + } + + /** + * @return array + */ + public function jsonSerialize() : array + { + return [ + 'form' => $this->form, + 'unique_id' => $this->uniqueId, + 'url' => $this->url, + 'user' => $this->user, + 'uploads' => $this->uploads + ]; + } + + /** + * @return string + */ + public function getTmpDir() : string + { + $grav = Grav::instance(); + + /** @var Session $session */ + $session = $grav['session']; + + $location = [ + 'forms', + $session->getId(), + $this->uniqueId ?: $this->form + ]; + + return $grav['locator']->findResource('tmp://', true, true) . '/' . implode('/', $location); + } + + /** + * @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 + { + $filename = $this->getTmpDir() . '/' . $name; + if ($name && is_file($filename)) { + unlink($filename); + } + } + + protected function removeTmpDir() : void + { + $tmpDir = $this->getTmpDir(); + if (file_exists($tmpDir)) { + Folder::delete($tmpDir); + } + } +} diff --git a/system/src/Grav/Framework/Form/FormFlashFile.php b/system/src/Grav/Framework/Form/FormFlashFile.php new file mode 100644 index 000000000..2cb78d016 --- /dev/null +++ b/system/src/Grav/Framework/Form/FormFlashFile.php @@ -0,0 +1,146 @@ +field = $field; + $this->upload = $upload; + $this->flash = $flash; + + if ($this->isOk() && (empty($this->upload['tmp_name']) || !file_exists($this->getTmpFile()))) { + $this->upload['error'] = \UPLOAD_ERR_NO_FILE; + } + + if (!isset($this->upload['size'])) { + $this->upload['size'] = $this->isOk() ? filesize($this->getTmpFile()) : 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->field, $this->upload['tmp_name']); + } + + 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 __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'); + } + } + + /** + * @return bool return true if there is no upload error + */ + private function isOk(): bool + { + return \UPLOAD_ERR_OK === $this->getError(); + } + + private function getTmpFile() : string + { + return $this->flash->getTmpDir() . '/' . $this->upload['tmp_name']; + } +} diff --git a/system/src/Grav/Framework/Object/Base/ObjectTrait.php b/system/src/Grav/Framework/Object/Base/ObjectTrait.php index 395e6ad3d..4b4a4fa65 100644 --- a/system/src/Grav/Framework/Object/Base/ObjectTrait.php +++ b/system/src/Grav/Framework/Object/Base/ObjectTrait.php @@ -140,7 +140,7 @@ trait ObjectTrait */ protected function doSerialize() { - return $this->jsonSerialize(); + return ['key' => $this->getKey(), 'type' => $this->getType(), 'elements' => $this->getElements()]; } /** @@ -163,7 +163,7 @@ trait ObjectTrait */ public function jsonSerialize() { - return ['key' => $this->getKey(), 'type' => $this->getType(), 'elements' => $this->getElements()]; + return $this->doSerialize(); } /** diff --git a/system/src/Grav/Framework/Psr7/Request.php b/system/src/Grav/Framework/Psr7/Request.php index 7e1fd6b93..f6c26ae4d 100644 --- a/system/src/Grav/Framework/Psr7/Request.php +++ b/system/src/Grav/Framework/Psr7/Request.php @@ -1,3 +1,3 @@ [ + 'parent' => '', + 'normalize' => '', + 'dirname' => '', + 'pathinfo' => [ + 'basename' => '', + 'filename' => '', + ] + ], + '.' => [ + 'parent' => '', + 'normalize' => '', + 'dirname' => '.', + 'pathinfo' => [ + 'dirname' => '.', + 'basename' => '.', + 'extension' => '', + 'filename' => '', + ] + ], + './' => [ + 'parent' => '', + 'normalize' => '', + 'dirname' => '.', + 'pathinfo' => [ + 'dirname' => '.', + 'basename' => '.', + 'extension' => '', + 'filename' => '', + + ] + ], + '././.' => [ + 'parent' => '', + 'normalize' => '', + 'dirname' => './.', + 'pathinfo' => [ + 'dirname' => './.', + 'basename' => '.', + 'extension' => '', + 'filename' => '', + ] + ], + '.file' => [ + 'parent' => '.', + 'normalize' => '.file', + 'dirname' => '.', + 'pathinfo' => [ + 'dirname' => '.', + 'basename' => '.file', + 'extension' => 'file', + 'filename' => '', + ] + ], + '/' => [ + 'parent' => '', + 'normalize' => '/', + 'dirname' => '/', + 'pathinfo' => [ + 'dirname' => '/', + 'basename' => '', + 'filename' => '', + ] + ], + '/absolute' => [ + 'parent' => '/', + 'normalize' => '/absolute', + 'dirname' => '/', + 'pathinfo' => [ + 'dirname' => '/', + 'basename' => 'absolute', + 'filename' => 'absolute', + ] + ], + '/absolute/' => [ + 'parent' => '/', + 'normalize' => '/absolute', + 'dirname' => '/', + 'pathinfo' => [ + 'dirname' => '/', + 'basename' => 'absolute', + 'filename' => 'absolute', + ] + ], + '/very/long/absolute/path' => [ + 'parent' => '/very/long/absolute', + 'normalize' => '/very/long/absolute/path', + 'dirname' => '/very/long/absolute', + 'pathinfo' => [ + 'dirname' => '/very/long/absolute', + 'basename' => 'path', + 'filename' => 'path', + ] + ], + 'relative' => [ + 'parent' => '.', + 'normalize' => 'relative', + 'dirname' => '.', + 'pathinfo' => [ + 'dirname' => '.', + 'basename' => 'relative', + 'filename' => 'relative', + ] + ], + 'very/long/relative/path' => [ + 'parent' => 'very/long/relative', + 'normalize' => 'very/long/relative/path', + 'dirname' => 'very/long/relative', + 'pathinfo' => [ + 'dirname' => 'very/long/relative', + 'basename' => 'path', + 'filename' => 'path', + ] + ], + 'path/to/file.jpg' => [ + 'parent' => 'path/to', + 'normalize' => 'path/to/file.jpg', + 'dirname' => 'path/to', + 'pathinfo' => [ + 'dirname' => 'path/to', + 'basename' => 'file.jpg', + 'extension' => 'jpg', + 'filename' => 'file', + ] + ], + 'user://' => [ + 'parent' => '', + 'normalize' => 'user://', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => '', + 'filename' => '', + 'scheme' => 'user', + ] + ], + 'user://.' => [ + 'parent' => '', + 'normalize' => 'user://', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => '', + 'filename' => '', + 'scheme' => 'user', + ] + ], + 'user://././.' => [ + 'parent' => '', + 'normalize' => 'user://', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => '', + 'filename' => '', + 'scheme' => 'user', + ] + ], + 'user://./././file' => [ + 'parent' => 'user://', + 'normalize' => 'user://file', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => 'file', + 'filename' => 'file', + 'scheme' => 'user', + ] + ], + 'user://./././folder/file' => [ + 'parent' => 'user://folder', + 'normalize' => 'user://folder/file', + 'dirname' => 'user://folder', + 'pathinfo' => [ + 'dirname' => 'user://folder', + 'basename' => 'file', + 'filename' => 'file', + 'scheme' => 'user', + ] + ], + 'user://.file' => [ + 'parent' => 'user://', + 'normalize' => 'user://.file', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => '.file', + 'extension' => 'file', + 'filename' => '', + 'scheme' => 'user', + ] + ], + 'user:///' => [ + 'parent' => '', + 'normalize' => 'user:///', + 'dirname' => 'user:///', + 'pathinfo' => [ + 'dirname' => 'user:///', + 'basename' => '', + 'filename' => '', + 'scheme' => 'user', + ] + ], + 'user:///absolute' => [ + 'parent' => 'user:///', + 'normalize' => 'user:///absolute', + 'dirname' => 'user:///', + 'pathinfo' => [ + 'dirname' => 'user:///', + 'basename' => 'absolute', + 'filename' => 'absolute', + 'scheme' => 'user', + ] + ], + 'user:///very/long/absolute/path' => [ + 'parent' => 'user:///very/long/absolute', + 'normalize' => 'user:///very/long/absolute/path', + 'dirname' => 'user:///very/long/absolute', + 'pathinfo' => [ + 'dirname' => 'user:///very/long/absolute', + 'basename' => 'path', + 'filename' => 'path', + 'scheme' => 'user', + ] + ], + 'user://relative' => [ + 'parent' => 'user://', + 'normalize' => 'user://relative', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => 'relative', + 'filename' => 'relative', + 'scheme' => 'user', + ] + ], + 'user://very/long/relative/path' => [ + 'parent' => 'user://very/long/relative', + 'normalize' => 'user://very/long/relative/path', + 'dirname' => 'user://very/long/relative', + 'pathinfo' => [ + 'dirname' => 'user://very/long/relative', + 'basename' => 'path', + 'filename' => 'path', + 'scheme' => 'user', + ] + ], + 'user://path/to/file.jpg' => [ + 'parent' => 'user://path/to', + 'normalize' => 'user://path/to/file.jpg', + 'dirname' => 'user://path/to', + 'pathinfo' => [ + 'dirname' => 'user://path/to', + 'basename' => 'file.jpg', + 'extension' => 'jpg', + 'filename' => 'file', + 'scheme' => 'user', + ] + ], + ]; + + protected function _before() + { + $this->class = new Filesystem(); + } + + protected function _after() + { + unset($this->class); + } + + protected function runTestSet(array $tests, $method) + { + $class = $this->class; + foreach ($tests as $path => $candidates) { + if (!array_key_exists($method, $candidates)) { + continue; + } + + $expected = $candidates[$method]; + + $result = $class->{$method}($path); + + $this->assertSame($expected, $result, "Test {$method}('{$path}')"); + + if (function_exists($method) && !strpos($path, '://')) { + $cmp_result = $method($path); + + $this->assertSame($cmp_result, $result, "Compare to original {$method}('{$path}')"); + } + } + } + + public function testParent() + { + $this->runTestSet($this->tests, 'parent'); + } + + public function testNormalize() + { + $this->runTestSet($this->tests, 'normalize'); + } + + public function testDirname() + { + $this->runTestSet($this->tests, 'dirname'); + } + + public function testPathinfo() + { + $this->runTestSet($this->tests, 'pathinfo'); + } +}