File "Onboarding.php"

Full Path: /var/www/html/wordpress/wp-content/plugins/wp-optimize/vendor/team-updraft/lib-onboarding-wizard/Wizard/Onboarding/Onboarding.php
File size: 23.78 KB
MIME-type: text/x-php
Charset: utf-8

 
Open Back
<?php
namespace Updraftplus\Wp_Optimize\Wizard\Onboarding;

defined( 'ABSPATH' ) || die();

use Updraftplus\Wp_Optimize\Wizard\Installer\Installer;
use Updraftplus\Wp_Optimize\Wizard\RestResponse\RestResponse;

/**
 * The onboarding class enqueues the react app and handles the REST API requests.
 * A trait is used to add plugin specific functionality.
 * The class itself is as much as possible independent of the plugin, so it can be used in other plugins with only little changes.
 *
 * There are three scenarios where the onboarding is active:
 * - Free plugin: no license activation, and on the finish page, an upsell to premium is shown.
 * - Pro plugin, first time onboarding: license activation is required, and on the finish page, some confirmation of the activation of additional features is shown.
 * - Pro plugin, onboarding already completed in free: only license activation, possibly pro feature configuration, and no plugins installation, no email signup.
 */
class Onboarding {

    private $steps;
    private $onboarding_path;
    private $onboarding_url;
    private $is_all_plugins_installed = false;
	public $version;
	public $prefix;
	public $plugin_name = '';
    public $privacy_statement_url;
    public $privacy_url_label = '';
	public $forgot_password_url = 'https://teamupdraft.com/my-account/lost-password/';
	public $caller_slug;
	public $capability;
	public $support_url;
	public $faqs_url = '';
	public $documentation_url = '';
	public $upgrade_url;
    public $mailing_list;
	public $mailing_list_endpoint;
	public $page_prefix;
	public $languages_dir;
	public $text_domain;
	public $logo_path;
	public $is_pro = false;
    public $reload_settings_page_on_finish = false;
    public $udmupdater_nonce = 'udmupdater-ajax-nonce';
    public $udmupdater_muid = 2;
	public $udmupdater_slug  = '';
	public $udmupdater_mothership = 'https://teamupdraft.com/plugin-info/';
    public $exit_wizard_text;

	/**
	 * Initialize hooks and filters
	 */
	public function init(): void {
		if ( ! self::is_compatible() ) {
			return;
		}

		$this->onboarding_path = __DIR__;
		$this->onboarding_url  = plugin_dir_url( __FILE__ );

        if (empty($this->privacy_url_label)) {
            $this->privacy_url_label = __( 'Privacy Statement', 'wp-optimize'  );
        }

		add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
		add_action( 'wp_ajax_' . $this->prefix . '_onboarding_rest_api_fallback', [ $this, 'rest_api_fallback' ] );
		add_action( 'admin_enqueue_scripts', [ $this, 'maybe_enqueue_onboarding' ] );
		add_action( 'admin_footer', [ $this, 'add_root_html' ] );

	}

	/**
	 * Maybe load the plugin wizard, to load on every page of the caller plugin
	 *
	 * @return void
	 */
	public function maybe_enqueue_onboarding(): void {
		$screen = \get_current_screen();
		if ( stripos( $screen->id, $this->page_prefix ) !== false) {
			$this->enqueue_onboarding_scripts();
		}
	}

