Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
72.03% |
103 / 143 |
|
35.29% |
6 / 17 |
CRAP | |
0.00% |
0 / 1 |
HiiveConnection | |
72.03% |
103 / 143 |
|
35.29% |
6 / 17 |
92.31 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
register_verification_hooks | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
rest_api_init | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
ajax_verify | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
verify_token | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
is_connected | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
connect | |
93.10% |
27 / 29 |
|
0.00% |
0 / 1 |
6.01 | |||
reconnect | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
throttle | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
get_throttle_interval | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
is_throttled | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
send_event | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
3.04 | |||
notify | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
6 | |||
hiive_request | |
92.59% |
25 / 27 |
|
0.00% |
0 / 1 |
8.03 | |||
get_auth_token | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
get_core_data | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
3 | |||
add_plugin_name_version_to_user_agent | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace NewfoldLabs\WP\Module\Data; |
4 | |
5 | use NewfoldLabs\WP\Module\Data\Helpers\Plugin as PluginHelper; |
6 | use NewfoldLabs\WP\Module\Data\Helpers\Transient; |
7 | use WP_Error; |
8 | use function NewfoldLabs\WP\ModuleLoader\container; |
9 | |
10 | /** |
11 | * Manages a Hiive connection instance and interactions with it |
12 | */ |
13 | class HiiveConnection implements SubscriberInterface { |
14 | |
15 | /** |
16 | * Hiive API url |
17 | * |
18 | * @var string |
19 | */ |
20 | private $api; |
21 | |
22 | /** |
23 | * Authentication token for data api |
24 | * |
25 | * @var string |
26 | */ |
27 | private $token; |
28 | |
29 | |
30 | /** |
31 | * Whether connection attempts are currently throttled |
32 | * |
33 | * @var bool |
34 | */ |
35 | private $throttled; |
36 | |
37 | /** |
38 | * The throttle |
39 | * |
40 | * @var bool |
41 | */ |
42 | protected $throttle; |
43 | |
44 | /** |
45 | * Construct |
46 | */ |
47 | public function __construct() { |
48 | |
49 | if ( ! defined( 'NFD_HIIVE_URL' ) ) { |
50 | define( 'NFD_HIIVE_URL', 'https://hiive.cloud/api' ); |
51 | } |
52 | |
53 | $this->api = constant( 'NFD_HIIVE_URL' ); |
54 | } |
55 | |
56 | /** |
57 | * Register the hooks required for site verification |
58 | * |
59 | * @return void |
60 | */ |
61 | public function register_verification_hooks() { |
62 | add_action( 'rest_api_init', array( $this, 'rest_api_init' ) ); |
63 | add_action( 'wp_ajax_nopriv_nfd-hiive-verify', array( $this, 'ajax_verify' ) ); |
64 | } |
65 | |
66 | /** |
67 | * Set up REST API routes |
68 | * |
69 | * @hooked rest_api_init |
70 | */ |
71 | public function rest_api_init(): void { |
72 | $controller = new API\Verify( $this ); |
73 | $controller->register_routes(); |
74 | } |
75 | |
76 | /** |
77 | * Process the admin-ajax request |
78 | * |
79 | * Hiive will first attempt to verify using the REST API, and fallback to this AJAX endpoint on error. |
80 | * |
81 | * Token is generated in {@see self::connect()} using {@see md5()}. |
82 | * |
83 | * @hooked wp_ajax_nopriv_nfd-hiive-verify |
84 | * |
85 | * @return never |
86 | */ |
87 | public function ajax_verify() { |
88 | // PHPCS: Ignore the nonce verification here – the token _is_ a nonce. |
89 | // @phpcs:ignore WordPress.Security.NonceVerification.Recommended |
90 | $token = $_REQUEST['token']; |
91 | |
92 | $is_valid = $this->verify_token( $token ); |
93 | $status = ( $is_valid ) ? 200 : 400; |
94 | |
95 | $data = array( |
96 | 'token' => $token, |
97 | 'valid' => $is_valid, |
98 | ); |
99 | \wp_send_json( $data, $status ); |
100 | } |
101 | |
102 | /** |
103 | * Confirm whether verification token is valid |
104 | * |
105 | * Token is generated in {@see self::connect()} using {@see md5()}. |
106 | * |
107 | * @param string $token Token to verify |
108 | */ |
109 | public function verify_token( string $token ): bool { |
110 | $saved_token = Transient::get( 'nfd_data_verify_token' ); |
111 | |
112 | if ( $saved_token && $saved_token === $token ) { |
113 | Transient::delete( 'nfd_data_verify_token' ); |
114 | |
115 | return true; |
116 | } |
117 | |
118 | return false; |
119 | } |
120 | |
121 | /** |
122 | * Check whether site has established connection to hiive |
123 | * |
124 | * This is cleared whenever Hiive returns 401 unauthenticated {@see Data::delete_token_on_401_response()}. |
125 | * |
126 | * @used-by Data::init() |
127 | */ |
128 | public static function is_connected(): bool { |
129 | return (bool) ( self::get_auth_token() ); |
130 | } |
131 | |
132 | /** |
133 | * Attempt to connect to Hiive |
134 | * |
135 | * @used-by Data::init() |
136 | * @used-by HiiveConnection::reconnect() |
137 | * |
138 | * @param string $path the path |
139 | * @param string $authorization the authorization |
140 | * @return Boolean success |
141 | */ |
142 | public function connect( string $path = '/sites/v2/connect', ?string $authorization = null ): bool { |
143 | |
144 | if ( $this->is_throttled() ) { |
145 | return false; |
146 | } |
147 | |
148 | $this->throttle(); |
149 | |
150 | $token = md5( \wp_generate_password() ); |
151 | Transient::set( 'nfd_data_verify_token', $token, 5 * constant( 'MINUTE_IN_SECONDS' ) ); |
152 | |
153 | $data = $this->get_core_data(); |
154 | $data['verify_token'] = $token; |
155 | $data['plugins'] = ( new PluginHelper() )->collect_installed(); |
156 | |
157 | $args = array( |
158 | 'body' => \wp_json_encode( $data ), |
159 | 'headers' => array( |
160 | 'Content-Type' => 'application/json', |
161 | 'Accept' => 'application/json', |
162 | ), |
163 | 'blocking' => true, |
164 | 'timeout' => 30, |
165 | ); |
166 | |
167 | if ( $authorization ) { |
168 | $args['headers']['Authorization'] = $authorization; |
169 | } |
170 | |
171 | $attempts = intval( get_option( 'nfd_data_connection_attempts', 0 ) ); |
172 | \update_option( 'nfd_data_connection_attempts', $attempts + 1 ); |
173 | |
174 | $response = \wp_remote_post( $this->api . $path, $args ); |
175 | $status = \wp_remote_retrieve_response_code( $response ); |
176 | |
177 | // Created = 201; Updated = 200 |
178 | if ( 201 === $status || 200 === $status ) { |
179 | $body = json_decode( \wp_remote_retrieve_body( $response ) ); |
180 | if ( ! empty( $body->token ) ) { |
181 | |
182 | // Token is auto-encrypted using the `pre_update_option_nfd_data_token` hook. |
183 | \update_option( 'nfd_data_token', $body->token ); |
184 | return true; |
185 | } |
186 | } |
187 | return false; |
188 | } |
189 | |
190 | /** |
191 | * Rename the site URL in Hiive. |
192 | * |
193 | * This performs almost the same request as {@see self::connect} but includes the Site authorization token, |
194 | * to verify this site is the owner of the existing site in Hiive, and Hiive pings back the new URL to verify |
195 | * the DNS points to this site. |
196 | */ |
197 | public function reconnect(): bool { |
198 | return $this->connect( '/sites/v2/reconnect', 'Bearer ' . self::get_auth_token() ); |
199 | } |
200 | |
201 | /** |
202 | * Set the connection throttle |
203 | * |
204 | * @return void |
205 | */ |
206 | public function throttle() { |
207 | $interval = $this->get_throttle_interval(); |
208 | |
209 | $this->throttle = Transient::set( 'nfd_data_connection_throttle', true, $interval ); |
210 | } |
211 | |
212 | /** |
213 | * Determine the throttle interval based off number of connection attempts |
214 | * |
215 | * @return integer Time to wait until next connection attempt |
216 | */ |
217 | public function get_throttle_interval() { |
218 | |
219 | $attempts = intval( \get_option( 'nfd_data_connection_attempts', 0 ) ); |
220 | |
221 | // Throttle intervals step-up: |
222 | // Hourly for 4 hours |
223 | // Twice a day for 3 days |
224 | // Once a day for 3 days |
225 | // Every 3 days for 3 times |
226 | // Once a week |
227 | if ( $attempts <= 4 ) { |
228 | return HOUR_IN_SECONDS; |
229 | } elseif ( $attempts <= 10 ) { |
230 | return 12 * HOUR_IN_SECONDS; |
231 | } elseif ( $attempts <= 13 ) { |
232 | return DAY_IN_SECONDS; |
233 | } elseif ( $attempts <= 16 ) { |
234 | return 3 * DAY_IN_SECONDS; |
235 | } else { |
236 | return WEEK_IN_SECONDS; |
237 | } |
238 | } |
239 | |
240 | /** |
241 | * Check whether connection is throttled |
242 | * |
243 | * @return boolean |
244 | */ |
245 | public function is_throttled() { |
246 | $this->throttled = Transient::get( 'nfd_data_connection_throttle' ); |
247 | |
248 | return $this->throttled; |
249 | } |
250 | |
251 | /** |
252 | * Synchronously send a single event and return the notifications. |
253 | * |
254 | * @used-by Events::create_item() |
255 | * |
256 | * @param Event $event the event |
257 | * |
258 | * @phpstan-type Notification_Array array{id:string,locations:array,query:string|null,expiration:int,content:string} |
259 | * @return array<Notification_Array>|WP_Error |
260 | */ |
261 | public function send_event( Event $event ) { |
262 | |
263 | $payload = array( |
264 | 'environment' => $this->get_core_data(), |
265 | 'events' => array( $event ), |
266 | ); |
267 | |
268 | $hiive_response = $this->hiive_request( 'sites/v1/events', $payload ); |
269 | |
270 | if ( is_wp_error( $hiive_response ) ) { |
271 | return $hiive_response; |
272 | } |
273 | |
274 | $status_code = \wp_remote_retrieve_response_code( $hiive_response ); |
275 | |
276 | if ( ! in_array( $status_code, array( 200, 201 ), true ) ) { |
277 | return new \WP_Error( $status_code, \wp_remote_retrieve_response_message( $hiive_response ) ); |
278 | } |
279 | |
280 | /** |
281 | * Sample shape. |
282 | * |
283 | * @var array{data:array{id:string,locations:array,query:string|null,expiration:int,content:string}} $response_payload |
284 | * */ |
285 | $response_payload = json_decode( \wp_remote_retrieve_body( $hiive_response ), true ); |
286 | |
287 | return $response_payload['data'] ?? array(); |
288 | } |
289 | |
290 | /** |
291 | * Send events to the v2 events endpoint and return the list of successes and list of failures. |
292 | * |
293 | * @see SubscriberInterface::notify() |
294 | * @used-by EventManager::send() |
295 | * |
296 | * @param Event[] $events Array of Event objects representing the actions that occurred. |
297 | * |
298 | * @return array{succeededEvents:array,failedEvents:array}|WP_Error |
299 | */ |
300 | public function notify( $events ) { |
301 | |
302 | $payload = array( |
303 | 'environment' => $this->get_core_data(), |
304 | 'events' => $events, |
305 | ); |
306 | |
307 | $hiive_response = $this->hiive_request( 'sites/v2/events', $payload ); |
308 | |
309 | if ( \is_wp_error( ( $hiive_response ) ) ) { |
310 | return $hiive_response; |
311 | } |
312 | |
313 | if ( ! in_array( \wp_remote_retrieve_response_code( $hiive_response ), array( 200, 201, 500 ), true ) ) { |
314 | return new WP_Error( \wp_remote_retrieve_response_code( $hiive_response ), \wp_remote_retrieve_response_message( $hiive_response ) ); |
315 | } |
316 | |
317 | $response_body = json_decode( wp_remote_retrieve_body( $hiive_response ), true ); |
318 | |
319 | // If the response from Hiive is not shaped as expected, e.g. a more serious 500 error, return as an error, not as the expected array. |
320 | if ( ! is_array( $response_body ) || ! array_key_exists( 'succeededEvents', $response_body ) || ! array_key_exists( 'failedEvents', $response_body ) ) { |
321 | return new WP_Error( 'hiive_response', 'Response body does not contain succeededEvents and failedEvents keys.' ); |
322 | } |
323 | |
324 | return $response_body; |
325 | } |
326 | |
327 | /** |
328 | * Send an HTTP request to Hiive and return the body of the request. |
329 | * |
330 | * Handles throttling and reconnection, clients should handle queueing if necessary. |
331 | * |
332 | * Defaults to POST. Override with `$args = array('method' => 'GET')`. |
333 | * |
334 | * @param string $path The Hiive api path (after /api/). |
335 | * @param array|null $payload the payload |
336 | * @param array|null $args and args for the request |
337 | * |
338 | * @return array|WP_Error The response array or a WP_Error when no Hiive connection, no network connection, network requests disabled. |
339 | */ |
340 | public function hiive_request( string $path, ?array $payload = array(), ?array $args = array() ) { |
341 | |
342 | /** |
343 | * Add plugin name/version to user agent |
344 | * |
345 | * @see \WP_Http::request() |
346 | * @see https://developer.wordpress.org/reference/hooks/http_headers_useragent/ |
347 | */ |
348 | add_filter( 'http_headers_useragent', array( $this, 'add_plugin_name_version_to_user_agent' ), 10, 2 ); |
349 | |
350 | // If for some reason we are not connected, bail out now. |
351 | // If we are not connected, the throttling logic should eventually reconnect. |
352 | if ( ! self::is_connected() ) { |
353 | return new WP_Error( 'hiive_connection', __( 'This site is not connected to the hiive.' ) ); |
354 | } |
355 | |
356 | $defaults = array( |
357 | 'method' => 'POST', |
358 | 'headers' => array( |
359 | 'Content-Type' => 'application/json', |
360 | 'Accept' => 'application/json', |
361 | 'Authorization' => 'Bearer ' . self::get_auth_token(), |
362 | ), |
363 | 'timeout' => \wp_is_serving_rest_request() ? 15 : 60, // If we're responding to the frontend, we need to be quick. |
364 | ); |
365 | |
366 | $parsed_args = \wp_parse_args( $args ?? array(), $defaults ); |
367 | |
368 | if ( ! empty( $payload ) ) { |
369 | $parsed_args['body'] = \wp_json_encode( $payload ); |
370 | } |
371 | |
372 | $request_response = \wp_remote_request( "{$this->api}/{$path}", $parsed_args ); |
373 | |
374 | // E.g. Hiive is down, or the site has disabled HTTP requests. |
375 | if ( \is_wp_error( $request_response ) ) { |
376 | return $request_response; |
377 | } |
378 | |
379 | // Authentication token is valid for Hiive but not for the resource or Site. |
380 | if ( 403 === $request_response['response']['code'] ) { |
381 | $body = json_decode( $request_response['body'], true ); |
382 | if ( 'Invalid token for url' === $body['message'] ) { |
383 | if ( $this->reconnect() ) { |
384 | $this->hiive_request( $path, $payload, $args ); |
385 | } else { |
386 | return new WP_Error( 'hiive_connection', __( 'This site is not connected to the hiive.' ) ); |
387 | } |
388 | } |
389 | } |
390 | |
391 | \remove_filter( 'http_headers_useragent', array( $this, 'add_plugin_name_version_to_user_agent' ) ); |
392 | |
393 | return $request_response; |
394 | } |
395 | |
396 | /** |
397 | * Try to return the auth token |
398 | * |
399 | * This is cleared whenever Hiive returns 401 unauthenticated {@see Data::delete_token_on_401_response()}. |
400 | * |
401 | * @return string|false The decrypted token if it's set |
402 | */ |
403 | public static function get_auth_token() { |
404 | return \get_option( 'nfd_data_token' ); |
405 | } |
406 | |
407 | /** |
408 | * Get core site data for initial connection |
409 | * |
410 | * @return array |
411 | */ |
412 | public function get_core_data() { |
413 | global $wpdb, $wp_version; |
414 | $container = container(); |
415 | |
416 | $data = array( |
417 | 'brand' => \sanitize_title( $container->plugin()->brand ), |
418 | 'cache_level' => intval( \get_option( 'newfold_cache_level', 2 ) ), |
419 | 'cloudflare' => \get_option( 'newfold_cloudflare_enabled', false ), |
420 | 'data' => defined( 'NFD_DATA_MODULE_VERSION' ) ? constant( 'NFD_DATA_MODULE_VERSION' ) : '0.0', |
421 | 'email' => \get_option( 'admin_email' ), |
422 | 'hostname' => gethostname(), |
423 | 'mysql' => $wpdb->db_version(), |
424 | 'origin' => $container->plugin()->get( 'id', 'error' ), |
425 | 'php' => phpversion(), |
426 | 'plugin' => $container->plugin()->get( 'version', '0' ), |
427 | 'url' => \get_site_url(), |
428 | 'username' => get_current_user(), |
429 | 'wp' => $wp_version, |
430 | 'server_path' => defined( 'ABSPATH' ) ? constant( 'ABSPATH' ) : '', |
431 | ); |
432 | |
433 | return apply_filters( 'newfold_wp_data_module_core_data_filter', $data ); |
434 | } |
435 | |
436 | /** |
437 | * Add the plugin name and version to the user agent string |
438 | * |
439 | * @param string $user_agent E.g. "WordPress/6.4.3; https://example.org". |
440 | * @param string $url E.g. "https://hiive.cloud/api/sites/v2/events". |
441 | * |
442 | * @return string E.g. "WordPress/6.4.3; bluehost/1.2.3; https://example.org". |
443 | */ |
444 | public function add_plugin_name_version_to_user_agent( string $user_agent, string $url ): string { |
445 | $container = container(); |
446 | $plugin_brand = \sanitize_title( $container->plugin()->brand ); |
447 | $plugin_version = $container->plugin()->get( 'version', '0' ); |
448 | |
449 | $user_agent_parts = array_map( 'trim', explode( ';', $user_agent ) ); |
450 | |
451 | array_splice( $user_agent_parts, 1, 0, "{$plugin_brand}/{$plugin_version}" ); |
452 | |
453 | return implode( '; ', $user_agent_parts ); |
454 | } |
455 | } |