action = 'form-'; $this->cache = CacheManager::for('forms', HOUR_IN_SECONDS); // Add query vars add_filter('query_vars', [$this, 'addQueryVars']); } /** * Register REST routes */ 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'] ] ]); // Get specific form configuration register_rest_route($this->namespace, '/forms/(?P[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' ] ] ] ]); } /** * Add query vars for form handling */ public function addQueryVars(array $vars): array { $vars[] = 'jvb_submitted'; $vars[] = 'jvb_form_error'; return $vars; } /** * Handle form submission via REST API */ public function submitForm(WP_REST_Request $request): WP_REST_Response { try { $form_type = $request->get_param('form_type'); $form_id = $request->get_param('form_id'); $form_data = $request->get_params(); $files = $request->get_file_params(); // Process the submission $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() ]); } if (array_key_exists('success', $result)){ return new WP_REST_Response($result); } return new WP_REST_Response([ 'success' => true, 'data' => $result ], 200); } catch (Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => 'An error occurred while processing your submission.' ], 500); } } protected function verifyTurnstile(string $token): bool { if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) { return true; } if (empty($token)) { return false; } return JVB()->connect('cloudflare')->verifyTurnstile($token); } /** * Handle the actual form submission logic */ protected function handleFormSubmission(string $form_type, string $form_id, array $form_data, array $files): array|WP_Error { // Get form configuration $form_config = FormBlock::getForm($form_type); if (!$form_config) { return new WP_Error('invalid_form', 'Form configuration not found.'); } // Verify Turnstile $turnstile_token = $form_data['cf-turnstile-response'] ?? ''; if (!$this->verifyTurnstile($turnstile_token)) { return new WP_Error('turnstile_failed', 'Security verification failed. Please try again.'); } // Validate file uploads if present if (!empty($files)) { try { $files = $this->validateFileUploads($files, $form_config); } catch (\Exception $e) { return new WP_Error('file_validation_failed', 'File validation error: ' . $e->getMessage()); } } // Validate and sanitize form data try { $processed_data = $this->validateAndSanitizeData($form_config, $form_data); } 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; } // Send email notification with attachments 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()); } if (!$email_sent) { return new WP_Error('email_failed', 'Failed to send your message. Please try again later.'); } // Store submission data temporarily for success display $this->cache->set('submission_' . $form_id, $processed_data); // Log successful submission $this->recordSubmission($_SERVER['REMOTE_ADDR'], $processed_data['email'] ?? ''); // Allow other plugins to hook into successful submission do_action('jvb_form_submitted', $form_type, $processed_data, $form_id); return $processed_data; } /** * Validate and sanitize form data */ protected function validateAndSanitizeData(array $form_config, array $form_data): array|WP_REST_Response { $meta = new MetaManager(null, 'form'); $processed_data = []; $errors = []; // Normalize form data to handle HTML checkbox arrays (field[] -> field) $normalized_form_data = []; foreach ($form_data as $key => $value) { // Remove [] suffix from checkbox field names $normalized_key = str_ends_with($key, '[]') ? substr($key, 0, -2) : $key; $normalized_form_data[$normalized_key] = $value; } foreach ($form_config['fields'] as $field_name => $field_config) { // Skip system fields if (in_array($field_name, ['action', 'form_id', 'form_type', 'timestamp', '_wpnonce', '_wp_http_referer', 'cf-turnstile-response'])) { continue; } $value = $normalized_form_data[$field_name] ?? ''; if (in_array($field_config['type'], ['checkbox', 'set'])) { $value = is_array($value) ? implode(',', $value) : $value; } // Check required fields if (!empty($field_config['required']) && empty($value)) { $errors['required'][] = $field_name; continue; } // Skip empty optional fields if (empty($value) && empty($field_config['required'])) { continue; } // Add field name to config for error messages $field_config['name'] = $field_name; // Validate field if (!$meta->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) ]; continue; } // Sanitize field $processed_data[$field_name] = $meta->sanitizer->sanitize($value, $field_config); } if (!empty($errors)) { $errors['success'] = false; return $errors; } return $processed_data; } /** * 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); $subject = apply_filters('jvb_form_email_subject', $form_config['email_subject'], $form_type, $form_data); // Get submitter details $submitter_email = $form_data['email'] ?? null; $submitter_name = ''; if (array_key_exists('first_name', $form_data)) { $submitter_name = $form_data['first_name']; $submitter_name .= array_key_exists('last_name', $form_data) ? ' '.$form_data['last_name'] : ''; } elseif (array_key_exists('name', $form_data)) { $submitter_name = $form_data['name']; } if (!array_key_exists('preheader', $form_config)) { $preheader = $form_config['preheader']; } else { $preheader = sprintf( 'New %s submission from %s', $form_config['title'], $submitter_name ?: ($submitter_name ?: 'website visitor') ); } // Email headers $headers = []; if ($submitter_email) { $to = $admin_email; $cc_name = $submitter_name ?: 'Submitter'; $headers[] = 'CC: ' . $cc_name . ' <' . $submitter_email . '>'; $site_name = get_bloginfo('name'); $admin_name = get_bloginfo('name') . ' Team'; $headers[] = 'Reply-To: ' . $admin_name . ' <' . $admin_email . '>, ' . $cc_name . ' <' . $submitter_email . '>'; $headers[] = 'From: ' . $site_name . ' <' . $admin_email . '>'; } else { $to = $admin_email; $site_name = get_bloginfo('name'); $headers[] = 'From: ' . $site_name . ' <' . $admin_email . '>'; $headers[] = 'Reply-To: ' . $admin_email; } // Build email body $body = JVB()->email()->h2($form_config['title']); $body .= '

