From 593abccedc5de5e2a31e71b531d33ebf5f20c022 Mon Sep 17 00:00:00 2001 From: Matias Griese Date: Wed, 12 Sep 2018 15:52:28 +0300 Subject: [PATCH] Added `Grav\Framework\Flex` classes --- CHANGELOG.md | 1 + .../Grav/Framework/Flex/FlexCollection.php | 364 +++++++++++ .../src/Grav/Framework/Flex/FlexDirectory.php | 578 +++++++++++++++++ system/src/Grav/Framework/Flex/FlexForm.php | 316 ++++++++++ system/src/Grav/Framework/Flex/FlexIndex.php | 299 +++++++++ system/src/Grav/Framework/Flex/FlexObject.php | 582 ++++++++++++++++++ .../Interfaces/FlexAuthorizeInterface.php | 26 + .../Interfaces/FlexCollectionInterface.php | 35 ++ .../Flex/Interfaces/FlexObjectInterface.php | 40 ++ .../Flex/Interfaces/FlexStorageInterface.php | 106 ++++ .../Storage/AbstractFilesystemStorage.php | 131 ++++ .../Framework/Flex/Storage/FileStorage.php | 75 +++ .../Framework/Flex/Storage/FolderStorage.php | 371 +++++++++++ .../Framework/Flex/Storage/SimpleStorage.php | 258 ++++++++ .../Flex/Traits/FlexAuthorizeTrait.php | 44 ++ .../Framework/Flex/Traits/FlexMediaTrait.php | 135 ++++ 16 files changed, 3361 insertions(+) create mode 100644 system/src/Grav/Framework/Flex/FlexCollection.php create mode 100644 system/src/Grav/Framework/Flex/FlexDirectory.php create mode 100644 system/src/Grav/Framework/Flex/FlexForm.php create mode 100644 system/src/Grav/Framework/Flex/FlexIndex.php create mode 100644 system/src/Grav/Framework/Flex/FlexObject.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexCollectionInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexObjectInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexStorageInterface.php create mode 100644 system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php create mode 100644 system/src/Grav/Framework/Flex/Storage/FileStorage.php create mode 100644 system/src/Grav/Framework/Flex/Storage/FolderStorage.php create mode 100644 system/src/Grav/Framework/Flex/Storage/SimpleStorage.php create mode 100644 system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php create mode 100644 system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b94c3a52..d5282ebcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Added `Grav\Framework\File` classes for handling YAML, Markdown, JSON, INI and PHP serialized files * Added `Grav\Framework\Collection\AbstractIndexCollection` class * Added `Grav\Framework\Object\ObjectIndex` class + * Added `Grav\Framework\Flex` classes # v1.5.2 ## mm/dd/2018 diff --git a/system/src/Grav/Framework/Flex/FlexCollection.php b/system/src/Grav/Framework/Flex/FlexCollection.php new file mode 100644 index 000000000..ce1752eb5 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexCollection.php @@ -0,0 +1,364 @@ + true, + 'getType' => true, + 'getFlexDirectory' => true, + 'getCacheKey' => true, + 'getCacheChecksum' => true, + 'getTimestamp' => true, + 'hasProperty' => true, + 'getProperty' => true, + 'hasNestedProperty' => true, + 'getNestedProperty' => true, + 'orderBy' => true, + + 'authorize' => true + ]; + } + + /** + * @param array $elements + * @param FlexDirectory|null $flexDirectory + * @throws \InvalidArgumentException + */ + public function __construct(array $elements = [], FlexDirectory $flexDirectory = null) + { + parent::__construct($elements); + + if ($flexDirectory) { + $this->setFlexDirectory($flexDirectory)->setKey($flexDirectory->getType()); + } + } + + /** + * 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. + * + * @return static + * @throws \InvalidArgumentException + */ + protected function createFrom(array $elements) + { + return new static($elements, $this->flexDirectory); + } + + /** + * @return string + */ + protected function getTypePrefix() + { + return 'c.'; + } + + /** + * @param bool $prefix + * @return string + */ + public function getType($prefix = true) + { + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->flexDirectory->getType(); + } + + /** + * @param string $layout + * @param array $context + * @return HtmlBlock + * @throws \Exception + * @throws \Throwable + * @throws \Twig_Error_Loader + * @throws \Twig_Error_Syntax + */ + public function render($layout = null, array $context = []) + { + if (null === $layout) { + $layout = 'default'; + } + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->startTimer('flex-collection-' . ($debugKey = uniqid($this->getType(false), false)), 'Render Collection ' . $this->getType(false)); + + $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->flexDirectory->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); + + $grav->fireEvent('onFlexCollectionRender', new Event([ + 'collection' => $this, + 'layout' => &$layout, + 'context' => &$context + ])); + + $output = $this->getTemplate($layout)->render( + ['grav' => $grav, 'block' => $block, 'collection' => $this, 'layout' => $layout] + $context + ); + + $block->setContent($output); + + try { + $cache && $cache->set($key, $block->toArray()); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + } + + $debugger->stopTimer('flex-collection-' . $debugKey); + + return $block; + } + + /** + * @param FlexDirectory $type + * @return $this + */ + public function setFlexDirectory(FlexDirectory $type) + { + $this->flexDirectory = $type; + + return $this; + } + + /** + * @return FlexDirectory + */ + public function getFlexDirectory() : FlexDirectory + { + return $this->flexDirectory; + } + + /** + * @return string + */ + public function getCacheKey() + { + return $this->getType(true) . '.' . sha1(json_encode($this->call('getKey'))); + } + + /** + * @return string + */ + public function getCacheChecksum() + { + return sha1(json_encode($this->getTimestamps())); + } + + /** + * @return int[] + */ + public function getTimestamps() + { + return $this->call('getTimestamp'); + } + + /** + * @param string $action + * @param string|null $scope + * @return FlexCollection + */ + public function authorize(string $action, string $scope = null) + { + $list = $this->call('authorize', [$action, $scope]); + $list = \array_filter($list); + + return $this->select(array_keys($list)); + } + + /** + * @param string $value + * @param string $field + * @return object|null + */ + public function find($value, $field = 'id') + { + if ($value) foreach ($this as $element) { + if (strtolower($element->getProperty($field)) === strtolower($value)) { + return $element; + } + } + + return null; + } + + /** + * @param array $ordering + * @return FlexCollection + */ + public function orderBy(array $ordering) + { + $criteria = Criteria::create()->orderBy($ordering); + + return $this->matching($criteria); + } + + /** + * @param int $start + * @param int|null $limit + * @return FlexCollection + */ + public function limit($start, $limit = null) + { + return $this->createFrom($this->slice($start, $limit)); + } + + /** + * Select items from collection. + * + * Collection is returned in the order of $keys given to the function. + * + * @param array $keys + * @return FlexCollection + */ + 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. + * + * Collection is returned in the order of $keys given to the function. + * + * @param array $keys + * @return FlexCollection + */ + public function unselect(array $keys) + { + return $this->select(array_diff($this->getKeys(), $keys)); + } + + /** + * @return array + */ + public function jsonSerialize() + { + $elements = []; + + /** + * @var string $key + * @var FlexObject $object + */ + foreach ($this->getElements() as $key => $object) { + $elements[$key] = $object->jsonSerialize(); + } + + return $elements; + } + + /** + * @param string $layout + * @return \Twig_Template + * @throws \Twig_Error_Loader + * @throws \Twig_Error_Syntax + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + try { + return $twig->twig()->resolveTemplate(["flex-objects/layouts/{$this->getType(false)}/collection/{$layout}.html.twig"]); + } catch (\Twig_Error_Loader $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + return $twig->twig()->resolveTemplate(["flex-objects/layouts/404.html.twig"]); + } + } + + /** + * @param $type + * @return FlexDirectory + * @throws \RuntimeException + */ + protected function getRelatedDirectory($type) + { + /** @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; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexDirectory.php b/system/src/Grav/Framework/Flex/FlexDirectory.php new file mode 100644 index 000000000..a113b8e26 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexDirectory.php @@ -0,0 +1,578 @@ +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 + */ + public function getType() : string + { + return $this->type; + } + + /** + * @return string + */ + public function getTitle() : string + { + return $this->getBlueprintInternal()->get('title', ucfirst($this->getType())); + } + + /** + * @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 + { + $blueprint = $this->getBlueprintInternal($type, $context); + + if (empty($this->blueprints_init[$type])) { + $this->blueprints_init[$type] = true; + + $blueprint->init(); + if (empty($blueprint->fields())) { + throw new RuntimeException(sprintf('Flex: Blueprint for %s is missing', $this->type)); + } + } + + return $blueprint; + } + + /** + * @return string + */ + public function getBlueprintFile() : string + { + return $this->blueprint_file; + } + + /** + * @param array|null $keys Array of keys. + * @return FlexIndex|FlexCollection + */ + public function getCollection(array $keys = null) : CollectionInterface + { + $index = clone $this->getIndex($keys); + + if (!Utils::isAdminPlugin()) { + $filters = (array)$this->getConfig('site.filter', []); + foreach ($filters as $filter) { + $index = $index->{$filter}(); + } + } + + return $index; + } + + /** + * @param string $key + * @return FlexObject|null + */ + public function getObject($key) : ?FlexObject + { + return $this->getCollection()->get($key); + } + + /** + * @param array $data + * @param string|null $key + * @param bool $isFullUpdate + * @return FlexObject + */ + public function update(array $data, string $key = null, bool $isFullUpdate = false) : FlexObject + { + $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->triggerEvent('onSave')->prepareStorage()]); + } else { + $rows = $storage->createRows([$object->triggerEvent('onSave')->prepareStorage()]); + } + } else { + $oldKey = $object->getStorageKey(); + $object->update($data, $isFullUpdate); + $newKey = $object->getStorageKey(); + + if ($oldKey !== $newKey) { + $object->triggerEvent('move'); + $storage->renameRow($oldKey, $newKey); + // TODO: media support. + } + + $object->save(); + //$rows = $storage->updateRows([$newKey => $object->triggerEvent('onSave')->prepareStorage()]); + } + + 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. + } + + /** @var FlexObject $class */ + //$class = $this->getObjectClass(); + // + //$row = $object; + //$index = $class::createIndex([key($rows) => time()]); + //$object = $this->createObject($row, key($index), false); + + return $object; + } + + /** + * @param string $key + * @return FlexObject|null + */ + public function remove(string $key) : ?FlexObject + { + $object = null !== $key ? $this->getIndex()->get($key) : null; + if (!$object) { + return null; + } + + $this->getStorage()->deleteRows([$object->getStorageKey() => $object->triggerEvent('onRemove')->prepareStorage()]); + + 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|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) : CacheInterface + { + $namespace = $namespace ?: 'index'; + + if (!isset($this->cache[$namespace])) { + try { + $grav = Grav::instance(); + + /** @var Cache $gravCache */ + $gravCache = $grav['cache']; + $config = $this->getConfig('cache.' . $namespace); + if (empty($config['enabled'])) { + throw new \RuntimeException(sprintf('Flex: %s %s cache not enabled', $this->type, $namespace)); + } + $timeout = $config['timeout'] ?? 60; + + $key = $gravCache->getKey(); + if (Utils::isAdminPlugin()) { + $key = substr($key, 0, -1); + } + $this->cache[$namespace] = new DoctrineCache($gravCache->getCacheDriver(), 'flex-objects-' . $this->getType() . $key, $timeout); + } catch (\Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + $this->cache[$namespace] = new MemoryCache('flex-objects-' . $this->getType()); + } + + // Disable cache key validation. + $this->cache[$namespace]->setValidation(false); + } + + return $this->cache[$namespace]; + } + + /** + * @return $this + */ + public function clearCache() : self + { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addMessage(sprintf('Flex: Clearing all %s cache', $this->type), 'debug'); + + $this->getCache('index')->clear(); + $this->getCache('object')->clear(); + $this->getCache('render')->clear(); + + 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 (!$this->storage) { + $this->storage = $this->createStorage(); + } + + return $this->storage; + } + + /** + * @param array|null $keys Array of keys. + * @return FlexIndex|FlexCollection + * @internal + */ + public function getIndex(array $keys = null) : CollectionInterface + { + $index = clone $this->loadIndex(); + + if (null !== $keys) { + $index = $index->select($keys); + } + + return $index; + } + + /** + * @param array $data + * @param string $key + * @param bool $validate + * @return FlexObject + */ + public function createObject(array $data, string $key, bool $validate = false) : FlexObject + { + $className = $this->objectClassName ?: $this->getObjectClass(); + + return new $className($data, $key, $this, $validate); + } + + /** + * @param array $entries + * @return FlexCollection + */ + public function createCollection(array $entries) : FlexCollection + { + $className = $this->collectionClassName ?: $this->getCollectionClass(); + + return new $className($entries, $this); + } + + /** + * @return string + */ + public function getObjectClass() : string + { + if (!$this->objectClassName) { + $this->objectClassName = $this->getConfig('data.object', 'Grav\\Plugin\\FlexObjects\\FlexObject'); + } + return $this->objectClassName; + + } + + /** + * @return string + */ + public function getCollectionClass() : string + { + if (!$this->collectionClassName) { + $this->collectionClassName = $this->getConfig('data.collection', 'Grav\\Plugin\\FlexObjects\\FlexCollection'); + } + return $this->collectionClassName; + } + + /** + * @param array $entries + * @return FlexCollection + */ + public function loadCollection(array $entries) : FlexCollection + { + return $this->createCollection($this->loadObjects($entries)); + } + + /** + * @param array $entries + * @return FlexObject[] + */ + 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 = \is_array($value) ? $value[0] : $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 { + $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; + } + $row += [ + 'storage_key' => $storageKey, + 'storage_timestamp' => $entries[$key][1] ?? $entries[$key], + ]; + $key = $keys[$storageKey]; + $object = $this->createObject($row, $key, false); + $list[$key] = $object; + } + + $debugger->stopTimer('flex-objects'); + + return $list; + } + + /** + * @param string $type + * @param string $context + * @return Blueprint + */ + protected function getBlueprintInternal(string $type = '', string $context = '') : Blueprint + { + if (!isset($this->blueprints[$type])) { + if (!file_exists($this->blueprint_file)) { + throw new RuntimeException(sprintf('Flex: Blueprint file for %s is missing', $this->type)); + } + $blueprint = new Blueprint($this->blueprint_file); + 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] = $blueprint; + } + + return $this->blueprints[$type]; + } + + /** + * @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 FlexIndex|FlexCollection + */ + protected function loadIndex() : CollectionInterface + { + static $i = 0; + + if (null === $this->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 FlexObject $className */ + $className = $this->getObjectClass(); + $keys = $className::createIndex($storage->getExistingKeys()); + $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 = new FlexIndex($keys, $this); + $this->index = $this->index->orderBy($this->getConfig('data.ordering', [])); + + $debugger->stopTimer('flex-keys-' . $this->type . $j); + } + + return $this->index; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexForm.php b/system/src/Grav/Framework/Flex/FlexForm.php new file mode 100644 index 000000000..f36354c52 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexForm.php @@ -0,0 +1,316 @@ +name = $name; + + $this->reset(); + + if ($object) { + $this->setObject($object); + } + } + + /** + * @return string + */ + public function getName() : string + { + $object = $this->object; + + return "flex-{$object->getType(false)}-{$this->name}"; + } + + /** + * @return string + */ + public function getAction() : string + { + // TODO: + return ''; + } + + /** + * @return array + */ + public function getButtons() : array + { + return [ + [ + 'type' => 'submit', + 'value' => 'Save' + ] + ]; + } + + /** + * @return Data + */ + public function getData() : Data + { + if (null === $this->data) { + $this->data = new Data($this->getObject()->jsonSerialize()); + } + + return $this->data; + } + + /** + * Get a value from the form. + * + * Note: Used in form fields. + * + * @param string $name + * @return mixed + */ + public function getValue(string $name) + { + return $this->getData()->get($name); + } + + /** + * @return UploadedFileInterface[] + */ + public function getFiles() : array + { + return $this->files; + } + + /** + * Note: this method clones the object. + * + * @param FlexObject $object + * @return $this + */ + public function setObject(FlexObject $object) : self + { + $this->object = clone $object; + + return $this; + } + + /** + * @return FlexObject + */ + public function getObject() : FlexObject + { + if (!$this->object) { + throw new \RuntimeException('FlexForm: Object is not defined'); + } + + return $this->object; + } + + /** + * @param ServerRequestInterface $request + * @return $this + */ + public function handleRequest(ServerRequestInterface $request) : self + { + try { + $method = $request->getMethod(); + if (!\in_array($method, ['PUT', 'POST', 'PATCH'])) { + throw new \RuntimeException(sprintf('FlexForm: Bad HTTP method %s', $method)); + } + + $data = $request->getParsedBody(); + $files = $request->getUploadedFiles(); + + $this->submit($data, $files); + } catch (\Exception $e) { + $this->errors[] = $e->getMessage(); + } + + return $this; + } + + /** + * @return bool + */ + public function isValid() : bool + { + return !$this->errors; + } + + /** + * @return array + */ + public function getErrors() : array + { + return $this->errors; + } + + /** + * @return bool + */ + public function isSubmitted() : bool + { + return $this->submitted; + } + + /** + * @param array $data + * @param UploadedFileInterface[] $files + * @return $this + */ + public function submit(array $data, array $files = null) : self + { + try { + if ($this->isSubmitted()) { + throw new \RuntimeException('Form has already been submitted'); + } + + $this->data = new Data($data); + $this->files = $files ?? []; + $this->submitted = true; + + $this->checkUploads($files); + + $object = clone $this->object; + $object->update($this->data->toArray()); + /* + if (method_exists($object, 'upload')) { + $object->upload($this->files); + } + $object->save(); + */ + + $this->object = $object; + $this->valid = true; + } catch (ValidationException $e) { + $this->errors = $e->getMessages(); + } catch (\Exception $e) { + $this->errors[] = $e->getMessage(); + } + + return $this; + } + + /** + * @return $this + */ + public function reset() : self + { + $this->data = null; + $this->files = []; + $this->errors = []; + $this->submitted = false; + + return $this; + } + + /** + * Note: Used in form fields. + * + * @return array + */ + public function getFields() : array + { + return $this->getObject()->getBlueprint()->fields(); + } + + /** + * Implements \Serializable::serialize(). + * + * @return string + */ + public function serialize() : string + { + $data = [ + 'name' => $this->name, + 'data' => $this->data, + 'files' => $this->files, + 'errors' => $this->errors, + 'submitted' => $this->submitted, + 'object' => $this->object, + ]; + + return serialize($data); + } + + /** + * Implements \Serializable::unserialize(). + * + * @param string $data + */ + public function unserialize($data) : void + { + $data = unserialize($data, ['allowed_classes' => [FlexObject::class]]); + + $this->name = $data['name']; + $this->data = $data['data']; + $this->files = $data['files']; + $this->errors = $data['errors']; + $this->submitted = $data['submitted']; + $this->object = $data['object']; + } + + protected function checkUploads(array $files) + { + foreach ($files as $file) { + if ($file instanceof UploadedFileInterface) { + $this->checkUpload($file); + } else { + $this->checkUploads($file); + } + } + } + + protected function checkUpload(UploadedFileInterface $file) : void + { + // Handle bad filenames. + $filename = $file->getClientFilename(); + if (strtr($filename, "\t\n\r\0\x0b", '_____') !== $filename + || rtrim($filename, '. ') !== $filename + || preg_match('|\.php|', $filename)) { + $grav = Grav::instance(); + throw new \RuntimeException( + sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null, true), $filename, 'Bad filename') + ); + } + } +} diff --git a/system/src/Grav/Framework/Flex/FlexIndex.php b/system/src/Grav/Framework/Flex/FlexIndex.php new file mode 100644 index 000000000..1f207398f --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexIndex.php @@ -0,0 +1,299 @@ +flexDirectory = $flexDirectory; + } + + /** + * @return FlexDirectory + */ + public function getFlexDirectory() : FlexDirectory + { + return $this->flexDirectory; + } + + /** + * @param bool $prefix + * @return string + */ + public function getType($prefix = true) + { + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->flexDirectory->getType(); + } + + /** + * @return string[] + */ + public function getStorageKeys() + { + // Get storage keys for the objects. + $keys = []; + foreach ($this->getEntries() as $key => $value) { + $keys[\is_array($value) ? $value[0] : $key] = $key; + } + + return $keys; + } + + /** + * @return int[] + */ + public function getTimestamps() + { + // Get storage keys for the objects. + $timestamps = []; + foreach ($this->getEntries() as $key => $value) { + $timestamps[$key] = \is_array($value) ? $value[1] : $value; + } + + return $timestamps; + } + + /** + * @return string + */ + public function getCacheKey() + { + return $this->getType(true) . '.' . sha1(json_encode($this->getKeys())); + } + + /** + * @return string + */ + public function getCacheChecksum() + { + return sha1($this->getCacheKey() . json_encode($this->getTimestamps())); + } + + /** + * @param array $orderings + * @return FlexIndex|FlexCollection + */ + public function orderBy(array $orderings) + { + if (!$orderings) { + return $this; + } + + // Check if ordering needs to load the objects. + if (array_diff_key($orderings, ['key' => true, 'storage_key' => true, 'timestamp' => true])) { + return $this->__call('orderBy', [$orderings]); + } + + // Ordering can be done by using index only. + $previous = null; + foreach (array_reverse($orderings) as $field => $ordering) { + switch ($field) { + case 'key': + $keys = $this->getKeys(); + $search = array_combine($keys, $keys); + break; + case 'storage_key': + $search = array_flip($this->getStorageKeys()); + break; + case 'timestamp': + $search = $this->getTimestamps(); + break; + default: + continue 2; + } + + // 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(); + + if (!empty($cachedMethods[$name])) { + $key = $this->getType(true) . '.' . sha1($name . '.' . json_encode($arguments) . $this->getCacheKey()); + + $cache = $this->flexDirectory->getCache('object'); + + $test = new \stdClass; + try { + $result = $cache->get($key, $test); + } catch (InvalidArgumentException $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + $result = $test; + } + + if ($result === $test) { + $result = $this->loadCollection()->{$name}(...$arguments); + + try { + // If flex collection is returned, convert it back to flex index. + if ($result instanceof FlexCollection) { + $cached = $result->getFlexDirectory()->getIndex($result->getKeys()); + } else { + $cached = $result; + } + + $cache->set($key, $cached); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + + // TODO: log error. + } + } + } else { + $collection = $this->loadCollection(); + $result = $collection->{$name}(...$arguments); + $class = \get_class($collection); + $debugger->addMessage("Call '{$class}:{$name}()' isn't cached", 'debug'); + } + + return $result; + } + + /** + * @return string + */ + public function serialize() + { + return serialize(['type' => $this->getType(false), 'entries' => $this->getEntries()]); + } + + /** + * @param string $serialized + */ + public function unserialize($serialized) + { + $data = unserialize($serialized); + + $this->flexDirectory = Grav::instance()['flex_objects']->getDirectory($data['type']); + $this->setEntries($data['entries']); + } + + /** + * @param array $entries + * @param array $indexes + * @return static + */ + protected function createFrom(array $entries) + { + return new static($entries, $this->flexDirectory); + } + + /** + * @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()); + } + + /** + * @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(); + } +} diff --git a/system/src/Grav/Framework/Flex/FlexObject.php b/system/src/Grav/Framework/Flex/FlexObject.php new file mode 100644 index 000000000..87a799423 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexObject.php @@ -0,0 +1,582 @@ + true, + 'getType' => true, + 'getFlexDirectory' => true, + 'getCacheKey' => true, + 'getCacheChecksum' => true, + 'getTimestamp' => true, + 'value' => true, + 'exists' => true, + 'hasProperty' => true, + 'getProperty' => true, + + // FlexAclTrait + 'authorize' => true, + ]; + } + + /** + * @param array $index + * @return array + */ + public static function createIndex(array $index) + { + return $index; + } + + /** + * @param array $elements + * @param string $key + * @param FlexDirectory $flexDirectory + * @param bool $validate + * @throws \InvalidArgumentException + * @throws ValidationException + */ + public function __construct(array $elements, $key, FlexDirectory $flexDirectory, $validate = false) + { + $this->flexDirectory = $flexDirectory; + + if ($validate) { + $blueprint = $this->getFlexDirectory()->getBlueprint(); + + $blueprint->validate($elements); + + $elements = $blueprint->filter($elements); + } + + $this->filterElements($elements); + + $this->objectConstruct($elements, $key); + } + + /** + * @param array $data + * @param bool $isFullUpdate + * @return $this + * @throws ValidationException + */ + public function update(array $data, $isFullUpdate = false) + { + $blueprint = $this->getFlexDirectory()->getBlueprint(); + + if (!$isFullUpdate) { + $elements = $this->getElements(); + $data = $blueprint->mergeData($elements, $data); + } + + $blueprint->validate($data + ['storage_key' => $this->getStorageKey()]); + $data = $blueprint->filter($data); + + $this->filterElements($data); + $this->setElements($data); + + return $this; + } + + /** + * @return string + */ + protected function getTypePrefix() + { + return 'o.'; + } + + /** + * @param bool $prefix + * @return string + */ + public function getType($prefix = true) + { + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->flexDirectory->getType(); + } + + /** + * @return FlexDirectory + */ + public function getFlexDirectory() : FlexDirectory + { + return $this->flexDirectory; + } + + /** + * @param string $name + * @return FlexForm + */ + public function getForm($name = 'default') + { + if (!isset($this->forms[$name])) { + $this->forms[$name] = new FlexForm($name, $this); + } + + return $this->forms[$name]; + } + + /** + * @return \Grav\Common\Data\Blueprint + */ + public function getBlueprint() + { + return $this->flexDirectory->getBlueprint(); + } + + /** + * Alias of getBlueprint() + * + * @return \Grav\Common\Data\Blueprint + * @deprecated Admin compatibility + */ + public function blueprints() + { + return $this->getBlueprint(); + } + + /** + * @return string + */ + public function getCacheKey() + { + return $this->getType(true) .'.'. $this->getStorageKey(); + } + + /** + * @return int + */ + public function getCacheChecksum() + { + return $this->getTimestamp(); + } + + /** + * @return string + */ + public function getStorageKey() + { + return $this->storageKey; + } + + /** + * @param string|null $key + * @return $this + */ + public function setStorageKey($key = null) + { + $this->storageKey = $key; + + return $this; + } + + /** + * @return int + */ + public function getTimestamp() : int + { + return $this->timestamp; + } + + /** + * @param int $timestamp + * @return $this + */ + public function setTimestamp($timestamp = null) + { + $this->timestamp = $timestamp ?? time(); + + return $this; + } + + /** + * @param string $layout + * @param array $context + * @return HtmlBlock + * @throws \Exception + * @throws \Throwable + * @throws \Twig_Error_Loader + * @throws \Twig_Error_Syntax + */ + public function render($layout = null, array $context = []) + { + if (null === $layout) { + $layout = 'default'; + } + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->startTimer('flex-object-' . ($debugKey = uniqid($this->getType(false), false)), 'Render Object ' . $this->getType(false)); + + $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->flexDirectory->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); + + $grav->fireEvent('onFlexObjectRender', new Event([ + 'object' => $this, + 'layout' => &$layout, + 'context' => &$context + ])); + + $output = $this->getTemplate($layout)->render( + ['grav' => $grav, 'block' => $block, 'object' => $this, 'layout' => $layout] + $context + ); + + $block->setContent($output); + + try { + $cache && $cache->set($key, $block->toArray()); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + } + + $debugger->stopTimer('flex-object-' . $debugKey); + + return $block; + } + + /** + * Form field compatibility. + * + * @param string $name + * @param mixed $default + * @return mixed + */ + public function value($name, $default = null) + { + if ($name === 'storage_key') { + return $this->getStorageKey(); + } + + return $this->getNestedProperty($name, $default); + } + + /** + * @return bool + */ + public function exists() + { + $key = $this->getStorageKey(); + + return $key && $this->getFlexDirectory()->getStorage()->hasKey($key); + } + + /** + * @return array + */ + public function jsonSerialize() + { + return $this->getElements(); + } + + /** + * @return array + */ + public function prepareStorage() + { + return $this->getElements(); + } + + /** + * @return string + */ + public function getStorageFolder() + { + return $this->getFlexDirectory()->getStorageFolder($this->getStorageKey()); + } + + /** + * @return string + */ + public function getMediaFolder() + { + return $this->getFlexDirectory()->getMediaFolder($this->getStorageKey()); + } + + /** + * @param string $name + * @return $this + */ + public function triggerEvent($name) + { + return $this; + } + + /** + * Create new object into storage. + * + * @param string|null $key Optional new key. + * @return $this + */ + public function create($key = null) + { + if ($key) { + $this->setStorageKey($key); + } + + if ($this->exists()) { + throw new \RuntimeException('Cannot create new object (Already exists)'); + } + + return $this->save(); + } + + /** + * @return $this + */ + public function save() + { + $this->getFlexDirectory()->getStorage()->replaceRows([$this->getStorageKey() => $this->prepareStorage()]); + + try { + $this->getFlexDirectory()->clearCache(); + if (method_exists($this, 'clearMediaCache')) { + $this->clearMediaCache(); + } + } catch (InvalidArgumentException $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + return $this; + } + + /** + * @return $this + */ + public function delete() + { + $this->getFlexDirectory()->getStorage()->deleteRows([$this->getStorageKey() => $this->prepareStorage()]); + + try { + $this->getFlexDirectory()->clearCache(); + if (method_exists($this, 'clearMediaCache')) { + $this->clearMediaCache(); + } + } catch (InvalidArgumentException $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + return $this; + } + + /** + * @return array + */ + protected function doSerialize() + { + return $this->jsonSerialize() + ['storage_key' => $this->getStorageKey(), 'storage_timestamp' => $this->getTimestamp()]; + } + + /** + * @param array $serialized + */ + protected function doUnserialize(array $serialized) + { + $type = $serialized['type'] ?? 'unknown'; + + if (!isset($serialized['key'], $serialized['type'], $serialized['elements'])) { + $type = $serialized['type'] ?? 'unknown'; + throw new \InvalidArgumentException("Cannot unserialize '{$type}': Bad data"); + } + + $grav = Grav::instance(); + /** @var Flex $flex */ + $flex = $grav['flex_directory']; + $directory = $flex->getDirectory($type); + if (!$directory) { + throw new \InvalidArgumentException("Cannot unserialize '{$type}': Not found"); + } + $this->flexDirectory = $directory; + $this->storageKey = $serialized['storage_key']; + $this->timestamp = $serialized['storage_timestamp']; + + $this->setKey($serialized['key']); + $this->setElements($serialized['elements']); + } + + /** + * @param string $uri + * @return Medium|null + */ + protected function createMedium($uri) + { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $file = $uri ? $locator->findResource($uri) : null; + + return $file ? MediumFactory::fromFile($file) : null; + } + + /** + * @param string $type + * @param string $property + * @return FlexCollection + */ + protected function getCollectionByProperty($type, $property) + { + $directory = $this->getRelatedDirectory($type); + $collection = $directory->getCollection(); + $list = $this->getNestedProperty($property) ?: []; + + $collection = $collection->filter(function ($object) use ($list) { return \in_array($object->id, $list, true); }); + + return $collection; + } + + /** + * @param $type + * @return FlexDirectory + * @throws \RuntimeException + */ + protected function getRelatedDirectory($type) + { + /** @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 \Twig_Template + * @throws \Twig_Error_Loader + * @throws \Twig_Error_Syntax + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + try { + return $twig->twig()->resolveTemplate(["flex-objects/layouts/{$this->getType(false)}/object/{$layout}.html.twig"]); + } catch (\Twig_Error_Loader $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + return $twig->twig()->resolveTemplate(["flex-objects/layouts/404.html.twig"]); + } + } + + /** + * @param array $elements + */ + protected function filterElements(array &$elements) + { + if (!empty($elements['storage_key'])) { + $this->storageKey = trim($elements['storage_key']); + } + if (!empty($elements['storage_timestamp'])) { + $this->timestamp = (int)$elements['storage_timestamp']; + } + + unset ($elements['storage_key'], $elements['storage_timestamp']); + } +} 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 000000000..be8c5ce8b --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php @@ -0,0 +1,26 @@ + timestamp] pairs. + * + * @return array + */ + public function getExistingKeys() : array; + + /** + * Check if storage has a row for the key. + * + * @param string $key + * @return bool + */ + public function hasKey(string $key) : bool; + + /** + * Create new rows. New keys will be assigned when the objects are created. + * + * @param array $rows Array of rows. + * @return array Returns created rows. Note that existing rows will fail to save and have null value. + */ + public function createRows(array $rows) : array; + + /** + * Read rows. 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 variable for storing only fetched items. + * @return array Returns rows. Note that non-existing rows have null value. + */ + public function readRows(array $rows, array &$fetched = null) : array; + + /** + * Update existing rows. + * + * @param array $rows Array of [key => row] pairs. + * @return array Returns updated rows. Note that non-existing rows will fail to save and have null value. + */ + public function updateRows(array $rows) : array; + + /** + * Delete rows. + * + * @param array $rows Array of [key => row] pairs. + * @return array Returns deleted rows. Note that non-existing rows have null value. + */ + public function deleteRows(array $rows) : array; + + /** + * Replace rows regardless if they exist or not. + * + * All rows should have a specified key for this to work. + * + * @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 + * @return string + */ + public function getStoragePath(string $key = null) : string; + + /** + * Get filesystem path for the collection or object media. + * + * @param string|null $key + * @return string + */ + 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 000000000..168384508 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php @@ -0,0 +1,131 @@ + $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) : File + { + $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 Base32::encode(Utils::generateRandomString(10)); + } + + /** + * 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 000000000..caac2ae9a --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/FileStorage.php @@ -0,0 +1,75 @@ +dataPattern = '%1s/%2s'; + + if (!isset($options['formatter']) && isset($options['pattern'])) { + $options['formatter'] = $this->detectDataFormatter($options['pattern']); + } + + parent::__construct($options); + } + + /** + * {@inheritdoc} + */ + 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 findAllKeys() : 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))) { + continue; + } + + $list[$key] = $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 000000000..82d63ff72 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/FolderStorage.php @@ -0,0 +1,371 @@ +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} + */ + public function getExistingKeys() : array + { + return $this->findAllKeys(); + } + + /** + * {@inheritdoc} + */ + public function hasKey(string $key) : bool + { + return $key && file_exists($this->getPathFromKey($key)); + } + + /** + * {@inheritdoc} + */ + 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} + */ + 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. + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + $list[$key] = $this->hasKey($key) ? $this->loadFile($file) : null; + if (null !== $fetched) { + $fetched[$key] = $list[$key]; + } + } else { + // Keep the row if it has been loaded. + $list[$key] = $row; + } + } + + return $list; + } + + /** + * {@inheritdoc} + */ + public function updateRows(array $rows) : array + { + $list = []; + foreach ($rows as $key => $row) { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + $list[$key] = $this->hasKey($key) ? $this->saveFile($file, $row) : null; + } + + return $list; + } + + /** + * {@inheritdoc} + */ + public function deleteRows(array $rows) : array + { + $list = []; + foreach ($rows as $key => $row) { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + $list[$key] = $this->hasKey($key) ? $this->deleteFile($file) : null; + + $storage = $this->getStoragePath($key); + $media = $this->getMediaPath($key); + + $this->deleteFolder($storage, true); + $media && $this->deleteFolder($media, true); + } + + return $list; + } + + /** + * {@inheritdoc} + */ + public function replaceRows(array $rows) : array + { + $list = []; + foreach ($rows as $key => $row) { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + $list[$key] = $this->saveFile($file, $row); + } + + return $list; + } + + /** + * {@inheritdoc} + */ + 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} + */ + public function getStoragePath(string $key = null) : string + { + if (null === $key) { + $path = $this->dataFolder; + } else { + $path = sprintf($this->dataPattern, $this->dataFolder, $key); + } + + return $path; + } + + /** + * {@inheritdoc} + */ + 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); + } + + /** + * @param File $file + * @return array|null + */ + protected function loadFile(File $file) : ?array + { + return $file->exists() ? (array)$file->content() : null; + } + + /** + * @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 findAllKeys() : 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->isDir() || !($key = $this->getKeyFromPath($filename))) { + continue; + } + + $list[$key] = $info->getMTime(); + } + + ksort($list, SORT_NATURAL); + + return $list; + } + + /** + * @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(); + $pattern = !empty($options['pattern']) ? $options['pattern'] : $this->dataPattern; + + $this->dataPattern = \dirname($pattern) . '/' . basename($pattern, $extension) . $extension; + $this->dataFolder = $options['folder']; + } +} 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 000000000..cb651da00 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php @@ -0,0 +1,258 @@ +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} + */ + public function getExistingKeys() : array + { + return $this->findAllKeys(); + } + + /** + * {@inheritdoc} + */ + public function hasKey(string $key) : bool + { + return isset($this->data[$key]); + } + + /** + * {@inheritdoc} + */ + public function createRows(array $rows) : array + { + $list = []; + foreach ($rows as $key => $row) { + $key = $this->getNewKey(); + $this->data[$key] = $list[$key] = $row; + } + + $list && $this->save(); + + return $list; + } + + /** + * {@inheritdoc} + */ + 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. + $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} + */ + public function updateRows(array $rows) : array + { + $list = []; + foreach ($rows as $key => $row) { + if ($this->hasKey($key)) { + $this->data[$key] = $list[$key] = $row; + } + } + + $list && $this->save(); + + return $list; + } + + /** + * {@inheritdoc} + */ + public function deleteRows(array $rows) : array + { + $list = []; + foreach ($rows as $key => $row) { + if ($this->hasKey($key)) { + unset($this->data[$key]); + $list[$key] = $row; + } + } + + $list && $this->save(); + + return $list; + } + + /** + * {@inheritdoc} + */ + public function replaceRows(array $rows) : array + { + $list = []; + foreach ($rows as $key => $row) { + $this->data[$key] = $list[$key] = $row; + } + + $list && $this->save(); + + return $list; + } + + /** + * {@inheritdoc} + */ + 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; + } + + // Change single key in the array without changing the order or value. + $keys = array_keys($this->data); + $keys[array_search($src, $keys, true)] = $dst; + + $this->data = array_combine($keys, $this->data); + + return true; + } + + /** + * {@inheritdoc} + */ + public function getStoragePath(string $key = null) : string + { + return $this->dataFolder . '/' . $this->dataPattern; + } + + /** + * {@inheritdoc} + */ + 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 + { + 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 findAllKeys() : array + { + $file = $this->getFile($this->getStoragePath()); + $modified = $file->modified(); + + $this->data = (array) $file->content(); + + $list = []; + foreach ($this->data as $key => $info) { + $list[$key] = $modified; + } + + return $list; + } + + /** + * @return string + */ + protected function getNewKey() : string + { + if (null === $this->data) { + $this->findAllKeys(); + } + + // 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 000000000..2966f2bc8 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php @@ -0,0 +1,44 @@ +exists() ? 'update' : 'create'; + } + + return $user->authorize(sprintf($this->authorize, $scope, $action)) || $user->authorize('admin.super'); + } + + 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 000000000..9e8065011 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php @@ -0,0 +1,135 @@ +getError()) { + case UPLOAD_ERR_OK: + break; + case UPLOAD_ERR_NO_FILE: + 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); + } + + /** @var Config $config */ + $config = $grav['config']; + $grav_limit = (int) $config->get('system.media.upload_limit', 0); + + if ($grav_limit > 0 && $uploadedFile->getSize() > $grav_limit) { + throw new \RuntimeException($language->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400); + } + + // Check the file extension. + $filename = $uploadedFile->getClientFilename(); + $fileParts = pathinfo($filename); + $extension = isset($fileParts['extension']) ? strtolower($fileParts['extension']) : ''; + + // If not a supported type, return + if (!$extension || !$config->get("media.types.{$extension}")) { + throw new \RuntimeException($language->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400); + } + + $media = $this->getMedia(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $path = $media->path(); + if ($locator->isStream($path)) { + $path = $locator->findResource($path, true, true); + $locator->clearCache($path); + } + + try { + // Upload it + $uploadedFile->moveTo(sprintf('%s/%s', $path, $filename)); + } catch (\Exception $e) { + 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']; + + $media = $this->getMedia(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $targetPath = $media->path() . '/' . $filename; + if ($locator->isStream($targetPath)) { + $targetPath = $locator->findResource($targetPath, true, true); + $locator->clearCache($targetPath); + } + + $fileParts = pathinfo($filename); + $found = false; + + if (file_exists($targetPath)) { + $found = true; + + $result = unlink($targetPath); + if (!$result) { + throw new \RuntimeException($language->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + } + } + + // Remove Extra Files + foreach (scandir($media->path(), SCANDIR_SORT_NONE) as $file) { + if (preg_match("/{$fileParts['filename']}@\d+x\.{$fileParts['extension']}(?:\.meta\.yaml)?$|{$filename}\.meta\.yaml$/", $file)) { + + $targetPath = $media->path() . '/' . $file; + if ($locator->isStream($targetPath)) { + $targetPath = $locator->findResource($targetPath, true, true); + $locator->clearCache($targetPath); + } + + $result = unlink($targetPath); + if (!$result) { + throw new \RuntimeException($language->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + } + + $found = true; + } + } + + $this->clearMediaCache(); + + if (!$found) { + throw new \RuntimeException($language->translate('PLUGIN_ADMIN.FILE_NOT_FOUND') . ': ' . $filename, 500); + } + } +}