Completed
Push — add/handling-connection-errors ( 253b42...700587 )
by
unknown
318:00 queued 310:19
created

Error_Handler::get_instance()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 0
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * The Jetpack Connection error class file.
4
 *
5
 * @package automattic/jetpack-connection
6
 */
7
8
namespace Automattic\Jetpack\Connection;
9
10
/**
11
 * The Jetpack Connection Errors that handles errors
12
 *
13
 * This class handles the following workflow:
14
 *
15
 * 1. A XML-RCP request with an invalid signature triggers a error
16
 * 2. Applies a gate to only process each error code once an hour to avoid overflow
17
 * 3. It stores the error on the database, but we don't know yet if this is a valid error, because
18
 *    we can't confirm it came from WP.com.
19
 * 4. It encrypts the error details and send it to thw wp.com server
20
 * 5. wp.com checks it and, if valid, sends a new request back to this site using the verify_xml_rpc_error REST endpoint
21
 * 6. This endpoint add this error to the Verified errors in the database
22
 * 7. Triggers a workflow depending on the error (display user an error message, do some self healing, etc.)
23
 */
24
class Error_Handler {
25
26
	/**
27
	 * The name of the option that stores the errors
28
	 *
29
	 * @var string
30
	 */
31
	const STORED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_errors';
32
33
	/**
34
	 * The name of the option that stores the errors
35
	 *
36
	 * @var string
37
	 */
38
	const STORED_VERIFIED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_verified_errors';
39
40
	/**
41
	 * The prefix of the transient that controls the gate for each error code
42
	 *
43
	 * @var string
44
	 */
45
	const ERROR_REPORTING_GATE = 'jetpack_connection_error_reporting_gate_';
46
47
	/**
48
	 * List of known errors. Only error codes in this list will be handled
49
	 *
50
	 * @var array
51
	 */
52
	public $known_errors = array(
53
		'malformed_token',
54
		'malformed_user_id',
55
		'unknown_user',
56
		'no_user_tokens',
57
		'empty_master_user_option',
58
		'no_token_for_user',
59
		'token_malformed',
60
		'user_id_mismatch',
61
		'no_possible_tokens',
62
		'no_valid_token',
63
		'unknown_token',
64
		'could_not_sign',
65
		'invalid_scheme',
66
		'invalid_secret',
67
		'invalid_token',
68
		'token_mismatch',
69
		'invalid_body',
70
		'invalid_signature',
71
		'invalid_body_hash',
72
		'invalid_nonce',
73
		'signature_mismatch',
74
	);
75
76
	/**
77
	 * Holds the instance of this singleton class
78
	 *
79
	 * @var Error_Handler $instance
80
	 */
81
	public static $instance = null;
82
83
	/**
84
	 * Initialize hooks
85
	 */
86
	private function __construct() {
87
		defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
88
89
		add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
90
	}
91
92
	/**
93
	 * Gets the instance of this singleton class
94
	 *
95
	 * @return Error_Handler $instance
96
	 */
97
	public static function get_instance() {
98
		if ( is_null( self::$instance ) ) {
99
			self::$instance = new self();
100
		}
101
		return self::$instance;
102
	}
103
104
	/**
105
	 * Keep track of a connection error that was encoutered
106
	 *
107
	 * @param \WP_Error $error the error object.
108
	 * @param boolean   $force Force the report, even if should_report_error is false.
109
	 * @return void
110
	 */
111
	public function report_error( \WP_Error $error, $force = false ) {
112
		if ( in_array( $error->get_error_code(), $this->known_errors, true ) && $this->should_report_error( $error ) || $force ) {
0 ignored issues
show
Bug introduced by
The method get_error_code() does not seem to exist on object<WP_Error>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
113
			$stored_error = $this->store_error( $error );
114
			if ( $stored_error ) {
115
				$this->send_error_to_wpcom( $stored_error );
116
			}
117
		}
118
	}
119
120
	/**
121
	 * Checks the status of the gate
122
	 *
123
	 * This protects the site (and WPCOM) against over loads.
124
	 *
125
	 * @param \WP_Error $error the error object.
126
	 * @return boolean $should_report True if gate is open and the error should be reported.
127
	 */
128
	public function should_report_error( \WP_Error $error ) {
129
130
		if ( defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG ) {
131
			return true;
132
		}
133
134
		$bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
135
		if ( true === $bypass_gate ) {
136
			return true;
137
		}
138
139
		$transient = self::ERROR_REPORTING_GATE . $error->get_error_code();
0 ignored issues
show
Bug introduced by
The method get_error_code() does not seem to exist on object<WP_Error>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
140
141
		if ( get_transient( $transient ) ) {
142
			return false;
143
		}
144
145
		set_transient( $transient, true, HOUR_IN_SECONDS );
146
		return true;
147
	}
148
149
	/**
150
	 * Stores the error in the database so we know there is an issue and can inform the user
151
	 *
152
	 * @param \WP_Error $error the error object.
153
	 * @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
154
	 */
155
	public function store_error( \WP_Error $error ) {
156
157
		$stored_errors = $this->get_stored_errors();
158
		$error_array   = $this->wp_error_to_array( $error );
159
		$error_code    = $error->get_error_code();
0 ignored issues
show
Bug introduced by
The method get_error_code() does not seem to exist on object<WP_Error>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
160
		$user_id       = $error_array['user_id'];
161
162
		if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
163
			$stored_errors[ $error_code ] = array();
164
		}
165
166
		$stored_errors[ $error_code ][ $user_id ] = $error_array;
167
168
		// Let's store a maximum of 5 different user ids for each error code.
169
		if ( count( $stored_errors[ $error_code ] ) > 5 ) {
170
			// array_shift will destroy keys here because they are numeric, so manually remove first item.
171
			$keys = array_keys( $stored_errors[ $error_code ] );
172
			unset( $stored_errors[ $error_code ][ $keys[0] ] );
173
		}
174
175
		if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
176
			return $error_array;
177
		}
