| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\rest\RestRouteManager; |
| | | use JVBase\managers\CacheManager; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\managers\CloudflareTurnstile; |
| | | use JVBase\meta\Sanitizer; |
| | | use JVBase\meta\Validator; |
| | | use JVBase\rest\PermissionHandler; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\blocks\FormBlock; |
| | | use JVBase\utility\Features; |
| | | use JVBase\rest\Route; |
| | | use JVBase\base\Site; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use WP_Error; |
| | |
| | | * |
| | | * Handles REST API endpoints for form submissions |
| | | */ |
| | | class FormRoutes extends RestRouteManager |
| | | class FormRoutes extends Rest |
| | | { |
| | | protected CacheManager $cache; |
| | | protected FormBlock $form_block; |
| | | protected CloudflareTurnstile|null $turnstile; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->cacheName = 'forms'; |
| | | $this->cacheTtl = HOUR_IN_SECONDS; |
| | | parent::__construct(); |
| | | $this->action = 'form-'; |
| | | $this->cache = CacheManager::for('forms', HOUR_IN_SECONDS); |
| | | |
| | | |
| | | // Add query vars |
| | | add_filter('query_vars', [$this, 'addQueryVars']); |
| | |
| | | */ |
| | | public function registerRoutes(): void |
| | | { |
| | | // Form submission endpoint |
| | | register_rest_route($this->namespace, '/forms', [ |
| | | [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'submitForm'], |
| | | 'permission_callback' => [$this, 'checkRateLimit'], // Public endpoint, rate limited |
| | | ], |
| | | [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getForms'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ] |
| | | ]); |
| | | // ['actionNonce'=>'dash-'] |
| | | Route::for('forms') |
| | | ->post([$this, 'submitForm']) |
| | | ->args([ |
| | | 'form_type' => 'string|required', |
| | | 'form_id' => 'string|required', |
| | | 'timestamp' => 'string', |
| | | 'cf-turnstile-response' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(5) // 5 submissions per minute |
| | | ->get([$this, 'getForms']) |
| | | ->auth(PermissionHandler::combine(['logged_in', ['actionNonce'=>'dash-']])) |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | // Get specific form configuration |
| | | register_rest_route($this->namespace, '/forms/(?P<form_type>[a-zA-Z0-9_-]+)', [ |
| | | [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getForm'], |
| | | 'permission_callback' => [$this, 'checkPermission'], |
| | | 'args' => [ |
| | | 'form_type' => [ |
| | | 'required' => true, |
| | | 'type' => 'string', |
| | | 'sanitize_callback' => 'sanitize_text_field' |
| | | ] |
| | | ] |
| | | ] |
| | | ]); |
| | | Route::for(Route::pattern('forms/{form_type}')) |
| | | ->get([$this, 'getForm']) |
| | | ->arg('form_type', 'string|required') |
| | | ->auth('logged_in') |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | $result = $this->handleFormSubmission($form_type, $form_id, $form_data, $files); |
| | | |
| | | if (is_wp_error($result)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => $result->get_error_message() |
| | | ]); |
| | | return $this->error( |
| | | $result->get_error_message(), |
| | | $result->get_error_code(), |
| | | 400 |
| | | ); |
| | | } |
| | | if (array_key_exists('success', $result)){ |
| | | return new WP_REST_Response($result); |
| | | return $this->validationError($result); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'data' => $result |
| | | ], 200); |
| | | return $this->success($result); |
| | | |
| | | } catch (Exception $e) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'An error occurred while processing your submission.' |
| | | ], 500); |
| | | $this->logError('Form submission error', [ |
| | | 'message' => $e->getMessage(), |
| | | 'trace' => $e->getTraceAsString() |
| | | ]); |
| | | |
| | | return $this->error( |
| | | 'An error occurred while processing your submission.', |
| | | 'submission_error', |
| | | 500 |
| | | ); |
| | | } |
| | | } |
| | | |
| | | protected function verifyTurnstile(string $token): bool |
| | | { |
| | | if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) { |
| | | if (!Site::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) { |
| | | return true; |
| | | } |
| | | |
| | |
| | | { |
| | | // Get form configuration |
| | | $form_config = FormBlock::getForm($form_type); |
| | | |
| | | if (!$form_config) { |
| | | return new WP_Error('invalid_form', 'Form configuration not found.'); |
| | | } |
| | |
| | | } catch (\Exception $e) { |
| | | return new WP_Error('validation_failed', 'Data validation error: ' . $e->getMessage()); |
| | | } |
| | | |
| | | if (array_key_exists('success', $processed_data) && $processed_data['success'] === false) { |
| | | return $processed_data; |
| | | } |
| | |
| | | try { |
| | | $email_sent = $this->sendEmailNotification($form_type, $form_config, $processed_data, $files); |
| | | } catch (\Exception $e) { |
| | | ; |
| | | return new WP_Error('email_failed', 'Email error: ' . $e->getMessage()); |
| | | } |
| | | |
| | |
| | | */ |
| | | protected function validateAndSanitizeData(array $form_config, array $form_data): array|WP_REST_Response |
| | | { |
| | | $meta = new MetaManager(null, 'form'); |
| | | $validator = new Validator(); |
| | | $sanitizer = new Sanitizer(); |
| | | |
| | | $processed_data = []; |
| | | $errors = []; |
| | | |
| | |
| | | $value = $normalized_form_data[$field_name] ?? ''; |
| | | |
| | | if (in_array($field_config['type'], ['checkbox', 'set'])) { |
| | | $value = jvbCommaList((is_array($value)) ? $value : explode(',',$value)); |
| | | $value = is_array($value) ? implode(',', $value) : $value; |
| | | } |
| | | |
| | | // Check required fields |
| | |
| | | $field_config['name'] = $field_name; |
| | | |
| | | // Validate field |
| | | if (!$meta->validator->validate($value, $field_config)) { |
| | | if (!$validator->validate($value, $field_config)) { |
| | | $label = $field_config['label'] ?? ucfirst(str_replace('_', ' ', $field_name)); |
| | | $errors['errors'][$field_name] = [ |
| | | 'message' => sprintf('Field "%s" contains invalid data.', $label) |
| | |
| | | } |
| | | |
| | | // Sanitize field |
| | | $processed_data[$field_name] = $meta->sanitizer->sanitize($value, $field_config); |
| | | $processed_data[$field_name] = $sanitizer->sanitize($value, $field_config); |
| | | } |
| | | |
| | | if (!empty($errors)) { |
| | |
| | | /** |
| | | * Send email notification |
| | | */ |
| | | /** |
| | | * Send email notification |
| | | */ |
| | | protected function sendEmailNotification(string $form_type, array $form_config, array $form_data, array $files = []): bool |
| | | { |
| | | $admin_email = apply_filters('jvb_form_email_to', $form_config['email_to'], $form_type, $form_data); |
| | |
| | | $submitter_name = $form_data['name']; |
| | | } |
| | | |
| | | if (array_key_exists('preheader', $form_config)) { |
| | | $preheader = $form_config['preheader']; |
| | | } else { |
| | | $submitter_name = $submitter_name?:'website visitor'; |
| | | $preheader = sprintf( |
| | | 'New %s submission from %s', |
| | | $form_config['title'], |
| | | $submitter_name |
| | | ); |
| | | } |
| | | |
| | | $subject .= ' ' . $submitter_name; |
| | | |
| | | |
| | | // Email headers |
| | | $headers = []; |
| | | |
| | |
| | | ); |
| | | } |
| | | |
| | | // Build form data array for table |
| | | // Build form data array for table - using similar logic to JS |
| | | $skip_fields = array_merge( |
| | | ['sendAll', 'action', 'form_id', 'form_type', 'timestamp', '_wpnonce', '_wp_http_referer', 'cf-turnstile-response'], |
| | | $form_config['ignore'] ?? [] |
| | | ); |
| | | |
| | | $form = []; |
| | | foreach ($form_config['fields'] as $field_name => $config) { |
| | | // Skip file upload fields |
| | | if ($config['type'] == 'upload') { |
| | | // Skip ignored fields |
| | | if (in_array($field_name, $skip_fields)) { |
| | | continue; |
| | | } |
| | | |
| | | // Skip upload fields (handled separately) |
| | | if ($config['type'] === 'upload') { |
| | | continue; |
| | | } |
| | | |
| | | $value = $form_data[$field_name] ?? ''; |
| | | |
| | | // Skip empty fields |
| | | if (empty($value)) { |
| | | if ($this->isEmptyValue($value)) { |
| | | continue; |
| | | } |
| | | |
| | | // Get label - check for summaryTitle first, then label (similar to JS checking legend then label) |
| | | $label = $config['summaryTitle'] ?? $config['label'] ?? ucfirst(str_replace('_', ' ', $field_name)); |
| | | |
| | | $form[] = [ |
| | | 'label' => $config['summaryTitle'] ?? $config['label'], |
| | | 'label' => $label, |
| | | 'value' => $this->formatFieldValueForEmail($field_name, $value, $config) |
| | | ]; |
| | | } |
| | |
| | | $subject, |
| | | $body, |
| | | '', |
| | | $preheader, |
| | | $headers, |
| | | $attachments |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Check if value is empty (similar to JS isEmptyValue) |
| | | */ |
| | | protected function isEmptyValue($value): bool |
| | | { |
| | | if ($value === null || $value === '' || $value === false) { |
| | | return true; |
| | | } |
| | | |
| | | if (is_array($value) && empty($value)) { |
| | | return true; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Format field value for email display |
| | | */ |
| | | protected function formatFieldValueForEmail(string $field_name, mixed $value, array $field_config): string |
| | | { |
| | | if (empty($value)) { |
| | | if ($this->isEmptyValue($value)) { |
| | | return ''; |
| | | } |
| | | |
| | |
| | | $type = $field_config['subType'] ?? $type; |
| | | |
| | | switch ($type) { |
| | | case 'repeater': |
| | | return $this->formatRepeaterForEmail($value, $field_config); |
| | | |
| | | case 'tag-list': |
| | | return $this->formatTagListForEmail($value); |
| | | |
| | | case 'location': |
| | | return $this->formatLocationForEmail($value); |
| | | |
| | | case 'phone': |
| | | case 'tel': |
| | | // Format phone number as xxx-xxx-xxxx |
| | | $cleaned = preg_replace('/\D/', '', $value); |
| | | if (strlen($cleaned) === 10) { |
| | | return substr($cleaned, 0, 3) . '-' . substr($cleaned, 3, 3) . '-' . substr($cleaned, 6); |
| | |
| | | |
| | | case 'checkbox': |
| | | case 'set': |
| | | // Convert array of values to comma-separated labels |
| | | if (!is_array($value)) { |
| | | $value = explode(',', $value); |
| | | } |
| | | $labels = []; |
| | | foreach ($value as $val) { |
| | | $values = explode(',', $value); |
| | | $labels = array_map(function($val) use ($field_config) { |
| | | $val = trim($val); |
| | | if (isset($field_config['options'][$val])) { |
| | | $labels[] = $field_config['options'][$val]; |
| | | } else { |
| | | $labels[] = $val; // Fallback to value if no label found |
| | | } |
| | | } |
| | | return $field_config['options'][$val] ?? ucfirst(str_replace('_', ' ', $val)); |
| | | }, $values); |
| | | return implode(', ', $labels); |
| | | |
| | | case 'radio': |
| | | case 'select': |
| | | // Return label instead of value |
| | | if (isset($field_config['options'][$value])) { |
| | | return $field_config['options'][$value]; |
| | | } |
| | | return $value; |
| | | |
| | | case 'textarea': |
| | | case 'wysiwyg': |
| | | // Keep line breaks for email display |
| | | return $value; // nl2br is already applied in the email body |
| | | // Fallback: capitalize the value |
| | | return ucfirst(str_replace('_', ' ', $value)); |
| | | |
| | | case 'true_false': |
| | | $true_text = $field_config['true_text'] ?? 'Yes'; |
| | | $false_text = $field_config['false_text'] ?? 'No'; |
| | | return ($value === '1' || $value === 1 || $value === true) ? $true_text : $false_text; |
| | | |
| | | case 'email': |
| | | return $value; // Will be clickable in email client |
| | | |
| | | case 'url': |
| | | return $value; // Will be clickable in email client |
| | | |
| | | default: |
| | | return is_array($value) ? implode(', ', $value) : $value; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Format repeater field for email |
| | | */ |
| | | protected function formatRepeaterForEmail(array $rows, array $field_config): string |
| | | { |
| | | $output = ''; |
| | | |
| | | foreach ($rows as $index => $row) { |
| | | $output .= '<strong>Entry ' . ($index + 1) . ':</strong><br>'; |
| | | |
| | | foreach ($row as $sub_field => $sub_value) { |
| | | if ($this->isEmptyValue($sub_value)) { |
| | | continue; |
| | | } |
| | | |
| | | // Get sub-field label if available |
| | | $sub_config = $field_config['sub_fields'][$sub_field] ?? []; |
| | | $label = $sub_config['label'] ?? ucfirst(str_replace('_', ' ', $sub_field)); |
| | | |
| | | $output .= ' ' . esc_html($label) . ': ' . esc_html($sub_value) . '<br>'; |
| | | } |
| | | |
| | | $output .= '<br>'; |
| | | } |
| | | |
| | | return $output; |
| | | } |
| | | |
| | | /** |
| | | * Format tag-list field for email |
| | | */ |
| | | protected function formatTagListForEmail(array $tags): string |
| | | { |
| | | $items = []; |
| | | |
| | | foreach ($tags as $tag) { |
| | | // Get first non-empty value as display |
| | | $display = ''; |
| | | foreach ($tag as $field => $value) { |
| | | if (!$this->isEmptyValue($value)) { |
| | | $display = $value; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if ($display) { |
| | | $items[] = $display; |
| | | } |
| | | } |
| | | |
| | | return implode(', ', $items); |
| | | } |
| | | |
| | | /** |
| | | * Format location field for email |
| | | */ |
| | | protected function formatLocationForEmail(array $location): string |
| | | { |
| | | $parts = []; |
| | | |
| | | if (!empty($location['street'])) $parts[] = $location['street']; |
| | | if (!empty($location['city'])) $parts[] = $location['city']; |
| | | if (!empty($location['province'])) $parts[] = $location['province']; |
| | | if (!empty($location['postal_code'])) $parts[] = $location['postal_code']; |
| | | if (!empty($location['country'])) $parts[] = $location['country']; |
| | | |
| | | return !empty($parts) ? implode(', ', $parts) : ($location['address'] ?? ''); |
| | | } |
| | | |
| | | /** |
| | | * Process uploaded files for email attachments |
| | | * Files are in PHP's tmp directory and will be automatically cleaned up after request |
| | | */ |
| | |
| | | ]; |
| | | } |
| | | |
| | | return new WP_REST_Response($public_forms, 200); |
| | | return $this->success($public_forms); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | public function getForm(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $form_type = $request->get_param('form_type'); |
| | | $form_type = sanitize_text_field($request->get_param('form_type')); |
| | | $form_config = FormBlock::getForm($form_type); |
| | | |
| | | if (!$form_config) { |
| | | return new WP_REST_Response([ |
| | | 'error' => 'Form not found' |
| | | ], 404); |
| | | return $this->notFound('Form not found'); |
| | | } |
| | | |
| | | // Remove sensitive data |
| | | unset($form_config['email_to']); |
| | | |
| | | return new WP_REST_Response($form_config, 200); |
| | | return $this->success($form_config); |
| | | } |
| | | } |