PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.3.1
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.3.1
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 2.10.1 All 138 releases
bit-form / includes / Core / Form / FormManager.php

FormManager.php in Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder 3.3.1, at includes/Core/Form/FormManager.php

2,108 lines 79.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Get set Form,fields
5 */
6
7 namespace BitCode\BitForm\Core\Form;
8
9 /**
10 * FrontendFormManager class
11 */
12
13 use BitCode\BitForm\Admin\Form\CustomFieldHandler;
14 use BitCode\BitForm\Admin\Form\Helpers;
15 use BitCode\BitForm\Core\Database\FormEntryLogModel;
16 use BitCode\BitForm\Core\Database\FormEntryMetaModel;
17 use BitCode\BitForm\Core\Database\FormEntryModel;
18 use BitCode\BitForm\Core\Database\FormModel;
19 use BitCode\BitForm\Core\Integration\IntegrationHandler;
20 use BitCode\BitForm\Core\Messages\SuccessMessageHandler;
21 use BitCode\BitForm\Core\Util\FieldValueHandler;
22 use BitCode\BitForm\Core\Util\FileHandler;
23 use BitCode\BitForm\Core\Util\FrontendHelpers;
24 use BitCode\BitForm\Core\Util\IpTool;
25 use BitCode\BitForm\Core\Util\Log;
26 use BitCode\BitForm\Core\Util\Translation\FormContentTranslator;
27 use BitCode\BitForm\Core\Util\Utilities;
28 use BitCode\BitForm\Core\WorkFlow\WorkFlow;
29 use BitCode\BitForm\Core\WorkFlow\WorkFlowHandler;
30 use BitCode\BitForm\enshrined\svgSanitize\Sanitizer;
31 use stdClass;
32 use WP_Error;
33
34 class FormManager
35 {
36 // Cache for instances of FormManager by form_id
37 private static $formManagerCache = [];
38
39 /**
40 * Object-cache group for translated form_content.
41 */
42 public const TRANSLATION_CACHE_GROUP = 'bitform_translation';
43
44 /**
45 * Request-scoped memo of translated form_content JSON, keyed "formId|lang".
46 * Static on purpose, unlike $form: the key carries the form id and the
47 * language, so an entry cannot be read back for the wrong form.
48 *
49 * @var array<string,string>
50 */
51 private static $translatedContentCache = [];
52
53 // Per-instance, never static: a static is shared with every subclass and overwritten by the
54 // last-constructed manager, so cached instances would read another form's row.
55 protected $form;
56 protected $formModel;
57 protected $form_id;
58 private $_has_upload;
59 private $_field_label;
60 private $_fields;
61 private $_repeaterFields;
62 private $_work_flows;
63 private $_conf_messages;
64 private $_atomic_class_map;
65 private $_saveFormAsDraft;
66
67 public function __construct($form_id)
68 {
69 $this->form_id = $form_id;
70 $this->formModel = new FormModel();
71
72 $this->form = $this->formModel->get(
73 [
74 'id',
75 'form_content',
76 'form_name',
77 'created_at',
78 'views',
79 'entries',
80 'status',
81 'builder_helper_state',
82 'atomic_class_map',
83 'generated_script_page_ids',
84 ],
85 [
86 'id' => $form_id,
87 ]
88 );
89 if (!is_wp_error($this->form)) {
90 $atomicClassMap = isset($this->form[0]->atomic_class_map) ? $this->form[0]->atomic_class_map : '';
91 $this->_atomic_class_map = json_decode((string) $atomicClassMap);
92 $bfMultipleFormsExists = FrontendHelpers::hasMultipleForms();
93 if ($bfMultipleFormsExists && isset($this->_atomic_class_map->atomic_class_map_with_form_id)) {
94 $this->_atomic_class_map = $this->_atomic_class_map->atomic_class_map_with_form_id;
95 } elseif (isset($this->_atomic_class_map->atomic_class_map)) {
96 $this->_atomic_class_map = $this->_atomic_class_map->atomic_class_map;
97 }
98 } else {
99 // Log the error if needed
100 Log::debug_log('Error fetching form: ' . "Form Id = ($form_id)" . $this->form->get_error_message());
101 }
102 }
103
104 /**
105 * Test hook: clears the request memo of translated form_content.
106 */
107 public static function resetTranslationMemoForTesting()
108 {
109 self::$translatedContentCache = [];
110 }
111
112 // Static method to get the instance of FormManager
113 public static function getInstance($form_id)
114 {
115 // Check if an instance of FormManager is already cached
116 if (!isset(self::$formManagerCache[$form_id])) {
117 // Create and cache the FormManager instance if not found
118 self::$formManagerCache[$form_id] = new self($form_id);
119 }
120
121 // Return the cached instance
122 return self::$formManagerCache[$form_id];
123 }
124
125 public function isExist()
126 {
127 return (!$this->form || is_wp_error($this->form)) ? false : true;
128 }
129
130 public function checkStatus()
131 {
132 // Fail closed for a missing form: $this->form is a WP_Error when the lookup
133 // found nothing, and the unauthenticated submit endpoints call this before
134 // isExist() — indexing it there fatals on any unknown form id.
135 if (!$this->isExist()) {
136 return false;
137 }
138 return '1' === $this->form[0]->status ? true : false;
139 }
140
141 public function getFieldsContent()
142 {
143 // Raw on purpose: FormFieldValidator's allowed-option lookup must compare
144 // against the source-language config whatever the request language is.
145 return $this->form[0]->form_content;
146 }
147
148 /**
149 * form_content JSON with display strings passed through
150 * `bitform_translate_form_string`; the raw string when nothing is hooked.
151 * Never persisted back to the row — stored form_content stays source-language.
152 * AdminFormManager overrides this to always return raw.
153 *
154 * @return string
155 */
156 protected function getEffectiveFormContentJson()
157 {
158 // $this->form is a WP_Error when the form was not found; indexing it here
159 // fataled for callers that pass an unknown or empty form id
160 $raw = $this->isExist() ? ($this->form[0]->form_content ?? '') : '';
161 $raw = is_string($raw) ? $raw : '';
162 if ('' === $raw || !has_filter('bitform_translate_form_string')) {
163 return $raw;
164 }
165
166 $rowId = isset($this->form[0]->id) ? (int) $this->form[0]->id : (int) $this->form_id;
167 $lang = (string) apply_filters('bitform_current_language', '', $rowId);
168 $memoKey = $rowId . '|' . $lang;
169 if (isset(self::$translatedContentCache[$memoKey])) {
170 return self::$translatedContentCache[$memoKey];
171 }
172
173 // Content-addressed, so a form save can never serve a stale entry. Edits on
174 // the translation side do not change the hash — the TTL bounds those.
175 $ttl = (int) apply_filters('bitform_translation_cache_ttl', HOUR_IN_SECONDS, $rowId, $lang);
176 $cacheKey = $ttl > 0 ? "form-{$rowId}-{$lang}-" . md5($raw) : '';
177
178 if ('' !== $cacheKey) {
179 $cached = wp_cache_get($cacheKey, self::TRANSLATION_CACHE_GROUP);
180 if (is_string($cached) && '' !== $cached) {
181 self::$translatedContentCache[$memoKey] = $cached;
182 return $cached;
183 }
184 }
185
186 $translated = $raw;
187 $decoded = Utilities::jsonObj($raw);
188 if ($decoded instanceof stdClass) {
189 FormContentTranslator::translate($decoded, $rowId);
190 $encoded = wp_json_encode($decoded);
191 $translated = is_string($encoded) ? $encoded : $raw;
192 }
193
194 if ('' !== $cacheKey) {
195 wp_cache_set($cacheKey, $translated, self::TRANSLATION_CACHE_GROUP, $ttl);
196 }
197 self::$translatedContentCache[$memoKey] = $translated;
198
199 return $translated;
200 }
201
202 public function getFont()
203 {
204 $atomicClassMap = $this->_atomic_class_map;
205 $font = isset($atomicClassMap->font) ? $atomicClassMap->font : '';
206 return $font;
207 }
208
209 public function getStyle()
210 {
211 $builerState = Utilities::jsonObj($this->isExist() ? ($this->form[0]->builder_helper_state ?? '') : '');
212 $style = '';
213 $themeVars = $builerState->themeVars ?? null;
214 $themeColors = $builerState->themeColors ?? null;
215
216 if (!empty($themeVars)) {
217 $style .= ':root {';
218 foreach ($themeVars->lgLightThemeVars as $key => $value) {
219 $style .= "$key: $value; ";
220 }
221 $style .= '} ';
222 }
223 if (!empty($themeColors)) {
224 $style .= ' :root {';
225 foreach ($themeColors->lightThemeColors as $k => $v) {
226 $style .= "$k:$v; ";
227 }
228 $style .= '} ';
229 }
230
231 $field = $builerState->style->lgLightStyles->fields;
232 foreach ($field as $value) {
233 $classes = $value->classes;
234 foreach ($classes as $key => $value) {
235 $style .= "{$key} {";
236 foreach ($value as $k => $v) {
237 $style .= "$k:$v; ";
238 }
239 $style .= '} ';
240 }
241 }
242 return $style;
243 }
244
245 public function getCustomStyle()
246 {
247 $customCSSPath = BITFORMS_CONTENT_DIR . DIRECTORY_SEPARATOR . 'form-styles' . DIRECTORY_SEPARATOR . "bitform-custom-{$this->form_id}.css";
248 return FileHandler::readFile($customCSSPath);
249 }
250
251 public function getCustomJS()
252 {
253 $customJsPath = BITFORMS_CONTENT_DIR . DIRECTORY_SEPARATOR . 'form-scripts' . DIRECTORY_SEPARATOR . "bitform-custom-{$this->form_id}.js";
254 return FileHandler::readFile($customJsPath);
255 }
256
257 public function getFormContentWithValue($defaultValues = [])
258 {
259 $form_content = Utilities::jsonObj($this->getEffectiveFormContentJson());
260 // this filter just use private purpose
261 if (isset($form_content->fields)) {
262 $form_content->fields = apply_filters('bitform_dynamic_field_filter', $form_content->fields);
263 }
264 if (!is_array($defaultValues) || 0 === count($defaultValues)) {
265 return $form_content;
266 }
267 foreach (($form_content->fields ?? []) as $fieldKey => $fieldDetails) {
268 // $field_name = empty($fieldDetails->lbl) ? null : \preg_replace('/[\`\~\!\@\#\$\'\.\s\?\+\-\*\&\|\/\\!]/', '_', $fieldDetails->lbl);
269 $fieldName = $fieldDetails->fieldName;
270 $defaultValue = isset($defaultValues[$fieldName]) ? $defaultValues[$fieldName] : null;
271 $defaultValue = isset($defaultValues[$fieldKey]) ? $defaultValues[$fieldKey] : $defaultValue;
272 if ((isset($fieldDetails->mul) || 'check' === $fieldDetails->typ) && isset($defaultValue)) {
273 // if (is_array($defaultValue)) {
274 // $fieldDetails->val =
275 // wp_json_encode(
276 // array_map('sanitize_text_field', $defaultValue)
277 // );
278 // } else {
279 // $fieldDetails->val = sanitize_text_field($defaultValue);
280 // }
281 if ((isset($fieldDetails->mul) && true === $fieldDetails->mul) || is_array($defaultValue)) {
282 $fieldDetails->val = wp_json_encode(array_map('sanitize_text_field', $defaultValue));
283 } elseif (!is_array($defaultValue)) {
284 $fieldDetails->val = sanitize_text_field($defaultValue);
285 }
286 } elseif (!is_null($defaultValue)) {
287 $fieldDetails->val = self::sanitizeDefaultValue($defaultValue);
288 }
289 }
290 return $form_content;
291 }
292
293 /**
294 * Normalize a prefill value into the scalar `val` a field can hold.
295 *
296 * Composite fields (address, name, …) arrive as associative arrays, which a
297 * numeric last-index lookup cannot read.
298 *
299 * @param mixed $defaultValue
300 *
301 * @return string
302 */
303 private static function sanitizeDefaultValue($defaultValue)
304 {
305 if (is_string($defaultValue)) {
306 return sanitize_text_field($defaultValue);
307 }
308
309 if (is_scalar($defaultValue)) {
310 return sanitize_text_field((string) $defaultValue);
311 }
312
313 if (is_object($defaultValue)) {
314 $defaultValue = (array) $defaultValue;
315 }
316
317 if (!is_array($defaultValue) || 0 === count($defaultValue)) {
318 return '';
319 }
320
321 // A plain list is a repeated query param — keep the historic "last one wins".
322 if (array_keys($defaultValue) === range(0, count($defaultValue) - 1)) {
323 $last = end($defaultValue);
324 return is_scalar($last) ? sanitize_text_field((string) $last) : (string) wp_json_encode($last);
325 }
326
327 // Keyed (composite) value: keep the whole shape, same as the `mul` branch above.
328 return (string) wp_json_encode(map_deep($defaultValue, 'sanitize_text_field'));
329 }
330
331 public function getFormContent()
332 {
333 $formContent = Utilities::jsonObj($this->getEffectiveFormContentJson());
334 $types = ['check', 'radio', 'select'];
335 $filter = false;
336 foreach (($formContent->fields ?? []) as $field) {
337 if (in_array($field->typ, $types) && property_exists($field, 'customType')) {
338 $filter = true;
339 break; // reduce unnecessary loop
340 }
341 }
342 if (true === $filter) {
343 $updateFields = apply_filters('bitform_dynamic_field_filter', $formContent->fields);
344 $formContent->fields = $updateFields;
345 }
346 return $formContent;
347 }
348
349 public function getFormInfo()
350 {
351 $formContent = json_decode($this->getEffectiveFormContentJson());
352 $formInfo = isset($formContent->formInfo) ? $formContent->formInfo : null;
353 return $formInfo;
354 }
355
356 public function getFormPermission()
357 {
358 $formContent = json_decode($this->form[0]->form_content);
359 $formPermission = isset($formContent->formPermissions) ? $formContent->formPermissions : null;
360 return $formPermission;
361 }
362
363 public function getFormHelperStates()
364 {
365 $formHelperStates = json_decode($this->form[0]->builder_helper_state);
366 return $formHelperStates;
367 }
368
369 public function getAtomicClsMap()
370 {
371 return $this->_atomic_class_map;
372 }
373
374 private function is_json($str)
375 {
376 $json = json_decode($str);
377 return $json && $str !== $json;
378 }
379
380 public function getFormData($columnName = '')
381 {
382 if (empty($columnName)) {
383 return null;
384 }
385
386 $form = $this->form[0];
387 if (!isset($form->{$columnName})) {
388 return null;
389 }
390
391 $data = $form->{$columnName};
392 if ($this->is_json($data)) {
393 return json_decode($data);
394 }
395
396 return $data;
397 }
398
399 public function getFormName()
400 {
401 return $this->form[0]->form_name;
402 }
403
404 public function getFormLayout()
405 {
406 $formContent = $this->getFormContent();
407 return isset($formContent->layout) ? $formContent->layout : new stdClass();
408 }
409
410 public function getFormNestedLayout()
411 {
412 $formContent = $this->getFormContent();
413 return isset($formContent->nestedLayout) ? $formContent->nestedLayout : new stdClass();
414 }
415
416 private function mergeNestedLayout(&$layout, $nestedLayout)
417 {
418 foreach ($nestedLayout as $key => $brkpnts) {
419 foreach ($brkpnts as $brkpnt=>$nLayout) {
420 $layout->{$brkpnt} = array_merge(isset($layout->{$brkpnt}) ? (array) $layout->{$brkpnt} : [], (array) $nLayout);
421 }
422 }
423 }
424
425 public function flatMultistepFormLayout()
426 {
427 $formLayout = $this->getFormLayout();
428 $multistepLayout = new stdClass();
429 foreach ($formLayout as $stpLayout) {
430 $lyout = $stpLayout->layout;
431
432 foreach ($lyout as $brkpnt=>$fields) {
433 $multistepLayout->{$brkpnt} = array_merge($multistepLayout->{$brkpnt} ?? [], $fields);
434 }
435 }
436
437 return $multistepLayout;
438 }
439
440 public function getFlatenFormLayout()
441 {
442 $layout = $this->getFormLayout();
443 $nestedLayout = $this->getFormNestedLayout();
444 if ('array' === gettype($layout)) {
445 // multi step form layout
446 $layout = $this->flatMultistepFormLayout();
447 }
448 if (!empty((array) $nestedLayout)) {
449 $this->mergeNestedLayout($layout, $nestedLayout);
450 }
451
452 return $layout;
453 }
454
455 /**
456 * Union of field keys referenced by ANY breakpoint (lg/md/sm) of the root
457 * layout (all steps) plus nested layouts of RENDERED containers.
458 *
459 * Nested layout entries are gated by their parent key being in the root
460 * layout — the renderer (FormViewer) only renders nested children of
461 * containers present in the root layout, so a stale nestedLayout entry
462 * (parent removed) must not mark its children as rendered.
463 *
464 * Returns [] when the layout is missing or unparseable — callers MUST
465 * fail closed (validate all fields) on an empty result.
466 *
467 * @return string[]
468 */
469 public function getLayoutFieldKeys()
470 {
471 try {
472 $layout = $this->getFormLayout();
473 $nestedLayout = $this->getFormNestedLayout();
474 if ('array' === gettype($layout)) {
475 // multi step form layout
476 $layout = $this->flatMultistepFormLayout();
477 }
478 } catch (\Throwable $e) {
479 return [];
480 }
481 $rootKeys = self::collectLayoutKeys($layout);
482 if (empty($rootKeys)) {
483 return [];
484 }
485 $keys = array_fill_keys($rootKeys, true);
486 if (is_object($nestedLayout) || is_array($nestedLayout)) {
487 foreach ($nestedLayout as $parentKey => $nLay) {
488 if (!isset($keys[$parentKey])) {
489 continue; // stale entry: container no longer rendered
490 }
491 foreach (self::collectLayoutKeys($nLay) as $nestedKey) {
492 $keys[$nestedKey] = true;
493 }
494 }
495 }
496 return array_keys($keys);
497 }
498
499 /**
500 * Collect field keys from every breakpoint of a layout object.
501 *
502 * @param object $layout layout with ->lg/->md/->sm arrays of {i} items
503 *
504 * @return string[]
505 */
506 private static function collectLayoutKeys($layout)
507 {
508 if (!is_object($layout)) {
509 return [];
510 }
511 $keys = [];
512 foreach (['lg', 'md', 'sm'] as $brkpnt) {
513 if (!isset($layout->{$brkpnt}) || !is_array($layout->{$brkpnt})) {
514 continue;
515 }
516 foreach ($layout->{$brkpnt} as $item) {
517 if (is_object($item) && isset($item->i)) {
518 $keys[$item->i] = true;
519 }
520 }
521 }
522 return array_keys($keys);
523 }
524
525 /**
526 * Extract a child field key from a childFields[] entry (stdClass or array shape).
527 *
528 * @return string|null
529 */
530 private static function childFldKey($child)
531 {
532 if (is_object($child) && isset($child->fldKey)) {
533 return $child->fldKey;
534 }
535 if (is_array($child) && isset($child['fldKey'])) {
536 return $child['fldKey'];
537 }
538 return null;
539 }
540
541 /**
542 * Add childFields of every rendered parent into $renderedKeys (by ref).
543 *
544 * Several field types keep their children flat in `fields` and NEVER in
545 * any layout — Name (first/middle/last), Address (street/city/zip/...),
546 * Email and Password (confirm fields). A child is rendered iff its
547 * parent is, so each rendered parent's childFields[].fldKey must join
548 * the rendered set or their validation would be wrongly skipped.
549 *
550 * Runs to a fixpoint so expansion is safe regardless of field order or
551 * nesting depth.
552 *
553 * @param array $renderedKeys key => true map, mutated in place
554 * @param iterable $fields fields keyed by field key; each field may be
555 * a processed array (getFields()) or raw stdClass
556 */
557 protected static function expandChildFieldKeys(array &$renderedKeys, $fields)
558 {
559 do {
560 $grew = false;
561 foreach ($fields as $key => $field) {
562 if (!isset($renderedKeys[$key])) {
563 continue;
564 }
565 $childFields = null;
566 if (is_object($field) && isset($field->childFields)) {
567 $childFields = $field->childFields;
568 } elseif (is_array($field) && isset($field['childFields'])) {
569 $childFields = $field['childFields'];
570 }
571 if (empty($childFields) || !is_iterable($childFields)) {
572 continue;
573 }
574 foreach ($childFields as $child) {
575 $childKey = self::childFldKey($child);
576 if ($childKey && !isset($renderedKeys[$childKey])) {
577 $renderedKeys[$childKey] = true;
578 $grew = true;
579 }
580 }
581 }
582 } while ($grew);
583 }
584
585 /**
586 * getFields() narrowed to provably-rendered fields: key present in any
587 * breakpoint of any step/nested layout, OR a childField of a rendered
588 * parent (name/address/email/password children live outside layouts),
589 * OR the synthetic GCLID key.
590 *
591 * SECURITY: fail-closed — if the layout yields no keys, ALL fields are
592 * returned (current behavior). Derives exclusively from DB-stored
593 * form_content, never from POST, so submitters cannot influence which
594 * fields are validated.
595 */
596 public function getRenderedFields()
597 {
598 $fields = $this->getFields();
599 try {
600 $renderedKeys = self::renderedKeyMap($this->getFormLayout(), $this->getFormNestedLayout(), $fields);
601 } catch (\Throwable $e) {
602 return $fields; // fail-closed: unusable layout validates all fields
603 }
604 if (null === $renderedKeys) {
605 return $fields;
606 }
607 $rendered = [];
608 foreach ($fields as $key => $field) {
609 // renderedKeys already includes NON_LAYOUT_FIELD_KEYS (GCLID, ...)
610 if (isset($renderedKeys[$key])) {
611 $rendered[$key] = $field;
612 }
613 }
614 return $rendered;
615 }
616
617 /**
618 * Field keys that are legitimately part of a form yet never appear in any
619 * layout — synthetic/system fields the renderer always keeps. They must
620 * never be flagged as orphan (save guard) or dropped from validation
621 * (renderer). Extend this list as new non-layout system fields are added.
622 *
623 * @var string[]
624 */
625 protected const NON_LAYOUT_FIELD_KEYS = ['GCLID'];
626
627 /**
628 * True when $fields (object or array) holds $key.
629 *
630 * @param object|array $fields
631 * @param string $key
632 */
633 private static function fieldExists($fields, $key)
634 {
635 return is_object($fields) ? isset($fields->{$key}) : (is_array($fields) && isset($fields[$key]));
636 }
637
638 /**
639 * Flatten a raw layout into one object unioning lg/md/sm across all steps.
640 * Accepts a single layout object or an array of multi-step entries (each
641 * wrapping its layout in ->layout). Shared by the frontend renderer
642 * (getRenderedFields) and the admin save/import orphan guard
643 * (computeOrphanFieldKeys) so both flatten identically.
644 *
645 * @param array|object $layout
646 *
647 * @return object {lg,md,sm} arrays of {i} items
648 */
649 private static function flattenLayout($layout)
650 {
651 $flat = new stdClass();
652 $flat->lg = [];
653 $flat->md = [];
654 $flat->sm = [];
655 $addLayout = function ($lay) use ($flat) {
656 if (!is_object($lay)) {
657 return;
658 }
659 foreach (['lg', 'md', 'sm'] as $brkpnt) {
660 if (isset($lay->{$brkpnt}) && is_array($lay->{$brkpnt})) {
661 $flat->{$brkpnt} = array_merge($flat->{$brkpnt}, $lay->{$brkpnt});
662 }
663 }
664 };
665 if (is_array($layout)) {
666 // multi-step: each entry wraps its layout in ->layout
667 foreach ($layout as $step) {
668 $addLayout(isset($step->layout) ? $step->layout : $step);
669 }
670 } else {
671 $addLayout($layout);
672 }
673 return $flat;
674 }
675
676 /**
677 * SINGLE SOURCE OF TRUTH for "which field keys the renderer would show":
678 * unions all breakpoints across steps, adds nested-layout children of
679 * RENDERED containers only, then expands childFields of rendered parents
680 * (name/address/email/password children live outside layouts).
681 *
682 * Both getRenderedFields (frontend validation) and computeOrphanFieldKeys
683 * (admin save guard) route through this so the two can never diverge — a
684 * divergence would prune a real field or wrongly validate an orphan.
685 *
686 * @param array|object $layout single layout or array of steps
687 * @param object|null $nestedLayout keyed by parent field key
688 * @param object|array $fields form_content->fields
689 *
690 * @return array<string,true>|null key=>true map, or null when the layout is
691 * unusable (callers MUST fail closed)
692 */
693 protected static function renderedKeyMap($layout, $nestedLayout, $fields)
694 {
695 // Fail closed on a partial/unloaded multi-step layout: a step with no
696 // layout items at all almost always means the layout never finished
697 // loading/syncing (not a real "every field on this step was deleted").
698 // Treating it as usable would flag that step's real fields as orphan.
699 if (is_array($layout)) {
700 if (empty($layout)) {
701 return null;
702 }
703 foreach ($layout as $step) {
704 $stepLayout = is_object($step) && isset($step->layout) ? $step->layout : $step;
705 if (empty(self::collectLayoutKeys(self::flattenLayout($stepLayout)))) {
706 return null;
707 }
708 }
709 }
710 $flat = self::flattenLayout($layout);
711 $rootKeys = self::collectLayoutKeys($flat);
712 if (empty($rootKeys)) {
713 return null;
714 }
715 $renderedKeys = array_fill_keys($rootKeys, true);
716 // nested children count as rendered only when their container is —
717 // matches the renderer, which skips stale nestedLayout entries
718 if (is_object($nestedLayout) || is_array($nestedLayout)) {
719 foreach ($nestedLayout as $parentKey => $nLay) {
720 if (!isset($renderedKeys[$parentKey])) {
721 continue;
722 }
723 foreach (self::collectLayoutKeys($nLay) as $nestedKey) {
724 $renderedKeys[$nestedKey] = true;
725 }
726 }
727 }
728 self::expandChildFieldKeys($renderedKeys, $fields);
729 // synthetic/system fields (e.g. GCLID) live outside every layout; the
730 // renderer always keeps them, so they must never count as orphan
731 foreach (self::NON_LAYOUT_FIELD_KEYS as $sysKey) {
732 if (self::fieldExists($fields, $sysKey)) {
733 $renderedKeys[$sysKey] = true;
734 }
735 }
736 return $renderedKeys;
737 }
738
739 /**
740 * Pure variant of the orphan rule for the admin save/import guard: returns
741 * the keys of $fields absent from every layout (children of rendered
742 * parents excluded). Uses renderedKeyMap — the exact flattening the
743 * renderer uses — so the save guard and the renderer never diverge.
744 *
745 * @param array|object $layout single layout or array of steps ({layout} each)
746 * @param object|null $nestedLayout keyed by parent field key
747 * @param mixed $fields raw form_content->fields (decoded JSON: shape is not guaranteed, hence the runtime guard)
748 *
749 * @return string[]|null orphan keys to drop, or null when the layout is
750 * unusable (fail closed: drop nothing)
751 */
752 public static function computeOrphanFieldKeys($layout, $nestedLayout, $fields)
753 {
754 if (!is_object($fields) && !is_array($fields)) {
755 return null;
756 }
757 $renderedKeys = self::renderedKeyMap($layout, $nestedLayout, $fields);
758 if (null === $renderedKeys) {
759 return null; // unusable layout: fail closed, drop nothing
760 }
761 $orphans = [];
762 foreach ($fields as $key => $field) {
763 if (!isset($renderedKeys[$key])) {
764 $orphans[] = $key;
765 }
766 }
767 return $orphans;
768 }
769
770 public function getFieldsBasedOnLayout()
771 {
772 $layout = $this->getFlatenFormLayout();
773
774 $fieldKeyOrderbasedOnLayout = array_map(function ($fld) {
775 return $fld->i;
776 }, $layout->lg);
777 $orderedFields = [];
778 $fields = $this->getFields();
779
780 foreach ($fieldKeyOrderbasedOnLayout as $key) {
781 if (array_key_exists($key, $fields)) {
782 $orderedFields[$key] = $fields[$key];
783 }
784 }
785
786 foreach ($fields as $k=>$v) {
787 if (!array_key_exists($k, $fieldKeyOrderbasedOnLayout)) {
788 $orderedFields[$k] = $fields[$k];
789 }
790 }
791
792 return $orderedFields;
793 }
794
795 public function getFields()
796 {
797 if (!is_null($this->_fields)) {
798 return $this->_fields;
799 }
800 $form_content = \json_decode($this->form[0]->form_content);
801 $layout = $form_content->layout;
802 $fields = $form_content->fields;
803 $field_details = [];
804 foreach ($fields as $key => $field) {
805 if ('recaptcha' === $field->typ || 'hcaptcha' === $field->typ) {
806 continue;
807 }
808 // $field_name = empty($field->lbl) ? null : \preg_replace('/[\`\~\!\@\#\$\'\.\s\?\+\-\*\&\|\/\\\!]/', '_', $field->lbl);
809 $field_type = $field->typ;
810 $field_details[$key]['label'] = !empty($field->lbl) ? $field->lbl : (!empty($field->adminLbl) ? $field->adminLbl : (!empty($field->fieldName) ? $field->fieldName : null));
811 $field_details[$key]['type'] = $field_type;
812 $field_details[$key]['key'] = $key;
813 $field_details[$key]['name'] = isset($field->fieldName) ? $field->fieldName : '';
814 if (isset($field->customType)) {
815 $field_details[$key]['customType'] = $field->customType;
816 }
817 // fields with confirm field
818 if (isset($field->childFields)) {
819 $field_details[$key]['childFields'] = $field->childFields;
820 }
821 if (isset($field->parentFieldKey)) {
822 $field_details[$key]['parentFieldKey'] = $field->parentFieldKey;
823 if (isset($field->isDeactive)) {
824 $field_details[$key]['isDeactive'] = $field->isDeactive;
825 }
826 }
827 if (isset($field->err)) {
828 if (isset($field->err->entryUnique)) {
829 $field_details[$key]['entryUnique'] = $field->err->entryUnique;
830 }
831 if (isset($field->err->userUnique)) {
832 $field_details[$key]['userUnique'] = $field->err->userUnique;
833 }
834 }
835
836 if (isset($field->mul)) {
837 $field_details[$key]['mul'] = $field->mul;
838 }
839 if (in_array($field_type, ['name'])) {
840 $field_details[$key]['label'] = $field->adminLbl ?? $field->lbl;
841 }
842 if ('file-up' === $field_type && isset($field->exts)) {
843 $field_details[$key]['valid']['type'] = $field->exts;
844 }
845 if ('file-up' === $field_type && isset($field->mxUp)) {
846 $field_details[$key]['valid']['upload_size'] = (int) $field->mxUp;
847 }
848 if (isset($field->valid) && !is_null($field->valid)) {
849 if (isset($field->valid->req)) {
850 $field_details[$key]['valid']['req'] = $field->valid->req;
851 }
852 if (isset($field->valid->reqMsg)) {
853 $field_details[$key]['valid']['reqMsg'] = $field->valid->reqMsg;
854 }
855 if (isset($field->valid->typMsg)) {
856 $field_details[$key]['valid']['typMsg'] = $field->valid->typMsg;
857 }
858 if (isset($field->valid->hide)) {
859 $field_details[$key]['valid']['hide'] = $field->valid->hide;
860 }
861 if (isset($field->valid->keepValueWhenHidden)) {
862 $field_details[$key]['valid']['keepValueWhenHidden'] = $field->valid->keepValueWhenHidden;
863 }
864 }
865 if ($this->isRepeatedField($key)) {
866 $field_details[$key]['repeated'] = true;
867 }
868 }
869 if ($this->isGCLIDEnabled()) {
870 $field_details['GCLID']['name'] = 'GCLID';
871 $field_details['GCLID']['adminLbl'] = 'GCLID';
872 $field_details['GCLID']['key'] = 'GCLID';
873 $field_details['GCLID']['type'] = 'hidden';
874 }
875 $this->_fields = $field_details;
876 return $field_details;
877 }
878
879 public function getFieldsKey()
880 {
881 $form_content = \json_decode($this->form[0]->form_content);
882 $fields = $form_content->fields;
883 $field_details = [];
884 foreach ($fields as $key => $field) {
885 if ('recaptcha' === $field->typ || 'hcaptcha' === $field->typ) {
886 continue;
887 }
888 // $field_name = empty($field->lbl) ? null : \preg_replace('/[\`\~\!\@\#\$\'\.\s\?\+\-\*\&\|\/\\\!]/', '_', $field->lbl);
889 $field_details[$key] = $key;
890 }
891 if ($this->isGCLIDEnabled()) {
892 $field_details['GCLID'] = 'GCLID';
893 }
894 return $field_details;
895 }
896
897 public function getFieldLabel($forQuery = false)
898 {
899 if (!is_null($this->_field_label)) {
900 return $this->_field_label;
901 }
902 $form_content = \json_decode($this->form[0]->form_content);
903 $fields = $form_content->fields;
904 $field_details = [];
905 $fieldCounter = 0;
906 foreach ($fields as $key => $field) {
907 if ('recaptcha' === $field->typ || 'turnstile' === $field->typ || 'html' === $field->typ || 'button' === $field->typ) {
908 continue;
909 }
910 $field_details[$fieldCounter]['name'] = empty($field->lbl) ? null : $field->lbl;
911 $field_details[$fieldCounter]['adminLbl'] = empty($field->adminLbl) ? $field_details[$fieldCounter]['name'] : $field->adminLbl;
912 $field_details[$fieldCounter]['key'] = $key;
913 $field_details[$fieldCounter]['type'] = $field->typ;
914 $fieldCounter += 1;
915 }
916 if ($this->isGCLIDEnabled()) {
917 $field_details[$fieldCounter]['name'] = 'GCLID';
918 $field_details[$fieldCounter]['adminLbl'] = 'GCLID';
919 $field_details[$fieldCounter]['key'] = 'GCLID';
920 $field_details[$fieldCounter]['type'] = 'hidden';
921 $fieldCounter += 1;
922 }
923 if (!$forQuery) {
924 $field_details = (array) $this->addEntryInfo($field_details, $fieldCounter);
925 }
926 $this->_field_label = $field_details;
927 return $field_details;
928 }
929
930 public function getUploadFields()
931 {
932 if (!is_null($this->_has_upload)) {
933 return $this->_has_upload;
934 }
935 $upload_fields = [];
936 $form_field_details = $this->getFields();
937 foreach ($form_field_details as $field_name => $__field_detail) {
938 if (isset($__field_detail['type']) && ('file-up' === $__field_detail['type'] || 'advanced-file-up' === $__field_detail['type'])) {
939 $upload_fields[] = $field_name;
940 }
941 }
942 $this->_has_upload = $upload_fields;
943 return $upload_fields;
944 }
945
946 public function getSignatureFilePath($blobLink, $form_id, $fieldKey, $entry_id, $imgType)
947 {
948 $imgTypes = [
949 'image/png' => 'png',
950 'image/jpeg' => 'jpg',
951 'image/svg+xml' => 'svg',
952 ];
953 try {
954 if (!isset($imgTypes[$imgType])) {
955 throw new \InvalidArgumentException("Unsupported image type: $imgType");
956 }
957 $parts = explode(',', $blobLink, 2);
958 if (2 !== count($parts) || false === ($decoded_image = base64_decode($parts[1]))) {
959 throw new \RuntimeException('Invalid or corrupt signature data URI');
960 }
961
962 // An attacker-controlled SVG signature is written to a web-served path, so a raw write is a
963 // stored-XSS sink. Sanitize with the same enshrined library the upload path uses (FileHandler).
964 if ('svg' === $imgTypes[$imgType]) {
965 $clean = (new Sanitizer())->sanitize($decoded_image);
966 if (false === $clean) {
967 throw new \RuntimeException('Invalid or unsafe SVG signature data');
968 }
969 $decoded_image = $clean;
970 }
971
972 $_upload_dir = FileHandler::getEntriesFileUploadDir($form_id, $entry_id);
973 FileHandler::createIndexFile($_upload_dir);
974 $uniqueId = time() . '-' . bin2hex(\random_bytes(4));
975 $filename = "{$entry_id}-{$fieldKey}-{$uniqueId}.{$imgTypes[$imgType]}";
976 $fullPath = $_upload_dir . DIRECTORY_SEPARATOR . $filename;
977 if (false === file_put_contents($fullPath, $decoded_image)) {
978 throw new \RuntimeException("Failed to write image to $fullPath");
979 }
980 return $filename;
981 } catch (\Throwable $e) {
982 Log::debug_log("[Signature Error] Form: $form_id, Entry: $entry_id, Field: $fieldKey - " . $e->getMessage());
983 return 'signature-failed.png'; // or a default filename if appropriate
984 }
985 }
986
987 private function entryInsert($user_details)
988 {
989 $formEntryModel = new FormEntryModel();
990 $entryId = $formEntryModel->insert(
991 [
992 'form_id' => $this->form_id,
993 'user_id' => $user_details['id'],
994 'user_ip' => $user_details['ip'],
995 'user_device' => $user_details['device'],
996 'referer' => $user_details['page'],
997 'status' => $this->_saveFormAsDraft ? 9 : 1,
998 'created_at' => $user_details['time'],
999 ]
1000 );
1001 return $entryId;
1002 }
1003
1004 public function submisionLog($user_details, $entry_id, $type)
1005 {
1006 $formEntryLogModel = new FormEntryLogModel();
1007 $submissionLogData = [
1008 'user_id' => $user_details['id'],
1009 'action_type' => $type, // create, update
1010 'log_type' => 'entry',
1011 'ip' => $user_details['ip'],
1012 'form_entry_id' => $entry_id,
1013 // encoded: wpdb cannot bind an array, so an array here silently stored an
1014 // empty string and every submission lost its device info
1015 'content' => wp_json_encode(['user_device' => $user_details['device']]),
1016 'form_id' => $this->form_id,
1017 'created_at' => $user_details['time'],
1018 ];
1019 $submissionLogData = apply_filters('bitform_filter_submission_log_data', $submissionLogData, $this->form_id, $type);
1020 $logId = $formEntryLogModel->form_log_insert(
1021 $submissionLogData
1022 );
1023 return $logId;
1024 }
1025
1026 private function isArrayAllKeyInt($InputArray)
1027 {
1028 if (!is_array($InputArray)) {
1029 return false;
1030 }
1031
1032 if (count($InputArray) <= 0) {
1033 return true;
1034 }
1035
1036 return array_unique(array_map('is_int', array_keys($InputArray))) === [true];
1037 }
1038
1039 public function formatSubmittedData($submitted_data)
1040 {
1041 $form_content = $this->getFormContent();
1042 $form_fields = $form_content->fields;
1043
1044 foreach ($submitted_data as $key => $value) {
1045 if (!isset($form_fields->{$key})) {
1046 continue;
1047 }
1048 $field_data = $form_fields->{$key};
1049 $field_type = $field_data->typ;
1050 $normalizedParentValue = $this->normalizeSubmittedValue($value);
1051 $parentFieldName = isset($field_data->fieldName) ? $field_data->fieldName : '';
1052 // Confirm child of a repeated email/password never persists — the non-repeated
1053 // path drops it too (the validator collapses the parent to its primary value).
1054 $isRepeatedConfirmComposite = in_array($field_type, ['email', 'password'], true) && $this->isRepeatedField($key);
1055 if (!$isRepeatedConfirmComposite && !empty($field_data->childFields) && is_array($field_data->childFields)) {
1056 foreach ($field_data->childFields as $childFieldRef) {
1057 $childFieldKey = isset($childFieldRef->fldKey) ? $childFieldRef->fldKey : '';
1058 if (empty($childFieldKey) || !isset($form_fields->{$childFieldKey})) {
1059 continue;
1060 }
1061
1062 $childFieldData = $form_fields->{$childFieldKey};
1063 $childFieldName = isset($childFieldData->fieldName) ? $childFieldData->fieldName : '';
1064 $childFieldName = FieldValueHandler::deriveChildName($childFieldName, $parentFieldName);
1065 if (empty($childFieldName)) {
1066 continue;
1067 }
1068
1069 $childValue = FieldValueHandler::extractChildValueFromParentValue($normalizedParentValue, $childFieldName, $childFieldKey);
1070 if (null !== $childValue) {
1071 $submitted_data[$childFieldKey] = $childValue;
1072 }
1073 }
1074 }
1075
1076 if ($this->isRepeatedField($key) && in_array($field_type, ['name', 'address', 'email', 'password'])) {
1077 $normalizedRows = $this->normalizeRepeatedCompositeFieldInput($normalizedParentValue);
1078 if ($isRepeatedConfirmComposite && is_array($normalizedRows)) {
1079 // Keep only the primary value per row, matching the non-repeated behavior
1080 // where a confirm-enabled field collapses to its primary value.
1081 foreach ($normalizedRows as $rowIndex => $rowValue) {
1082 if (is_array($rowValue) && array_key_exists('primary', $rowValue)) {
1083 $normalizedRows[$rowIndex] = $rowValue['primary'];
1084 }
1085 }
1086 }
1087 $submitted_data[$key] = $normalizedRows;
1088 }
1089
1090 if ('select' === $field_type && !empty($field_data->config->multipleSelect)) {
1091 $valueArr = [];
1092 if ($this->isRepeatedField($key) && is_array($normalizedParentValue)) {
1093 foreach ($normalizedParentValue as $index => $v) {
1094 $valueArr[$index] = explode(BITFORMS_BF_SEPARATOR, $v);
1095 }
1096 } else {
1097 $valueArr = explode(BITFORMS_BF_SEPARATOR, (string) $value);
1098 }
1099 $submitted_data[$key] = $valueArr;
1100 }
1101 }
1102 $submitted_data = apply_filters('bitform_filter_format_submitted_data', $submitted_data, $this->form_id);
1103 return $submitted_data;
1104 }
1105
1106 private function normalizeSubmittedValue($value)
1107 {
1108 if (!is_string($value)) {
1109 return $value;
1110 }
1111
1112 $decoded = json_decode($value, true);
1113 return (JSON_ERROR_NONE === json_last_error()) ? $decoded : $value;
1114 }
1115
1116 private function addNewFilePathToFiles($form_id, $entry_id, $file_fields = [])
1117 {
1118 $common_file_path = Helpers::getFullPathWithEncryptedEntryId($form_id, $entry_id);
1119 foreach ($_FILES as $field_key => $file_details) {
1120 if (!($file_fields && in_array($field_key, $file_fields))) {
1121 continue;
1122 }
1123
1124 $isRepeaterFldKey = $this->isRepeatedField($field_key);
1125 if ($isRepeaterFldKey && isset($file_details['new_name'])) {
1126 // If 'new_name' is an array (i.e., for repeated fields)
1127 foreach ($file_details['new_name'] as $slNo => $newFileNamesArray) {
1128 if (is_array($newFileNamesArray)) {
1129 foreach ($newFileNamesArray as $newFileName) {
1130 $filePath = $common_file_path . DIRECTORY_SEPARATOR . $newFileName;
1131 $_FILES[$field_key]['file_path'][$slNo][] = $filePath;
1132 }
1133 } else {
1134 // Generate the file path for each file
1135 $filePath = $common_file_path . DIRECTORY_SEPARATOR . $newFileNamesArray;
1136 $_FILES[$field_key]['file_path'][$slNo] = $filePath;
1137 }
1138 }
1139 } elseif (isset($file_details['new_name'])) {
1140 // If 'new_name' is an array (i.e., for repeated fields)
1141 if (is_array($file_details['new_name'])) {
1142 foreach ($file_details['new_name'] as $slNo => $newFileName) {
1143 // Generate the file path for each file
1144 $filePath = $common_file_path . DIRECTORY_SEPARATOR . $newFileName;
1145 $_FILES[$field_key]['file_path'][$slNo] = $filePath;
1146 }
1147 } else {
1148 $filePath = $common_file_path . DIRECTORY_SEPARATOR . $file_details['new_name'];
1149 $_FILES[$field_key]['file_path'] = $filePath;
1150 }
1151 }
1152 }
1153 }
1154
1155 private function formatRepeateFieldData($submitted_data, $form_fields)
1156 {
1157 $repeaterFields = $this->getRepeaterFields();
1158 foreach ($repeaterFields as $repeaterFldKey => $repeatedFields) {
1159 $repeatIndexes = $submitted_data["{$form_fields[$repeaterFldKey]['name']}-repeat-index"];
1160 $repeatIndexes = explode(',', $repeatIndexes);
1161 foreach ($repeatedFields as $repeatedField) {
1162 $oldFileKey = "{$repeatedField}_old";
1163 if (isset($submitted_data[$oldFileKey]) && is_array($submitted_data[$oldFileKey])) {
1164 $oldFileValues = $submitted_data[$oldFileKey];
1165 $oldFileKeys = array_map('strval', array_keys($oldFileValues));
1166 $repeatIndexKeys = array_map('strval', $repeatIndexes);
1167 $oldFilesUseRepeatIndexes = empty(array_diff($oldFileKeys, $repeatIndexKeys));
1168 $normalizedOldFileValues = [];
1169
1170 foreach ($repeatIndexes as $slNo => $repeatIndex) {
1171 $oldFileSourceIndex = $oldFilesUseRepeatIndexes ? $repeatIndex : $slNo;
1172 if (array_key_exists($oldFileSourceIndex, $oldFileValues)) {
1173 $normalizedOldFileValues[$slNo] = $oldFileValues[$oldFileSourceIndex];
1174 }
1175 }
1176
1177 $submitted_data[$oldFileKey] = $normalizedOldFileValues;
1178 }
1179 }
1180
1181 foreach ($repeatIndexes as $slNo => $repeatIndex) {
1182 foreach ($repeatedFields as $repeatedField) {
1183 if (!isset($submitted_data[$repeatedField][$repeatIndex])) {
1184 continue;
1185 }
1186 if (!isset($submitted_data[$repeaterFldKey][$slNo])) {
1187 $submitted_data[$repeaterFldKey][$slNo] = [];
1188 }
1189 if (!isset($submitted_data[$repeaterFldKey][$slNo][$repeatedField])) {
1190 $submitted_data[$repeaterFldKey][$slNo][$repeatedField] = [];
1191 }
1192 $submitted_data[$repeaterFldKey][$slNo][$repeatedField] = $submitted_data[$repeatedField][$repeatIndex];
1193 }
1194 }
1195 foreach ($repeatedFields as $repeatedField) {
1196 unset($submitted_data[$repeatedField]);
1197 }
1198 unset($submitted_data["{$form_fields[$repeaterFldKey]['name']}-repeat-index"]);
1199 }
1200
1201 return $submitted_data;
1202 }
1203
1204 private function saveEntryMeta($submitted_data, $entry_id)
1205 {
1206 $errorInEntryMetaInsert = false;
1207 $entryMeta = new FormEntryMetaModel();
1208 foreach ($submitted_data as $key => $value) {
1209 $value = $submitted_data[$key];
1210 if (is_string($value)) {
1211 $value = wp_unslash($value);
1212 } elseif ($this->isArrayAllKeyInt($value)) {
1213 $value = wp_json_encode(array_values($value));
1214 } else {
1215 $value = wp_json_encode($value);
1216 }
1217 // Form entry meta insert; meta_key/meta_value required to store dynamic field data per entry.
1218 $status = $entryMeta->insert(
1219 [
1220 'bitforms_form_entry_id' => $entry_id,
1221 'meta_key' => $key,
1222 'meta_value' => $value,
1223 ]
1224 );
1225 if (is_wp_error($status)) {
1226 $errorInEntryMetaInsert = true;
1227 break;
1228 }
1229 }
1230 return $errorInEntryMetaInsert;
1231 }
1232
1233 public function setSaveFormAsDraft()
1234 {
1235 $this->_saveFormAsDraft = true;
1236 }
1237
1238 public function saveFormEntry($submitted_data)
1239 {
1240 // CSRF verified upstream via FrontendFormManager::verifySubmissionNonce() before this method is invoked.
1241 $submitted_data = $this->formatSubmittedData($submitted_data);
1242 $submitted_data = apply_filters('bitform_filter_save_form_entry', $submitted_data, $this->form_id);
1243 $form_content = \json_decode($this->form[0]->form_content);
1244 do_action('bitform_save_entry', $this, $submitted_data, $this->form_id);
1245 $key = null;
1246 $ipTool = new IpTool();
1247 $fileHandler = new FileHandler();
1248 $form_fields = $this->getFields();
1249 $file_fields = $this->getUploadFields();
1250
1251 foreach ($_FILES as $file_name => $file_details) {
1252 if ($file_fields && in_array($file_name, $file_fields)) {
1253 $validation = $fileHandler->validation($file_name, $file_details, $this->form_id);
1254 if (!empty($validation['error_type']) && !empty($validation['message'])) {
1255 return new WP_Error($validation['error_type'], esc_html($validation['message']));
1256 }
1257 }
1258 }
1259 $user_details = $ipTool->getUserDetail();
1260 $user_details = apply_filters('bitform_filter_user_details', $user_details, $this->form_id);
1261 $user_details = apply_filters('bitform_filter_save_entry_user_details', $user_details, $this->form_id);
1262
1263 $form_fields = $this->getFields();
1264 $submitted_data = $this->passwordEncrypted($submitted_data, $form_fields);
1265 $submitted_data = $this->formatRepeateFieldData($submitted_data, $form_fields);
1266 global $wpdb;
1267 // Direct transaction control; no user input involved.
1268 $wpdb->query('START TRANSACTION');
1269 $entry_id = $this->entryInsert($user_details);
1270 $log_id = null;
1271
1272 $GLOBALS['bitform_entry_id'] = $entry_id;
1273
1274 if (is_wp_error($entry_id)) {
1275 return new WP_Error('insert_error', __('Sorry, Error occurred in saving form entry', 'bit-form'));
1276 }
1277 if ($entry_id) {
1278 $log_id = $this->submisionLog($user_details, $entry_id, 'create', $key);
1279 if (is_wp_error($log_id)) {
1280 $wpdb->query('ROLLBACK');
1281 return new WP_Error('error_entry_log', __('Sorry, error occurred in logging form entry', 'bit-form'));
1282 }
1283 }
1284 if ($entry_id) {
1285 $submitted_fields = $this->getFormContentWithValue($submitted_data)->fields;
1286 $workFlowRunHelper = new WorkFlow($this->form_id);
1287
1288 $workFlowreturnedOnSubmit = $workFlowRunHelper->executeOnSubmit(
1289 'create',
1290 $submitted_fields,
1291 $submitted_data,
1292 $entry_id,
1293 $log_id
1294 );
1295
1296 if (!empty($workFlowreturnedOnSubmit['fields'])) {
1297 $submitted_data = $workFlowreturnedOnSubmit['fields'];
1298 }
1299
1300 $file_fields = $this->getUploadFields();
1301 $formFields = $this->getFields();
1302 $submitted_data = FileHandler::tempDirToUploadDir($submitted_data, $formFields, $this->form_id, $entry_id);
1303 $fileHandler = new FileHandler();
1304 foreach ($_FILES as $field_key => $file_details) {
1305 if ($file_fields && in_array($field_key, $file_fields)) {
1306 $fileNames = [];
1307 $repeaterFldKey = $this->isRepeatedField($field_key);
1308 if ($repeaterFldKey) {
1309 foreach ($file_details['name'] as $slNo => $fileName) {
1310 $repeateFileDetails = [
1311 'name' => $file_details['name'][$slNo],
1312 'type' => $file_details['type'][$slNo],
1313 'tmp_name' => $file_details['tmp_name'][$slNo],
1314 'error' => $file_details['error'][$slNo],
1315 'size' => $file_details['size'][$slNo],
1316 ];
1317 $fileNames = $fileHandler->moveUploadedFiles($repeateFileDetails, $this->form_id, $entry_id);
1318 if (!empty($fileNames)) {
1319 $submitted_data[$repeaterFldKey][$slNo - 1][$field_key] = $fileNames;
1320 $_FILES[$field_key]['new_name'][$slNo - 1] = $fileNames;
1321 }
1322 }
1323 } else {
1324 $fileNames = $fileHandler->moveUploadedFiles($file_details, $this->form_id, $entry_id);
1325 if (!empty($fileNames)) {
1326 $submitted_data[$field_key] = $fileNames;
1327 $_FILES[$field_key]['new_name'] = $fileNames;
1328 }
1329 }
1330 }
1331 }
1332
1333 // Get the common path for file storage
1334 $this->addNewFilePathToFiles($this->form_id, $entry_id, $file_fields);
1335
1336 foreach ($form_content->fields as $key => $field) {
1337 /* ======== for Signature field ===========*/
1338 if ('signature' === $field->typ) {
1339 if (isset($submitted_data[$key])) {
1340 $fld_data = $submitted_data[$key];
1341 $img_type = $field->config->imgTyp;
1342 $submitted_data[$key] = $this->getSignatureFilePath($fld_data, $this->form_id, $key, $entry_id, $img_type);
1343 }
1344 }
1345
1346 // for Signature field inside reepater
1347 if ('repeater' === $field->typ) {
1348 $rptr_data = $submitted_data[$key];
1349 $formFields = $form_content->fields;
1350 $this->setSignatureFilePathInRepeater($rptr_data, $key, $formFields, $entry_id, $submitted_data);
1351 }
1352 }
1353
1354 if (!isset($form_content->additional->enabled->submission)) {
1355 $errorInEntryMetaInsert = $this->saveEntryMeta($submitted_data, $entry_id);
1356 if ($errorInEntryMetaInsert) {
1357 do_action('bitform_save_entry_error', $this, $submitted_data, $this->form_id);
1358 $wpdb->query('ROLLBACK');
1359 return new WP_Error('insert_error', __('Sorry, Error occured in saving form entry data', 'bit-form'));
1360 }
1361 do_action('bitform_after_save_entry_success', $this, $submitted_data, $this->form_id, $entry_id);
1362 } else {
1363 $wpdb->query('ROLLBACK');
1364 }
1365 $wpdb->query('COMMIT');
1366 $this->setSubmissionCount();
1367 $workFlowreturnedOnSubmit['entry_id'] = $entry_id;
1368 $workFlowreturnedOnSubmit['fields'] = $submitted_data;
1369 $workFlowreturnedOnSubmit = apply_filters('bitform_filter_return_submit_success', $workFlowreturnedOnSubmit, $this->form_id);
1370
1371 return $workFlowreturnedOnSubmit;
1372 }
1373 }
1374
1375 /**
1376 * The signature file name an entry currently points at, '' when it has none.
1377 */
1378 private function getStoredSignatureFile($entryMeta, $entryID, $fieldKey)
1379 {
1380 $stored = $entryMeta->get(
1381 'meta_value',
1382 [
1383 'bitforms_form_entry_id' => $entryID,
1384 'meta_key' => $fieldKey,
1385 ]
1386 );
1387 if (is_wp_error($stored) || 0 === count($stored)) {
1388 return '';
1389 }
1390 $fileName = trim((string) $stored[0]->meta_value);
1391
1392 // signature-failed.png is a shared placeholder, not this entry's own file.
1393 return 'signature-failed.png' === $fileName ? '' : $fileName;
1394 }
1395
1396 private function setSignatureFilePathInRepeater($repeaterData, $repeaterFieldKey, $formFields, $entry_id, &$submitted_data)
1397 {
1398 foreach ($repeaterData as $rptr_entry_index => $rptr_entries) {
1399 foreach ($rptr_entries as $entry_key => $entry_value) {
1400 if (!isset($formFields->{$entry_key})) {
1401 continue;
1402 }
1403 $rptr_entry_info = $formFields->{$entry_key};
1404
1405 if ('signature' === $rptr_entry_info->typ) {
1406 $imgType = $rptr_entry_info->config->imgTyp;
1407 $signatureImage = $this->getSignatureFilePath($entry_value, $this->form_id, $repeaterFieldKey, $entry_id, $imgType);
1408 $submitted_data[$repeaterFieldKey][$rptr_entry_index][$entry_key] = $signatureImage;
1409 }
1410 }
1411 }
1412 }
1413
1414 public function passwordEncrypted($updatedValue, $form_fields)
1415 {
1416 $integrationHandler = new IntegrationHandler($this->form_id);
1417 $formIntegrations = $integrationHandler->getAllIntegration('wp_user_auth', 'wp_auth', 1);
1418 if (!isset($formIntegrations->errors['result_empty'])) {
1419 foreach ($form_fields as $field) {
1420 if (array_key_exists($field['key'], $updatedValue) && 'password' === $field['type']) {
1421 $updatedValue[$field['key']] = '**** (encrypted)';
1422 }
1423 }
1424 }
1425 return $updatedValue;
1426 }
1427
1428 private function normalizeOldFileValues($stored_files, $old_values)
1429 {
1430 $stored_files = is_array($stored_files) ? $stored_files : [];
1431 if (!is_array($old_values)) {
1432 $old_values_string = trim((string) $old_values);
1433 $decoded_old_values = json_decode($old_values_string, true);
1434 $old_values = is_array($decoded_old_values) ? $decoded_old_values : explode(',', $old_values_string);
1435 }
1436
1437 $normalized_values = [];
1438 foreach ($old_values as $value) {
1439 if (!is_string($value) && !is_numeric($value)) {
1440 continue;
1441 }
1442
1443 $trimmed_value = trim((string) $value);
1444 if ('' === $trimmed_value) {
1445 continue;
1446 }
1447
1448 if (in_array($trimmed_value, $stored_files, true)) {
1449 $normalized_values[] = $trimmed_value;
1450 }
1451 }
1452
1453 return array_values(array_unique($normalized_values));
1454 }
1455
1456 public function updateFormEntry($updatedValue, $formID, $entryID)
1457 {
1458 // CSRF / entry-token verified upstream via FrontendFormManager::handleUpdateEntry() before this method is invoked.
1459 $updatedValue = $this->formatSubmittedData($updatedValue);
1460 $updatedValue = apply_filters('bitform_filter_update_form_entry', $updatedValue, $this->form_id);
1461 do_action('bitform_update_entry', $this, $updatedValue, $formID, $entryID);
1462 $form_content = $this->getFormContent();
1463 if (isset($form_content->additional->enabled->submission)) {
1464 // Run workflow but skip DB/meta update
1465 $workFlowRunHelper = new WorkFlow($formID);
1466 $fieldsWithValue = $this->getFormContentWithValue($updatedValue)->fields;
1467 $workFlowreturnedOnSubmit = $workFlowRunHelper->executeOnSubmit(
1468 'edit',
1469 $fieldsWithValue,
1470 $updatedValue,
1471 $entryID,
1472 0
1473 );
1474 if (empty($workFlowreturnedOnSubmit['message'])) {
1475 $workFlowreturnedOnSubmit['message'] = __('Entry update skipped due to submission restriction.', 'bit-form');
1476 }
1477 $workFlowreturnedOnSubmit['entry_id'] = $entryID;
1478 $workFlowreturnedOnSubmit = apply_filters('bitform_filter_return_edit_success', $workFlowreturnedOnSubmit, $this->form_id);
1479 return $workFlowreturnedOnSubmit;
1480 }
1481
1482 $formEntryModel = new FormEntryModel();
1483 $formEntryLogModel = new FormEntryLogModel();
1484 $formOldData = $formEntryLogModel->get_form_value($entryID);
1485 $key = null;
1486 $entryMeta = new FormEntryMetaModel();
1487 $ipTool = new IpTool();
1488 $user_details = $ipTool->getUserDetail();
1489 $user_details = apply_filters('bitform_filter_user_details', $user_details, $this->form_id);
1490 $user_details = apply_filters('bitform_filter_update_entry_user_details', $user_details, $this->form_id);
1491
1492 $form_fields = $this->getFields();
1493
1494 $updatedValue = $this->passwordEncrypted($updatedValue, $form_fields);
1495 $updatedValue = $this->formatRepeateFieldData($updatedValue, $form_fields);
1496 $field_map = [];
1497 foreach ($formOldData as $index => $data) {
1498 foreach ($form_fields as $field_key => $field) {
1499 if ($data->meta_key === $field['key']) {
1500 $field_map[$field_key] = $field['key'];
1501 }
1502 }
1503 }
1504
1505 $geResult = $formEntryModel->get('status', ['form_id' => $formID, 'id' => $entryID]);
1506 if (is_wp_error($geResult) || empty($geResult)) {
1507 return new WP_Error('empty_form', __('provided form entries does not exists', 'bit-form'));
1508 }
1509 $oldEntry = $geResult[0];
1510 $formEntry = $formEntryModel->update(
1511 [
1512 'status' => ('9' === $oldEntry->status && !$this->_saveFormAsDraft) ? 1 : $oldEntry->status,
1513 'updated_at' => $user_details['time'],
1514 ],
1515 [
1516 'form_id' => $formID,
1517 'id' => $entryID,
1518 ]
1519 );
1520
1521 if (is_wp_error($formEntry) && 'result_empty' !== $formEntry->get_error_code()) {
1522 return new WP_Error('entry_update_failed', __('Sorry, error occurred in updating form entry', 'bit-form'));
1523 }
1524
1525 $log_id = $this->submisionLog($user_details, $entryID, 'update');
1526 $formFields = $this->getFields();
1527 $updatedValue = FileHandler::tempDirToUploadDir($updatedValue, $formFields, $this->form_id, $entryID);
1528 $file_fields = $this->getUploadFields();
1529 if (count($file_fields) > 0) {
1530 $fileHandler = new FileHandler();
1531 foreach ($_FILES as $file_name => $file_details) {
1532 if ($file_fields && in_array($file_name, $file_fields)) {
1533 $validation = $fileHandler->validation($file_name, $file_details, $this->form_id);
1534 if (!empty($validation['error_type']) && !empty($validation['message'])) {
1535 return new WP_Error($validation['error_type'], esc_html($validation['message']));
1536 }
1537 }
1538 }
1539 if (is_object($updatedValue)) {
1540 $updatedValue = (array) $updatedValue;
1541 }
1542 foreach ($file_fields as $field_key) {
1543 $repeaterFldKey = $this->isRepeatedField($field_key);
1544 if (isset($updatedValue[$field_key . '_old'])) {
1545 // Handle file deletion for repeater fields
1546 if ($repeaterFldKey) {
1547 // Form entry meta lookup; meta_key/meta_value query required to retrieve repeater field data by entry.
1548 $repeaterExistData = $entryMeta->get(
1549 'meta_value',
1550 [
1551 'bitforms_form_entry_id' => $entryID,
1552 'meta_key' => $repeaterFldKey,
1553 ]
1554 );
1555 if (!is_wp_error($repeaterExistData)) {
1556 // restructor json
1557 $repeaterExistData = json_decode($repeaterExistData[0]->meta_value, true);
1558 $repeaterExistFiles = [];
1559 $repeaterDeleted_files = [];
1560 $repeaterFiles_old = [];
1561 $submittedRepeaterOldFiles = is_array($updatedValue[$field_key . '_old']) ? $updatedValue[$field_key . '_old'] : [];
1562 foreach ($repeaterExistData as $index => $repeaterRow) {
1563 $repeaterExistFiles[$index] = [];
1564 if (isset($repeaterRow[$field_key]) && !empty($repeaterRow[$field_key]) && is_string($repeaterRow[$field_key])) {
1565 $repeaterExistFiles[$index] = json_decode($repeaterRow[$field_key], true);
1566 }
1567 if (isset($repeaterRow[$field_key]) && !empty($repeaterRow[$field_key]) && is_array($repeaterRow[$field_key])) {
1568 $repeaterExistFiles[$index] = $repeaterRow[$field_key];
1569 }
1570 if (!is_array($repeaterExistFiles[$index])) {
1571 $repeaterExistFiles[$index] = [];
1572 }
1573 $oldFileInputExists = array_key_exists($index, $submittedRepeaterOldFiles);
1574 $repeaterRowExists = $oldFileInputExists || (isset($updatedValue[$repeaterFldKey][$index]) && is_array($updatedValue[$repeaterFldKey][$index]));
1575 $oldFileValues = ($repeaterRowExists && $oldFileInputExists) ? $submittedRepeaterOldFiles[$index] : [];
1576 $repeaterFiles_old[$index] = $this->normalizeOldFileValues($repeaterExistFiles[$index], $oldFileValues);
1577 $repeaterDeleted_files[$index] = array_diff($repeaterExistFiles[$index], $repeaterFiles_old[$index]);
1578 $repeaterFiles_old[$index] = array_values(array_diff($repeaterFiles_old[$index], $repeaterDeleted_files[$index]));
1579 $fileHandler->deleteFiles($formID, $entryID, $repeaterDeleted_files[$index]);
1580 if ($repeaterRowExists) {
1581 if (!isset($updatedValue[$repeaterFldKey][$index]) || !is_array($updatedValue[$repeaterFldKey][$index])) {
1582 $updatedValue[$repeaterFldKey][$index] = [];
1583 }
1584 $updatedValue[$repeaterFldKey][$index][$field_key] = $repeaterFiles_old[$index];
1585 }
1586 }
1587 }
1588 } else {
1589 // Handle file deletion for non-repeater fields; meta_key/meta_value lookup required to identify stored file paths per entry.
1590 $file_exists = $entryMeta->get(
1591 'meta_value',
1592 [
1593 'bitforms_form_entry_id' => $entryID,
1594 'meta_key' => $field_key,
1595 ]
1596 );
1597 if (!is_wp_error($file_exists) && count($file_exists) > 0) {
1598 $files_in_db = json_decode($file_exists[0]->meta_value, true);
1599 if (!is_array($files_in_db)) {
1600 $files_in_db = [];
1601 }
1602 $retained_files = $this->normalizeOldFileValues($files_in_db, empty($updatedValue[$field_key . '_old']) ? [] : $updatedValue[$field_key . '_old']);
1603 $deleted_files = array_diff($files_in_db, $retained_files);
1604 $retained_files = array_values(array_diff($retained_files, $deleted_files));
1605 if (count($deleted_files) > 0) {
1606 $fileHandler->deleteFiles($formID, $entryID, $deleted_files);
1607 }
1608 $updatedValue[$field_key] = $retained_files;
1609 }
1610 }
1611 }
1612 if (!empty($_FILES[$field_key]['name'])) {
1613 if ($repeaterFldKey) {
1614 // Handle repeater field files
1615 $file_details = $_FILES[$field_key];
1616 foreach ($file_details['name'] as $index => $file) {
1617 $old_meta_value = [];
1618 // Retrieve existing old files for this specific repeater index
1619 if (isset($repeaterFiles_old[$index - 1]) && count($repeaterFiles_old[$index - 1]) > 0) {
1620 $old_meta_value = $repeaterFiles_old[$index - 1];
1621 // json format causing issue with repeater file in mail attachment as it's sending broken url(for multistep and abandonment form)
1622 // $updatedValue[$repeaterFldKey][$index - 1][$field_key] = wp_json_encode($old_meta_value);
1623 $updatedValue[$repeaterFldKey][$index - 1][$field_key] = $old_meta_value;
1624 }
1625 $repeateFileDetails = [
1626 'name' => $file_details['name'][$index],
1627 'type' => $file_details['type'][$index],
1628 'tmp_name' => $file_details['tmp_name'][$index],
1629 'error' => $file_details['error'][$index],
1630 'size' => $file_details['size'][$index],
1631 ];
1632 $meta_value = $fileHandler->moveUploadedFiles($repeateFileDetails, $formID, $entryID, $index);
1633 if (!empty($meta_value)) {
1634 $mergedMetaValueWithOld = array_merge($old_meta_value, (array) $meta_value);
1635 // json format causing issue with repeater file in mail attachment as it's sending broken url(for multistep and abandonment form)
1636 // $updatedValue[$repeaterFldKey][$index - 1][$field_key] = wp_json_encode($mergedMetaValueWithOld);
1637 $updatedValue[$repeaterFldKey][$index - 1][$field_key] = $mergedMetaValueWithOld;
1638
1639 $_FILES[$field_key]['new_name'][$index - 1] = $mergedMetaValueWithOld;
1640 // $_FILES[$field_key]['file_path'][$index - 1] = $common_file_path . DIRECTORY_SEPARATOR . $meta_value;
1641 }
1642 }
1643 } else {
1644 // Handle non-repeater field files
1645 $meta_value = $fileHandler->moveUploadedFiles($_FILES[$field_key], $formID, $entryID);
1646 if (!empty($meta_value)) {
1647 $_FILES[$field_key]['new_name'] = $meta_value;
1648 if (isset($updatedValue[$field_key . '_old']) && !is_wp_error($file_exists) && count($file_exists) > 0) {
1649 $meta_value = empty($retained_files) ? $meta_value : array_merge($meta_value, $retained_files);
1650 $updatedValue[$field_key] = $meta_value;
1651 } else {
1652 $updatedValue[$field_key] = $meta_value;
1653 }
1654 }
1655 }
1656 }
1657 }
1658
1659 // Get the common file path to avoid repetitive calculation
1660 $this->addNewFilePathToFiles($formID, $entryID, $file_fields);
1661 }
1662
1663 if (is_object($updatedValue)) {
1664 $updatedValue = (array) $updatedValue;
1665 }
1666 if (isset($updatedValue['_ajax_nonce'], $_REQUEST['g-recaptcha-response']) && sanitize_text_field(wp_unslash($_REQUEST['g-recaptcha-response']))) {
1667 unset($updatedValue['_ajax_nonce'], $_REQUEST['g-recaptcha-response']);
1668 }
1669
1670 $toUpdateValues = [];
1671 foreach ($form_fields as $field) {
1672 if (isset($updatedValue[$field['key']])) {
1673 $toUpdateValues[$field['key']] = $updatedValue[$field['key']];
1674 }
1675 }
1676 $form_content = \json_decode($this->form[0]->form_content);
1677
1678 $replacedSignatureFiles = [];
1679
1680 foreach ($form_content->fields as $key => $field) {
1681 if ('signature' === $field->typ) {
1682 $fld_data = isset($updatedValue[$key]) ? $updatedValue[$key] : '';
1683 $img_type = isset($field->config->imgTyp) ? $field->config->imgTyp : 'image/png';
1684 $storedSignature = $this->getStoredSignatureFile($entryMeta, $entryID, $key);
1685 if (is_string($fld_data) && 0 === strpos($fld_data, 'data:')) {
1686 $toUpdateValues[$key] = $this->getSignatureFilePath($fld_data, $this->form_id, $key, $entryID, $img_type);
1687 if ('' !== $storedSignature && $storedSignature !== $toUpdateValues[$key]) {
1688 $replacedSignatureFiles[] = $storedSignature;
1689 }
1690 } elseif (isset($updatedValue[$key . '_old'])) {
1691 // Nothing drawn: `_old` only confirms the stored file was kept, so write that back.
1692 $retained = FieldValueHandler::retainedOldValues($updatedValue, $key);
1693 $keepsStored = '' !== $storedSignature && in_array($storedSignature, $retained, true);
1694 $toUpdateValues[$key] = $keepsStored ? $storedSignature : '';
1695 if (!$keepsStored && '' !== $storedSignature) {
1696 $replacedSignatureFiles[] = $storedSignature;
1697 }
1698 } else {
1699 // No signature and no `_old` marker: leave what is stored alone.
1700 unset($toUpdateValues[$key]);
1701 }
1702 }
1703
1704 // for Signature field inside reepater
1705 if ('repeater' === $field->typ && isset($updatedValue[$key]) && is_array($updatedValue[$key])) {
1706 $rptr_data = $updatedValue[$key];
1707 $formFields = $form_content->fields;
1708 $this->setSignatureFilePathInRepeater($rptr_data, $key, $formFields, $entryID, $toUpdateValues);
1709 }
1710 }
1711
1712 $workFlowRunHelper = new WorkFlow($formID);
1713 $workFlowreturnedOnSubmit = $workFlowRunHelper->executeOnSubmit(
1714 'edit',
1715 $this->getFormContentWithValue($toUpdateValues)->fields,
1716 $toUpdateValues,
1717 $entryID,
1718 $log_id
1719 );
1720
1721 if (!empty($workFlowreturnedOnSubmit['fields'])) {
1722 $updatedValue = $workFlowreturnedOnSubmit['fields'];
1723 }
1724
1725 $formEntryMetaUpdateStatus = $entryMeta->update(
1726 $toUpdateValues,
1727 [
1728 'bitforms_form_entry_id' => $entryID,
1729 ]
1730 );
1731 if (is_wp_error($formEntryMetaUpdateStatus) || isset($newFileInsertStatus) && is_wp_error($newFileInsertStatus)) {
1732 do_action('bitform_update_entry_error', $this, $toUpdateValues, $formEntryMetaUpdateStatus, $this->form_id);
1733 return $formEntryMetaUpdateStatus;
1734 }
1735 // Deleted only now the entry points elsewhere, so a failed update strands nothing.
1736 if (!empty($replacedSignatureFiles)) {
1737 (new FileHandler())->deleteFiles($formID, $entryID, $replacedSignatureFiles);
1738 }
1739 $toUpdateValues = array_merge($formEntryMetaUpdateStatus, ['entry_id' => $entryID]);
1740 do_action('bitform_after_update_entry_success', $this, $toUpdateValues, $formID, $entryID);
1741 if (empty($workFlowreturnedOnSubmit['message'])) {
1742 $workFlowreturnedOnSubmit['message'] = __('Entry Updated Successfully', 'bit-form');
1743 }
1744 $customFieldHandler = new CustomFieldHandler();
1745 $toUpdateValues = $customFieldHandler->updatedData($form_fields, $toUpdateValues);
1746
1747 $workFlowreturnedOnSubmit['updatedData'] = $toUpdateValues;
1748 $counter = 0;
1749 for ($i = 0; $i < count($formOldData); $i++) {
1750 if (array_key_exists($formOldData[$i]->meta_key . '_old', $toUpdateValues)) {
1751 unset($toUpdateValues[$formOldData[$i]->meta_key . '_old']);
1752 }
1753 if (in_array($formOldData[$i]->meta_key, $file_fields)) {
1754 if (
1755 empty($_FILES[$formOldData[$i]->meta_key]['name'])
1756 || (is_array($_FILES[$formOldData[$i]->meta_key]['name'])
1757 && 1 === count($_FILES[$formOldData[$i]->meta_key]['name'])
1758 && empty($_FILES[$formOldData[$i]->meta_key]['name'][0]))
1759 ) {
1760 unset($toUpdateValues[$formOldData[$i]->meta_key]);
1761 continue;
1762 }
1763 if (is_array($_FILES[$formOldData[$i]->meta_key]['name']) && !in_array($_FILES[$formOldData[$i]->meta_key]['name'], json_decode($formOldData[$i]->meta_value))) {
1764 $sanitized_names = array_map('sanitize_file_name', array_map('wp_unslash', (array) $_FILES[$formOldData[$i]->meta_key]['name']));
1765 $key[$i] = '${' . $formOldData[$i]->meta_key . '} file was Updated To ' . wp_json_encode($sanitized_names);
1766 } elseif (!is_array($_FILES[$formOldData[$i]->meta_key]['name']) && !in_array($_FILES[$formOldData[$i]->meta_key]['name'], json_decode($formOldData[$i]->meta_value))) {
1767 $key[$i] = '${' . $formOldData[$i]->meta_key . '} file was Updated To ' . sanitize_file_name(wp_unslash($_FILES[$formOldData[$i]->meta_key]['name']));
1768 }
1769 unset($toUpdateValues[$formOldData[$i]->meta_key]);
1770 } elseif (isset($toUpdateValues[$formOldData[$i]->meta_key])) {
1771 if (is_array($toUpdateValues[$formOldData[$i]->meta_key])) {
1772 if (json_decode($formOldData[$i]->meta_value) !== $toUpdateValues[$formOldData[$i]->meta_key]) {
1773 $key[$i] = '${' . $formOldData[$i]->meta_key . '} was Updated From ' . implode(',', json_decode($formOldData[$i]->meta_value)) . ' To ' . implode(',', $toUpdateValues[$formOldData[$i]->meta_key]);
1774 }
1775 } elseif (is_string($toUpdateValues[$formOldData[$i]->meta_key]) && !FieldValueHandler::isEmpty($toUpdateValues[$formOldData[$i]->meta_key])) {
1776 if ($formOldData[$i]->meta_value !== $toUpdateValues[$formOldData[$i]->meta_key]) {
1777 $key[$i] = '${' . $formOldData[$i]->meta_key . '} was Updated' . ($formOldData[$i]->meta_value ? ' From ' . $formOldData[$i]->meta_value : '') . ' To ' . $toUpdateValues[$formOldData[$i]->meta_key];
1778 }
1779 }
1780 }
1781 $counter++;
1782 }
1783
1784 $newField = array_keys(array_diff_key($formEntryMetaUpdateStatus, $field_map));
1785 for ($i = 0; $i < count($newField); $i++) {
1786 if (is_array($toUpdateValues[$newField[$i]]) && !empty($toUpdateValues[$newField[$i]])) {
1787 $key[$counter + $i] = '${' . $newField[$i] . '} Updated To ' . implode(',', $toUpdateValues[$newField[$i]]);
1788 } elseif (is_string($newField[$i]) && !FieldValueHandler::isEmpty($toUpdateValues[$newField[$i]])) {
1789 $key[$counter + $i] = '${' . $newField[$i] . '} Updated To ' . $toUpdateValues[$newField[$i]];
1790 }
1791 }
1792 if (null !== $key) {
1793 $logUpdate = implode('b::f', (array) $key);
1794 $formEntryLogUpdate = $formEntryLogModel->logUpdate($logUpdate, $log_id);
1795 }
1796 $workFlowreturnedOnSubmit['entry_id'] = $entryID;
1797 $workFlowreturnedOnSubmit = apply_filters('bitform_filter_return_edit_success', $workFlowreturnedOnSubmit, $this->form_id);
1798
1799 return $workFlowreturnedOnSubmit;
1800 }
1801
1802 public function getRepeaterFields()
1803 {
1804 if (!is_null($this->_repeaterFields)) {
1805 return $this->_repeaterFields;
1806 }
1807 $repeaterFields = [];
1808 $form_content = \json_decode($this->form[0]->form_content);
1809 $fields = $form_content->fields;
1810 $nestedLayouts = !empty($form_content->nestedLayout) ? $form_content->nestedLayout : [];
1811 foreach ($nestedLayouts as $fieldKey => $repeatLayout) {
1812 if ('repeater' !== $fields->{$fieldKey}->typ) {
1813 continue;
1814 }
1815 $repeaterFields[$fieldKey] = [];
1816 foreach ($repeatLayout->lg as $fieldLayoutData) {
1817 $repeaterFields[$fieldKey][] = $fieldLayoutData->i;
1818 }
1819 }
1820 $this->_repeaterFields = $repeaterFields;
1821 return $repeaterFields;
1822 }
1823
1824 public function isRepeatedField($fieldKey)
1825 {
1826 $repeatedFields = $this->getRepeaterFields();
1827 foreach ($repeatedFields as $repeaterKey => $repeaterFields) {
1828 if (in_array($fieldKey, $repeaterFields)) {
1829 return $repeaterKey;
1830 }
1831 }
1832 return false;
1833 }
1834
1835 public function getParentRepeaterField($fieldKey)
1836 {
1837 $repeatedFields = $this->getRepeaterFields();
1838 foreach ($repeatedFields as $repeaterKey => $repeaterFields) {
1839 if (in_array($fieldKey, $repeaterFields)) {
1840 return $repeaterKey;
1841 }
1842 }
1843 return null;
1844 }
1845
1846 public function isRepeaterField($fieldKey)
1847 {
1848 $repeatedFields = $this->getRepeaterFields();
1849 if (array_key_exists($fieldKey, $repeatedFields)) {
1850 return true;
1851 }
1852 return false;
1853 }
1854
1855 public function fieldNameReplaceOfPost()
1856 {
1857 // CSRF verified upstream before this method is called; $_POST/$_FILES are being normalized (field key remapping), not reading new user input.
1858 $fields = $this->getFields();
1859 $consumedNames = [];
1860 foreach ($fields as $fieldKey => $fieldData) {
1861 if (array_key_exists('name', $fieldData)) {
1862 $fldName = $fieldData['name'];
1863 $catchChildFldNamePattern = '/\[(.*?)\]/';
1864 // catching the child field name for confirm field, name field's child
1865 // preg_match_all($catchChildFldNamePattern, $fldName, $matches);
1866 $fldName = preg_replace($catchChildFldNamePattern, '', $fldName);
1867 $fldName = str_replace(['.', ' '], '_', $fldName);
1868 if (!empty($fldName)) {
1869 // The second field silently saves nothing; nothing else reports it.
1870 if (isset($consumedNames[$fldName]) && !isset($fieldData['parentFieldKey'])) {
1871 Log::debug_log([
1872 'status' => 'error',
1873 'code' => 'duplicate_field_name',
1874 'message' => "Field {$fieldKey} shares the name attribute \"{$fldName}\" with {$consumedNames[$fldName]}; its submitted value is dropped.",
1875 'form_id' => $this->form_id,
1876 ]);
1877 }
1878 $consumedNames[$fldName] = $fieldKey;
1879 if (array_key_exists($fldName, $_POST)) {
1880 $temp = $this->sanitize_text_recursive($_POST[$fldName]);
1881 unset($_POST[$fldName]);
1882 $_POST[$fieldKey] = $temp;
1883 } elseif (array_key_exists($fieldKey, $_POST)) {
1884 $_POST[$fieldKey] = $this->sanitize_text_recursive($_POST[$fieldKey]);
1885 } elseif (array_key_exists($fldName, $_FILES)) {
1886 $temp = $this->sanitize_text_recursive($_FILES[$fldName], false);
1887 unset($_FILES[$fldName]);
1888 $_FILES[$fieldKey] = $temp;
1889 } elseif (array_key_exists($fieldKey, $_FILES)) {
1890 $_FILES[$fieldKey] = $this->sanitize_text_recursive($_FILES[$fieldKey], false);
1891 }
1892 // Convert _session_id suffix (used by email-otp and similar fields)
1893 if (array_key_exists($fldName . '_session_id', $_POST)) {
1894 $temp = sanitize_text_field(wp_unslash($_POST[$fldName . '_session_id']));
1895 unset($_POST[$fldName . '_session_id']);
1896 $_POST[$fieldKey . '_session_id'] = $temp;
1897 }
1898 }
1899 }
1900 }
1901 }
1902
1903 private function sanitize_text_recursive($input, $unslash = true)
1904 {
1905 if (is_array($input)) {
1906 return array_map(fn ($item) => $this->sanitize_text_recursive($item, $unslash), $input);
1907 }
1908
1909 return sanitize_text_field($unslash ? wp_unslash($input) : $input);
1910 }
1911
1912 private function normalizeRepeatedCompositeFieldInput($value)
1913 {
1914 if (!is_array($value) || empty($value)) {
1915 return $value;
1916 }
1917
1918 $hasNestedArray = false;
1919 foreach ($value as $childValues) {
1920 if (!is_array($childValues)) {
1921 return $value;
1922 }
1923 $hasNestedArray = true;
1924 }
1925
1926 if (!$hasNestedArray) {
1927 return $value;
1928 }
1929
1930 $formattedValue = [];
1931 foreach ($value as $childKey => $childValues) {
1932 foreach ($childValues as $repeatIndex => $repeatValue) {
1933 if (!isset($formattedValue[$repeatIndex]) || !is_array($formattedValue[$repeatIndex])) {
1934 $formattedValue[$repeatIndex] = [];
1935 }
1936 $formattedValue[$repeatIndex][$childKey] = $repeatValue;
1937 }
1938 }
1939
1940 return $formattedValue;
1941 }
1942
1943 public function setSubmissionCount($countStep = 1)
1944 {
1945 $update_status = $this->formModel->update(
1946 [
1947 'entries' => intval($this->form[0]->entries) + $countStep,
1948 ],
1949 [
1950 'id' => $this->form_id,
1951 ]
1952 );
1953 }
1954
1955 public function resetSubmissionCount($countStep)
1956 {
1957 $update_status = $this->formModel->update(
1958 [
1959 'entries' => intval($countStep),
1960 ],
1961 [
1962 'id' => $this->form_id,
1963 ]
1964 );
1965 }
1966
1967 public function getCaptchaSettings()
1968 {
1969 $formContents = $this->getFormContent();
1970 $fieldStr = wp_json_encode($formContents->fields);
1971 if (false !== strpos($fieldStr, '"typ":"recaptcha"')) {
1972 return true;
1973 }
1974 }
1975
1976 public function getTurnstileSettings()
1977 {
1978 $formContents = $this->getFormContent();
1979 $fieldStr = wp_json_encode($formContents->fields);
1980 if (false !== strpos($fieldStr, '"typ":"turnstile"')) {
1981 return true;
1982 }
1983 }
1984
1985 public function isFieldTypeExist($fieldType)
1986 {
1987 $formContents = $this->getFormContent();
1988 $fieldStr = wp_json_encode($formContents->fields);
1989 if (false !== strpos($fieldStr, '"typ":"' . $fieldType . '"')) {
1990 return true;
1991 }
1992 }
1993
1994 public function getCaptchaV3Settings()
1995 {
1996 $formContents = $this->getFormContent();
1997 if (!empty($formContents->additional->enabled) && !empty($formContents->additional->enabled->recaptchav3)) {
1998 return $formContents->additional->settings->recaptchav3;
1999 }
2000 return false;
2001 }
2002
2003 // public function getSuccessMessageMarkups() {
2004 // if (is_null($this->_work_flows)) {
2005 // $workFlowManager = new WorkFlowHandler($this->form_id);
2006 // $this->_work_flows = $workFlowManager->getAllworkFlow();
2007 // }
2008
2009 // $ids = [];
2010 // foreach ($this->_work_flows as $msgItem) {
2011 // foreach ($msgItem['conditions'] as $condition) {
2012 // if (isset($condition->actions->success)) {
2013 // foreach ($condition->actions->success as $msg) {
2014 // if ('successMsg' === $msg->type && isset($msg->details->id)) {
2015 // $msgDetailsId = $msg->details->id;
2016 // $idObj = json_decode(stripslashes($msgDetailsId));
2017 // if (is_object($idObj) && !empty($idObj->id)) {
2018 // array_push($ids, $idObj->id);
2019 // }
2020 // }
2021 // }
2022 // }
2023 // if (isset($condition->actions->failure)) {
2024 // $idObj = json_decode(stripslashes($condition->actions->failure));
2025 // if (is_object($idObj) && !empty($idObj->id)) {
2026 // array_push($ids, $idObj->id);
2027 // }
2028 // }
2029 // }
2030 // }
2031 // $ids = array_unique($ids);
2032 // if (is_null($this->_conf_messages)) {
2033 // $successMsgHandler = new SuccessMessageHandler($this->form_id);
2034 // $this->_conf_messages = $successMsgHandler->getMessages($ids);
2035 // }
2036
2037 // $messageMarkups = '';
2038 // if (is_wp_error($this->_conf_messages)) {
2039 // return $messageMarkups;
2040 // }
2041
2042 // foreach ($this->_conf_messages as $key => $msgItem) {
2043 // $messageMarkups .= $this->messageMarkup($msgItem->id);
2044 // }
2045
2046 // return $messageMarkups;
2047 // }
2048
2049 // private function messageMarkup($msgId) {
2050 // return <<<SUCCESSMSG
2051 // <div role="dialog" aria-hidden="true" data-modal-backdrop="true" class="{$this->getAtomicCls("msg-container-{$msgId}")} deactive2 test">
2052 // <div role="button" class="{$this->getAtomicCls("msg-background-{$msgId}")} msg-backdrop">
2053 // <div class="bf-notification-message {$this->getAtomicCls("msg-content-{$msgId}")}">
2054 // <button class="{$this->getAtomicCls("close-{$msgId}")} bf-msg-close" type="button">
2055 // <svg class="{$this->getAtomicCls("close-icn-{$msgId}")}" viewBox="0 0 30 30">
2056 // <line fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" x1="4" y1="3.88" x2="26" y2="26.12"></line>
2057 // <line fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" x1="26" y1="3.88" x2="4" y2="26.12"></line>
2058 // </svg>
2059 // </button>
2060 // <div class="msg-content"></div>
2061 // </div>
2062 // </div>
2063 // </div>
2064 // SUCCESSMSG;
2065 // }
2066
2067 public function getAtomicCls($element)
2068 {
2069 $atomicClassMap = $this->getAtomicClsMap();
2070 if (is_object($atomicClassMap) && property_exists($atomicClassMap, ".$element")) {
2071 $getAtomicCls = $atomicClassMap->{".$element"};
2072 return implode(' ', $getAtomicCls) . " $element";
2073 }
2074 return $element;
2075 }
2076
2077 public function isGCLIDEnabled()
2078 {
2079 $formContents = $this->getFormContent();
2080 if (isset($formContents->additional->enabled->captureGCLID) && $formContents->additional->enabled->captureGCLID) {
2081 return true;
2082 }
2083 return false;
2084 }
2085
2086 protected function addEntryInfo($field_details, $counter)
2087 {
2088 $infos = [
2089 '__user_id' => __('User', 'bit-form'),
2090 '__entry_status' => __('Status', 'bit-form'),
2091 //'__user_location' => __(''),
2092 '__referer' => __('Refer URL', 'bit-form'),
2093 '__user_device' => __('Device', 'bit-form'),
2094 '__user_ip' => __('IP address', 'bit-form'),
2095 '__created_at' => __('Created Time', 'bit-form'),
2096 '__updated_at' => __('Modified Time', 'bit-form'),
2097 ];
2098 foreach ($infos as $key => $value) {
2099 $field_details[$counter]['name'] = $value;
2100 $field_details[$counter]['key'] = $key;
2101 $field_details[$counter]['type'] = 'sys';
2102 $counter = $counter + 1;
2103 }
2104
2105 return $field_details;
2106 }
2107 }
2108