Completed
Push — add/handling-connection-errors ( 76997e...1b4c4d )
by
unknown
331:58 queued 323:55
created

Error_Handler   B

Complexity

Total Complexity 44

Size/Duplication

Total Lines 353
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 1

Importance

Changes 0
Metric Value
dl 0
loc 353
rs 8.8798
c 0
b 0
f 0
wmc 44
lcom 2
cbo 1

16 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 2
A get_instance() 0 6 2
A report_error() 0 8 4
A should_report_error() 0 20 5
A store_error() 0 27 5
A wp_error_to_array() 0 29 5
A send_error_to_wpcom() 0 26 2
A get_user_id_from_token() 0 12 4
A get_stored_errors() 0 9 2
A get_verified_errors() 0 7 2
A delete_stored_errors() 0 3 1
A delete_verified_errors() 0 3 1
A get_error_by_nonce() 0 11 4
A verify_error() 0 15 2
A register_verify_error_endpoint() 0 16 1
A verify_xml_rpc_error() 0 12 2

How to fix   Complexity   

Complex Class

Complex classes like Error_Handler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Error_Handler, and based on these observations, apply Extract Interface, too.

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
	 * Holds the instance of this singleton class
49
	 *
50
	 * @var Error_Handler $instance
51
	 */
52
	public static $instance = null;
53
54
	/**
55
	 * Initialize hooks
56
	 */
57
	private function __construct() {
58
		defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
59
60
		add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
61
	}
62
63
	/**
64
	 * Gets the instance of this singleton class
65
	 *
66
	 * @return Error_Handler $instance
67
	 */
68
	public static function get_instance() {
69
		if ( is_null( self::$instance ) ) {
70
			self::$instance = new self();
71
		}
72
		return self::$instance;
73
	}
74
75
	/**
76
	 * Keep track of a connection error that was encoutered
77
	 *
78
	 * @param \WP_Error $error the error object.
79
	 * @param boolean   $force Force the report, even if should_report_error is false.
80
	 * @return void
81
	 */
82
	public function report_error( \WP_Error $error, $force = false ) {
83
		if ( $this->should_report_error( $error ) || $force ) {
84
			$stored_error = $this->store_error( $error );
85
			if ( $stored_error ) {
86
				$this->send_error_to_wpcom( $stored_error );
87
			}
88
		}
89
	}
90
91
	/**
92
	 * Checks the status of the gate
93
	 *
94
	 * This protects the site (and WPCOM) against over loads.
95
	 *
96
	 * @param \WP_Error $error the error object.
97
	 * @return boolean $should_report True if gate is open and the error should be reported.
98
	 */
99
	public function should_report_error( \WP_Error $error ) {
100
101
		if ( defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG ) {
102
			return true;
103
		}
104
105
		$bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
106
		if ( true === $bypass_gate ) {
107
			return true;
108
		}
109
110
		$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...
111
112
		if ( get_transient( $transient ) ) {
113
			return false;
114
		}
115
116
		set_transient( $transient, true, HOUR_IN_SECONDS );
117
		return true;
118
	}
119
120
	/**
121
	 * Stores the error in the database so we know there is an issue and can inform the user
122
	 *
123
	 * @param \WP_Error $error the error object.
124
	 * @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
125
	 */
126
	public function store_error( \WP_Error $error ) {
127
128
		$stored_errors = $this->get_stored_errors();
129
		$error_array   = $this->wp_error_to_array( $error );
130
		$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...
131
		$user_id       = $error_array['user_id'];
132
133
		if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
134
			$stored_errors[ $error_code ] = array();
135
		}
136
137
		$stored_errors[ $error_code ][ $user_id ] = $error_array;
138
139
		// Let's store a maximum of 5 different user ids for each error code.
140
		if ( count( $stored_errors[ $error_code ] ) > 5 ) {
141
			// array_shift will destroy keys here because they are numeric, so manually remove first item.
142
			$keys = array_keys( $stored_errors[ $error_code ] );
143
			unset( $stored_errors[ $error_code ][ $keys[0] ] );
144
		}
145
146
		if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
147
			return $error_array;
148
		}
149
150
		return false;
151
152
	}
153
154
	/**
155
	 * Converts a WP_Error object in the array representation we store in the database
156
	 *
157
	 * @param \WP_Error $error the error object.
158
	 * @return boolean|array False if error is invalid or the error array
159
	 */
160
	public function wp_error_to_array( \WP_Error $error ) {
161
162
		$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...
163
		$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...
164
165
		if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
166
			return false;
167
		}
168
169
		$data = $data['signature_details'];
170
171
		if ( ! isset( $data['token'] ) || empty( $data['token'] ) ) {
172
			return false;
173
		}
174
175
		$user_id = $this->get_user_id_from_token( $data['token'] );
176
177
		$error_array = array(
178
			'error_code'    => $error_code,
179
			'user_id'       => $user_id,
180
			'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...
181
			'error_data'    => $data,
182
			'timestamp'     => time(),
183
			'nonce'         => wp_generate_password( 10, false ),
184
		);
185
186
		return $error_array;
187
188
	}
189
190
	/**
191
	 * Sends the error to WP.com to be verified
192
	 *
193
	 * @param array $error_array The array representation of the error as it is stored in the database.
194
	 * @return bool
195
	 */
