id, array( 'toplevel_page_abtestkit-dashboard', 'abtestkit-dashboard_page_abtestkit-dashboard', 'abtestkit-dashboard_page_abtestkit-email-updates', 'admin_page_abtestkit-pt-wizard', 'admin_page_abtestkit-test-performance', ), true ); } /** * Determine whether the empty dashboard already provides enough connection actions. * * @return bool */ function abtestkit_hosted_email_updates_banner_is_redundant() : bool { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen ) { return false; } if ( ! in_array( (string) $screen->id, array( 'toplevel_page_abtestkit-dashboard', 'abtestkit-dashboard_page_abtestkit-dashboard', ), true ) ) { return false; } $tests = get_option( 'abtestkit_page_tests', array() ); return ! is_array( $tests ) || empty( $tests ); } /** * Show the required connected-account callout on abtestkit admin screens. * * @return void */ function abtestkit_hosted_render_email_updates_banner() : void { if ( ! is_admin() || ! current_user_can( 'manage_options' ) || ! abtestkit_hosted_email_updates_banner_is_target_screen() ) { return; } if ( abtestkit_connected_platform_can_create_or_launch() ) { return; } if ( abtestkit_hosted_email_updates_banner_is_redundant() ) { return; } echo '
'; echo '

' . esc_html__( 'Connect your abtestkit account to continue', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'A connected account is required to create or launch tests.', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'Connect site', 'abtestkit' ) . '

'; echo '
'; } add_action( 'admin_notices', 'abtestkit_hosted_render_email_updates_banner', 20 ); /** * Permanently dismiss the Email updates banner for administrators. * * @return void */ function abtestkit_hosted_dismiss_email_updates_banner() : void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to change this setting.', 'abtestkit' ) ); } check_admin_referer( 'abtestkit_hosted_dismiss_email_updates_banner' ); update_option( ABTESTKIT_HOSTED_EMAIL_UPDATES_BANNER_OPTION, array( 'dismissed' => 1 ), false ); wp_safe_redirect( abtestkit_admin_get_safe_return_url() ); exit; } add_action( 'admin_post_abtestkit_hosted_dismiss_email_updates_banner', 'abtestkit_hosted_dismiss_email_updates_banner' ); /** * Generate one URL-safe 256-bit value. * * @return string|WP_Error */ function abtestkit_hosted_random_credential() { try { return rtrim( strtr( base64_encode( random_bytes( 32 ) ), '+/', '-_' ), '=' ); } catch ( Exception $error ) { return new WP_Error( 'abtestkit_hosted_random_failed' ); } } /** * Validate the shared 43-character credential shape. * * @param mixed $value Candidate value. * @return string|WP_Error */ function abtestkit_hosted_validate_credential( $value ) { if ( ! is_string( $value ) || 43 !== strlen( $value ) || 1 !== preg_match( '/^[A-Za-z0-9_-]{43}$/D', $value ) ) { return new WP_Error( 'abtestkit_hosted_invalid_credential' ); } return $value; } /** * Canonicalise an HTTPS origin with no path, query or credentials. * * @param mixed $value Candidate origin. * @return string|WP_Error */ function abtestkit_hosted_canonical_origin( $value ) { if ( ! is_string( $value ) || '' === $value || strlen( $value ) > 255 ) { return new WP_Error( 'abtestkit_hosted_invalid_origin' ); } $parts = wp_parse_url( $value ); if ( ! is_array( $parts ) || 'https' !== strtolower( (string) ( $parts['scheme'] ?? '' ) ) || empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || isset( $parts['query'] ) || isset( $parts['fragment'] ) || ( isset( $parts['path'] ) && ! in_array( $parts['path'], array( '', '/' ), true ) ) ) { return new WP_Error( 'abtestkit_hosted_invalid_origin' ); } $host = strtolower( rtrim( (string) $parts['host'], '.' ) ); if ( '' === $host || 1 !== preg_match( '/^[a-z0-9.-]+$/D', $host ) ) { return new WP_Error( 'abtestkit_hosted_invalid_origin' ); } $port = isset( $parts['port'] ) ? (int) $parts['port'] : 0; if ( isset( $parts['port'] ) && ( $port < 1 || $port > 65535 ) ) { return new WP_Error( 'abtestkit_hosted_invalid_origin' ); } return 'https://' . $host . ( $port > 0 && 443 !== $port ? ':' . $port : '' ); } /** * Return this WordPress installation's HTTPS origin. * * @return string|WP_Error */ function abtestkit_hosted_site_origin() { $home = home_url( '/' ); $parts = wp_parse_url( $home ); if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return new WP_Error( 'abtestkit_hosted_invalid_origin' ); } $candidate = (string) ( $parts['scheme'] ?? '' ) . '://' . (string) $parts['host']; if ( isset( $parts['port'] ) && 443 !== (int) $parts['port'] ) { $candidate .= ':' . (int) $parts['port']; } /** * Filter the exact site origin used by the hosted connection protocol. * * Intended for controlled compatibility environments. Production values * must remain an HTTPS origin with no path or query. * * @param string $candidate Candidate origin. */ return abtestkit_hosted_canonical_origin( apply_filters( 'abtestkit_hosted_site_origin', $candidate ) ); } /** * Return the fixed service account page URL. * * @return string|WP_Error */ function abtestkit_hosted_service_account_url() { $url = apply_filters( 'abtestkit_hosted_service_account_url', 'https://www.abtestkit.io/abtestkit-account/' ); if ( ! is_string( $url ) || strlen( $url ) > 500 ) { return new WP_Error( 'abtestkit_hosted_invalid_service_url' ); } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || 'https' !== strtolower( (string) ( $parts['scheme'] ?? '' ) ) || empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || isset( $parts['query'] ) || isset( $parts['fragment'] ) || '/abtestkit-account/' !== (string) ( $parts['path'] ?? '' ) ) { return new WP_Error( 'abtestkit_hosted_invalid_service_url' ); } $origin = abtestkit_hosted_canonical_origin( (string) $parts['scheme'] . '://' . (string) $parts['host'] . ( isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '' ) ); if ( is_wp_error( $origin ) ) { return new WP_Error( 'abtestkit_hosted_invalid_service_url' ); } return $url; } /** * Return the fixed service REST base URL. * * @return string|WP_Error */ function abtestkit_hosted_service_api_base_url() { $url = apply_filters( 'abtestkit_hosted_service_api_base_url', 'https://www.abtestkit.io/wp-json/abtestkit-service/v1' ); if ( ! is_string( $url ) || strlen( $url ) > 500 ) { return new WP_Error( 'abtestkit_hosted_invalid_service_url' ); } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || 'https' !== strtolower( (string) ( $parts['scheme'] ?? '' ) ) || empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || isset( $parts['query'] ) || isset( $parts['fragment'] ) || '/wp-json/abtestkit-service/v1' !== untrailingslashit( (string) ( $parts['path'] ?? '' ) ) ) { return new WP_Error( 'abtestkit_hosted_invalid_service_url' ); } $origin = abtestkit_hosted_canonical_origin( (string) $parts['scheme'] . '://' . (string) $parts['host'] . ( isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '' ) ); if ( is_wp_error( $origin ) ) { return new WP_Error( 'abtestkit_hosted_invalid_service_url' ); } return untrailingslashit( $url ); } /** * Return and validate the fixed local callback URL. * * @param string $origin Expected site origin. * @return string|WP_Error */ function abtestkit_hosted_callback_url( $origin ) { $url = admin_url( 'admin.php?page=' . ABTESTKIT_HOSTED_CONNECTION_PAGE_SLUG ); $url = apply_filters( 'abtestkit_hosted_callback_url', $url ); if ( ! is_string( $url ) || strlen( $url ) > 500 ) { return new WP_Error( 'abtestkit_hosted_invalid_callback' ); } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || isset( $parts['fragment'] ) ) { return new WP_Error( 'abtestkit_hosted_invalid_callback' ); } $callback_origin = abtestkit_hosted_canonical_origin( (string) $parts['scheme'] . '://' . (string) $parts['host'] . ( isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '' ) ); if ( is_wp_error( $callback_origin ) || ! hash_equals( (string) $origin, $callback_origin ) ) { return new WP_Error( 'abtestkit_hosted_invalid_callback' ); } if ( empty( $parts['path'] ) || 1 !== preg_match( '#/wp-admin/admin\.php$#D', (string) $parts['path'] ) ) { return new WP_Error( 'abtestkit_hosted_invalid_callback' ); } $query = array(); parse_str( (string) ( $parts['query'] ?? '' ), $query ); if ( array( 'page' => ABTESTKIT_HOSTED_CONNECTION_PAGE_SLUG ) !== $query ) { return new WP_Error( 'abtestkit_hosted_invalid_callback' ); } return $url; } /** * Return one supported WordPress environment. * * @return string */ function abtestkit_hosted_environment() : string { $environment = function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production'; return in_array( $environment, array( 'production', 'staging', 'development', 'local' ), true ) ? $environment : 'production'; } /** * Store one exact option with autoload disabled. * * @param string $name Option name. * @param array $value Option value. * @return bool */ function abtestkit_hosted_add_private_option( string $name, array $value ) : bool { return add_option( $name, $value, '', 'no' ); } /** Claim the local handoff across PHP requests and browser tabs. */ function abtestkit_hosted_claim_connection() { $lock = array( 'expires_at' => time() + 40, 'token' => wp_generate_uuid4() ); if ( abtestkit_hosted_add_private_option( ABTESTKIT_HOSTED_CONNECTION_LOCK_OPTION, $lock ) ) { return $lock; } $old = get_option( ABTESTKIT_HOSTED_CONNECTION_LOCK_OPTION, null ); if ( ! is_array( $old ) || (int) ( $old['expires_at'] ?? 0 ) >= time() ) { return false; } // Remove only the lease observed above. A concurrent claimant must not be deleted. abtestkit_hosted_release_connection_claim( $old ); return abtestkit_hosted_add_private_option( ABTESTKIT_HOSTED_CONNECTION_LOCK_OPTION, $lock ) ? $lock : false; } /** Release only the caller's claim, including when a stale claim was replaced. */ function abtestkit_hosted_release_connection_claim( array $lock ) : void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- The Options API cannot atomically delete only this caller's expiring claim. $wpdb->delete( $wpdb->options, array( 'option_name' => ABTESTKIT_HOSTED_CONNECTION_LOCK_OPTION, 'option_value' => maybe_serialize( $lock ), ), array( '%s', '%s' ) ); wp_cache_delete( ABTESTKIT_HOSTED_CONNECTION_LOCK_OPTION, 'options' ); wp_cache_delete( 'notoptions', 'options' ); } /** Retain a safe failure category and bound automatic recovery. */ function abtestkit_hosted_defer_connection( array $attempt, string $category ) : void { $current = get_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, null ); if ( ! is_array( $current ) || ! hash_equals( (string) ( $current['verifier'] ?? '' ), (string) ( $attempt['verifier'] ?? '' ) ) ) { return; } $attempt = $current; $failures = min( 3, max( 0, (int) ( $attempt['retry_count'] ?? 0 ) ) + 1 ); $attempt['retry_count'] = $failures; $attempt['retry_after'] = time() + min( 60, 10 * ( 1 << ( $failures - 1 ) ) ); $attempt['last_failure'] = $category; update_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, $attempt, false ); } /** * Read a structurally valid local connection. * * @return array|null */ function abtestkit_hosted_get_connection() : ?array { $value = get_option( ABTESTKIT_HOSTED_CONNECTION_OPTION, null ); if ( ! is_array( $value ) ) { return null; } $site_ref = isset( $value['site_ref'] ) ? (string) $value['site_ref'] : ''; $credential = abtestkit_hosted_validate_credential( $value['credential'] ?? null ); $pending_credential = isset( $value['pending_credential'] ) ? abtestkit_hosted_validate_credential( $value['pending_credential'] ) : null; $origin = abtestkit_hosted_canonical_origin( $value['canonical_origin'] ?? null ); $environment = isset( $value['environment'] ) ? (string) $value['environment'] : ''; $current_origin = abtestkit_hosted_site_origin(); $current_environment = abtestkit_hosted_environment(); if ( 1 !== preg_match( '/^[a-f0-9]{32}$/D', $site_ref ) || is_wp_error( $credential ) || is_wp_error( $pending_credential ) || is_wp_error( $origin ) || is_wp_error( $current_origin ) || ! in_array( $environment, array( 'production', 'staging', 'development', 'local' ), true ) || ! hash_equals( $origin, $current_origin ) || ! hash_equals( $environment, $current_environment ) ) { return null; } return $value; } /** * Validate a service-issued UTC agreement timestamp without making a request. * * @param mixed $value Candidate timestamp. * @return bool */ function abtestkit_hosted_valid_agreement_time( $value ) : bool { if ( ! is_string( $value ) || 1 !== preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value ) ) { return false; } $timestamp = strtotime( $value . ' UTC' ); return false !== $timestamp && $timestamp > 0 && $timestamp <= time() + 5 * MINUTE_IN_SECONDS; } /** * Return whether the cached account agreement permits create/launch. * * @param bool $refresh_status Whether to perform one bounded authenticated * status refresh before reading the cache. * @return bool */ function abtestkit_connected_platform_can_create_or_launch( bool $refresh_status = false ) : bool { if ( $refresh_status ) { abtestkit_hosted_refresh_connection_status(); } $connection = abtestkit_hosted_get_connection(); return is_array( $connection ) && isset( $connection['platform_terms_version'] ) && hash_equals( ABTESTKIT_PLATFORM_TERMS_VERSION, (string) $connection['platform_terms_version'] ) && abtestkit_hosted_valid_agreement_time( $connection['platform_terms_accepted_at'] ?? null ); } /** * Stable server-side error for every protected creation/launch path. * * @return WP_Error */ function abtestkit_connected_platform_required_error() : WP_Error { return new WP_Error( 'abtestkit_connected_account_required', __( 'Connect your abtestkit account to continue', 'abtestkit' ), array( 'status' => 403, 'connection_url' => abtestkit_hosted_admin_url(), ) ); } /** * Apply a strictly validated status response to the local agreement cache. * * @param array $connection Stored connection. * @param array $data Status response data. * @return array|null Updated connection, or null if invalid. */ function abtestkit_hosted_connection_with_status_agreement( array $connection, array $data ) : ?array { if ( ! array_key_exists( 'platform_terms_version', $data ) || ! array_key_exists( 'platform_terms_accepted_at', $data ) ) { return null; } $version = (string) $data['platform_terms_version']; $accepted_at = $data['platform_terms_accepted_at']; if ( '' === $version && null === $accepted_at ) { $connection['platform_terms_version'] = ''; $connection['platform_terms_accepted_at'] = null; return $connection; } if ( ! hash_equals( ABTESTKIT_PLATFORM_TERMS_VERSION, $version ) || ! abtestkit_hosted_valid_agreement_time( $accepted_at ) ) { return null; } $connection['platform_terms_version'] = $version; $connection['platform_terms_accepted_at'] = $accepted_at; return $connection; } /** * Send one bounded JSON request to the fixed service API. * * @param string $method HTTP method. * @param string $path Exact API-relative path. * @param array $body Request body. * @param string $credential Optional Bearer credential. * @param int $timeout Request timeout in seconds, capped at ten. * @return array{status:int,data:array}|WP_Error */ function abtestkit_hosted_service_request( string $method, string $path, array $body, string $credential = '', int $timeout = 5 ) { $method = strtoupper( $method ); $allowed = ( 'POST' === $method && ( '/connections/exchange' === $path || '/connections/resume' === $path || '/connections/status' === $path || 1 === preg_match( '#^/sites/[a-f0-9]{32}/(?:revoke|rotate)$#D', $path ) ) ) || ( 'GET' === $method && 1 === preg_match( '#^/sites/[a-f0-9]{32}/status$#D', $path ) ) || ( 'POST' === $method && 1 === preg_match( '#^/sites/[a-f0-9]{32}/events$#D', $path ) ) || ( 'POST' === $method && 1 === preg_match( '#^/sites/[a-f0-9]{32}/heartbeat$#D', $path ) ) || ( 'POST' === $method && 1 === preg_match( '#^/sites/[a-f0-9]{32}/support-reports$#D', $path ) ) || ( 'PUT' === $method && 1 === preg_match( '#^/sites/[a-f0-9]{32}/environment$#D', $path ) ) || ( 'PUT' === $method && 1 === preg_match( '#^/sites/[a-f0-9]{32}/experiments/test_[A-Za-z0-9_-]{43}/registration$#D', $path ) ) || ( in_array( $method, array( 'PUT', 'DELETE' ), true ) && 1 === preg_match( '#^/sites/[a-f0-9]{32}/tests/[A-Za-z0-9_-]{16,64}$#D', $path ) ); $base = abtestkit_hosted_service_api_base_url(); if ( ! $allowed || is_wp_error( $base ) ) { return new WP_Error( 'abtestkit_hosted_service_unavailable' ); } $headers = array(); if ( 'GET' !== $method && 'DELETE' !== $method ) { $headers['Content-Type'] = 'application/json'; } if ( '' !== $credential ) { $credential = abtestkit_hosted_validate_credential( $credential ); if ( is_wp_error( $credential ) ) { return new WP_Error( 'abtestkit_hosted_service_unavailable' ); } $headers['Authorization'] = 'Bearer ' . $credential; } $request_args = array( 'method' => $method, 'timeout' => max( 1, min( 10, $timeout ) ), 'redirection' => 0, 'reject_unsafe_urls' => true, 'limit_response_size' => 8192, 'headers' => $headers, ); if ( 'GET' !== $method && 'DELETE' !== $method ) { $request_args['body'] = wp_json_encode( empty( $body ) ? (object) array() : $body ); $request_args['data_format'] = 'body'; } $response = wp_remote_request( $base . $path, $request_args ); if ( is_wp_error( $response ) ) { return new WP_Error( 'abtestkit_hosted_service_unavailable' ); } $content_type = strtolower( (string) wp_remote_retrieve_header( $response, 'content-type' ) ); if ( 0 !== strpos( $content_type, 'application/json' ) ) { return new WP_Error( 'abtestkit_hosted_service_bad_response' ); } $response_body = trim( (string) wp_remote_retrieve_body( $response ) ); $decoded = json_decode( $response_body, true ); if ( ! is_array( $decoded ) || '{' !== substr( $response_body, 0, 1 ) ) { return new WP_Error( 'abtestkit_hosted_service_bad_response' ); } return array( 'status' => (int) wp_remote_retrieve_response_code( $response ), 'data' => $decoded, ); } /** Submit one explicit support report through the authenticated site connection. */ function abtestkit_hosted_submit_support_report( string $test_id, string $message, string $source ) { $connection = abtestkit_hosted_get_connection(); if ( ! is_array( $connection ) || ! abtestkit_connected_platform_can_create_or_launch() ) { return new WP_Error( 'abtestkit_hosted_support_account_required' ); } $allowed_sources = array( 'performance_page', 'test_health_card', 'version_a_performance_tile', 'version_b_performance_tile' ); if ( ! in_array( $source, $allowed_sources, true ) ) { $source = 'performance_page'; } $test_record = function_exists( 'abtestkit_connected_test_ref_record' ) ? abtestkit_connected_test_ref_record( $test_id ) : new WP_Error( 'abtestkit_hosted_support_test_unavailable' ); if ( is_wp_error( $test_record ) ) { return $test_record; } $report_ref = wp_generate_uuid4(); $response = abtestkit_hosted_service_request( 'POST', '/sites/' . (string) $connection['site_ref'] . '/support-reports', array( 'schema_version' => 1, 'report_ref' => $report_ref, 'test_ref' => (string) $test_record['test_ref'], 'source' => $source, 'message' => substr( sanitize_textarea_field( $message ), 0, 1500 ), ), (string) $connection['credential'], 3 ); if ( is_wp_error( $response ) || ! in_array( (int) $response['status'], array( 200, 201 ), true ) || empty( $response['data']['accepted'] ) || ! isset( $response['data']['report_ref'] ) || ! hash_equals( $report_ref, (string) $response['data']['report_ref'] ) ) { return new WP_Error( 'abtestkit_hosted_support_unavailable' ); } return array( 'report_ref' => $report_ref, 'test_ref' => (string) $test_record['test_ref'], ); } /** * Replace one connection only if it is still the state observed by the caller. * * @param array $expected Connection used for a remote request. * @param array $replacement Validated replacement connection. * @return bool Whether the expected connection was replaced. */ function abtestkit_hosted_replace_local_connection( array $expected, array $replacement ) : bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- The Options API has no compare-and-swap operation for concurrent remote responses. $updated = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET option_value = %s WHERE option_name = %s AND BINARY option_value = %s', $wpdb->options, maybe_serialize( $replacement ), ABTESTKIT_HOSTED_CONNECTION_OPTION, maybe_serialize( $expected ) ) ); wp_cache_delete( ABTESTKIT_HOSTED_CONNECTION_OPTION, 'options' ); wp_cache_delete( 'notoptions', 'options' ); if ( 1 !== $updated ) { return false; } return true; } /** * Remove only connected-service state after confirmed remote revocation. * * A response-driven caller supplies the exact connection used for its request, * so a delayed response cannot remove a newer connection. * * @param array|null $expected Connection used for the request, or null for an explicit local removal. * @return bool Whether local connection state was removed. */ function abtestkit_hosted_remove_local_connection( ?array $expected = null ) : bool { $lock = false; if ( is_array( $expected ) ) { $lock = abtestkit_hosted_claim_connection(); if ( false === $lock ) { return false; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- The Options API cannot atomically delete only the connection used by this caller. $deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name = %s AND BINARY option_value = %s', $wpdb->options, ABTESTKIT_HOSTED_CONNECTION_OPTION, maybe_serialize( $expected ) ) ); wp_cache_delete( ABTESTKIT_HOSTED_CONNECTION_OPTION, 'options' ); wp_cache_delete( 'notoptions', 'options' ); if ( 1 !== $deleted ) { abtestkit_hosted_release_connection_claim( $lock ); return false; } } else { delete_option( ABTESTKIT_HOSTED_CONNECTION_OPTION ); } if ( class_exists( 'ABTestKit_Connected_Outbox' ) ) { ABTestKit_Connected_Outbox::clear(); } if ( function_exists( 'abtestkit_connected_deactivation_reset' ) ) { abtestkit_connected_deactivation_reset(); } if ( function_exists( 'abtestkit_connected_registration_clear' ) ) { abtestkit_connected_registration_clear(); } if ( function_exists( 'abtestkit_connected_transport_remove_local_state' ) ) { abtestkit_connected_transport_remove_local_state(); } if ( function_exists( 'abtestkit_hosted_sync_remove_local_state' ) ) { abtestkit_hosted_sync_remove_local_state(); } if ( is_array( $lock ) ) { abtestkit_hosted_release_connection_claim( $lock ); } return true; } /** * Revalidate a local connection once per request without failing closed on an * unavailable or malformed service response. * * @return bool Whether the service confirmed that the connection was removed. */ function abtestkit_hosted_refresh_connection_status() : bool { static $checked = false; static $removed = false; if ( $checked ) { return $removed; } $checked = true; $connection = abtestkit_hosted_get_connection(); if ( ! is_array( $connection ) ) { return false; } if ( ! empty( $connection['pending_credential'] ) ) { if ( ! abtestkit_hosted_rotate_connection_credential( $connection ) ) { return false; } $connection = abtestkit_hosted_get_connection(); if ( ! is_array( $connection ) ) { return false; } } $response = abtestkit_hosted_service_request( 'POST', '/connections/status', array( 'site_ref' => (string) $connection['site_ref'], 'credential' => (string) $connection['credential'], ), '', 2 ); if ( is_wp_error( $response ) ) { return false; } $data = is_array( $response['data'] ?? null ) ? $response['data'] : array(); if ( 200 === (int) $response['status'] && array( 'connected' => false ) === $data ) { if ( abtestkit_hosted_remove_local_connection( $connection ) ) { $removed = true; } return $removed; } if ( 200 === (int) $response['status'] && true === ( $data['connected'] ?? null ) ) { $response_keys = array_keys( $data ); sort( $response_keys ); if ( array( 'connected', 'platform_terms_accepted_at', 'platform_terms_version' ) !== $response_keys ) { return false; } $updated = abtestkit_hosted_connection_with_status_agreement( $connection, $data ); if ( is_array( $updated ) && $updated !== $connection ) { abtestkit_hosted_replace_local_connection( $connection, $updated ); } } return false; } /** Revalidate only while an administrator is using an abtestkit screen. */ function abtestkit_hosted_maybe_refresh_admin_connection() : void { if ( ! is_admin() || ! current_user_can( 'manage_options' ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen routing. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( in_array( $page, array( 'abtestkit-dashboard', 'abtestkit-pt-wizard', 'abtestkit-test-performance' ), true ) ) { abtestkit_hosted_refresh_connection_status(); } } add_action( 'admin_init', 'abtestkit_hosted_maybe_refresh_admin_connection', 20 ); /** * Recheck one connected site from the open Account screen. * * This is called once when the administrator returns to the browser tab. It * deliberately does not create a polling loop. * * @return void */ function abtestkit_hosted_check_connection_status() : void { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'connected' => false ), 403 ); } check_admin_referer( 'abtestkit_hosted_check_connection_status' ); $removed = abtestkit_hosted_refresh_connection_status(); $connection = abtestkit_hosted_get_connection(); wp_send_json_success( array( 'connected' => is_array( $connection ), 'removed' => $removed, ) ); } add_action( 'admin_post_abtestkit_hosted_check_connection_status', 'abtestkit_hosted_check_connection_status' ); /** * Route a hosted-account reconnect link to the local Account screen. * * The hosted service knows only the canonical site origin. WordPress handles * this front-end URL, then requires a local administrator session before the * normal nonce- and proof-bound connection flow can begin. * * @return void */ function abtestkit_hosted_route_reconnect() : void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only navigation into the local connection UI. $reconnect = isset( $_GET['abtestkit_reconnect'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_reconnect'] ) ) : ''; if ( '1' !== $reconnect ) { return; } if ( ! is_user_logged_in() ) { auth_redirect(); } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage this connection.', 'abtestkit' ), '', array( 'response' => 403 ) ); } wp_safe_redirect( abtestkit_hosted_admin_url() ); exit; } add_action( 'template_redirect', 'abtestkit_hosted_route_reconnect', 1 ); /** * Build the local connection page URL. * * @param string $message Stable message key. * @param array $extra Additional safe query values. * @return string */ function abtestkit_hosted_admin_url( string $message = '', array $extra = array() ) : string { $args = array_merge( array( 'page' => ABTESTKIT_HOSTED_CONNECTION_PAGE_SLUG ), $extra ); if ( '' !== $message ) { $args['abtestkit_hosted_message'] = sanitize_key( $message ); } return add_query_arg( $args, admin_url( 'admin.php' ) ); } /** * Redirect to the local connection page. * * @param string $message Stable message key. * @param array $extra Additional safe query values. * @return void */ function abtestkit_hosted_redirect( string $message, array $extra = array() ) : void { wp_safe_redirect( abtestkit_hosted_admin_url( $message, $extra ) ); exit; } /** * Return the fixed hosted preference page used after connection setup. * * @return string|WP_Error */ function abtestkit_hosted_preferences_url() { return abtestkit_hosted_service_account_url(); } /** * Begin the administrator-approved browser handoff. * * @return void */ function abtestkit_hosted_start_connection() : void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage this connection.', 'abtestkit' ) ); } check_admin_referer( 'abtestkit_hosted_start_connection' ); if ( null !== abtestkit_hosted_get_connection() ) { abtestkit_hosted_redirect( 'already-connected' ); } $existing_attempt = get_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, null ); if ( is_array( $existing_attempt ) && (int) ( $existing_attempt['expires_at'] ?? 0 ) >= time() ) { abtestkit_hosted_redirect( 'connection-busy' ); } $origin = abtestkit_hosted_site_origin(); $account_url = abtestkit_hosted_service_account_url(); if ( is_wp_error( $origin ) || is_wp_error( $account_url ) ) { abtestkit_hosted_redirect( 'https-required' ); } $callback_url = abtestkit_hosted_callback_url( $origin ); if ( is_wp_error( $callback_url ) ) { abtestkit_hosted_redirect( 'https-required' ); } $verifier = abtestkit_hosted_random_credential(); $state = abtestkit_hosted_random_credential(); if ( is_wp_error( $verifier ) || is_wp_error( $state ) ) { abtestkit_hosted_redirect( 'start-failed' ); } $environment = abtestkit_hosted_environment(); $attempt = array( 'verifier' => $verifier, 'state_hash' => hash( 'sha256', $state ), 'canonical_origin' => $origin, 'environment' => $environment, 'phase' => 'pending', 'created_at' => time(), 'expires_at' => time() + 20 * MINUTE_IN_SECONDS, ); $lock = abtestkit_hosted_claim_connection(); if ( false === $lock ) { abtestkit_hosted_redirect( 'connection-busy' ); } $existing_attempt = get_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, null ); if ( is_array( $existing_attempt ) && (int) ( $existing_attempt['expires_at'] ?? 0 ) >= time() ) { abtestkit_hosted_release_connection_claim( $lock ); abtestkit_hosted_redirect( 'connection-busy' ); } if ( is_array( abtestkit_hosted_get_connection() ) ) { abtestkit_hosted_release_connection_claim( $lock ); abtestkit_hosted_redirect( 'already-connected' ); } delete_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION ); if ( ! abtestkit_hosted_add_private_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, $attempt ) ) { abtestkit_hosted_release_connection_claim( $lock ); abtestkit_hosted_redirect( 'start-failed' ); } abtestkit_hosted_release_connection_claim( $lock ); $challenge = rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); $label = sanitize_text_field( get_bloginfo( 'name' ) ); if ( '' === $label ) { $label = $origin; } $label = function_exists( 'mb_substr' ) ? mb_substr( $label, 0, 160 ) : substr( $label, 0, 160 ); $handoff_url = add_query_arg( array( 'abtk_connect' => '1', 'canonical_origin' => $origin, 'environment' => $environment, 'display_label' => $label, 'proof_challenge' => $challenge, 'callback_url' => $callback_url, 'state' => $state, 'expires_at' => (string) $attempt['expires_at'], ), $account_url ); $handoff_url = esc_url_raw( $handoff_url ); $handoff_host = wp_parse_url( $handoff_url, PHP_URL_HOST ); if ( ! is_string( $handoff_host ) || '' === $handoff_host ) { abtestkit_hosted_redirect( 'start-failed' ); } $allow_service_host = static function ( array $hosts ) use ( $handoff_host ) : array { $hosts[] = $handoff_host; return array_values( array_unique( $hosts ) ); }; add_filter( 'allowed_redirect_hosts', $allow_service_host ); $redirected = wp_safe_redirect( $handoff_url ); remove_filter( 'allowed_redirect_hosts', $allow_service_host ); if ( ! $redirected ) { abtestkit_hosted_redirect( 'start-failed' ); } exit; } add_action( 'admin_post_abtestkit_hosted_start_connection', 'abtestkit_hosted_start_connection' ); /** * Resume a connection whose email was verified in another browser or device. * * @return void */ function abtestkit_hosted_resume_connection() : void { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'pending' => false ), 403 ); } check_admin_referer( 'abtestkit_hosted_resume_connection' ); if ( is_array( abtestkit_hosted_get_connection() ) ) { wp_send_json_success( array( 'connected' => true ) ); } $attempt = get_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, null ); $verifier = is_array( $attempt ) ? abtestkit_hosted_validate_credential( $attempt['verifier'] ?? null ) : new WP_Error(); $attempt_origin = is_array( $attempt ) ? abtestkit_hosted_canonical_origin( $attempt['canonical_origin'] ?? null ) : new WP_Error(); $attempt_environment = is_array( $attempt ) ? (string) ( $attempt['environment'] ?? '' ) : ''; $attempt_phase = is_array( $attempt ) ? (string) ( $attempt['phase'] ?? '' ) : ''; $expires_at = is_array( $attempt ) ? (int) ( $attempt['expires_at'] ?? 0 ) : 0; $current_origin = abtestkit_hosted_site_origin(); $current_environment = abtestkit_hosted_environment(); if ( ! is_array( $attempt ) || is_wp_error( $verifier ) || is_wp_error( $attempt_origin ) || is_wp_error( $current_origin ) || ! in_array( $attempt_environment, array( 'production', 'staging', 'development', 'local' ), true ) || ! in_array( $attempt_phase, array( 'pending', 'approved' ), true ) || ! hash_equals( $attempt_origin, $current_origin ) || ! hash_equals( $attempt_environment, $current_environment ) || $expires_at < time() ) { delete_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION ); wp_send_json_error( array( 'pending' => false ), 410 ); } $lock = abtestkit_hosted_claim_connection(); if ( false === $lock ) { wp_send_json_error( array( 'pending' => true, 'retry_after' => 5 ), 202 ); } if ( is_array( abtestkit_hosted_get_connection() ) ) { abtestkit_hosted_release_connection_claim( $lock ); wp_send_json_success( array( 'connected' => true ) ); } $latest_attempt = get_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, null ); if ( ! is_array( $latest_attempt ) || ! hash_equals( (string) ( $latest_attempt['verifier'] ?? '' ), $verifier ) ) { abtestkit_hosted_release_connection_claim( $lock ); wp_send_json_error( array( 'pending' => false ), 410 ); } $attempt = $latest_attempt; if ( (int) ( $attempt['retry_count'] ?? 0 ) >= 3 ) { abtestkit_hosted_release_connection_claim( $lock ); wp_send_json_error( array( 'pending' => true, 'retry_stopped' => true ), 409 ); } $retry_after = (int) ( $attempt['retry_after'] ?? 0 ) - time(); if ( $retry_after > 0 ) { abtestkit_hosted_release_connection_claim( $lock ); wp_send_json_error( array( 'pending' => true, 'retry_after' => $retry_after ), 202 ); } $next_poll = (int) ( $attempt['next_poll_at'] ?? 0 ) - time(); if ( $next_poll > 0 ) { abtestkit_hosted_release_connection_claim( $lock ); wp_send_json_error( array( 'pending' => true, 'retry_after' => $next_poll ), 202 ); } $response = abtestkit_hosted_service_request( 'POST', '/connections/resume', array( 'canonical_origin' => $attempt_origin, 'environment' => $attempt_environment, 'proof_verifier' => $verifier, ) ); if ( is_wp_error( $response ) ) { abtestkit_hosted_defer_connection( $attempt, 'abtestkit_hosted_service_bad_response' === $response->get_error_code() ? 'resume_invalid_response' : 'resume_transport' ); abtestkit_hosted_release_connection_claim( $lock ); wp_send_json_error( array( 'pending' => true, 'retry_after' => 10, 'retrying' => true ), 202 ); } $response_status = (int) ( $response['status'] ?? 0 ); if ( 410 === $response_status ) { delete_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION ); abtestkit_hosted_release_connection_claim( $lock ); wp_send_json_error( array( 'pending' => false ), 410 ); } if ( 202 === $response_status ) { $attempt['next_poll_at'] = time() + 10; update_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, $attempt, false ); abtestkit_hosted_release_connection_claim( $lock ); wp_send_json_error( array( 'pending' => true, 'retry_after' => 10 ), 202 ); } $data = is_array( $response['data'] ?? null ) ? $response['data'] : array(); if ( 201 !== (int) ( $response['status'] ?? 0 ) || array( 'code' ) !== array_keys( $data ) ) { abtestkit_hosted_defer_connection( $attempt, 201 === $response_status ? 'resume_invalid_response' : 'resume_http_' . $response_status ); abtestkit_hosted_release_connection_claim( $lock ); wp_send_json_error( array( 'pending' => true, 'retry_after' => 10, 'retrying' => true ), 202 ); } $code = sanitize_text_field( (string) $data['code'] ); if ( is_wp_error( abtestkit_hosted_validate_credential( $code ) ) ) { abtestkit_hosted_defer_connection( $attempt, 'resume_invalid_response' ); abtestkit_hosted_release_connection_claim( $lock ); wp_send_json_error( array( 'pending' => true, 'retry_after' => 10, 'retrying' => true ), 202 ); } $attempt['phase'] = 'approved'; update_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, $attempt, false ); $result = abtestkit_hosted_exchange_code( $attempt, $code ); if ( 'connected' !== $result && 'cancelled' !== $result ) { abtestkit_hosted_defer_connection( $attempt, $result ); } abtestkit_hosted_release_connection_claim( $lock ); if ( 'connected' === $result ) { wp_send_json_success( array( 'connected' => true ) ); } if ( 'cancelled' === $result ) { wp_send_json_error( array( 'pending' => false ), 410 ); } wp_send_json_error( array( 'pending' => true, 'retry_after' => 10, 'retrying' => true ), 202 ); } add_action( 'admin_post_abtestkit_hosted_resume_connection', 'abtestkit_hosted_resume_connection' ); /** Let an administrator deliberately restart bounded recovery. */ function abtestkit_hosted_retry_connection() : void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage this connection.', 'abtestkit' ) ); } check_admin_referer( 'abtestkit_hosted_retry_connection' ); $lock = abtestkit_hosted_claim_connection(); if ( false === $lock ) { abtestkit_hosted_redirect( 'connection-busy' ); } $attempt = get_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, null ); if ( is_array( $attempt ) && (int) ( $attempt['expires_at'] ?? 0 ) >= time() ) { $attempt['retry_count'] = 0; $attempt['retry_after'] = 0; $attempt['next_poll_at'] = 0; unset( $attempt['last_failure'] ); update_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, $attempt, false ); } abtestkit_hosted_release_connection_claim( $lock ); abtestkit_hosted_redirect( '' ); } add_action( 'admin_post_abtestkit_hosted_retry_connection', 'abtestkit_hosted_retry_connection' ); /** End the local handoff without touching existing tests. */ function abtestkit_hosted_cancel_connection() : void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage this connection.', 'abtestkit' ) ); } check_admin_referer( 'abtestkit_hosted_cancel_connection' ); $lock = abtestkit_hosted_claim_connection(); if ( false === $lock ) { abtestkit_hosted_redirect( 'connection-busy' ); } delete_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION ); abtestkit_hosted_release_connection_claim( $lock ); abtestkit_hosted_redirect( 'connection-ended' ); } add_action( 'admin_post_abtestkit_hosted_cancel_connection', 'abtestkit_hosted_cancel_connection' ); /** * Exchange a one-time code for the site credential. * * @return void */ function abtestkit_hosted_complete_connection() : void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage this connection.', 'abtestkit' ) ); } check_admin_referer( 'abtestkit_hosted_complete_connection' ); $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : ''; $state = isset( $_POST['state'] ) ? sanitize_text_field( wp_unslash( $_POST['state'] ) ) : ''; $return_to_preferences = isset( $_POST['return_to_preferences'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['return_to_preferences'] ) ); if ( is_wp_error( abtestkit_hosted_validate_credential( $code ) ) || is_wp_error( abtestkit_hosted_validate_credential( $state ) ) ) { abtestkit_hosted_redirect( 'callback-invalid' ); } if ( null !== abtestkit_hosted_get_connection() ) { delete_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION ); abtestkit_hosted_redirect( 'already-connected' ); } $attempt = get_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, null ); $attempt_verifier = is_array( $attempt ) ? abtestkit_hosted_validate_credential( $attempt['verifier'] ?? null ) : new WP_Error(); $attempt_origin = is_array( $attempt ) ? abtestkit_hosted_canonical_origin( $attempt['canonical_origin'] ?? null ) : new WP_Error(); $attempt_environment = is_array( $attempt ) && isset( $attempt['environment'] ) ? (string) $attempt['environment'] : ''; $attempt_phase = is_array( $attempt ) && isset( $attempt['phase'] ) ? (string) $attempt['phase'] : ''; $attempt_state_hash = is_array( $attempt ) && isset( $attempt['state_hash'] ) ? (string) $attempt['state_hash'] : ''; $attempt_expires_at = is_array( $attempt ) && isset( $attempt['expires_at'] ) ? (int) $attempt['expires_at'] : 0; $current_origin = abtestkit_hosted_site_origin(); $current_environment = abtestkit_hosted_environment(); if ( ! is_array( $attempt ) || is_wp_error( $attempt_verifier ) || is_wp_error( $attempt_origin ) || is_wp_error( $current_origin ) || ! in_array( $attempt_environment, array( 'production', 'staging', 'development', 'local' ), true ) || ! in_array( $attempt_phase, array( 'pending', 'approved' ), true ) || ! hash_equals( $attempt_origin, $current_origin ) || ! hash_equals( $attempt_environment, $current_environment ) || 1 !== preg_match( '/^[a-f0-9]{64}$/D', $attempt_state_hash ) || $attempt_expires_at < 1 || time() > $attempt_expires_at || ! hash_equals( $attempt_state_hash, hash( 'sha256', $state ) ) ) { abtestkit_hosted_redirect( 'callback-expired' ); } $lock = abtestkit_hosted_claim_connection(); if ( false === $lock ) { abtestkit_hosted_redirect( 'connection-busy' ); } $latest_attempt = get_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, null ); if ( ! is_array( $latest_attempt ) || ! hash_equals( (string) ( $latest_attempt['state_hash'] ?? '' ), $attempt_state_hash ) ) { abtestkit_hosted_release_connection_claim( $lock ); abtestkit_hosted_redirect( 'callback-expired' ); } $attempt = $latest_attempt; if ( (int) ( $attempt['retry_count'] ?? 0 ) >= 3 || (int) ( $attempt['retry_after'] ?? 0 ) > time() ) { abtestkit_hosted_release_connection_claim( $lock ); abtestkit_hosted_redirect( 'connection-retrying' ); } $attempt['phase'] = 'approved'; update_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, $attempt, false ); $result = abtestkit_hosted_exchange_code( $attempt, $code ); if ( 'connected' !== $result && 'cancelled' !== $result ) { abtestkit_hosted_defer_connection( $attempt, $result ); } abtestkit_hosted_release_connection_claim( $lock ); if ( 'connected' === $result ) { abtestkit_hosted_redirect( 'connected', $return_to_preferences ? array( 'abtestkit_hosted_return' => 'preferences' ) : array() ); } abtestkit_hosted_redirect( 'cancelled' === $result ? 'connection-ended' : 'connection-retrying' ); } add_action( 'admin_post_abtestkit_hosted_complete_connection', 'abtestkit_hosted_complete_connection' ); /** Exchange one proof-bound code while the caller holds the local claim. */ function abtestkit_hosted_exchange_code( array $attempt, string $code ) : string { $attempt_origin = (string) $attempt['canonical_origin']; $attempt_environment = (string) $attempt['environment']; $attempt_verifier = (string) $attempt['verifier']; $response = abtestkit_hosted_service_request( 'POST', '/connections/exchange', array( 'code' => $code, 'canonical_origin' => $attempt_origin, 'environment' => $attempt_environment, 'proof_verifier' => $attempt_verifier, ), '', 10 ); if ( is_wp_error( $response ) ) { return 'abtestkit_hosted_service_bad_response' === $response->get_error_code() ? 'invalid_response' : 'transport'; } $data = $response['data']; $response_keys = array_keys( $data ); sort( $response_keys ); if ( 201 !== $response['status'] || array( 'credential', 'environment', 'platform_terms_accepted_at', 'platform_terms_version', 'site_ref' ) !== $response_keys || 1 !== preg_match( '/^[a-f0-9]{32}$/D', (string) ( $data['site_ref'] ?? '' ) ) || is_wp_error( abtestkit_hosted_validate_credential( $data['credential'] ?? null ) ) || ! hash_equals( $attempt_environment, (string) ( $data['environment'] ?? '' ) ) || ! hash_equals( ABTESTKIT_PLATFORM_TERMS_VERSION, (string) ( $data['platform_terms_version'] ?? '' ) ) || ! abtestkit_hosted_valid_agreement_time( $data['platform_terms_accepted_at'] ?? null ) ) { return 201 !== $response['status'] ? 'http_' . (int) $response['status'] : 'invalid_response'; } $current_attempt = get_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, null ); if ( ! is_array( $current_attempt ) || ! hash_equals( (string) ( $current_attempt['verifier'] ?? '' ), $attempt_verifier ) ) { abtestkit_hosted_service_request( 'POST', '/sites/' . (string) $data['site_ref'] . '/revoke', array(), (string) $data['credential'] ); return 'cancelled'; } $installation = ABTestKit_Connected_Installation::get_or_create(); if ( is_wp_error( $installation ) ) { abtestkit_hosted_service_request( 'POST', '/sites/' . (string) $data['site_ref'] . '/revoke', array(), (string) $data['credential'] ); return 'local_setup'; } $connection = array( 'site_ref' => (string) $data['site_ref'], 'credential' => (string) $data['credential'], 'environment' => (string) $data['environment'], 'canonical_origin' => $attempt_origin, 'connected_at' => time(), 'platform_terms_version' => (string) $data['platform_terms_version'], 'platform_terms_accepted_at' => (string) $data['platform_terms_accepted_at'], ); if ( ! abtestkit_hosted_add_private_option( ABTESTKIT_HOSTED_CONNECTION_OPTION, $connection ) ) { abtestkit_hosted_service_request( 'POST', '/sites/' . $connection['site_ref'] . '/revoke', array(), $connection['credential'] ); return 'local_store'; } delete_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION ); if ( function_exists( 'abtestkit_hosted_sync_schedule' ) ) { abtestkit_hosted_sync_schedule(); } if ( function_exists( 'abtestkit_connected_transport_schedule' ) ) { abtestkit_connected_transport_schedule(); } return 'connected'; } /** * Remove the local connection, with a best-effort remote revocation first. * * @return void */ function abtestkit_hosted_disconnect() : void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage this connection.', 'abtestkit' ) ); } check_admin_referer( 'abtestkit_hosted_disconnect' ); $connection = abtestkit_hosted_get_connection(); $revoked = false; if ( is_array( $connection ) ) { $response = abtestkit_hosted_service_request( 'POST', '/sites/' . (string) $connection['site_ref'] . '/revoke', array(), (string) $connection['credential'] ); $revoked = ! is_wp_error( $response ) && 200 === $response['status'] && array( 'revoked' => true ) === $response['data']; } delete_option( ABTESTKIT_HOSTED_CONNECTION_OPTION ); delete_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION ); ABTestKit_Connected_Outbox::clear(); abtestkit_connected_deactivation_reset(); abtestkit_connected_registration_clear(); if ( function_exists( 'abtestkit_connected_transport_remove_local_state' ) ) { abtestkit_connected_transport_remove_local_state(); } if ( function_exists( 'abtestkit_hosted_sync_remove_local_state' ) ) { abtestkit_hosted_sync_remove_local_state(); } abtestkit_hosted_redirect( $revoked ? 'disconnected' : 'disconnected-locally' ); } add_action( 'admin_post_abtestkit_hosted_disconnect', 'abtestkit_hosted_disconnect' ); /** * Promote a pending credential after proving the service accepts it. * * @param array $connection Connection state. * @return bool */ function abtestkit_hosted_promote_pending_credential( array $connection ) : bool { $pending = abtestkit_hosted_validate_credential( $connection['pending_credential'] ?? null ); if ( is_wp_error( $pending ) ) { return false; } $expected = $connection; $connection['credential'] = $pending; unset( $connection['pending_credential'], $connection['rotation_started_at'] ); if ( abtestkit_hosted_replace_local_connection( $expected, $connection ) ) { return true; } $latest = abtestkit_hosted_get_connection(); return is_array( $latest ) && empty( $latest['pending_credential'] ) && hash_equals( $pending, (string) $latest['credential'] ); } /** * Rotate a site credential while retaining enough local state to recover * safely from a lost service response. * * @param array $connection Connection state. * @return bool */ function abtestkit_hosted_rotate_connection_credential( array $connection ) : bool { $site_ref = (string) $connection['site_ref']; $credential = (string) $connection['credential']; $pending = isset( $connection['pending_credential'] ) ? abtestkit_hosted_validate_credential( $connection['pending_credential'] ) : new WP_Error( 'abtestkit_hosted_no_pending_credential' ); if ( ! is_wp_error( $pending ) ) { $status = abtestkit_hosted_service_request( 'GET', '/sites/' . $site_ref . '/status', array(), $pending ); if ( ! is_wp_error( $status ) && 200 === $status['status'] ) { return abtestkit_hosted_promote_pending_credential( $connection ); } } else { $pending = abtestkit_hosted_random_credential(); if ( is_wp_error( $pending ) ) { return false; } $expected = $connection; $connection['pending_credential'] = $pending; $connection['rotation_started_at'] = time(); if ( ! abtestkit_hosted_replace_local_connection( $expected, $connection ) ) { return false; } } $response = abtestkit_hosted_service_request( 'POST', '/sites/' . $site_ref . '/rotate', array( 'credential' => $pending ), $credential ); if ( ! is_wp_error( $response ) && 200 === $response['status'] && array( 'rotated' => true ) === $response['data'] ) { return abtestkit_hosted_promote_pending_credential( $connection ); } $status = abtestkit_hosted_service_request( 'GET', '/sites/' . $site_ref . '/status', array(), $pending ); if ( ! is_wp_error( $status ) && 200 === $status['status'] ) { return abtestkit_hosted_promote_pending_credential( $connection ); } return false; } /** * Handle explicit administrator credential rotation. * * @return void */ function abtestkit_hosted_rotate_credential() : void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage this connection.', 'abtestkit' ) ); } check_admin_referer( 'abtestkit_hosted_rotate_credential' ); $connection = abtestkit_hosted_get_connection(); if ( ! is_array( $connection ) ) { abtestkit_hosted_redirect( 'rotation-failed' ); } abtestkit_hosted_redirect( abtestkit_hosted_rotate_connection_credential( $connection ) ? 'credential-rotated' : 'rotation-failed' ); } add_action( 'admin_post_abtestkit_hosted_rotate_credential', 'abtestkit_hosted_rotate_credential' ); /** * Render a stable administrator notice from a key, never a remote message. * * @param bool $connected Whether a valid local connection currently exists. * @return void */ function abtestkit_hosted_render_message( bool $connected = false ) : void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only, allow-listed notice key; no state change occurs here. $key = isset( $_GET['abtestkit_hosted_message'] ) ? sanitize_key( wp_unslash( $_GET['abtestkit_hosted_message'] ) ) : ''; $messages = array( 'already-connected' => array( 'info', __( 'This site is already connected.', 'abtestkit' ) ), 'callback-expired' => array( 'error', __( 'That connection request expired. Start a new connection and try again.', 'abtestkit' ) ), 'callback-invalid' => array( 'error', __( 'That connection response was invalid. Start a new connection and try again.', 'abtestkit' ) ), 'connected' => array( 'success', __( 'Your abtestkit account is connected. This site can now create and launch tests.', 'abtestkit' ) ), 'credential-rotated' => array( 'success', __( 'The connection was refreshed successfully.', 'abtestkit' ) ), 'rotation-failed' => array( 'error', __( 'The connection could not be refreshed. Your tests are unaffected; try again in a moment.', 'abtestkit' ) ), 'sync-queued' => array( 'success', __( 'Checking for updates now. You can leave this page.', 'abtestkit' ) ), 'connection-failed' => array( 'error', __( 'The previous connection response could not be completed. Your tests are unaffected; checking the current connection status.', 'abtestkit' ) ), 'connection-retrying' => array( 'warning', __( 'The connection could not be confirmed. abtestkit will wait before trying again.', 'abtestkit' ) ), 'connection-busy' => array( 'info', __( 'This connection is already being checked. Return to the Account page in a moment.', 'abtestkit' ) ), 'connection-ended' => array( 'warning', __( 'That connection request is no longer active. Start a new connection to continue.', 'abtestkit' ) ), 'disconnected' => array( 'success', __( 'Your abtestkit account has been disconnected from this site.', 'abtestkit' ) ), 'disconnected-locally' => array( 'warning', __( 'This site was disconnected here, but abtestkit.io could not confirm it. Check the connected sites in your abtestkit account.', 'abtestkit' ) ), 'account-removed' => array( 'warning', __( 'Your abtestkit account is no longer connected. Connect an account again to create or launch tests.', 'abtestkit' ) ), 'https-required' => array( 'error', __( 'A valid HTTPS site and callback URL are required to connect.', 'abtestkit' ) ), 'start-failed' => array( 'error', __( 'The connection request could not be started. Your tests are unaffected; refresh and try again.', 'abtestkit' ) ), ); if ( ! isset( $messages[ $key ] ) ) { return; } if ( $connected && in_array( $key, array( 'callback-expired', 'callback-invalid', 'connection-failed', 'connection-retrying', 'connection-busy', 'connection-ended', 'disconnected', 'disconnected-locally', 'account-removed', 'https-required', 'start-failed' ), true ) ) { return; } echo '

' . esc_html( $messages[ $key ][1] ) . '

'; } /** * Render the explicit opt-in connection boundary. * * @return void */ function abtestkit_hosted_render_admin_page() : void { if ( ! current_user_can( 'manage_options' ) ) { return; } $stored_connection = get_option( ABTESTKIT_HOSTED_CONNECTION_OPTION, null ); $connection = abtestkit_hosted_get_connection(); $connection_attempt = get_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION, null ); $attempt_phase = is_array( $connection_attempt ) ? (string) ( $connection_attempt['phase'] ?? '' ) : ''; if ( is_array( $connection_attempt ) && ! in_array( $attempt_phase, array( 'pending', 'approved' ), true ) ) { delete_option( ABTESTKIT_HOSTED_CONNECTION_ATTEMPT_OPTION ); $connection_attempt = null; $attempt_phase = ''; } $attempt_started_at = is_array( $connection_attempt ) ? (int) ( $connection_attempt['created_at'] ?? 0 ) : 0; $attempt_expires_at = is_array( $connection_attempt ) ? (int) ( $connection_attempt['expires_at'] ?? 0 ) : 0; $has_pending_attempt = null === $connection && $attempt_started_at > 0 && $attempt_expires_at >= time(); $retry_stopped = $has_pending_attempt && (int) ( $connection_attempt['retry_count'] ?? 0 ) >= 3; // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Cross-origin return and read-only display flags; the one-time state and credentials are validated before any connection change. $code = isset( $_GET['abtestkit_hosted_code'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_hosted_code'] ) ) : ''; $state = isset( $_GET['abtestkit_hosted_state'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_hosted_state'] ) ) : ''; $callback_ready = ! is_wp_error( abtestkit_hosted_validate_credential( $code ) ) && ! is_wp_error( abtestkit_hosted_validate_credential( $state ) ); $message_key = isset( $_GET['abtestkit_hosted_message'] ) ? sanitize_key( wp_unslash( $_GET['abtestkit_hosted_message'] ) ) : ''; $callback_failed = $callback_ready && 'connection-failed' === $message_key; $auto_complete = $callback_ready && ! $callback_failed && isset( $_GET['abtestkit_hosted_auto'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['abtestkit_hosted_auto'] ) ); $open_preferences = isset( $_GET['abtestkit_hosted_preferences'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['abtestkit_hosted_preferences'] ) ); $return_to_preferences = isset( $_GET['abtestkit_hosted_return'] ) && 'preferences' === sanitize_key( wp_unslash( $_GET['abtestkit_hosted_return'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended echo '
'; echo ''; abtestkit_hosted_render_message( is_array( $connection ) ); if ( is_array( $connection ) ) { $account_url = abtestkit_hosted_service_account_url(); echo '
'; echo '
' . esc_html__( 'Checking account connection…', 'abtestkit' ) . '
'; echo '
'; echo '
'; echo '

' . esc_html__( 'abtestkit account', 'abtestkit' ) . '

'; if ( abtestkit_connected_platform_can_create_or_launch() ) { echo '

' . esc_html__( 'You’re connected', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'This site can create and launch tests. Email preferences remain available in your account.', 'abtestkit' ) . '

'; } else { echo '

' . esc_html__( 'Reconnect to continue', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'This older connection has not accepted the current Terms, Privacy Policy and Data Processing Agreement. Remove it, then reconnect this site to create or launch tests. Existing running tests keep working.', 'abtestkit' ) . '

'; } echo '
'; if ( ! abtestkit_connected_platform_can_create_or_launch() ) { echo '
'; echo ''; wp_nonce_field( 'abtestkit_hosted_disconnect' ); echo '
'; } if ( ! is_wp_error( $account_url ) ) { echo '' . esc_html__( 'Manage account', 'abtestkit' ) . ''; } echo '
'; echo ''; echo '
' . esc_html__( 'Connection settings', 'abtestkit' ) . ''; if ( abtestkit_connected_platform_can_create_or_launch() ) { abtestkit_connected_transport_render_status(); } echo '

' . esc_html__( 'You normally won’t need these controls. Use them if the connection has a problem or you no longer want this account connected to the site.', 'abtestkit' ) . '

'; echo '
'; echo ''; wp_nonce_field( 'abtestkit_hosted_rotate_credential' ); echo '
'; echo '
'; echo ''; wp_nonce_field( 'abtestkit_hosted_disconnect' ); echo ''; echo '
'; echo ''; if ( $return_to_preferences ) { $preferences_url = abtestkit_hosted_preferences_url(); if ( ! is_wp_error( $preferences_url ) ) { echo ''; } } } elseif ( null !== $stored_connection ) { echo '

' . esc_html__( 'The stored connection is invalid. Disconnect it locally before starting again.', 'abtestkit' ) . '

'; echo '
'; echo ''; wp_nonce_field( 'abtestkit_hosted_disconnect' ); echo '

'; } elseif ( $callback_failed ) { $account_url = abtestkit_hosted_admin_url(); echo '

' . esc_html__( 'Checking your connection', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'That one-time response cannot be reused. Return to the Account page to check the current connection or start a new request.', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'Check connection status', 'abtestkit' ) . '

'; echo ''; } elseif ( $callback_ready ) { if ( $auto_complete ) { echo ''; } echo '

' . esc_html__( 'Finishing your setup', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'Your email is verified. abtestkit is securely connecting this site.', 'abtestkit' ) . '

'; echo '
'; echo ''; echo ''; echo ''; echo ''; wp_nonce_field( 'abtestkit_hosted_complete_connection' ); echo '

'; if ( $auto_complete ) { echo ''; } } else { $origin = abtestkit_hosted_site_origin(); if ( $has_pending_attempt ) { echo '

' . esc_html( $retry_stopped ? __( 'Connection needs your attention', 'abtestkit' ) : __( 'Connection in progress', 'abtestkit' ) ) . '

' . esc_html( $retry_stopped ? __( 'Automatic recovery paused after several unsuccessful attempts. You can try again or cancel this request. Your existing tests are unaffected.', 'abtestkit' ) : __( 'abtestkit is securely checking for the completed account approval and will finish this connection automatically.', 'abtestkit' ) ) . '

'; if ( $retry_stopped ) { echo '
'; wp_nonce_field( 'abtestkit_hosted_retry_connection' ); echo '
'; } echo '
'; wp_nonce_field( 'abtestkit_hosted_cancel_connection' ); echo '
'; echo ''; } else { echo '

' . esc_html__( 'For your WordPress tests', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'Connect your abtestkit account', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'Connect this site to create or launch tests. If you do not have an account yet, you can create one during setup.', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'Why connect your site?', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'Connect your site to your abtestkit account to receive experiment updates, view connected-site information, and access new account features as they become available.', 'abtestkit' ) . '

'; echo '
' . esc_html__( 'See meaningful results', 'abtestkit' ) . '

' . esc_html__( 'Know when a test has a result worth acting on.', 'abtestkit' ) . '

'; echo '
' . esc_html__( 'Catch issues early', 'abtestkit' ) . '

' . esc_html__( 'Stay ahead of tracking and test-health changes.', 'abtestkit' ) . '

'; echo '
' . esc_html__( 'Choose what matters', 'abtestkit' ) . '

' . esc_html__( 'Pick the updates you want after you connect.', 'abtestkit' ) . '

'; echo '
' . esc_html__( 'What this connection shares', 'abtestkit' ) . '

' . esc_html__( 'The connected service receives bounded experiment lifecycle and aggregate result data, basic platform context, and installed/active state and versions for Elementor, Woodmart, and cache plugins that abtestkit directly integrates with. It does not receive a general plugin list, page content, selectors, custom code, or customer and order identity.', 'abtestkit' ) . '

'; echo '

' . esc_html__( 'Ready when you are', 'abtestkit' ) . '

' . esc_html__( 'Connect this site to your account in a new tab.', 'abtestkit' ) . '

'; echo '
'; echo ''; wp_nonce_field( 'abtestkit_hosted_start_connection' ); echo '
'; if ( is_wp_error( $origin ) ) { echo '

' . esc_html__( 'This site must use HTTPS before it can connect.', 'abtestkit' ) . '

'; } } echo ''; } else { echo ''; } } echo ''; }