HEX
Server: LiteSpeed
System: Linux server315.web-hosting.com 4.18.0-553.54.1.lve.el8.x86_64 #1 SMP Wed Jun 4 13:01:13 UTC 2025 x86_64
User: globfdxw (6114)
PHP: 8.1.34
Disabled: NONE
Upload Files
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] ) : '';
	}
}