__( 'You must be logged in to save drafts.', 'airy-frontend-forms' ), ) ); return; } // Get form ID. $form_id = isset( $_POST['affcf_form_id'] ) ? absint( wp_unslash( $_POST['affcf_form_id'] ) ) : 0; if ( ! $form_id ) { wp_send_json_error( array( 'message' => __( 'Invalid form ID.', 'airy-frontend-forms' ), ) ); } // Get post ID (if editing). $post_id = isset( $_POST['post_id'] ) ? absint( wp_unslash( $_POST['post_id'] ) ) : 0; // Get user ID. $user_id = get_current_user_id(); if ( ! $user_id ) { // For guest users, use session ID of 0. $user_id = 0; } // Prepare draft data (wp_unslash to remove WordPress magic quotes). // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce already verified via check_ajax_referer above $draft_data = $this->prepare_draft_data( wp_unslash( $_POST ) ); // Save draft. $draft_id = $this->save_draft( $user_id, $form_id, $post_id, $draft_data ); if ( $draft_id ) { wp_send_json_success( array( 'message' => __( 'Draft saved.', 'airy-frontend-forms' ), 'draft_id' => $draft_id, 'timestamp' => current_time( 'mysql' ), ) ); } else { wp_send_json_error( array( 'message' => __( 'Failed to save draft.', 'airy-frontend-forms' ), ) ); } } /** * Handle AJAX load draft request */ public function ajax_load_draft() { // Verify nonce. check_ajax_referer( 'affcf_frontend_nonce', 'nonce' ); // Verify user is logged in. if ( ! is_user_logged_in() ) { wp_send_json_error( array( 'message' => __( 'You must be logged in to load drafts.', 'airy-frontend-forms' ), ) ); return; } // Get draft ID. $draft_id = isset( $_POST['draft_id'] ) ? absint( wp_unslash( $_POST['draft_id'] ) ) : 0; if ( ! $draft_id ) { wp_send_json_error( array( 'message' => __( 'Invalid draft ID.', 'airy-frontend-forms' ), ) ); return; } // Load draft. $draft = $this->get_draft( $draft_id ); // Verify ownership - user can only access their own drafts. if ( $draft && get_current_user_id() !== (int) $draft->user_id ) { wp_send_json_error( array( 'message' => __( 'Draft not found.', 'airy-frontend-forms' ), ) ); return; } if ( $draft ) { $draft_data = json_decode( $draft->draft_data, true ); wp_send_json_success( array( 'draft_data' => $draft_data, 'last_saved' => $draft->last_saved, ) ); } else { wp_send_json_error( array( 'message' => __( 'Draft not found.', 'airy-frontend-forms' ), ) ); } } /** * Handle AJAX delete draft request */ public function ajax_delete_draft() { // Verify nonce. check_ajax_referer( 'affcf_frontend_nonce', 'nonce' ); // Verify user is logged in. if ( ! is_user_logged_in() ) { wp_send_json_error( array( 'message' => __( 'You must be logged in to delete drafts.', 'airy-frontend-forms' ), ) ); return; } // Get draft ID. $draft_id = isset( $_POST['draft_id'] ) ? absint( wp_unslash( $_POST['draft_id'] ) ) : 0; if ( ! $draft_id ) { wp_send_json_error( array( 'message' => __( 'Invalid draft ID.', 'airy-frontend-forms' ), ) ); return; } // Verify ownership before deleting. $draft = $this->get_draft( $draft_id ); if ( ! $draft || get_current_user_id() !== (int) $draft->user_id ) { wp_send_json_error( array( 'message' => __( 'Draft not found.', 'airy-frontend-forms' ), ) ); return; } // Delete draft. $deleted = $this->delete_draft( $draft_id ); if ( $deleted ) { wp_send_json_success( array( 'message' => __( 'Draft deleted.', 'airy-frontend-forms' ), ) ); } else { wp_send_json_error( array( 'message' => __( 'Failed to delete draft.', 'airy-frontend-forms' ), ) ); } } /** * Prepare draft data from POST * * @param array $post_data POST data. * @return array Prepared draft data. */ private function prepare_draft_data( $post_data ) { $draft_data = array(); // Exclude certain fields. $exclude_fields = array( 'action', 'nonce', 'affcf_action', 'affcf_form_id', 'affcf_nonce', 'draft_id', ); foreach ( $post_data as $key => $value ) { if ( in_array( $key, $exclude_fields, true ) ) { continue; } // Sanitize recursively for nested arrays (repeaters, groups, etc.). $sanitized = $this->sanitize_draft_value( $value ); // Filter template rows from repeater/flexible content arrays. // Template rows have non-numeric keys like {INDEX}. if ( is_array( $sanitized ) ) { $sanitized = $this->filter_template_rows( $sanitized ); } $draft_data[ $key ] = $sanitized; } return $draft_data; } /** * Recursively sanitize a draft value (handles nested arrays from repeaters/groups) * * @param mixed $value Value to sanitize. * @return mixed Sanitized value. */ private function sanitize_draft_value( $value ) { if ( is_array( $value ) ) { return array_map( array( $this, 'sanitize_draft_value' ), $value ); } return sanitize_text_field( $value ); } /** * Filter out template rows from nested arrays * * Removes entries with non-numeric keys (like {INDEX}) that come from * hidden repeater/flexible content template rows, and removes completely * empty rows. Processes recursively for nested repeaters. * * @param array $data The array to filter. * @return array Filtered array. */ private function filter_template_rows( $data ) { // Check if this looks like a repeater array (has numeric or template keys). $has_numeric_keys = false; $has_template_keys = false; foreach ( array_keys( $data ) as $key ) { if ( is_numeric( $key ) ) { $has_numeric_keys = true; } elseif ( strpos( (string) $key, '{' ) !== false ) { $has_template_keys = true; } } // Only filter if this looks like a repeater (has template keys or all numeric keys with sub-arrays). if ( $has_template_keys ) { $data = array_filter( $data, function ( $row, $key ) { // Remove template rows (non-numeric keys like {INDEX}). if ( ! is_numeric( $key ) ) { return false; } // Remove 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 ); $data = array_values( $data ); // Re-index. } // Recurse into sub-arrays. foreach ( $data as $key => $value ) { if ( is_array( $value ) ) { $data[ $key ] = $this->filter_template_rows( $value ); } } return $data; } /** * Save draft to database * * @param int $user_id User ID. * @param int $form_id Form ID. * @param int $post_id Post ID (0 for new). * @param array $draft_data Draft data. * @return int|false Draft ID or false on failure. */ private function save_draft( $user_id, $form_id, $post_id, $draft_data ) { global $wpdb; $table_name = $wpdb->prefix . 'affcf_drafts'; // Check if draft already exists. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, plugin-controlled $existing_draft = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM $table_name WHERE user_id = %d AND form_id = %d AND post_id = %d", $user_id, $form_id, $post_id ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $data = array( 'user_id' => $user_id, 'form_id' => $form_id, 'post_id' => $post_id, 'draft_data' => wp_json_encode( $draft_data ), 'last_saved' => current_time( 'mysql' ), ); if ( $existing_draft ) { // Update existing draft. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table update for draft auto-save $wpdb->update( $table_name, $data, array( 'id' => $existing_draft ), array( '%d', '%d', '%d', '%s', '%s' ), array( '%d' ) ); return $existing_draft; } else { // Insert new draft. $data['created_at'] = current_time( 'mysql' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table insert for new draft $wpdb->insert( $table_name, $data, array( '%d', '%d', '%d', '%s', '%s', '%s' ) ); return $wpdb->insert_id; } } /** * Get draft from database * * @param int $draft_id Draft ID. * @return object|null Draft object or null. */ private function get_draft( $draft_id ) { global $wpdb; $table_name = $wpdb->prefix . 'affcf_drafts'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, plugin-controlled $result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $draft_id ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter return $result; } /** * Delete draft from database * * @param int $draft_id Draft ID. * @return bool Success. */ private function delete_draft( $draft_id ) { global $wpdb; $table_name = $wpdb->prefix . 'affcf_drafts'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table delete for draft removal $result = $wpdb->delete( $table_name, array( 'id' => $draft_id ), array( '%d' ) ); return false !== $result; } /** * Get user's drafts * * @param int $user_id User ID. * @param int $form_id Form ID (optional). * @return array Drafts. */ public function get_user_drafts( $user_id, $form_id = 0 ) { global $wpdb; $table_name = $wpdb->prefix . 'affcf_drafts'; if ( $form_id ) { // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, plugin-controlled $drafts = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table_name WHERE user_id = %d AND form_id = %d ORDER BY last_saved DESC", $user_id, $form_id ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter } else { // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, plugin-controlled $drafts = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table_name WHERE user_id = %d ORDER BY last_saved DESC", $user_id ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter } return $drafts; } /** * Clean up old drafts * * @param int $days Days to keep drafts. */ public function cleanup_old_drafts( $days = 30 ) { global $wpdb; $table_name = $wpdb->prefix . 'affcf_drafts'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, plugin-controlled $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE last_saved < DATE_SUB(NOW(), INTERVAL %d DAY)", $days ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter } }