WP_REST_Server::READABLE, 'callback' => array( $this, 'handle_callback' ), 'permission_callback' => array( $this, 'can_handle_callback' ), ) ); } /** Callback belongs to the authenticated administrator who started it. */ public function can_handle_callback() { return current_user_can( 'manage_options' ); } /** Start a site-bound, 15-minute authorization request. */ public function handle_connect() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'У вас нет прав для управления этим плагином.', 'admetric' ) ); } check_admin_referer( 'admetric_connect' ); $url = $this->create_authorization_url(); wp_redirect( esc_url_raw( $url ), 302, 'AdMetric' ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect exit; } /** * Create the exact authorization URL and its site-bound, 15-minute secret state. * * @return string */ private function create_authorization_url() { $site_url = untrailingslashit( get_site_url() ); $verifier = $this->base64url( random_bytes( 64 ) ); $state = $this->base64url( random_bytes( 32 ) ); $challenge = $this->base64url( hash( 'sha256', $verifier, true ) ); set_transient( self::TRANSIENT_ATTEMPT, array( 'code_verifier' => $verifier, 'state' => $state, 'site_url' => $site_url, ), 15 * MINUTE_IN_SECONDS ); $callback = add_query_arg( '_wpnonce', wp_create_nonce( 'wp_rest' ), rest_url( 'admetric/v1/connect/callback' ) ); return add_query_arg( array( 'site_url' => $site_url, 'callback' => $callback, 'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', 'plugin_version' => ADMETRIC_VERSION, ), self::APP_URL ); } /** * Exchange the callback code and activate the connection. * State is checked before the authorization code is ever sent. * * @param WP_REST_Request $request Callback request. * @return WP_REST_Response */ public function handle_callback( WP_REST_Request $request ) { $attempt = get_transient( self::TRANSIENT_ATTEMPT ); try { if ( ! is_array( $attempt ) || ! isset( $attempt['state'], $attempt['code_verifier'], $attempt['site_url'] ) ) { return $this->result_redirect( 'expired' ); } if ( untrailingslashit( get_site_url() ) !== (string) $attempt['site_url'] ) { return $this->result_redirect( 'invalid_address' ); } $state = (string) $request->get_param( 'state' ); $code = (string) $request->get_param( 'code' ); if ( '' === $state || '' === $code || ! hash_equals( (string) $attempt['state'], $state ) ) { return $this->result_redirect( 'invalid_request' ); } $pair = $this->post_json( self::API_URL . '/pair', array( 'code' => $code, 'codeVerifier' => (string) $attempt['code_verifier'], 'siteUrl' => (string) $attempt['site_url'], ) ); if ( is_wp_error( $pair ) ) { return $this->result_redirect( $this->safe_error_key( $pair ) ); } if ( empty( $pair['token'] ) || ! is_string( $pair['token'] ) || empty( $pair['connectionId'] ) || ! is_string( $pair['connectionId'] ) || empty( $pair['siteUrl'] ) || ! is_string( $pair['siteUrl'] ) || empty( $pair['activationToken'] ) || ! is_string( $pair['activationToken'] ) || empty( $pair['activationExpiresAt'] ) || ! is_string( $pair['activationExpiresAt'] ) || ! $this->same_site_url( (string) $attempt['site_url'], $pair['siteUrl'] ) ) { return $this->result_redirect( 'service_error' ); } // The status endpoint can authenticate only after this hash is saved. AdMetric_Token::save( $pair['token'] ); // Exact protocol order: pair -> persist token hash -> consume verifier/state // -> activate. The finally block also covers every earlier terminal path. $this->cleanup_attempt(); update_option( self::OPTION_DOMAIN, (string) wp_parse_url( $attempt['site_url'], PHP_URL_HOST ), false ); update_option( self::OPTION_ID, sanitize_text_field( $pair['connectionId'] ), false ); $activation = $this->post_json( self::API_URL . '/activate', array( 'activationToken' => $pair['activationToken'] ) ); if ( is_wp_error( $activation ) ) { update_option( self::OPTION_STATUS, 'error', false ); return $this->result_redirect( $this->safe_error_key( $activation ) ); } if ( ! isset( $activation['status'] ) || 'active' !== $activation['status'] ) { update_option( self::OPTION_STATUS, 'error', false ); return $this->result_redirect( 'verification_failed' ); } update_option( self::OPTION_STATUS, 'active', false ); return $this->result_redirect( 'connected' ); } finally { $this->cleanup_attempt(); } } /** Remove protocol secrets on every terminal callback path. */ private function cleanup_attempt() { delete_transient( self::TRANSIENT_ATTEMPT ); } /** Compare canonical HTTP(S) site URLs, including path and effective port. */ private function same_site_url( $left, $right ) { $left = wp_parse_url( untrailingslashit( $left ) ); $right = wp_parse_url( untrailingslashit( $right ) ); if ( ! is_array( $left ) || ! is_array( $right ) ) { return false; } foreach ( array( $left, $right ) as $parts ) { if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) || ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) { return false; } if ( isset( $parts['user'] ) || isset( $parts['pass'] ) || isset( $parts['query'] ) || isset( $parts['fragment'] ) ) { return false; } } $left_host = $this->canonical_host( $left['host'] ); $right_host = $this->canonical_host( $right['host'] ); if ( '' === $left_host || '' === $right_host ) { return false; } $left_port = isset( $left['port'] ) ? (int) $left['port'] : ( 'https' === strtolower( $left['scheme'] ) ? 443 : 80 ); $right_port = isset( $right['port'] ) ? (int) $right['port'] : ( 'https' === strtolower( $right['scheme'] ) ? 443 : 80 ); return strtolower( $left['scheme'] ) === strtolower( $right['scheme'] ) && $left_host === $right_host && $left_port === $right_port && ( isset( $left['path'] ) ? $left['path'] : '' ) === ( isset( $right['path'] ) ? $right['path'] : '' ); } /** Canonicalize an IDN host exactly once; reject non-ASCII if no encoder exists. */ private function canonical_host( $host ) { $host = strtolower( rtrim( (string) $host, '.' ) ); if ( '' === $host ) { return ''; } if ( function_exists( 'idn_to_ascii' ) ) { $flags = defined( 'IDNA_DEFAULT' ) ? IDNA_DEFAULT : 0; $ascii = defined( 'INTL_IDNA_VARIANT_UTS46' ) ? idn_to_ascii( $host, $flags, INTL_IDNA_VARIANT_UTS46 ) : idn_to_ascii( $host ); return is_string( $ascii ) ? strtolower( rtrim( $ascii, '.' ) ) : ''; } try { if ( class_exists( 'WpOrg\\Requests\\IdnaEncoder' ) ) { $ascii = \WpOrg\Requests\IdnaEncoder::encode( $host ); return is_string( $ascii ) ? strtolower( rtrim( $ascii, '.' ) ) : ''; } if ( class_exists( 'Requests_IDNAEncoder' ) ) { $ascii = Requests_IDNAEncoder::encode( $host ); return is_string( $ascii ) ? strtolower( rtrim( $ascii, '.' ) ) : ''; } } catch ( Throwable $error ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch return ''; } return preg_match( '/[^\\x20-\\x7e]/', $host ) ? '' : $host; } /** Clear non-secret one-click display metadata on local disconnect. */ public static function clear_connection_metadata() { delete_option( self::OPTION_STATUS ); delete_option( self::OPTION_DOMAIN ); delete_option( self::OPTION_ID ); delete_transient( self::TRANSIENT_ATTEMPT ); } /** * POST a JSON object without following redirects. * * @param string $url Canonical API URL. * @param array $body Request body. * @return array|WP_Error */ private function post_json( $url, array $body ) { $response = wp_remote_post( $url, array( 'timeout' => 15, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $body ), 'data_format' => 'body', ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'admetric_service_unavailable' ); } $status = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) { $code = is_array( $data ) && isset( $data['code'] ) && is_string( $data['code'] ) ? $data['code'] : 'admetric_service_unavailable'; return new WP_Error( $code ); } return $data; } /** Convert API codes to a small safe UI vocabulary. */ private function safe_error_key( WP_Error $error ) { $map = array( 'wp_connect_invalid_site_url' => 'invalid_address', 'wp_connect_invalid_callback' => 'invalid_address', 'wp_connect_request_expired' => 'expired', 'wp_connect_code_expired' => 'expired', 'wp_connect_code_consumed' => 'expired', 'wp_connect_pkce_mismatch' => 'invalid_request', 'wp_connect_activation_expired' => 'activation_expired', 'wp_connect_verification_failed' => 'verification_failed', 'wp_connect_rate_limited' => 'rate_limited', ); $code = $error->get_error_code(); return isset( $map[ $code ] ) ? $map[ $code ] : 'service_error'; } /** Build a same-site redirect containing only a non-secret result key. */ private function result_redirect( $result ) { $allowed = array( 'connected', 'invalid_address', 'expired', 'invalid_request', 'activation_expired', 'verification_failed', 'rate_limited', 'service_error' ); if ( ! in_array( $result, $allowed, true ) ) { $result = 'service_error'; } $url = add_query_arg( 'admetric-connect-result', $result, admin_url( 'options-general.php?page=admetric' ) ); $response = new WP_REST_Response( null, 302 ); $response->header( 'Location', $url ); return $response; } /** RFC 4648 base64url without padding. */ private function base64url( $bytes ) { return rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' ); } }