diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f10d52d6..66985d3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,10 @@ * 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 `FormFlash` class to contain AJAX uploaded files in more reliable way + * Added `FormFlashFile` class which implements `UploadedFileInterface` from PSR-7 1. [](#improved) * Improve Flex storage 1. [](#bugfix) 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/Traits/FlexMediaTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php index c9a6c7428..2012291d2 100644 --- a/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php +++ b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php @@ -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']; + } +}