<?php
|
namespace JVBase\rest\routes;
|
|
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\rest\Route;
|
use JVBase\utility\Features;
|
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 Rest
|
{
|
protected FormBlock $form_block;
|
|
public function __construct()
|
{
|
$this->cacheName = 'forms';
|
$this->cacheTtl = HOUR_IN_SECONDS;
|
parent::__construct();
|
|
// Add query vars
|
add_filter('query_vars', [$this, 'addQueryVars']);
|
}
|
|
|
|
/**
|
* Register REST routes
|
*/
|
public function registerRoutes(): void
|
{
|
// ['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
|
Route::for(Route::pattern('forms/{form_type}'))
|
->get([$this, 'getForm'])
|
->arg('form_type', 'string|required')
|
->auth('logged_in')
|
->rateLimit(30)
|
->register();
|
}
|
|
/**
|
* 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 $this->error(
|
$result->get_error_message(),
|
$result->get_error_code(),
|
400
|
);
|
}
|
if (array_key_exists('success', $result)){
|
return $this->validationError($result);
|
}
|
|
return $this->success($result);
|
|
} catch (Exception $e) {
|
$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()) {
|
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
|
{
|
$validator = new Validator();
|
$sanitizer = new Sanitizer();
|
|
$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 (!$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] = $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 {
|
$submitter_name = $submitter_name?:'website visitor';
|
$preheader = sprintf(
|
'New %s submission from %s',
|
$form_config['title'],
|
$submitter_name
|
);
|
}
|
|
$subject .= ' ' . $submitter_name;
|
|
|
// 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 .= '<p style="font-size:13px;color:' . JVB()->email()->colours['dark-200'] . ';">
|
Submitted: ' . current_time('F j, Y \a\t g:i A') . '
|
</p>';
|
|
// 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 - 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 .= '<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
|
*/
|
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 $this->success($public_forms);
|
}
|
|
/**
|
* Get specific form configuration
|
*/
|
public function getForm(WP_REST_Request $request): WP_REST_Response
|
{
|
$form_type = sanitize_text_field($request->get_param('form_type'));
|
$form_config = FormBlock::getForm($form_type);
|
|
if (!$form_config) {
|
return $this->notFound('Form not found');
|
}
|
|
// Remove sensitive data
|
unset($form_config['email_to']);
|
|
return $this->success($form_config);
|
}
|
}
|