| @@ -3,13 +3,18 @@ | ||
| 3 | 3 | namespace FluentBooking\App\Services; |
| 4 | 4 | |
| 5 | 5 | use FluentBooking\App\Models\Booking; |
| 6 | 6 | use FluentBooking\App\Models\CalendarSlot; |
| 7 | +use FluentBooking\App\Services\Libs\FileSystem; | |
| 8 | +use FluentBooking\App\Services\SanitizeService; | |
| 7 | 9 | use FluentBooking\Framework\Support\Arr; |
| 8 | 10 | |
| 9 | 11 | class BookingFieldService |
| 10 | 12 | { |
| 11 | - public static function getCustomFieldsData($postedData, CalendarSlot $slot) | |
| 13 | + /** | |
| 14 | + * @param array|null $mappedKeys Fluent Forms mapped fields: read and sanitized only, the form owns validation. | |
| 15 | + */ | |
| 16 | + public static function getCustomFieldsData($postedData, CalendarSlot $slot, $mappedKeys = null) | |
| 12 | 17 | { |
| 13 | 18 | $customFields = self::getCustomFields($slot, true); |
| 14 | 19 | |
| 15 | 20 | $errors = []; |
| @@ -16,12 +21,29 @@ | ||
| 16 | 21 | |
| 17 | 22 | $formattedValues = []; |
| 18 | 23 | |
| 19 | 24 | foreach ($customFields as $fieldKey => $customField) { |
| 25 | + $isMapped = $mappedKeys !== null; | |
| 26 | + | |
| 27 | + if ($isMapped && !in_array($fieldKey, $mappedKeys, true)) { | |
| 28 | + continue; | |
| 29 | + } | |
| 30 | + | |
| 20 | 31 | $value = wp_unslash(Arr::get($postedData, $fieldKey)); |
| 21 | - if (Arr::isTrue($customField, 'required')) { | |
| 22 | - if (!$value || ($customField['type'] == 'checkbox' && $value != 'Yes')) { | |
| 23 | - // translators: %s is the label of the required field | |
| 32 | + | |
| 33 | + if ($customField['type'] === 'file' && $value) { | |
| 34 | + // A posted external URL would render as a trusted download link, and | |
| 35 | + // pro deletes stored basenames from the upload folder with the booking. | |
| 36 | + // Slice first so a crafted array can't force a check per entry. | |
| 37 | + $value = array_slice((array) $value, 0, (int) Arr::get($customField, 'max_file_allow', 1)); | |
| 38 | + $value = array_values(array_filter($value, [FileSystem::class, 'isUploadedFileUrl'])); | |
| 39 | + } | |
| 40 | + | |
| 41 | + if (!$isMapped && Arr::isTrue($customField, 'required')) { | |
| 42 | + $isTerms = $customField['type'] === 'terms-and-conditions'; | |
| 43 | + $isCheckbox = $customField['type'] === 'checkbox'; | |
| 44 | + if (!$value || ($isCheckbox && $value !== 'Yes') || ($isTerms && $value !== 'Accepted')) { | |
| 45 | + /* translators: %s: Field label */ | |
| 24 | 46 | $errors[$fieldKey . '.required'] = sprintf(__('%s is required', 'fluent-booking'), $customField['label']); |
| 25 | 47 | continue; |
| 26 | 48 | } |
| 27 | 49 | } |
| @@ -28,14 +50,11 @@ | ||
| 28 | 50 | |
| 29 | 51 | if (is_array($value)) { |
| 30 | 52 | if ($customField['type'] === 'multi-select') { |
| 31 | 53 | $value = array_map(function ($item) { |
| 32 | - return sanitize_text_field(Arr::get($item, 'value')); | |
| 54 | + $val = is_array($item) ? Arr::get($item, 'value') : $item; | |
| 55 | + return sanitize_text_field($val); | |
| 33 | 56 | },$value); |
| 34 | - } else if ($customField['type'] === 'file') { | |
| 35 | - $maxField = Arr::get($customField, 'max_file_allow', 1); | |
| 36 | - $value = array_slice($value, 0, $maxField); | |
| 37 | - $value = array_map('sanitize_text_field', $value); | |
| 38 | 57 | } else { |
| 39 | 58 | $value = array_map('sanitize_text_field', $value); |
| 40 | 59 | } |
| 41 | 60 | } else if ($customField['type'] == 'textarea') { |
| @@ -43,8 +62,14 @@ | ||
| 43 | 62 | } else { |
| 44 | 63 | $value = sanitize_text_field($value); |
| 45 | 64 | } |
| 46 | 65 | |
| 66 | + if (!$isMapped && $customField['type'] === 'phone' && $value && !Helper::isValidPhoneNumber($value)) { | |
| 67 | + /* translators: %s: Field label */ | |
| 68 | + $errors[$fieldKey . '.valid_phone_number'] = sprintf(__('%s is not a valid phone number', 'fluent-booking'), $customField['label']); | |
| 69 | + continue; | |
| 70 | + } | |
| 71 | + | |
| 47 | 72 | $formattedValues[$fieldKey] = $value; |
| 48 | 73 | } |
| 49 | 74 | |
| 50 | 75 | if ($errors) { |
| @@ -55,12 +80,12 @@ | ||
| 55 | 80 | } |
| 56 | 81 | |
| 57 | 82 | public static function getBookingFields(CalendarSlot $calendarSlot, $cached = false) |
| 58 | 83 | { |
| 59 | - static $bookingFields = null; | |
| 84 | + static $bookingFields = []; | |
| 60 | 85 | |
| 61 | - if ($cached && $bookingFields) { | |
| 62 | - return $bookingFields; | |
| 86 | + if ($cached && isset($bookingFields[$calendarSlot->id])) { | |
| 87 | + return $bookingFields[$calendarSlot->id]; | |
| 63 | 88 | } |
| 64 | 89 | |
| 65 | 90 | $requiredIndexes = ['name', 'email', 'message', 'cancellation_reason', 'rescheduling_reason']; |
| 66 | 91 | |
| @@ -141,8 +166,9 @@ | ||
| 141 | 166 | 'system_defined' => true, |
| 142 | 167 | 'disable_alter' => false |
| 143 | 168 | ]; |
| 144 | 169 | } |
| 170 | + | |
| 145 | 171 | if ($calendarSlot->isLocationFieldRequired()) { |
| 146 | 172 | $requiredIndexes[] = 'location'; |
| 147 | 173 | $defaultFields['location'] = [ |
| 148 | 174 | 'index' => 7, |
| @@ -231,11 +257,9 @@ | ||
| 231 | 257 | } |
| 232 | 258 | |
| 233 | 259 | $existingFields['email']['disabled'] = false; |
| 234 | 260 | |
| 235 | - $bookingFields = apply_filters('fluent_booking/booking_fields', array_values($existingFields), $calendarSlot); | |
| 236 | - | |
| 237 | - return $bookingFields; | |
| 261 | + return $bookingFields[$calendarSlot->id] = apply_filters('fluent_booking/booking_fields', array_values($existingFields), $calendarSlot); | |
| 238 | 262 | } |
| 239 | 263 | |
| 240 | 264 | public static function getBookingFieldLabels(CalendarSlot $calendarSlot, $enabledOnly = false) |
| 241 | 265 | { |
| @@ -254,9 +278,10 @@ | ||
| 254 | 278 | } |
| 255 | 279 | |
| 256 | 280 | public static function generateFieldName($calendarEvent, $fieldLabel) |
| 257 | 281 | { |
| 258 | - $fieldName = 'custom_' . sanitize_title($fieldLabel); | |
| 282 | + $fieldLabel = preg_replace('/[^A-Za-z0-9]/', '_', $fieldLabel); | |
| 283 | + $fieldName = 'custom_' . strtolower($fieldLabel); | |
| 259 | 284 | $bookingFields = self::getBookingFields($calendarEvent); |
| 260 | 285 | |
| 261 | 286 | $matched = 0; |
| 262 | 287 | foreach ($bookingFields as $field) { |
| @@ -296,11 +321,11 @@ | ||
| 296 | 321 | $formattedData = []; |
| 297 | 322 | |
| 298 | 323 | foreach ($customFormData as $dataKey => $value) { |
| 299 | 324 | $label = $labels[$dataKey] ?? $dataKey; |
| 300 | - | |
| 325 | + | |
| 301 | 326 | $formattedValue = is_array($value) ? implode(', ', $value) : $value; |
| 302 | - | |
| 327 | + | |
| 303 | 328 | $field = self::getBookingFieldByName($booking->calendar_event, $dataKey); |
| 304 | 329 | |
| 305 | 330 | $fieldType = Arr::get($field, 'type'); |
| 306 | 331 | |
| @@ -306,16 +331,22 @@ | ||
| 306 | 331 | |
| 307 | 332 | if ($fieldType == 'file' && is_array($value)) { |
| 308 | 333 | $formattedValue = self::getUploadedFiles($value, $htmlSupport); |
| 309 | 334 | } |
| 310 | - | |
| 335 | + | |
| 311 | 336 | if ($fieldType == 'hidden') { |
| 312 | 337 | if ($isPublic) continue; |
| 313 | 338 | $formattedValue = EditorShortcodeParser::parse($formattedValue, $booking); |
| 339 | + | |
| 340 | + // Some shortcodes return HTML, and the admin renders hidden values as HTML. | |
| 341 | + if ($htmlSupport) { | |
| 342 | + $formattedValue = wp_kses_post($formattedValue); | |
| 343 | + } | |
| 314 | 344 | } |
| 315 | 345 | |
| 316 | 346 | $formattedData[$dataKey] = [ |
| 317 | 347 | 'label' => $label, |
| 348 | + 'type' => $fieldType, | |
| 318 | 349 | 'value' => $formattedValue |
| 319 | 350 | ]; |
| 320 | 351 | } |
| 321 | 352 | |
| @@ -406,19 +437,88 @@ | ||
| 406 | 437 | if ($fieldValue && Arr::get($field, 'type') == 'date') { |
| 407 | 438 | $minDate = Arr::get($field, 'min_date'); |
| 408 | 439 | $maxDate = Arr::get($field, 'max_date'); |
| 409 | 440 | |
| 410 | - $fieldValue = date('Y-m-d', strtotime($fieldValue)); | |
| 411 | - $minDate = date('Y-m-d', strtotime($minDate ?: '1900-01-01')); | |
| 412 | - $maxDate = date('Y-m-d', strtotime($maxDate ?: date('Y-12-31'))); | |
| 441 | + $fieldValue = DateTimeHelper::getFormattedDate($fieldValue, Arr::get($field, 'date_format')); | |
| 442 | + $minDate = gmdate('Y-m-d', strtotime($minDate ?: '1900-01-01')); | |
| 443 | + $maxDate = gmdate('Y-m-d', strtotime($maxDate ?: gmdate('Y-12-31'))); | |
| 413 | 444 | |
| 414 | 445 | if ($minDate && $fieldValue < $minDate) { |
| 415 | - return new \WP_Error('invalid_date', sprintf(__('The date for %s cannot be earlier than %s.', 'fluent-booking'), $field['label'], $minDate)); | |
| 446 | + /* translators: %1$s: Field label, %2$s: Minimum date */ | |
| 447 | + return new \WP_Error('invalid_date', sprintf(__('The date for %1$s cannot be earlier than %2$s.', 'fluent-booking'), $field['label'], $minDate)); | |
| 416 | 448 | } |
| 417 | 449 | if ($maxDate && $fieldValue > $maxDate) { |
| 418 | - return new \WP_Error('invalid_date', sprintf(__('The date for %s cannot be later than %s.', 'fluent-booking'), $field['label'], $maxDate)); | |
| 450 | + /* translators: %1$s: Field label, %2$s: Maximum date */ | |
| 451 | + return new \WP_Error('invalid_date', sprintf(__('The date for %1$s cannot be later than %2$s.', 'fluent-booking'), $field['label'], $maxDate)); | |
| 419 | 452 | } |
| 420 | 453 | } |
| 421 | 454 | } |
| 422 | 455 | return true; |
| 456 | + } | |
| 457 | + | |
| 458 | + /** | |
| 459 | + * Format booking fields (text sanitized, terms-and-conditions kses'd). Shared | |
| 460 | + * by the admin save and calendar import so neither path can store raw HTML. | |
| 461 | + * $calendarEvent is null-safe (import skips name generation). | |
| 462 | + */ | |
| 463 | + public static function sanitizeBookingFields($bookingFields, $calendarEvent = null) | |
| 464 | + { | |
| 465 | + if (!is_array($bookingFields)) { | |
| 466 | + return []; | |
| 467 | + } | |
| 468 | + | |
| 469 | + $optionRequiredFields = ['dropdown', 'radio', 'checkbox-group', 'multi-select']; | |
| 470 | + $textFields = ['type', 'name', 'label', 'placeholder', 'limit', 'help_text', 'date_format', 'min_date', 'max_date']; | |
| 471 | + $booleanFields = ['enabled', 'required', 'system_defined', 'disable_alter', 'is_sms_number']; | |
| 472 | + | |
| 473 | + $formattedFields = []; | |
| 474 | + | |
| 475 | + foreach ($bookingFields as $value) { | |
| 476 | + if (!is_array($value)) { | |
| 477 | + continue; | |
| 478 | + } | |
| 479 | + | |
| 480 | + if ($calendarEvent) { | |
| 481 | + if (empty($value['name'])) { | |
| 482 | + $value['name'] = self::generateFieldName($calendarEvent, Arr::get($value, 'label', '')); | |
| 483 | + } else { | |
| 484 | + $value['name'] = self::maybeGenerateFieldName($calendarEvent, $value); | |
| 485 | + } | |
| 486 | + } else { | |
| 487 | + $value['name'] = sanitize_text_field(Arr::get($value, 'name', '')); | |
| 488 | + } | |
| 489 | + | |
| 490 | + $textValues = array_map('sanitize_text_field', Arr::only($value, $textFields)); | |
| 491 | + | |
| 492 | + $booleanValues = array_map(function ($valueItem) { | |
| 493 | + return $valueItem === true || $valueItem === 'true' || $valueItem == 1; | |
| 494 | + }, Arr::only($value, $booleanFields)); | |
| 495 | + | |
| 496 | + $formattedField = array_merge($textValues, $booleanValues); | |
| 497 | + | |
| 498 | + $fieldType = Arr::get($value, 'type'); | |
| 499 | + | |
| 500 | + $formattedField['index'] = (int) Arr::get($value, 'index'); | |
| 501 | + if (in_array($fieldType, $optionRequiredFields)) { | |
| 502 | + $formattedField['options'] = array_map('sanitize_text_field', (array) Arr::get($value, 'options', [])); | |
| 503 | + } | |
| 504 | + if ($fieldType == 'file') { | |
| 505 | + $formattedField['max_file_allow'] = intval(Arr::get($value, 'max_file_allow')); | |
| 506 | + $formattedField['allow_file_types'] = array_map('sanitize_text_field', (array) Arr::get($value, 'allow_file_types', [])); | |
| 507 | + $formattedField['file_size_value'] = intval(Arr::get($value, 'file_size_value')); | |
| 508 | + $formattedField['file_size_unit'] = SanitizeService::checkCollection(Arr::get($value, 'file_size_unit'), ['kb', 'mb']); | |
| 509 | + } | |
| 510 | + if ($fieldType == 'hidden') { | |
| 511 | + $formattedField['default_value'] = sanitize_text_field(Arr::get($value, 'default_value')); | |
| 512 | + } | |
| 513 | + if ($fieldType == 'terms-and-conditions') { | |
| 514 | + $formattedField['terms_and_conditions'] = wp_kses_post(Arr::get($value, 'terms_and_conditions')); | |
| 515 | + } | |
| 516 | + | |
| 517 | + $formattedField = apply_filters('fluent_booking/save_event_booking_field_' . $fieldType, $formattedField, $value, $calendarEvent); | |
| 518 | + | |
| 519 | + $formattedFields[] = $formattedField; | |
| 520 | + } | |
| 521 | + | |
| 522 | + return $formattedFields; | |
| 423 | 523 | } |
| 424 | 524 | } |