<?php
|
namespace JVBase\rest\routes;
|
|
use JVBase\rest\RestRouteManager;
|
use JVBase\managers\CacheManager;
|
use JVBase\meta\MetaManager;
|
use JVBase\managers\CloudflareTurnstile;
|
use JVBase\blocks\FormBlock;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
use WP_Error;
|
use Exception;
|
|
if (!defined('ABSPATH')) {
|
exit; // Exit if accessed directly
|
}
|
|
/**
|
* Form Routes Class
|
*
|
* Handles REST API endpoints for form submissions
|
*/
|
class FormRoutes extends RestRouteManager
|
{
|
protected CacheManager $cache;
|
protected FormBlock $form_block;
|
protected CloudflareTurnstile|null $turnstile;
|
|
public function __construct()
|
{
|
parent::__construct();
|
$this->action = 'form-';
|
$this->cache = new CacheManager('form_submissions', 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
|
*/
|
public function registerRoutes(): void
|
{
|
// Form submission endpoint
|
register_rest_route($this->namespace, '/forms', [
|
[
|
'methods' => 'POST',
|
'callback' => [$this, 'submitForm'],
|
'permission_callback' => '__return_true', // Public endpoint
|
[
|
'methods' => 'GET',
|
'callback' => [$this, 'getForms'],
|
'permission_callback' => [$this, 'checkPermission']
|
]
|
]
|
]);
|
|
// 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'
|
]
|
]
|
]
|
]);
|
}
|
|
/**
|
* 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();
|
|
error_log('Form submission: '.print_r($request->get_params(), true));
|
// Process the submission
|
$result = $this->handleFormSubmission($form_type, $form_id, $form_data);
|
|
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);
|
}
|
}
|
|
/**
|
* Handle the actual form submission logic
|
*/
|
protected function handleFormSubmission(string $form_type, string $form_id, array $form_data): array|WP_Error
|
{
|
// Get form configuration
|
$form_config = FormBlock::getForm($form_type);
|
|
error_log('Config: '.print_r($form_config, true));
|
|
|
// 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.');
|
// }
|
// }
|
|
// Validate and sanitize form data
|
$processed_data = $this->validateAndSanitizeData($form_config, $form_data);
|
error_log('Processed data: '.print_r($processed_data, true));
|
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);
|
|
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, HOUR_IN_SECONDS);
|
|
// 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 = jvbCommaList((is_array($value)) ? $value : explode(',',$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): 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'] ?? '';
|
|
// 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>';
|
}
|
|
$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');
|
|
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';
|
$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 . '>';
|
|
} else {
|
// No submitter email, just send to admin
|
$to = $admin_email;
|
$site_name = get_bloginfo('name');
|
$headers[] = 'From: ' . $site_name . ' Forms <' . $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);
|
|
// Send the unified email
|
$email_sent = jvbMail($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})" : ""));
|
}
|
|
return $email_sent;
|
}
|
|
|
/**
|
* 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);
|
}
|
}
|