Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.08% covered (warning)
78.08%
57 / 73
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_IXR_Client
79.71% covered (warning)
79.71%
55 / 69
33.33% covered (danger)
33.33%
2 / 6
27.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 query
85.37% covered (warning)
85.37%
35 / 41
0.00% covered (danger)
0.00%
0 / 1
12.45
 get_jetpack_error
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 parse_jetpack_fault_string
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_response_header
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 get_last_response
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * IXR_Client
4 *
5 * @package automattic/jetpack-connection
6 *
7 * @since 1.7.0
8 * @since-jetpack 1.5
9 * @since-jetpack 7.7 Moved to the jetpack-connection package.
10 */
11
12use Automattic\Jetpack\Connection\Client;
13use Automattic\Jetpack\Connection\Error_Handler;
14use Automattic\Jetpack\Connection\Manager;
15
16/**
17 * Disable direct access.
18 */
19if ( ! defined( 'ABSPATH' ) ) {
20    exit( 0 );
21}
22
23if ( ! class_exists( IXR_Client::class ) ) {
24    require_once ABSPATH . WPINC . '/class-IXR.php';
25}
26
27/**
28 * A Jetpack implementation of the WordPress core IXR client.
29 */
30class Jetpack_IXR_Client extends IXR_Client {
31    /**
32     * Jetpack args, used for the remote requests.
33     *
34     * @var array
35     */
36    public $jetpack_args = null;
37
38    /**
39     * Remote Response Headers.
40     *
41     * @var array
42     */
43    public $response_headers = null;
44
45    /**
46     * Holds the raw remote response from the latest call to query().
47     *
48     * @var null|array|WP_Error
49     */
50    public $last_response = null;
51
52    /**
53     * Constructor.
54     * Initialize a new Jetpack IXR client instance.
55     *
56     * @param array       $args    Jetpack args, used for the remote requests.
57     * @param string|bool $path    Path to perform the reuqest to.
58     * @param int         $port    Port number.
59     * @param int         $timeout The connection timeout, in seconds.
60     */
61    public function __construct( $args = array(), $path = false, $port = 80, $timeout = 15 ) {
62        $connection = new Manager();
63
64        $defaults = array(
65            'url'     => $connection->xmlrpc_api_url(),
66            'user_id' => 0,
67            'headers' => array(),
68        );
69
70        $args            = wp_parse_args( $args, $defaults );
71        $args['headers'] = array_merge( array( 'Content-Type' => 'text/xml' ), (array) $args['headers'] );
72
73        $this->jetpack_args = $args;
74
75        $this->IXR_Client( $args['url'], $path, $port, $timeout );
76    }
77
78    /**
79     * Perform the IXR request.
80     *
81     * @param mixed ...$args IXR method and args.
82     *
83     * @return bool True if request succeeded, false otherwise.
84     */
85    public function query( ...$args ) {
86        $method  = array_shift( $args );
87        $request = new IXR_Request( $method, $args );
88        $xml     = trim( $request->getXml() );
89
90        $response = Client::remote_request( $this->jetpack_args, $xml );
91
92        // Store response headers.
93        $this->response_headers = wp_remote_retrieve_headers( $response );
94
95        $this->last_response = $response;
96        if ( is_array( $this->last_response ) && isset( $this->last_response['http_response'] ) ) {
97            // If the expected array response is received, format the data as plain arrays.
98            $this->last_response            = $this->last_response['http_response']->to_array();
99            $this->last_response['headers'] = $this->last_response['headers']->getAll();
100        }
101
102        if ( is_wp_error( $response ) ) {
103            $this->error = new IXR_Error( -10520, sprintf( 'Jetpack: [%s] %s', $response->get_error_code(), $response->get_error_message() ) );
104            return false;
105        }
106
107        if ( ! $response ) {
108            $this->error = new IXR_Error( -10520, 'Jetpack: Unknown Error' );
109            return false;
110        }
111
112        if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
113            $this->error = new IXR_Error( -32300, 'transport error - HTTP status code was not 200' );
114            return false;
115        }
116
117        $content = wp_remote_retrieve_body( $response );
118
119        // Now parse what we've got back.
120        $this->message = new IXR_Message( $content );
121        if ( ! $this->message->parse() ) {
122            // XML error.
123            $this->error = new IXR_Error( -32700, 'parse error. not well formed' );
124            return false;
125        }
126
127        // Is the message a fault?
128        if ( 'fault' === $this->message->messageType ) {
129            $this->error = new IXR_Error( $this->message->faultCode, $this->message->faultString );
130
131            // Faults arrive as HTTP 200 responses with an XML body, so they never reach
132            // Error_Handler::check_api_response_for_errors() (called on this same request by
133            // Client::remote_request()); report them here instead.
134            //
135            // method_exists() guards against mid-update/mid-deploy skew: this legacy file can
136            // load before the connection package's Error_Handler class does, so the method may
137            // not exist yet on this request.
138            if ( method_exists( Error_Handler::class, 'check_xmlrpc_fault_for_errors' ) ) {
139                // is_scalar() avoids a PHP 8 array-to-string warning if malformed XML ever
140                // leaves faultString as something other than a string.
141                $fault_string = is_scalar( $this->message->faultString ) ? (string) $this->message->faultString : '';
142                $parsed       = self::parse_jetpack_fault_string( $fault_string );
143
144                if ( $parsed !== null ) {
145                    $user_id = true === $this->jetpack_args['user_id']
146                        ? (int) \Jetpack_Options::get_option( 'master_user' )
147                        : (int) $this->jetpack_args['user_id'];
148
149                    Error_Handler::get_instance()->check_xmlrpc_fault_for_errors(
150                        $parsed[0],
151                        $parsed[1],
152                        $this->jetpack_args['url'],
153                        'POST',
154                        $user_id
155                    );
156                }
157            }
158
159            return false;
160        }
161
162        // Message must be OK.
163        return true;
164    }
165
166    /**
167     * Retrieve the Jetpack error from the result of the last request.
168     *
169     * @param int    $fault_code   Fault code.
170     * @param string $fault_string Fault string.
171     * @return WP_Error Error object.
172     */
173    public function get_jetpack_error( $fault_code = null, $fault_string = null ) {
174        if ( $fault_code === null ) {
175            $fault_code = $this->error->code;
176        }
177
178        if ( $fault_string === null ) {
179            $fault_string = $this->error->message;
180        }
181
182        $parsed = self::parse_jetpack_fault_string( $fault_string );
183
184        if ( $parsed !== null ) {
185            list( $code, $message ) = $parsed;
186            return new WP_Error( $code, $message, $fault_code );
187        }
188
189        return new WP_Error( "IXR_{$fault_code}", $fault_string );
190    }
191
192    /**
193     * Parse a `Jetpack: [code] message` fault string, the convention WP.com uses to surface
194     * a Jetpack-specific error code and message inside an XML-RPC fault.
195     *
196     * Without the `s` modifier, `.` doesn't match a newline, and unmodified `$` anchors to the
197     * end of the whole string (not each line) — so a fault string containing embedded newlines
198     * after the code fails this match entirely rather than truncating the message. WP.com fault
199     * strings are single-line in practice, so this is intentional, not a bug.
200     *
201     * @param string $fault_string The XML-RPC fault string.
202     * @return array{0: string, 1: string}|null A [ code, message ] pair, or null if the string
203     *                                           doesn't follow the convention.
204     */
205    public static function parse_jetpack_fault_string( $fault_string ) {
206        if ( ! preg_match( '#\Ajetpack:\s+\[(\w+)\]\s*(.*)$#i', $fault_string, $match ) ) {
207            return null;
208        }
209
210        return array( $match[1], $match[2] );
211    }
212
213    /**
214     * Retrieve a response header if set.
215     *
216     * @param  string $name  header name.
217     * @return string|bool Header value if set, false if not set.
218     */
219    public function get_response_header( $name ) {
220        if ( isset( $this->response_headers[ $name ] ) ) {
221            return $this->response_headers[ $name ];
222        }
223        // case-insensitive header names: http://www.ietf.org/rfc/rfc2616.txt.
224        if ( isset( $this->response_headers[ strtolower( $name ) ] ) ) {
225            return $this->response_headers[ strtolower( $name ) ];
226        }
227        return false;
228    }
229
230    /**
231     * Retrieve the raw response for the last query() call.
232     *
233     * @return null|array|WP_Error
234     */
235    public function get_last_response() {
236        return $this->last_response;
237    }
238}