PluginProbe
The Innovative Form Builder – IvyForms / 1.4
The Innovative Form Builder – IvyForms v1.4
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / backend / src / Common / Sanitizer / Sanitizer.php

Sanitizer.php in The Innovative Form Builder – IvyForms 1.4, at backend/src/Common/Sanitizer/Sanitizer.php

2,276 lines 86.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © Melograno Venture Studio. All rights reserved.
5 * @licence See COPYING.md for license details.
6 */
7
8 namespace IvyForms\Common\Sanitizer;
9
10 use IvyForms\Common\Constants\SupportedCurrencyCodes;
11 use IvyForms\Common\Exceptions\ForbiddenException;
12 use IvyForms\Common\Exceptions\InvalidArgumentException;
13 use IvyForms\Services\Field\FieldType;
14 use IvyForms\Services\Form\Style\StyleConstants;
15 use IvyForms\Services\Template\TemplateDefaults;
16 use IvyForms\Services\Translations\BackendStrings;
17
18 class Sanitizer
19 {
20 public const FORM_TYPE_CLASSIC = 'classic';
21 public const FORM_TYPE_CONVERSATIONAL = 'conversational';
22
23 /**
24 * Sanitize a field default value based on field type.
25 * Pro field types can override via ivyforms/sanitizer/default_value.
26 *
27 * @param string $fieldType
28 * @param mixed $value
29 *
30 * @return string
31 */
32 private static function sanitizeDefaultValue(string $fieldType, $value): string
33 {
34 if (is_array($value)) {
35 $sanitized = array_map(function ($item) {
36 return sanitize_text_field(is_scalar($item) ? (string) $item : '');
37 }, array_slice(array_values($value), 0, 2));
38
39 return wp_json_encode($sanitized) ?: '';
40 }
41
42 $value = is_scalar($value) ? (string) $value : '';
43
44 if ($fieldType === 'rich_text') {
45 /**
46 * @param string|null $sanitized
47 * @param string $type
48 * @param string $rawValue
49 */
50 $filtered = apply_filters('ivyforms/sanitizer/default_value', null, $fieldType, $value);
51 if ($filtered !== null) {
52 return (string) $filtered;
53 }
54
55 return HtmlSanitizer::sanitizeEditorContent($value);
56 }
57
58 /**
59 * @param string|null $sanitized
60 * @param string $type
61 * @param string $rawValue
62 */
63 $filtered = apply_filters('ivyforms/sanitizer/default_value', null, $fieldType, $value);
64 if ($filtered !== null) {
65 return (string) $filtered;
66 }
67
68 return sanitize_text_field($value);
69 }
70
71 /**
72 * Sanitize a submission value for an unknown or extension field type.
73 * Pro/add-ons register ivyforms/sanitizer/submission_field_value.
74 *
75 * @param string $fieldType
76 * @param mixed $value
77 *
78 * @return mixed|null Filtered value, or null to use lite defaults.
79 */
80 private static function filterSubmissionFieldValue(string $fieldType, $value)
81 {
82 /**
83 * @param mixed|null $sanitized
84 * @param string $type
85 * @param mixed $rawValue
86 */
87 return apply_filters('ivyforms/sanitizer/submission_field_value', null, $fieldType, $value);
88 }
89
90 /**
91 * Sanitize form action buttons settings
92 *
93 * @param mixed[] $settings
94 *
95 * @return mixed[]
96 */
97 public static function sanitizeFormActionButtons(array $settings): array
98 {
99 $submitButtonSettings = $settings['submitButtonSettings'] ?? [];
100 $validPositions = ['default', 'left', 'center', 'right'];
101 $position = $submitButtonSettings['position'] ?? 'default';
102
103 // Validate position
104 if (!in_array($position, $validPositions, true)) {
105 $position = 'default';
106 }
107
108 $sanitized = [
109 'submitButtonSettings' => [
110 'label' => sanitize_text_field(
111 $submitButtonSettings['label'] ?? BackendStrings::getCommonStrings()['submit']
112 ),
113 'position' => $position
114 ]
115 ];
116
117 // Allow extensions (e.g. Pro) to merge pageNavigationSettings and other keys.
118 // Always call apply_filters; do not gate on has_filter (extensions register at plugins_loaded).
119 return apply_filters('ivyforms/sanitize/form_action_buttons', $sanitized, $settings);
120 }
121
122 /**
123 * Sanitize payment settings payload (inner shape stored in form settings / API).
124 * Mirrors {@see self::sanitizeIntegrationSettings()}: invalid input normalizes to defaults.
125 *
126 * @param mixed $paymentSettings
127 *
128 * @return array{currency:string}
129 */
130 public static function sanitizePaymentSettings($paymentSettings): array
131 {
132 if (!is_array($paymentSettings)) {
133 return self::sanitizePaymentSettingsInner([]);
134 }
135
136 return self::sanitizePaymentSettingsInner($paymentSettings);
137 }
138
139 /**
140 * Whitelist known payment setting keys and coerce types.
141 *
142 * @param array<string, mixed> $paymentSettings
143 *
144 * @return array{currency:string}
145 */
146 private static function sanitizePaymentSettingsInner(array $paymentSettings): array
147 {
148 $code = strtoupper(trim(sanitize_text_field($paymentSettings['currency'] ??
149 SupportedCurrencyCodes::DEFAULT_CODE)));
150 if (!SupportedCurrencyCodes::isAllowed($code)) {
151 $code = SupportedCurrencyCodes::DEFAULT_CODE;
152 }
153
154 return [
155 'currency' => $code,
156 ];
157 }
158
159 /**
160 * Normalize form presentation type.
161 *
162 * The default allowed types are 'classic' (always) and 'conversational' (only when the Pro
163 * gate filter `ivyforms/form/can_use_form_type_conversational` returns true). Additional
164 * types (e.g. quizzes, polls, user-defined types) can be registered via the
165 * `ivyforms/form/allowed_form_types` filter, which receives the resolved allowlist and
166 * must return an array of lowercase string identifiers.
167 *
168 * @param mixed $raw
169 * @return string
170 */
171 public static function sanitizeFormType($raw): string
172 {
173 $value = strtolower(sanitize_text_field(is_scalar($raw) ? (string) $raw : ''));
174
175 if (in_array($value, self::getAllowedFormTypes(), true)) {
176 return $value;
177 }
178
179 return self::FORM_TYPE_CLASSIC;
180 }
181
182 /**
183 * Build the allowlist of form types accepted by sanitizeFormType().
184 *
185 * @return string[]
186 */
187 public static function getAllowedFormTypes(): array
188 {
189 $types = [self::FORM_TYPE_CLASSIC];
190
191 if (apply_filters('ivyforms/form/can_use_form_type_conversational', false)) {
192 $types[] = self::FORM_TYPE_CONVERSATIONAL;
193 }
194
195 $filtered = apply_filters('ivyforms/form/allowed_form_types', $types);
196
197 if (!is_array($filtered) || $filtered === []) {
198 return [self::FORM_TYPE_CLASSIC];
199 }
200
201 $normalized = array_values(array_unique(array_filter(array_map(
202 static function ($type) {
203 return is_string($type) ? strtolower(trim($type)) : '';
204 },
205 $filtered
206 ))));
207
208 if (!in_array(self::FORM_TYPE_CLASSIC, $normalized, true)) {
209 $normalized[] = self::FORM_TYPE_CLASSIC;
210 }
211
212 return $normalized;
213 }
214
215 /**
216 * Sanitize all form data
217 *
218 * @param mixed[] $data
219 * @return mixed[]
220 * @throws InvalidArgumentException
221 */
222 public static function sanitizeFormData(array $data): array
223 {
224 $fields = $data['fields'] ?? [];
225 $formTypeRaw = $data['formType'] ?? 'classic';
226
227 $sanitized = [
228 'id' => (int)($data['id'] ?? 0),
229 'name' => sanitize_text_field($data['name'] ?? ''),
230 'description' => sanitize_text_field($data['description'] ?? ''),
231 'formType' => self::sanitizeFormType($formTypeRaw),
232 'showTitle' => (bool)($data['showTitle'] ?? false),
233 'published' => (bool)($data['published'] ?? false),
234 'showDescription' => (bool)($data['showDescription'] ?? false),
235 'storeEntries' => (bool)($data['storeEntries'] ?? false),
236 'fields' => self::sanitizeFields($fields),
237 'integrationSettings' => self::sanitizeIntegrationSettings(
238 $data['integrationSettings'] ?? []
239 ),
240 'styleSettings' => self::sanitizeStyleSettings($data['styleSettings'] ?? null),
241 'formActionButtons' => self::sanitizeFormActionButtons(
242 $data['formActionButtons'] ?? []
243 ),
244 'paymentSettings' => self::sanitizePaymentSettings(
245 $data['paymentSettings'] ?? []
246 ),
247 ];
248
249 /**
250 * Allows Pro conversational hooks to enrich sanitized form data.
251 *
252 * @param array<string, mixed> $sanitized The sanitized form data
253 * @param array<string, mixed> $data The raw form data
254 * @return array<string, mixed> The modified sanitized data
255 */
256 $defaults = $sanitized;
257 $sanitized = apply_filters('ivyforms/form/sanitized_form_data', $sanitized, $data);
258 if (!is_array($sanitized)) {
259 $sanitized = $defaults;
260 }
261
262 /**
263 * Allows Pro plugin to add additional form data properties (pages, progressIndicator, etc.)
264 *
265 * @since 0.1.0
266 *
267 * @param array<string, mixed> $sanitized The sanitized form data
268 * @param array<string, mixed> $data The raw form data
269 * @return array<string, mixed> The modified sanitized data
270 */
271 $filtered = apply_filters('ivyforms/sanitize/form_data', $sanitized, $data);
272 if (!is_array($filtered)) {
273 $filtered = $sanitized;
274 }
275
276 if (!array_key_exists('name', $filtered)) {
277 $filtered['name'] = $sanitized['name'] ?? $defaults['name'];
278 }
279
280 return $filtered;
281 }
282 /**
283 * Sanitize all notification data
284 *
285 * @param mixed[] $data
286 *
287 * @return mixed[]
288 */
289 public static function sanitizeNotificationData(array $data): array
290 {
291 return [
292 'id' => (int)($data['id'] ?? 0),
293 'name' => sanitize_text_field($data['name'] ?? ''),
294 'sender' => self::sanitizeEmailOrPlaceholder($data['sender'] ?? ''),
295 'replyTo' => self::sanitizeEmailOrPlaceholder($data['replyTo'] ?? ''),
296 'receiver' => self::sanitizeEmailOrPlaceholder($data['receiver'] ?? ''),
297 'enabled' => (bool)($data['enabled'] ?? true),
298 'subject' => sanitize_text_field($data['subject'] ?? ''),
299 'message' => HtmlSanitizer::sanitizeEditorContent($data['message'] ?? ''),
300 'showEmptyFields' => (bool)($data['showEmptyFields'] ?? false),
301 'smartLogic' => self::sanitizeNotificationSmartLogic($data['smartLogic'] ?? false),
302 'formId' => (int)($data['formId'] ?? 0),
303 ];
304 }
305
306 /**
307 * Sanitize notification smart logic payload.
308 *
309 * @param mixed $smartLogic
310 * @return array<string, mixed>
311 */
312 public static function sanitizeNotificationSmartLogic($smartLogic): array
313 {
314 if (!is_array($smartLogic)) {
315 return [
316 'enabled' => (bool)$smartLogic,
317 'match' => 'any',
318 'rules' => [],
319 ];
320 }
321
322 $match = sanitize_text_field((string)($smartLogic['match'] ?? 'any'));
323 if (!in_array($match, ['any', 'all'], true)) {
324 $match = 'any';
325 }
326
327 $rules = [];
328 $rawRules = $smartLogic['rules'] ?? [];
329 if (is_array($rawRules)) {
330 foreach ($rawRules as $rule) {
331 if (!is_array($rule)) {
332 continue;
333 }
334
335 $operator = sanitize_text_field((string)($rule['operator'] ?? 'equals'));
336 if (!in_array($operator, ['equals', 'not_equals'], true)) {
337 $operator = 'equals';
338 }
339
340 $field = isset($rule['field']) ? sanitize_text_field((string)$rule['field']) : null;
341
342 $rules[] = [
343 'id' => isset($rule['id']) ? (int)$rule['id'] : 0,
344 'field' => $field === '' ? null : $field,
345 'operator' => $operator,
346 'value' => sanitize_text_field((string)($rule['value'] ?? '')),
347 ];
348 }
349 }
350
351 return [
352 'enabled' => (bool)($smartLogic['enabled'] ?? false),
353 'match' => $match,
354 'rules' => $rules,
355 ];
356 }
357 /**
358 * Sanitize all confirmation data
359 *
360 * @param mixed[] $data
361 *
362 * @return mixed[]
363 */
364 public static function sanitizeConfirmationData(array $data): array
365 {
366 return [
367 'id' => (int)($data['id'] ?? 0),
368 'formId' => (int)($data['formId'] ?? 0),
369 'name' => sanitize_text_field($data['name'] ?? ''),
370 'type' => sanitize_text_field($data['type'] ?? ''),
371 'enabled' => (bool)($data['enabled'] ?? true),
372 'showForm' => (bool)($data['showForm'] ?? false),
373 'message' => HtmlSanitizer::sanitizeEditorContent($data['message'] ?? ''),
374 'url' => sanitize_url(
375 isset($data['url']) ? urldecode($data['url']) : ''
376 ),
377 'page' => sanitize_text_field($data['page'] ?? ''),
378 'position' => (int)($data['position'] ?? 0),
379 'isDefault' => (bool)($data['isDefault'] ?? false),
380 'smartLogic' => self::sanitizeNotificationSmartLogic($data['smartLogic'] ?? false),
381 ];
382 }
383
384 /**
385 * Sanitize confirmation reorder request payload.
386 *
387 * @param mixed[] $data
388 *
389 * @return array{formId: int, orderedIds: int[]}
390 */
391 public static function sanitizeConfirmationReorderData(array $data): array
392 {
393 $orderedIds = [];
394 if (!empty($data['orderedIds']) && is_array($data['orderedIds'])) {
395 foreach ($data['orderedIds'] as $id) {
396 $orderedIds[] = (int) $id;
397 }
398 }
399
400 return [
401 'formId' => (int) ($data['formId'] ?? 0),
402 'orderedIds' => $orderedIds,
403 ];
404 }
405
406 /**
407 * Sanitize form fields
408 *
409 * @param mixed[] $fields
410 *
411 * @return mixed[]
412 * @throws InvalidArgumentException
413 */
414 private static function sanitizeFields(array $fields): array
415 {
416 return array_map(function ($field) {
417 // Validate field type
418 $fieldType = sanitize_text_field($field['type'] ?? '');
419 if (!FieldType::isValid($fieldType)) {
420 throw new InvalidArgumentException(
421 BackendStrings::getExceptionStrings()['invalid_field_type']
422 );
423 }
424
425 $sanitizedField = [
426 'id' => (int)($field['id'] ?? 0),
427 'fieldIndex' => (int)($field['fieldIndex'] ?? 0),
428 'type' => $fieldType,
429 'label' => sanitize_text_field($field['label'] ?? ''),
430 'required' => (bool)($field['required'] ?? false),
431 'defaultValue' => self::sanitizeDefaultValue(
432 $fieldType,
433 $field['defaultValue'] ?? ''
434 ),
435 'placeholder' => sanitize_text_field($field['placeholder'] ?? ''),
436 'position' => (int)($field['position'] ?? 0),
437 'rowIndex' => max(0, (int)($field['rowIndex'] ?? 0)),
438 'columnIndex' => max(0, min(4, (int)($field['columnIndex'] ?? 0))),
439 'width' => max(20, min(100, (int)($field['width'] ?? 100))),
440 'parentId' => isset($field['parentId']) ? max(0, (int)$field['parentId']) : null,
441 'hideLabel' => (bool)($field['hideLabel'] ?? false),
442 'readOnly' => (bool)($field['readOnly'] ?? false),
443 'description' => HtmlSanitizer::sanitizeFieldDescription($field['description'] ?? ''),
444 'requiredMessage' => sanitize_text_field($field['requiredMessage'] ?? ''),
445 'cssClasses' => HtmlSanitizer::sanitizeCssClasses($field['cssClasses'] ?? ''),
446 'fieldOptions' => self::sanitizeFieldOptions($field['fieldOptions'] ?? []),
447 'shuffleOptions' => (bool)($field['shuffleOptions'] ?? false),
448 'showValues' => (bool)($field['showValues'] ?? false),
449 'enableSearch' => (bool)($field['enableSearch'] ?? false),
450 'rows' => (int)($field['rows'] ?? 0),
451 'confirmFieldEnabled' => (bool)($field['confirmFieldEnabled'] ?? false),
452 'confirmFieldLabel' => sanitize_text_field($field['confirmFieldLabel'] ?? ''),
453 'confirmFieldPlaceholder' => sanitize_text_field($field['confirmFieldPlaceholder'] ?? ''),
454 'confirmFieldHideLabel' => (bool)($field['confirmFieldHideLabel'] ?? false),
455 'phoneFormat' => self::sanitizePhoneFormat($field['phoneFormat'] ?? ''),
456 'phoneAutoDetect' => (bool)($field['phoneAutoDetect'] ?? false),
457 'minValue' => self::sanitizeNumericValue($field['minValue'] ?? null),
458 'maxValue' => self::sanitizeNumericValue($field['maxValue'] ?? null),
459 'step' => (float)($field['step'] ?? 1.0),
460 'showGrid' => (bool)($field['showGrid'] ?? false),
461 'numberFormat' => sanitize_text_field($field['numberFormat'] ?? ''),
462 'visible' => (bool)($field['visible'] ?? true),
463 'limitMaxLength' => (bool)($field['limitMaxLength'] ?? false),
464 'limitRange' => (bool)($field['limitRange'] ?? false),
465 'maxLength' => (int)($field['maxLength'] ?? 255),
466 'customValidationMessage' => sanitize_text_field($field['customValidationMessage'] ?? ''),
467 'labelPosition' => sanitize_text_field($field['labelPosition'] ?? 'default'),
468 'noDuplicates' => (bool)($field['noDuplicates'] ?? false),
469 'inputPrefix' => sanitize_text_field($field['inputPrefix'] ?? ''),
470 'inputSuffix' => sanitize_text_field($field['inputSuffix'] ?? ''),
471 'timeFieldType' => sanitize_text_field($field['timeFieldType'] ?? ''),
472 'timeFormat' => sanitize_text_field($field['timeFormat'] ?? ''),
473 'dateFieldType' => sanitize_text_field($field['dateFieldType'] ?? ''),
474 'dateFormat' => sanitize_text_field($field['dateFormat'] ?? ''),
475 'minDateValue' => sanitize_text_field($field['minDateValue'] ?? ''),
476 'maxDateValue' => sanitize_text_field($field['maxDateValue'] ?? ''),
477 'showRatingText' => (bool)($field['showRatingText'] ?? false),
478 'ratingIcon' => sanitize_text_field($field['ratingIcon'] ?? 'star'),
479 'htmlContent' => self::sanitizeFieldHtmlContentByType(
480 $fieldType,
481 (string)($field['htmlContent'] ?? '')
482 ),
483 'agreementText' => HtmlSanitizer::sanitizeAgreementText($field['agreementText'] ?? ''),
484 'limitDateRange' => (bool)($field['limitDateRange'] ?? false),
485 'minTimeValue' => sanitize_text_field($field['minTimeValue'] ?? ''),
486 'maxTimeValue' => sanitize_text_field($field['maxTimeValue'] ?? ''),
487 'limitTimeRange' => (bool)($field['limitTimeRange'] ?? false),
488 'maxFileSizeMb' => max(0, min(9999, (int)($field['maxFileSizeMb'] ?? 0))),
489 'saveUploadsTo' => self::sanitizeSaveUploadsTo($field['saveUploadsTo'] ?? 'ivyforms'),
490 'allowedFileExtensions' => self::sanitizeAllowedFileExtensionsInput(
491 $field['allowedFileExtensions'] ?? []
492 ),
493 'pageId' => ($pageId = sanitize_text_field($field['pageId'] ?? ''))
494 !== '' ? $pageId : null,
495 'nameFieldType' => sanitize_text_field(
496 $field['nameFieldType']
497 ?? (is_array($field['settings'] ?? null)
498 ? ($field['settings']['nameFieldType'] ?? '')
499 : '')
500 ),
501 'subFieldIndex' => isset($field['subFieldIndex'])
502 ? (int)$field['subFieldIndex']
503 : (is_array($field['settings'] ?? null) && isset($field['settings']['subFieldIndex'])
504 ? (int)$field['settings']['subFieldIndex']
505 : null),
506 ];
507
508 /**
509 * This filter allows to sanitize form fields properties
510 * for fields that are not part of the core plugin.
511 *
512 * @since 0.1.0
513 *
514 * @param mixed[] $sanitizedField The sanitized field data so far.
515 * @param mixed[] $field The original field data before sanitization.
516 * @return mixed[] The modified sanitized field data.
517 */
518 return apply_filters('ivyforms/sanitizer/field_properties', $sanitizedField, $field);
519 }, $fields);
520 }
521
522 /**
523 * Sanitize field options
524 *
525 * @param mixed[] $options
526 *
527 * @return mixed[]
528 */
529 private static function sanitizeFieldOptions(array $options): array
530 {
531 return array_map(function ($option) {
532 return [
533 'id' => (int)($option['id'] ?? 0),
534 'fieldId' => (int)($option['fieldId'] ?? 0),
535 'label' => sanitize_text_field($option['label'] ?? ''),
536 'value' => sanitize_text_field($option['value'] ?? ''),
537 'isDefault' => (bool)($option['isDefault'] ?? false),
538 'position' => (int)($option['position'] ?? 1),
539 ];
540 }, $options);
541 }
542
543 /**
544 * Sanitize field htmlContent based on field type (JSON blobs vs rich HTML).
545 *
546 * @param string $fieldType
547 * @param string $htmlContent
548 * @return string
549 */
550 private static function sanitizeFieldHtmlContentByType(string $fieldType, string $htmlContent): string
551 {
552 if ($fieldType === 'quantity') {
553 return self::sanitizeQuantityFieldHtmlContent($htmlContent);
554 }
555
556 if ($fieldType === 'total') {
557 return self::sanitizeTotalFieldHtmlContent($htmlContent);
558 }
559
560 if ($fieldType === 'product') {
561 return self::sanitizeProductFieldHtmlContent($htmlContent);
562 }
563
564 return HtmlSanitizer::sanitizeEditorContent($htmlContent);
565 }
566
567 /**
568 * Decode htmlContent JSON object blob and re-encode via sanitizer callback.
569 *
570 * @param string $raw
571 * @param callable $sanitizeConfig Receives decoded associative array (or empty for defaults).
572 * @return string
573 */
574 private static function sanitizeJsonHtmlContentBlob(string $raw, callable $sanitizeConfig): string
575 {
576 $trimmed = trim($raw);
577 if ($trimmed === '' || ($trimmed[0] ?? '') !== '{') {
578 return wp_json_encode($sanitizeConfig([]), JSON_UNESCAPED_UNICODE) ?: '';
579 }
580
581 $decoded = json_decode($trimmed, true);
582 if (!is_array($decoded)) {
583 return wp_json_encode($sanitizeConfig([]), JSON_UNESCAPED_UNICODE) ?: '';
584 }
585
586 return wp_json_encode($sanitizeConfig($decoded), JSON_UNESCAPED_UNICODE) ?: '';
587 }
588
589 /**
590 * Strict boolean for JSON field configs (only true or string "true").
591 *
592 * @param mixed $value
593 * @return bool
594 */
595 private static function sanitizeJsonConfigBoolean($value): bool
596 {
597 return $value === true || $value === 'true';
598 }
599
600 /**
601 * Sanitize quantity field linkage JSON stored in htmlContent.
602 *
603 * @param string $raw
604 * @return string
605 */
606 private static function sanitizeQuantityFieldHtmlContent(string $raw): string
607 {
608 return self::sanitizeJsonHtmlContentBlob(
609 $raw,
610 static function (array $decoded): array {
611 $linked = $decoded['linkedProductFieldIndex'] ?? null;
612 $linkedIndex = null;
613 if ($linked !== null && $linked !== '') {
614 $asInt = (int) $linked;
615 if ($asInt >= 0) {
616 $linkedIndex = $asInt;
617 }
618 }
619
620 return [
621 'quantityConfigVersion' => 1,
622 'linkedProductFieldIndex' => $linkedIndex,
623 ];
624 }
625 );
626 }
627
628 /**
629 * Sanitize total field JSON stored in htmlContent.
630 *
631 * @param string $raw
632 * @return string
633 */
634 private static function sanitizeTotalFieldHtmlContent(string $raw): string
635 {
636 return self::sanitizeJsonHtmlContentBlob(
637 $raw,
638 static function (array $decoded): array {
639 return self::sanitizeTotalFieldConfigArray($decoded);
640 }
641 );
642 }
643
644 /**
645 * Sanitize product field JSON stored in htmlContent.
646 *
647 * @param string $raw
648 * @return string
649 */
650 private static function sanitizeProductFieldHtmlContent(string $raw): string
651 {
652 return self::sanitizeJsonHtmlContentBlob(
653 $raw,
654 static function (array $decoded): array {
655 return self::sanitizeProductFieldConfigArray($decoded);
656 }
657 );
658 }
659
660 /**
661 * Normalize and sanitize total field configuration array (TotalFieldConfig).
662 *
663 * @param mixed[] $decoded
664 * @return mixed[]
665 */
666 private static function sanitizeTotalFieldConfigArray(array $decoded): array
667 {
668 $emptyHtml = isset($decoded['emptyPaymentMessageHtml'])
669 ? HtmlSanitizer::sanitizeEditorContent((string) $decoded['emptyPaymentMessageHtml'])
670 : '';
671
672 return [
673 'totalConfigVersion' => 1,
674 'enablePaymentSummary' => self::sanitizeJsonConfigBoolean($decoded['enablePaymentSummary'] ?? false),
675 'showPaymentSummaryCloseButton' => self::sanitizeJsonConfigBoolean(
676 $decoded['showPaymentSummaryCloseButton'] ?? true
677 ),
678 'emptyPaymentMessageHtml' => $emptyHtml,
679 'enableAutocomplete' => self::sanitizeJsonConfigBoolean($decoded['enableAutocomplete'] ?? false),
680 ];
681 }
682
683 /**
684 * Sanitize product field configuration array (ProductFieldConfig).
685 *
686 * @param mixed[] $decoded
687 * @return mixed[]
688 */
689 private static function sanitizeProductFieldConfigArray(array $decoded): array
690 {
691 $columnsCount = isset($decoded['columnsCount']) ? (int) $decoded['columnsCount'] : 3;
692 $columnsCount = max(2, min(6, $columnsCount));
693
694 return [
695 'configVersion' => 1,
696 'productType' => self::sanitizeProductFieldType($decoded['productType'] ?? null),
697 'paymentAmount' => sanitize_text_field((string) ($decoded['paymentAmount'] ?? '')),
698 'amountLabel' => sanitize_text_field((string) ($decoded['amountLabel'] ?? '')),
699 'showPriceAfterItemLabel' => self::sanitizeJsonConfigBoolean(
700 $decoded['showPriceAfterItemLabel'] ?? false
701 ),
702 'prices' => self::sanitizeProductPricesMap($decoded['prices'] ?? null),
703 'choiceLayout' => self::sanitizeProductChoiceLayout($decoded['choiceLayout'] ?? null),
704 'columnsCount' => $columnsCount,
705 ];
706 }
707
708 /**
709 * Whitelist productType (ProductFieldConfig.productType).
710 *
711 * @param mixed $raw
712 * @return string
713 */
714 private static function sanitizeProductFieldType($raw): string
715 {
716 $allowed = ['single', 'checkbox', 'radio', 'select', 'user_defined'];
717 $type = is_string($raw) ? sanitize_key($raw) : '';
718 return in_array($type, $allowed, true) ? $type : 'radio';
719 }
720
721 /**
722 * Whitelist choiceLayout (ProductFieldConfig.choiceLayout).
723 *
724 * @param mixed $raw
725 * @return string
726 */
727 private static function sanitizeProductChoiceLayout($raw): string
728 {
729 $allowed = ['inline', 'columns', 'button'];
730 $layout = is_string($raw) ? sanitize_key($raw) : '';
731 return in_array($layout, $allowed, true) ? $layout : 'inline';
732 }
733
734 /**
735 * Sanitize map of option value -> price string (ProductFieldConfig.prices).
736 *
737 * @param mixed $prices
738 * @return array<string, string>
739 */
740 private static function sanitizeProductPricesMap($prices): array
741 {
742 if (!is_array($prices)) {
743 return [];
744 }
745
746 $sanitized = [];
747 foreach ($prices as $key => $value) {
748 if (!is_scalar($key) || !is_scalar($value)) {
749 continue;
750 }
751 $optionKey = sanitize_text_field((string) $key);
752 if ($optionKey === '') {
753 continue;
754 }
755 $sanitized[$optionKey] = sanitize_text_field((string) $value);
756 }
757
758 return $sanitized;
759 }
760
761 private static function sanitizePhoneFormat(string $format): string
762 {
763 $allowed = ['international','national','e164'];
764 $formatLower = strtolower($format);
765 return in_array($formatLower, $allowed, true) ? $formatLower : '';
766 }
767
768 /**
769 * @param mixed $value
770 */
771 private static function sanitizeSaveUploadsTo($value): string
772 {
773 $v = sanitize_text_field((string) ($value ?? 'ivyforms'));
774 return in_array($v, ['ivyforms', 'media_library'], true) ? $v : 'ivyforms';
775 }
776
777 /**
778 * @param mixed $raw
779 *
780 * @return array<int, string>
781 */
782 private static function sanitizeAllowedFileExtensionsInput($raw): array
783 {
784 if (!is_array($raw)) {
785 return [];
786 }
787 $out = [];
788 $seen = [];
789 foreach ($raw as $item) {
790 if (!is_string($item)) {
791 continue;
792 }
793 $ext = strtolower(ltrim(trim($item), '.'));
794 if ($ext === '' || strlen($ext) > 20 || !preg_match('/^[a-z0-9]+$/', $ext)) {
795 continue;
796 }
797 if (isset($seen[$ext])) {
798 continue;
799 }
800 $seen[$ext] = true;
801 $out[] = $ext;
802 if (count($out) >= 50) {
803 break;
804 }
805 }
806 return $out;
807 }
808
809 /**
810 * Sanitize entry data
811 *
812 * @param mixed[] $data
813 *
814 * @return mixed[]
815 */
816 public static function sanitizeEntryData(array $data): array
817 {
818 return [
819 'id' => (int)($data['id'] ?? 0),
820 'formId' => (int)($data['formId'] ?? 0),
821 'userId' => (int)($data['userId'] ?? 0),
822 'status' => sanitize_text_field($data['status'] ?? ''),
823 'ipAddress' => sanitize_text_field($data['ipAddress'] ?? ''),
824 'userAgent' => sanitize_text_field($data['userAgent'] ?? ''),
825 'sourceURL' => sanitize_text_field($data['sourceURL'] ?? ''),
826 'dateCreated' => sanitize_text_field($data['dateCreated'] ?? ''),
827 'dateEdited' => sanitize_text_field($data['dateEdited'] ?? ''),
828 'starred' => (bool)($data['starred'] ?? false),
829 ];
830 }
831
832 /**
833 * Sanitize ID
834 *
835 * @param int $id
836 *
837 * @return int
838 *
839 * @throws InvalidArgumentException
840 */
841 public static function sanitizeId(int $id): int
842 {
843 if ($id <= 0) {
844 throw new InvalidArgumentException(
845 BackendStrings::getExceptionStrings()['invalid_id']
846 );
847 }
848
849 return $id;
850 }
851
852 /**
853 * Sanitize multiple IDs
854 *
855 * @param int[] $ids
856 *
857 * @return int[]
858 *
859 */
860 public static function sanitizeIds(array $ids): array
861 {
862 return array_map(function ($id) {
863 return (int)$id;
864 }, $ids);
865 }
866
867 /**
868 * Sanitize search parameters
869 *
870 * @param array<string, mixed> $params
871 *
872 * @return array<string, mixed>
873 * @throws InvalidArgumentException
874 */
875 public static function sanitizeSearchParams(array $params): array
876 {
877 $filters = [
878 'starred' => array_key_exists('starred', $params['filters'] ?? [])
879 ? (int)filter_var($params['filters']['starred'], FILTER_VALIDATE_BOOLEAN)
880 : null,
881 'published' => array_key_exists('published', $params['filters'] ?? [])
882 ? (int)filter_var($params['filters']['published'], FILTER_VALIDATE_BOOLEAN)
883 : null,
884 'status' => sanitize_text_field($params['filters']['read'] ?? ''),
885 ];
886 // Add formId to filters if present and valid
887 if (
888 isset($params['filters']['formId']) &&
889 is_numeric($params['filters']['formId']) &&
890 (int)$params['filters']['formId'] > 0
891 ) {
892 $filters['formId'] = self::sanitizeId((int)$params['filters']['formId']);
893 }
894 return [
895 'page' => max((int)($params['page'] ?? 1), 1),
896 'perPage' => max((int)($params['perPage'] ?? 10), 1),
897 'search' => sanitize_text_field($params['search'] ?? ''),
898 'orderBy' => sanitize_text_field($params['orderBy'] ?? 'id'),
899 'order' => strtolower(sanitize_text_field($params['order'] ?? 'asc')) === 'desc' ? 'desc' : 'asc',
900 'filters' => $filters,
901 'dateRange' => [
902 sanitize_text_field($params['dateRange'][0] ?? ''),
903 sanitize_text_field($params['dateRange'][1] ?? ''),
904 ],
905 'shouldGetCount' => (bool)($params['shouldGetCount'] ?? false),
906 'searchFieldValue' => filter_var($params['searchFieldValue'] ?? false, FILTER_VALIDATE_BOOLEAN),
907 ];
908 }
909
910 /**
911 * Sanitize form submission data based on field types and IDs
912 *
913 * @param mixed[] $data Form submission data
914 * @param mixed[] $formFields Form fields configuration
915 *
916 * @return mixed[] Sanitized submission data
917 */
918 public static function sanitizeFormSubmissionData(array $data, array $formFields): array
919 {
920 $sanitizedData = [];
921 list($fieldIdMap, $typeIndexMap) = self::buildFieldMaps($formFields);
922
923 foreach ($data['values'] as $key => $value) {
924 if (self::isInternalField($key)) {
925 $sanitizedData[$key] = $value;
926 continue;
927 }
928 if (isset($fieldIdMap[$key])) {
929 $sanitizedData[$key] = self::sanitizeByFieldType($fieldIdMap[$key], $value);
930 continue;
931 }
932 if (isset($typeIndexMap[$key])) {
933 $fieldId = $typeIndexMap[$key]['id'];
934 $fieldType = $typeIndexMap[$key]['type'];
935 $sanitizedData[$fieldId] = self::sanitizeByFieldType($fieldType, $value);
936 }
937 }
938
939 // Add form ID to sanitized data if present
940 if (isset($data['formId'])) {
941 $sanitizedData['formId'] = (int)$data['formId'];
942 }
943
944 // Add post ID and referer if present (for placeholders)
945 if (isset($data['postId'])) {
946 $sanitizedData['postId'] = (int)($data['postId']);
947 }
948 if (isset($data['referer'])) {
949 $sanitizedData['referer'] = sanitize_text_field($data['referer']);
950 }
951
952 return $sanitizedData;
953 }
954
955 /**
956 * Build field ID and type-index maps from form fields
957 *
958 * @param mixed[] $formFields Form fields configuration
959 * @return mixed[]
960 */
961 private static function buildFieldMaps(array $formFields): array
962 {
963 $fieldIdMap = [];
964 $typeIndexMap = [];
965 foreach ($formFields as $field) {
966 $fieldId = $field->getId() ?? null;
967 $fieldType = $field->getType() ?? null;
968 $fieldIndex = $field->getIndex() ?? null;
969 if ($fieldId !== null) {
970 $fieldIdMap[$fieldId] = $fieldType;
971 if ($fieldType !== null && $fieldIndex !== null) {
972 $typeIndexMap[$fieldType . '_' . $fieldIndex] = [
973 'id' => $fieldId,
974 'type' => $fieldType
975 ];
976 }
977 }
978 }
979 return [$fieldIdMap, $typeIndexMap];
980 }
981
982 /**
983 * Check if a key is an internal field
984 *
985 * @param string $key
986 * @return bool
987 */
988 private static function isInternalField(string $key): bool
989 {
990 return in_array($key, ['nonce', 'formId']);
991 }
992
993 /**
994 * Sanitize a value based on its field type
995 *
996 * @param string $fieldType The type of field
997 * @param mixed $value The value to sanitize
998 *
999 * @return mixed Sanitized value
1000 */
1001 private static function sanitizeByFieldType(string $fieldType, $value)
1002 {
1003 // File-upload values can arrive as arrays (JSON submit with zero/one/many files),
1004 // JSON-encoded strings, or single URL strings. Normalize them before the generic
1005 // array handling so the stored value is always a consistent scalar that mirrors the
1006 // multipart upload path (avoids "Array to string conversion" further down the flow).
1007 if ($fieldType === 'file-upload') {
1008 return self::sanitizeFileUploadEntryValue($value);
1009 }
1010
1011 // Recursively sanitize arrays/objects (parent-child fields)
1012 if (is_array($value)) {
1013 return array_map(function ($v) use ($fieldType) {
1014 return self::sanitizeByFieldType($fieldType, $v);
1015 }, $value);
1016 }
1017 if (is_object($value)) {
1018 $sanitized = array_map(function ($v) use ($fieldType) {
1019 return self::sanitizeByFieldType($fieldType, $v);
1020 }, get_object_vars($value));
1021 return (object)$sanitized;
1022 }
1023 switch ($fieldType) {
1024 case 'email':
1025 return sanitize_email($value);
1026
1027 case 'number':
1028 case 'quantity':
1029 case 'total':
1030 return is_numeric($value) ? (float)$value : null;
1031
1032 case 'slider':
1033 return is_numeric($value) ? (float)$value : null;
1034
1035 case 'phone':
1036 // Remove everything except digits, +, and spaces
1037 return preg_replace('/[^\d\s+]/', '', $value);
1038
1039 case 'paragraph':
1040 return sanitize_textarea_field($value);
1041
1042 case 'rich_text':
1043 $filtered = self::filterSubmissionFieldValue($fieldType, $value);
1044 if ($filtered !== null) {
1045 return $filtered;
1046 }
1047
1048 return HtmlSanitizer::sanitizeEditorContent((string) $value);
1049
1050 case 'website':
1051 return sanitize_url($value);
1052
1053 case 'time':
1054 $sanitizedTime = preg_replace('/[^0-9: ]/i', '', (string)$value);
1055 return sanitize_text_field($sanitizedTime);
1056
1057 case 'date':
1058 return sanitize_text_field($value);
1059
1060 case 'product':
1061 return self::sanitizeProductEntryValue($value);
1062
1063 case 'gdpr':
1064 // GDPR checkbox: sanitize as boolean-like value
1065 return (bool)$value ? 'Yes' : 'No';
1066
1067 case 'text':
1068 return self::sanitizeText($value);
1069
1070 default:
1071 $filtered = self::filterSubmissionFieldValue($fieldType, $value);
1072 if ($filtered !== null) {
1073 return $filtered;
1074 }
1075
1076 return self::sanitizeText($value);
1077 }
1078 }
1079
1080 /**
1081 * Normalize a file-upload submission value to a consistent scalar form.
1082 *
1083 * Accepts arrays (JSON submit), JSON-encoded strings, or single URL strings and always
1084 * returns either an empty string, a single sanitized URL, or a JSON-encoded list of
1085 * sanitized URLs (mirroring the multipart upload storage format).
1086 *
1087 * @param mixed $value
1088 * @return string
1089 */
1090 private static function sanitizeFileUploadEntryValue($value): string
1091 {
1092 if ($value === null || $value === '') {
1093 return '';
1094 }
1095
1096 if (is_array($value)) {
1097 return self::encodeFileUploadUrls($value);
1098 }
1099
1100 $asString = (string) $value;
1101 if ($asString !== '' && $asString[0] === '[') {
1102 $decoded = json_decode($asString, true);
1103 if (!is_array($decoded)) {
1104 return sanitize_text_field($asString);
1105 }
1106
1107 return self::encodeFileUploadUrls($decoded);
1108 }
1109
1110 return esc_url_raw($asString);
1111 }
1112
1113 /**
1114 * Sanitize a list of file-upload URLs into a single stored value.
1115 *
1116 * @param array<mixed> $rawUrls
1117 * @return string
1118 */
1119 private static function encodeFileUploadUrls(array $rawUrls): string
1120 {
1121 $urls = [];
1122 foreach ($rawUrls as $url) {
1123 if (is_string($url) && $url !== '') {
1124 $sanitizedUrl = esc_url_raw($url);
1125 if ($sanitizedUrl !== '') {
1126 $urls[] = $sanitizedUrl;
1127 }
1128 }
1129 }
1130
1131 if ($urls === []) {
1132 return '';
1133 }
1134
1135 return count($urls) === 1 ? $urls[0] : (string) wp_json_encode($urls);
1136 }
1137
1138 /**
1139 * Sanitize stored product field value (JSON snapshot or legacy plain text).
1140 *
1141 * @param mixed $value
1142 * @return string
1143 */
1144 private static function sanitizeProductEntryValue($value): string
1145 {
1146 if (!is_string($value) || $value === '') {
1147 return '';
1148 }
1149 $trimmed = trim($value);
1150 if ($trimmed === '' || $trimmed[0] !== '{') {
1151 return self::sanitizeText($value) ?? '';
1152 }
1153 $decoded = json_decode($value, true);
1154 if (
1155 !is_array($decoded) || (int) ($decoded['v'] ?? 0) !== 1 ||
1156 !isset($decoded['lines']) || !is_array($decoded['lines'])
1157 ) {
1158 return self::sanitizeText($value) ?? '';
1159 }
1160 $allowedTypes = ['single', 'checkbox', 'radio', 'select', 'user_defined'];
1161 $productType = sanitize_key((string) ($decoded['productType'] ?? ''));
1162 if (!in_array($productType, $allowedTypes, true)) {
1163 $productType = 'radio';
1164 }
1165 $linesOut = [];
1166 foreach ($decoded['lines'] as $line) {
1167 if (!is_array($line)) {
1168 continue;
1169 }
1170 $label = sanitize_text_field((string) ($line['label'] ?? ''));
1171 $price = isset($line['price']) ? sanitize_text_field((string) $line['price']) : '';
1172 $priceAmount = isset($line['priceAmount'])
1173 ? sanitize_text_field((string) $line['priceAmount'])
1174 : '';
1175 if ($label === '' && $priceAmount === '' && $price === '') {
1176 continue;
1177 }
1178 $entry = ['label' => $label];
1179 if ($priceAmount !== '') {
1180 $entry['priceAmount'] = $priceAmount;
1181 }
1182 if ($price !== '') {
1183 $entry['price'] = $price;
1184 }
1185 $linesOut[] = $entry;
1186 }
1187 $raw = sanitize_text_field((string) ($decoded['raw'] ?? ''));
1188 $summary = sanitize_text_field((string) ($decoded['summary'] ?? ''));
1189
1190 return wp_json_encode(
1191 [
1192 'v' => 1,
1193 'productType' => $productType,
1194 'raw' => $raw,
1195 'lines' => $linesOut,
1196 'summary' => $summary,
1197 ],
1198 JSON_UNESCAPED_UNICODE
1199 ) ?: '';
1200 }
1201
1202 /**
1203 * Verify nonce with custom error handling
1204 *
1205 * @param string|null $nonce
1206 *
1207 * @throws ForbiddenException
1208 */
1209 public static function verifyNonce(?string $nonce): void
1210 {
1211 $nonce = sanitize_text_field($nonce);
1212
1213 if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
1214 throw new ForbiddenException(
1215 BackendStrings::getExceptionStrings()['invalid_nonce']
1216 );
1217 }
1218 }
1219 /**
1220 * Verify submission nonce with custom error handling
1221 *
1222 * @param string|null $nonce
1223 * @param int $formId
1224 *
1225 * @throws ForbiddenException
1226 */
1227 public static function verifySubmissionNonce(?string $nonce, int $formId): void
1228 {
1229 $nonce = sanitize_text_field($nonce);
1230
1231 // Verify the form-specific nonce
1232 if (!wp_verify_nonce($nonce, 'ivyformsFrontSubmissionNonce_' . $formId)) {
1233 throw new ForbiddenException(
1234 BackendStrings::getExceptionStrings()['invalid_nonce']
1235 );
1236 }
1237 }
1238
1239 /**
1240 * Sanitize text
1241 *
1242 * @param string|null $text
1243 *
1244 * @return string
1245 */
1246 public static function sanitizeText(?string $text): ?string
1247 {
1248 if (is_null($text)) {
1249 return '';
1250 }
1251
1252 return wp_kses_post(sanitize_text_field($text));
1253 }
1254
1255 /**
1256 * Sanitize settings fields
1257 *
1258 * @param mixed $value
1259 *
1260 * @return mixed Sanitized value
1261 */
1262 public static function sanitizeSettingsFields($value)
1263 {
1264 if (is_array($value)) {
1265 return array_map([self::class, 'sanitizeSettingsFields'], $value);
1266 }
1267
1268 if (is_object($value)) {
1269 return (object)array_map([self::class, 'sanitizeSettingsFields'], (array)$value);
1270 }
1271
1272 if (is_string($value)) {
1273 return self::sanitizeText($value);
1274 }
1275
1276 if (is_int($value)) {
1277 return intval($value);
1278 }
1279
1280 if (is_bool($value)) {
1281 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
1282 }
1283
1284 return '';
1285 }
1286
1287 /**
1288 * Whether a value is a notification email placeholder token.
1289 *
1290 * Supports {{field_key}}, {{wp.admin_email}}, and single-brace {admin_email}.
1291 *
1292 * @param string $value
1293 * @return bool
1294 */
1295 public static function isEmailPlaceholderToken(string $value): bool
1296 {
1297 $value = trim($value);
1298
1299 if ($value === '') {
1300 return false;
1301 }
1302
1303 if (preg_match('/^{{[^}]+}}$/', $value)) {
1304 return true;
1305 }
1306
1307 return (bool) preg_match('/^{[^{}]+}$/', $value);
1308 }
1309
1310 /**
1311 * Whether a value contains CR/LF characters unsafe for email headers.
1312 *
1313 * @param string $value
1314 * @return bool
1315 */
1316 public static function containsEmailHeaderInjection(string $value): bool
1317 {
1318 return (bool) preg_match('/[\r\n]/', $value);
1319 }
1320
1321 /**
1322 * Sanitize email or placeholder
1323 *
1324 * @param string $value
1325 *
1326 * @return string
1327 */
1328 public static function sanitizeEmailOrPlaceholder($value): string
1329 {
1330 $value = trim((string) $value);
1331 if ($value === '') {
1332 return '';
1333 }
1334
1335 if (self::isEmailPlaceholderToken($value)) {
1336 return $value;
1337 }
1338
1339 return sanitize_email($value);
1340 }
1341
1342 /**
1343 * Sanitize a comma-separated list of emails or placeholders.
1344 *
1345 * @param mixed $value
1346 * @return string
1347 */
1348 public static function sanitizeEmailListOrPlaceholders($value): string
1349 {
1350 $value = trim((string) $value);
1351 if ($value === '') {
1352 return '';
1353 }
1354
1355 $parts = array_map('trim', explode(',', $value));
1356 $sanitized = [];
1357
1358 foreach ($parts as $part) {
1359 if ($part === '') {
1360 continue;
1361 }
1362
1363 if (self::isEmailPlaceholderToken($part)) {
1364 $sanitized[] = $part;
1365 continue;
1366 }
1367
1368 if (!self::isValidEmail($part)) {
1369 continue;
1370 }
1371
1372 $sanitizedValue = sanitize_email($part);
1373 if ($sanitizedValue === '') {
1374 continue;
1375 }
1376
1377 $sanitized[] = $sanitizedValue;
1378 }
1379
1380 return implode(', ', $sanitized);
1381 }
1382
1383 /**
1384 * Sanitize numeric values for min/max fields
1385 * Convert empty/unselected to null, but allow 0 as a valid value
1386 *
1387 * @param mixed $value
1388 * @return float|null
1389 */
1390 private static function sanitizeNumericValue($value): ?float
1391 {
1392 // If value is null or empty string, return null (not selected)
1393 if ($value === null || $value === '') {
1394 return null;
1395 }
1396
1397 // If value is numeric (including 0), convert to float
1398 if (is_numeric($value)) {
1399 return (float)$value;
1400 }
1401
1402 // If not numeric, return null
1403 return null;
1404 }
1405
1406 /**
1407 * Validate email address.
1408 *
1409 * @param string $email
1410 * @return bool
1411 */
1412 public static function isValidEmail(string $email): bool
1413 {
1414 return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
1415 }
1416
1417 /**
1418 * Normalize field value to string (comma-separated if array)
1419 * @param mixed $value
1420 * @return string
1421 */
1422 public static function normalizeFieldValue($value): string
1423 {
1424 if (is_array($value)) {
1425 return implode(', ', $value);
1426 }
1427 return (string)$value;
1428 }
1429
1430 /**
1431 * Sanitize integration settings
1432 * Recursively handles nested structures and various data types.
1433 *
1434 * @param mixed $integrationSettings
1435 *
1436 * @return array<string, array<string, mixed>>
1437 */
1438 public static function sanitizeIntegrationSettings($integrationSettings): array
1439 {
1440 // Handle null or non-array inputs
1441 if (!is_array($integrationSettings)) {
1442 return [];
1443 }
1444
1445 $cleanedSettings = [];
1446
1447 foreach ($integrationSettings as $integration => $settings) {
1448 // Skip if not a valid integration name or settings structure
1449 if (!is_string($integration) || !is_array($settings)) {
1450 continue;
1451 }
1452
1453 $cleanedSettings[sanitize_text_field($integration)] = self::sanitizeIntegrationSettingsRecursive($settings);
1454 }
1455
1456 return $cleanedSettings;
1457 }
1458
1459 /**
1460 * Recursively sanitize integration settings values
1461 *
1462 * @param mixed $value
1463 * @return mixed
1464 */
1465 public static function sanitizeIntegrationSettingsRecursive($value)
1466 {
1467 // Handle arrays recursively
1468 if (is_array($value)) {
1469 $sanitized = [];
1470 foreach ($value as $key => $val) {
1471 $sanitizedKey = is_string($key) ? sanitize_text_field($key) : $key;
1472 $sanitized[$sanitizedKey] = self::sanitizeIntegrationSettingsRecursive($val);
1473 }
1474 return $sanitized;
1475 }
1476
1477 // Handle different scalar types
1478 if (is_bool($value)) {
1479 return $value;
1480 }
1481 if (is_int($value)) {
1482 return $value;
1483 }
1484 if (is_float($value)) {
1485 return $value;
1486 }
1487 if (is_string($value)) {
1488 return sanitize_text_field($value);
1489 }
1490
1491 return '';
1492 }
1493
1494 /**
1495 * Sanitize style settings for form styling
1496 *
1497 * @param mixed $styleSettings
1498 * @return array<string, mixed>|null
1499 */
1500 public static function sanitizeStyleSettings($styleSettings): ?array
1501 {
1502 if (!is_array($styleSettings)) {
1503 return null;
1504 }
1505
1506 // Merge with bundled defaults so partial payloads (or missing stylesEnabled) still persist.
1507 $merged = array_replace_recursive(TemplateDefaults::getDefaultFormStylesSafe(), $styleSettings);
1508
1509 $sanitized = [];
1510
1511 // Sanitize style mode flags
1512 $sanitized['stylesEnabled'] = (bool)($merged['stylesEnabled'] ?? true);
1513 $sanitized['selectedTheme'] = sanitize_text_field($merged['selectedTheme'] ?? 'ivy-default');
1514 $sanitized['styleMode'] = in_array($merged['styleMode'] ?? 'quick', ['quick', 'advanced'], true)
1515 ? $merged['styleMode']
1516 : 'quick';
1517
1518 // Sanitize quick mode settings
1519 $sanitized['quickMode'] = self::sanitizeQuickStyleSettings($merged['quickMode'] ?? []);
1520
1521 // Sanitize advanced mode settings
1522 $sanitized['advancedMode'] = self::sanitizeAdvancedStyleSettings($merged['advancedMode'] ?? []);
1523
1524 // Sanitize custom CSS class
1525 $sanitized['customCssClass'] = CssSanitizer::sanitizeCssClass(
1526 $merged['customCssClass'] ?? ''
1527 );
1528 $sanitized['additionalCssClasses'] = CssSanitizer::sanitizeCssClassList(
1529 $merged['additionalCssClasses'] ?? ''
1530 );
1531
1532 $filtered = apply_filters('ivyforms/form/sanitized_style_settings', $sanitized, $styleSettings);
1533
1534 return is_array($filtered) ? $filtered : $sanitized;
1535 }
1536
1537 /**
1538 * Sanitize quick style settings with basic color and unit validation
1539 *
1540 * @param mixed $quickModeSettings
1541 * @return array<string, mixed>
1542 */
1543 private static function sanitizeQuickStyleSettings($quickModeSettings): array
1544 {
1545 if (isset($quickModeSettings) && is_array($quickModeSettings)) {
1546 return [
1547 'formBackground' => self::sanitizeColor($quickModeSettings['formBackground'] ?? ''),
1548 'formBorderColor' => self::sanitizeColor($quickModeSettings['formBorderColor'] ?? ''),
1549 'formBorderWidth' => CssSanitizer::sanitizeCssUnit($quickModeSettings['formBorderWidth'] ?? ''),
1550 'verticalSpacing' => CssSanitizer::sanitizeCssUnit(
1551 $quickModeSettings['verticalSpacing'] ?? ''
1552 ),
1553 'horizontalSpacing' => CssSanitizer::sanitizeCssUnit(
1554 $quickModeSettings['horizontalSpacing'] ?? ''
1555 ),
1556 'formCorner' => self::sanitizeCornerSetting($quickModeSettings['formCorner'] ?? ''),
1557 'formTitleText' => self::sanitizeColor($quickModeSettings['formTitleText'] ?? ''),
1558 'fieldBackground' => self::sanitizeColor($quickModeSettings['fieldBackground'] ?? ''),
1559 'fieldText' => self::sanitizeColor(
1560 $quickModeSettings['fieldText'] ?? ''
1561 ),
1562 'fieldBorderColor' => self::sanitizeColor(
1563 $quickModeSettings['fieldBorderColor'] ?? ''
1564 ),
1565 'fieldCorner' => self::sanitizeCornerSetting(
1566 $quickModeSettings['fieldCorner'] ?? ''
1567 ),
1568 'fieldBorderWidth' => CssSanitizer::sanitizeCssUnit(
1569 $quickModeSettings['fieldBorderWidth'] ?? ''
1570 ),
1571 'fieldPaddingLeftRight' => CssSanitizer::sanitizeCssUnit(
1572 $quickModeSettings['fieldPaddingLeftRight'] ?? ''
1573 ),
1574 'fieldPaddingTopBottom' => CssSanitizer::sanitizeCssUnit(
1575 $quickModeSettings['fieldPaddingTopBottom'] ?? ''
1576 ),
1577 'labelFontColor' => self::sanitizeColor(
1578 $quickModeSettings['labelFontColor'] ?? ''
1579 ),
1580 'buttonBackground' => self::sanitizeColor(
1581 $quickModeSettings['buttonBackground'] ?? ''
1582 ),
1583 'buttonBorderColor' => self::sanitizeColor(
1584 $quickModeSettings['buttonBorderColor'] ?? ''
1585 ),
1586 'buttonBorderWidth' => CssSanitizer::sanitizeCssUnit(
1587 $quickModeSettings['buttonBorderWidth'] ?? ''
1588 ),
1589 'buttonPaddingLeftRight' => CssSanitizer::sanitizeCssUnit(
1590 $quickModeSettings['buttonPaddingLeftRight'] ?? ''
1591 ),
1592 'buttonPaddingTopBottom' => CssSanitizer::sanitizeCssUnit(
1593 $quickModeSettings['buttonPaddingTopBottom'] ?? ''
1594 ),
1595 'buttonWidth' => CssSanitizer::sanitizeCssUnitOrAuto(
1596 $quickModeSettings['buttonWidth'] ?? ''
1597 ),
1598 'buttonHeight' => CssSanitizer::sanitizeCssUnitOrAuto(
1599 $quickModeSettings['buttonHeight'] ?? ''
1600 ),
1601 'buttonText' => self::sanitizeColor(
1602 $quickModeSettings['buttonText'] ?? ''
1603 ),
1604 ];
1605 }
1606 return [];
1607 }
1608
1609 /**
1610 * Sanitize advanced style settings with basic color and unit validation
1611 *
1612 * @param mixed $advancedModeSettings
1613 * @return array<string, mixed>
1614 */
1615 private static function sanitizeAdvancedStyleSettings($advancedModeSettings): array
1616 {
1617 $advanced = [];
1618
1619 if (isset($advancedModeSettings) && is_array($advancedModeSettings)) {
1620 if (isset($advancedModeSettings['general'])) {
1621 $advanced['general'] = [
1622 'formBackground' => self::sanitizeColor(
1623 $advancedModeSettings['general']['formBackground'] ?? ''
1624 ),
1625 'formBorderColor' => self::sanitizeColor(
1626 $advancedModeSettings['general']['formBorderColor'] ?? ''
1627 ),
1628 'formBorderWidth' => CssSanitizer::sanitizeCssUnit(
1629 $advancedModeSettings['general']['formBorderWidth'] ?? ''
1630 ),
1631 'formBorderStyle' => self::sanitizeBorderStyle(
1632 $advancedModeSettings['general']['formBorderStyle'] ?? 'solid'
1633 ),
1634 'formPaddingLeftRight' => CssSanitizer::sanitizeCssUnit(
1635 $advancedModeSettings['general']['formPaddingLeftRight'] ?? ''
1636 ),
1637 'formPaddingTopBottom' => CssSanitizer::sanitizeCssUnit(
1638 $advancedModeSettings['general']['formPaddingTopBottom'] ?? ''
1639 ),
1640 'verticalSpacing' => CssSanitizer::sanitizeCssUnit(
1641 $advancedModeSettings['general']['verticalSpacing'] ?? ''
1642 ),
1643 'horizontalSpacing' => CssSanitizer::sanitizeCssUnit(
1644 $advancedModeSettings['general']['horizontalSpacing'] ?? ''
1645 ),
1646 'alignment' => self::sanitizeAlignment(
1647 $advancedModeSettings['general']['alignment'] ?? 'left'
1648 ),
1649 'formCorner' => self::sanitizeCornerSetting(
1650 $advancedModeSettings['general']['formCorner'] ?? ''
1651 ),
1652 ];
1653 }
1654
1655 if (isset($advancedModeSettings['formTitle'])) {
1656 $advanced['formTitle'] = [
1657 'fontColor' => self::sanitizeColor(
1658 $advancedModeSettings['formTitle']['fontColor'] ?? ''
1659 ),
1660 'fontWeight' => self::sanitizeFontWeight(
1661 $advancedModeSettings['formTitle']['fontWeight'] ?? ''
1662 ),
1663 'fontStyle' => self::sanitizeFontStyle(
1664 $advancedModeSettings['formTitle']['fontStyle'] ?? 'normal'
1665 ),
1666 'fontSize' => CssSanitizer::sanitizeCssUnit(
1667 $advancedModeSettings['formTitle']['fontSize'] ?? ''
1668 ),
1669 'paddingLeftRight' => CssSanitizer::sanitizeCssUnit(
1670 $advancedModeSettings['formTitle']['paddingLeftRight'] ?? ''
1671 ),
1672 'paddingTopBottom' => CssSanitizer::sanitizeCssUnit(
1673 $advancedModeSettings['formTitle']['paddingTopBottom'] ?? ''
1674 ),
1675 ];
1676 }
1677
1678 if (isset($advancedModeSettings['formDescription'])) {
1679 $advanced['formDescription'] = [
1680 'fontColor' => self::sanitizeColor(
1681 $advancedModeSettings['formDescription']['fontColor'] ?? ''
1682 ),
1683 'fontWeight' => self::sanitizeFontWeight(
1684 $advancedModeSettings['formDescription']['fontWeight'] ?? ''
1685 ),
1686 'fontStyle' => self::sanitizeFontStyle(
1687 $advancedModeSettings['formDescription']['fontStyle'] ?? 'normal'
1688 ),
1689 'fontSize' => CssSanitizer::sanitizeCssUnit(
1690 $advancedModeSettings['formDescription']['fontSize'] ?? ''
1691 ),
1692 'paddingLeftRight' => CssSanitizer::sanitizeCssUnit(
1693 $advancedModeSettings['formDescription']['paddingLeftRight'] ?? ''
1694 ),
1695 'paddingTopBottom' => CssSanitizer::sanitizeCssUnit(
1696 $advancedModeSettings['formDescription']['paddingTopBottom'] ?? ''
1697 ),
1698 ];
1699 }
1700
1701 if (isset($advancedModeSettings['fieldLabels'])) {
1702 $labelFontColor = $advancedModeSettings['fieldLabels']['label']['fontColor']
1703 ?? $advancedModeSettings['fieldLabels']['fontColor']
1704 ?? '';
1705 $requiredIndicatorColor =
1706 $advancedModeSettings['fieldLabels']['requiredIndicator']['fontColor']
1707 ?? $advancedModeSettings['fieldLabels']['requiredIndicatorFontColor']
1708 ?? '';
1709 $advanced['fieldLabels'] = [
1710 'label' => [
1711 'fontColor' => self::sanitizeColor($labelFontColor),
1712 'fontWeight' => self::sanitizeFontWeight(
1713 $advancedModeSettings['fieldLabels']['label']['fontWeight'] ?? ''
1714 ),
1715 'fontStyle' => self::sanitizeFontStyle(
1716 $advancedModeSettings['fieldLabels']['label']['fontStyle'] ?? 'normal'
1717 ),
1718 'fontSize' => CssSanitizer::sanitizeCssUnit(
1719 $advancedModeSettings['fieldLabels']['label']['fontSize'] ?? ''
1720 ),
1721 'paddingLeftRight' => CssSanitizer::sanitizeCssUnit(
1722 $advancedModeSettings['fieldLabels']['label']['paddingLeftRight'] ?? ''
1723 ),
1724 'paddingTopBottom' => CssSanitizer::sanitizeCssUnit(
1725 $advancedModeSettings['fieldLabels']['label']['paddingTopBottom'] ?? ''
1726 ),
1727 'alignment' => self::sanitizeAlignment(
1728 $advancedModeSettings['fieldLabels']['label']['alignment'] ?? 'left'
1729 ),
1730 ],
1731 'requiredIndicator' => [
1732 'fontColor' => self::sanitizeColor($requiredIndicatorColor),
1733 ],
1734 ];
1735 }
1736
1737 if (isset($advancedModeSettings['fieldPlaceholder'])) {
1738 $advanced['fieldPlaceholder'] = [
1739 'fontColor' => self::sanitizeColor(
1740 $advancedModeSettings['fieldPlaceholder']['fontColor'] ?? ''
1741 ),
1742 'fontWeight' => self::sanitizeFontWeight(
1743 $advancedModeSettings['fieldPlaceholder']['fontWeight'] ?? ''
1744 ),
1745 'fontStyle' => self::sanitizeFontStyle(
1746 $advancedModeSettings['fieldPlaceholder']['fontStyle'] ?? 'normal'
1747 ),
1748 'fontSize' => CssSanitizer::sanitizeCssUnit(
1749 $advancedModeSettings['fieldPlaceholder']['fontSize'] ?? ''
1750 ),
1751 'paddingLeftRight' => CssSanitizer::sanitizeCssUnit(
1752 $advancedModeSettings['fieldPlaceholder']['paddingLeftRight'] ?? ''
1753 ),
1754 'alignment' => self::sanitizeAlignment(
1755 $advancedModeSettings['fieldPlaceholder']['alignment'] ?? 'left'
1756 ),
1757 ];
1758 }
1759
1760 if (isset($advancedModeSettings['fieldStates'])) {
1761 $advanced['fieldStates'] = [];
1762 if (isset($advancedModeSettings['fieldStates']['default'])) {
1763 $advanced['fieldStates']['default'] = [
1764 'background' => self::sanitizeColor(
1765 $advancedModeSettings['fieldStates']['default']['background']
1766 ?? $advancedModeSettings['fieldStates']['default']['backgroundColor']
1767 ?? ''
1768 ),
1769 'textColor' => self::sanitizeColor(
1770 $advancedModeSettings['fieldStates']['default']['textColor'] ?? ''
1771 ),
1772 'fontWeight' => self::sanitizeFontWeight(
1773 $advancedModeSettings['fieldStates']['default']['fontWeight'] ?? ''
1774 ),
1775 'fontStyle' => self::sanitizeFontStyle(
1776 $advancedModeSettings['fieldStates']['default']['fontStyle'] ?? 'normal'
1777 ),
1778 'fontSize' => CssSanitizer::sanitizeCssUnit(
1779 $advancedModeSettings['fieldStates']['default']['fontSize'] ?? ''
1780 ),
1781 'descriptionColor' => self::sanitizeColor(
1782 $advancedModeSettings['fieldStates']['default']['descriptionColor'] ?? ''
1783 ),
1784 'borderColor' => self::sanitizeColor(
1785 $advancedModeSettings['fieldStates']['default']['borderColor'] ?? ''
1786 ),
1787 'borderStyle' => self::sanitizeBorderStyle(
1788 $advancedModeSettings['fieldStates']['default']['borderStyle'] ?? 'solid'
1789 ),
1790 'corner' => self::sanitizeCornerSetting(
1791 $advancedModeSettings['fieldStates']['default']['corner'] ?? ''
1792 ),
1793 'borderWidth' => CssSanitizer::sanitizeCssUnit(
1794 $advancedModeSettings['fieldStates']['default']['borderWidth'] ?? ''
1795 ),
1796 'paddingLeftRight' => CssSanitizer::sanitizeCssUnit(
1797 $advancedModeSettings['fieldStates']['default']['paddingLeftRight'] ?? ''
1798 ),
1799 'paddingTopBottom' => CssSanitizer::sanitizeCssUnit(
1800 $advancedModeSettings['fieldStates']['default']['paddingTopBottom'] ?? ''
1801 ),
1802 ];
1803 }
1804 if (isset($advancedModeSettings['fieldStates']['active'])) {
1805 $advanced['fieldStates']['active'] = [
1806 'background' => self::sanitizeColor(
1807 $advancedModeSettings['fieldStates']['active']['background']
1808 ?? $advancedModeSettings['fieldStates']['active']['backgroundColor']
1809 ?? ''
1810 ),
1811 'textColor' => self::sanitizeColor(
1812 $advancedModeSettings['fieldStates']['active']['textColor'] ?? ''
1813 ),
1814 'descriptionColor' => self::sanitizeColor(
1815 $advancedModeSettings['fieldStates']['active']['descriptionColor'] ?? ''
1816 ),
1817 'borderColor' => self::sanitizeColor(
1818 $advancedModeSettings['fieldStates']['active']['borderColor'] ?? ''
1819 ),
1820 'borderStyle' => self::sanitizeBorderStyle(
1821 $advancedModeSettings['fieldStates']['active']['borderStyle'] ?? 'solid'
1822 ),
1823 ];
1824 }
1825 if (isset($advancedModeSettings['fieldStates']['readOnly'])) {
1826 $advanced['fieldStates']['readOnly'] = [
1827 'background' => self::sanitizeColor(
1828 $advancedModeSettings['fieldStates']['readOnly']['background']
1829 ?? $advancedModeSettings['fieldStates']['readOnly']['backgroundColor']
1830 ?? ''
1831 ),
1832 'textColor' => self::sanitizeColor(
1833 $advancedModeSettings['fieldStates']['readOnly']['textColor'] ?? ''
1834 ),
1835 'descriptionColor' => self::sanitizeColor(
1836 $advancedModeSettings['fieldStates']['readOnly']['descriptionColor'] ?? ''
1837 ),
1838 'borderColor' => self::sanitizeColor(
1839 $advancedModeSettings['fieldStates']['readOnly']['borderColor'] ?? ''
1840 ),
1841 'borderStyle' => self::sanitizeBorderStyle(
1842 $advancedModeSettings['fieldStates']['readOnly']['borderStyle'] ?? 'solid'
1843 ),
1844 ];
1845 }
1846 if (isset($advancedModeSettings['fieldStates']['error'])) {
1847 $advanced['fieldStates']['error'] = [
1848 'background' => self::sanitizeColor(
1849 $advancedModeSettings['fieldStates']['error']['background']
1850 ?? $advancedModeSettings['fieldStates']['error']['backgroundColor']
1851 ?? ''
1852 ),
1853 'textColor' => self::sanitizeColor(
1854 $advancedModeSettings['fieldStates']['error']['textColor'] ?? ''
1855 ),
1856 'descriptionColor' => self::sanitizeColor(
1857 $advancedModeSettings['fieldStates']['error']['descriptionColor'] ?? ''
1858 ),
1859 'borderColor' => self::sanitizeColor(
1860 $advancedModeSettings['fieldStates']['error']['borderColor'] ?? ''
1861 ),
1862 'borderStyle' => self::sanitizeBorderStyle(
1863 $advancedModeSettings['fieldStates']['error']['borderStyle'] ?? 'solid'
1864 ),
1865 ];
1866 }
1867 }
1868
1869 if (isset($advancedModeSettings['checkboxRadio'])) {
1870 $advanced['checkboxRadio'] = [
1871 'fontColor' => self::sanitizeColor(
1872 $advancedModeSettings['checkboxRadio']['fontColor'] ?? ''
1873 ),
1874 'fontWeight' => self::sanitizeFontWeight(
1875 $advancedModeSettings['checkboxRadio']['fontWeight'] ?? ''
1876 ),
1877 'fontStyle' => self::sanitizeFontStyle(
1878 $advancedModeSettings['checkboxRadio']['fontStyle'] ?? 'normal'
1879 ),
1880 'fontSize' => CssSanitizer::sanitizeCssUnit(
1881 $advancedModeSettings['checkboxRadio']['fontSize'] ?? ''
1882 ),
1883 'selectionColor' => self::sanitizeColor(
1884 $advancedModeSettings['checkboxRadio']['selectionColor'] ?? ''
1885 ),
1886 'borderColor' => self::sanitizeColor(
1887 $advancedModeSettings['checkboxRadio']['borderColor'] ?? ''
1888 ),
1889 ];
1890 }
1891
1892 if (isset($advancedModeSettings['buttonStates'])) {
1893 $advanced['buttonStates'] = [
1894 'normal' => [
1895 'background' => self::sanitizeColor(
1896 $advancedModeSettings['buttonStates']['normal']['background']
1897 ?? $advancedModeSettings['buttonStates']['normal']['backgroundColor']
1898 ?? ''
1899 ),
1900 'borderColor' => self::sanitizeColor(
1901 $advancedModeSettings['buttonStates']['normal']['borderColor'] ?? ''
1902 ),
1903 'textColor' => self::sanitizeColor(
1904 $advancedModeSettings['buttonStates']['normal']['textColor']
1905 ?? $advancedModeSettings['buttonStates']['normal']['fontColor']
1906 ?? ''
1907 ),
1908 'fontWeight' => self::sanitizeFontWeight(
1909 $advancedModeSettings['buttonStates']['normal']['fontWeight'] ?? ''
1910 ),
1911 'fontStyle' => self::sanitizeFontStyle(
1912 $advancedModeSettings['buttonStates']['normal']['fontStyle'] ?? 'normal'
1913 ),
1914 'fontSize' => CssSanitizer::sanitizeCssUnit(
1915 $advancedModeSettings['buttonStates']['normal']['fontSize'] ?? ''
1916 ),
1917 'borderWidth' => CssSanitizer::sanitizeCssUnit(
1918 $advancedModeSettings['buttonStates']['normal']['borderWidth'] ?? ''
1919 ),
1920 'corner' => self::sanitizeCornerSetting(
1921 $advancedModeSettings['buttonStates']['normal']['corner'] ?? ''
1922 ),
1923 'paddingLeftRight' => CssSanitizer::sanitizeCssUnit(
1924 $advancedModeSettings['buttonStates']['normal']['paddingLeftRight'] ?? ''
1925 ),
1926 'paddingTopBottom' => CssSanitizer::sanitizeCssUnit(
1927 $advancedModeSettings['buttonStates']['normal']['paddingTopBottom'] ?? ''
1928 ),
1929 'width' => CssSanitizer::sanitizeCssUnitOrAuto(
1930 $advancedModeSettings['buttonStates']['normal']['width'] ?? ''
1931 ),
1932 'height' => CssSanitizer::sanitizeCssUnitOrAuto(
1933 $advancedModeSettings['buttonStates']['normal']['height'] ?? ''
1934 ),
1935 ],
1936 'hover' => [
1937 'background' => self::sanitizeColor(
1938 $advancedModeSettings['buttonStates']['hover']['background']
1939 ?? $advancedModeSettings['buttonStates']['hover']['backgroundColor']
1940 ?? ''
1941 ),
1942 'borderColor' => self::sanitizeColor(
1943 $advancedModeSettings['buttonStates']['hover']['borderColor'] ?? ''
1944 ),
1945 'textColor' => self::sanitizeColor(
1946 $advancedModeSettings['buttonStates']['hover']['textColor']
1947 ?? $advancedModeSettings['buttonStates']['hover']['fontColor']
1948 ?? ''
1949 ),
1950 'corner' => self::sanitizeCornerSetting(
1951 $advancedModeSettings['buttonStates']['hover']['corner']
1952 ?? $advancedModeSettings['buttonStates']['normal']['corner']
1953 ?? ''
1954 ),
1955 ],
1956 ];
1957 }
1958
1959 if (
1960 isset($advancedModeSettings['confirmationMessage'])
1961 || isset($advancedModeSettings['confirmation'])
1962 ) {
1963 $confirmationMessage = $advancedModeSettings['confirmationMessage']
1964 ?? $advancedModeSettings['confirmation'];
1965 $advanced['confirmationMessage'] = [
1966 'borderColor' => self::sanitizeColor(
1967 $confirmationMessage['borderColor'] ?? ''
1968 ),
1969 'borderStyle' => self::sanitizeBorderStyle(
1970 $confirmationMessage['borderStyle'] ?? 'solid'
1971 ),
1972 'backgroundColor' => self::sanitizeColor(
1973 $confirmationMessage['backgroundColor'] ?? ''
1974 ),
1975 'textColor' => self::sanitizeColor(
1976 $confirmationMessage['textColor'] ?? ''
1977 ),
1978 'borderWidth' => CssSanitizer::sanitizeCssUnit(
1979 $confirmationMessage['borderWidth'] ?? ''
1980 ),
1981 'corner' => self::sanitizeCornerSetting(
1982 $confirmationMessage['corner'] ?? ''
1983 ),
1984 ];
1985 }
1986
1987 if (is_array($advancedModeSettings['rating'] ?? null)) {
1988 $advanced['rating'] = [
1989 'iconSize' => CssSanitizer::sanitizeCssUnit(
1990 $advancedModeSettings['rating']['iconSize'] ?? ''
1991 ),
1992 'iconColor' => self::sanitizeColor(
1993 $advancedModeSettings['rating']['iconColor'] ?? ''
1994 ),
1995 ];
1996 }
1997
1998 if (is_array($advancedModeSettings['slider'] ?? null)) {
1999 $advanced['slider'] = [
2000 'handleColor' => self::sanitizeStrictColor(
2001 $advancedModeSettings['slider']['handleColor'] ?? ''
2002 ),
2003 'highlightColor' => self::sanitizeStrictColor(
2004 $advancedModeSettings['slider']['highlightColor'] ?? ''
2005 ),
2006 'borderColor' => self::sanitizeStrictColor(
2007 $advancedModeSettings['slider']['borderColor'] ?? ''
2008 ),
2009 'trackColor' => self::sanitizeStrictColor(
2010 $advancedModeSettings['slider']['trackColor']
2011 ?? $advancedModeSettings['slider']['rangeColor']
2012 ?? ''
2013 ),
2014 'remainingTrackColor' => self::sanitizeStrictColor(
2015 $advancedModeSettings['slider']['remainingTrackColor'] ?? ''
2016 ),
2017 ];
2018 }
2019
2020 if (is_array($advancedModeSettings['dateTimePicker'] ?? null)) {
2021 $advanced['dateTimePicker'] = [
2022 'calendarBackground' => self::sanitizeColor(
2023 $advancedModeSettings['dateTimePicker']['calendarBackground'] ?? ''
2024 ),
2025 'numberTextColor' => self::sanitizeColor(
2026 $advancedModeSettings['dateTimePicker']['numberTextColor'] ?? ''
2027 ),
2028 'selectionColor' => self::sanitizeColor(
2029 $advancedModeSettings['dateTimePicker']['selectionColor'] ?? ''
2030 ),
2031 ];
2032 }
2033
2034 if (is_array($advancedModeSettings['select'] ?? null)) {
2035 $advanced['select'] = [
2036 'tagColor' => self::sanitizeColor(
2037 $advancedModeSettings['select']['tagColor'] ?? ''
2038 ),
2039 'tagTextColor' => self::sanitizeColor(
2040 $advancedModeSettings['select']['tagTextColor'] ?? ''
2041 ),
2042 'optionHoverColor' => self::sanitizeColor(
2043 $advancedModeSettings['select']['optionHoverColor'] ?? ''
2044 ),
2045 'selectionTextColor' => self::sanitizeColor(
2046 $advancedModeSettings['select']['selectionTextColor'] ?? ''
2047 ),
2048 'selectionBackgroundColor' => self::sanitizeColor(
2049 $advancedModeSettings['select']['selectionBackgroundColor'] ?? ''
2050 ),
2051 ];
2052 }
2053 }
2054
2055 /**
2056 * Allow Pro (and other extensions) to sanitize plan-specific advanced style sections.
2057 *
2058 * @param array<string, mixed> $advanced
2059 * @param array<string, mixed> $advancedModeSettings
2060 */
2061 return apply_filters('ivyforms/style/sanitize_advanced_mode', $advanced, $advancedModeSettings);
2062 }
2063
2064 /**
2065 * Sanitize linked or per-corner border radius settings.
2066 *
2067 * @param mixed $cornerSetting
2068 * @return array<string, mixed>|string
2069 */
2070 private static function sanitizeCornerSetting($cornerSetting)
2071 {
2072 if (is_array($cornerSetting)) {
2073 return [
2074 'isLinked' => isset($cornerSetting['isLinked']) ? (bool)$cornerSetting['isLinked'] : true,
2075 'value' => CssSanitizer::sanitizeCssUnit($cornerSetting['value'] ?? ''),
2076 'topLeft' => CssSanitizer::sanitizeCssUnit($cornerSetting['topLeft'] ?? ''),
2077 'topRight' => CssSanitizer::sanitizeCssUnit($cornerSetting['topRight'] ?? ''),
2078 'bottomRight' => CssSanitizer::sanitizeCssUnit($cornerSetting['bottomRight'] ?? ''),
2079 'bottomLeft' => CssSanitizer::sanitizeCssUnit($cornerSetting['bottomLeft'] ?? ''),
2080 ];
2081 }
2082
2083 if (is_string($cornerSetting)) {
2084 return CssSanitizer::sanitizeCssUnit($cornerSetting);
2085 }
2086
2087 return '';
2088 }
2089
2090 /**
2091 * Sanitize CSS border style value.
2092 *
2093 * @param string $value
2094 * @return string
2095 */
2096 private static function sanitizeBorderStyle(string $value): string
2097 {
2098 $normalizedValue = strtolower(trim($value));
2099 return in_array($normalizedValue, StyleConstants::ALLOWED_BORDER_STYLES, true) ? $normalizedValue : 'solid';
2100 }
2101
2102 /**
2103 * Sanitize font weight value.
2104 *
2105 * @param string $value
2106 * @return string
2107 */
2108 private static function sanitizeFontWeight(string $value): string
2109 {
2110 $normalizedValue = trim($value);
2111 return in_array($normalizedValue, StyleConstants::ALLOWED_FONT_WEIGHTS, true) ? $normalizedValue : '';
2112 }
2113
2114 private static function sanitizeFontStyle(string $value): string
2115 {
2116 $normalizedValue = strtolower(trim($value));
2117 return in_array($normalizedValue, StyleConstants::ALLOWED_FONT_STYLES, true) ? $normalizedValue : 'normal';
2118 }
2119
2120 private static function sanitizeAlignment(string $value): string
2121 {
2122 $trimmed = trim($value);
2123 return in_array($trimmed, StyleConstants::ALLOWED_ALIGNMENTS, true) ? $trimmed : 'left';
2124 }
2125
2126 /**
2127 * Sanitize color value (hex, rgb, rgba)
2128 *
2129 * @param string $color
2130 * @return string
2131 */
2132 private static function sanitizeColor(string $color): string
2133 {
2134 // Allow hex colors, rgb(), rgba(), color names
2135 $color = trim($color);
2136
2137 // Check if it's a hex color
2138 if (preg_match('/^#[a-f0-9]{6}$/i', $color) || preg_match('/^#[a-f0-9]{3}$/i', $color)) {
2139 return $color;
2140 }
2141
2142 // Check if it's rgb() or rgba()
2143 if (preg_match('/^rgba?\([0-9,.\s]+\)$/i', $color)) {
2144 return sanitize_text_field($color);
2145 }
2146
2147 // Default safe color
2148 return sanitize_text_field($color);
2149 }
2150
2151 /**
2152 * Sanitize color value for advanced style settings — rejects values unsafe for CSS output.
2153 *
2154 * @param string $color
2155 * @return string
2156 */
2157 private static function sanitizeStrictColor(string $color): string
2158 {
2159 return CssSanitizer::sanitizeStrictColor($color);
2160 }
2161
2162 /**
2163 * Sanitize bulk update request data
2164 *
2165 * @param array<string, mixed> $data
2166 * @return array<string, mixed>
2167 */
2168 public static function sanitizeBulkUpdateData(array $data): array
2169 {
2170 $sanitized = [];
2171
2172 // Sanitize column name (alphanumeric and underscore only)
2173 if (isset($data['column'])) {
2174 $sanitized['column'] = preg_replace('/[^a-zA-Z0-9_]/', '', $data['column']);
2175 }
2176
2177 // Sanitize value (depends on column)
2178 if (isset($data['value'])) {
2179 $column = $sanitized['column'] ?? '';
2180 if ($column !== '') {
2181 if (in_array($column, ['starred', 'status'], true)) {
2182 $sanitized['value'] = self::sanitizeEntryColumnValue($column, $data['value']);
2183 } else {
2184 $sanitized['value'] = self::sanitizeFormColumnValue($column, $data['value']);
2185 }
2186 } else {
2187 $sanitized['value'] = sanitize_text_field($data['value']);
2188 }
2189 }
2190
2191 // Sanitize IDs array
2192 if (isset($data['ids']) && is_array($data['ids'])) {
2193 $sanitized['ids'] = array_map('intval', $data['ids']);
2194 $sanitized['ids'] = array_filter($sanitized['ids'], fn($id) => $id > 0);
2195 }
2196
2197 return $sanitized;
2198 }
2199
2200 /**
2201 * Sanitize starred value (convert to boolean integer)
2202 *
2203 * @param mixed $value
2204 * @return int
2205 */
2206 public static function sanitizeStarredValue($value): int
2207 {
2208 return $value ? 1 : 0;
2209 }
2210
2211 /**
2212 * Sanitize entry status value
2213 *
2214 * @param string $status
2215 * @return string
2216 */
2217 public static function sanitizeEntryStatus(string $status): string
2218 {
2219 $allowedStatuses = ['read', 'unread'];
2220 $status = sanitize_text_field($status);
2221 return in_array($status, $allowedStatuses, true) ? $status : 'unread';
2222 }
2223
2224 /**
2225 * Sanitize form column value based on column type
2226 *
2227 * @param string $column
2228 * @param mixed $value
2229 * @return mixed
2230 */
2231 public static function sanitizeFormColumnValue(string $column, $value)
2232 {
2233 // Use FormRepository constants
2234 if ($column === 'starred') {
2235 return self::sanitizeStarredValue($value);
2236 }
2237 return sanitize_text_field($value);
2238 }
2239
2240 /**
2241 * Sanitize entry column value based on column type
2242 *
2243 * @param string $column
2244 * @param mixed $value
2245 * @return mixed
2246 */
2247 public static function sanitizeEntryColumnValue(string $column, $value)
2248 {
2249 // Use EntryRepository constants
2250 if ($column === 'starred') {
2251 return self::sanitizeStarredValue($value);
2252 }
2253 if ($column === 'status') {
2254 return self::sanitizeEntryStatus($value);
2255 }
2256 return sanitize_text_field($value);
2257 }
2258
2259 /**
2260 * Sanitize a single field
2261 *
2262 * @param mixed[] $field
2263 * @return mixed[]
2264 */
2265 public static function sanitizeField(array $field): array
2266 {
2267 return [
2268 'id' => (int)($field['id'] ?? 0),
2269 'fieldIndex' => (int)($field['fieldIndex'] ?? 0),
2270 'type' => sanitize_text_field($field['type'] ?? ''),
2271 'label' => sanitize_text_field($field['label'] ?? ''),
2272 'placeholder' => sanitize_text_field($field['placeholder'] ?? ''),
2273 ];
2274 }
2275 }
2276