	/**
	 * Add values and defaults to fields in steps
	 *
	 * @param array $steps array of onboarding steps.
	 * @return array<int, array{
	 *     id: string,
	 *     title: string,
	 *     subtitle?: string,
	 *     button?: array{id: string, label: string, icon?: string},
	 *     fields?: array<int, array<string, mixed>>
	 * }>
	 */
	private function add_fields_data_to_steps( array $steps ): array {
		foreach ( $steps as $step_index => $step ) {
			if ( isset( $step['fields'] ) && is_array( $step['fields'] ) ) {
				foreach ( $step['fields'] as $field_index => $field ) {
					// update values and defaults based on plugin specific functions.
					// using prefixed hook.
                    // phpcs:ignore
					$field = apply_filters( $this->prefix . '_onboarding_field', $field, $step['id'] );
					if ( $field['type'] === 'email' ) {
                        $current_user = wp_get_current_user();
                        $current_user_email = $current_user->user_email;
						$field['default'] = $current_user_email;
						$field['value']   = $current_user_email;
					}
					if ( $field['id'] === 'plugins' ) {
						$field['options'] = $this->get_recommended_plugins();
						$field['value']   = $this->get_recommended_plugins( true );
                        if ($this->is_all_plugins_installed){
                            $field['label'] = '';
                            if (isset($step['title_conditional']) && !empty($step['title_conditional'])) {
                                if (isset($step['title_conditional']['all_installed']) && !empty($step['title_conditional']['all_installed'])) {
                                    $steps[$step_index]['title'] = $steps[$step_index]['title_conditional']['all_installed'];
                                }
                                if (isset($step['subtitle_conditional']['all_installed']) && !empty($step['subtitle_conditional']['all_installed'])) {
                                    $steps[$step_index]['subtitle'] = $steps[$step_index]['subtitle_conditional']['all_installed'];
                                }
                            }
                            $steps[ $step_index ]['button']['label'] = __('Continue', 'wp-optimize');
                        }
					}
					$steps[ $step_index ]['fields'][ $field_index ] = $field;
				}
			}
		}
		return $steps;
	}

	/**
	 * Conditionally drop steps
	 *
	 * @param array $steps array of onboarding steps.
	 * @return array<int, array{
	 *      id: string,
	 *      type: string,
     *      icon?: string,
	 *      title: string,
	 *      subtitle?: string,
	 *      button?: array{id: string, label: string, icon?: string},
	 *      fields?: array<int, array<string, mixed>>,
	 *      solutions?: array<int, string>,
	 *      bullets?: array<int, string>,
     *      intro_bullets?: array{title: string, desc: string, icon?: string},
	 *      documentation?: string,
	 *  }>
	 */
	private function conditionally_drop_steps( array $steps ): array {
		$is_pro_with_onboarding_free_completed = $this->is_pro_with_onboarding_free_completed();
		foreach ( $steps as $step_index => $step ) {
			// if this is the pro plugin onboarding,  and user has completed the onboarding in the free plugin, we can skip first_run_only steps.
			$first_run_only = isset( $step['first_run_only'] ) && (bool) $step['first_run_only'];
			if ( $is_pro_with_onboarding_free_completed && $first_run_only ) {
				unset( $steps[ $step_index ] );
				continue;
			}

			if ( $step['id'] === 'license' ) {
				// using prefixed hook.
                // phpcs:ignore
                $license_is_valid = (bool) apply_filters( $this->prefix . '_license_is_valid', false );
				if ( $license_is_valid || ! $this->is_pro ) {
					unset( $steps[ $step_index ] );
					continue;
				}
			}

			if ( $is_pro_with_onboarding_free_completed ) {
				if ( isset( $step['title_upgrade'] ) ) {
					$steps[ $step_index ]['title'] = $step['title_upgrade'];
				}
				if ( isset( $step['subtitle_upgrade'] ) ) {
					$steps[ $step_index ]['subtitle'] = $step['subtitle_upgrade'];
				}
			}
		}
		// reset keys.
		return array_values( $steps );
	}

	/**
	 * Extract the used fields from the onboarding steps, so react can filter the applicable fields.
	 *
	 * @param array $steps array of onboarding steps.
	 * @return array<int, array{
	 *       id: string,
	 *       title: string,
	 *       subtitle?: string,
	 *       button?: array{id: string, label: string, icon?: string},
	 *       fields?: array<int, array<string, mixed>>
	 *   }>
	 */
	private function extract_fields_from_steps( array $steps ): array {
		$fields = [];
		foreach ( $steps as $step ) {
			if ( isset( $step['fields'] ) && is_array( $step['fields'] ) ) {
				foreach ( $step['fields'] as $index => $field ) {
					if ( isset( $field['id'] ) ) {
						$fields[] = $field;
					}
				}
			}
		}
		return $fields;
	}

