| | |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\managers\CloudflareTurnstile; |
| | | use JVBase\blocks\FormBlock; |
| | | use JVBase\utility\Features; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use WP_Error; |
| | |
| | | $this->action = 'form-'; |
| | | $this->cache = CacheManager::for('forms', HOUR_IN_SECONDS); |
| | | |
| | | // Initialize Cloudflare Turnstile if available |
| | | $this->turnstile = class_exists('JVBase\managers\CloudflareTurnstile') && jvbSiteUsesCloudflare() |
| | | ? new CloudflareTurnstile() |
| | | : null; |
| | | |
| | | // Add query vars |
| | | add_filter('query_vars', [$this, 'addQueryVars']); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Register REST routes |
| | | */ |
| | |
| | | [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'submitForm'], |
| | | 'permission_callback' => '__return_true', // Public endpoint |
| | | [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getForms'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ] |
| | | 'permission_callback' => [$this, 'checkRateLimit'], // Public endpoint, rate limited |
| | | ], |
| | | [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getForms'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ] |
| | | ]); |
| | | |
| | |
| | | $form_type = $request->get_param('form_type'); |
| | | $form_id = $request->get_param('form_id'); |
| | | $form_data = $request->get_params(); |
| | | $files = $request->get_file_params(); |
| | | |
| | | error_log('Form submission: '.print_r($request->get_params(), true)); |
| | | // Process the submission |
| | | $result = $this->handleFormSubmission($form_type, $form_id, $form_data); |
| | | $result = $this->handleFormSubmission($form_type, $form_id, $form_data, $files); |
| | | |
| | | if (is_wp_error($result)) { |
| | | return new WP_REST_Response([ |
| | |
| | | } |
| | | } |
| | | |
| | | 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|WP_Error |
| | | 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); |
| | | |
| | | error_log('Config: '.print_r($form_config, true)); |
| | | if (!$form_config) { |
| | | return new WP_Error('invalid_form', 'Form configuration not found.'); |
| | | } |
| | | |
| | | // Verify Turnstile |
| | | $turnstile_token = $form_data['cf-turnstile-response'] ?? ''; |
| | | |
| | | // Verify Turnstile if enabled |
| | | //TODO: Reenable |
| | | // if (jvbSiteUsesCloudflare() && $this->turnstile) { |
| | | // error_log('Verifying turnstile...'); |
| | | // $turnstile_token = $form_data['cf-turnstile-response'] ?? ''; |
| | | // if (!$this->turnstile->verifyTurnstile($turnstile_token)) { |
| | | // return new WP_Error('turnstile_failed', 'Security verification failed. Please try again.'); |
| | | // } |
| | | // } |
| | | 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 |
| | | $processed_data = $this->validateAndSanitizeData($form_config, $form_data); |
| | | error_log('Processed data: '.print_r($processed_data, true)); |
| | | 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 |
| | | $email_sent = $this->sendEmailNotification($form_type, $form_config, $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.'); |
| | |
| | | /** |
| | | * Send email notification |
| | | */ |
| | | protected function sendEmailNotification(string $form_type, array $form_config, array $form_data): bool |
| | | /** |
| | | * 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 = $form_data['name'] ?? ''; |
| | | $submitter_name = ''; |
| | | |
| | | // Generate unique message ID for threading |
| | | $form_id = $form_data['form_id'] ?? uniqid(); |
| | | $timestamp = current_time('timestamp'); |
| | | $message_id = sprintf('<%s-%s-%s@%s>', |
| | | $form_type, |
| | | $form_id, |
| | | $timestamp, |
| | | parse_url(home_url(), PHP_URL_HOST) |
| | | ); |
| | | |
| | | // Build unified email body |
| | | $body = '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif; line-height: 1.6; color: #333;">'; |
| | | $body .= '<h2 style="color: #2563eb; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem;">New Form Submission: ' . esc_html($form_config['title']) . '</h2>'; |
| | | $body .= '<p style="color: #6b7280;"><strong>Submitted:</strong> ' . current_time('F j, Y \a\t g:i A') . '</p>'; |
| | | |
| | | // Add note about email thread if submitter email exists |
| | | if ($submitter_email) { |
| | | $body .= '<div style="background: #f0f9ff; border-left: 4px solid #3b82f6; padding: 1rem; margin: 1rem 0; border-radius: 0 8px 8px 0;">'; |
| | | $body .= '<p style="margin: 0; color: #1e40af;"><strong>💬 Email Thread Created</strong></p>'; |
| | | $body .= '<p style="margin: 0.5rem 0 0 0; color: #1e40af; font-size: 0.9em;">This email includes both the admin and the person who submitted the form. Any replies will be shared between all parties, creating a conversation thread.</p>'; |
| | | $body .= '</div>'; |
| | | 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']; |
| | | } |
| | | |
| | | $body .= '<div style="margin: 2rem 0;">'; |
| | | |
| | | // Add submission details in a nice format |
| | | foreach ($form_config['fields'] as $field_name => $config) { |
| | | $label = $config['summaryTitle'] ?? $config['label']; |
| | | //Submitted Value |
| | | $value = $form_data[$field_name]; |
| | | if (str_contains($label, '%s')) { |
| | | switch ($config['type']) { |
| | | case 'true_false': |
| | | $replace = ($value === '1') ? $config['isTrue'] : $config['isFalse']; |
| | | break; |
| | | } |
| | | $label = sprintf( |
| | | $label, |
| | | $replace |
| | | ); |
| | | } |
| | | |
| | | $body .= '<div style="margin-bottom: 1rem; padding: 0.75rem; background: #f9fafb; border-radius: 6px;">'; |
| | | $body .= '<strong style="color: #374151; display: block; margin-bottom: 0.25rem;">' . esc_html($label) . ':</strong>'; |
| | | $body .= '<span style="color: #6b7280;">' . nl2br(esc_html($value)) . '</span>'; |
| | | $body .= '</div>'; |
| | | } |
| | | |
| | | $body .= '</div>'; |
| | | |
| | | // Add footer |
| | | $body .= '<div style="border-top: 1px solid #e5e7eb; padding-top: 1rem; margin-top: 2rem;">'; |
| | | $body .= '<p style="color: #9ca3af; font-size: 0.8em; margin: 0;"><em>'; |
| | | $body .= 'This message was sent through the ' . esc_html($form_config['title']) . ' on ' . get_bloginfo('name'); |
| | | // Email headers |
| | | $headers = []; |
| | | |
| | | if ($submitter_email) { |
| | | $body .= '<br>To continue this conversation, simply reply to this email.'; |
| | | } |
| | | |
| | | $body .= '</em></p>'; |
| | | $body .= '</div>'; |
| | | $body .= '</div>'; |
| | | |
| | | // Prepare email headers for unified thread with enhanced threading |
| | | $headers = [ |
| | | 'Content-Type: text/html; charset=UTF-8', |
| | | ]; |
| | | |
| | | // Set up recipients and reply-to based on whether submitter email exists |
| | | if ($submitter_email) { |
| | | // Primary recipient: admin |
| | | $to = $admin_email; |
| | | |
| | | // Add submitter as CC so they both see the email |
| | | $cc_name = $submitter_name ? $submitter_name : 'Form Submitter'; |
| | | $cc_name = $submitter_name ?: 'Submitter'; |
| | | $headers[] = 'CC: ' . $cc_name . ' <' . $submitter_email . '>'; |
| | | |
| | | // Set reply-to to include both emails for unified thread |
| | | $site_name = get_bloginfo('name'); |
| | | $admin_name = get_bloginfo('name') . ' Team'; |
| | | |
| | | $headers[] = 'Reply-To: ' . $admin_name . ' <' . $admin_email . '>, ' . $cc_name . ' <' . $submitter_email . '>'; |
| | | |
| | | // Set from address to be more professional |
| | | $headers[] = 'From: ' . $site_name . ' Forms <' . $admin_email . '>'; |
| | | |
| | | $headers[] = 'From: ' . $site_name . ' <' . $admin_email . '>'; |
| | | } else { |
| | | // No submitter email, just send to admin |
| | | $to = $admin_email; |
| | | $site_name = get_bloginfo('name'); |
| | | $headers[] = 'From: ' . $site_name . ' Forms <' . $admin_email . '>'; |
| | | $headers[] = 'From: ' . $site_name . ' <' . $admin_email . '>'; |
| | | $headers[] = 'Reply-To: ' . $admin_email; |
| | | } |
| | | |
| | | // Allow filtering of email data |
| | | $email_data = apply_filters('jvb_form_unified_email_data', [ |
| | | 'to' => $to, |
| | | 'subject' => $subject, |
| | | 'body' => $body, |
| | | 'headers' => $headers, |
| | | 'submitter_included' => !empty($submitter_email), |
| | | 'submitter_email' => $submitter_email, |
| | | 'message_id' => $message_id |
| | | ], $form_type, $form_data); |
| | | // Build email body |
| | | $body = JVB()->email()->h2($form_config['title']); |
| | | $body .= '<p style="font-size:13px;color:' . JVB()->email()->colours['dark-200'] . ';"> |
| | | Submitted: ' . current_time('F j, Y \a\t g:i A') . ' |
| | | </p>'; |
| | | |
| | | // Send the unified email |
| | | $email_sent = JVB()->email()->sendEmail($email_data['to'], $email_data['subject'], $email_data['body'], implode(';',$email_data['headers'])); |
| | | |
| | | // Log the email sending for debugging |
| | | if ($email_sent) { |
| | | error_log("Form email sent successfully. Recipients: {$to}" . |
| | | ($submitter_email ? " (CC: {$submitter_email})" : "") . |
| | | " | Message-ID: {$message_id}"); |
| | | } else { |
| | | error_log("Failed to send form email to: {$to}" . |
| | | ($submitter_email ? " (CC: {$submitter_email})" : "")); |
| | | // Add thread notice if CC'd |
| | | if ($submitter_email) { |
| | | $body .= JVB()->email()->notice( |
| | | '<strong>Email Thread Created</strong><br>Reply to this email to continue the conversation with ' . |
| | | esc_html($submitter_name ?: 'the submitter') . '.' |
| | | ); |
| | | } |
| | | |
| | | // Build form data array for table |
| | | $form = []; |
| | | foreach ($form_config['fields'] as $field_name => $config) { |
| | | // Skip file upload fields |
| | | if (in_array($config['type'], ['upload'])) { |
| | | continue; |
| | | } |
| | | |
| | | $value = $form_data[$field_name] ?? ''; |
| | | |
| | | // Skip empty fields |
| | | if (empty($value)) { |
| | | continue; |
| | | } |
| | | |
| | | $form[] = [ |
| | | 'label' => $config['summaryTitle'] ?? $config['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 |
| | | $email_sent = JVB()->email()->sendEmail( |
| | | $to, |
| | | $subject, |
| | | $body, |
| | | '', |
| | | $headers, |
| | | $attachments |
| | | ); |
| | | |
| | | return $email_sent; |
| | | } |
| | | |
| | | /** |
| | | * Format field value for email display |
| | | */ |
| | | protected function formatFieldValueForEmail(string $field_name, mixed $value, array $field_config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $type = $field_config['type'] ?? 'text'; |
| | | $type = $field_config['subType'] ?? $type; |
| | | |
| | | switch ($type) { |
| | | 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); |
| | | } |
| | | return $value; |
| | | |
| | | case 'checkbox': |
| | | case 'set': |
| | | // Convert array of values to comma-separated labels |
| | | if (!is_array($value)) { |
| | | $value = explode(',', $value); |
| | | } |
| | | $labels = []; |
| | | foreach ($value as $val) { |
| | | $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 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 |
| | | |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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 |