196
	public function send_error_to_wpcom( $error_array ) {
197
198
		$blog_id = \Jetpack_Options::get_option( 'id' );
199
200
		// encrypt data.
201
		try {
202
			// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
203
			// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
204
			$encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $error_array ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
205
			// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
206
			// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
207
		} catch ( SodiumException $e ) {
0 ignored issues
show
Bug introduced by
The class Automattic\Jetpack\Connection\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...
208
			// error encrypting data.
209
			return false;
210
		}
211
212
		$args = array(
213
			'body' => array(
214
				'error_data' => $encrypted_data,
215
			),
216
		);
217
218
		// send encrypted data to WP.com Public-API v2.
219
		wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
220
		return true;
221
	}
222
223
	/**
224
	 * Extracts the user ID from a token
225
	 *
226
	 * @param string $token the token used to make the xml-rpc request.
227
	 * @return string $the user id or `invalid` if user id not present.
228
	 */
229
	public function get_user_id_from_token( $token ) {
230
		$parsed_token = explode( ':', wp_unslash( $token ) );
231
232
		if ( isset( $parsed_token[2] ) && ! empty( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
233
			$user_id = $parsed_token[2];
234
		} else {
235
			$user_id = 'invalid';
236
		}
237
238
		return $user_id;
239
240
	}
241
242
	/**
243
	 * Gets the reported errors stored in the database
244
	 *
245
	 * @return array $errors
246
	 */
247
	public function get_stored_errors() {
248
		// todo: add object cache.
249
		// todo: garbage collector, delete old unverified errors based on timestamp.
250
		$stored_errors = get_option( self::STORED_ERRORS_OPTION );
251
		if ( ! is_array( $stored_errors ) ) {
252
			$stored_errors = array();
253
		}
254
		return $stored_errors;
255
	}
256
257
	/**
258
	 * Gets the verified errors stored in the database
259
	 *
260
	 * @return array $errors
261
	 */
262
	public function get_verified_errors() {
263
		$verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
264
		if ( ! is_array( $verified_errors ) ) {
265
			$verified_errors = array();
266
		}
267
		return $verified_errors;
268
	}
269
270
	/**
271
	 * Delete the reported errors stored in the database
272
	 *
273
	 * @return boolean True, if option is successfully deleted. False on failure.
274
	 */
275
	public function delete_stored_errors() {
276
		return delete_option( self::STORED_ERRORS_OPTION );
277
	}
278
279
	/**
280
	 * Delete the verified errors stored in the database
281
	 *
282
	 * @return boolean True, if option is successfully deleted. False on failure.
283
	 */
284
	public function delete_verified_errors() {
285
		return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
286
	}
287
288
	/**
289
	 * Gets an error based on the nonce
290
	 *
291
	 * Receives a nonce and finds the related error. If error is found, move it to the verified errors option.
292
	 *
293
	 * @param string $nonce The nonce created for the error we want to get.
294
	 * @return null|array Returns the error array representation or null if error not found.
295
	 */
296
	public function get_error_by_nonce( $nonce ) {
297
		$errors = $this->get_stored_errors();
298
		foreach ( $errors as $user_group ) {
299
			foreach ( $user_group as $error ) {
300
				if ( $error['nonce'] === $nonce ) {
301
					return $error;
302
				}
303
			}
304
		}
305
		return null;
306
	}
307
308
	/**
309
	 * Adds an error to the verified error list
310
	 *
311
	 * @param array $error The error array, as it was saved in the unverified errors list.
312
	 * @return void
313
	 */
314
	public function verify_error( $error ) {
315
316
		$verified_errors = $this->get_verified_errors();
317
		$error_code      = $error['error_code'];
318
		$user_id         = $error['user_id'];
319
320
		if ( ! isset( $verified_errors[ $error_code ] ) ) {
321
			$verified_errors[ $error_code ] = array();
322
		}
323
324
		$verified_errors[ $error_code ][ $user_id ] = $error;
325
326
		update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
327
328
	}
329
330
	/**
331
	 * Register REST API end point for error hanlding.
332
	 *
333
	 * @since 8.7.0
334
	 *
335
	 * @return void
336
	 */
337
	public function register_verify_error_endpoint() {
338
		register_rest_route(
339
			'jetpack/v4',
340
			'/verify_xmlrpc_error',
341
			array(
342
				'methods'  => \WP_REST_Server::CREATABLE,
343
				'callback' => array( $this, 'verify_xml_rpc_error' ),
344
				'args'     => array(
345
					'nonce' => array(
346
						'required' => true,
347
						'type'     => 'string',
348
					),
349
				),
350
			)
351
		);
352
	}
353
354
	/**
355
	 * Handles verification that a xml rpc error is legit and came from WordPres.com
356
	 *
357
	 * @since 8.7.0
358
	 *
359
	 * @param \WP_REST_Request $request The request sent to the WP REST API.
360
	 *
361
	 * @return boolean
362
	 */
363
	public function verify_xml_rpc_error( \WP_REST_Request $request ) {
364
365
		$error = $this->get_error_by_nonce( $request['nonce'] );
366
367
		if ( $error ) {
368
			$this->verify_error( $error );
369
		}
370
371
		// We don't give any useful information away.
372
		return new \WP_REST_Response( true, 200 );
373
374
	}
375
376
}
377