178
179
		return false;
180
181
	}
182
183
	/**
184
	 * Converts a WP_Error object in the array representation we store in the database
185
	 *
186
	 * @param \WP_Error $error the error object.
187
	 * @return boolean|array False if error is invalid or the error array
188
	 */
189
	public function wp_error_to_array( \WP_Error $error ) {
190
191
		$data       = $error->get_error_data();
0 ignored issues
show
Bug introduced by
The method get_error_data() does not seem to exist on object<WP_Error>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
192
		$error_code = $error->get_error_code();
0 ignored issues
show
Bug introduced by
The method get_error_code() does not seem to exist on object<WP_Error>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
193
194
		if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
195
			return false;
196
		}
197
198
		$data = $data['signature_details'];
199
200
		if ( ! isset( $data['token'] ) || empty( $data['token'] ) ) {
201
			return false;
202
		}
203
204
		$user_id = $this->get_user_id_from_token( $data['token'] );
205
206
		$error_array = array(
207
			'error_code'    => $error_code,
208
			'user_id'       => $user_id,
209
			'error_message' => $error->get_error_message(),
0 ignored issues
show
Bug introduced by
The method get_error_message() does not seem to exist on object<WP_Error>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
210
			'error_data'    => $data,
211
			'timestamp'     => time(),
212
			'nonce'         => wp_generate_password( 10, false ),
213
		);
214
215
		return $error_array;
216
217
	}
218
219
	/**
220
	 * Sends the error to WP.com to be verified
221
	 *
222
	 * @param array $error_array The array representation of the error as it is stored in the database.
223
	 * @return bool
224
	 */
225
	public function send_error_to_wpcom( $error_array ) {
226
227
		$blog_id = \Jetpack_Options::get_option( 'id' );
228
229
		$encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
230
231
		$args = array(
232
			'body' => array(
233
				'error_data' => $encrypted_data,
234
			),
235
		);
236
237
		// send encrypted data to WP.com Public-API v2.
238
		wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
239
		return true;
240
	}
241
242
	/**
243
	 * Encrypt data to be sent over to WP.com
244
	 *
245
	 * @param array|string $data the data to be encoded.
246
	 * @return boolean|string The encoded string on success, false on failure
247
	 */
248
	public function encrypt_data_to_wpcom( $data ) {
249
250
		try {
251
			// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
252
			// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
253
			$encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
254
			// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
255
			// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
256
		} catch ( \SodiumException $e ) {
0 ignored issues
show
Bug introduced by
The class SodiumException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
257
			// error encrypting data.
258
			return false;
259
		}
260
261
		return $encrypted_data;
262
263
	}
264
265
	/**
266
	 * Extracts the user ID from a token
267
	 *
268
	 * @param string $token the token used to make the xml-rpc request.
269
	 * @return string $the user id or `invalid` if user id not present.
270
	 */
