Skouter mortgage estimates. Web application with view written in PHP and Vue, but controller and models in Go.
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

form.php 44 KiB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357
  1. <?php
  2. namespace Grav\Plugin;
  3. use Composer\Autoload\ClassLoader;
  4. use DateTime;
  5. use Doctrine\Common\Cache\Cache;
  6. use Exception;
  7. use Grav\Common\Data\ValidationException;
  8. use Grav\Common\Debugger;
  9. use Grav\Common\Filesystem\Folder;
  10. use Grav\Common\Grav;
  11. use Grav\Common\Page\Interfaces\PageInterface;
  12. use Grav\Common\Page\Pages;
  13. use Grav\Common\Page\Types;
  14. use Grav\Common\Plugin;
  15. use Grav\Common\Twig\Twig;
  16. use Grav\Common\Utils;
  17. use Grav\Common\Uri;
  18. use Grav\Common\Yaml;
  19. use Grav\Framework\Form\Interfaces\FormInterface;
  20. use Grav\Framework\Psr7\Response;
  21. use Grav\Framework\Route\Route;
  22. use Grav\Plugin\Form\BasicCaptcha;
  23. use Grav\Plugin\Form\Form;
  24. use Grav\Plugin\Form\Forms;
  25. use Grav\Plugin\Form\TwigExtension;
  26. use Grav\Common\HTTP\Client;
  27. use Monolog\Logger;
  28. use ReCaptcha\ReCaptcha;
  29. use ReCaptcha\RequestMethod\CurlPost;
  30. use RecursiveArrayIterator;
  31. use RecursiveIteratorIterator;
  32. use RocketTheme\Toolbox\File\JsonFile;
  33. use RocketTheme\Toolbox\File\YamlFile;
  34. use RocketTheme\Toolbox\File\File;
  35. use RocketTheme\Toolbox\Event\Event;
  36. use RuntimeException;
  37. use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  38. use Twig\Environment;
  39. use Twig\Extension\CoreExtension;
  40. use Twig\Extension\EscaperExtension;
  41. use Twig\TwigFunction;
  42. use function count;
  43. use function function_exists;
  44. use function is_array;
  45. use function is_string;
  46. use function sprintf;
  47. /**
  48. * Class FormPlugin
  49. * @package Grav\Plugin
  50. */
  51. class FormPlugin extends Plugin
  52. {
  53. /** @var array */
  54. public $features = [
  55. 'blueprints' => 1000
  56. ];
  57. /** @var Form */
  58. protected $form;
  59. /** @var array[]|FormInterface[] */
  60. protected $forms = [];
  61. /** @var FormInterface[] */
  62. protected $active_forms = [];
  63. /** @var array */
  64. protected $json_response = [];
  65. /**
  66. * @return bool
  67. */
  68. public static function checkRequirements(): bool
  69. {
  70. return version_compare(GRAV_VERSION, '1.7', '>');
  71. }
  72. /**
  73. * @return array
  74. */
  75. public static function getSubscribedEvents()
  76. {
  77. if (!static::checkRequirements()) {
  78. return [];
  79. }
  80. return [
  81. 'onPluginsInitialized' => ['onPluginsInitialized', 0],
  82. 'onTwigExtensions' => ['onTwigExtensions', 0],
  83. 'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0]
  84. ];
  85. }
  86. /**
  87. * @return ClassLoader
  88. */
  89. public function autoload()
  90. {
  91. return require __DIR__ . '/vendor/autoload.php';
  92. }
  93. /**
  94. * Initialize forms from cache if possible
  95. *
  96. * @return void
  97. */
  98. public function onPluginsInitialized(): void
  99. {
  100. // Backwards compatibility for plugins that use forms.
  101. class_alias(Form::class, 'Grav\Plugin\Form');
  102. $this->grav['forms'] = function () {
  103. $forms = new Forms();
  104. $event = new Event(['forms' => $forms]);
  105. $this->grav->fireEvent('onFormRegisterTypes', $event);
  106. return $forms;
  107. };
  108. if ($this->isAdmin()) {
  109. $this->enable([
  110. 'onPageInitialized' => ['onPageInitialized', 0],
  111. 'onGetPageTemplates' => ['onGetPageTemplates', 0],
  112. ]);
  113. return;
  114. }
  115. /** @var Uri $uri */
  116. $uri = $this->grav['uri'];
  117. // Mini Keep-Alive Logic
  118. $task = $uri->param('task');
  119. if ($task === 'keep-alive') {
  120. $response = new Response(200);
  121. $this->grav->close($response);
  122. }
  123. $this->processBasicCaptchaImage($uri);
  124. $this->enable([
  125. 'onPageProcessed' => ['onPageProcessed', 0],
  126. 'onPagesInitialized' => ['onPagesInitialized', 0],
  127. 'onPageInitialized' => ['onPageInitialized', 0],
  128. 'onTwigInitialized' => ['onTwigInitialized', 0],
  129. 'onTwigPageVariables' => ['onTwigVariables', 0],
  130. 'onTwigSiteVariables' => ['onTwigVariables', 0],
  131. 'onFormValidationProcessed' => ['onFormValidationProcessed', 0],
  132. ]);
  133. }
  134. /**
  135. * @param Event $event
  136. * @return void
  137. */
  138. public function onGetPageTemplates(Event $event): void
  139. {
  140. /** @var Types $types */
  141. $types = $event->types;
  142. $types->register('form');
  143. }
  144. /**
  145. * Process forms after page header processing, but before caching
  146. *
  147. * @param Event $event
  148. * @return void
  149. */
  150. public function onPageProcessed(Event $event): void
  151. {
  152. /** @var PageInterface $page */
  153. $page = $event['page'];
  154. $forms = $page->getForms();
  155. if (!$forms) {
  156. return;
  157. }
  158. // Force never_cache_twig if modular form (recursively up)
  159. $current = $page;
  160. while ($current && $current->modularTwig()) {
  161. $header = $current->header();
  162. $header->never_cache_twig = true;
  163. $current = $current->parent();
  164. }
  165. $parent = $current && $current !== $page ? $current : null;
  166. // If the form was in the modular page, we need to add the form into the parent page as well.
  167. if ($parent) {
  168. $parent->addForms($forms);
  169. }
  170. // Store the page forms in the forms instance
  171. foreach ($forms as $name => $form) {
  172. if ($parent) {
  173. $this->addFormDefinition($parent, $name, $form);
  174. }
  175. $this->addFormDefinition($page, $name, $form);
  176. }
  177. }
  178. /**
  179. * Initialize all the forms
  180. *
  181. * @return void
  182. */
  183. public function onPagesInitialized(): void
  184. {
  185. $this->loadCachedForms();
  186. }
  187. /**
  188. * Catches form processing if user posts the form.
  189. *
  190. * @return void
  191. */
  192. public function onPageInitialized(): void
  193. {
  194. $submitted = false;
  195. $this->json_response = [];
  196. /** @var PageInterface $page */
  197. $page = $this->grav['page'];
  198. // DEPRECATED: This should no longer ever happen
  199. if (!$this->forms) {
  200. $this->onPageProcessed(new Event(['page' => $page]));
  201. }
  202. // Enable form events if there's a POST
  203. if ($this->shouldProcessForm()) {
  204. $this->enable([
  205. 'onFormProcessed' => ['onFormProcessed', 0],
  206. 'onFormValidationError' => ['onFormValidationError', 0],
  207. 'onFormFieldTypes' => ['onFormFieldTypes', 0],
  208. ]);
  209. /** @var Uri $uri */
  210. $uri = $this->grav['uri'];
  211. /** @var Forms $forms */
  212. $forms = $this->grav['forms'];
  213. $form = $forms->getActiveForm();
  214. if ($form instanceof Form) {
  215. // Post the form
  216. $isJson = $uri->extension() === 'json';
  217. $task = (string)($uri->post('task') ?? $uri->param('task'));
  218. if ($isJson) {
  219. if ($task === 'store-state') {
  220. $this->json_response = $form->storeState();
  221. } elseif ($task === 'clear-state') {
  222. $this->json_response = $form->clearState();
  223. } elseif ($task === 'file-remove' || $uri->post('__form-file-remover__')) {
  224. $this->json_response = $form->filesSessionRemove();
  225. } elseif ($task === 'file-upload' || $uri->post('__form-file-uploader__')) {
  226. $this->json_response = $form->uploadFiles();
  227. }
  228. }
  229. if (empty($this->json_response)) {
  230. if ($task === 'clear-state') {
  231. $form->getFlash()->delete();
  232. $redirect = $form->getBlueprint()->get('form/clear_redirect_url') ?? $page->route();
  233. $this->grav->redirect($redirect, 303);
  234. } else {
  235. $form->post();
  236. $submitted = true;
  237. }
  238. }
  239. // Return JSON if we're not in form template.
  240. if ($this->json_response && $page->template() !== 'form') {
  241. $status = $this->json_response['status'] ?? null;
  242. $response = new Response(
  243. $status !== 'error' ? 200 : 400,
  244. ['Content-Type' => 'application/json'],
  245. json_encode($this->json_response, JSON_THROW_ON_ERROR)
  246. );
  247. $this->grav->close($response);
  248. }
  249. }
  250. // Clear flash objects for previously uploaded files
  251. // whenever the user switches page / reloads
  252. // ignoring any JSON / extension call
  253. if (!$submitted && null === $uri->extension()) {
  254. // Discard any previously uploaded files session.
  255. // and if there were any uploaded file, remove them from the filesystem
  256. if ($flash = $this->grav['session']->getFlashObject('files-upload')) {
  257. $flash = new RecursiveIteratorIterator(new RecursiveArrayIterator($flash));
  258. foreach ($flash as $key => $value) {
  259. if ($key !== 'tmp_name') {
  260. continue;
  261. }
  262. @unlink($value);
  263. }
  264. }
  265. }
  266. } else {
  267. // There is no active form to be posted.
  268. // Check all the forms for the current page; we are looking for forms with remember state turned on with random unique id.
  269. /** @var Forms $forms */
  270. $forms = $this->grav['forms'];
  271. $lang = $this->grav['language']->getLanguage();
  272. /** @var Route $route */
  273. $route = $this->grav['route'];
  274. $pageForms = $this->forms[$lang][$route->getRoute()] ?? [];
  275. /**
  276. * @var string $name
  277. * @var array|FormInterface $form
  278. */
  279. foreach ($pageForms as $name => $form) {
  280. if (is_array($form)) {
  281. $form = $this->createForm($page, $name, $form);
  282. }
  283. if (!$form instanceof FormInterface) {
  284. continue;
  285. }
  286. if ($form->get('remember_redirect')) {
  287. // Found one; we need to check if unique id is set.
  288. $formParam = $form->get('uniqueid_param', 'fid');
  289. $uniqueId = $route->getGravParam($formParam);
  290. if ($uniqueId && preg_match('/[a-z\d]+/', $uniqueId)) {
  291. // URL contains unique id, initialize the current form.
  292. $form->setUniqueId($uniqueId);
  293. $form->initialize();
  294. $forms->setActiveForm($form);
  295. break;
  296. }
  297. // Append unique id to the URL and redirect.
  298. $route = $route->withGravParam($formParam, $form->getUniqueId());
  299. $page->redirect($route->toString());
  300. // TODO: Do we want to add support for multiple forms with remembered state?
  301. break;
  302. }
  303. }
  304. }
  305. }
  306. /**
  307. * Add simple `forms()` Twig function
  308. *
  309. * @return void
  310. */
  311. public function onTwigInitialized(): void
  312. {
  313. $this->grav['twig']->twig()->addFunction(
  314. new TwigFunction('forms', [$this, 'getForm'])
  315. );
  316. if (Environment::VERSION_ID > 20000) {
  317. // Twig 2/3
  318. $this->grav['twig']->twig()->getExtension(EscaperExtension::class)->setEscaper(
  319. 'yaml',
  320. function ($twig, $string, $charset) {
  321. return Yaml::dump($string);
  322. }
  323. );
  324. } else {
  325. // Twig 1.x
  326. $this->grav['twig']->twig()->getExtension(CoreExtension::class)->setEscaper(
  327. 'yaml',
  328. function ($twig, $string, $charset) {
  329. return Yaml::dump($string);
  330. }
  331. );
  332. }
  333. }
  334. /**
  335. * @return void
  336. */
  337. public function onTwigExtensions(): void
  338. {
  339. $this->grav['twig']->twig->addExtension(new TwigExtension());
  340. }
  341. /**
  342. * Add current directory to twig lookup paths.
  343. *
  344. * @return void
  345. */
  346. public function onTwigTemplatePaths(): void
  347. {
  348. $this->grav['twig']->twig_paths[] = __DIR__ . '/templates';
  349. }
  350. /**
  351. * Make form accessible from twig.
  352. *
  353. * @param Event|null $event
  354. * @return void
  355. */
  356. public function onTwigVariables(Event $event = null): void
  357. {
  358. if ($event && isset($event['page'])) {
  359. $page = $event['page'];
  360. } else {
  361. $page = $this->grav['page'];
  362. }
  363. $twig = $this->grav['twig'];
  364. if (!isset($twig->twig_vars['form'])) {
  365. $twig->twig_vars['form'] = $this->form($page);
  366. }
  367. if ($this->config->get('plugins.form.built_in_css')) {
  368. $this->grav['assets']->addCss('plugin://form/assets/form-styles.css');
  369. }
  370. $twig->twig_vars['form_max_filesize'] = Form::getMaxFilesize();
  371. $twig->twig_vars['form_json_response'] = $this->json_response;
  372. }
  373. /**
  374. * Handle form processing instructions.
  375. *
  376. * @param Event $event
  377. * @return void
  378. * @throws Exception
  379. * @throws TransportExceptionInterface
  380. */
  381. public function onFormProcessed(Event $event): void
  382. {
  383. /** @var Form $form */
  384. $form = $event['form'];
  385. $action = $event['action'];
  386. $params = $event['params'];
  387. $this->process($form);
  388. switch ($action) {
  389. case 'captcha':
  390. $captcha_config = $this->config->get('plugins.form.recaptcha');
  391. $secret = $params['recaptcha_secret'] ?? $params['recatpcha_secret'] ?? $captcha_config['secret_key'];
  392. /** @var Uri $uri */
  393. $uri = $this->grav['uri'];
  394. $action = $form->value('action');
  395. $hostname = $uri->host();
  396. $ip = Uri::ip();
  397. $recaptcha = new ReCaptcha($secret);
  398. if (extension_loaded('curl')) {
  399. $recaptcha = new ReCaptcha($secret, new CurlPost());
  400. }
  401. // get captcha version
  402. $captcha_version = $captcha_config['version'] ?? 2;
  403. // Add version 3 specific options
  404. if ($captcha_version == 3) {
  405. $token = $form->value('token');
  406. $resp = $recaptcha
  407. ->setExpectedHostname($hostname)
  408. ->setExpectedAction($action)
  409. ->setScoreThreshold(0.5)
  410. ->verify($token, $ip);
  411. } else {
  412. $token = $form->value('g-recaptcha-response', true);
  413. $resp = $recaptcha
  414. ->setExpectedHostname($hostname)
  415. ->verify($token, $ip);
  416. }
  417. if (!$resp->isSuccess()) {
  418. $errors = $resp->getErrorCodes();
  419. $message = $this->grav['language']->translate('PLUGIN_FORM.ERROR_VALIDATING_CAPTCHA');
  420. $fields = $form->value()->blueprints()->get('form/fields');
  421. foreach ($fields as $field) {
  422. $type = $field['type'] ?? 'text';
  423. $field_message = $field['recaptcha_not_validated'] ?? null;
  424. if ($type === 'captcha' && $field_message) {
  425. $message = $field_message;
  426. break;
  427. }
  428. }
  429. $this->grav->fireEvent('onFormValidationError', new Event([
  430. 'form' => $form,
  431. 'message' => $message
  432. ]));
  433. $this->grav['log']->addWarning('Form reCAPTCHA Errors: [' . $uri->route() . '] ' . json_encode($errors));
  434. $event->stopPropagation();
  435. return;
  436. }
  437. break;
  438. case 'basic-captcha':
  439. $captcha = new BasicCaptcha();
  440. $captcha_value = trim($form->value('basic-captcha'));
  441. if (!$captcha->validateCaptcha($captcha_value)) {
  442. $message = $params['message'] ?? $this->grav['language']->translate('PLUGIN_FORM.ERROR_BASIC_CAPTCHA');
  443. $form->setData('basic-captcha', '');
  444. $this->grav->fireEvent('onFormValidationError', new Event([
  445. 'form' => $form,
  446. 'message' => $message
  447. ]));
  448. $event->stopPropagation();
  449. return;
  450. }
  451. break;
  452. case 'turnstile':
  453. /** @var Uri $uri */
  454. $uri = $this->grav['uri'];
  455. $turnstile_config = $this->config->get('plugins.form.turnstile');
  456. $secret = $turnstile_config['secret_key'] ?? null;
  457. $token = $form->getValue('cf-turnstile-response') ?? null;
  458. $ip = Uri::ip();
  459. $client = Client::getClient();
  460. $response = $client->request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', [
  461. 'body' => [
  462. 'secret' => $secret,
  463. 'response' => $token,
  464. 'remoteip' => $ip
  465. ]
  466. ]);
  467. $content = $response->toArray();
  468. if (!$content['success']) {
  469. $message = $params['message'] ?? $this->grav['language']->translate('PLUGIN_FORM.ERROR_BASIC_CAPTCHA');
  470. $this->grav->fireEvent('onFormValidationError', new Event([
  471. 'form' => $form,
  472. 'message' => $message
  473. ]));
  474. $this->grav['log']->addWarning('Form Turnstile invalid: [' . $uri->route() . '] ' . json_encode($content));
  475. $event->stopPropagation();
  476. return;
  477. }
  478. break;
  479. case 'timestamp':
  480. $label = $params['label'] ?? 'Timestamp';
  481. $format = $params['format'] ?? 'Y-m-d H:i:s';
  482. $blueprint = $form->value()->blueprints();
  483. $blueprint->set('form/fields/timestamp', ['name' => 'timestamp', 'label' => $label, 'type' => 'hidden']);
  484. $now = new DateTime('now');
  485. $date_string = $now->format($format);
  486. $form->setFields($blueprint->fields());
  487. $form->setData('timestamp', $date_string);
  488. break;
  489. case 'ip':
  490. $label = $params['label'] ?? 'User IP';
  491. $blueprint = $form->value()->blueprints();
  492. $blueprint->set('form/fields/ip', ['name' => 'ip', 'label' => $label, 'type' => 'hidden']);
  493. $form->setFields($blueprint->fields());
  494. $form->setData('ip', Uri::ip());
  495. break;
  496. case 'message':
  497. $translated_string = $this->grav['language']->translate($params);
  498. $vars = array(
  499. 'form' => $form
  500. );
  501. /** @var Twig $twig */
  502. $twig = $this->grav['twig'];
  503. $processed_string = $twig->processString($translated_string, $vars);
  504. $form->message = $processed_string;
  505. break;
  506. case 'redirect':
  507. $this->grav['session']->setFlashObject('form', $form);
  508. $url = ((string)$params);
  509. $vars = array(
  510. 'form' => $form
  511. );
  512. /** @var Twig $twig */
  513. $twig = $this->grav['twig'];
  514. $url = $twig->processString($url, $vars);
  515. $message = $form->message;
  516. if ($message) {
  517. $this->grav['messages']->add($form->message, 'success');
  518. }
  519. $this->grav->redirect($url);
  520. break;
  521. case 'reset':
  522. if (Utils::isPositive($params)) {
  523. $message = $form->message;
  524. $form->reset();
  525. $form->message = $message;
  526. }
  527. break;
  528. case 'display':
  529. $route = (string)$params;
  530. if (!$route || $route[0] !== '/') {
  531. /** @var Uri $uri */
  532. $uri = $this->grav['uri'];
  533. $route = rtrim($uri->route(), '/') . '/' . ($route ?: '');
  534. }
  535. /** @var Twig $twig */
  536. $twig = $this->grav['twig'];
  537. $twig->twig_vars['form'] = $form;
  538. /** @var Pages $pages */
  539. $pages = $this->grav['pages'];
  540. $page = $pages->dispatch($route, true);
  541. if (!$page) {
  542. throw new RuntimeException('Display page not found. Please check the page exists.', 400);
  543. }
  544. unset($this->grav['page']);
  545. $this->grav['page'] = $page;
  546. break;
  547. case 'remember':
  548. foreach ($params as $remember_field) {
  549. $field_cookie = 'forms-' . $form['name'] . '-' . $remember_field;
  550. setcookie($field_cookie, $form->value($remember_field), time() + 60 * 60 * 24 * 60);
  551. }
  552. break;
  553. case 'upload':
  554. if ($params !== false) {
  555. $form->copyFiles();
  556. }
  557. break;
  558. case 'save':
  559. $prefix = $params['fileprefix'] ?? '';
  560. $format = $params['dateformat'] ?? 'Ymd-His-u';
  561. $raw_format = (bool)($params['dateraw'] ?? false);
  562. $postfix = $params['filepostfix'] ?? '';
  563. $ext = !empty($params['extension']) ? '.' . trim($params['extension'], '.') : '.txt';
  564. $filename = $params['filename'] ?? '';
  565. $folder = !empty($params['folder']) ? $params['folder'] : $form->getName();
  566. $operation = $params['operation'] ?? 'create';
  567. if (!$filename) {
  568. if ($operation === 'add') {
  569. throw new RuntimeException('Form save: \'operation: add\' is only supported with a static filename');
  570. }
  571. $filename = $prefix . $this->udate($format, $raw_format) . $postfix . $ext;
  572. }
  573. // Handle bad filenames.
  574. if (!Utils::checkFilename($filename)) {
  575. throw new RuntimeException(sprintf('Form save: File with extension not allowed: %s', $filename));
  576. }
  577. /** @var Twig $twig */
  578. $twig = $this->grav['twig'];
  579. $vars = [
  580. 'form' => $form
  581. ];
  582. // Process with Twig
  583. $filename = $twig->processString($filename, $vars);
  584. $locator = $this->grav['locator'];
  585. $path = $locator->findResource('user-data://', true);
  586. $dir = $path . DS . $folder;
  587. $fullFileName = $dir . DS . $filename;
  588. if (!empty($params['raw']) || !empty($params['template'])) {
  589. // Save data as it comes from the form.
  590. if ($operation === 'add') {
  591. throw new RuntimeException('Form save: \'operation: add\' is not supported for raw files');
  592. }
  593. switch ($ext) {
  594. case '.yaml':
  595. $file = YamlFile::instance($fullFileName);
  596. break;
  597. case '.json':
  598. $file = JsonFile::instance($fullFileName);
  599. break;
  600. default:
  601. throw new RuntimeException('Form save: Unsupported RAW file format, please use either yaml or json');
  602. }
  603. $content = $form->getData();
  604. $data = [
  605. '_data_type' => 'form',
  606. 'template' => !empty($params['template']) ? $params['template'] : null,
  607. 'name' => $form->getName(),
  608. 'timestamp' => date('Y-m-d H:i:s'),
  609. 'content' => $content ? $content->toArray() : []
  610. ];
  611. $file->lock();
  612. $form->copyFiles();
  613. $file->save(array_filter($data));
  614. break;
  615. }
  616. $file = File::instance($fullFileName);
  617. $file->lock();
  618. $form->copyFiles();
  619. if ($operation === 'create') {
  620. $body = $twig->processString($params['body'] ?? '{% include "forms/data.txt.twig" %}', $vars);
  621. $file->save($body);
  622. } elseif ($operation === 'add') {
  623. if (!empty($params['body'])) {
  624. // use body similar to 'create' action and append to file as a log
  625. $body = $twig->processString($params['body'], $vars);
  626. // create folder if it doesn't exist
  627. if (!file_exists($dir)) {
  628. Folder::create($dir);
  629. }
  630. // append data to existing file
  631. $file->unlock();
  632. file_put_contents($fullFileName, $body, FILE_APPEND | LOCK_EX);
  633. } else {
  634. // serialize YAML out to file for easier parsing as data sets
  635. $vars = $vars['form']->value()->toArray();
  636. foreach ($form->fields as $field) {
  637. if (!empty($field['process']['ignore'])) {
  638. unset($vars[$field['name']]);
  639. }
  640. }
  641. if (file_exists($fullFileName)) {
  642. $data = Yaml::parse($file->content());
  643. if (count($data) > 0) {
  644. array_unshift($data, $vars);
  645. } else {
  646. $data[] = $vars;
  647. }
  648. } else {
  649. $data[] = $vars;
  650. }
  651. $file->save(Yaml::dump($data));
  652. }
  653. }
  654. break;
  655. case 'call':
  656. $callable = $params;
  657. if (is_array($callable) && !method_exists($callable[0], $callable[1])) {
  658. throw new RuntimeException('Form cannot be processed (method does not exist)');
  659. }
  660. if (is_string($callable) && !function_exists($callable)) {
  661. throw new RuntimeException('Form cannot be processed (function does not exist)');
  662. }
  663. $callable($form);
  664. break;
  665. }
  666. }
  667. /**
  668. * Custom field logic can go in here
  669. *
  670. * @param Event $event
  671. * @return void
  672. */
  673. public function onFormValidationProcessed(Event $event): void
  674. {
  675. // special check for honeypot field
  676. foreach ($event['form']->fields() as $field) {
  677. if ($field['type'] === 'honeypot' && !empty($event['form']->value($field['name']))) {
  678. throw new ValidationException('Are you a bot?');
  679. }
  680. }
  681. }
  682. /**
  683. * Handle form validation error
  684. *
  685. * @param Event $event An event object
  686. * @return void
  687. * @throws Exception
  688. */
  689. public function onFormValidationError(Event $event): void
  690. {
  691. /** @var FormInterface $form */
  692. $form = $event['form'];
  693. if (isset($event['message'])) {
  694. $form->status = 'error';
  695. $form->message = $event['message'];
  696. $form->messages = $event['messages'];
  697. }
  698. /** @var Uri $uri */
  699. $uri = $this->grav['uri'];
  700. $route = $uri->route();
  701. /** @var Twig $twig */
  702. $twig = $this->grav['twig'];
  703. $twig->twig_vars['form'] = $form;
  704. /** @var Pages $pages */
  705. $pages = $this->grav['pages'];
  706. $page = $pages->find($route, true);
  707. if ($page) {
  708. unset($this->grav['page']);
  709. $this->grav['page'] = $page;
  710. }
  711. $event->stopPropagation();
  712. }
  713. /**
  714. * Add a form definition to the forms plugin
  715. *
  716. * @param PageInterface $page
  717. * @return void
  718. */
  719. public function addFormDefinition(PageInterface $page, string $name, array $form): void
  720. {
  721. $route = ($page->home() ? '/' : $page->route()) ?? '/';
  722. $lang = $this->grav['language']->getLanguage();
  723. if (!isset($this->forms[$lang][$route][$name])) {
  724. $form['_page_routable'] = !$page->isModule();
  725. $this->forms[$lang][$route][$name] = $form;
  726. $this->saveCachedForms();
  727. }
  728. }
  729. /**
  730. * Add a form to the forms plugin
  731. *
  732. * @param string|null $route
  733. * @param FormInterface|null $form
  734. * @return void
  735. */
  736. public function addForm(?string $route, ?FormInterface $form): void
  737. {
  738. if (null === $form) {
  739. return;
  740. }
  741. $lang = $this->grav['language']->getLanguage();
  742. $name = $form->getName();
  743. if (!isset($this->forms[$lang][$route][$name])) {
  744. $form['_page_routable'] = true;
  745. $this->forms[$lang][$route][$name] = $form;
  746. $this->saveCachedForms();
  747. }
  748. }
  749. /**
  750. * function to get a specific form
  751. *
  752. * @param string|array|null $data Optional form name or ['name' => $name, 'route' => $route]
  753. * @return FormInterface|null
  754. */
  755. public function getForm($data = null): ?FormInterface
  756. {
  757. /** @var Pages $pages */
  758. $pages = $this->grav['pages'];
  759. $lang = $this->grav['language']->getLanguage();
  760. // Handle parameters.
  761. if (is_array($data)) {
  762. $name = (string)($data['name'] ?? '');
  763. $route = (string)($data['route'] ?? '');
  764. } elseif (is_string($data)) {
  765. $name = $data;
  766. $route = '';
  767. } else {
  768. $name = '';
  769. $route = '';
  770. }
  771. // Return always the same form instance.
  772. $form = $this->active_forms[$route][$name] ?? null;
  773. if ($form) {
  774. return $form;
  775. }
  776. $unnamed = $name === '';
  777. $routed = $route !== '';
  778. // Get the page.
  779. if ($routed) {
  780. // Use fixed route for the form.
  781. $route_provided = true;
  782. $page = $pages->find($route, true);
  783. } else {
  784. // Search form from the current page first.
  785. $route_provided = false;
  786. /** @var PageInterface|null $page */
  787. $page = $this->grav['page'] ?? null;
  788. if ($page) {
  789. $route = $page->route();
  790. } else {
  791. // Get page route with a fallback using current URI if page is not yet initialized.
  792. $route = $this->getCurrentPageRoute();
  793. $page = $pages->find($route);
  794. }
  795. }
  796. // Attempt to find the form from the page.
  797. if ('' !== $route) {
  798. $forms = $this->forms[$lang][$route] ?? [];
  799. if (!$unnamed) {
  800. // Get form by the name.
  801. $form = $forms[$name] ?? null;
  802. } else {
  803. // Get the first form.
  804. $form = reset($forms) ?: null;
  805. $name = key($forms);
  806. }
  807. }
  808. // Search the form from the other pages.
  809. if (null === $form) {
  810. // First check if we requested a specific form which didn't exist.
  811. if ($route_provided || $unnamed) {
  812. $this->grav['debugger']->addMessage(sprintf('Form %s not found in page %s', $name ?? 'unnamed', $route), 'warning');
  813. return null;
  814. }
  815. // Attempt to find any form with given name.
  816. $forms = $this->findFormByName($name);
  817. $first = reset($forms);
  818. if (!$first) {
  819. return null;
  820. }
  821. // Check for naming conflicts.
  822. if (count($forms) > 1) {
  823. $this->grav['debugger']->addMessage(sprintf('Fetching form by its name, but there are multiple pages with the same form name %s', $name), 'warning');
  824. }
  825. [$route, $name, $form] = $first;
  826. $page = $pages->find($route);
  827. }
  828. // Form can be saved as an array or an object. If it's an array, we need to create object from it.
  829. if (is_array($form)) {
  830. // Form was cached as an array, try to create the object.
  831. if (null === $page) {
  832. $this->grav['debugger']->addMessage(sprintf('Form %s cannot be created as page %s does not exist', $name, $route), 'warning');
  833. return null;
  834. }
  835. $form = $this->createForm($page, $name, $form);
  836. }
  837. // Register form to the active forms to get the same instance back next time.
  838. $this->active_forms[$route][$name] = $form;
  839. if ($unnamed) {
  840. $this->active_forms[$route][''] = $form;
  841. }
  842. // Also make aliases if route was not provided to the method.
  843. if (!$routed) {
  844. $this->active_forms[''][$name] = $form;
  845. if ($unnamed) {
  846. $this->active_forms[''][''] = $form;
  847. }
  848. }
  849. return $form;
  850. }
  851. /**
  852. * Get list of form field types specified in this plugin. Only special types needs to be listed.
  853. *
  854. * @return array
  855. */
  856. public function getFormFieldTypes(): array
  857. {
  858. return [
  859. 'avatar' => [
  860. 'input@' => false,
  861. 'media_field' => true
  862. ],
  863. 'captcha' => [
  864. 'input@' => false
  865. ],
  866. 'columns' => [
  867. 'input@' => false
  868. ],
  869. 'column' => [
  870. 'input@' => false
  871. ],
  872. 'conditional' => [
  873. 'input@' => false
  874. ],
  875. 'display' => [
  876. 'input@' => false
  877. ],
  878. 'fieldset' => [
  879. 'input@' => false
  880. ],
  881. 'file' => [
  882. 'array' => true,
  883. 'media_field' => true,
  884. 'validate' => [
  885. 'type' => 'ignore'
  886. ]
  887. ],
  888. 'formname' => [
  889. 'input@' => false
  890. ],
  891. 'honeypot' => [
  892. 'input@' => false
  893. ],
  894. 'ignore' => [
  895. 'input@' => false
  896. ],
  897. 'key' => [
  898. 'input@' => false
  899. ],
  900. 'section' => [
  901. 'input@' => false
  902. ],
  903. 'spacer' => [
  904. 'input@' => false
  905. ],
  906. 'tabs' => [
  907. 'input@' => false
  908. ],
  909. 'tab' => [
  910. 'input@' => false
  911. ],
  912. 'uniqueid' => [
  913. 'input@' => false
  914. ],
  915. 'value' => [
  916. 'input@' => false
  917. ]
  918. ];
  919. }
  920. /**
  921. * Process a form
  922. *
  923. * Currently available processing tasks:
  924. *
  925. * - fillWithCurrentDateTime
  926. *
  927. * @param FormInterface $form
  928. * @return void
  929. */
  930. protected function process($form)
  931. {
  932. foreach ($form->fields as $field) {
  933. if (!empty($field['process']['fillWithCurrentDateTime'])) {
  934. $form->setData($field['name'], gmdate('D, d M Y H:i:s', time()));
  935. }
  936. }
  937. }
  938. /**
  939. * Get current page's route
  940. *
  941. * @return string
  942. */
  943. protected function getCurrentPageRoute()
  944. {
  945. $path = $this->grav['uri']->route();
  946. return $path ?: '/';
  947. }
  948. /**
  949. * Return all forms matching the given name.
  950. *
  951. * @param string $name
  952. * @return array
  953. */
  954. protected function findFormByName(string $name): array
  955. {
  956. $list = [];
  957. $lang = $this->grav['language']->getLanguage();
  958. $lang_forms = $this->forms[$lang] ?? [];
  959. foreach ($lang_forms as $route => $forms) {
  960. foreach ($forms as $key => $form) {
  961. if ($name === $key && !empty($form['_page_routable'])) {
  962. $list[] = [$route, $key, $form];
  963. }
  964. }
  965. }
  966. return $list;
  967. }
  968. /**
  969. * Determine if the page has a form submission that should be processed
  970. *
  971. * @return bool
  972. */
  973. protected function shouldProcessForm(): bool
  974. {
  975. /** @var Uri $uri */
  976. $uri = $this->grav['uri'];
  977. $status = (bool)$uri->post('form-nonce');
  978. if ($status && $form = $this->form()) {
  979. // Make sure form is something we recognize.
  980. if (!$form instanceof Form) {
  981. return false;
  982. }
  983. if (isset($form->xhr_submit) && $form->xhr_submit) {
  984. $form->set('template', $form->template ?? 'form-xhr');
  985. }
  986. // Set page template if passed by form
  987. if (isset($form->template)) {
  988. $this->grav['page']->template($form->template);
  989. }
  990. if (isset($form->refresh_prevention)) {
  991. $refresh_prevention = (bool)$form->refresh_prevention;
  992. } else {
  993. $refresh_prevention = $this->config->get('plugins.form.refresh_prevention', false);
  994. }
  995. $unique_form_id = $form->getUniqueId();
  996. if ($refresh_prevention && $unique_form_id) {
  997. if ($this->grav['session']->unique_form_id !== $unique_form_id) {
  998. $isJson = $uri->extension() === 'json';
  999. // AJAX tasks aren't submitting
  1000. if (!$isJson || !($uri->post('__form-file-uploader__') || $uri->post('__form-file-remover__'))) {
  1001. $this->grav['session']->unique_form_id = $unique_form_id;
  1002. }
  1003. } else {
  1004. $status = false;
  1005. $form->message = $this->grav['language']->translate('PLUGIN_FORM.FORM_ALREADY_SUBMITTED');
  1006. $form->status = 'error';
  1007. }
  1008. }
  1009. }
  1010. return $status;
  1011. }
  1012. /**
  1013. * Get the current form, should already be processed but can get it directly from the page if necessary
  1014. *
  1015. * @param PageInterface|null $page
  1016. * @return FormInterface|null
  1017. */
  1018. protected function form(PageInterface $page = null)
  1019. {
  1020. /** @var Forms $forms */
  1021. $forms = $this->grav['forms'];
  1022. $form = $forms->getActiveForm();
  1023. if (null === $form) {
  1024. // try to get the page if possible
  1025. if (null === $page) {
  1026. $page = $this->grav['page'];
  1027. }
  1028. // Try to find the posted form if available.
  1029. $form_name = $this->grav['uri']->post('__form-name__', GRAV_SANITIZE_STRING) ?? '';
  1030. $unique_id = $this->grav['uri']->post('__unique_form_id__', GRAV_SANITIZE_STRING) ?? '';
  1031. if (!$form_name) {
  1032. $form_name = $page ? $page->slug() : null;
  1033. }
  1034. $form = $form_name ? $this->getForm($form_name) : null;
  1035. if ($form && '' === $unique_id) {
  1036. // Reset form to change the cached unique id and to fire onFormInitialized event.
  1037. $form->setUniqueId('');
  1038. $form->reset();
  1039. }
  1040. // last attempt using current page's form
  1041. if (!$form && $page) {
  1042. $form = $this->createForm($page);
  1043. }
  1044. if ($form) {
  1045. // Only set posted unique id if the form name matches to the one that was posted.
  1046. if ($unique_id && $form_name === $form->getFormName()) {
  1047. $form->setUniqueId($unique_id);
  1048. $form->initialize();
  1049. }
  1050. $forms->setActiveForm($form);
  1051. }
  1052. }
  1053. return $form;
  1054. }
  1055. /**
  1056. * @param PageInterface $page
  1057. * @param string|null $name
  1058. * @param array|null $form
  1059. * @return FormInterface|null
  1060. */
  1061. protected function createForm(PageInterface $page, string $name = null, array $form = null): ?FormInterface
  1062. {
  1063. /** @var Forms $forms */
  1064. $forms = $this->grav['forms'];
  1065. return $forms->createPageForm($page, $name, $form);
  1066. }
  1067. /**
  1068. * Load cached forms and merge with any currently found forms
  1069. *
  1070. * @return void
  1071. */
  1072. protected function loadCachedForms(): void
  1073. {
  1074. // Get and set the cache of forms if it exists
  1075. try {
  1076. /** @var Cache $cache */
  1077. $cache = $this->grav['cache'];
  1078. $forms = $cache->fetch($this->getFormCacheId());
  1079. } catch (Exception $e) {
  1080. $this->grav['debugger']->addMessage(sprintf('Unserializing cached forms failed: %s', $e->getMessage()), 'error');
  1081. $forms = null;
  1082. }
  1083. if (!is_array($forms)) {
  1084. return;
  1085. }
  1086. // Only update the forms if it's not empty
  1087. if ($forms) {
  1088. $this->forms = Utils::arrayMergeRecursiveUnique($this->forms, $forms);
  1089. if ($this->config()['debug']) {
  1090. $this->grav['log']->addDebug(sprintf("<<<< Loaded cached forms: %s\n%s", $this->getFormCacheId(), $this->arrayToString($this->forms)));
  1091. }
  1092. }
  1093. }
  1094. /**
  1095. * Save the current state of the forms
  1096. *
  1097. * @return void
  1098. */
  1099. protected function saveCachedForms(): void
  1100. {
  1101. /** @var Cache $cache */
  1102. $cache = $this->grav['cache'];
  1103. $cache_id = $this->getFormCacheId();
  1104. $forms = $cache->fetch($cache_id);
  1105. if ($forms) {
  1106. $this->forms = Utils::arrayMergeRecursiveUnique($this->forms, $forms);
  1107. }
  1108. $cache->save($cache_id, $this->forms);
  1109. if ($this->config()['debug']) {
  1110. $this->grav['log']->addDebug(sprintf(">>>> Saved cached forms: %s\n%s", $this->getFormCacheId(), $this->arrayToString($this->forms)));
  1111. }
  1112. }
  1113. /**
  1114. * Get the current page cache based id for the forms cache
  1115. *
  1116. * @return string
  1117. */
  1118. protected function getFormCacheId(): string
  1119. {
  1120. /** @var \Grav\Common\Cache $cache */
  1121. $cache = $this->grav['cache'];
  1122. $cache_id = $cache->getKey() . '-form-plugin';
  1123. return $cache_id;
  1124. }
  1125. /**
  1126. * Create unix timestamp for storing the data into the filesystem.
  1127. *
  1128. * @param string $format
  1129. * @param bool $raw
  1130. * @return string
  1131. */
  1132. protected function udate($format = 'u', $raw = false)
  1133. {
  1134. if ($raw) {
  1135. return date($format);
  1136. }
  1137. $utimestamp = microtime(true);
  1138. $timestamp = floor($utimestamp);
  1139. $milliseconds = round(($utimestamp - $timestamp) * 1000000);
  1140. return date(preg_replace('`(?<!\\\\)u`', sprintf('%06d', $milliseconds), $format), $timestamp);
  1141. }
  1142. protected function processBasicCaptchaImage(Uri $uri)
  1143. {
  1144. if ($uri->path() === '/forms-basic-captcha-image.jpg') {
  1145. $captcha = new BasicCaptcha();
  1146. $code = $captcha->getCaptchaCode();
  1147. $image = $captcha->createCaptchaImage($code);
  1148. $captcha->renderCaptchaImage($image);
  1149. exit;
  1150. }
  1151. }
  1152. protected function arrayToString($array, $level = 2) {
  1153. $result = $this->limitArrayLevels($array, $level);
  1154. return json_encode($result, JSON_UNESCAPED_SLASHES);
  1155. }
  1156. protected function limitArrayLevels($array, $levelsToKeep, $currentLevel = 0) {
  1157. if ($currentLevel >= $levelsToKeep) {
  1158. return '-';
  1159. }
  1160. $result = [];
  1161. foreach ($array as $key => $value) {
  1162. if (is_array($value)) {
  1163. $value = $this->limitArrayLevels($value, $levelsToKeep, $currentLevel + 1);
  1164. }
  1165. $result[$key] = $value;
  1166. }
  1167. return $result;
  1168. }
  1169. }