File "RestApi.php"

Full Path: /home/buyiwexj/public_html/wp-content/plugins/wpforms-lite/src/SetupWizard/RestApi.php
File size: 18.5 KB
MIME-type: text/x-php
Charset: utf-8

<?php

namespace WPForms\SetupWizard;

use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WPForms\SetupChecklist\Page;
use WPForms\SetupWizard\Service\StateManager;

/**
 * Setup Wizard REST API.
 *
 * Exposes the endpoints the SPA calls back into:
 * - `GET  /hydrate`              Initial fetch of settings + wizard_settings.
 * - `POST /update`               Persist wizard step state.
 * - `POST /install-plugins`      License-gated install of addons/cross-product plugins.
 * - `POST /complete`             Finalize the wizard.
 * - `GET  /license/key`          Fetch the configured license key.
 * - `POST /license/verify`       Validate and activate a license key.
 *
 * Every route uses `validate_request()` as its `permission_callback`. The
 * permission callback also hydrates the bound user into the current request
 * via `wp_set_current_user()` so downstream calls behave as if the admin were
 * logged in on the WP host.
 *
 * @since 2.0.0
 */
class RestApi {

	/**
	 * REST namespace.
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	private const ROUTE_NAMESPACE = 'wpforms/v1';

	/**
	 * Route prefix shared by all wizard routes.
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	private const ROUTE_BASE = '/setup-wizard';

	/**
	 * License API base URL, used when the Pro `WPFORMS_UPDATER_API` constant is
	 * not defined (i.e. in Lite).
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	private const LICENSE_API_URL_FALLBACK = 'https://wpformsapi.com/license/v1';

	/**
	 * Minimum accepted license-key length (anti-abuse guard).
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	private const LICENSE_MIN_KEY_LENGTH = 16;

	/**
	 * Maximum accepted license-key length (anti-abuse guard).
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	private const LICENSE_MAX_KEY_LENGTH = 255;

	/**
	 * Auth service.
	 *
	 * @since 2.0.0
	 *
	 * @var Auth
	 */
	private $auth;

	/**
	 * Setup Wizard state service.
	 *
	 * @since 2.0.0
	 *
	 * @var StateManager
	 */
	private $service;

	/**
	 * Constructor.
	 *
	 * @since 2.0.0
	 *
	 * @param Auth         $auth    Auth service.
	 * @param StateManager $service State manager.
	 */
	public function __construct( Auth $auth, StateManager $service ) {

		$this->auth    = $auth;
		$this->service = $service;
	}