	/**
	 * Get the fields from a specific step.
	 *
	 * @param string $step The step ID to extract fields from.
	 * @return array<int, array<string, mixed>> List of fields (each field is an assoc array).
	 */
	private function extract_fields_from_step( string $step ): array {
		$step = $this->get_step_by_id( $this->sanitize_step_id( $step ) );
		if ( empty( $step ) ) {
			return [];
		}
		return ! empty( $step['fields'] ) ? $step['fields'] : [];
	}

	/**
	 * Sanitize the step ID to ensure it exists in the steps array.
	 */
	private function sanitize_step_id( string $step_id ): string {
		$steps = $this->get_steps();
		foreach ( $steps as $step ) {
			if ( isset( $step['id'] ) && $step['id'] === $step_id ) {
				return $step_id;
			}
		}
		return '';
	}

	/**
	 * Check if the current environment is compatible with the onboarding app.
	 */
	private static function is_compatible(): bool {
		if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
			return false;
		}
		// check the WordPress version.
		global $wp_version;
		if ( version_compare( $wp_version, '6.2', '<' ) ) {
			return false;
		}
		return true;
	}

	/**
	 * Check if the onboarding is active
	 */
	public static function is_onboarding_active( string $prefix, string $caller_slug ): bool {
		if ( ! self::is_compatible() ) {
			return false;
		}

		$skipped   = (bool) get_site_option( $prefix . '_skipped_onboarding' );
		$started   = (bool) get_site_option( $prefix . '_start_onboarding' );
		$completed = (bool) get_site_option( $prefix . '_completed_onboarding' );

		$current_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';

		// Don't Reopen the wizard if the user skipped the wizard.
		if ($skipped || $completed) {
            delete_site_option( $prefix . '_skipped_onboarding' );
            delete_site_option( $prefix . '_start_onboarding' );
            delete_site_option( $prefix . '_completed_onboarding' );
			return false;
		}

		// If onboarding is started or in progress or being called via REST
		return $started
			|| strpos( $current_uri, $prefix . '/v1/onboarding/do_action/' ) !== false
			|| strpos( $current_uri, $prefix . '_onboarding_rest_api_fallback' ) !== false;
	}

	/**
	 * Add root HTML element for the onboarding app
	 */
	public function add_root_html(): void {
		echo '<div id="teamupdraft-onboarding"></div>';
	}
	/**
	 * Register REST API routes
	 */
	public function register_rest_routes(): void {
		register_rest_route(
			$this->prefix . '/v1/onboarding',
			'do_action/(?P<action>[a-z\_\-]+)',
			[
				'methods'             => 'POST',
				'callback'            => [ $this, 'handle_rest_request' ],
				'permission_callback' => [ $this, 'has_permission' ],
			]
		);
	}

	/**
	 * Check if user has required capability
	 */
	public function has_permission(): bool {
		return current_user_can( $this->capability );
	}

	/**
	 * Handle REST API requests
	 */
	public function handle_rest_request( \WP_REST_Request $request ): \WP_REST_Response {
		if ( ! $this->has_permission() ) {
			return $this->response( false, [], 'You do not have permission to do this.', 403 );
		}
		$action = sanitize_text_field( $request->get_param( 'action' ) );
		$data   = $request->get_json_params();
		if ( ! wp_verify_nonce( $data['nonce'], $this->prefix . '_nonce' ) ) {
			return $this->response( false, [], 'Nonce verification failed', 403 );
		}
		return $this->handle_onboarding_action( $action, $data );
	}

	/**
	 * Handle AJAX fallback requests, when the REST API is not available
	 */
	public function rest_api_fallback(): void {
		if ( ! $this->has_permission() ) {
			wp_send_json_error( 'Unauthorized', 403 );
		}
		$data = json_decode( file_get_contents( 'php://input' ), true );
		$data = $data['data'] ?? [];
		if ( ! wp_verify_nonce( $data['nonce'], $this->prefix . '_nonce' ) ) {
			$response          = new RestResponse();
			$response->message = 'Nonce verification failed';
			wp_send_json( $response );
			exit;
		}

        /**
         * Determine action — prefer JSON body, fallback to GET
         * Sanitized and unslashed
         */
        if ( isset( $data['path'] ) ) {
            $action = sanitize_title( wp_unslash( $data['path'] ) );
        } else {
            $action = isset($_GET['rest_action']) ? sanitize_text_field( wp_unslash( $_GET['rest_action'] ) ) : '';
        }
		preg_match( '/do_action[\/|\-]([a-z\_\-]+)$/', $action, $matches );
		if ( isset( $matches[1] ) ) {
			$action = $matches[1];
		}

		$response = $this->handle_onboarding_action( $action, $data );
		wp_send_json( $response );
		exit;
	}

	/**
	 * Standardized response format
	 */
	protected function response( bool $success = false, array $data = [], string $message = '', int $code = 200 ): \WP_REST_Response {
		if ( ob_get_length() ) {
			ob_clean();
		}

		return new \WP_REST_Response(
			[
				'success'         => $success,
				'message'         => $message,
				'data'            => $data,
				// can be used to check if the response in react actually contains this array.
				'request_success' => true,
			],
			$code
		);
	}

	/**
	 * Get step by id
	 *
	 * @return ?array{
	 *        id: string,
	 *        title: string,
	 *        subtitle?: string,
	 *        button?: array{id: string, label: string, icon?: string},
	 *        fields?: array<int, array<string, mixed>>
	 *    }
	 */
	private function get_step_by_id( string $id ): ?array {
		$steps = $this->get_steps();
		foreach ( $steps as $step ) {
			if ( isset( $step['id'] ) && $step['id'] === $id ) {
				return $step;
			}
		}
		return null;
	}

	/**
	 * Handle onboarding actions
	 *
	 * @param string $action The onboarding action to handle.
	 * @param array  $data   The data associated with the action.
	 */
	private function handle_onboarding_action( string $action, array $data ): \WP_REST_Response {
		$response = $this->response( false );
		switch ( $action ) {
            case 'user_skipped_wizard':
                update_site_option($this->prefix . '_skipped_onboarding', true );
                $message = __('User skipped the wizard', 'wp-optimize');
                $response = $this->response( true, [], $message);
                break;
            case 'user_completed_wizard':
                update_site_option($this->prefix . '_completed_onboarding', true );
                $message = __('User Completed the wizard', 'wp-optimize');
                $response = $this->response( true, [], $message);
                break;
			case 'activate_license':
				// using prefixed hook.
                // phpcs:ignore
				$license_data = apply_filters( $this->prefix . '_license_activation', [], $data );
				$response     = $this->response( $license_data['success'], [], $license_data['message'] );
				break;
			case 'update_settings':
				// Get current step fields, so we only update these fields.
				$step_fields = isset( $data['step'] ) ? $this->extract_fields_from_step( $data['step'] ) : [];
				if ( ! empty( $step_fields ) ) {
					// sanitized in save functions.
					// using prefixed hook.
                    // phpcs:ignore
					do_action( $this->prefix . '_onboarding_update_options', $data['settings'], $step_fields );
				}
				$response = $this->response( true );
				break;
			case 'download':
				if ( isset( $data['plugin'] ) ) {
					$installer   = new Installer( $this->caller_slug, $data['plugin'] );
                    // Avoid re-downloading if already downloaded/installed or activated.
                    if ( $installer->plugin_is_activated( $data['plugin'] ) ) {
                        // Already active: nothing to do.
                        $response = $this->response( true, [ 'next_action' => 'installed' ] );
                    } elseif ( $installer->plugin_is_downloaded( $data['plugin'] ) ) {
                        // Already downloaded: next step is activation.
                        $response = $this->response( true, [ 'next_action' => 'activate' ] );
                    } else {
                        $success     = $installer->download_plugin();
                        $next_action = $success ? 'activate' : 'installed';
                        $response    = $this->response( (bool) $success, [ 'next_action' => $next_action ] );
                    }
                }
				break;
			case 'activate':
				if ( isset( $data['plugin'] ) ) {
					$installer = new Installer( $this->caller_slug, $data['plugin'] );
                    // Avoid re-activating if already active.
                    if ( $installer->plugin_is_activated( $data['plugin'] ) ) {
                        $response = $this->response( true, [ 'next_action' => 'installed' ] );
                    } else {
                        $success  = $installer->activate_plugin();
                        $response = $this->response( (bool) $success, [ 'next_action' => 'installed' ] );
                    }
                }
				break;

			case 'update_email':
				$step_fields = isset( $data['step'] ) ? $this->extract_fields_from_step( $data['step'] ) : [];
				if ( isset( $data['email'] ) && is_email( $data['email'] ) ) {
					$email = sanitize_email( $data['email'] );
					if ( ! empty( $email ) ) {
						$reporting_email_field_name   = '';
						$mailinglist_email_field_name = '';
						foreach ( $step_fields as $field ) {
							if ( isset( $field['type'] ) && $field['type'] === 'email' ) {
								$reporting_email_field_name = $field['id'] ?? '';
							}
							if ( isset( $field['type'] ) && $field['type'] === 'checkbox' ) {
								$mailinglist_email_field_name = $field['id'] ?? '';
							}
						}

						if ( ! empty( $reporting_email_field_name ) ) {
							// using prefixed hook.
                            // phpcs:ignore
							do_action( $this->prefix . '_onboarding_update_single_option', $reporting_email_field_name, $email );
						}
						if ( ! empty( $mailinglist_email_field_name ) ) {
							$include_tips = isset( $data['tips_tricks'] ) && (bool) $data['tips_tricks'];
							// using prefixed hook.
                            // phpcs:ignore

							do_action( $this->prefix . '_onboarding_update_single_option', 'tips_tricks_mailinglist', $email );

							if ( $include_tips ) {
								$this->signup_for_mailinglist( $email );
							}
						}
					}
                    $response = $this->response( true );
				}
				break;
			default:
				$response = $this->response( false, [], 'Unknown action', 400 );
		}

		return $response;
	}

    /**
     * Signup for a mailing list
     */
    private function signup_for_mailinglist( string $email ) {
        $endpoint = $this->mailing_list_endpoint;

        if (!empty($endpoint)) {
            $api_params = [
                'email' => sanitize_email($email),
                'tags'   => $this->mailing_list,
            ];
            wp_remote_post(
                $endpoint,
                [
                    'timeout'   => 15,
                    'sslverify' => true,
                    'body'      => $api_params
                ]
            );
        }
    }

    /**
	 * Get onboarding steps
	 *
	 * @return array<int, array{
	 *      id: string,
	 *      type: string,
     *      icon?: string,
	 *      title: string,
	 *      subtitle?: string,
	 *      button?: array{id: string, label: string, icon?: string},
	 *      fields?: array<int, array<string, mixed>>,
	 *      solutions?: array<int, string>,
	 *      bullets?: array<int, string>,
     *      intro_bullets?: array{title: string, desc: string, icon?: string},
	 *      documentation?: string,
	 *  }> The onboarding steps array.
	 */
	public function get_steps(): array {
		if ( ! empty( $this->steps ) ) {
			return $this->steps;
		}

        // phpcs:ignore
		$steps = apply_filters( $this->prefix . '_onboarding_steps', [] );
		// Hook name based on prefix.
        // phpcs:ignore
		$steps       = apply_filters( $this->prefix . '_onboarding_steps', $steps );
		$steps       = $this->add_fields_data_to_steps( $steps );
		$steps       = $this->conditionally_drop_steps( $steps );
		$this->steps = $steps;
		return $this->steps;
	}

	/**
	 * Get recommended plugins for onboarding
	 *
	 * @return array<int, array{
	 *      slug: string,
	 *      file: string,
	 *      constant_free: string,
	 *      premium: array{
	 *          type: string,
	 *          value: string
	 *      },
	 *      wordpress_url: string,
	 *      upgrade_url: string,
	 *      title: string
	 *  }>
	 */
	private function get_recommended_plugins( bool $keys = false ): array {
		$installer = new Installer( $this->caller_slug );
		$plugins   = $installer->get_plugins( false, 3 );
        $this->is_all_plugins_installed = $installer->all_installed;
		if ( $keys ) {
			// just return the slugs as a value, value , value array.
			return array_column( $plugins, 'slug' );
		}
		return $plugins;
	}

	/**
	 * Check if the user has completed the onboarding in the free version.
	 * At least an hour ago, so we don't drop steps for the curren premium installing user.
	 */
	private function is_pro_with_onboarding_free_completed(): bool {
		// if the pro plugin is active, and the free plugin has completed onboarding, we can skip some parts of the onboarding.
		$free_completed_time            = get_site_option( "{$this->prefix}_onboarding_free_completed" );
		$now                            = time();
		$free_completed_over_1_hour_ago = $free_completed_time && ( $now - $free_completed_time > HOUR_IN_SECONDS );
		return $this->is_pro && $free_completed_over_1_hour_ago;
	}

	/**
	 * Enqueue onboarding scripts and styles
	 */
	public function enqueue_onboarding_scripts(): void {
		$steps      = $this->get_steps();
		$asset_file = include $this->onboarding_path . '/build/index.asset.php';

		wp_set_script_translations( 'teamupdraft_onboarding', 'wp-optimize', $this->languages_dir );

		wp_enqueue_script(
			'teamupdraft_onboarding',
			$this->onboarding_url . 'build/index.js',
			$asset_file['dependencies'],
			$asset_file['version'],
			true
		);

		wp_enqueue_style(
			$this->prefix . '_onboarding',
			$this->onboarding_url . "build/Onboarding.css",
			[],
			$asset_file['version']
		);

        wp_enqueue_style(
            'google-font-inter',
            'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
            [],
            null
        );

        wp_localize_script(
			'teamupdraft_onboarding',
			'teamupdraft_onboarding',
			[
				'logo'                  => $this->logo_path,
				'prefix'                => $this->prefix,
				'plugin_name'           => $this->plugin_name,
				'version'               => $this->version,
				'steps'                 => $steps,
				'nonce'                 => wp_create_nonce( $this->prefix . '_nonce' ),
				'fields'                => $this->extract_fields_from_steps( $this->get_steps() ),
				'rest_url'              => get_rest_url(),
				'site_url'              => get_site_url(),
				'support'               => esc_url( $this->support_url ),
				'faqs'                  => esc_url( $this->faqs_url ),
				'documentation'         => esc_url( $this->documentation_url ),
				'upgrade'               => esc_url( $this->upgrade_url ),
                'privacy_statement_url' => esc_url( $this->privacy_statement_url ),
				'privacy_url_label'     => $this->privacy_url_label,
                'forgot_password_url'   => esc_url( $this->forgot_password_url ),
				'admin_ajax_url'        => add_query_arg( [ 'action' => $this->prefix . '_onboarding_rest_api_fallback' ], admin_url( 'admin-ajax.php' ) ),
				'is_pro'                => $this->is_pro,
				'network_link'          => network_site_url( 'plugins.php' ),
                'reload_on_finish'      => $this->reload_settings_page_on_finish,
                'text_domain'           => $this->text_domain,
                'udmupdater_nonce'      => wp_create_nonce($this->udmupdater_nonce),
                'udmupdater_muid'       => $this->udmupdater_muid,
                'udmupdater_slug'       => $this->udmupdater_slug,
                'udmupdater_mothership' => esc_url( $this->udmupdater_mothership ),
                'is_all_plugins_installed' => $this->is_all_plugins_installed,
                'exit_wizard_text'      => !empty($this->exit_wizard_text) ? $this->exit_wizard_text : __('Exit setup', 'wp-optimize'),

			]
		);
		// remember if user has completed the onboarding in the free plugin.
		if ( $this->is_pro ) {
            update_site_option( "{$this->prefix}_onboarding_free_completed", time() );
		}
	}
}