FormTrait.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874
  1. <?php
  2. /**
  3. * @package Grav\Framework\Form
  4. *
  5. * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Framework\Form\Traits;
  9. use ArrayAccess;
  10. use Exception;
  11. use FilesystemIterator;
  12. use Grav\Common\Data\Blueprint;
  13. use Grav\Common\Data\Data;
  14. use Grav\Common\Data\ValidationException;
  15. use Grav\Common\Debugger;
  16. use Grav\Common\Form\FormFlash;
  17. use Grav\Common\Grav;
  18. use Grav\Common\Twig\Twig;
  19. use Grav\Common\User\Interfaces\UserInterface;
  20. use Grav\Common\Utils;
  21. use Grav\Framework\Compat\Serializable;
  22. use Grav\Framework\ContentBlock\HtmlBlock;
  23. use Grav\Framework\Form\FormFlashFile;
  24. use Grav\Framework\Form\Interfaces\FormFlashInterface;
  25. use Grav\Framework\Form\Interfaces\FormInterface;
  26. use Grav\Framework\Session\SessionInterface;
  27. use Psr\Http\Message\ServerRequestInterface;
  28. use Psr\Http\Message\UploadedFileInterface;
  29. use RuntimeException;
  30. use SplFileInfo;
  31. use Twig\Error\LoaderError;
  32. use Twig\Error\SyntaxError;
  33. use Twig\Template;
  34. use Twig\TemplateWrapper;
  35. use function in_array;
  36. use function is_array;
  37. use function is_object;
  38. /**
  39. * Trait FormTrait
  40. * @package Grav\Framework\Form
  41. */
  42. trait FormTrait
  43. {
  44. use Serializable;
  45. /** @var string */
  46. public $status = 'success';
  47. /** @var string|null */
  48. public $message;
  49. /** @var string[] */
  50. public $messages = [];
  51. /** @var string */
  52. private $name;
  53. /** @var string */
  54. private $id;
  55. /** @var bool */
  56. private $enabled = true;
  57. /** @var string */
  58. private $uniqueid;
  59. /** @var string */
  60. private $sessionid;
  61. /** @var bool */
  62. private $submitted;
  63. /** @var ArrayAccess<string,mixed>|Data|null */
  64. private $data;
  65. /** @var UploadedFileInterface[] */
  66. private $files = [];
  67. /** @var FormFlashInterface|null */
  68. private $flash;
  69. /** @var string */
  70. private $flashFolder;
  71. /** @var Blueprint */
  72. private $blueprint;
  73. /**
  74. * @return string
  75. */
  76. public function getId(): string
  77. {
  78. return $this->id;
  79. }
  80. /**
  81. * @param string $id
  82. */
  83. public function setId(string $id): void
  84. {
  85. $this->id = $id;
  86. }
  87. /**
  88. * @return void
  89. */
  90. public function disable(): void
  91. {
  92. $this->enabled = false;
  93. }
  94. /**
  95. * @return void
  96. */
  97. public function enable(): void
  98. {
  99. $this->enabled = true;
  100. }
  101. /**
  102. * @return bool
  103. */
  104. public function isEnabled(): bool
  105. {
  106. return $this->enabled;
  107. }
  108. /**
  109. * @return string
  110. */
  111. public function getUniqueId(): string
  112. {
  113. return $this->uniqueid;
  114. }
  115. /**
  116. * @param string $uniqueId
  117. * @return void
  118. */
  119. public function setUniqueId(string $uniqueId): void
  120. {
  121. $this->uniqueid = $uniqueId;
  122. }
  123. /**
  124. * @return string
  125. */
  126. public function getName(): string
  127. {
  128. return $this->name;
  129. }
  130. /**
  131. * @return string
  132. */
  133. public function getFormName(): string
  134. {
  135. return $this->name;
  136. }
  137. /**
  138. * @return string
  139. */
  140. public function getNonceName(): string
  141. {
  142. return 'form-nonce';
  143. }
  144. /**
  145. * @return string
  146. */
  147. public function getNonceAction(): string
  148. {
  149. return 'form';
  150. }
  151. /**
  152. * @return string
  153. */
  154. public function getNonce(): string
  155. {
  156. return Utils::getNonce($this->getNonceAction());
  157. }
  158. /**
  159. * @return string
  160. */
  161. public function getAction(): string
  162. {
  163. return '';
  164. }
  165. /**
  166. * @return string
  167. */
  168. public function getTask(): string
  169. {
  170. return $this->getBlueprint()->get('form/task') ?? '';
  171. }
  172. /**
  173. * @param string|null $name
  174. * @return mixed
  175. */
  176. public function getData(string $name = null)
  177. {
  178. return null !== $name ? $this->data[$name] : $this->data;
  179. }
  180. /**
  181. * @return array|UploadedFileInterface[]
  182. */
  183. public function getFiles(): array
  184. {
  185. return $this->files;
  186. }
  187. /**
  188. * @param string $name
  189. * @return mixed|null
  190. */
  191. public function getValue(string $name)
  192. {
  193. return $this->data[$name] ?? null;
  194. }
  195. /**
  196. * @param string $name
  197. * @return mixed|null
  198. */
  199. public function getDefaultValue(string $name)
  200. {
  201. $path = explode('.', $name);
  202. $offset = array_shift($path);
  203. $current = $this->getDefaultValues();
  204. if (!isset($current[$offset])) {
  205. return null;
  206. }
  207. $current = $current[$offset];
  208. while ($path) {
  209. $offset = array_shift($path);
  210. if ((is_array($current) || $current instanceof ArrayAccess) && isset($current[$offset])) {
  211. $current = $current[$offset];
  212. } elseif (is_object($current) && isset($current->{$offset})) {
  213. $current = $current->{$offset};
  214. } else {
  215. return null;
  216. }
  217. };
  218. return $current;
  219. }
  220. /**
  221. * @return array
  222. */
  223. public function getDefaultValues(): array
  224. {
  225. return $this->getBlueprint()->getDefaults();
  226. }
  227. /**
  228. * @param ServerRequestInterface $request
  229. * @return FormInterface|$this
  230. */
  231. public function handleRequest(ServerRequestInterface $request): FormInterface
  232. {
  233. // Set current form to be active.
  234. $grav = Grav::instance();
  235. $forms = $grav['forms'] ?? null;
  236. if ($forms) {
  237. $forms->setActiveForm($this);
  238. /** @var Twig $twig */
  239. $twig = $grav['twig'];
  240. $twig->twig_vars['form'] = $this;
  241. }
  242. try {
  243. [$data, $files] = $this->parseRequest($request);
  244. $this->submit($data, $files);
  245. } catch (Exception $e) {
  246. /** @var Debugger $debugger */
  247. $debugger = $grav['debugger'];
  248. $debugger->addException($e);
  249. $this->setError($e->getMessage());
  250. }
  251. return $this;
  252. }
  253. /**
  254. * @param ServerRequestInterface $request
  255. * @return FormInterface|$this
  256. */
  257. public function setRequest(ServerRequestInterface $request): FormInterface
  258. {
  259. [$data, $files] = $this->parseRequest($request);
  260. $this->data = new Data($data, $this->getBlueprint());
  261. $this->files = $files;
  262. return $this;
  263. }
  264. /**
  265. * @return bool
  266. */
  267. public function isValid(): bool
  268. {
  269. return $this->status === 'success';
  270. }
  271. /**
  272. * @return string|null
  273. */
  274. public function getError(): ?string
  275. {
  276. return !$this->isValid() ? $this->message : null;
  277. }
  278. /**
  279. * @return array
  280. */
  281. public function getErrors(): array
  282. {
  283. return !$this->isValid() ? $this->messages : [];
  284. }
  285. /**
  286. * @return bool
  287. */
  288. public function isSubmitted(): bool
  289. {
  290. return $this->submitted;
  291. }
  292. /**
  293. * @return bool
  294. */
  295. public function validate(): bool
  296. {
  297. if (!$this->isValid()) {
  298. return false;
  299. }
  300. try {
  301. $this->validateData($this->data);
  302. $this->validateUploads($this->getFiles());
  303. } catch (ValidationException $e) {
  304. $this->setErrors($e->getMessages());
  305. } catch (Exception $e) {
  306. /** @var Debugger $debugger */
  307. $debugger = Grav::instance()['debugger'];
  308. $debugger->addException($e);
  309. $this->setError($e->getMessage());
  310. }
  311. $this->filterData($this->data);
  312. return $this->isValid();
  313. }
  314. /**
  315. * @param array $data
  316. * @param UploadedFileInterface[]|null $files
  317. * @return FormInterface|$this
  318. */
  319. public function submit(array $data, array $files = null): FormInterface
  320. {
  321. try {
  322. if ($this->isSubmitted()) {
  323. throw new RuntimeException('Form has already been submitted');
  324. }
  325. $this->data = new Data($data, $this->getBlueprint());
  326. $this->files = $files ?? [];
  327. if (!$this->validate()) {
  328. return $this;
  329. }
  330. $this->doSubmit($this->data->toArray(), $this->files);
  331. $this->submitted = true;
  332. } catch (Exception $e) {
  333. /** @var Debugger $debugger */
  334. $debugger = Grav::instance()['debugger'];
  335. $debugger->addException($e);
  336. $this->setError($e->getMessage());
  337. }
  338. return $this;
  339. }
  340. /**
  341. * @return void
  342. */
  343. public function reset(): void
  344. {
  345. // Make sure that the flash object gets deleted.
  346. $this->getFlash()->delete();
  347. $this->data = null;
  348. $this->files = [];
  349. $this->status = 'success';
  350. $this->message = null;
  351. $this->messages = [];
  352. $this->submitted = false;
  353. $this->flash = null;
  354. }
  355. /**
  356. * @return array
  357. */
  358. public function getFields(): array
  359. {
  360. return $this->getBlueprint()->fields();
  361. }
  362. /**
  363. * @return array
  364. */
  365. public function getButtons(): array
  366. {
  367. return $this->getBlueprint()->get('form/buttons') ?? [];
  368. }
  369. /**
  370. * @return array
  371. */
  372. public function getTasks(): array
  373. {
  374. return $this->getBlueprint()->get('form/tasks') ?? [];
  375. }
  376. /**
  377. * @return Blueprint
  378. */
  379. abstract public function getBlueprint(): Blueprint;
  380. /**
  381. * Get form flash object.
  382. *
  383. * @return FormFlashInterface
  384. */
  385. public function getFlash()
  386. {
  387. if (null === $this->flash) {
  388. $grav = Grav::instance();
  389. $config = [
  390. 'session_id' => $this->getSessionId(),
  391. 'unique_id' => $this->getUniqueId(),
  392. 'form_name' => $this->getName(),
  393. 'folder' => $this->getFlashFolder()
  394. ];
  395. $this->flash = new FormFlash($config);
  396. $this->flash->setUrl($grav['uri']->url)->setUser($grav['user'] ?? null);
  397. }
  398. return $this->flash;
  399. }
  400. /**
  401. * Get all available form flash objects for this form.
  402. *
  403. * @return FormFlashInterface[]
  404. */
  405. public function getAllFlashes(): array
  406. {
  407. $folder = $this->getFlashFolder();
  408. if (!$folder || !is_dir($folder)) {
  409. return [];
  410. }
  411. $name = $this->getName();
  412. $list = [];
  413. /** @var SplFileInfo $file */
  414. foreach (new FilesystemIterator($folder) as $file) {
  415. $uniqueId = $file->getFilename();
  416. $config = [
  417. 'session_id' => $this->getSessionId(),
  418. 'unique_id' => $uniqueId,
  419. 'form_name' => $name,
  420. 'folder' => $this->getFlashFolder()
  421. ];
  422. $flash = new FormFlash($config);
  423. if ($flash->exists() && $flash->getFormName() === $name) {
  424. $list[] = $flash;
  425. }
  426. }
  427. return $list;
  428. }
  429. /**
  430. * {@inheritdoc}
  431. * @see FormInterface::render()
  432. */
  433. public function render(string $layout = null, array $context = [])
  434. {
  435. if (null === $layout) {
  436. $layout = 'default';
  437. }
  438. $grav = Grav::instance();
  439. $block = HtmlBlock::create();
  440. $block->disableCache();
  441. $output = $this->getTemplate($layout)->render(
  442. ['grav' => $grav, 'config' => $grav['config'], 'block' => $block, 'form' => $this, 'layout' => $layout] + $context
  443. );
  444. $block->setContent($output);
  445. return $block;
  446. }
  447. /**
  448. * @return array
  449. */
  450. public function jsonSerialize(): array
  451. {
  452. return $this->doSerialize();
  453. }
  454. /**
  455. * @return array
  456. */
  457. final public function __serialize(): array
  458. {
  459. return $this->doSerialize();
  460. }
  461. /**
  462. * @param array $data
  463. * @return void
  464. */
  465. final public function __unserialize(array $data): void
  466. {
  467. $this->doUnserialize($data);
  468. }
  469. protected function getSessionId(): string
  470. {
  471. if (null === $this->sessionid) {
  472. /** @var Grav $grav */
  473. $grav = Grav::instance();
  474. /** @var SessionInterface|null $session */
  475. $session = $grav['session'] ?? null;
  476. $this->sessionid = $session ? ($session->getId() ?? '') : '';
  477. }
  478. return $this->sessionid;
  479. }
  480. /**
  481. * @param string $sessionId
  482. * @return void
  483. */
  484. protected function setSessionId(string $sessionId): void
  485. {
  486. $this->sessionid = $sessionId;
  487. }
  488. /**
  489. * @return void
  490. */
  491. protected function unsetFlash(): void
  492. {
  493. $this->flash = null;
  494. }
  495. /**
  496. * @return string|null
  497. */
  498. protected function getFlashFolder(): ?string
  499. {
  500. $grav = Grav::instance();
  501. /** @var UserInterface|null $user */
  502. $user = $grav['user'] ?? null;
  503. if (null !== $user && $user->exists()) {
  504. $username = $user->username;
  505. $mediaFolder = $user->getMediaFolder();
  506. } else {
  507. $username = null;
  508. $mediaFolder = null;
  509. }
  510. $session = $grav['session'] ?? null;
  511. $sessionId = $session ? $session->getId() : null;
  512. // Fill template token keys/value pairs.
  513. $dataMap = [
  514. '[FORM_NAME]' => $this->getName(),
  515. '[SESSIONID]' => $sessionId ?? '!!',
  516. '[USERNAME]' => $username ?? '!!',
  517. '[USERNAME_OR_SESSIONID]' => $username ?? $sessionId ?? '!!',
  518. '[ACCOUNT]' => $mediaFolder ?? '!!'
  519. ];
  520. $flashLookupFolder = $this->getFlashLookupFolder();
  521. $path = str_replace(array_keys($dataMap), array_values($dataMap), $flashLookupFolder);
  522. // Make sure we only return valid paths.
  523. return strpos($path, '!!') === false ? rtrim($path, '/') : null;
  524. }
  525. /**
  526. * @return string
  527. */
  528. protected function getFlashLookupFolder(): string
  529. {
  530. if (null === $this->flashFolder) {
  531. $this->flashFolder = $this->getBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]';
  532. }
  533. return $this->flashFolder;
  534. }
  535. /**
  536. * @param string $folder
  537. * @return void
  538. */
  539. protected function setFlashLookupFolder(string $folder): void
  540. {
  541. $this->flashFolder = $folder;
  542. }
  543. /**
  544. * Set a single error.
  545. *
  546. * @param string $error
  547. * @return void
  548. */
  549. protected function setError(string $error): void
  550. {
  551. $this->status = 'error';
  552. $this->message = $error;
  553. }
  554. /**
  555. * Set all errors.
  556. *
  557. * @param array $errors
  558. * @return void
  559. */
  560. protected function setErrors(array $errors): void
  561. {
  562. $this->status = 'error';
  563. $this->messages = $errors;
  564. }
  565. /**
  566. * @param string $layout
  567. * @return Template|TemplateWrapper
  568. * @throws LoaderError
  569. * @throws SyntaxError
  570. */
  571. protected function getTemplate($layout)
  572. {
  573. $grav = Grav::instance();
  574. /** @var Twig $twig */
  575. $twig = $grav['twig'];
  576. return $twig->twig()->resolveTemplate(
  577. [
  578. "forms/{$layout}/form.html.twig",
  579. 'forms/default/form.html.twig'
  580. ]
  581. );
  582. }
  583. /**
  584. * Parse PSR-7 ServerRequest into data and files.
  585. *
  586. * @param ServerRequestInterface $request
  587. * @return array
  588. */
  589. protected function parseRequest(ServerRequestInterface $request): array
  590. {
  591. $method = $request->getMethod();
  592. if (!in_array($method, ['PUT', 'POST', 'PATCH'])) {
  593. throw new RuntimeException(sprintf('FlexForm: Bad HTTP method %s', $method));
  594. }
  595. $body = (array)$request->getParsedBody();
  596. $data = isset($body['data']) ? $this->decodeData($body['data']) : null;
  597. $flash = $this->getFlash();
  598. /*
  599. if (null !== $data) {
  600. $flash->setData($data);
  601. $flash->save();
  602. }
  603. */
  604. $blueprint = $this->getBlueprint();
  605. $includeOriginal = (bool)($blueprint->form()['images']['original'] ?? null);
  606. $files = $flash->getFilesByFields($includeOriginal);
  607. $data = $blueprint->processForm($data ?? [], $body['toggleable_data'] ?? []);
  608. return [
  609. $data,
  610. $files
  611. ];
  612. }
  613. /**
  614. * Validate data and throw validation exceptions if validation fails.
  615. *
  616. * @param ArrayAccess|Data|null $data
  617. * @return void
  618. * @throws ValidationException
  619. * @phpstan-param ArrayAccess<string,mixed>|Data|null $data
  620. * @throws Exception
  621. */
  622. protected function validateData($data = null): void
  623. {
  624. if ($data instanceof Data) {
  625. $data->validate();
  626. }
  627. }
  628. /**
  629. * Filter validated data.
  630. *
  631. * @param ArrayAccess|Data|null $data
  632. * @return void
  633. * @phpstan-param ArrayAccess<string,mixed>|Data|null $data
  634. */
  635. protected function filterData($data = null): void
  636. {
  637. if ($data instanceof Data) {
  638. $data->filter();
  639. }
  640. }
  641. /**
  642. * Validate all uploaded files.
  643. *
  644. * @param array $files
  645. * @return void
  646. */
  647. protected function validateUploads(array $files): void
  648. {
  649. foreach ($files as $file) {
  650. if (null === $file) {
  651. continue;
  652. }
  653. if ($file instanceof UploadedFileInterface) {
  654. $this->validateUpload($file);
  655. } else {
  656. $this->validateUploads($file);
  657. }
  658. }
  659. }
  660. /**
  661. * Validate uploaded file.
  662. *
  663. * @param UploadedFileInterface $file
  664. * @return void
  665. */
  666. protected function validateUpload(UploadedFileInterface $file): void
  667. {
  668. // Handle bad filenames.
  669. $filename = $file->getClientFilename();
  670. if ($filename && !Utils::checkFilename($filename)) {
  671. $grav = Grav::instance();
  672. throw new RuntimeException(
  673. sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null, true), $filename, 'Bad filename')
  674. );
  675. }
  676. if ($file instanceof FormFlashFile) {
  677. $file->checkXss();
  678. }
  679. }
  680. /**
  681. * Decode POST data
  682. *
  683. * @param array $data
  684. * @return array
  685. */
  686. protected function decodeData($data): array
  687. {
  688. if (!is_array($data)) {
  689. return [];
  690. }
  691. // Decode JSON encoded fields and merge them to data.
  692. if (isset($data['_json'])) {
  693. $data = array_replace_recursive($data, $this->jsonDecode($data['_json']));
  694. unset($data['_json']);
  695. }
  696. return $data;
  697. }
  698. /**
  699. * Recursively JSON decode POST data.
  700. *
  701. * @param array $data
  702. * @return array
  703. */
  704. protected function jsonDecode(array $data): array
  705. {
  706. foreach ($data as $key => &$value) {
  707. if (is_array($value)) {
  708. $value = $this->jsonDecode($value);
  709. } elseif (trim($value) === '') {
  710. unset($data[$key]);
  711. } else {
  712. $value = json_decode($value, true);
  713. if ($value === null && json_last_error() !== JSON_ERROR_NONE) {
  714. unset($data[$key]);
  715. $this->setError("Badly encoded JSON data (for {$key}) was sent to the form");
  716. }
  717. }
  718. }
  719. return $data;
  720. }
  721. /**
  722. * @return array
  723. */
  724. protected function doSerialize(): array
  725. {
  726. $data = $this->data instanceof Data ? $this->data->toArray() : null;
  727. return [
  728. 'name' => $this->name,
  729. 'id' => $this->id,
  730. 'uniqueid' => $this->uniqueid,
  731. 'submitted' => $this->submitted,
  732. 'status' => $this->status,
  733. 'message' => $this->message,
  734. 'messages' => $this->messages,
  735. 'data' => $data,
  736. 'files' => $this->files,
  737. ];
  738. }
  739. /**
  740. * @param array $data
  741. * @return void
  742. */
  743. protected function doUnserialize(array $data): void
  744. {
  745. $this->name = $data['name'];
  746. $this->id = $data['id'];
  747. $this->uniqueid = $data['uniqueid'];
  748. $this->submitted = $data['submitted'] ?? false;
  749. $this->status = $data['status'] ?? 'success';
  750. $this->message = $data['message'] ?? null;
  751. $this->messages = $data['messages'] ?? [];
  752. $this->data = isset($data['data']) ? new Data($data['data'], $this->getBlueprint()) : null;
  753. $this->files = $data['files'] ?? [];
  754. }
  755. }