PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / V3.3.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder vV3.3.2
V3.3.2 3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 All 139 releases
← All changes | includes/Frontend/Form/FrontendFormHandler.php +372 -82 V3.0.2 → V3.3.2 View file →
@@ -8,8 +8,9 @@
8 8
9 9 use BitCode\BitForm\Admin\Form\AdminFormHandler;
10 10 use BitCode\BitForm\Admin\Form\FrontEndScriptGenerator;
11 11 use BitCode\BitForm\Admin\Form\Helpers;
12 +use BitCode\BitForm\Core\Cryptography\Cryptography;
12 13 use BitCode\BitForm\Core\Database\FormEntryMetaModel;
13 14 use BitCode\BitForm\Core\Database\FormModel;
14 15 use BitCode\BitForm\Core\Form\FormManager;
15 16 use BitCode\BitForm\Core\Integration\IntegrationHandler;
@@ -15,14 +16,21 @@
15 16 use BitCode\BitForm\Core\Integration\IntegrationHandler;
16 17 use BitCode\BitForm\Core\Util\EscapingHelper;
17 18 use BitCode\BitForm\Core\Util\FieldValueHandler;
18 19 use BitCode\BitForm\Core\Util\FileDownloadProvider;
20 +use BitCode\BitForm\Core\Util\FileHandler;
19 21 use BitCode\BitForm\Core\Util\FrontendHelpers;
22 +use BitCode\BitForm\Core\Util\Log;
23 +use BitCode\BitForm\Core\Util\SmartTagRegistry;
20 24 use BitCode\BitForm\Core\Util\SmartTags;
25 +use BitCode\BitForm\Core\Util\Utilities;
21 26 use BitCode\BitForm\Core\WorkFlow\WorkFlow;
22 27
23 28 final class FrontendFormHandler
24 29 {
30 + /** Largest stored signature inlined into the page as a data URI. */
31 + private const MAX_INLINE_SIGNATURE_BYTES = 2097152;
32 +
25 33 public function __construct()
26 34 {
27 35 // before markup load - formids [], posts [1,2]
28 36 add_action('wp_enqueue_scripts', [$this, 'loadAssets']);
@@ -28,29 +36,37 @@
28 36 add_action('wp_enqueue_scripts', [$this, 'loadAssets']);
29 37 // markup loads - formids []
30 38 add_shortcode('bitform', [$this, 'handleFrontendRenderRequest']);
31 39 // after markup load - formids [1,35,3]
32 - add_action('wp_footer', [$this, 'generateJS']);
40 + // After popup plugins render at 10 (that is when popup-only forms register
41 + // their formID), before wp_print_footer_scripts at 20.
42 + add_action('wp_footer', [$this, 'generateJS'], 15);
33 43 }
34 44
35 - private function validPassowordResetToken($token, $userID, $formId)
45 + /**
46 + * A reset form opened from a used or expired link cannot succeed, so show why instead of
47 + * the form. Returns the notice markup, or '' when the link is fine or the form is not a
48 + * reset form. Rendered in place of the form (the old exit() cut the page off mid-theme).
49 + *
50 + * @param string $token key from the reset link
51 + * @param string $userID user ID from the reset link
52 + * @param int $formId form being rendered
53 + *
54 + * @return string
55 + */
56 + private function invalidPasswordResetNotice($token, $userID, $formId)
36 57 {
37 58 $existResetInteg = (new IntegrationHandler($formId))->getAllIntegration('wp_user_auth', 'wp_auth', 1);
38 - if (!is_wp_error($existResetInteg) && count($existResetInteg) > 0) {
39 - if ('reset' === $existResetInteg[0]->integration_name) {
40 - $user = get_userdata($userID);
41 - if ($user) {
42 - $validKey = check_password_reset_key($token, $user->user_login);
43 - if (is_wp_error($validKey)) {
44 - echo "<div id='bf-resp' style='display:grid;justify-content:center;color:#860000;'>This password reset token is invalid.</div>";
45 - exit();
46 - }
47 - } else {
48 - echo "<div id='bf-resp' style='display:grid;justify-content:center;color:#860000;'>Invalid User!!</div>";
49 - exit();
50 - }
51 - }
59 + if (is_wp_error($existResetInteg) || empty($existResetInteg) || 'reset' !== $existResetInteg[0]->integration_name) {
60 + return '';
52 61 }
62 + $user = get_userdata((int) $userID);
63 + if ($user && !is_wp_error(check_password_reset_key($token, $user->user_login))) {
64 + return '';
65 + }
66 + return "<div id='bf-resp' role='alert' style='display:grid;justify-content:center;color:#860000;'>"
67 + . esc_html__('This password reset link is invalid or has expired. Please request a new one.', 'bit-form')
68 + . '</div>';
53 69 }
54 70
55 71 private function getJSFileSrc($postId)
56 72 {
@@ -59,10 +75,43 @@
59 75
60 76 return $formScriptSrc;
61 77 }
62 78
79 + private function getJSFilePath($postId)
80 + {
81 + return BITFORMS_CONTENT_DIR . "/form-scripts/$postId/bitform-js-$postId.js";
82 + }
83 +
84 + /**
85 + * Does this page's bundle need (re)generating?
86 + *
87 + * The DB flag alone is not enough: a page marked generated whose file was never written
88 + * (crashed generation, unwritable uploads dir) would enqueue a 404 forever. Conversely a
89 + * file that cannot be written must not make every request rebuild it, so a missing file
90 + * is retried on a backoff window rather than on every hit.
91 + *
92 + * @param int $postId
93 + * @param bool $regenerateScriptFlag DB-side verdict from regenerateScriptChecker()
94 + *
95 + * @return bool
96 + */
97 + private function needsScriptGeneration($postId, $regenerateScriptFlag)
98 + {
99 + if (file_exists($this->getJSFilePath($postId))) {
100 + return (bool) $regenerateScriptFlag;
101 + }
102 + $retryKey = 'bitforms_js_regen_' . $postId;
103 + if (get_transient($retryKey)) {
104 + return false;
105 + }
106 + set_transient($retryKey, 1, 5 * MINUTE_IN_SECONDS);
107 + return true;
108 + }
109 +
63 110 public function generateJs($formID = null, $entryID = null, $formType = null)
64 111 {
112 + // bitform-js-{postId}.js is disk-cached per post ID with no language key.
113 + // Display strings must stay out of it and travel in bf_globals per request.
65 114 // return true;
66 115 $isFormPreview = get_transient('bitform_form_preview');
67 116 if ($isFormPreview && !$formID) {
68 117 delete_transient('bitform_form_preview');
@@ -122,8 +171,10 @@
122 171 if (!$regenerateScriptFlag) {
123 172 $regenerateScriptFlag = $this->deleteUnusedFormPageIds($postId, $bfUniqFormIds);
124 173 }
125 174 $isJsGenerating = get_option('bitforms_frontend_js_generating');
175 + // The fast path also requires the cached bundle to exist on disk, not just be flagged in the DB.
176 + $regenerateScriptFlag = $this->needsScriptGeneration($postId, $regenerateScriptFlag);
126 177 if (!$regenerateScriptFlag && !$isJsGenerating && !empty($formIDs)) {
127 178 wp_enqueue_script('bit-form-all-script-test', $this->getJSFileSrc($postId), [], $formUpdateVersion, true);
128 179 return;
129 180 }
@@ -156,8 +207,13 @@
156 207 $frontendScriptGenObj->generateJsFile($formContents, $allFields, $contentIds, $postId, $formIDs, $previewMode);
157 208 if ('preview' === $previewMode) {
158 209 return;
159 210 }
211 + // Only mark the page as generated once the bundle is verifiably on disk; otherwise the
212 + // next request must retry generation instead of fast-pathing to a stale/missing file.
213 + if (!empty($bfUniqFormIds) && file_exists($this->getJSFilePath($postId))) {
214 + $this->markScriptGenerated($bfUniqFormIds, $postId);
215 + }
160 216 wp_enqueue_script('bit-form-all-script-test', $this->getJSFileSrc($postId), [], $formUpdateVersion, true);
161 217 }
162 218
163 219 private function deleteUnusedFormPageIds($postId, $formIDs)
@@ -173,9 +229,9 @@
173 229 );
174 230 $regenerateScriptFlag = false;
175 231 foreach ($forms as $form) {
176 232 $formId = $form->id;
177 - $generatedScriptPageIdsDecoded = json_decode($form->generated_script_page_ids, true);
233 + $generatedScriptPageIdsDecoded = json_decode((string) $form->generated_script_page_ids, true);
178 234 $generatedScriptPageIds = is_array($generatedScriptPageIdsDecoded) ? array_keys($generatedScriptPageIdsDecoded) : [];
179 235 if (!empty($generatedScriptPageIds) && !in_array($formId, $formIDs) && in_array($postId, $generatedScriptPageIds)) {
180 236 unset($generatedScriptPageIdsDecoded[$postId]);
181 237 if (empty($generatedScriptPageIdsDecoded)) {
@@ -203,10 +259,11 @@
203 259 if (!is_a($post, 'WP_Post') && !isset($post->ID)) {
204 260 return;
205 261 }
206 262 $postId = $post->ID;
207 - $regenerateScriptFlag = false;
208 - $formModel = new FormModel();
263 + // Read-only check. Marking the page as generated is deferred to markScriptGenerated(),
264 + // called only after the bundle file is actually written — marking here left the DB
265 + // saying "generated" while the file stayed stale whenever generation failed mid-way.
209 266 foreach ($formsIds as $formId) {
210 267 $formInstance = FormManager::getInstance($formId);
211 268 if (!$formInstance->isExist()) {
212 269 continue;
@@ -212,18 +269,35 @@
212 269 continue;
213 270 }
214 271 $generatedPages = $formInstance->getFormData('generated_script_page_ids');
215 272 if (empty($generatedPages)) {
216 - $regenerateScriptFlag = true;
217 - } elseif (is_object($generatedPages) && (!isset($generatedPages->{$postId}) || (isset($generatedPages->{$postId}) && false === $generatedPages->{$postId}))) {
218 - $regenerateScriptFlag = true;
273 + return true;
219 274 }
220 - if (!$regenerateScriptFlag) {
275 + if (is_object($generatedPages) && (!isset($generatedPages->{$postId}) || false === $generatedPages->{$postId})) {
276 + return true;
277 + }
278 + }
279 + return false;
280 + }
281 +
282 + private function markScriptGenerated($formsIds, $postId)
283 + {
284 + // Fetched via FormModel rather than FormManager: FormManager keeps its row in a static
285 + // property shared across instances, so after the render loop it holds the last form's
286 + // data regardless of which instance is asked.
287 + $formModel = new FormModel();
288 + foreach ($formsIds as $formId) {
289 + $form = $formModel->get(['generated_script_page_ids'], ['id' => $formId]);
290 + if (is_wp_error($form) || empty($form)) {
221 291 continue;
222 292 }
293 + $generatedPages = Utilities::jsonObj($form[0]->generated_script_page_ids ?? '');
223 294 if (!is_object($generatedPages)) {
224 295 $generatedPages = (object) [];
225 296 }
297 + if (!empty($generatedPages->{$postId})) {
298 + continue;
299 + }
226 300 $generatedPages->{$postId} = true;
227 301 $formModel->update(
228 302 [
229 303 'generated_script_page_ids' => \wp_json_encode($generatedPages)
@@ -232,9 +306,8 @@
232 306 'id' => $formId,
233 307 ]
234 308 );
235 309 }
236 - return $regenerateScriptFlag;
237 310 }
238 311
239 312 private function addInlineScript($code, $handle = '', $position = 'after')
240 313 {
@@ -279,9 +352,9 @@
279 352
280 353 return $fields;
281 354 }
282 355
283 - private function executeOnUserInput($formID, $shortCodeCounter, $fields)
356 + private function executeOnUserInput($formID, $shortCodeCounter, $workFlowRunType = 'create')
284 357 {
285 358 $FrontendFormManager = FrontendFormManager::getInstance($formID, $shortCodeCounter);
286 359 $previousValue = $this->getValuesFromQueryParams();
287 360 $formContent = $FrontendFormManager->getFormContentWithValue($previousValue);
@@ -287,9 +360,9 @@
287 360 $formContent = $FrontendFormManager->getFormContentWithValue($previousValue);
288 361 $customCodesExist = strpos(FrontEndScriptGenerator::getCustomCodes($formID)['JavaScript'], 'bfVars');
289 362 if ($customCodesExist || (!empty($formContent->workFlowExist) && !empty($formContent->workFlowExist->oninput))) {
290 363 $workFlowRunHelper = new WorkFlow($formID);
291 - return $workFlowRunHelper->executeOnUserInput('create', $fields);
364 + return $workFlowRunHelper->executeOnUserInput($workFlowRunType);
292 365 }
293 366 }
294 367
295 368 private function getValuesFromQueryParams()
@@ -296,21 +369,20 @@
296 369 {
297 370 // Read-only: query string parsed to pre-fill form fields. Values are sanitized per field before use.
298 371 $queryParamsValue = [];
299 372 if (isset($_SERVER['QUERY_STRING']) && !empty($_SERVER['QUERY_STRING'])) {
300 - $reqField = sanitize_text_field(wp_unslash($_SERVER['QUERY_STRING']));
373 + $reqField = wp_unslash($_SERVER['QUERY_STRING']);
301 374 foreach (explode('&', $reqField) as $keyValue) {
302 - // $pattern = '/([a-zA-Z0-9])([a-zA-Z])\=+/';
303 - $pattern = '/([^.]+)=(.*?)([^.]+)/';
304 - $matches = preg_match($pattern, $keyValue, $matchFormat);
305 - if ($matches) {
375 + if (false !== strpos($keyValue, '=')) {
306 376 list($field, $value) = explode('=', $keyValue, 2);
307 377
308 378 if (!trim($value)) {
309 379 continue;
310 380 }
311 -
312 - $queryParamsValue[$field][] = sanitize_text_field(urldecode($value));
381 + $field = sanitize_text_field(urldecode($field));
382 + if (!empty($field)) {
383 + $queryParamsValue[$field][] = sanitize_text_field(urldecode($value));
384 + }
313 385 }
314 386 }
315 387 }
316 388
@@ -345,10 +417,13 @@
345 417 /* translators: %s: form ID */
346 418 return sprintf(__('#%s no. Form doesn\'t exists', 'bit-form'), $formID);
347 419 }
348 420
349 - // Add-ons may detect whether the current visitor is resuming an abandoned entry.
350 - $isAbandoned = (bool) apply_filters('bitform_is_abandoned_entry', false, $formID, $entryId, $atts);
421 + // Add-ons return the id of the abandoned entry this visitor can resume. A bare
422 + // true (older Pro) carries no id, so nothing is restored rather than entry #1.
423 + $abandonedEntryId = apply_filters('bitform_is_abandoned_entry', false, $formID, $entryId, $atts);
424 + $abandonedEntryId = (!is_bool($abandonedEntryId) && is_numeric($abandonedEntryId)) ? (int) $abandonedEntryId : 0;
425 + $isAbandoned = $abandonedEntryId > 0;
351 426
352 427 FrontendHelpers::setBfFrontendFormIds($formID);
353 428 $bfFrontendFormIds = FrontendHelpers::$bfFrontendFormIds;
354 429 $shortCodeCounter = count($bfFrontendFormIds);
@@ -368,9 +443,13 @@
368 443 }
369 444
370 445 // Read-only: password reset token from URL for display-time validation. No state written until form is submitted.
371 446 if (!empty($_GET['token']) && !empty($_GET['id'])) {
372 - $this->validPassowordResetToken(sanitize_text_field(wp_unslash($_GET['token'])), sanitize_text_field(wp_unslash($_GET['id'])), $formID);
447 + $resetNotice = $this->invalidPasswordResetNotice(sanitize_text_field(wp_unslash($_GET['token'])), sanitize_text_field(wp_unslash($_GET['id'])), $formID);
448 + if ('' !== $resetNotice) {
449 + ob_end_clean();
450 + return $resetNotice;
451 + }
373 452 }
374 453
375 454 $previousValue = $this->getValuesFromQueryParams();
376 455 $errorMessages = []; // delete
@@ -410,9 +489,9 @@
410 489 $fields = apply_filters('bitform_filter_before_workflow_onload_fields', $fields, $formID);
411 490 $fields = $this->triggerWorkflowOnLoad($formID, $shortCodeCounter, $fields, $workFlowRunType);
412 491 $fields = apply_filters('bitform_filter_after_workflow_onload_fields', $fields, $formID);
413 492 do_action('bitform_onload_fields', $fields, $formID);
414 - $workFlowreturnedOnUserInput = $this->executeOnUserInput($formID, $shortCodeCounter, $fields);
493 + $workFlowreturnedOnUserInput = $this->executeOnUserInput($formID, $shortCodeCounter, $workFlowRunType);
415 494
416 495 // test for form before remove
417 496 $noLabelFieldTypes = ['decision-box', 'gdpr', 'html', 'shortcode', 'button', 'paypal', 'razorpay', 'recaptcha', 'turnstile', 'hcaptcha', 'stripe', 'spacer'];
418 497 foreach ($fields as $fldKey => $field) {
@@ -433,12 +512,14 @@
433 512 $FrontendFormManager->getCaptchaSettings()
434 513 && !is_null($integration->integration_type)
435 514 && 'gReCaptcha' === $integration->integration_type
436 515 ) {
437 - $integrationDetails = json_decode($integration->integration_details);
438 - $integrationDetails->id = $integration->id;
439 - $reCAPTCHA = $integrationDetails;
440 - $reCAPTCHAVersion = 'v2';
516 + $integrationDetails = Utilities::jsonObj($integration->integration_details);
517 + if ($integrationDetails) {
518 + $integrationDetails->id = $integration->id;
519 + $reCAPTCHA = $integrationDetails;
520 + $reCAPTCHAVersion = 'v2';
521 + }
441 522 }
442 523
443 524 if (
444 525 $FrontendFormManager->getTurnstileSettings()
@@ -444,10 +525,10 @@
444 525 $FrontendFormManager->getTurnstileSettings()
445 526 && !is_null($integration->integration_type)
446 527 && 'turnstileCaptcha' === $integration->integration_type
447 528 ) {
448 - $integrationDetails = json_decode($integration->integration_details);
449 - $turnstileSiteKey = $integrationDetails->siteKey;
529 + $integrationDetails = Utilities::jsonObj($integration->integration_details);
530 + $turnstileSiteKey = $integrationDetails->siteKey ?? '';
450 531 }
451 532
452 533 if (
453 534 $FrontendFormManager->isFieldTypeExist('hcaptcha')
@@ -453,18 +534,20 @@
453 534 $FrontendFormManager->isFieldTypeExist('hcaptcha')
454 535 && !is_null($integration->integration_type)
455 536 && 'hcaptcha' === $integration->integration_type
456 537 ) {
457 - $integrationDetails = json_decode($integration->integration_details);
458 - $hCaptchaSiteKey = $integrationDetails->siteKey;
538 + $integrationDetails = Utilities::jsonObj($integration->integration_details);
539 + $hCaptchaSiteKey = $integrationDetails->siteKey ?? '';
459 540 }
460 541
461 542 if ($captchaV3Settings) {
462 543 if (!is_null($integration->integration_type) && 'gReCaptchaV3' === $integration->integration_type) {
463 - $integrationDetails = json_decode($integration->integration_details);
464 - $integrationDetails->id = $integration->id;
465 - $reCAPTCHA = $integrationDetails;
466 - $reCAPTCHAVersion = 'v3';
544 + $integrationDetails = Utilities::jsonObj($integration->integration_details);
545 + if ($integrationDetails) {
546 + $integrationDetails->id = $integration->id;
547 + $reCAPTCHA = $integrationDetails;
548 + $reCAPTCHAVersion = 'v3';
549 + }
467 550 }
468 551 }
469 552 }
470 553 }
@@ -497,19 +580,20 @@
497 580 $paymentIntegration = $integrationHandler->getAIntegration($fldData->payIntegID);
498 581 if (is_wp_error($paymentIntegration)) {
499 582 continue;
500 583 }
584 + $paymentIntegrationRow = Utilities::firstRow($paymentIntegration);
501 585 if ('paypal' === $fldData->typ) {
502 - $integrationDetails = json_decode($paymentIntegration[0]->integration_details);
503 - $clientID = $integrationDetails->clientID;
586 + $integrationDetails = Utilities::jsonObj($paymentIntegrationRow->integration_details ?? '');
587 + $clientID = $integrationDetails->clientID ?? '';
504 588 $fields->{$fldKey}->clientId = $clientID;
505 589 } elseif ('razorpay' === $fldData->typ) {
506 - $integrationDetails = json_decode($paymentIntegration[0]->integration_details);
507 - $clientID = $integrationDetails->apiKey;
590 + $integrationDetails = Utilities::jsonObj($paymentIntegrationRow->integration_details ?? '');
591 + $clientID = $integrationDetails->apiKey ?? '';
508 592 $fields->{$fldKey}->clientId = $clientID;
509 593 } elseif ('stripe' === $fldData->typ) {
510 - $integrationDetails = json_decode($paymentIntegration[0]->integration_details);
511 - $publishableKey = $integrationDetails->publishableKey;
594 + $integrationDetails = Utilities::jsonObj($paymentIntegrationRow->integration_details ?? '');
595 + $publishableKey = $integrationDetails->publishableKey ?? '';
512 596 $fields->{$fldKey}->publishableKey = $publishableKey;
513 597 }
514 598 }
515 599 }
@@ -529,9 +613,9 @@
529 613 'appID' => "bitforms_{$formID}",
530 614 'GCLID' => $FrontendFormManager->isGCLIDEnabled(),
531 615 'assetUrl' => BITFORMS_ASSET_URI,
532 616 'onfieldCondition' => !empty($workFlowreturnedOnUserInput['onfield_input_conditions']) ? $workFlowreturnedOnUserInput['onfield_input_conditions'] : false,
533 - 'smartTags' => SmartTags::smartTags(SmartTags::getPostUserData()),
617 + 'smartTags' => $this->buildFrontendSmartTags($formID, $workFlowreturnedOnUserInput, $fields),
534 618 'paymentCallbackUrl' => get_rest_url() . 'bitform/v1/payments/razorpay',
535 619 'gRecaptchaSiteKey' => !empty($reCAPTCHA->siteKey) ? $reCAPTCHA->siteKey : null,
536 620 'gRecaptchaVersion' => !empty($reCAPTCHAVersion) ? $reCAPTCHAVersion : null,
537 621 'turnstileSiteKey' => !empty($turnstileSiteKey) ? $turnstileSiteKey : null,
@@ -539,8 +623,9 @@
539 623 ];
540 624
541 625 if ($entryId) {
542 626 $bitFormFrontArr['entryId'] = $entryId;
627 + self::markResponseUncacheable();
543 628 }
544 629
545 630 if (isset($additional->enabled->validateFocusLost)) {
546 631 $bitFormFrontArr['validateFocusLost'] = true;
@@ -545,12 +630,19 @@
545 630 if (isset($additional->enabled->validateFocusLost)) {
546 631 $bitFormFrontArr['validateFocusLost'] = true;
547 632 }
548 633
549 - if (!empty($isAbandoned)) {
550 - $bitFormFrontArr['oldValues'] = $this->getFieldsValue($formID, $isAbandoned);
634 + if ($isAbandoned) {
635 + // One visitor's typed values, so this response must not be page-cached.
636 + $bitFormFrontArr['oldValues'] = $this->getFieldsValue($formID, $abandonedEntryId);
637 + self::markResponseUncacheable();
551 638 if (empty($entryId)) {
552 - $bitFormFrontArr['entryId'] = $isAbandoned;
639 + // Later draft saves must update this entry, not start a new one each time.
640 + $bitFormFrontArr['entryId'] = $abandonedEntryId;
641 + $entryToken = Cryptography::encrypt($abandonedEntryId, Helpers::getBitformSalt());
642 + if (is_string($entryToken)) {
643 + $bitFormFrontArr['entryToken'] = $entryToken;
644 + }
553 645 }
554 646 }
555 647
556 648 $formInfo = $FrontendFormManager->getFormInfo();
@@ -585,27 +677,31 @@
585 677 $layout = wp_json_encode($layout);
586 678 $buttons = wp_json_encode($buttons);
587 679 $frontArr = wp_json_encode($bitFormFrontArr);
588 680
589 - $bfGlobals = sprintf('
590 - if(!window.bf_globals) {
591 - window.bf_globals = {}
592 - } if(!window.bf_globals.%1$s) {
593 - window.bf_globals.%1$s = {}
681 + $bfGlobals = sprintf('
682 + if(!window.bf_globals) {
683 + window.bf_globals = {}
684 + } if(!window.bf_globals.%1$s) {
685 + window.bf_globals.%1$s = {}
594 686 }
595 - if(document.getElementById("%1$s")) {
596 - window.bf_globals.%1$s = {
597 - ...window.bf_globals.%1$s,
598 - ...%2$s
599 - };
600 - }', $FormIdentifier, $frontArr);
687 + window.bf_globals.%1$s = {
688 + ...window.bf_globals.%1$s,
689 + ...%2$s
690 + };
691 + if (typeof window.bitformInit === "function") { window.bitformInit("%1$s"); }', $FormIdentifier, $frontArr);
601 692
693 + // Inert copy of the config. Optimizers only rewrite executable scripts, so
694 + // this survives and travels with the markup; the runtime hydrates from it
695 + // whenever bf_globals is missing.
696 + $configTag = self::buildFormConfigTag($FormIdentifier, $bitFormFrontArr);
697 +
602 698 if ('conversational' === $formType
603 699 && isset($formContent->formInfo->conversationalSettings->enable)
604 700 && $formContent->formInfo->conversationalSettings->enable) {
605 - $html = $FrontendFormManager->conversationalFormView($fields, $file, $errorMessages);
701 + $html = $FrontendFormManager->conversationalFormView($fields, $file, $errorMessages, null, !empty($entryId));
606 702 } else {
607 - $html = $FrontendFormManager->formView($fields, $file, $errorMessages);
703 + $html = $FrontendFormManager->formView($fields, $file, $errorMessages, null, !empty($entryId));
608 704 }
609 705
610 706 // if form preview then return html otherwise echo with output buffer
611 707 if ($formPreview) {
@@ -613,8 +709,9 @@
613 709 $formViewObject = new \stdClass();
614 710 $formViewObject->html = $html;
615 711 $formViewObject->font = $font;
616 712 $formViewObject->bfGlobals = $bfGlobals;
713 + $formViewObject->configTag = $configTag;
617 714 $formViewObject->formContent = $formContent;
618 715 return $formViewObject;
619 716 }
620 717
@@ -621,13 +718,110 @@
621 718 $bfGlobalsHandle = 'bitform-bf-globals-' . sanitize_key($FormIdentifier);
622 719 $this->addInlineScript($bfGlobals, $bfGlobalsHandle, 'after');
623 720 $this->emitShowPickerBridge();
624 721
722 + // Printed outside wp_kses rather than allowing <script> in form markup.
723 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built by buildFormConfigTag(), JSON_HEX_* escaped.
724 + echo $configTag;
625 725 echo wp_kses(trim($html), EscapingHelper::getFormAllowedHtml($formContent));
626 726 return ob_get_clean();
627 727 }
628 728
629 729 /**
730 + * Keep per-visitor config (oldValues, entryId) out of full-page caches.
731 + *
732 + * @return void
733 + */
734 + public static function markResponseUncacheable()
735 + {
736 + // DONOTCACHEPAGE does the work; caches read it at shutdown. Rendering
737 + // usually runs after headers are sent, so nocache_headers() is a bonus.
738 + if (!defined('DONOTCACHEPAGE')) {
739 + define('DONOTCACHEPAGE', true);
740 + }
741 + if (!headers_sent() && function_exists('nocache_headers')) {
742 + nocache_headers();
743 + }
744 + }
745 +
746 + /**
747 + * Build the inert JSON config block for a rendered form.
748 + *
749 + * JSON_HEX_* escapes < > & as \u00XX so no field value can close the script
750 + * element or inject markup.
751 + *
752 + * @param string $formIdentifier
753 + * @param array $config
754 + *
755 + * @return string
756 + */
757 + public static function buildFormConfigTag($formIdentifier, $config)
758 + {
759 + $json = wp_json_encode($config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
760 + if (false === $json) {
761 + return '';
762 + }
763 + // Some optimizers wrap any inline <script>, including application/json,
764 + // in DOMContentLoaded boilerplate that corrupts the JSON. These attributes
765 + // make the common ones skip it; the JS side also salvage-parses.
766 + return sprintf(
767 + '<script type="application/json" class="bf-form-config" id="bf-config-%1$s" data-bf-form="%1$s" data-no-optimize="1" data-no-defer="1" data-no-minify="1" data-cfasync="false" nowprocket>%2$s</script>',
768 + esc_attr($formIdentifier),
769 + $json
770 + );
771 + }
772 +
773 + /**
774 + * Build the smart-tag map exposed to the browser in window.bf_globals[formId].smartTags.
775 + *
776 + * Security: the legacy code shipped the ENTIRE ~43-tag map to every visitor, leaking
777 + * PII (admin/user/author email) and freezing per-visitor request data (IP, time,
778 + * browser, referer) into cacheable HTML. We now emit ONLY tags that are (a) actually
779 + * referenced by this form's client-evaluated surfaces — conditional logic, payment
780 + * notes, admin custom JS — AND (b) flagged frontend-safe in the registry (static/post
781 + * context only). Sensitive (identity) and request/visitor tags are never emitted; they
782 + * resolve server-side at submit time instead.
783 + *
784 + * @param int|string $formID
785 + * @param mixed $workflowConditions on-field input conditions (client-evaluated)
786 + * @param mixed $fields form fields object (carries payment notes, etc.)
787 + * @return array<string,string>
788 + */
789 + private function buildFrontendSmartTags($formID, $workflowConditions, $fields)
790 + {
791 + // Haystack = only surfaces the browser actually evaluates against smartTags.
792 + $haystack = wp_json_encode($workflowConditions) . ' ' . wp_json_encode($fields);
793 + $customJs = FrontEndScriptGenerator::getCustomCodes($formID)['JavaScript'];
794 + if (is_string($customJs) && '' !== $customJs) {
795 + $haystack .= ' ' . $customJs;
796 + }
797 +
798 + $ctx = SmartTags::getPostUserData();
799 + $frontendSmartTags = [];
800 + $referenced = [];
801 + foreach (SmartTags::smartTagFieldKeys() as $key) {
802 + if (!SmartTagRegistry::isFrontendExposable($key)) {
803 + continue; // identity / request / param tags never travel to the browser
804 + }
805 + // Match '${' . key prefix so keys containing spaces/slashes/commas are handled.
806 + if (false !== strpos($haystack, '${' . $key)) {
807 + $referenced[] = $key;
808 + $frontendSmartTags[$key] = SmartTagRegistry::resolve($key, $ctx);
809 + }
810 + }
811 +
812 + /**
813 + * Escape hatch: a site that genuinely needs an extra tag client-side can opt it
814 + * back in explicitly here, rather than core shipping everything by default.
815 + *
816 + * @param array<string,string> $frontendSmartTags resolved frontend-safe smart tags
817 + * @param int|string $formID
818 + * @param string[] $referenced keys detected in client surfaces
819 + */
820 + return apply_filters('bitform_frontend_smarttags', $frontendSmartTags, $formID, $referenced);
821 + }
822 +
823 + /**
630 824 * Delegated listener that opens the native picker on date/time inputs marked
631 825 * with data-bf-show-picker. Replaces the legacy hardcoded onclick attribute.
632 826 * Registered as inline script once per request via wp_add_inline_script so
633 827 * the markup never travels through wp_kses().
@@ -642,8 +836,15 @@
642 836 $code = 'if(!window.__bfShowPickerBound){window.__bfShowPickerBound=true;document.addEventListener("click",function(e){var t=e.target;if(t&&t.matches&&t.matches("input[data-bf-show-picker=\"1\"]")&&typeof t.showPicker==="function"){try{t.showPicker();}catch(_){}}});}';
643 837 $this->addInlineScript($code, 'bitform-show-picker-bridge', 'after');
644 838 }
645 839
840 + /**
841 + * Does this form row exist?
842 + *
843 + * @param int $formID
844 + *
845 + * @return bool
846 + */
646 847 private function isExist($formID)
647 848 {
648 849 $formModel = new FormModel();
649 850 $form = $formModel->get(
@@ -653,12 +854,32 @@
653 854 [
654 855 'id' => $formID,
655 856 ]
656 857 );
657 - if (!is_wp_error($form)) {
658 - return true;
858 +
859 + if (is_wp_error($form)) {
860 + if ('result_empty' !== $form->get_error_code()) {
861 + Log::debug_log([
862 + 'message' => 'Form lookup failed — reported to the visitor as a missing form',
863 + 'formID' => $formID,
864 + 'code' => $form->get_error_code(),
865 + 'error' => $form->get_error_message(),
866 + ]);
867 + }
868 +
869 + return false;
659 870 }
660 - return false;
871 +
872 + if (empty($form)) {
873 + Log::debug_log([
874 + 'message' => 'Form lookup returned no rows without an error (is the form table present?)',
875 + 'formID' => $formID,
876 + ]);
877 +
878 + return false;
879 + }
880 +
881 + return true;
661 882 }
662 883
663 884 private function getFieldsValue($formID, $entryID)
664 885 {
@@ -688,8 +909,12 @@
688 909 } else {
689 910 $metaVal = implode(BITFORMS_BF_SEPARATOR, $metaVal);
690 911 }
691 912 }
913 + // A restored draft never gets the password back; the visitor types it again.
914 + if (isset($formFields[$metaKey]['type']) && 'password' === $formFields[$metaKey]['type']) {
915 + continue;
916 + }
692 917 if (!isset($fldsData->{$metaKey})) {
693 918 $fldsData->{$metaKey} = '';
694 919 }
695 920 $fldsData->{$metaKey} = $metaVal;
@@ -714,8 +939,23 @@
714 939 'bitforms_form_entry_id' => $entryID,
715 940 ]
716 941 );
717 942 if (!is_wp_error($metaValues)) {
943 + $urlQuery = wp_parse_url(FileDownloadProvider::getBaseDownloadURL(), PHP_URL_QUERY);
944 + $baseDLURL = FileDownloadProvider::getBaseDownloadURL();
945 + $baseDLURL = empty($urlQuery) ? $baseDLURL . '?' : $baseDLURL . '&';
946 + $baseDLURL .= "formID={$formID}&entryID={$entryID}";
947 +
948 + foreach ($fields as $field) {
949 + if ('file-up' === $field->typ || 'advanced-file-up' === $field->typ) {
950 + if (!isset($field->config)) {
951 + $field->config = (object) [];
952 + } elseif (is_array($field->config)) {
953 + $field->config = (object) $field->config;
954 + }
955 + $field->config->baseDLURL = $baseDLURL;
956 + }
957 + }
718 958 foreach ($metaValues as $metaValue) {
719 959 $metaKey = $metaValue->meta_key;
720 960 $metaVal = $metaValue->meta_value;
721 961 // if meta value is array then convert to string
@@ -727,18 +967,25 @@
727 967 } else {
728 968 $metaVal = implode(BITFORMS_BF_SEPARATOR, $metaVal);
729 969 }
730 970 }
971 + if (property_exists($fields, $metaKey) && 'password' === $fields->{$metaKey}->typ) {
972 + // Never hand a stored password back to the browser; blank keeps it on update.
973 + $fields->{$metaKey}->val = '';
974 + if ('' !== (string) $metaValue->meta_value && isset($fields->{$metaKey}->valid) && is_object($fields->{$metaKey}->valid)) {
975 + $fields->{$metaKey}->valid->req = false;
976 + }
977 + continue;
978 + }
731 979 if (property_exists($fields, $metaKey)) {
732 980 $fields->{$metaKey}->val = $metaVal;
733 981 if ('file-up' === $fields->{$metaKey}->typ || 'advanced-file-up' === $fields->{$metaKey}->typ) {
734 982 $fields->{$metaKey}->val = $metaValue->meta_value;
735 983 $fields->{$metaKey}->config->oldFiles = $metaValue->meta_value;
736 - $urlQuery = wp_parse_url(FileDownloadProvider::getBaseDownloadURL(), PHP_URL_QUERY);
737 - $baseDLURL = FileDownloadProvider::getBaseDownloadURL();
738 - $baseDLURL = empty($urlQuery) ? $baseDLURL . '?' : $baseDLURL . '&';
739 - $fields->{$metaKey}->config->baseDLURL = $baseDLURL . "formID={$formID}&entryID={$entryID}";
740 984 }
985 + if ('signature' === $fields->{$metaKey}->typ) {
986 + $this->setOldSignature($fields->{$metaKey}, $formID, $entryID, $metaValue->meta_value);
987 + }
741 988 }
742 989 }
743 990 }
744 991 return $fields;
@@ -743,8 +990,51 @@
743 990 }
744 991 return $fields;
745 992 }
746 993
994 + /** Give the signature field its stored signature: a data URI to redraw, and the name it posts back as `_old`. */
995 + private function setOldSignature($field, $formID, $entryID, $storedValue)
996 + {
997 + $fileName = is_string($storedValue) ? trim($storedValue) : '';
998 + $decoded = json_decode($fileName, true);
999 + if (is_array($decoded)) {
1000 + $fileName = empty($decoded) ? '' : trim((string) reset($decoded));
1001 + }
1002 + // signature-failed.png means the stored signature was never usable.
1003 + if ('' === $fileName || 'signature-failed.png' === $fileName) {
1004 + return;
1005 + }
1006 + $fileName = sanitize_file_name($fileName);
1007 + if (!isset($field->config)) {
1008 + $field->config = (object) [];
1009 + } elseif (is_array($field->config)) {
1010 + $field->config = (object) $field->config;
1011 + }
1012 + $field->config->oldSignatureFile = $fileName;
1013 +
1014 + $filePath = FileHandler::getEntriesFileUploadDir($formID, $entryID) . DIRECTORY_SEPARATOR . $fileName;
1015 + if (!is_file($filePath) || !is_readable($filePath)) {
1016 + return;
1017 + }
1018 + // The types getSignatureFilePath() writes; wp_check_filetype() reports none for SVG.
1019 + $signatureMimeTypes = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'svg' => 'image/svg+xml'];
1020 + $extension = strtolower((string) pathinfo($fileName, PATHINFO_EXTENSION));
1021 + if (!isset($signatureMimeTypes[$extension])) {
1022 + return;
1023 + }
1024 + $mimeType = $signatureMimeTypes[$extension];
1025 + // A hand-drawn signature is a few KB; a larger file is not worth inlining.
1026 + if (filesize($filePath) > self::MAX_INLINE_SIGNATURE_BYTES) {
1027 + return;
1028 + }
1029 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents -- local upload dir read, inlined as a data URI for the signature pad.
1030 + $contents = file_get_contents($filePath);
1031 + if (false === $contents || '' === $contents) {
1032 + return;
1033 + }
1034 + $field->config->oldSignature = 'data:' . $mimeType . ';base64,' . base64_encode($contents);
1035 + }
1036 +
747 1037 public function loadAssets($formID = 0, $fromType = 'classic')
748 1038 {
749 1039 $bfUniqFormIds = FrontendHelpers::getAllFormIdsInPage();
750 1040 $isPageBuilder = FrontendHelpers::$isPageBuilder;
@@ -768,9 +1058,9 @@
768 1058 $formUpdateVersion = get_option('bitform_form_update_version');
769 1059 if (!wp_style_is('bitform-style-' . $newFormId) && is_readable(BITFORMS_CONTENT_DIR . '/form-styles/bitform-' . $newFormId . '.css')) {
770 1060 wp_enqueue_style(
771 1061 'bitform-style-' . $newFormId,
772 - BITFORMS_UPLOAD_BASE_URL . "/form-styles/bitform-{$newFormId}.css",
1062 + FrontendHelpers::styleSrc("form-styles/bitform-{$newFormId}.css"),
773 1063 [],
774 1064 $formUpdateVersion
775 1065 );
776 1066 if ($isPageBuilder) {
@@ -780,9 +1070,9 @@
780 1070 }
781 1071 if (!wp_style_is('bitform-style-custom-' . $formID) && is_readable(BITFORMS_CONTENT_DIR . '/form-styles/bitform-custom-' . $formID . '.css')) {
782 1072 wp_enqueue_style(
783 1073 'bitform-style-custom-' . $formID,
784 - BITFORMS_UPLOAD_BASE_URL . "/form-styles/bitform-custom-{$formID}.css",
1074 + FrontendHelpers::styleSrc("form-styles/bitform-custom-{$formID}.css"),
785 1075 [],
786 1076 $formUpdateVersion
787 1077 );
788 1078 if ($isPageBuilder) {
@@ -795,9 +1085,9 @@
795 1085 if (!wp_style_is('bitform-conversational-style-' . $formID) &&
796 1086 is_readable(BITFORMS_CONTENT_DIR . "/form-styles/bitform-conversational-{$formID}.css")) {
797 1087 wp_enqueue_style(
798 1088 'bitform-conversational-style',
799 - BITFORMS_UPLOAD_BASE_URL . "/form-styles/bitform-conversational-{$formID}.css",
1089 + FrontendHelpers::styleSrc("form-styles/bitform-conversational-{$formID}.css"),
800 1090 [],
801 1091 $formUpdateVersion
802 1092 );
803 1093 }