JezK
Edit File: FormController.php
<?php /** * Collection controller * * @package kirki */ namespace Kirki\API\Frontend\Controllers; if (!defined('ABSPATH')) { exit; // Exit if accessed directly. } use WP_REST_Server; use Kirki\Ajax\WpAdmin; use Kirki\FormValidator\FormValidator; use Kirki\HelperFunctions; use WP_Error; /** * FormController for managing front end form submission. * * @deprecated * @see Kirki\App\Http\Controllers\Api\FormController */ class FormController extends FrontendRESTController { /** * Register the form routes */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/form', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array($this, 'save_form_data'), 'permission_callback' => array($this, 'get_item_permissions_check'), 'args' => $this->get_endpoint_args_for_item_schema(WP_REST_Server::CREATABLE), ), 'schema' => array($this, 'get_item_schema'), ) ); } /** * ReCaptcha token verification * * @param string $secret_key secret key. * @param string $token token. * * @return bool */ private function verifyGoogleRecaptchaToken($secret_key, $token) { $url = 'https://www.google.com/recaptcha/api/siteverify'; $response = HelperFunctions::http_post( $url, array( 'method' => 'POST', 'httpversion' => '2.0', 'headers' => array( 'Content-type' => 'application/x-www-form-urlencoded', ), 'body' => array( 'secret' => $secret_key, 'response' => $token, ), ), ); if (is_wp_error($response)) { return false; } $response_body = wp_remote_retrieve_body($response); $response_body = json_decode($response_body, true); return (bool) $response_body['success'] ?? false; } /** * Creates one item from the collection. * * @param \WP_REST_Request $request all user request parameter. * * @return \WP_Error|WP_REST_Response */ public function save_form_data($request) { $params = $request->get_params(); $additional_keys = array('_kirki_form', '_wpnonce', '_wp_http_referer', 'g-recaptcha-token', 'g-recaptcha-response'); $form_data = $params; // Verify reCAPTCHA if present $this->verify_recaptcha($form_data); // Clean form data foreach ($additional_keys as $key) { unset($form_data[$key]); } // Validate required parameters $form_meta_data_base64 = $request->get_param('_kirki_form'); $wpnonce = $request->get_param('_wpnonce'); if (!isset($form_meta_data_base64) || !isset($wpnonce)) { wp_send_json_error('Form data or wpnonce not found!', 400); exit; } // Verify the WP REST nonce (action "wp_rest") used by the front‑end form. if (!wp_verify_nonce($wpnonce, 'wp_rest')) { wp_send_json_error('Nonce verification failed', 403); exit; } // Parse form metadata $metadata = $this->parse_form_metadata($form_meta_data_base64); $form_id = $metadata['form_id']; $post_id = $metadata['post_id']; $form_config = HelperFunctions::get_session_data($form_id); if (!$form_config) { wp_send_json_error('Form config not found', 400); exit; } // form validation $validation_result = FormValidator::validate($form_data, $form_config['fields']); if ($validation_result['has_error']) { wp_send_json_error($validation_result, 400); } if (!isset($form_id) || !isset($post_id) || !$form_config) { wp_send_json_error('Form data is invalid!', 400); exit; } // Extract form configuration $form_name = $form_config['name']; $max_entry = $form_config['maxEntry']; $response_limit = $form_config['responseLimit']; $mail_clients = isset($form_config['mailClients']) ? $form_config['mailClients'] : null; $actions = isset($form_config['actions']) && is_array($form_config['actions']) ? $form_config['actions'] : array(); // Categorize actions $categorized_actions = $this->categorize_actions($actions); $email_actions = $categorized_actions['email']; $webhook_actions = $categorized_actions['webhook']; $mailclient_actions = $categorized_actions['mailclient']; // Handle legacy email notification $email_list = array(); if (isset($form_config['emailNotification'])) { $email_notification = $form_config['emailNotification']; $email_list = $email_notification['enabled'] && isset($email_notification['emailList']) && is_array($email_notification['emailList']) ? $email_notification['emailList'] : null; } $entry_limit = !empty($max_entry['restricted']) ? $max_entry['value'] : null; $response_limit = !empty($response_limit['restricted']) ? $response_limit['value'] : null; $mail_clients = is_array($mail_clients) ? $mail_clients : null; $has_email_field = !empty($form_data['email']) || !empty($form_data['Email']); $res_data = false; if (isset($post_id, $form_id)) { $saved_form_id = ''; $saved_form = $this->get_form($post_id, $form_id); if (null === $saved_form) { $saved_form_id = $this->insert_form($post_id, $form_id, $form_name); } else { $saved_form_id = $saved_form['id']; if ($saved_form['name'] !== $form_name) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $wpdb->prefix . KIRKI_FORM_TABLE, array( 'name' => $form_name, ), array('id' => $saved_form_id), array('%s'), array('%d'), ); } } $this->check_entry_limit($saved_form_id, $entry_limit); $this->check_response_limit($saved_form_id, $response_limit); // Process file uploads $form_data = $this->process_file_uploads($form_data, $form_config['fields']); // Save data $should_save_data = !isset($form_config['saveData']) || !empty($form_config['saveData']); if (isset($saved_form_id) && $should_save_data) { $res = $this->insert_form_data($form_data, $saved_form_id, $form_config['fields']); } else { $res = true; } if (isset($res) && $res !== false) { $res_data = true; } } // Register shortcodes for email actions if (!empty($email_actions)) { foreach ($email_actions as $email_action) { $this->register_shortcodes_from_field($email_action['emailList'] ?? '', $form_data); $this->register_shortcodes_from_field($email_action['replyTo'] ?? '', $form_data); $this->register_shortcodes_from_field($email_action['name'] ?? '', $form_data); $this->register_shortcodes_from_field($email_action['subject'] ?? '', $form_data); } add_shortcode( 'admin_email', function () { return get_option('admin_email'); } ); } // Process email actions (independent of data saving success) if (!empty($email_list) || !empty($email_actions)) { $this->process_email_actions($email_list, $email_actions, $form_data, $form_name); } // Process mailclient actions if ($has_email_field || ($mail_clients && !empty($mail_clients))) { $this->process_mailclient_actions($mail_clients, $mailclient_actions, $form_data); } // Process webhook actions if (!$this->process_webhook_actions($webhook_actions, $form_data)) { $res_data = false; } do_action('kirki_form_submitted', $form_data, $form_config); return rest_ensure_response($res_data); } /** * Process file uploads from form submission * * @param array $form_data Form data array. * @param array $form_fields Form field configuration. * @return array Modified form data with attachment IDs. */ private function process_file_uploads($form_data, $form_fields) { if (empty($_FILES)) { return $form_data; } foreach ($_FILES as $name => $file_data) { if ($file_data['error'] !== UPLOAD_ERR_OK) { continue; } // Sanitize file info $_FILES[$name]['name'] = wp_unslash($file_data['name']); $_FILES[$name]['type'] = wp_unslash($file_data['type']); $_FILES[$name]['tmp_name'] = wp_unslash($file_data['tmp_name']); $_FILES[$name]['error'] = wp_unslash($file_data['error']); $_FILES[$name]['size'] = wp_unslash($file_data['size']); // Validate file $field_config = isset($form_fields[$name]) ? $form_fields[$name] : null; $file_type = $_FILES[$name]['type']; $file_size = $_FILES[$name]['size']; if ($field_config) { // Check MIME/type allowlist if (!empty($field_config['allowed_types']) && !in_array($file_type, $field_config['allowed_types'], true)) { $form_data[$name] = new WP_Error( 'invalid_file_type', /* translators: %s: file type */ sprintf(__('File type "%s" is not allowed for this field.', 'kirki'), $file_type) ); continue; } // Check max size if (!empty($field_config['max_size']) && $file_size > $field_config['max_size']) { $form_data[$name] = new WP_Error( 'file_too_large', /* translators: %s: maximum file size in bytes */ sprintf(__('File size exceeds the maximum allowed for this field (%s bytes).', 'kirki'), $field_config['max_size']) ); continue; } } // Upload file safely $attachment_id = $this->upload_file_to_media($name); $form_data[$name] = $attachment_id; // Store attachment ID or WP_Error } return $form_data; } /** * Upload file to WordPress media library * * @param string $name File input name. * @return int|WP_Error Attachment ID or error. */ private function upload_file_to_media($name) { require_once ABSPATH . 'wp-admin/includes/image.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; return media_handle_upload($name, 0); } /** * Convert form data into html for email * * @param array $form_data form all data. * * @return string html string. */ private function convert_form_data_into_html_for_email($form_data = array()) { $html = '<ul>'; if (is_array($form_data)) { foreach ($form_data as $key => $value) { $html .= '<li>' . esc_html($key) . ': ' . esc_html($value) . '</li>'; } } $html .= '</ul>'; return $html; } /** * Send email notification to admin email * * @param string|string[] $to Array or comma-separated list of email addresses to send message. * @param string $subject Email subject. * @param string $message Message contents. * * @return void */ private function send_email_notification($to, $subject, $message, $headers = array()) { apply_filters('kirki_element_smtp', ''); wp_mail($to, $subject, $message, $headers); } /** * Check form submit/entry limit * * @param int $form_id form id. * @param int $limit Form limit. * @return void wp_send_json. */ private function check_entry_limit($form_id, $limit = null) { if (!empty($limit) || is_numeric($limit)) { global $wpdb; $session_id = session_id(); $table_name = $wpdb->prefix . KIRKI_FORM_DATA_TABLE; //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $query = $wpdb->prepare("SELECT COUNT(DISTINCT timestamp) as total_entries FROM $table_name WHERE session_id=%s AND form_id=%s", array($session_id, $form_id)); //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $res = $wpdb->get_results($query, ARRAY_A); $total_entries = (int) $res[0]['total_entries']; if ($total_entries >= $limit) { wp_send_json(false); die(); } } } /** * Check response limit * * @param int $form_id form id. * @param int $limit Form limit. * @return void wp_send_json. */ private function check_response_limit($form_id, $limit = null) { if (!empty($limit) || is_numeric($limit)) { global $wpdb; $table_name = $wpdb->prefix . KIRKI_FORM_DATA_TABLE; //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $query = $wpdb->prepare("SELECT COUNT(DISTINCT timestamp) as total_entries FROM $table_name WHERE form_id=%s", array($form_id)); //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $res = $wpdb->get_results($query, ARRAY_A); $total_entries = (int) $res[0]['total_entries']; if ($total_entries >= $limit) { wp_send_json(false); die(); } } } /** * Retrieve a specific form * * @param string $post_id wp post id. * @param string $form_id user form id. * @return mixed|null current field id. */ private function get_form($post_id, $form_id) { if (isset($post_id, $form_id)) { global $wpdb; $table_name = $wpdb->prefix . KIRKI_FORM_TABLE; //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $field = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table_name WHERE post_id=%d AND form_ele_id=%s", $post_id, $form_id), ARRAY_A); return $field; } return null; } /** * Insert a form * * @param string $post_id In which post/page the form resides. * @param string $form_id ID of the form element. * @param string $form_name Name of the form. * @return string Inserted form ID. */ private function insert_form($post_id, $form_id, $form_name) { if (isset($post_id, $form_id, $form_name)) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $wpdb->prefix . KIRKI_FORM_TABLE, array( 'post_id' => (int) $post_id, 'form_ele_id' => $form_id, 'name' => $form_name, ), array( '%d', '%s', '%s', ) ); return $wpdb->insert_id; } return ''; } /** * Insert form data * * @param iterable|object $form_data form data. * @param string $form_id form id. * @return int|Boolean */ private function insert_form_data($form_data, $form_id, $form_data_types = array()) { if (isset($form_data, $form_id) && !empty($form_data)) { global $wpdb; $table_name = $wpdb->prefix . KIRKI_FORM_DATA_TABLE; $timestamp = time(); $session_id = session_id(); $values = array(); $place_holders = array(); $query = "INSERT INTO $table_name (form_id, user_id, session_id, timestamp, input_key, input_value, input_type) VALUES "; $plholder_str = ''; foreach ($form_data as $name => $value) { $type = isset($form_data_types[$name]['type']) ? $form_data_types[$name]['type'] : 'text'; // Handle array values by serializing them if (is_array($value)) { $value = serialize($value); } array_push( $values, $form_id, get_current_user_id(), $session_id, $timestamp, "$name", $value, "$type" ); $place_holders[] = '(%d, NULLIF(%d, 0), %s, %d, %s, %s, %s)'; } $plholder_str = implode(', ', $place_holders); $query .= $plholder_str; //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $sql = $wpdb->prepare("$query ", $values); //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $res = $wpdb->query($sql); return $res; } return false; } /** * Verify reCAPTCHA token * * @param array $form_data Form data containing reCAPTCHA token. * @return void Exits on failure. */ private function verify_recaptcha($form_data) { if (!isset($form_data['g-recaptcha-token'])) { return; } $common_data = WpAdmin::get_common_data(true); if (!isset($common_data['recaptcha']['GRC_version'])) { wp_send_json_error('reCAPTCHA configuration not found', 400); exit; } $version = $common_data['recaptcha']['GRC_version']; $recaptcha = $common_data['recaptcha'][$version] ?? array(); if (empty($recaptcha['GRC_secret_key'])) { wp_send_json_error('reCAPTCHA secret key not configured', 400); exit; } $is_valid = $this->verifyGoogleRecaptchaToken($recaptcha['GRC_secret_key'], $form_data['g-recaptcha-token']); if (!$is_valid) { wp_send_json_error('Google reCAPTCHA verification failed', 400); exit; } } /** * Parse and verify form metadata * * @param string $form_meta_data_base64 Base64 encoded form metadata. * @return array Array with form_id and post_id. */ private function parse_form_metadata($form_meta_data_base64) { //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode $form_meta_data = explode('|', base64_decode(base64_decode($form_meta_data_base64))); if (count($form_meta_data) < 3) { return array( 'form_id' => null, 'post_id' => null, ); } $form_id = $form_meta_data[0]; $post_id = $form_meta_data[1]; $signature = $form_meta_data[2]; $expected = wp_hash($form_id . '|' . $post_id); if (!hash_equals($expected, (string) $signature)) { return array( 'form_id' => null, 'post_id' => null, ); } return array( 'form_id' => $form_id ?: null, 'post_id' => $post_id ?: null, ); } /** * Categorize actions by type * * @param array $actions Form actions configuration. * @return array Categorized actions. */ private function categorize_actions($actions) { $email_actions = array(); $webhook_actions = array(); $mailclient_actions = array(); if (is_array($actions) && !empty($actions)) { foreach ($actions as $action) { if (!isset($action['type'])) { continue; } switch ($action['type']) { case 'email': $email_actions[] = $action; break; case 'webhooks': $webhook_actions[] = $action; break; case 'mailclients': $mailclient_actions[] = $action; break; } } } return array( 'email' => $email_actions, 'webhook' => $webhook_actions, 'mailclient' => $mailclient_actions, ); } /** * Register shortcodes from email action field * * @param string $field_value The field value containing shortcodes. * @param array $form_data The form data to use for shortcode values. * @return void */ private function register_shortcodes_from_field($field_value, $form_data) { if (!isset($field_value) || empty($field_value)) { return; } if (preg_match_all('/\[([^\]]+)\]/', $field_value, $matches)) { foreach ($matches[1] as $match) { if (isset($form_data[$match])) { add_shortcode( $match, function () use ($form_data, $match) { return $form_data[$match]; } ); } } } } /** * Process and send emails * * @param array $email_list Legacy email list. * @param array $email_actions Email actions configuration. * @param array $form_data Form data. * @param string $form_name Form name. * @return void */ private function process_email_actions($email_list, $email_actions, $form_data, $form_name) { // Set email content type to HTML add_filter( 'wp_mail_content_type', function () { return 'text/html'; } ); if (!empty($email_list)) { $body = $this->convert_form_data_into_html_for_email($form_data); $this->send_email_notification($email_list, 'New ' . $form_name, $body); } elseif (!empty($email_actions)) { foreach ($email_actions as $email_action) { $this->send_single_email_action($email_action, $form_data, $form_name); } } } /** * Send a single email action * * @param array $email_action Email action configuration. * @param array $form_data Form data. * @param string $form_name Form name. * @return void */ private function send_single_email_action($email_action, $form_data, $form_name) { $body = $this->convert_form_data_into_html_for_email($form_data); $replyTo = ''; $name = ''; $subject = 'New ' . $form_name; $header = array(); if (isset($email_action['body']) && is_array($email_action['body'])) { $body = $this->build_email_body($email_action['body'], $form_data); } if (isset($email_action['replyTo'])) { $replyTo = sanitize_email($this->render_email_action_text($email_action['replyTo'], $form_data)); } if (isset($email_action['name'])) { $name = $this->render_email_action_text($email_action['name'], $form_data); } if (isset($email_action['subject'])) { $subject = $this->render_email_action_text($email_action['subject'], $form_data); } if (strlen($replyTo) > 0 && strlen($name) > 0) { $header = array('Reply-To: ' . $name . ' <' . $replyTo . '>'); } if (isset($email_action['emailList']) && !empty($email_action['emailList'])) { $to = $this->sanitize_email_list($this->render_email_action_text($email_action['emailList'], $form_data)); if ($to) { $this->send_email_notification($to, $subject, $body, $header); } } } /** * Render a templated email action field using an explicit whitelist array. * * @param string $value The configured field value. * @param array $form_data The submitted form data. * @return string */ private function render_email_action_text($value, $form_data) { if (empty($value) || !is_string($value)) { return (string) $value; } // Whitelist array of acceptable email action shortcodes and their resolved values. $whitelist = array( 'admin_email' => (string) get_option('admin_email'), ); if (is_array($form_data)) { foreach ($form_data as $field_name => $field_value) { if (is_array($field_value)) { $whitelist[$field_name] = implode(', ', $field_value); } else { $whitelist[$field_name] = (string) $field_value; } } } // Render only the whitelisted shortcodes from the array foreach ($whitelist as $tag => $replacement) { $value = str_replace('[' . $tag . ']', $replacement, $value); } return $value; } /** * Sanitize a comma-separated list of email addresses. * * @param string $value The configured email list value. * @return string A comma-separated string of valid addresses, or '' when empty. */ private function sanitize_email_list($value) { if (empty($value) || !is_string($value)) { return ''; } $valid = array(); foreach (explode(',', $value) as $address) { $address = sanitize_email(trim($address)); if ($address) { $valid[] = $address; } } return implode(', ', $valid); } /** * Build email body from body configuration * * @param array $body_config Body configuration. * @param array $form_data Form data. * @return string Email body HTML. */ private function build_email_body($body_config, $form_data) { $body_parts = array(); foreach ($body_config as $body_data) { if (!isset($body_data['type'], $body_data['value'])) { continue; } if ($body_data['type'] === 'text') { $body_parts[] = $body_data['value']; } elseif ($body_data['type'] === 'form' && isset($form_data[$body_data['value']])) { $body_parts[] = $form_data[$body_data['value']]; } } return nl2br(implode('', $body_parts)); } /** * Process webhook actions * * @param array $webhook_actions Webhook actions configuration. * @param array $form_data Form data. * @return bool Success status. */ private function process_webhook_actions($webhook_actions, $form_data) { if (empty($webhook_actions)) { return true; } $success = true; foreach ($webhook_actions as $webhook) { if (!isset($webhook['action'], $webhook['method'])) { continue; } if ($webhook['method'] === 'get') { $success = $this->send_webhook_get($webhook['action'], $form_data) && $success; } elseif ($webhook['method'] === 'post') { $success = $this->send_webhook_post($webhook['action'], $form_data) && $success; } } return $success; } /** * Send GET webhook request * * @param string $url Webhook URL. * @param array $form_data Form data. * @return bool Success status. */ private function send_webhook_get($url, $form_data) { if (!HelperFunctions::is_safe_url($url)) { return false; } $query_string = http_build_query($form_data); if (substr($url, -1) !== '/') { $url = $url . '/'; } $url_with_query = $url . '?' . $query_string; $response = HelperFunctions::http_get($url_with_query); return !is_wp_error($response); } /** * Send POST webhook request * * @param string $url Webhook URL. * @param array $form_data Form data. * @return bool Success status. */ private function send_webhook_post($url, $form_data) { if (!HelperFunctions::is_safe_url($url)) { return false; } $options = array( 'method' => 'POST', 'httpversion' => '2.0', 'headers' => array( 'Content-type' => 'application/x-www-form-urlencoded', ), 'body' => $form_data, ); $response = HelperFunctions::http_post($url, $options); return !is_wp_error($response); } /** * Process mailclient actions * * @param array $mail_clients Mail clients configuration. * @param array $mailclient_actions Mailclient actions. * @param array $form_data Form data. * @return void */ private function process_mailclient_actions($mail_clients, $mailclient_actions, $form_data) { $email = isset($form_data['email']) ? $form_data['email'] : (isset($form_data['Email']) ? $form_data['Email'] : null); if (!$email) { return; } $merge_fields = $this->extract_merge_fields($form_data); // Merge legacy mail_clients into mailclient_actions if (is_array($mail_clients) && !empty($mail_clients)) { foreach ($mail_clients as $mail_client) { if (isset($mail_client['enabled']) && $mail_client['enabled']) { $mailclient_actions[] = $mail_client; } } } // Process mailclient actions foreach ($mailclient_actions as $mail_client) { do_action('kirki_mailclient_action', $mail_client, $email, $merge_fields); } } /** * Extract merge fields from form data * * @param array $form_data Form data. * @return array Merge fields. */ private function extract_merge_fields($form_data) { $merge_fields = array(); if (!is_array($form_data)) { return $merge_fields; } foreach ($form_data as $key => $value) { if (self::matchFormField($key, 'fullname') || self::matchFormField($key, 'name')) { $merge_fields['Fullname'] = $value; } if (self::matchFormField($key, 'firstname') || self::matchFormField($key, 'fname')) { $merge_fields['FNAME'] = $value; } if (self::matchFormField($key, 'lastname') || self::matchFormField($key, 'lname')) { $merge_fields['LNAME'] = $value; } if (self::matchFormField($key, 'birthday') || self::matchFormField($key, 'bday')) { $merge_fields['BIRTHDAY'] = $value; } } return $merge_fields; } /** * Search or match from filed * * @param string $field_name search column name. * @param string $match_text search column value. * @return boolean */ private static function matchFormField($field_name = '', $match_text = '') { if (!empty($field_name) && !empty($match_text) && strtolower(preg_replace('/\s|_|-/', '', $field_name)) === $match_text) { return true; } return false; } }