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( '/