Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.16% covered (warning)
76.16%
115 / 151
41.18% covered (danger)
41.18%
7 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
HiiveConnection
76.16% covered (warning)
76.16%
115 / 151
41.18% covered (danger)
41.18%
7 / 17
81.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 register_verification_hooks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rest_api_init
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 ajax_verify
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 verify_token
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 is_connected
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connect
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
7.04
 reconnect
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 throttle
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_throttle_interval
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 is_throttled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 send_event
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
3.04
 notify
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 hiive_request
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
9
 get_auth_token
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_core_data
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 add_plugin_name_version_to_user_agent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace NewfoldLabs\WP\Module\Data;
4
5use NewfoldLabs\WP\Module\Data\Helpers\Plugin as PluginHelper;
6use NewfoldLabs\WP\Module\Data\Helpers\Transient;
7use WP_Error;
8use function NewfoldLabs\WP\ModuleLoader\container;
9
10/**
11 * Manages a Hiive connection instance and interactions with it
12 */
13class 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 Additional path to append to the API URL
139     * @param ?string $authorization The authorization header
140     * @return bool 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        if ( Transient::get( 'nfd_data_verify_token' ) !== $token ) {
154            return false;
155        }
156
157        $data                 = $this->get_core_data();
158        $data['verify_token'] = $token;
159        $data['plugins']      = ( new PluginHelper() )->collect_installed();
160
161        $args = array(
162            'body'     => \wp_json_encode( $data ),
163            'headers'  => array(
164                'Content-Type' => 'application/json',
165                'Accept'       => 'application/json',
166            ),
167            'blocking' => true,
168            'timeout'  => 30,
169        );
170
171        if ( $authorization ) {
172            $args['headers']['Authorization'] = $authorization;
173        }
174
175        $attempts = intval( get_option( 'nfd_data_connection_attempts', 0 ) );
176        \update_option( 'nfd_data_connection_attempts', $attempts + 1 );
177
178        $response = \wp_remote_post( $this->api . $path, $args );
179        $status   = \wp_remote_retrieve_response_code( $response );
180
181        // Created = 201; Updated = 200
182        if ( 201 === $status || 200 === $status ) {
183            $body = json_decode( \wp_remote_retrieve_body( $response ) );
184            if ( ! empty( $body->token ) ) {
185
186                // Token is auto-encrypted using the `pre_update_option_nfd_data_token` hook.
187                \update_option( 'nfd_data_token', $body->token );
188
189                ( new SiteCapabilities() )->clear();
190
191                return true;
192            }
193        }
194        return false;
195    }
196
197    /**
198     * Rename the site URL in Hiive.
199     *
200     * This performs almost the same request as {@see self::connect} but includes the Site authorization token,
201     * to verify this site is the owner of the existing site in Hiive, and Hiive pings back the new URL to verify
202     * the DNS points to this site.
203     */
204    public function reconnect(): bool {
205        return $this->connect( '/sites/v2/reconnect', 'Bearer ' . self::get_auth_token() );
206    }
207
208    /**
209     * Set the connection throttle
210     *
211     * @return void
212     */
213    public function throttle() {
214        $interval = $this->get_throttle_interval();
215
216        $this->throttle = Transient::set( 'nfd_data_connection_throttle', true, $interval );
217    }
218
219    /**
220     * Determine the throttle interval based off number of connection attempts
221     *
222     * @return integer Time to wait until next connection attempt
223     */
224    public function get_throttle_interval() {
225
226        $attempts = intval( \get_option( 'nfd_data_connection_attempts', 0 ) );
227
228        // Throttle intervals step-up:
229        // Hourly for 4 hours
230        // Twice a day for 3 days
231        // Once a day for 3 days
232        // Every 3 days for 3 times
233        // Once a week
234        if ( $attempts <= 4 ) {
235            return HOUR_IN_SECONDS;
236        } elseif ( $attempts <= 10 ) {
237            return 12 * HOUR_IN_SECONDS;
238        } elseif ( $attempts <= 13 ) {
239            return DAY_IN_SECONDS;
240        } elseif ( $attempts <= 16 ) {
241            return 3 * DAY_IN_SECONDS;
242        } else {
243            return WEEK_IN_SECONDS;
244        }
245    }
246
247    /**
248     * Check whether connection is throttled
249     *
250     * @return boolean
251     */
252    public function is_throttled() {
253        $this->throttled = Transient::get( 'nfd_data_connection_throttle' );
254
255        return $this->throttled;
256    }
257
258    /**
259     * Synchronously send a single event and return the notifications.
260     *
261     * @used-by Events::create_item()
262     *
263     * @param Event $event the event
264     *
265     * @phpstan-type Notification_Array array{id:string,locations:array,query:string|null,expiration:int,content:string}
266     * @return array<Notification_Array>|WP_Error
267     */
268    public function send_event( Event $event ) {
269
270        $payload = array(
271            'environment' => $this->get_core_data(),
272            'events'      => array( $event ),
273        );
274
275        $hiive_response = $this->hiive_request( 'sites/v1/events', $payload );
276
277        if ( is_wp_error( $hiive_response ) ) {
278            return $hiive_response;
279        }
280
281        $status_code = \wp_remote_retrieve_response_code( $hiive_response );
282
283        if ( ! in_array( $status_code, array( 200, 201 ), true ) ) {
284            return new \WP_Error( $status_code, \wp_remote_retrieve_response_message( $hiive_response ) );
285        }
286
287        /**
288         * Sample shape.
289         *
290         * @var array{data:array{id:string,locations:array,query:string|null,expiration:int,content:string}} $response_payload
291         * */
292        $response_payload = json_decode( \wp_remote_retrieve_body( $hiive_response ), true );
293
294        return $response_payload['data'] ?? array();
295    }
296
297    /**
298     * Send events to the v2 events endpoint and return the list of successes and list of failures.
299     *
300     * @see SubscriberInterface::notify()
301     * @used-by EventManager::send()
302     *
303     * @param Event[] $events Array of Event objects representing the actions that occurred.
304     *
305     * @return array{succeededEvents:array,failedEvents:array}|WP_Error
306     */
307    public function notify( $events ) {
308
309        $payload = array(
310            'environment' => $this->get_core_data(),
311            'events'      => $events,
312        );
313
314        $hiive_response = $this->hiive_request( 'sites/v2/events', $payload );
315
316        if ( \is_wp_error( ( $hiive_response ) ) ) {
317            return $hiive_response;
318        }
319
320        if ( ! in_array( \wp_remote_retrieve_response_code( $hiive_response ), array( 200, 201, 500 ), true ) ) {
321            return new WP_Error( \wp_remote_retrieve_response_code( $hiive_response ), \wp_remote_retrieve_response_message( $hiive_response ) );
322        }
323
324        $response_body = json_decode( wp_remote_retrieve_body( $hiive_response ), true );
325
326        // 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.
327        if ( ! is_array( $response_body ) || ! array_key_exists( 'succeededEvents', $response_body ) || ! array_key_exists( 'failedEvents', $response_body ) ) {
328            return new WP_Error( 'hiive_response', 'Response body does not contain succeededEvents and failedEvents keys.' );
329        }
330
331        return $response_body;
332    }
333
334    /**
335     * Send an HTTP request to Hiive and return the body of the request.
336     *
337     * Handles throttling and reconnection, clients should handle queueing if necessary.
338     *
339     * Defaults to POST. Override with `$args = array('method' => 'GET')`.
340     *
341     * @param string     $path The Hiive api path (after /api/).
342     * @param array|null $payload the payload
343     * @param array|null $args and args for the request
344     *
345     * @return array|WP_Error The response array or a WP_Error when no Hiive connection, no network connection, network requests disabled.
346     */
347    public function hiive_request( string $path, ?array $payload = array(), ?array $args = array() ) {
348
349        /**
350         * Add plugin name/version to user agent
351         *
352         * @see \WP_Http::request()
353         * @see https://developer.wordpress.org/reference/hooks/http_headers_useragent/
354         */
355        add_filter( 'http_headers_useragent', array( $this, 'add_plugin_name_version_to_user_agent' ), 10, 2 );
356
357        // If for some reason we are not connected, bail out now.
358        // If we are not connected, the throttling logic should eventually reconnect.
359        if ( ! self::is_connected() ) {
360            return new WP_Error( 'hiive_connection', __( 'This site is not connected to the hiive.', 'wp-module-data' ) );
361        }
362
363        $defaults = array(
364            'method'  => 'POST',
365            'headers' => array(
366                'Content-Type'  => 'application/json',
367                'Accept'        => 'application/json',
368                'Authorization' => 'Bearer ' . self::get_auth_token(),
369            ),
370            'timeout' => \wp_is_serving_rest_request() ? 15 : 60, // If we're responding to the frontend, we need to be quick.
371        );
372
373        $parsed_args = \wp_parse_args( $args ?? array(), $defaults );
374        $url         = "{$this->api}/{$path}";
375
376        if ( ! empty( $payload ) ) {
377            $parsed_args['body'] = 'GET' === $parsed_args['method']
378                ? $payload
379                : \wp_json_encode( $payload );
380        }
381
382        $request_response = \wp_remote_request( $url, $parsed_args );
383
384        // E.g. Hiive is down, or the site has disabled HTTP requests.
385        if ( \is_wp_error( $request_response ) ) {
386            return $request_response;
387        }
388
389        // Authentication token is valid for Hiive but not for the resource or Site.
390        if ( 403 === $request_response['response']['code'] ) {
391            $body = json_decode( $request_response['body'], true );
392            if ( 'Invalid token for url' === $body['message'] ) {
393                if ( $this->reconnect() ) {
394                    return $this->hiive_request( $path, $payload, $args );
395                }
396
397                return new WP_Error( 'hiive_connection', __( 'This site is not connected to the hiive.', 'wp-module-data' ) );
398            }
399        }
400
401        \remove_filter( 'http_headers_useragent', array( $this, 'add_plugin_name_version_to_user_agent' ) );
402
403        return $request_response;
404    }
405
406    /**
407     * Try to return the auth token
408     *
409     * This is cleared whenever Hiive returns 401 unauthenticated {@see Data::delete_token_on_401_response()}.
410     *
411     * @return string|false The decrypted token if it's set
412     */
413    public static function get_auth_token() {
414        // Check if a custom token is defined via constant
415        if ( defined( 'NFD_HIIVE_AUTH_TOKEN' ) ) {
416            return NFD_HIIVE_AUTH_TOKEN;
417        }
418
419        // Fall back to the stored option
420        return \get_option( 'nfd_data_token' );
421    }
422
423    /**
424     * Get core site data for initial connection
425     *
426     * @return array
427     */
428    public function get_core_data() {
429        global $wpdb, $wp_version;
430        $container = container();
431
432        $data = array(
433            'brand'       => \sanitize_title( $container->plugin()->brand ),
434            'cache_level' => intval( \get_option( 'newfold_cache_level', 2 ) ),
435            'cloudflare'  => \get_option( 'newfold_cloudflare_enabled', false ),
436            'data'        => defined( 'NFD_DATA_MODULE_VERSION' ) ? constant( 'NFD_DATA_MODULE_VERSION' ) : '0.0',
437            'email'       => \get_option( 'admin_email' ),
438            'hostname'    => gethostname(),
439            'mysql'       => $wpdb->db_version(),
440            'origin'      => $container->plugin()->get( 'id', 'error' ),
441            'php'         => phpversion(),
442            'plugin'      => $container->plugin()->get( 'version', '0' ),
443            'url'         => \get_site_url(),
444            'username'    => get_current_user(),
445            'wp'          => $wp_version,
446            'server_path' => \untrailingslashit( defined( 'ABSPATH' ) ? constant( 'ABSPATH' ) : '' ),
447        );
448
449        return apply_filters( 'newfold_wp_data_module_core_data_filter', $data );
450    }
451
452    /**
453     * Add the plugin name and version to the user agent string
454     *
455     * @param string $user_agent E.g. "WordPress/6.4.3; https://example.org".
456     * @param string $url   E.g. "https://hiive.cloud/api/sites/v2/events".
457     *
458     * @return string E.g. "WordPress/6.4.3; bluehost/1.2.3; https://example.org".
459     */
460    public function add_plugin_name_version_to_user_agent( string $user_agent, string $url ): string {
461        $container      = container();
462        $plugin_brand   = \sanitize_title( $container->plugin()->brand );
463        $plugin_version = $container->plugin()->get( 'version', '0' );
464
465        $user_agent_parts = array_map( 'trim', explode( ';', $user_agent ) );
466
467        array_splice( $user_agent_parts, 1, 0, "{$plugin_brand}/{$plugin_version}" );
468
469        return implode( '; ', $user_agent_parts );
470    }
471}