verify_nonce() ) { return new WP_Error( 'security_check', __( 'Security check failed.', 'airy-frontend-forms' ) ); } // Get form. if ( ! $this->get_form() ) { return new WP_Error( 'invalid_form', __( 'Invalid form.', 'airy-frontend-forms' ) ); } // Check if editing. $this->check_edit_mode(); // Verify permissions. if ( ! $this->verify_permissions() ) { // Return specific error if available, otherwise generic. $error_msg = ! empty( $this->errors ) ? implode( '
', $this->errors ) : __( 'You do not have permission to perform this action.', 'airy-frontend-forms' ); return new WP_Error( 'permission_denied', $error_msg ); } // Check rate limits. $rate_check = AFFCF_Rate_Limiter::check_limit( $this->get_user_id(), $this->form->id ); if ( is_wp_error( $rate_check ) ) { return $rate_check; } // Record attempt for rate limiting. AFFCF_Rate_Limiter::record_attempt( $this->get_user_id(), $this->form->id ); // Check submission limits (only for new submissions, not edits). if ( ! $this->is_edit ) { $limits_check = AFFCF_Submission_Limits::check_limits( $this->get_user_id(), $this->form->id ); if ( is_wp_error( $limits_check ) ) { return $limits_check; } } // Verify reCAPTCHA. if ( AFFCF_Captcha::is_enabled() ) { $recaptcha_token = isset( $_POST['g-recaptcha-response'] ) ? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) ) : ''; $captcha_result = AFFCF_Captcha::verify( $recaptcha_token ); if ( is_wp_error( $captcha_result ) ) { return $captcha_result; } // Store score for tracking. if ( is_array( $captcha_result ) && isset( $captcha_result['score'] ) ) { $this->data['captcha_score'] = $captcha_result['score']; } } // Sanitize data. $this->sanitize_data(); // Validate submission. if ( ! $this->validate_submission() ) { return new WP_Error( 'validation_failed', implode( '
', $this->errors ) ); } // Process file uploads. $upload_result = $this->process_uploads(); if ( is_wp_error( $upload_result ) ) { return $upload_result; } // Create or update post. $result = $this->is_edit ? $this->update_post() : $this->create_post(); if ( is_wp_error( $result ) ) { return $result; } // Save custom fields (resilient - won't crash if errors). $this->save_custom_fields( $result ); // Save taxonomies (resilient - won't crash if errors). $this->save_taxonomies( $result ); // Track submission (resilient - won't crash if table missing). $this->track_submission( $result ); // Send notifications (resilient - won't crash if email issues). $this->send_notifications( $result ); // Delete draft if this was from a draft. $this->delete_draft_if_exists(); return array( 'success' => true, 'post_id' => $result, 'message' => $this->is_edit ? __( 'Post updated successfully!', 'airy-frontend-forms' ) : __( 'Your post has been submitted successfully and is awaiting review!', 'airy-frontend-forms' ), ); } catch ( Throwable $e ) { return new WP_Error( 'submission_error', __( 'An error occurred while processing your submission. Please try again.', 'airy-frontend-forms' ) ); } // phpcs:enable } /** * Verify nonce * * @return bool True if nonce is valid. */ private function verify_nonce() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- This IS the nonce verification method if ( ! isset( $_POST['affcf_nonce'] ) ) { return false; } $form_id = isset( $_POST['affcf_form_id'] ) ? absint( wp_unslash( $_POST['affcf_form_id'] ) ) : 0; return wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['affcf_nonce'] ) ), 'affcf_submit_form_' . $form_id ); // phpcs:enable } /** * Get form * * @return bool True if form found. */ private function get_form() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Called after nonce verification in handle_submission() $form_id = isset( $_POST['affcf_form_id'] ) ? absint( wp_unslash( $_POST['affcf_form_id'] ) ) : 0; if ( ! $form_id ) { return false; } $form_repo = new AFFCF_Form_Repository(); $this->form = $form_repo->get_by_id( $form_id ); return null !== $this->form; } /** * Check edit mode * * @return void */ private function check_edit_mode() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Called after nonce verification in handle_submission() $this->post_id = isset( $_POST['affcf_post_id'] ) ? absint( wp_unslash( $_POST['affcf_post_id'] ) ) : 0; $this->is_edit = ! empty( $this->post_id ); } /** * Add error message * * @param string $message Error message. * @return void */ private function add_error( $message ) { $this->errors[] = $message; } /** * Verify permissions * * @return bool True if user has permission. */ private function verify_permissions() { // Get settings to check if guest submissions are allowed. $settings = get_option( 'affcf_settings', array() ); $allow_guest = ! empty( $settings['allow_guest_submissions'] ); // Check if user is logged in OR guest submissions are allowed. if ( ! is_user_logged_in() && ! $allow_guest ) { $this->add_error( 'You must be logged in to submit posts.' ); return false; } // Get post type object to check capabilities. $post_type_obj = get_post_type_object( $this->form->post_type ); if ( ! $post_type_obj ) { $this->add_error( 'Invalid post type: ' . $this->form->post_type ); return false; } // If editing, verify ownership. if ( $this->is_edit ) { $post = get_post( $this->post_id ); if ( ! $post ) { $this->add_error( 'Post not found.' ); return false; } // Check if user owns the post OR has capability to edit others' posts. $can_edit = ( get_current_user_id() === (int) $post->post_author ) || current_user_can( $post_type_obj->cap->edit_others_posts ); if ( ! $can_edit ) { $this->add_error( 'You do not have permission to edit this post.' ); return false; } // Verify post was created via this plugin. $form_id = get_post_meta( $this->post_id, '_affcf_form_id', true ); if ( (int) $form_id !== (int) $this->form->id ) { $this->add_error( 'This post was not created with this form.' ); return false; } } elseif ( ! $allow_guest && is_user_logged_in() ) { // For new posts, check if user can create posts of this type. // Skip this check if guest submissions are allowed. $current_user = wp_get_current_user(); $user_roles = $current_user->roles; // Check 1: Is user administrator? (always allowed). $is_admin = in_array( 'administrator', $user_roles, true ); // Check 2: Is user's role in allowed submission roles? $allowed_roles = isset( $settings['allowed_submission_roles'] ) && is_array( $settings['allowed_submission_roles'] ) ? $settings['allowed_submission_roles'] : array(); $has_allowed_role = false; foreach ( $user_roles as $role ) { if ( in_array( $role, $allowed_roles, true ) ) { $has_allowed_role = true; break; } } // Check 3: Does user have the required capability? (existing behavior). $required_cap = $post_type_obj->cap->edit_posts; $has_capability = current_user_can( $required_cap ); // If none of the above checks pass, deny permission. if ( ! $is_admin && ! $has_allowed_role && ! $has_capability ) { $user_roles_string = implode( ', ', $user_roles ); $error_msg = sprintf( 'Permission denied: Your role (%s) does not have permission to create %s posts. Required capability: %s. Please ask your administrator to enable your role in Airy Forms > Settings > General > Allowed User Roles.', $user_roles_string, $post_type_obj->label, $required_cap ); $this->add_error( $error_msg ); return false; } } return true; } /** * Sanitize data * * @return void */ private function sanitize_data() { // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Called after nonce verification, data sanitized line by line below $this->data = array(); // Sanitize default WordPress fields. if ( isset( $_POST['post_title'] ) ) { $this->data['post_title'] = sanitize_text_field( wp_unslash( $_POST['post_title'] ) ); } if ( isset( $_POST['post_content'] ) ) { $this->data['post_content'] = wp_kses_post( wp_unslash( $_POST['post_content'] ) ); } if ( isset( $_POST['post_excerpt'] ) ) { $this->data['post_excerpt'] = sanitize_textarea_field( wp_unslash( $_POST['post_excerpt'] ) ); } // Sanitize featured_image (attachment ID from AJAX upload). if ( isset( $_POST['featured_image'] ) && ! empty( $_POST['featured_image'] ) ) { $this->data['featured_image'] = absint( wp_unslash( $_POST['featured_image'] ) ); } // Sanitize custom fields (will be done by field type). if ( ! empty( $this->form->field_groups ) ) { $field_manager = AFFCF_Field_Manager::get_instance(); foreach ( $this->form->field_groups as $group ) { if ( empty( $group['enabled_fields'] ) ) { continue; } foreach ( $group['enabled_fields'] as $field_key ) { // Get field type to determine how to handle. $field = $field_manager->get_field( $field_key ); // Skip layout-only fields - they have no data to save. if ( $field && in_array( $field['type'], array( 'accordion', 'tab', 'message' ), true ) ) { continue; } // Handle file-based fields specially. if ( $field && in_array( $field['type'], array( 'file', 'image', 'gallery' ), true ) ) { // For gallery fields, we need to capture existing attachment IDs from POST. // These are the hidden inputs for existing images (from edit mode or draft restore). // New uploads will be added in process_uploads(). if ( 'gallery' === $field['type'] && isset( $_POST[ $field_key ] ) ) { // Sanitize as array of integers (attachment IDs). // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via absint() below $raw_value = wp_unslash( $_POST[ $field_key ] ); $existing_ids = is_array( $raw_value ) ? array_map( 'absint', $raw_value ) : array( absint( $raw_value ) ); // Store existing IDs - new uploads will be merged later. $this->data[ $field_key ] = array_filter( $existing_ids ); // Remove zeros. } // For image/file fields using Media Library, capture the attachment ID // from the hidden input. process_uploads() will overwrite if a new // file is actually uploaded via $_FILES. if ( in_array( $field['type'], array( 'image', 'file' ), true ) && isset( $_POST[ $field_key ] ) ) { $attachment_id = absint( wp_unslash( $_POST[ $field_key ] ) ); if ( $attachment_id > 0 ) { $this->data[ $field_key ] = $attachment_id; } } // Skip further processing - files are handled in process_uploads(). continue; } // Only process if data exists in POST. if ( isset( $_POST[ $field_key ] ) ) { // Use wp_unslash before sanitization to handle magic quotes properly. $raw_value = wp_unslash( $_POST[ $field_key ] ); $this->data[ $field_key ] = $this->sanitize_field_value( $raw_value, $field_key ); } } } } // Sanitize taxonomies. if ( ! empty( $this->form->taxonomies ) ) { foreach ( $this->form->taxonomies as $taxonomy ) { $tax_name = $taxonomy['taxonomy']; if ( isset( $_POST[ $tax_name ] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via absint() below $raw_tax_value = wp_unslash( $_POST[ $tax_name ] ); $this->data[ $tax_name ] = is_array( $raw_tax_value ) ? array_map( 'absint', $raw_tax_value ) : absint( $raw_tax_value ); } } } // phpcs:enable } /** * Sanitize field value by type * * @param mixed $value The value to sanitize. * @param string $field_key The field key. * @return mixed Sanitized value. */ private function sanitize_field_value( $value, $field_key ) { // Get field manager. $field_manager = AFFCF_Field_Manager::get_instance(); $field = $field_manager->get_field( $field_key ); if ( ! $field ) { // Preserve array values (e.g. group/repeater data) when field lookup fails. if ( is_array( $value ) ) { return $this->sanitize_nested_data( $value ); } return sanitize_text_field( $value ); } $field_type = $field['type']; switch ( $field_type ) { case 'text': case 'select': case 'radio': return sanitize_text_field( $value ); case 'textarea': return sanitize_textarea_field( $value ); case 'wysiwyg': // Remove any label HTML that might have been accidentally included. $value = preg_replace( '/]*>.*?<\/label>/is', '', $value ); // Use wp_kses_post() to sanitize but keep HTML structure. $sanitized = wp_kses_post( $value ); return $sanitized; case 'email': return sanitize_email( $value ); case 'url': return esc_url_raw( $value ); case 'number': return absint( $value ); case 'checkbox': return is_array( $value ) ? array_map( 'sanitize_text_field', $value ) : sanitize_text_field( $value ); case 'relationship': case 'post_object': case 'page_link': // These fields return post IDs (single or array). return is_array( $value ) ? array_map( 'absint', $value ) : absint( $value ); case 'repeater': case 'flexible_content': if ( ! is_array( $value ) ) { return array(); } $sanitized_rows = $this->sanitize_nested_data( $value ); // Remove template rows (non-numeric keys like {INDEX}) and empty rows. $sanitized_rows = array_filter( $sanitized_rows, function ( $row, $key ) { // Template rows have non-numeric keys like {INDEX}. if ( ! is_numeric( $key ) ) { return false; } // Filter out completely empty rows. if ( is_array( $row ) ) { $non_empty = array_filter( $row, function ( $v ) { if ( is_array( $v ) ) { return ! empty( $v ); } return '' !== $v && null !== $v; } ); return ! empty( $non_empty ); } return '' !== $row && null !== $row; }, ARRAY_FILTER_USE_BOTH ); return array_values( $sanitized_rows ); // Re-index to 0, 1, 2... case 'group': return is_array( $value ) ? $this->sanitize_nested_data( $value ) : array(); case 'gallery': // Gallery values are already attachment IDs from process_uploads. // They're set directly in $this->data, not from $_POST. return is_array( $value ) ? $value : array(); case 'taxonomy': // Taxonomy field returns term IDs. return is_array( $value ) ? array_map( 'absint', $value ) : absint( $value ); case 'user': // User field returns user IDs. return is_array( $value ) ? array_map( 'absint', $value ) : absint( $value ); case 'link': // Link field returns array with url, title, target. if ( ! is_array( $value ) ) { return array(); } return array( 'url' => isset( $value['url'] ) ? esc_url_raw( $value['url'] ) : '', 'title' => isset( $value['title'] ) ? sanitize_text_field( $value['title'] ) : '', 'target' => isset( $value['target'] ) ? sanitize_text_field( $value['target'] ) : '', ); case 'oembed': // oEmbed field stores URL. return esc_url_raw( $value ); case 'nav_menu': // Nav menu field returns menu ID (term ID). return absint( $value ); case 'icon_picker': // Icon picker internally stores as array('type' => ..., 'value' => ...). // Frontend form submits a plain string, so convert to expected format. if ( is_array( $value ) && isset( $value['type'], $value['value'] ) ) { // Already in correct format. $value['type'] = sanitize_text_field( $value['type'] ); $value['value'] = sanitize_text_field( $value['value'] ); return $value; } $icon_value = sanitize_text_field( $value ); if ( empty( $icon_value ) ) { return array( 'type' => '', 'value' => '', ); } // Detect icon type from value. $icon_type = 'dashicons'; if ( filter_var( $icon_value, FILTER_VALIDATE_URL ) ) { $icon_type = 'url'; } return array( 'type' => $icon_type, 'value' => $icon_value, ); case 'message': case 'tab': case 'accordion': // These are display-only fields, no data to save. return null; case 'google_map': // Google map field returns array with address, lat, lng. if ( ! is_array( $value ) ) { return array(); } return array( 'address' => isset( $value['address'] ) ? sanitize_text_field( $value['address'] ) : '', 'lat' => isset( $value['lat'] ) ? sanitize_text_field( $value['lat'] ) : '', 'lng' => isset( $value['lng'] ) ? sanitize_text_field( $value['lng'] ) : '', ); case 'clone': // Clone field returns nested data like group. return is_array( $value ) ? $this->sanitize_nested_data( $value ) : array(); case 'range': // Range field returns numeric value. return floatval( $value ); case 'password': // Password field - sanitize but don't escape (might contain special chars). return sanitize_text_field( $value ); case 'button_group': // Button group returns selected value. return sanitize_text_field( $value ); default: return sanitize_text_field( $value ); } } /** * Sanitize nested array (for repeater/group) * Preserves attachment IDs as integers * * @param array $data_array The array data to sanitize. * @return array Sanitized array. */ private function sanitize_nested_data( $data_array ) { $sanitized = array(); foreach ( $data_array as $key => $value ) { if ( is_array( $value ) ) { // Check if this is a gallery field (array of integers). // Gallery fields contain only numeric values (attachment IDs). $is_gallery = $this->is_gallery_array( $value ); if ( $is_gallery ) { // Preserve attachment IDs as integers. $sanitized[ $key ] = array_map( 'absint', $value ); } else { // Recursively sanitize nested arrays. $sanitized[ $key ] = $this->sanitize_nested_data( $value ); } } elseif ( is_numeric( $value ) ) { // Preserve numbers as integers (important for attachment IDs). $sanitized[ $key ] = absint( $value ); } else { // Sanitize text values. $sanitized[ $key ] = sanitize_text_field( $value ); } } return $sanitized; } /** * Check if array is a gallery field (array of attachment IDs) * * @param array $data_array Array to check. * @return bool True if gallery field. */ private function is_gallery_array( $data_array ) { // Empty arrays are not galleries. if ( empty( $data_array ) ) { return false; } // Gallery fields are numeric arrays (0, 1, 2...) with all integer values. // Regular arrays have string keys (field names). // Check if all keys are numeric sequential (0, 1, 2...). $keys = array_keys( $data_array ); $expected_keys = range( 0, count( $data_array ) - 1 ); $is_sequential = $keys === $expected_keys; if ( ! $is_sequential ) { return false; } // Check if all values are numeric (attachment IDs). foreach ( $data_array as $value ) { if ( ! is_numeric( $value ) ) { return false; } } return true; } /** * Validate submission * * @return bool True if validation passes. */ private function validate_submission() { $this->errors = array(); // Validate required default fields. $this->validate_default_fields(); // Validate custom fields. $this->validate_custom_fields(); // Validate taxonomies. $this->validate_taxonomies(); return empty( $this->errors ); } /** * Validate default WordPress fields * * @return void */ private function validate_default_fields() { if ( empty( $this->form->default_fields ) ) { return; } foreach ( $this->form->default_fields as $field_name => $config ) { if ( empty( $config['enabled'] ) ) { continue; } $required = ! empty( $config['required'] ); if ( $required && empty( $this->data[ $field_name ] ) ) { $label = ! empty( $config['label'] ) ? $config['label'] : ucfirst( str_replace( '_', ' ', $field_name ) ); /* translators: %s: field label */ $this->errors[] = sprintf( __( '%s is required.', 'airy-frontend-forms' ), $label ); } } // Check for unique title if setting is enabled. if ( ! empty( $this->data['post_title'] ) ) { $this->validate_unique_title( $this->data['post_title'] ); } } /** * Validate unique title * * @param string $title Post title to check. * @return void */ private function validate_unique_title( $title ) { // Get settings. $settings = get_option( 'affcf_settings', array() ); // Skip if unique titles not enforced. if ( empty( $settings['enforce_unique_titles'] ) ) { return; } global $wpdb; // Build query to check for existing post with same title. if ( $this->is_edit && $this->post_id ) { // If editing, exclude current post from check. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for duplicate title check $existing_post = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_title = %s AND post_type = %s AND post_status NOT IN ('trash', 'auto-draft') AND ID != %d", $title, $this->form->post_type, $this->post_id ) ); } else { // If creating new post. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for duplicate title check $existing_post = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_title = %s AND post_type = %s AND post_status NOT IN ('trash', 'auto-draft')", $title, $this->form->post_type ) ); } if ( $existing_post ) { // Get custom error message or use default. $error_messages = get_option( 'affcf_error_messages', array() ); $error_msg = ! empty( $error_messages['duplicate_title'] ) ? $error_messages['duplicate_title'] : __( 'This title already exists. Please use a unique title.', 'airy-frontend-forms' ); $this->errors[] = $error_msg; } } /** * Validate custom fields * * @return void */ private function validate_custom_fields() { if ( empty( $this->form->field_groups ) ) { return; } $field_manager = AFFCF_Field_Manager::get_instance(); foreach ( $this->form->field_groups as $group ) { if ( empty( $group['enabled_fields'] ) ) { continue; } foreach ( $group['enabled_fields'] as $field_key ) { $field = $field_manager->get_field( $field_key ); if ( ! $field ) { continue; } // Check if field has custom settings. $field_settings = $group['field_settings'][ $field_key ] ?? array(); $required = ! empty( $field_settings['required'] ) || ! empty( $field['required'] ); if ( $required && empty( $this->data[ $field_key ] ) ) { $label = ! empty( $field_settings['custom_label'] ) ? $field_settings['custom_label'] : $field['label']; /* translators: %s: field label */ $this->errors[] = sprintf( __( '%s is required.', 'airy-frontend-forms' ), $label ); } // Type-specific validation. $this->validate_field_type( $field_key, $field, $field_settings ); } } } /** * Validate field by type * * @param string $field_key The field key. * @param array $field The field configuration. * @param array $settings The field settings. * @return void */ private function validate_field_type( $field_key, $field, $settings ) { if ( empty( $this->data[ $field_key ] ) ) { return; } $value = $this->data[ $field_key ]; switch ( $field['type'] ) { case 'email': if ( ! is_email( $value ) ) { $label = ! empty( $settings['custom_label'] ) ? $settings['custom_label'] : $field['label']; /* translators: %s: field label */ $this->errors[] = sprintf( __( '%s must be a valid email address.', 'airy-frontend-forms' ), $label ); } break; case 'url': if ( ! filter_var( $value, FILTER_VALIDATE_URL ) ) { $label = ! empty( $settings['custom_label'] ) ? $settings['custom_label'] : $field['label']; /* translators: %s: field label */ $this->errors[] = sprintf( __( '%s must be a valid URL.', 'airy-frontend-forms' ), $label ); } break; } } /** * Validate taxonomies * * @return void */ private function validate_taxonomies() { if ( empty( $this->form->taxonomies ) ) { return; } foreach ( $this->form->taxonomies as $taxonomy ) { $tax_name = $taxonomy['taxonomy']; $required = ! empty( $taxonomy['required'] ); if ( $required && empty( $this->data[ $tax_name ] ) ) { $label = ! empty( $taxonomy['label'] ) ? $taxonomy['label'] : $tax_name; /* translators: %s: taxonomy label */ $this->errors[] = sprintf( __( '%s is required.', 'airy-frontend-forms' ), $label ); } } } /** * Process file uploads * * @return true|WP_Error True on success or WP_Error on failure. */ private function process_uploads() { // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Called after nonce verification, files sanitized via AFFCF_File_Handler if ( empty( $_FILES ) ) { return true; } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; // Process featured image. if ( ! empty( $_FILES['featured_image']['name'] ) ) { $attachment_id = $this->upload_file( 'featured_image' ); if ( is_wp_error( $attachment_id ) ) { return $attachment_id; } $this->data['featured_image'] = $attachment_id; } // Process custom field files. if ( ! empty( $this->form->field_groups ) ) { $field_manager = AFFCF_Field_Manager::get_instance(); foreach ( $this->form->field_groups as $group ) { if ( empty( $group['enabled_fields'] ) ) { continue; } foreach ( $group['enabled_fields'] as $field_key ) { $field = $field_manager->get_field( $field_key ); if ( ! $field ) { continue; } // Handle group fields - process nested file uploads. if ( 'group' === $field['type'] && ! empty( $field['sub_fields'] ) ) { $result = $this->process_group_file_uploads( $field ); if ( is_wp_error( $result ) ) { return $result; } } elseif ( 'repeater' === $field['type'] && ! empty( $field['sub_fields'] ) ) { // Handle repeater fields - process nested file uploads. $result = $this->process_repeater_file_uploads( $field ); if ( is_wp_error( $result ) ) { return $result; } } elseif ( in_array( $field['type'], array( 'file', 'image' ), true ) ) { // Handle top-level file/image fields. $file_key = $field_key . '_file'; if ( ! empty( $_FILES[ $file_key ]['name'] ) ) { $attachment_id = $this->upload_file( $file_key ); if ( is_wp_error( $attachment_id ) ) { return $attachment_id; } $this->data[ $field_key ] = $attachment_id; } } elseif ( 'gallery' === $field['type'] ) { // Handle gallery fields. $file_key = $field_key . '_files'; // Check if files were actually uploaded. // For multiple files with brackets [], $_FILES[$file_key]['name'] is an array. // We need to check if the array has actual filenames (not empty strings). $has_files = false; if ( ! empty( $_FILES[ $file_key ]['name'] ) && is_array( $_FILES[ $file_key ]['name'] ) ) { // Filter out empty strings. $file_names = array_filter( array_map( 'sanitize_file_name', $_FILES[ $file_key ]['name'] ) ); $has_files = ! empty( $file_names ); } if ( $has_files ) { $attachment_ids = $this->upload_gallery( $file_key ); if ( is_wp_error( $attachment_ids ) ) { return $attachment_ids; } // Merge new uploads with existing attachment IDs. // Existing IDs were captured in sanitize_data() from hidden inputs. $existing_ids = isset( $this->data[ $field_key ] ) && is_array( $this->data[ $field_key ] ) ? $this->data[ $field_key ] : array(); // Combine existing + new, remove duplicates. $this->data[ $field_key ] = array_unique( array_merge( $existing_ids, $attachment_ids ) ); } } } } } return true; // phpcs:enable } /** * Process file uploads in group fields * * @param array $group_field The group field configuration. * @return true|WP_Error True on success or WP_Error on failure. */ private function process_group_file_uploads( $group_field ) { // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Called after nonce verification, files sanitized via AFFCF_File_Handler $group_key = $group_field['key']; // Initialize group data if not already set. if ( ! isset( $this->data[ $group_key ] ) ) { // Sanitize group data from $_POST to prevent unsanitized data storage. $raw_data = isset( $_POST[ $group_key ] ) ? wp_unslash( $_POST[ $group_key ] ) : array(); $this->data[ $group_key ] = is_array( $raw_data ) ? $this->sanitize_nested_data( $raw_data ) : array(); } foreach ( $group_field['sub_fields'] as $sub_field ) { if ( in_array( $sub_field['type'], array( 'file', 'image' ), true ) ) { $sub_field_key = $sub_field['key']; $file_key = $sub_field_key . '_file'; // Check if file was uploaded. // File input name is: group_key[sub_field_key_file]. // So $_FILES structure is: $_FILES['group_key']['name']['sub_field_key_file']. if ( ! empty( $_FILES[ $group_key ]['name'][ $file_key ] ) ) { // Sanitize file name and validate type. $uploaded_name = sanitize_file_name( $_FILES[ $group_key ]['name'][ $file_key ] ); $file_check = wp_check_filetype( $uploaded_name ); // Only use type from wp_check_filetype - never trust client-provided type. // If wp_check_filetype returns false, the file type is not allowed. if ( ! $file_check['type'] ) { // Invalid file type - skip this file. continue; } // Extract the file from nested $_FILES structure. // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- File upload fields: tmp_name, error, size are PHP-internal values, not user input $file = array( 'name' => $uploaded_name, 'type' => $file_check['type'], // Only use verified type from wp_check_filetype. 'tmp_name' => isset( $_FILES[ $group_key ]['tmp_name'][ $file_key ] ) ? $_FILES[ $group_key ]['tmp_name'][ $file_key ] : '', 'error' => isset( $_FILES[ $group_key ]['error'][ $file_key ] ) ? $_FILES[ $group_key ]['error'][ $file_key ] : UPLOAD_ERR_NO_FILE, 'size' => isset( $_FILES[ $group_key ]['size'][ $file_key ] ) ? $_FILES[ $group_key ]['size'][ $file_key ] : 0, ); // phpcs:enable // Skip if no file uploaded (error 4 = UPLOAD_ERR_NO_FILE). if ( 4 === $file['error'] ) { continue; } // Upload the file. $config = $this->get_file_config_from_field( $sub_field ); $attachment_id = AFFCF_File_Handler::upload_file( $file, $config ); if ( is_wp_error( $attachment_id ) ) { return $attachment_id; } // Store attachment ID in group data. if ( ! is_array( $this->data[ $group_key ] ) ) { $this->data[ $group_key ] = array(); } $this->data[ $group_key ][ $sub_field_key ] = $attachment_id; } } } return true; // phpcs:enable } /** * Process file uploads in repeater fields * * @param array $repeater_field The repeater field configuration. * @return true|WP_Error True on success or WP_Error on failure. */ private function process_repeater_file_uploads( $repeater_field ) { // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Called after nonce verification, files sanitized via AFFCF_File_Handler $repeater_key = $repeater_field['key']; // Initialize repeater data if not already set. if ( ! isset( $this->data[ $repeater_key ] ) ) { $raw_data = isset( $_POST[ $repeater_key ] ) ? wp_unslash( $_POST[ $repeater_key ] ) : array(); // Sanitize repeater data using nested array sanitization. $this->data[ $repeater_key ] = is_array( $raw_data ) ? $this->sanitize_nested_data( $raw_data ) : array(); } if ( ! is_array( $this->data[ $repeater_key ] ) ) { return true; } // Process each repeater row. foreach ( $this->data[ $repeater_key ] as $row_index => $row_data ) { foreach ( $repeater_field['sub_fields'] as $sub_field ) { if ( in_array( $sub_field['type'], array( 'file', 'image' ), true ) ) { $sub_field_key = $sub_field['key']; $file_key = $sub_field_key . '_file'; // Check if file was uploaded for this row. // File input name is: repeater_key[row_index][sub_field_key_file]. // So $_FILES structure is: $_FILES['repeater_key']['name'][row_index]['sub_field_key_file']. if ( ! empty( $_FILES[ $repeater_key ]['name'][ $row_index ][ $file_key ] ) ) { // Sanitize file name and validate type. $uploaded_name = sanitize_file_name( $_FILES[ $repeater_key ]['name'][ $row_index ][ $file_key ] ); $file_check = wp_check_filetype( $uploaded_name ); // Only use type from wp_check_filetype - never trust client-provided type. // If wp_check_filetype returns false, the file type is not allowed. if ( ! $file_check['type'] ) { // Invalid file type - skip this file. continue; } // Extract the file from nested $_FILES structure. // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- File upload fields: tmp_name, error, size are PHP-internal values $file = array( 'name' => $uploaded_name, 'type' => $file_check['type'], // Only use verified type from wp_check_filetype. 'tmp_name' => isset( $_FILES[ $repeater_key ]['tmp_name'][ $row_index ][ $file_key ] ) ? $_FILES[ $repeater_key ]['tmp_name'][ $row_index ][ $file_key ] : '', 'error' => isset( $_FILES[ $repeater_key ]['error'][ $row_index ][ $file_key ] ) ? $_FILES[ $repeater_key ]['error'][ $row_index ][ $file_key ] : UPLOAD_ERR_NO_FILE, 'size' => isset( $_FILES[ $repeater_key ]['size'][ $row_index ][ $file_key ] ) ? $_FILES[ $repeater_key ]['size'][ $row_index ][ $file_key ] : 0, ); // phpcs:enable // Skip if no file uploaded (error 4 = UPLOAD_ERR_NO_FILE). if ( 4 === $file['error'] ) { continue; } // Upload the file. $config = $this->get_file_config_from_field( $sub_field ); $attachment_id = AFFCF_File_Handler::upload_file( $file, $config ); if ( is_wp_error( $attachment_id ) ) { return $attachment_id; } // Store attachment ID in repeater row data. $this->data[ $repeater_key ][ $row_index ][ $sub_field_key ] = $attachment_id; } } } } return true; // phpcs:enable } /** * Get file configuration from field definition * * @param array $field The field definition. * @return array Configuration array. */ private function get_file_config_from_field( $field ) { $config = array(); // Handle max_size. // Note: ACF sub_fields often don't include max_size. // Default: 2MB for images, 5MB for files. $default_size = ( 'image' === $field['type'] ) ? 2 : 5; // Check if max_size is provided and valid (> 0). if ( isset( $field['max_size'] ) && $field['max_size'] > 0 ) { $max_value = floatval( $field['max_size'] ); // If value is very large (>100), assume it's in KB, convert to MB. if ( $max_value > 100 ) { $config['max_size'] = $max_value / 1024; } else { // Already in MB. $config['max_size'] = $max_value; } } else { // Use default when not set or 0. $config['max_size'] = $default_size; } // Handle allowed file types. if ( isset( $field['mime_types'] ) && ! empty( $field['mime_types'] ) ) { // ACF stores as comma-separated string: "jpg,png,gif". $types = explode( ',', $field['mime_types'] ); $config['allowed_types'] = array_map( 'trim', $types ); } elseif ( 'image' === $field['type'] ) { $config['allowed_types'] = array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ); } return $config; } /** * Upload single file * * @param string $file_key The file input key. * @return int|WP_Error Attachment ID or WP_Error on failure. */ private function upload_file( $file_key ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Called after nonce verification, file sanitized by AFFCF_File_Handler::upload_file $file = isset( $_FILES[ $file_key ] ) ? $_FILES[ $file_key ] : array(); // Get file upload configuration from form settings. $config = $this->get_file_config( $file_key ); // Use file handler. $attachment_id = AFFCF_File_Handler::upload_file( $file, $config ); return $attachment_id; } /** * Upload multiple files (gallery) * * @param string $file_key The file input key. * @return array|WP_Error Array of attachment IDs or WP_Error on failure. */ private function upload_gallery( $file_key ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Called after nonce verification, file sanitized by AFFCF_File_Handler::upload_file $files = isset( $_FILES[ $file_key ] ) ? $_FILES[ $file_key ] : array(); // Get file upload configuration. $config = $this->get_file_config( $file_key ); // Use file handler for multiple files. $attachment_ids = AFFCF_File_Handler::upload_multiple( $files, $config ); return $attachment_ids; } /** * Get file upload configuration * * @param string $file_key File field key. * @return array Configuration. */ private function get_file_config( $file_key ) { $config = array( 'max_size' => 5, // 5MB default. 'allowed_types' => array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf' ), ); // Check for featured image configuration. if ( 'featured_image' === $file_key ) { if ( ! empty( $this->form->default_fields['featured_image'] ) ) { $field_config = $this->form->default_fields['featured_image']; if ( isset( $field_config['max_file_size'] ) && $field_config['max_file_size'] > 0 ) { // Convert to float to prevent "string * int" error. $config['max_size'] = floatval( $field_config['max_file_size'] ); } if ( isset( $field_config['allowed_types'] ) ) { $config['allowed_types'] = $field_config['allowed_types']; } } } else { // Check for custom field configuration. $field_manager = AFFCF_Field_Manager::get_instance(); $field_key_clean = str_replace( '_file', '', str_replace( '_files', '', $file_key ) ); $field = $field_manager->get_field( $field_key_clean ); if ( $field && isset( $field['max_size'] ) && $field['max_size'] > 0 ) { // Convert to float to prevent "string * int" error. $config['max_size'] = floatval( $field['max_size'] ); } if ( $field && isset( $field['mime_types'] ) ) { // Convert ACF mime_types to file extensions. $mime_map = array( 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'application/pdf' => 'pdf', ); $allowed = array(); foreach ( explode( ',', $field['mime_types'] ) as $mime ) { $mime = trim( $mime ); if ( isset( $mime_map[ $mime ] ) ) { $allowed[] = $mime_map[ $mime ]; } } if ( ! empty( $allowed ) ) { $config['allowed_types'] = $allowed; } } // Gallery max files. if ( $field && 'gallery' === $field['type'] && isset( $field['max'] ) ) { $config['max_files'] = $field['max']; } } return $config; } /** * Create post * * @return int|WP_Error Post ID or WP_Error on failure. */ private function create_post() { $post_data = array( 'post_title' => $this->data['post_title'] ?? '', 'post_content' => $this->data['post_content'] ?? '', 'post_excerpt' => $this->data['post_excerpt'] ?? '', 'post_type' => $this->form->post_type, 'post_status' => 'pending', // Always pending for review. 'post_author' => $this->get_user_id(), ); $post_id = wp_insert_post( $post_data, true ); if ( is_wp_error( $post_id ) ) { return $post_id; } // Save featured image. if ( ! empty( $this->data['featured_image'] ) ) { set_post_thumbnail( $post_id, $this->data['featured_image'] ); } // Save form ID meta. update_post_meta( $post_id, '_affcf_form_id', $this->form->id ); update_post_meta( $post_id, '_affcf_original_author', $this->get_user_id() ); update_post_meta( $post_id, '_affcf_submission_count', 0 ); return $post_id; } /** * Update post * * @return int|WP_Error Post ID or WP_Error on failure. */ private function update_post() { $post_data = array( 'ID' => $this->post_id, 'post_title' => $this->data['post_title'] ?? '', 'post_content' => $this->data['post_content'] ?? '', 'post_excerpt' => $this->data['post_excerpt'] ?? '', 'post_status' => 'pending', // Back to pending after edit. ); $result = wp_update_post( $post_data, true ); if ( is_wp_error( $result ) ) { return $result; } // Update featured image. if ( ! empty( $this->data['featured_image'] ) ) { set_post_thumbnail( $this->post_id, $this->data['featured_image'] ); } // Update submission count. $count = (int) get_post_meta( $this->post_id, '_affcf_submission_count', true ); update_post_meta( $this->post_id, '_affcf_submission_count', $count + 1 ); update_post_meta( $this->post_id, '_affcf_last_edited', current_time( 'mysql' ) ); return $this->post_id; } /** * Save custom fields * * @param int $post_id The post ID. * @return void */ private function save_custom_fields( $post_id ) { // Gracefully handle missing field groups. if ( ! isset( $this->form->field_groups ) || empty( $this->form->field_groups ) ) { return; } // Ensure field groups is an array. if ( ! is_array( $this->form->field_groups ) ) { return; } try { $field_manager = AFFCF_Field_Manager::get_instance(); $acf_available = function_exists( 'acf_get_field' ) && function_exists( 'acf_update_value' ); foreach ( $this->form->field_groups as $group ) { if ( ! is_array( $group ) || empty( $group['enabled_fields'] ) ) { continue; } // ------------------------------------------------------------------ // Phase 1: Detect clone sub-fields in enabled_fields. // // The form config may list clone sub-fields as individual entries // using clone-modified keys (e.g. "field_PARENT_field_CHILD"). // These have type=unknown because acf_get_field() can't resolve // modified keys directly. We detect them, load the parent clone // field, collect their values keyed by sub_field['_name'], and // save via ACF's native clone pipeline. // ------------------------------------------------------------------ $clone_handled = array(); // field_keys handled as clone sub-fields. if ( $acf_available ) { $clone_parents = array(); // parent_key => parent_field definition. $clone_values = array(); // parent_key => [ _name => value, ... ]. foreach ( $group['enabled_fields'] as $fk ) { if ( ! isset( $this->data[ $fk ] ) ) { continue; } // Detect compound keys: {parent_field_key}_{child_field_key}. // Both parts start with "field_", so look for "_field_" after // the initial "field_" prefix (position 6+). $sep = strpos( $fk, '_field_', 6 ); if ( false === $sep ) { continue; } $parent_key = substr( $fk, 0, $sep ); if ( strpos( $parent_key, 'field_' ) !== 0 ) { continue; } // Load parent clone field (once per parent). if ( ! array_key_exists( $parent_key, $clone_parents ) ) { $parent = acf_get_field( $parent_key ); if ( $parent && 'clone' === $parent['type'] && ! empty( $parent['sub_fields'] ) ) { $clone_parents[ $parent_key ] = $parent; $clone_values[ $parent_key ] = array(); } else { // Not a clone — mark as checked so we don't re-try. $clone_parents[ $parent_key ] = false; } } if ( empty( $clone_parents[ $parent_key ] ) ) { continue; } // Find the matching sub-field by its clone-modified key. foreach ( $clone_parents[ $parent_key ]['sub_fields'] as $sf ) { if ( isset( $sf['key'] ) && $sf['key'] === $fk ) { $uname = isset( $sf['_name'] ) ? $sf['_name'] : ''; if ( ! empty( $uname ) ) { $clone_values[ $parent_key ][ $uname ] = $this->data[ $fk ]; $clone_handled[] = $fk; } break; } } } // Save each clone field via ACF's native pipeline. // acf_update_value() triggers the clone's update_value() which: // - Finds values by sub_field['_name'] (our remapped keys) // - Calls acf_prepare_field() to restore original key // - Saves each sub-field individually with correct meta key + reference // - Returns '' to save the clone field's own meta + reference. foreach ( $clone_parents as $parent_key => $parent_field ) { if ( ! empty( $parent_field ) && ! empty( $clone_values[ $parent_key ] ) ) { acf_update_value( $clone_values[ $parent_key ], $post_id, $parent_field ); } } } // ------------------------------------------------------------------ // Phase 2: Save all other fields normally. // ------------------------------------------------------------------ foreach ( $group['enabled_fields'] as $field_key ) { // Skip clone sub-fields already handled in Phase 1. if ( in_array( $field_key, $clone_handled, true ) ) { continue; } if ( ! isset( $this->data[ $field_key ] ) ) { continue; } // Get field info. $field = $field_manager->get_field( $field_key ); $field_type = $field ? $field['type'] : 'unknown'; // Skip layout-only fields - no data to save. if ( in_array( $field_type, array( 'accordion', 'tab', 'message' ), true ) ) { continue; } $value = $this->data[ $field_key ]; // Clone fields with nested data (clone field itself in enabled_fields): // remap value keys from sub_field['name'] → sub_field['_name'] and // save via ACF's native pipeline. if ( 'clone' === $field_type && is_array( $value ) && $acf_available ) { $clone_field = acf_get_field( $field_key ); if ( $clone_field && ! empty( $clone_field['sub_fields'] ) ) { $remapped = array(); foreach ( $clone_field['sub_fields'] as $sf ) { $sf_name = isset( $sf['name'] ) ? $sf['name'] : ''; $sf_uname = isset( $sf['_name'] ) ? $sf['_name'] : ''; if ( ! empty( $sf_name ) && ! empty( $sf_uname ) && isset( $value[ $sf_name ] ) ) { $remapped[ $sf_uname ] = $value[ $sf_name ]; } } if ( ! empty( $remapped ) ) { acf_update_value( $remapped, $post_id, $clone_field ); continue; } } } $field_manager->update_field( $field_key, $value, $post_id ); } } } catch ( Throwable $e ) { // Log error but don't fail the entire submission. unset( $e ); } } /** * Save taxonomies * * @param int $post_id The post ID. * @return void */ private function save_taxonomies( $post_id ) { // Gracefully handle missing taxonomies. if ( ! isset( $this->form->taxonomies ) || empty( $this->form->taxonomies ) ) { return; } // Ensure taxonomies is an array. if ( ! is_array( $this->form->taxonomies ) ) { return; } try { foreach ( $this->form->taxonomies as $taxonomy ) { if ( ! is_array( $taxonomy ) || empty( $taxonomy['taxonomy'] ) ) { continue; } $tax_name = $taxonomy['taxonomy']; if ( ! isset( $this->data[ $tax_name ] ) ) { continue; } $term_ids = is_array( $this->data[ $tax_name ] ) ? $this->data[ $tax_name ] : array( $this->data[ $tax_name ] ); // Filter out empty values. $term_ids = array_filter( $term_ids ); if ( empty( $term_ids ) ) { continue; } wp_set_post_terms( $post_id, $term_ids, $tax_name, false ); } } catch ( Throwable $e ) { // Log error but don't fail the entire submission. unset( $e ); } } /** * Track submission * * @param int $post_id The post ID. * @return void */ private function track_submission( $post_id ) { global $wpdb; try { $submissions_table = $wpdb->prefix . 'affcf_submissions'; // Check if table exists. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Checking table existence, no caching needed $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $submissions_table ) ) === $submissions_table; if ( ! $table_exists ) { return; // Don't fail submission if tracking table is missing. } // Safely get form ID. $form_id = isset( $this->form->id ) ? $this->form->id : 0; if ( ! $form_id ) { return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table insert for submission tracking $wpdb->insert( $submissions_table, array( 'user_id' => $this->get_user_id(), 'form_id' => $form_id, 'post_id' => $post_id, 'submission_type' => $this->is_edit ? 'edit' : 'new', 'submission_date' => current_time( 'mysql' ), 'ip_address' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '', 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', 'captcha_score' => isset( $this->data['captcha_score'] ) ? $this->data['captcha_score'] : null, ), array( '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%f' ) ); // Increment submission counts (only for new submissions). if ( ! $this->is_edit && ! $wpdb->last_error ) { AFFCF_Submission_Limits::increment_counts( $this->get_user_id(), $form_id ); } } catch ( Throwable $e ) { // Log error but don't fail the entire submission. unset( $e ); } } /** * Send notifications * * @param int $post_id The post ID. * @return void */ private function send_notifications( $post_id ) { try { // Validate form object. if ( ! isset( $this->form ) || ! is_object( $this->form ) ) { return; } $email_manager = new AFFCF_Email_Manager(); // Send submission received email to user. $email_manager->send_submission_received( $this->get_user_id(), $post_id, $this->form ); // Send admin notification. $email_manager->send_admin_notification( $post_id, $this->form ); } catch ( Throwable $e ) { // Log error but don't fail the entire submission. unset( $e ); } } /** * Delete draft if form was submitted from a draft * * @return void */ private function delete_draft_if_exists() { try { global $wpdb; $drafts_table = $wpdb->prefix . 'affcf_drafts'; $user_id = $this->get_user_id(); $form_id = $this->form->id; // Check if draft_id was provided. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Called after nonce verification in handle_submission() $draft_id = isset( $_POST['affcf_draft_id'] ) ? absint( wp_unslash( $_POST['affcf_draft_id'] ) ) : 0; if ( $draft_id ) { // Delete by specific draft ID (verified with user/form ownership). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->delete( $drafts_table, array( 'id' => $draft_id, 'user_id' => $user_id, 'form_id' => $form_id, ), array( '%d', '%d', '%d' ) ); } // Also clean up by user + form as fallback. // Auto-save may have created a draft without the hidden input. // Delete any matching draft to ensure no orphans remain. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix $wpdb->query( $wpdb->prepare( "DELETE FROM $drafts_table WHERE user_id = %d AND form_id = %d", $user_id, $form_id ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } catch ( Throwable $e ) { unset( $e ); } } }