271
	public function get_user_id_from_token( $token ) {
272
		$parsed_token = explode( ':', wp_unslash( $token ) );
273
274
		if ( isset( $parsed_token[2] ) && ! empty( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
275
			$user_id = $parsed_token[2];
276
		} else {
277
			$user_id = 'invalid';
278
		}
279
280
		return $user_id;
281
282
	}
283
284
	/**
285
	 * Gets the reported errors stored in the database
286
	 *
287
	 * @return array $errors
288
	 */
289
	public function get_stored_errors() {
290
		// todo: add object cache.
291
		// todo: garbage collector, delete old unverified errors based on timestamp.
292
		$stored_errors = get_option( self::STORED_ERRORS_OPTION );
293
		if ( ! is_array( $stored_errors ) ) {
294
			$stored_errors = array();
295
		}
296
		return $stored_errors;
297
	}
298
299
	/**
300
	 * Gets the verified errors stored in the database
301
	 *
302
	 * @return array $errors
303
	 */
304
	public function get_verified_errors() {
305
		$verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
306
		if ( ! is_array( $verified_errors ) ) {
307
			$verified_errors = array();
308
		}
309
		return $verified_errors;
310
	}
311
312
	/**
313
	 * Delete the reported errors stored in the database
314
	 *
315
	 * @return boolean True, if option is successfully deleted. False on failure.
316
	 */
317
	public function delete_stored_errors() {
318
		return delete_option( self::STORED_ERRORS_OPTION );
319
	}
320
321
	/**
322
	 * Delete the verified errors stored in the database
323
	 *
324
	 * @return boolean True, if option is successfully deleted. False on failure.
325
	 */
326
	public function delete_verified_errors() {
327
		return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
328
	}
329
330
	/**
331
	 * Gets an error based on the nonce
332
	 *
333
	 * Receives a nonce and finds the related error. If error is found, move it to the verified errors option.
334
	 *
335
	 * @param string $nonce The nonce created for the error we want to get.
336
	 * @return null|array Returns the error array representation or null if error not found.
337
	 */
338
	public function get_error_by_nonce( $nonce ) {
339
		$errors = $this->get_stored_errors();
340
		foreach ( $errors as $user_group ) {
341
			foreach ( $user_group as $error ) {
342
				if ( $error['nonce'] === $nonce ) {
343
					return $error;
344
				}
345
			}
346
		}
347
		return null;
348
	}
349
350
	/**
351
	 * Adds an error to the verified error list
352
	 *
353
	 * @param array $error The error array, as it was saved in the unverified errors list.
354
	 * @return void
355
	 */
356
	public function verify_error( $error ) {
357
358
		$verified_errors = $this->get_verified_errors();
359
		$error_code      = $error['error_code'];
360
		$user_id         = $error['user_id'];
361
362
		if ( ! isset( $verified_errors[ $error_code ] ) ) {
363
			$verified_errors[ $error_code ] = array();
364
		}
365
366
		$verified_errors[ $error_code ][ $user_id ] = $error;
367
368
		update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
369
370
	}
371
372
	/**
373
	 * Register REST API end point for error hanlding.
374
	 *
375
	 * @since 8.7.0
376
	 *
377
	 * @return void
378
	 */
379
	public function register_verify_error_endpoint() {
380
		register_rest_route(
381
			'jetpack/v4',
382
			'/verify_xmlrpc_error',
383
			array(
384
				'methods'  => \WP_REST_Server::CREATABLE,
385
				'callback' => array( $this, 'verify_xml_rpc_error' ),
386
				'args'     => array(
387
					'nonce' => array(
388
						'required' => true,
389
						'type'     => 'string',
390
					),
391
				),
392
			)
393
		);
394
	}
395
396
	/**
397
	 * Handles verification that a xml rpc error is legit and came from WordPres.com
398
	 *
399
	 * @since 8.7.0
400
	 *
401
	 * @param \WP_REST_Request $request The request sent to the WP REST API.
402
	 *
403
	 * @return boolean
404
	 */
405
	public function verify_xml_rpc_error( \WP_REST_Request $request ) {
406
407
		$error = $this->get_error_by_nonce( $request['nonce'] );
408
409
		if ( $error ) {
410
			$this->verify_error( $error );
411
			return new \WP_REST_Response( true, 200 );
412
		}
413
414
		return new \WP_REST_Response( false, 200 );
415
416
	}
417
418
}
419