<?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' );
}
}