Form.php 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422
  1. <?php
  2. namespace Grav\Plugin\Form;
  3. use ArrayAccess;
  4. use Grav\Common\Config\Config;
  5. use Grav\Common\Data\Data;
  6. use Grav\Common\Data\Blueprint;
  7. use Grav\Common\Data\ValidationException;
  8. use Grav\Common\Filesystem\Folder;
  9. use Grav\Common\Form\FormFlash;
  10. use Grav\Common\Grav;
  11. use Grav\Common\Inflector;
  12. use Grav\Common\Language\Language;
  13. use Grav\Common\Page\Interfaces\PageInterface;
  14. use Grav\Common\Page\Pages;
  15. use Grav\Common\Security;
  16. use Grav\Common\Uri;
  17. use Grav\Common\Utils;
  18. use Grav\Framework\Filesystem\Filesystem;
  19. use Grav\Framework\Form\FormFlashFile;
  20. use Grav\Framework\Form\Interfaces\FormInterface;
  21. use Grav\Framework\Form\Traits\FormTrait;
  22. use Grav\Framework\Route\Route;
  23. use RocketTheme\Toolbox\ArrayTraits\NestedArrayAccessWithGetters;
  24. use RocketTheme\Toolbox\Event\Event;
  25. use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
  26. use RuntimeException;
  27. use stdClass;
  28. use function is_array;
  29. use function is_int;
  30. use function is_string;
  31. use function json_encode;
  32. /**
  33. * Class Form
  34. * @package Grav\Plugin\Form
  35. *
  36. * @property string $id
  37. * @property string $uniqueid
  38. * @property string $name
  39. * @property string $noncename
  40. * @property string $nonceaction
  41. * @property string $action
  42. * @property Data $data
  43. * @property array $files
  44. * @property Data $value
  45. * @property array $errors
  46. * @property array $fields
  47. * @property Blueprint $blueprint
  48. * @property PageInterface $page
  49. */
  50. class Form implements FormInterface, ArrayAccess
  51. {
  52. use NestedArrayAccessWithGetters {
  53. NestedArrayAccessWithGetters::get as private traitGet;
  54. NestedArrayAccessWithGetters::set as private traitSet;
  55. }
  56. use FormTrait {
  57. FormTrait::reset as private traitReset;
  58. FormTrait::doSerialize as private doTraitSerialize;
  59. FormTrait::doUnserialize as private doTraitUnserialize;
  60. }
  61. /** @var int */
  62. public const BYTES_TO_MB = 1048576;
  63. /** @var string */
  64. public $message;
  65. /** @var int */
  66. public $response_code;
  67. /** @var string */
  68. public $status = 'success';
  69. /** @var array */
  70. protected $header_data = [];
  71. /** @var array */
  72. protected $rules = [];
  73. /**
  74. * Form header items
  75. *
  76. * @var array $items
  77. */
  78. protected $items = [];
  79. /**
  80. * All the form data values, including non-data
  81. *
  82. * @var Data $values
  83. */
  84. protected $values;
  85. /**
  86. * The form page route
  87. *
  88. * @var string $page
  89. */
  90. protected $page;
  91. /**
  92. * Create form for the given page.
  93. *
  94. * @param PageInterface $page
  95. * @param string|int|null $name
  96. * @param array|null $form
  97. */
  98. public function __construct(PageInterface $page, $name = null, $form = null)
  99. {
  100. $this->nestedSeparator = '/';
  101. $slug = $page->slug();
  102. $header = $page->header();
  103. $this->rules = $header->rules ?? [];
  104. $this->header_data = $header->data ?? [];
  105. if ($form) {
  106. // If form is given, use it.
  107. $this->items = $form;
  108. } else {
  109. // Otherwise get all forms in the page.
  110. $forms = $page->forms();
  111. if ($name) {
  112. // If form with given name was found, use that.
  113. $this->items = $forms[$name] ?? [];
  114. } else {
  115. // Otherwise pick up the first form.
  116. $this->items = reset($forms) ?: [];
  117. $name = key($forms);
  118. }
  119. }
  120. // If we're on a modular page, find the real page.
  121. while ($page && $page->modularTwig()) {
  122. $header = $page->header();
  123. $header->never_cache_twig = true;
  124. $page = $page->parent();
  125. }
  126. $this->page = $page ? $page->route() : '/';
  127. // Add form specific rules.
  128. if (!empty($this->items['rules']) && is_array($this->items['rules'])) {
  129. $this->rules += $this->items['rules'];
  130. }
  131. // Set form name if not set.
  132. if ($name && !is_int($name)) {
  133. $this->items['name'] = $name;
  134. } elseif (empty($this->items['name'])) {
  135. $this->items['name'] = $slug;
  136. }
  137. // Set form id if not set.
  138. if (empty($this->items['id'])) {
  139. $this->items['id'] = Inflector::hyphenize($this->items['name']);
  140. }
  141. if (empty($this->items['nonce']['name'])) {
  142. $this->items['nonce']['name'] = 'form-nonce';
  143. }
  144. if (empty($this->items['nonce']['action'])) {
  145. $this->items['nonce']['action'] = 'form';
  146. }
  147. if (Utils::isPositive($this->items['disabled'] ?? false)) {
  148. $this->disable();
  149. }
  150. // Initialize form properties.
  151. $this->name = $this->items['name'];
  152. $this->setId($this->items['id']);
  153. $uniqueid = $this->items['uniqueid'] ?? null;
  154. if (null === $uniqueid && !empty($this->items['remember_state'])) {
  155. $this->set('remember_redirect', true);
  156. }
  157. $this->setUniqueId($uniqueid ?? strtolower(Utils::generateRandomString($this->items['uniqueid_len'] ?? 20)));
  158. $this->initialize();
  159. }
  160. /**
  161. * @return $this
  162. */
  163. public function initialize()
  164. {
  165. // Reset and initialize the form
  166. $this->errors = [];
  167. $this->submitted = false;
  168. $this->unsetFlash();
  169. // Remember form state.
  170. $flash = $this->getFlash();
  171. if ($flash->exists()) {
  172. $data = $flash->getData() ?? $this->header_data;
  173. } else {
  174. $data = $this->header_data;
  175. }
  176. // Remember data and files.
  177. $this->setAllData($data);
  178. $this->setAllFiles($flash);
  179. $this->values = new Data();
  180. // Fire event
  181. $grav = Grav::instance();
  182. $grav->fireEvent('onFormInitialized', new Event(['form' => $this]));
  183. return $this;
  184. }
  185. /**
  186. * @param FormFlash $flash
  187. * @return void
  188. */
  189. protected function setAllFiles(FormFlash $flash)
  190. {
  191. if (!$flash->exists()) {
  192. return;
  193. }
  194. /** @var Uri $url */
  195. $url = Grav::instance()['uri'];
  196. $fields = $flash->getFilesByFields(true);
  197. foreach ($fields as $field => $files) {
  198. if (strpos($field, '/') !== false) {
  199. continue;
  200. }
  201. $list = [];
  202. /**
  203. * @var string $filename
  204. * @var FormFlashFile $file
  205. */
  206. foreach ($files as $filename => $file) {
  207. $original = $fields["{$field}/original"][$filename] ?? $file;
  208. $basename = basename($filename);
  209. if ($file) {
  210. $imagePath = $original->getTmpFile();
  211. $thumbPath = $file->getTmpFile();
  212. $list[$basename] = [
  213. 'name' => $file->getClientFilename(),
  214. 'type' => $file->getClientMediaType(),
  215. 'size' => $file->getSize(),
  216. 'image_url' => $url->rootUrl() . '/' . Folder::getRelativePath($imagePath) . '?' . filemtime($imagePath),
  217. 'thumb_url' => $url->rootUrl() . '/' . Folder::getRelativePath($thumbPath) . '?' . filemtime($thumbPath),
  218. 'cropData' => $original->getMetaData()['crop'] ?? []
  219. ];
  220. }
  221. }
  222. $this->setData($field, $list);
  223. }
  224. }
  225. /**
  226. * Reset form.
  227. *
  228. * @return void
  229. */
  230. public function reset(): void
  231. {
  232. $this->traitReset();
  233. // Reset and initialize the form
  234. $this->blueprint = null;
  235. $this->setAllData($this->header_data);
  236. $this->values = new Data();
  237. // Reset unique id (allow multiple form submits)
  238. $uniqueid = $this->items['uniqueid'] ?? null;
  239. $this->set('remember_redirect', null === $uniqueid && !empty($this->items['remember_state']));
  240. $this->setUniqueId($uniqueid ?? strtolower(Utils::generateRandomString($this->items['uniqueid_len'] ?? 20)));
  241. // Fire event
  242. $grav = Grav::instance();
  243. $grav->fireEvent('onFormInitialized', new Event(['form' => $this]));
  244. }
  245. /**
  246. * @param string $name
  247. * @param mixed|null $default
  248. * @param string|null $separator
  249. * @return mixed
  250. */
  251. public function get($name, $default = null, $separator = null)
  252. {
  253. switch (strtolower($name)) {
  254. case 'id':
  255. case 'uniqueid':
  256. case 'name':
  257. case 'noncename':
  258. case 'nonceaction':
  259. case 'action':
  260. case 'data':
  261. case 'files':
  262. case 'errors';
  263. case 'fields':
  264. case 'blueprint':
  265. case 'page':
  266. $method = 'get' . $name;
  267. return $this->{$method}();
  268. }
  269. return $this->traitGet($name, $default, $separator);
  270. }
  271. /**
  272. * @return string
  273. */
  274. public function getAction(): string
  275. {
  276. return $this->items['action'] ?? $this->page;
  277. }
  278. /**
  279. * @param string $message
  280. * @param string $type
  281. * @todo Type not used
  282. */
  283. public function setMessage($message, $type = 'error')
  284. {
  285. $this->setError($message);
  286. }
  287. /**
  288. * @param string $name
  289. * @param mixed $value
  290. * @param string|null $separator
  291. * @return Form
  292. */
  293. public function set($name, $value, $separator = null)
  294. {
  295. switch (strtolower($name)) {
  296. case 'id':
  297. case 'uniqueid':
  298. $method = 'set' . $name;
  299. return $this->{$method}();
  300. }
  301. return $this->traitSet($name, $value, $separator);
  302. }
  303. /**
  304. * Get the nonce value for a form
  305. *
  306. * @return string
  307. */
  308. public function getNonce(): string
  309. {
  310. return Utils::getNonce($this->getNonceAction());
  311. }
  312. /**
  313. * @inheritdoc
  314. */
  315. public function getNonceName(): string
  316. {
  317. return $this->items['nonce']['name'];
  318. }
  319. /**
  320. * @inheritdoc
  321. */
  322. public function getNonceAction(): string
  323. {
  324. return $this->items['nonce']['action'];
  325. }
  326. /**
  327. * @inheritdoc
  328. */
  329. public function getValue(string $name)
  330. {
  331. return $this->values->get($name);
  332. }
  333. /**
  334. * @return Data
  335. */
  336. public function getValues(): Data
  337. {
  338. return $this->values;
  339. }
  340. /**
  341. * @inheritdoc
  342. */
  343. public function getFields(): array
  344. {
  345. return $this->getBlueprint()->fields();
  346. }
  347. /**
  348. * Return page object for the form.
  349. *
  350. * Can be called only after onPageInitialize event has fired.
  351. *
  352. * @return PageInterface
  353. * @throws \LogicException
  354. */
  355. public function getPage(): PageInterface
  356. {
  357. /** @var Pages $pages */
  358. $pages = Grav::instance()['pages'];
  359. $page = $pages->find($this->page);
  360. if (null === $page) {
  361. throw new \LogicException('Form::getPage() method was called too early!');
  362. }
  363. return $page;
  364. }
  365. /**
  366. * @inheritdoc
  367. */
  368. public function getBlueprint(): Blueprint
  369. {
  370. if (null === $this->blueprint) {
  371. // Fix naming for fields (supports nested fields now!)
  372. if (isset($this->items['fields'])) {
  373. $this->items['fields'] = $this->processFields($this->items['fields']);
  374. }
  375. $blueprint = new Blueprint($this->name, ['form' => $this->items, 'rules' => $this->rules]);
  376. $blueprint->load()->init();
  377. $this->blueprint = $blueprint;
  378. }
  379. return $this->blueprint;
  380. }
  381. /**
  382. * Allow overriding of fields.
  383. *
  384. * @param array $fields
  385. * @return void
  386. */
  387. public function setFields(array $fields = [])
  388. {
  389. $this->items['fields'] = $fields;
  390. unset($this->items['field']);
  391. // Reset blueprint.
  392. $this->blueprint = null;
  393. // Update data to contain the new blueprints.
  394. $this->setAllData($this->data->toArray());
  395. }
  396. /**
  397. * Get value of given variable (or all values).
  398. * First look in the $data array, fallback to the $values array
  399. *
  400. * @param string|null $name
  401. * @param bool $fallback
  402. * @return mixed
  403. */
  404. public function value($name = null, $fallback = false)
  405. {
  406. if (!$name) {
  407. return $this->data;
  408. }
  409. if (isset($this->data[$name])) {
  410. return $this->data[$name];
  411. }
  412. if ($fallback) {
  413. return $this->values[$name];
  414. }
  415. return null;
  416. }
  417. /**
  418. * Get value of given variable (or all values).
  419. *
  420. * @param string|null $name
  421. * @return mixed
  422. */
  423. public function data($name = null)
  424. {
  425. return $this->value($name);
  426. }
  427. /**
  428. * Set value of given variable in the values array
  429. *
  430. * @param string|null $name
  431. * @param mixed $value
  432. * @return void
  433. */
  434. public function setValue($name = null, $value = '')
  435. {
  436. if (!$name) {
  437. return;
  438. }
  439. $this->values->set($name, $value);
  440. }
  441. /**
  442. * Set value of given variable in the data array
  443. *
  444. * @param string|null $name
  445. * @param string $value
  446. * @return bool
  447. */
  448. public function setData($name = null, $value = '')
  449. {
  450. if (!$name) {
  451. return false;
  452. }
  453. $this->data->set($name, $value);
  454. return true;
  455. }
  456. /**
  457. * @param array $array
  458. * @return void
  459. */
  460. public function setAllData($array): void
  461. {
  462. $callable = function () {
  463. return $this->getBlueprint();
  464. };
  465. $this->data = new Data($array, $callable);
  466. }
  467. /**
  468. * Handles ajax upload for files.
  469. * Stores in a flash object the temporary file and deals with potential file errors.
  470. *
  471. * @return mixed True if the action was performed.
  472. */
  473. public function uploadFiles()
  474. {
  475. $grav = Grav::instance();
  476. /** @var Uri $uri */
  477. $uri = $grav['uri'];
  478. $url = $uri->url;
  479. $post = $uri->post();
  480. $name = $post['name'] ?? null;
  481. $task = $post['task'] ?? null;
  482. /** @var Language $language */
  483. $language = $grav['language'];
  484. /** @var Config $config */
  485. $config = $grav['config'];
  486. $settings = $this->getBlueprint()->schema()->getProperty($name);
  487. $settings = (object) array_merge(
  488. ['destination' => $config->get('plugins.form.files.destination', 'self@'),
  489. 'avoid_overwriting' => $config->get('plugins.form.files.avoid_overwriting', false),
  490. 'random_name' => $config->get('plugins.form.files.random_name', false),
  491. 'accept' => $config->get('plugins.form.files.accept', ['image/*']),
  492. 'limit' => $config->get('plugins.form.files.limit', 10),
  493. 'filesize' => static::getMaxFilesize(),
  494. ],
  495. (array) $settings,
  496. ['name' => $name]
  497. );
  498. // Allow plugins to adapt settings for a given post name
  499. // Useful if schema retrieval is not an option, e.g. dynamically created forms
  500. $grav->fireEvent('onFormUploadSettings', new Event(['settings' => &$settings, 'post' => $post]));
  501. $upload = json_decode(json_encode($this->normalizeFiles($_FILES['data'], $settings->name)), true);
  502. $filename = $post['filename'] ?? $upload['file']['name'];
  503. $field = $upload['field'];
  504. // Handle errors and breaks without proceeding further
  505. if ($upload['file']['error'] !== UPLOAD_ERR_OK) {
  506. // json_response
  507. return [
  508. 'status' => 'error',
  509. 'message' => sprintf(
  510. $language->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null, true),
  511. $filename,
  512. $this->getFileUploadError($upload['file']['error'], $language)
  513. )
  514. ];
  515. }
  516. // Handle bad filenames.
  517. if (!Utils::checkFilename($filename)) {
  518. return [
  519. 'status' => 'error',
  520. 'message' => sprintf($language->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null),
  521. $filename, 'Bad filename')
  522. ];
  523. }
  524. if (!isset($settings->destination)) {
  525. return [
  526. 'status' => 'error',
  527. 'message' => $language->translate('PLUGIN_FORM.DESTINATION_NOT_SPECIFIED', null)
  528. ];
  529. }
  530. // Remove the error object to avoid storing it
  531. unset($upload['file']['error']);
  532. // Handle Accepted file types
  533. // Accept can only be mime types (image/png | image/*) or file extensions (.pdf|.jpg)
  534. $accepted = false;
  535. $errors = [];
  536. // Do not trust mimetype sent by the browser
  537. $mime = Utils::getMimeByFilename($filename);
  538. foreach ((array)$settings->accept as $type) {
  539. // Force acceptance of any file when star notation
  540. if ($type === '*') {
  541. $accepted = true;
  542. break;
  543. }
  544. $isMime = strstr($type, '/');
  545. $find = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type);
  546. if ($isMime) {
  547. $match = preg_match('#' . $find . '$#', $mime);
  548. if (!$match) {
  549. $errors[] = sprintf($language->translate('PLUGIN_FORM.INVALID_MIME_TYPE', null, true), $mime, $filename);
  550. } else {
  551. $accepted = true;
  552. break;
  553. }
  554. } else {
  555. $match = preg_match('#' . $find . '$#', $filename);
  556. if (!$match) {
  557. $errors[] = sprintf($language->translate('PLUGIN_FORM.INVALID_FILE_EXTENSION', null, true), $filename);
  558. } else {
  559. $accepted = true;
  560. break;
  561. }
  562. }
  563. }
  564. if (!$accepted) {
  565. // json_response
  566. return [
  567. 'status' => 'error',
  568. 'message' => implode('<br/>', $errors)
  569. ];
  570. }
  571. // Handle file size limits
  572. $settings->filesize *= self::BYTES_TO_MB; // 1024 * 1024 [MB in Bytes]
  573. if ($settings->filesize > 0 && $upload['file']['size'] > $settings->filesize) {
  574. // json_response
  575. return [
  576. 'status' => 'error',
  577. 'message' => $language->translate('PLUGIN_FORM.EXCEEDED_GRAV_FILESIZE_LIMIT')
  578. ];
  579. }
  580. // Generate random name if required
  581. if ($settings->random_name) {
  582. $extension = pathinfo($filename, PATHINFO_EXTENSION);
  583. $filename = Utils::generateRandomString(15) . '.' . $extension;
  584. }
  585. // Look up for destination
  586. /** @var UniformResourceLocator $locator */
  587. $locator = $grav['locator'];
  588. $destination = $settings->destination;
  589. if (!$locator->isStream($destination)) {
  590. $destination = $this->getPagePathFromToken(Folder::getRelativePath(rtrim($settings->destination, '/')));
  591. }
  592. // Handle conflicting name if needed
  593. if ($settings->avoid_overwriting) {
  594. if (file_exists($destination . '/' . $filename)) {
  595. $filename = date('YmdHis') . '-' . $filename;
  596. }
  597. }
  598. // Prepare object for later save
  599. $path = $destination . '/' . $filename;
  600. $upload['file']['name'] = $filename;
  601. $upload['file']['path'] = $path;
  602. // Special Sanitization for SVG
  603. if (method_exists('Grav\Common\Security', 'sanitizeSVG') && Utils::contains($mime, 'svg', false)) {
  604. Security::sanitizeSVG($upload['file']['tmp_name']);
  605. }
  606. // We need to store the file into flash object or it will not be available upon save later on.
  607. $flash = $this->getFlash();
  608. $flash->setUrl($url)->setUser($grav['user'] ?? null);
  609. if ($task === 'cropupload') {
  610. $crop = $post['crop'];
  611. if (is_string($crop)) {
  612. $crop = json_decode($crop, true);
  613. }
  614. $success = $flash->cropFile($field, $filename, $upload, $crop);
  615. } else {
  616. $success = $flash->uploadFile($field, $filename, $upload);
  617. }
  618. if (!$success) {
  619. // json_response
  620. return [
  621. 'status' => 'error',
  622. 'message' => sprintf($language->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_MOVE', null, true), '', $flash->getTmpDir())
  623. ];
  624. }
  625. $flash->save();
  626. // json_response
  627. $json_response = [
  628. 'status' => 'success',
  629. 'session' => json_encode([
  630. 'sessionField' => base64_encode($url),
  631. 'path' => $path,
  632. 'field' => $settings->name,
  633. 'uniqueid' => $this->uniqueid
  634. ])
  635. ];
  636. // Return JSON
  637. header('Content-Type: application/json');
  638. echo json_encode($json_response);
  639. exit;
  640. }
  641. /**
  642. * Return an error message for a PHP file upload error code
  643. * https://www.php.net/manual/en/features.file-upload.errors.php
  644. *
  645. * @param int $error PHP file upload error code
  646. * @param Language|null $language
  647. * @return string File upload error message
  648. */
  649. public function getFileUploadError(int $error, Language $language = null): string
  650. {
  651. if (!$language) {
  652. $grav = Grav::instance();
  653. /** @var Language $language */
  654. $language = $grav['language'];
  655. }
  656. switch ($error) {
  657. case UPLOAD_ERR_OK:
  658. $item = 'FILEUPLOAD_ERR_OK';
  659. break;
  660. case UPLOAD_ERR_INI_SIZE:
  661. $item = 'FILEUPLOAD_ERR_INI_SIZE';
  662. break;
  663. case UPLOAD_ERR_FORM_SIZE:
  664. $item = 'FILEUPLOAD_ERR_FORM_SIZE';
  665. break;
  666. case UPLOAD_ERR_PARTIAL:
  667. $item = 'FILEUPLOAD_ERR_PARTIAL';
  668. break;
  669. case UPLOAD_ERR_NO_FILE:
  670. $item = 'FILEUPLOAD_ERR_NO_FILE';
  671. break;
  672. case UPLOAD_ERR_NO_TMP_DIR:
  673. $item = 'FILEUPLOAD_ERR_NO_TMP_DIR';
  674. break;
  675. case UPLOAD_ERR_CANT_WRITE:
  676. $item = 'FILEUPLOAD_ERR_CANT_WRITE';
  677. break;
  678. case UPLOAD_ERR_EXTENSION:
  679. $item = 'FILEUPLOAD_ERR_EXTENSION';
  680. break;
  681. default:
  682. $item = 'FILEUPLOAD_ERR_UNKNOWN';
  683. }
  684. return $language->translate('PLUGIN_FORM.'.$item);
  685. }
  686. /**
  687. * Removes a file from the flash object session, before it gets saved.
  688. *
  689. * @return void
  690. */
  691. public function filesSessionRemove(): void
  692. {
  693. $callable = function (): array {
  694. $field = $this->values->get('name');
  695. $filename = $this->values->get('filename');
  696. if (!isset($field, $filename)) {
  697. throw new RuntimeException('Bad Request: name and/or filename are missing', 400);
  698. }
  699. $this->removeFlashUpload($filename, $field);
  700. return ['status' => 'success'];
  701. };
  702. $this->sendJsonResponse($callable);
  703. }
  704. /**
  705. * @return void
  706. */
  707. public function storeState()
  708. {
  709. $callable = function (): array {
  710. $this->updateFlashData($this->values->get('data') ?? []);
  711. return ['status' => 'success'];
  712. };
  713. $this->sendJsonResponse($callable);
  714. }
  715. /**
  716. * @return void
  717. */
  718. public function clearState(): void
  719. {
  720. $callable = function (): array {
  721. $this->getFlash()->delete();
  722. return ['status' => 'success'];
  723. };
  724. $this->sendJsonResponse($callable);
  725. }
  726. /**
  727. * Handle form processing on POST action.
  728. *
  729. * @return void
  730. */
  731. public function post()
  732. {
  733. $grav = Grav::instance();
  734. /** @var Uri $uri */
  735. $uri = $grav['uri'];
  736. // Get POST data and decode JSON fields into arrays
  737. $post = $uri->post();
  738. $post['data'] = $this->decodeData($post['data'] ?? []);
  739. if ($post) {
  740. $this->values = new Data((array)$post);
  741. $data = $this->values->get('data');
  742. // Add post data to form dataset
  743. if (!$data) {
  744. $data = $this->values->toArray();
  745. }
  746. if (!$this->values->get('form-nonce') || !Utils::verifyNonce($this->values->get('form-nonce'), 'form')) {
  747. $this->status = 'error';
  748. $event = new Event(['form' => $this,
  749. 'message' => $grav['language']->translate('PLUGIN_FORM.NONCE_NOT_VALIDATED')
  750. ]);
  751. $grav->fireEvent('onFormValidationError', $event);
  752. return;
  753. }
  754. $i = 0;
  755. foreach ($this->items['fields'] as $key => $field) {
  756. $name = $field['name'] ?? $key;
  757. if (!isset($field['name'])) {
  758. if (isset($data[$i])) { //Handle input@ false fields
  759. $data[$name] = $data[$i];
  760. unset($data[$i]);
  761. }
  762. }
  763. if ($field['type'] === 'checkbox' || $field['type'] === 'switch') {
  764. $data[$name] = isset($data[$name]) ? true : false;
  765. }
  766. $i++;
  767. }
  768. $this->data->merge($data);
  769. }
  770. // Validate and filter data
  771. try {
  772. $grav->fireEvent('onFormPrepareValidation', new Event(['form' => $this]));
  773. $this->data->validate();
  774. $this->data->filter();
  775. $grav->fireEvent('onFormValidationProcessed', new Event(['form' => $this]));
  776. } catch (ValidationException $e) {
  777. $this->status = 'error';
  778. $event = new Event(['form' => $this, 'message' => $e->getMessage(), 'messages' => $e->getMessages()]);
  779. $grav->fireEvent('onFormValidationError', $event);
  780. if ($event->isPropagationStopped()) {
  781. return;
  782. }
  783. } catch (RuntimeException $e) {
  784. $this->status = 'error';
  785. $event = new Event(['form' => $this, 'message' => $e->getMessage(), 'messages' => []]);
  786. $grav->fireEvent('onFormValidationError', $event);
  787. if ($event->isPropagationStopped()) {
  788. return;
  789. }
  790. }
  791. $redirect = $redirect_code = null;
  792. $process = $this->items['process'] ?? [];
  793. $legacyUploads = !isset($process['upload']) || $process['upload'] !== false;
  794. if ($legacyUploads) {
  795. $this->legacyUploads();
  796. }
  797. if (is_array($process)) {
  798. foreach ($process as $action => $data) {
  799. if (is_numeric($action)) {
  800. $action = key($data);
  801. $data = $data[$action];
  802. }
  803. // do not execute action, if deactivated
  804. if (false === $data) {
  805. continue;
  806. }
  807. $event = new Event(['form' => $this, 'action' => $action, 'params' => $data]);
  808. $grav->fireEvent('onFormProcessed', $event);
  809. if ($event['redirect']) {
  810. $redirect = $event['redirect'];
  811. $redirect_code = $event['redirect_code'];
  812. }
  813. if ($event->isPropagationStopped()) {
  814. break;
  815. }
  816. }
  817. }
  818. if ($legacyUploads) {
  819. $this->copyFiles();
  820. }
  821. $this->getFlash()->delete();
  822. if ($redirect) {
  823. $grav->redirect($redirect, $redirect_code);
  824. }
  825. }
  826. /**
  827. * @return string
  828. * @deprecated 3.0 Use $form->getName() instead
  829. */
  830. public function name(): string
  831. {
  832. return $this->getName();
  833. }
  834. /**
  835. * @return array
  836. * @deprecated 3.0 Use $form->getFields() instead
  837. */
  838. public function fields(): array
  839. {
  840. return $this->getFields();
  841. }
  842. /**
  843. * @return PageInterface
  844. * @deprecated 3.0 Use $form->getPage() instead
  845. */
  846. public function page(): PageInterface
  847. {
  848. return $this->getPage();
  849. }
  850. /**
  851. * Backwards compatibility
  852. *
  853. * @return void
  854. * @deprecated 3.0 Calling $form->filter() is not needed anymore (does nothing)
  855. */
  856. public function filter(): void
  857. {
  858. }
  859. /**
  860. * Store form uploads to the final location.
  861. *
  862. * @return void
  863. */
  864. public function copyFiles()
  865. {
  866. // Get flash object in order to save the files.
  867. $flash = $this->getFlash();
  868. $fields = $flash->getFilesByFields();
  869. foreach ($fields as $key => $uploads) {
  870. /** @var FormFlashFile $upload */
  871. foreach ($uploads as $upload) {
  872. if (null === $upload || $upload->isMoved()) {
  873. continue;
  874. }
  875. $destination = $upload->getDestination();
  876. $filesystem = Filesystem::getInstance();
  877. $folder = $filesystem->dirname($destination);
  878. if (!is_dir($folder) && !@mkdir($folder, 0777, true) && !is_dir($folder)) {
  879. $grav = Grav::instance();
  880. throw new RuntimeException(sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_MOVE', null, true), '"' . $upload->getClientFilename() . '"', $destination));
  881. }
  882. try {
  883. $upload->moveTo($destination);
  884. } catch (RuntimeException $e) {
  885. $grav = Grav::instance();
  886. throw new RuntimeException(sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_MOVE', null, true), '"' . $upload->getClientFilename() . '"', $destination));
  887. }
  888. }
  889. }
  890. $flash->clearFiles();
  891. }
  892. /**
  893. * @return void
  894. */
  895. public function legacyUploads()
  896. {
  897. // Get flash object in order to save the files.
  898. $flash = $this->getFlash();
  899. $queue = $verify = $flash->getLegacyFiles();
  900. if (!$queue) {
  901. return;
  902. }
  903. $grav = Grav::instance();
  904. /** @var Uri $uri */
  905. $uri = $grav['uri'];
  906. // Get POST data and decode JSON fields into arrays
  907. $post = $uri->post();
  908. $post['data'] = $this->decodeData($post['data'] ?? []);
  909. // Allow plugins to implement additional / alternative logic
  910. $grav->fireEvent('onFormStoreUploads', new Event(['form' => $this, 'queue' => &$queue, 'post' => $post]));
  911. $modified = $queue !== $verify;
  912. if (!$modified) {
  913. // Fill file fields just like before.
  914. foreach ($queue as $key => $files) {
  915. foreach ($files as $destination => $file) {
  916. unset($files[$destination]['tmp_name']);
  917. }
  918. $this->setImageField($key, $files);
  919. }
  920. } else {
  921. user_error('Event onFormStoreUploads is deprecated.', E_USER_DEPRECATED);
  922. if (is_array($queue)) {
  923. foreach ($queue as $key => $files) {
  924. foreach ($files as $destination => $file) {
  925. $filesystem = Filesystem::getInstance();
  926. $folder = $filesystem->dirname($destination);
  927. if (!is_dir($folder) && !@mkdir($folder, 0777, true) && !is_dir($folder)) {
  928. $grav = Grav::instance();
  929. throw new RuntimeException(sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_MOVE', null, true), '"' . $file['tmp_name'] . '"', $destination));
  930. }
  931. if (!rename($file['tmp_name'], $destination)) {
  932. $grav = Grav::instance();
  933. throw new RuntimeException(sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_MOVE', null, true), '"' . $file['tmp_name'] . '"', $destination));
  934. }
  935. if (file_exists($file['tmp_name'] . '.yaml')) {
  936. unlink($file['tmp_name'] . '.yaml');
  937. }
  938. unset($files[$destination]['tmp_name']);
  939. }
  940. $this->setImageField($key, $files);
  941. }
  942. }
  943. $flash->clearFiles();
  944. }
  945. }
  946. /**
  947. * @param string $path
  948. * @return string
  949. */
  950. public function getPagePathFromToken($path)
  951. {
  952. return Utils::getPagePathFromToken($path, $this->getPage());
  953. }
  954. /**
  955. * @return Route|null
  956. */
  957. public function getFileUploadAjaxRoute(): ?Route
  958. {
  959. $route = Uri::getCurrentRoute()->withExtension('json')->withGravParam('task', 'file-upload');
  960. return $route;
  961. }
  962. /**
  963. * @param string|null $field
  964. * @param string|null $filename
  965. * @return Route|null
  966. */
  967. public function getFileDeleteAjaxRoute($field = null, $filename = null): ?Route
  968. {
  969. $route = Uri::getCurrentRoute()->withExtension('json')->withGravParam('task', 'file-remove');
  970. return $route;
  971. }
  972. /**
  973. * @param int|null $code
  974. * @return int|mixed
  975. */
  976. public function responseCode($code = null)
  977. {
  978. if ($code) {
  979. $this->response_code = $code;
  980. }
  981. return $this->response_code;
  982. }
  983. /**
  984. * @return array
  985. */
  986. public function doSerialize()
  987. {
  988. return $this->doTraitSerialize() + [
  989. 'items' => $this->items,
  990. 'message' => $this->message,
  991. 'status' => $this->status,
  992. 'header_data' => $this->header_data,
  993. 'rules' => $this->rules,
  994. 'values' => $this->values->toArray(),
  995. 'page' => $this->page
  996. ];
  997. }
  998. /**
  999. * @param array $data
  1000. * @return void
  1001. */
  1002. public function doUnserialize(array $data)
  1003. {
  1004. $this->items = $data['items'];
  1005. $this->message = $data['message'];
  1006. $this->status = $data['status'];
  1007. $this->header_data = $data['header_data'];
  1008. $this->rules = $data['rules'];
  1009. $this->values = new Data($data['values']);
  1010. $this->page = $data['page'];
  1011. // Backwards compatibility.
  1012. $defaults = [
  1013. 'name' => $this->items['name'],
  1014. 'id' => $this->items['id'],
  1015. 'uniqueid' => $this->items['uniqueid'] ?? null,
  1016. 'data' => []
  1017. ];
  1018. $this->doTraitUnserialize($data + $defaults);
  1019. }
  1020. /**
  1021. * Get the configured max file size in bytes
  1022. *
  1023. * @param bool $mbytes return size in MB
  1024. * @return int
  1025. */
  1026. public static function getMaxFilesize($mbytes = false)
  1027. {
  1028. $config = Grav::instance()['config'];
  1029. $system_filesize = 0;
  1030. $form_filesize = $config->get('plugins.form.files.filesize', 0);
  1031. $upload_limit = (int) Utils::getUploadLimit();
  1032. if ($upload_limit > 0) {
  1033. $system_filesize = intval($upload_limit / static::BYTES_TO_MB);
  1034. }
  1035. if ($form_filesize > $system_filesize || $form_filesize == 0) {
  1036. $form_filesize = $system_filesize;
  1037. }
  1038. if ($mbytes) {
  1039. return $form_filesize * static::BYTES_TO_MB;
  1040. }
  1041. return $form_filesize;
  1042. }
  1043. /**
  1044. * @param callable $callable
  1045. * @return void
  1046. */
  1047. protected function sendJsonResponse(callable $callable)
  1048. {
  1049. $grav = Grav::instance();
  1050. /** @var Uri $uri */
  1051. $uri = $grav['uri'];
  1052. // Get POST data and decode JSON fields into arrays
  1053. $post = $uri->post();
  1054. $post['data'] = $this->decodeData($post['data'] ?? []);
  1055. if (empty($post['form-nonce']) || !Utils::verifyNonce($post['form-nonce'], 'form')) {
  1056. throw new RuntimeException('Bad Request: Nonce is missing or invalid', 400);
  1057. }
  1058. $this->values = new Data($post);
  1059. $json_response = $callable($post);
  1060. // Return JSON
  1061. header('Content-Type: application/json');
  1062. echo json_encode($json_response);
  1063. exit;
  1064. }
  1065. /**
  1066. * Remove uploaded file from flash object.
  1067. *
  1068. * @param string $filename
  1069. * @param string|null $field
  1070. * @return void
  1071. */
  1072. protected function removeFlashUpload(string $filename, string $field = null)
  1073. {
  1074. $flash = $this->getFlash();
  1075. $flash->removeFile($filename, $field);
  1076. $flash->save();
  1077. }
  1078. /**
  1079. * Store updated data into flash object.
  1080. *
  1081. * @param array $data
  1082. * @return void
  1083. */
  1084. protected function updateFlashData(array $data)
  1085. {
  1086. // Store updated data into flash.
  1087. $flash = $this->getFlash();
  1088. // Check special case where there are no changes made to the form.
  1089. if (!$flash->exists() && $data === $this->header_data) {
  1090. return;
  1091. }
  1092. $this->setAllData($flash->getData() ?? []);
  1093. $this->data->merge($data);
  1094. $flash->setData($this->data->toArray());
  1095. $flash->save();
  1096. }
  1097. /**
  1098. * @param array $data
  1099. * @param array $files
  1100. * @return void
  1101. */
  1102. protected function doSubmit(array $data, array $files)
  1103. {
  1104. return;
  1105. }
  1106. /**
  1107. * @param array $fields
  1108. * @return array
  1109. */
  1110. protected function processFields($fields)
  1111. {
  1112. $types = Grav::instance()['plugins']->formFieldTypes;
  1113. $return = [];
  1114. foreach ($fields as $key => $value) {
  1115. // Default to text if not set
  1116. if (!isset($value['type'])) {
  1117. $value['type'] = 'text';
  1118. }
  1119. // Manually merging the field types
  1120. if ($types !== null && array_key_exists($value['type'], $types)) {
  1121. $value += $types[$value['type']];
  1122. }
  1123. // Fix numeric indexes
  1124. if (is_numeric($key) && isset($value['name'])) {
  1125. $key = $value['name'];
  1126. }
  1127. // Recursively process children
  1128. if (isset($value['fields']) && is_array($value['fields'])) {
  1129. $value['fields'] = $this->processFields($value['fields']);
  1130. }
  1131. $return[$key] = $value;
  1132. }
  1133. return $return;
  1134. }
  1135. /**
  1136. * @param string $key
  1137. * @param array $files
  1138. * @return void
  1139. */
  1140. protected function setImageField($key, $files)
  1141. {
  1142. $field = $this->data->blueprints()->schema()->get($key);
  1143. if (isset($field['type']) && !empty($field['array'])) {
  1144. $this->data->set($key, $files);
  1145. }
  1146. }
  1147. /**
  1148. * Decode data
  1149. *
  1150. * @param array $data
  1151. * @return array
  1152. */
  1153. protected function decodeData($data)
  1154. {
  1155. if (!is_array($data)) {
  1156. return [];
  1157. }
  1158. // Decode JSON encoded fields and merge them to data.
  1159. if (isset($data['_json'])) {
  1160. $data = array_replace_recursive($data, $this->jsonDecode($data['_json']));
  1161. unset($data['_json']);
  1162. }
  1163. $data = $this->cleanDataKeys($data);
  1164. return $data;
  1165. }
  1166. /**
  1167. * Decode [] in the data keys
  1168. *
  1169. * @param array $source
  1170. * @return array
  1171. */
  1172. protected function cleanDataKeys($source = [])
  1173. {
  1174. $out = [];
  1175. if (is_array($source)) {
  1176. foreach ($source as $key => $value) {
  1177. $key = str_replace(['%5B', '%5D'], ['[', ']'], $key);
  1178. if (is_array($value)) {
  1179. $out[$key] = $this->cleanDataKeys($value);
  1180. } else {
  1181. $out[$key] = $value;
  1182. }
  1183. }
  1184. }
  1185. return $out;
  1186. }
  1187. /**
  1188. * Internal method to normalize the $_FILES array
  1189. *
  1190. * @param array $data $_FILES starting point data
  1191. * @param string $key
  1192. * @return object a new Object with a normalized list of files
  1193. */
  1194. protected function normalizeFiles($data, $key = '')
  1195. {
  1196. $files = new stdClass();
  1197. $files->field = $key;
  1198. $files->file = new stdClass();
  1199. foreach ($data as $fieldName => $fieldValue) {
  1200. // Since Files Upload are always happening via Ajax
  1201. // we are not interested in handling `multiple="true"`
  1202. // because they are always handled one at a time.
  1203. // For this reason we normalize the value to string,
  1204. // in case it is arriving as an array.
  1205. $value = (array) Utils::getDotNotation($fieldValue, $key);
  1206. $files->file->{$fieldName} = array_shift($value);
  1207. }
  1208. return $files;
  1209. }
  1210. }