	/**
	 * Register REST routes.
	 *
	 * @since 2.0.0
	 */
	public function register_routes(): void {

		$permission = [ $this, 'validate_request' ];

		register_rest_route(
			self::ROUTE_NAMESPACE,
			self::ROUTE_BASE . '/hydrate',
			[
				'methods'             => 'GET',
				'callback'            => [ $this, 'hydrate' ],
				'permission_callback' => $permission,
			]
		);

		register_rest_route(
			self::ROUTE_NAMESPACE,
			self::ROUTE_BASE . '/update',
			[
				'methods'             => 'POST',
				'callback'            => [ $this, 'update' ],
				'permission_callback' => $permission,
			]
		);

		register_rest_route(
			self::ROUTE_NAMESPACE,
			self::ROUTE_BASE . '/install-plugins',
			[
				'methods'             => 'POST',
				'callback'            => [ $this, 'install_plugins' ],
				'permission_callback' => $permission,
				'args'                => [
					'plugins' => [
						'type'              => 'array',
						'required'          => true,
						'default'           => [],
						'items'             => [
							'type' => 'string',
						],
						'sanitize_callback' => [ $this, 'sanitize_plugin_files' ],
					],
				],
			]
		);

		register_rest_route(
			self::ROUTE_NAMESPACE,
			self::ROUTE_BASE . '/complete',
			[
				'methods'             => 'POST',
				'callback'            => [ $this, 'complete' ],
				'permission_callback' => $permission,
				'args'                => [
					'outcome' => [
						'type'              => 'string',
						'default'           => 'build',
						'enum'              => [ 'build', 'import', 'exit', 'forms' ],
						'sanitize_callback' => 'sanitize_key',
					],
				],
			]
		);

		register_rest_route(
			self::ROUTE_NAMESPACE,
			self::ROUTE_BASE . '/stripe/connect-url',
			[
				'methods'             => 'POST',
				'callback'            => [ $this, 'stripe_connect_url' ],
				'permission_callback' => $permission,
			]
		);

		register_rest_route(
			self::ROUTE_NAMESPACE,
			self::ROUTE_BASE . '/license/key',
			[
				'methods'             => 'GET',
				'callback'            => [ $this, 'license_key' ],
				'permission_callback' => $permission,
			]
		);

		register_rest_route(
			self::ROUTE_NAMESPACE,
			self::ROUTE_BASE . '/license/verify',
			[
				'methods'             => 'POST',
				'callback'            => [ $this, 'verify_license' ],
				'permission_callback' => $permission,
				'args'                => [
					'key' => [
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);
	}

	/**
	 * Whether the current request is the Setup Wizard's install-plugins REST call.
	 *
	 * Evaluated as early as `wpforms_loaded`, before the REST route is dispatched
	 * and before the session is authenticated, so it matches the namespaced route
	 * against the request URI rather than relying on the REST dispatcher. Pretty
	 * permalinks carry the route in the URL path; plain permalinks carry it in the
	 * `rest_route` query var, so both forms are checked. The Addons handler reads
	 * this to load its addon and license data in this otherwise non-admin context;
	 * the install action itself stays gated by `validate_request()` and
	 * `wpforms_can_install()`.
	 *
	 * @since 2.0.0
	 *
	 * @return bool
	 */
	public static function is_install_request(): bool {

		if ( ! wpforms_is_rest() ) {
			return false;
		}

		$route = self::ROUTE_NAMESPACE . self::ROUTE_BASE . '/install-plugins';

		// Plain permalinks route REST through the `rest_route` query var
		// (e.g. `/index.php?rest_route=/wpforms/v1/...`), leaving the route out of
		// the URL path, so match it there first. An exact match keeps an unrelated
		// longer route from triggering the early load.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$rest_route = isset( $_GET['rest_route'] ) ? sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ) : '';

		if ( $rest_route !== '' ) {
			return untrailingslashit( $rest_route ) === '/' . $route;
		}

		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		$path        = untrailingslashit( (string) wp_parse_url( $request_uri, PHP_URL_PATH ) );

		// Match the route as the trailing path segment (REST adds a `/wp-json` prefix),
		// not merely a substring, so an unrelated longer route cannot trigger the early load.
		return $path === $route || substr( $path, - strlen( '/' . $route ) ) === '/' . $route;
	}

	/**
	 * Sanitize the `plugins` argument for the install-plugins route.
	 *
	 * Coerces to an array of plugin-file strings (`{directory}/{main-file.php}`);
	 * non-string entries and empty values are dropped. `wpforms_sanitize_key()`
	 * preserves the `/` and `.` a plugin file needs. The catalog whitelist in
	 * `PluginCatalog::main_file()` remains the guard against installing an
	 * arbitrary path.
	 *
	 * @since 2.0.0
	 *
	 * @param mixed $value Raw argument value supplied by the SPA.
	 *
	 * @return array
	 */
	public function sanitize_plugin_files( $value ): array {

		if ( ! is_array( $value ) ) {
			return [];
		}

		return array_values( array_filter( array_map( 'wpforms_sanitize_key', $value ) ) );
	}

	/**
	 * Permission callback for every wizard route.
	 *
	 * Verifies the session token via Auth, hydrates the bound user with
	 * `wp_set_current_user()`, and refreshes the token TTL on success.
	 *
	 * @since 2.0.0
	 *
	 * @param WP_REST_Request $request REST request.
	 *
	 * @return bool|WP_Error True when the request is allowed.
	 */
	public function validate_request( WP_REST_Request $request ) {

		$user_id = $this->auth->validate_request( $request );

		if ( $user_id <= 0 ) {
			return new WP_Error(
				'rest_forbidden',
				esc_html__( 'Invalid Setup Wizard session.', 'wpforms-lite' ),
				[ 'status' => 401 ]
			);
		}

		wp_set_current_user( $user_id );

		if ( ! current_user_can( 'manage_options' ) ) {
			return new WP_Error(
				'rest_forbidden',
				esc_html__( 'Insufficient capability.', 'wpforms-lite' ),
				[ 'status' => 403 ]
			);
		}

		return true;
	}

	/**
	 * `GET /hydrate` Initial fetch of the wizard state.
	 *
	 * @since 2.0.0
	 *
	 * @param WP_REST_Request $request REST request.
	 *
	 * @return WP_REST_Response
	 * @noinspection PhpUnusedParameterInspection
	 */
	public function hydrate( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found

		return new WP_REST_Response( $this->service->get_hydrate_payload(), 200 );
	}

	/**
	 * `POST /update` Replace the wizard_settings blob.
	 *
	 * The request body is the new `wizard_settings` snapshot. The server treats
	 * the contents as opaque and overwrites the previous value wholesale. The
	 * single exception, on Pro, is the `lite_connect.consent` flag: when truthy,
	 * the state manager schedules the Lite Connect entries restore in the
	 * background (see {@see \WPForms\Pro\SetupWizard\Service\StateManager}).
	 *
	 * @since 2.0.0
	 *
	 * @param WP_REST_Request $request REST request.
	 *
	 * @return WP_REST_Response
	 */
	public function update( WP_REST_Request $request ): WP_REST_Response {

		$payload = (array) $request->get_json_params();
		$state   = $this->service->save_wizard_settings( $payload );

		return new WP_REST_Response( $state, 200 );
	}

	/**
	 * `POST /install-plugins` Install queued plugins/addons.
	 *
	 * @since 2.0.0
	 *
	 * @param WP_REST_Request $request REST request.
	 *
	 * @return WP_REST_Response
	 */
	public function install_plugins( WP_REST_Request $request ): WP_REST_Response {

		$plugins = (array) $request->get_param( 'plugins' );

		return new WP_REST_Response( $this->service->install_plugins( $plugins ), 200 );
	}

	/**
	 * `POST /complete` Finalize the wizard and resolve every redirect target.
	 *
	 * Now fired once on the SPA's Complete-page mount rather than per-CTA, so
	 * the response carries the redirect URLs for all outcomes up front. The
	 * CTAs navigate using the stored URLs without re-calling the endpoint.
	 *
	 * Delegates to `StateManager::complete()` so the manager can refresh
	 * its `settings` snapshot once post-wizard side effects exist. The SPA owns
	 * the `wizard_settings` blob and is expected to send any completion
	 * metadata (`completed_at`, cleared queues) through `/update` before
	 * invoking this route.
	 *
	 * @since 2.0.0
	 *
	 * @param WP_REST_Request $request REST request.
	 *
	 * @return WP_REST_Response
	 */
	public function complete( WP_REST_Request $request ): WP_REST_Response {

		$outcome = (string) $request->get_param( 'outcome' );

		$this->service->complete( $outcome );
		$this->auth->revoke();

		$state = $this->service->get_state();

		return new WP_REST_Response(
			[
				'redirect_urls'   => [
					'build'  => $this->get_redirect_url( 'build' ),
					'import' => $this->get_redirect_url( 'import' ),
					'exit'   => $this->get_redirect_url( 'exit' ),
					'forms'  => $this->get_redirect_url( 'forms' ),
				],
				'wizard_settings' => $state['wizard_settings'],
			],
			200
		);
	}

	/**
	 * `POST /stripe/connect-url` Get the Stripe Connect URL for the wizard.
	 *
	 * Returns a kickoff URL that defers OAuth URL generation to wp-admin context
	 * so the nonce is bound to the correct session.
	 *
	 * @since 2.0.0
	 *
	 * @param WP_REST_Request $request REST request.
	 *
	 * @return WP_REST_Response
	 * @noinspection PhpUnusedParameterInspection
	 */
	public function stripe_connect_url( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found

		$result = $this->service->get_stripe_connect_url();

		if ( is_wp_error( $result ) ) {
			return $this->error_response( $result );
		}

		return new WP_REST_Response( $result, 200 );
	}

	/**
	 * `GET /license/key` Fetch the configured license key only.
	 *
	 * @since 2.0.0
	 *
	 * @param WP_REST_Request $request REST request.
	 *
	 * @return WP_REST_Response
	 * @noinspection PhpUnusedParameterInspection
	 */
	public function license_key( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found

		return new WP_REST_Response( [ 'key' => wpforms_get_license_key() ], 200 );
	}

	/**
	 * `POST /license/verify` Validate and activate a license key.
	 *
	 * Verifies the key against the license API and runs the edition-specific
	 * completion step: Pro only stores the verified key, while Lite upgrades
	 * the site to WPForms Pro (install, activate, deactivate Lite).
	 *
	 * @since 2.0.0
	 *
	 * @param WP_REST_Request $request REST request.
	 *
	 * @return WP_REST_Response
	 */
	public function verify_license( WP_REST_Request $request ): WP_REST_Response {

		$key    = (string) $request->get_param( 'key' );
		$result = $this->activate_license( $key );

		if ( is_wp_error( $result ) ) {
			return $this->error_response( $result );
		}

		return new WP_REST_Response( $result, 200 );
	}

	/**
	 * Validate a key against the license API and run the edition-specific
	 * completion step.
	 *
	 * The validated key is persisted before the completion step runs, so a valid
	 * key is never lost if a subsequent edition-specific action (e.g. the Lite
	 * Pro install) fails. The Lite and Pro controllers override this seam; the
	 * shared base only verifies and stores the key.
	 *
	 * @since 2.0.0
	 *
	 * @param string $key License key.
	 *
	 * @return array|WP_Error
	 */
	protected function activate_license( string $key ) {

		$verify = $this->verify_key( $key );

		if ( is_wp_error( $verify ) ) {
			return $verify;
		}

		update_option(
			'wpforms_license',
			[
				'key'              => $key,
				'type'             => (string) $verify['type'],
				'is_expired'       => false,
				'is_disabled'      => false,
				'is_invalid'       => false,
				'is_limit_reached' => false,
				'is_flagged'       => false,
			]
		);

		return [
			'success'   => true,
			'installed' => true,
			'activated' => true,
			'message'   => esc_html__( 'License activated.', 'wpforms-lite' ),
		];
	}

	/**
	 * Verify a key against the license API without persisting anything.
	 *
	 * @since 2.0.0
	 *
	 * @param string $key License key.
	 *
	 * @return array|WP_Error Decoded verification response, or `WP_Error` when
	 *                        the key is rejected, the API is unreachable, or the
	 *                        response is malformed.
	 */
	private function verify_key( string $key ) {

		$size_error = $this->validate_license_key_size( $key );

		if ( $size_error !== null ) {
			return $size_error;
		}

		$verify = $this->license_api_request( 'verify-key', $key );

		if ( $verify === null ) {
			return new WP_Error(
				'wpforms_setup_wizard_license_connection',
				esc_html__( 'There was an error connecting to the license server. Please try again later.', 'wpforms-lite' ),
				[ 'status' => 503 ]
			);
		}

		if ( ! empty( $verify['error'] ) ) {
			return new WP_Error(
				'wpforms_setup_wizard_license_rejected',
				(string) $verify['error'],
				[ 'status' => 400 ]
			);
		}

		// A success response without a license type is malformed and must not be treated as a valid activation.
		if ( empty( $verify['type'] ) ) {
			return new WP_Error(
				'wpforms_setup_wizard_license_invalid_response',
				esc_html__( 'There was an error connecting to the license server. Please try again later.', 'wpforms-lite' ),
				[ 'status' => 502 ]
			);
		}

		return $verify;
	}

	/**
	 * Perform a live GET request against the license API.
	 *
	 * Replicates the essentials of the Pro `perform_remote_request()` without
	 * the Pro `License` class or any caching layer.
	 *
	 * @since 2.0.0
	 *
	 * @param string $action API action slug appended to the base URL.
	 * @param string $key    License key.
	 * @param array  $extra  Extra query args merged over the defaults.
	 *
	 * @return array|null Decoded JSON as an associative array, or null on transport/HTTP error.
	 */
	protected function license_api_request( string $action, string $key, array $extra = [] ): ?array {

		$args = array_merge(
			[
				'tgm-updater-action'      => $action,
				'tgm-updater-key'         => $key,
				'tgm-updater-wp-version'  => get_bloginfo( 'version' ),
				'tgm-updater-php-version' => PHP_VERSION,
				'tgm-updater-referer'     => site_url(),
				'wpforms_refresh_key'     => 1,
			],
			$extra
		);

		$response = wp_remote_get(
			add_query_arg( $args, $this->license_api_url() . '/' . $action ),
			[
				'user-agent' => wpforms_get_default_user_agent(),
				'timeout'    => 30,
			]
		);

		if ( is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) !== 200 ) {
			return null;
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );

		return is_array( $body ) ? $body : null;
	}

	/**
	 * Resolve the license API base URL.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	private function license_api_url(): string {

		return defined( 'WPFORMS_UPDATER_API' ) ? WPFORMS_UPDATER_API : self::LICENSE_API_URL_FALLBACK;
	}

	/**
	 * Guard the key length to block empty, garbage, or oversized payloads.
	 *
	 * @since 2.0.0
	 *
	 * @param string $key License key.
	 *
	 * @return WP_Error|null `WP_Error` when out of bounds, null when acceptable.
	 */
	private function validate_license_key_size( string $key ): ?WP_Error {

		$length = strlen( $key );

		if ( $length < self::LICENSE_MIN_KEY_LENGTH || $length > self::LICENSE_MAX_KEY_LENGTH ) {
			return new WP_Error(
				'wpforms_setup_wizard_license_invalid_size',
				esc_html__( 'Please enter a valid license key.', 'wpforms-lite' ),
				[ 'status' => 400 ]
			);
		}

		return null;
	}

	/**
	 * Build an error response from a WP_Error, using its `status` data as the HTTP code.
	 *
	 * @since 2.0.0
	 *
	 * @param WP_Error $error Error to surface to the SPA.
	 *
	 * @return WP_REST_Response
	 */
	private function error_response( WP_Error $error ): WP_REST_Response {

		$status = (int) ( ( (array) $error->get_error_data() )['status'] ?? 500 );

		return new WP_REST_Response( [ 'error' => $error->get_error_message() ], $status );
	}

	/**
	 * Resolve the post-wizard redirect target for a given outcome.
	 *
	 * @since 2.0.0
	 *
	 * @param string $outcome Outcome page: `build`, `import`, `exit`, or `forms`.
	 *
	 * @return string
	 */
	private function get_redirect_url( string $outcome ): string {

		if ( $outcome === 'import' ) {
			return admin_url( 'admin.php?page=wpforms-tools&view=import&tab=forms' );
		}

		if ( $outcome === 'exit' ) {
			return Page::get_url();
		}

		if ( $outcome === 'forms' ) {
			return admin_url( 'admin.php?page=wpforms-overview' );
		}

		return admin_url( 'admin.php?page=wpforms-builder' );
	}
}