Submitted: ' . current_time('F j, Y \a\t g:i A') . '

'; // Add thread notice if CC'd if ($submitter_email) { $body .= JVB()->email()->notice( 'Email Thread Created
Reply to this email to continue the conversation with ' . esc_html($submitter_name ?: 'the submitter') . '.' ); } // 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 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 ($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' => $label, 'value' => $this->formatFieldValueForEmail($field_name, $value, $config) ]; } $body .= JVB()->email()->spacer(20); $body .= JVB()->email()->table($form); // Show attachment info $attachments = $this->processFileAttachments($files); if (!empty($attachments)) { $body .= JVB()->email()->spacer(10); $body .= JVB()->email()->alert( sprintf('%d file(s) attached to this email', count($attachments)), 'info' ); } // Send the email return JVB()->email()->sendEmail( $to, $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 ($this->isEmptyValue($value)) { return ''; } $type = $field_config['type'] ?? 'text'; $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': $cleaned = preg_replace('/\D/', '', $value); if (strlen($cleaned) === 10) { return substr($cleaned, 0, 3) . '-' . substr($cleaned, 3, 3) . '-' . substr($cleaned, 6); } return $value; case 'checkbox': case 'set': $values = explode(',', $value); $labels = array_map(function($val) use ($field_config) { $val = trim($val); return $field_config['options'][$val] ?? ucfirst(str_replace('_', ' ', $val)); }, $values); return implode(', ', $labels); case 'radio': case 'select': if (isset($field_config['options'][$value])) { return $field_config['options'][$value]; } // 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; 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 .= 'Entry ' . ($index + 1) . ':
'; 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) . '
'; } $output .= '
'; } 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 */ protected function processFileAttachments(array $files): array { $attachments = []; if (empty($files)) { return $attachments; } foreach ($files as $field_name => $field_files) { // Skip metadata fields if (str_ends_with($field_name, '_meta')) { continue; } // Handle single file if (isset($field_files['tmp_name']) && !is_array($field_files['tmp_name'])) { if (!empty($field_files['tmp_name']) && is_uploaded_file($field_files['tmp_name'])) { $attachments[] = $field_files['tmp_name']; } continue; } // Handle multiple files (field[]) if (is_array($field_files) && isset($field_files['tmp_name'])) { foreach ($field_files['tmp_name'] as $index => $tmp_name) { if (!empty($tmp_name) && is_uploaded_file($tmp_name)) { $attachments[] = $tmp_name; } } } } return $attachments; } protected function validateFileUploads(array $files, array $form_config): array { $validated_files = []; $max_file_size = 10 * 1024 * 1024; // 10MB default $allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; foreach ($files as $field_name => $field_files) { // Skip metadata fields if (str_ends_with($field_name, '_meta')) { continue; } // Get field config for this upload field $field_config = $form_config['fields'][$field_name] ?? null; // Override defaults if field config specifies them if ($field_config) { $max_file_size = $field_config['max_file_size'] ?? $max_file_size; $allowed_types = $field_config['allowed_types'] ?? $allowed_types; } // Handle multiple files uploaded to same field (field[]) if (isset($field_files['tmp_name']) && is_array($field_files['tmp_name'])) { $valid_indices = []; foreach ($field_files['tmp_name'] as $index => $tmp_name) { $file = [ 'tmp_name' => $tmp_name, 'size' => $field_files['size'][$index] ?? 0, 'type' => $field_files['type'][$index] ?? '', 'name' => $field_files['name'][$index] ?? '', 'error' => $field_files['error'][$index] ?? UPLOAD_ERR_OK ]; // Validate this file if ($this->isValidFile($file, $max_file_size, $allowed_types)) { $valid_indices[] = $index; } } // Keep only valid files in the original structure if (!empty($valid_indices)) { $validated_files[$field_name] = [ 'tmp_name' => [], 'size' => [], 'type' => [], 'name' => [], 'error' => [] ]; foreach ($valid_indices as $index) { $validated_files[$field_name]['tmp_name'][] = $field_files['tmp_name'][$index]; $validated_files[$field_name]['size'][] = $field_files['size'][$index]; $validated_files[$field_name]['type'][] = $field_files['type'][$index]; $validated_files[$field_name]['name'][] = $field_files['name'][$index]; $validated_files[$field_name]['error'][] = $field_files['error'][$index]; } } } // Handle single file elseif (isset($field_files['tmp_name']) && !is_array($field_files['tmp_name'])) { if ($this->isValidFile($field_files, $max_file_size, $allowed_types)) { $validated_files[$field_name] = $field_files; } } } return $validated_files; } /** * Validate a single file */ protected function isValidFile(array $file, int $max_file_size, array $allowed_types): bool { // Check for upload errors if ($file['error'] !== UPLOAD_ERR_OK) { error_log("File upload error: " . $file['error'] . " for " . ($file['name'] ?? 'unknown')); return false; } // Check file size if ($file['size'] > $max_file_size) { error_log("File too large: " . $file['size'] . " bytes (max: $max_file_size) for " . ($file['name'] ?? 'unknown')); return false; } // Check file type if (!in_array($file['type'], $allowed_types)) { error_log("Invalid file type: " . $file['type'] . " for " . ($file['name'] ?? 'unknown')); return false; } // Security check - verify it's actually uploaded if (!is_uploaded_file($file['tmp_name'])) { error_log("Security check failed: file not uploaded via POST for " . ($file['name'] ?? 'unknown')); return false; } return true; } /** * Record submission for rate limiting */ protected function recordSubmission(string $ip_address, string $email): void { // Record IP-based submissions $hourly_key = 'submissions_hour_' . md5($ip_address); $daily_key = 'submissions_day_' . md5($ip_address); $hourly_count = (int) get_transient($hourly_key) + 1; $daily_count = (int) get_transient($daily_key) + 1; set_transient($hourly_key, $hourly_count, HOUR_IN_SECONDS); set_transient($daily_key, $daily_count, DAY_IN_SECONDS); // Record email-based submissions if provided if (!empty($email)) { $email_key = 'submissions_email_' . md5($email); $email_count = (int) get_transient($email_key) + 1; set_transient($email_key, $email_count, DAY_IN_SECONDS); } } /** * Get all available forms */ public function getForms(WP_REST_Request $request): WP_REST_Response { $forms = FormBlock::getForms(); // Remove sensitive data $public_forms = []; foreach ($forms as $key => $config) { $public_forms[$key] = [ 'title' => $config['title'], 'description' => $config['description'], 'fields' => array_map(function($field) { // Remove sensitive configuration unset($field['email_to']); return $field; }, $config['fields']) ]; } return new WP_REST_Response($public_forms, 200); } /** * Get specific form configuration */ public function getForm(WP_REST_Request $request): WP_REST_Response { $form_type = $request->get_param('form_type'); $form_config = FormBlock::getForm($form_type); if (!$form_config) { return new WP_REST_Response([ 'error' => 'Form not found' ], 404); } // Remove sensitive data unset($form_config['email_to']); return new WP_REST_Response($form_config, 200); } }