File: /home/globfdxw/www/wp-content/plugins/wpforms-user-journey/src/Process.php
<?php
namespace WPFormsUserJourney;
/**
* User Journey processing.
*
* @since 1.0.0
*/
class Process {
/**
* Initialize.
*
* @since 1.0.0
*/
public function init(): void {
$this->hooks();
}
/**
* Process hooks.
*
* @since 1.0.0
*/
public function hooks(): void {
add_action( 'wpforms_process_entry_saved', [ $this, 'copy_abandoned_journeys' ], 5, 4 );
add_action( 'wpforms_process_entry_saved', [ $this, 'process_entry_meta' ], 10, 4 );
add_action( 'wpforms_process_complete_form_abandonment', [ $this, 'process_abandoned_entry' ], 10, 4 );
}
/**
* Check if user journey data present and process.
*
* @since 1.0.0
*
* @param array|mixed $fields Final/sanitized submitted field data.
* @param array $entry Copy of the original $_POST.
* @param array $form_data Form data and settings.
* @param string|int $entry_id Entry ID.
*
* @noinspection PhpUnusedParameterInspection
*/
public function process_entry_meta( $fields, array $entry, array $form_data, $entry_id ): void {
// Set a cleanup cookie to delete the local storage.
$this->set_cleanup_cookie();
// Check if the form has entries disabled.
if ( isset( $form_data['settings']['disable_entries'] ) ) {
return;
}
$journey = $this->get_user_journey_data();
$this->save_journey( $journey, $form_data, $entry_id );
}
/**
* Process User Journey data for abandoned entry.
*
* @since 1.7.0
*
* @param array|mixed $fields Final/sanitized submitted field data.
* @param array $entry Copy of the original $_POST.
* @param array $form_data Form data and settings.
* @param string|int $entry_id Entry ID.
*
* @noinspection PhpUnusedParameterInspection
*/
public function process_abandoned_entry( $fields, array $entry, array $form_data, $entry_id ): void {
// Clear localStorage so the next session starts fresh.
// Cross-session data is recovered from the DB by copy_abandoned_journeys().
$this->set_cleanup_cookie();
// Check if the form has entries disabled.
if ( isset( $form_data['settings']['disable_entries'] ) ) {
return;
}
$journey = $this->get_user_journey_data();
if ( empty( $journey ) ) {
return;
}
$db = wpforms_user_journey()->db;
// When "Prevent duplicate" is enabled, Form Abandonment addon reuses the same entry_id.
// If journey rows already exist for this entry, insert an abandoned marker to separate sessions,
// then append the new session's data.
$existing_rows = $db->get_rows( [ 'entry_id' => $entry_id ] );
if ( ! empty( $existing_rows ) ) {
$db->add(
[
'entry_id' => absint( $entry_id ),
'form_id' => absint( $form_data['id'] ),
'post_id' => 0,
'url' => '',
'parameters' => wp_json_encode( [ '_wpforms_abandoned_session' => true ] ),
'external' => 0,
'title' => '',
'duration' => 0,
'step' => 0,
'date' => current_time( 'mysql', true ),
]
);
}
$this->save_journey( $journey, $form_data, $entry_id );
}
/**
* Copy journey data from abandoned entries to the completed entry.
*
* Runs at priority 5 on wpforms_process_entry_saved — before Form Abandonment
* deletes abandoned entries at priority 10.
*
* @since 1.7.0
*
* @param array|mixed $fields Final/sanitized submitted field data.
* @param array $entry Copy of the original $_POST.
* @param array $form_data Form data and settings.
* @param string|int $entry_id Entry ID.
*
* @noinspection PhpUnusedParameterInspection
*/
public function copy_abandoned_journeys( $fields, array $entry, array $form_data, $entry_id ): void {
// Only proceed if form abandonment is enabled for this form.
if ( empty( $form_data['settings']['form_abandonment'] ) ) {
return;
}
if ( isset( $form_data['settings']['disable_entries'] ) ) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$user_uuid = isset( $_COOKIE['_wpfuuid'] ) ? sanitize_key( $_COOKIE['_wpfuuid'] ) : '';
if ( empty( $user_uuid ) ) {
return;
}
global $wpdb;
// Find the most recent abandoned entries for this user and form. Limited to 10.
$table_name = esc_sql( $wpdb->prefix . 'wpforms_entries' );
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
$abandoned_entries = $wpdb->get_results(
$wpdb->prepare(
'SELECT entry_id, date FROM ' . $table_name .
" WHERE `form_id` = %d AND `user_uuid` = %s AND `status` = 'abandoned'
ORDER BY `date` DESC
LIMIT 10",
absint( $form_data['id'] ),
$user_uuid
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
// Reverse to chronological order for proper journey display.
$abandoned_entries = array_reverse( $abandoned_entries );
if ( empty( $abandoned_entries ) ) {
return;
}
$db = wpforms_user_journey()->db;
// Copy journey rows from each abandoned entry to the completed entry.
foreach ( $abandoned_entries as $abandoned_entry ) {
$journey_rows = $db->get_rows(
[
'entry_id' => $abandoned_entry->entry_id,
]
);
if ( empty( $journey_rows ) ) {
continue;
}
foreach ( $journey_rows as $row ) {
$db->add(
[
'entry_id' => absint( $entry_id ),
'form_id' => absint( $form_data['id'] ),
'post_id' => absint( $row->post_id ),
'url' => $row->url,
'parameters' => $row->parameters,
'external' => absint( $row->external ),
'title' => $row->title,
'duration' => absint( $row->duration ),
'step' => absint( $row->step ),
'date' => $row->date,
]
);
}
// Insert an "abandoned" marker row after the last copied journey row.
$db->add(
[
'entry_id' => absint( $entry_id ),
'form_id' => absint( $form_data['id'] ),
'post_id' => 0,
'url' => '',
'parameters' => wp_json_encode( [ '_wpforms_abandoned_session' => true ] ),
'external' => 0,
'title' => '',
'duration' => 0,
'step' => 0,
'date' => $abandoned_entry->date,
]
);
}
}
/**
* Get user journey data from the form input.
*
* @since 1.6.0
*
* @return array
*/
private function get_user_journey_data(): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$journey_data = isset( $_POST[ Loader::STORAGE_NAME ] )
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
? json_decode( wp_unslash( $_POST[ Loader::STORAGE_NAME ] ), true )
: [];
// phpcs:enable WordPress.Security.NonceVerification.Missing
$journey_data = is_array( $journey_data ) ? array_map( 'urldecode', $journey_data ) : [];
return $this->get_last_data( $journey_data );
}
/**
* Get last data from the journey.
*
* @since 1.6.0
*
* @param array $data Journey data.
*
* @return array
*/
private function get_last_data( array $data ): array {
// Sort in reverse order.
krsort( $data );
$plugin = wpforms_user_journey();
$max_data_size = $plugin->get_max_data_size();
$max_data_items = $plugin->get_max_data_items();
$cutoff = time() - YEAR_IN_SECONDS;
$last_data = [];
$total = 2; // Outer {} in JSON.
// Get last entries.
// Do not trust data received from the frontend, make the same cleanup as in the JS.
foreach ( $data as $key => $value ) {
if ( (int) $key < $cutoff ) {
// Reject all entries older than 1 year.
break;
}
if ( count( $last_data ) >= $max_data_items ) {
// We have reached the data items limit.
break;
}
// Evaluate pair length in JSON: `"key":"value",`.
$pair_size = strlen( (string) $key ) + strlen( (string) $value ) + 6;
if ( $total + $pair_size > $max_data_size ) {
break;
}
$total += $pair_size;
$last_data[ $key ] = $value;
}
ksort( $last_data );
return $last_data;
}
/**
* Save the journey to the entry.
*
* @since 1.1.0
*
* @param array $journey List of records.
* @param array $form_data Form data and settings.
* @param string|int $entry_id Entry ID.
*/
private function save_journey( array $journey, array $form_data, $entry_id ): void {
$count = 1;
$timestamp_prev = 0;
foreach ( $journey as $timestamp => $record ) {
$item = $this->get_record_data( $record, $timestamp, $count );
++$count;
if ( empty( $item ) ) {
continue;
}
$item['entry_id'] = absint( $entry_id );
$item['form_id'] = absint( $form_data['id'] );
$item['duration'] = ! empty( $timestamp_prev ) ? absint( $timestamp ) - absint( $timestamp_prev ) : 0;
wpforms_user_journey()->db->add( $item );
$timestamp_prev = $timestamp;
}
}
/**
* Set a cleanup cookie to delete the local storage.
* This is the reliable way to send a signal to the browser.
* Works with POST, Ajax, and page caching.
*
* @since 1.6.0
*/
private function set_cleanup_cookie(): void {
setcookie( Loader::CLEANUP_COOKIE_NAME, '1', time() + YEAR_IN_SECONDS, '/', '', is_ssl() );
}
/**
* Get record from the string.
*
* @since 1.0.0
*
* @param string $record Record string.
* @param int $timestamp Timestamp.
* @param int $step Current step.
*
* @return array
*/
private function get_record_data( string $record, int $timestamp, int $step ): array {
if ( empty( $record ) || strpos( $record, '|#|' ) === false ) {
return [];
}
$parts = explode( '|#|', $record );
$url = esc_url_raw( strtok( $parts[0], '?' ) );
if ( $step !== 1 && false === strpos( $url, home_url() ) ) {
return [];
}
$item = [
'post_id' => ! empty( $parts[2] ) ? absint( $parts[2] ) : 0,
'url' => $url,
'parameters' => '',
'title' => ! empty( $parts[1] ) ? sanitize_text_field( $parts[1] ) : '',
'external' => strpos( $parts[0], home_url() ) === false,
'step' => $step,
'date' => gmdate( 'Y-m-d H:i:s', absint( $timestamp ) ),
];
$query_component = wp_parse_url( $parts[0], PHP_URL_QUERY );
if ( ! empty( $query_component ) ) {
parse_str( $query_component, $params );
}
if ( ! empty( $params ) ) {
$parameters = [];
foreach ( $params as $key => $value ) {
$parameters[ sanitize_key( $key ) ] = sanitize_text_field( $value );
}
$item['parameters'] = wp_json_encode( $parameters );
}
if ( $step === 1 && strpos( $item['title'], '{ReferrerPageTitle}' ) !== false ) {
$title = $this->get_html_page_title( $url );
$item['title'] = ! empty( $title )
? sanitize_text_field( $title )
: str_replace( '{ReferrerPageTitle}', __( 'Referrer', 'wpforms-user-journey' ), $item['title'] );
}
return $item;
}
/**
* Get the page <title> from a given URL.
*
* @since 1.0.0
*
* @param string $url Page URL.
*
* @return string
*/
public function get_html_page_title( string $url ): string {
/**
* Allow disabling the referrer page title replacement.
*
* @since 1.0.0
*
* @param bool $allow Allow the replacement.
*/
if ( ! apply_filters( 'wpforms_user_journey_process_referrer_page_title', true ) ) {
return '';
}
$request = wp_remote_get( $url );
if (
'OK' !== wp_remote_retrieve_response_message( $request ) ||
200 !== wp_remote_retrieve_response_code( $request )
) {
return '';
}
$response = wp_remote_retrieve_body( $request );
preg_match( '/<title>(.*)<\/title>/i', $response, $matches );
return ! empty( $matches[1] ) ? trim( $matches[1] ) : '';
}
}