Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.76% covered (warning)
73.76%
163 / 221
64.71% covered (warning)
64.71%
11 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Connector
73.76% covered (warning)
73.76%
163 / 221
64.71% covered (warning)
64.71%
11 / 17
184.17
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 register_connector
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_script_module
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 get_connector_data
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
7.01
 add_identity_crisis_data
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 get_current_user_data
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 get_connection_owner_data
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 resolve_user_fields
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 is_connectors_screen
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_connectors_page_path
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 store_auth_error
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 consume_auth_error
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_connected_plugins_data
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 get_connected_plugin_families
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 get_connector_logo_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 get_inline_connector_logo_url
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 get_plugin_logo_url
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
1<?php
2/**
3 * Jetpack connector card for the WP core Connectors screen.
4 *
5 * Registers a connector in the WP 7.0+ Connectors registry and enqueues
6 * a script module that provides a custom render function with connection
7 * details (owner, connected plugins, disconnect).
8 *
9 * @package automattic/jetpack-connection
10 */
11
12namespace Automattic\Jetpack\Connection;
13
14use Automattic\Jetpack\Identity_Crisis;
15use Automattic\Jetpack\Modules;
16use Automattic\Jetpack\Status;
17use Automattic\Jetpack\Status\Host;
18
19/**
20 * Jetpack connector card handler.
21 *
22 * @since 8.2.0
23 */
24class Jetpack_Connector {
25
26    /**
27     * Whether the connector has been initialized.
28     *
29     * @var bool
30     */
31    private static $initialized = false;
32
33    /**
34     * Script module identifier.
35     *
36     * @var string
37     */
38    const MODULE_ID = '@automattic/jetpack-connection-connectors';
39
40    /**
41     * Screen ID assigned by WordPress to the Gutenberg plugin's connectors submenu page.
42     *
43     * @var string
44     */
45    const GUTENBERG_CONNECTORS_SCREEN_ID = 'settings_page_options-connectors-wp-admin';
46
47    /**
48     * Page slug registered by the Gutenberg plugin for the connectors submenu page.
49     *
50     * @var string
51     */
52    const GUTENBERG_CONNECTORS_PAGE_SLUG = 'options-connectors-wp-admin';
53
54    /**
55     * Initialize the connector.
56     */
57    public static function init() {
58        if ( static::$initialized ) {
59            return;
60        }
61        static::$initialized = true;
62
63        add_action( 'wp_connectors_init', array( static::class, 'register_connector' ), 20 );
64        add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_script_module' ) );
65        add_action( 'jetpack_client_authorize_error', array( static::class, 'store_auth_error' ) );
66    }
67
68    /**
69     * Register Jetpack as a connector in the WP core Connectors screen.
70     *
71     * The wp_connectors_init action is available in WordPress 7.0+.
72     * On older versions this action never fires, so the hook is safely a no-op.
73     *
74     * @since 8.2.0
75     *
76     * @param \WP_Connector_Registry $registry Connector registry instance.
77     */
78    public static function register_connector( $registry ) {
79        // @phan-suppress-previous-line PhanUndeclaredTypeParameter -- WP 7.0+ class.
80        $registry->register( // @phan-suppress-current-line PhanUndeclaredClassMethod -- WP 7.0+ class.
81            'wordpress_com',
82            array(
83                'name'           => 'Jetpack Connection',
84                'description'    => __( 'Enhanced functionality for Jetpack and WooCommerce with WordPress.com.', 'jetpack-connection' ),
85                'type'           => 'cloud_service',
86                'logo_url'       => static::get_connector_logo_url(),
87                'authentication' => array(
88                    'method' => 'none',
89                ),
90            )
91        );
92    }
93
94    /**
95     * Enqueue the connectors card script module on the Settings > Connectors page.
96     *
97     * @since 8.2.0
98     */
99    public static function enqueue_script_module() {
100        $screen = get_current_screen();
101
102        if ( ! $screen || ! static::is_connectors_screen( $screen ) ) {
103            return;
104        }
105
106        if ( ! class_exists( 'WP_Connector_Registry' ) ) {
107            return;
108        }
109
110        $css_path = __DIR__ . '/css/connectors-card.css';
111        wp_enqueue_style(
112            'jetpack-connector-card',
113            plugins_url( 'css/connectors-card.css', __FILE__ ),
114            array(),
115            (string) @filemtime( $css_path ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback to empty string if file is missing.
116        );
117
118        $js_path = __DIR__ . '/js/connectors-card.js';
119        wp_register_script_module(
120            static::MODULE_ID,
121            plugins_url( 'js/connectors-card.js', __FILE__ ),
122            array(
123                array(
124                    'id'     => '@wordpress/connectors',
125                    'import' => 'static',
126                ),
127            ),
128            (string) @filemtime( $js_path ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback to empty string if file is missing.
129        );
130        wp_enqueue_script_module( static::MODULE_ID );
131
132        add_filter(
133            'script_module_data_' . static::MODULE_ID,
134            array( static::class, 'get_connector_data' )
135        );
136    }
137
138    /**
139     * Build the data passed to the script module via the script_module_data_ filter.
140     *
141     * @since 8.2.0
142     *
143     * @param array $data Existing script module data.
144     * @return array Filtered script module data.
145     */
146    public static function get_connector_data( $data ) {
147        $manager       = new Manager();
148        $is_registered = $manager->is_connected();
149        $is_connected  = $is_registered && $manager->has_connected_owner();
150
151        $data['isConnected']          = $is_connected;
152        $data['isRegistered']         = $is_registered;
153        $data['isOfflineMode']        = ( new Status() )->is_offline_mode();
154        $data['isFirstConnection']    = ! $is_registered && ! (bool) \Jetpack_Options::get_option( 'id' );
155        $data['apiRoot']              = esc_url_raw( rest_url() );
156        $data['apiNonce']             = wp_create_nonce( 'wp_rest' );
157        $data['redirectUri']          = static::get_connectors_page_path();
158        $data['connectorName']        = 'Jetpack Connection';
159        $data['connectorDescription'] = __( 'Enhanced functionality for Jetpack and WooCommerce with WordPress.com.', 'jetpack-connection' );
160        $data['connectorLogoUrl']     = static::get_connector_logo_url();
161
162        $data['connectedPlugins'] = static::get_connected_plugins_data( $manager );
163
164        if ( $is_registered ) {
165            $data['siteDetails'] = array(
166                'blogId'  => (int) \Jetpack_Options::get_option( 'id' ),
167                'siteUrl' => site_url(),
168                'homeUrl' => home_url(),
169            );
170
171            if ( in_array( 'jetpack', array_column( $data['connectedPlugins'], 'slug' ), true ) ) {
172                $data['ssoStatus'] = ( new Modules() )->is_active( 'sso', false );
173            }
174
175            static::add_identity_crisis_data( $data );
176        }
177
178        if ( $is_connected ) {
179            $data['currentUser']     = static::get_current_user_data( $manager );
180            $data['connectionOwner'] = static::get_connection_owner_data( $manager );
181        }
182
183        $host              = new Host();
184        $data['isWoaSite'] = $host->is_woa_site();
185        $data['isVipSite'] = $host->is_vip_site();
186
187        $auth_error = static::consume_auth_error();
188        if ( $auth_error ) {
189            $data['authError'] = $auth_error;
190        }
191
192        return $data;
193    }
194
195    /**
196     * Add Jetpack Identity Crisis (Safe Mode) data to the script module data.
197     *
198     * The connector card uses this to swap the status badge to "Safe Mode" and
199     * to render IDC resolution options (migrate / start fresh / stay in safe
200     * mode) in the expanded details. Mirrors the data assembled by
201     * \Automattic\Jetpack\IdentityCrisis\UI::get_initial_state_data().
202     *
203     * @since 8.7.0
204     *
205     * @param array $data Script module data passed by reference.
206     */
207    private static function add_identity_crisis_data( &$data ) {
208        if ( ! class_exists( Identity_Crisis::class ) ) {
209            return;
210        }
211
212        $in_safe_mode = ( new Status() )->in_safe_mode();
213
214        $data['isInSafeMode'] = $in_safe_mode;
215
216        if ( ! $in_safe_mode ) {
217            return;
218        }
219
220        $idc_urls = Identity_Crisis::get_mismatched_urls();
221
222        $data['isSafeModeConfirmed'] = (bool) Identity_Crisis::$is_safe_mode_confirmed;
223        $data['idc']                 = array(
224            'currentUrl'                     => ( is_array( $idc_urls ) && array_key_exists( 'current_url', $idc_urls ) ) ? $idc_urls['current_url'] : home_url(),
225            'wpcomHomeUrl'                   => ( is_array( $idc_urls ) && array_key_exists( 'wpcom_url', $idc_urls ) ) ? $idc_urls['wpcom_url'] : '',
226            'isDevelopmentSite'              => (bool) Status::is_development_site(),
227            'possibleDynamicSiteUrlDetected' => (bool) Identity_Crisis::detect_possible_dynamic_site_url(),
228        );
229    }
230
231    /**
232     * Get the current (logged-in) user's connection details.
233     *
234     * @param Manager $manager Connection manager instance.
235     * @return array|null Current user data or null if not connected.
236     */
237    private static function get_current_user_data( $manager ) {
238        $user_id = get_current_user_id();
239
240        if ( ! $user_id || ! $manager->is_user_connected( $user_id ) ) {
241            return null;
242        }
243
244        $user      = get_userdata( $user_id );
245        $user_info = static::resolve_user_fields( $user, $manager->get_connected_user_data( $user_id ) );
246        $is_owner  = $manager->is_connection_owner( $user_id );
247
248        $has_other_connected_users = false;
249        if ( $is_owner ) {
250            $connected_users           = $manager->get_connected_users( 'any', 2 );
251            $has_other_connected_users = count( $connected_users ) > 1;
252        }
253
254        return array_merge(
255            $user_info,
256            array(
257                'isOwner'                => $is_owner,
258                'hasOtherConnectedUsers' => $has_other_connected_users,
259            )
260        );
261    }
262
263    /**
264     * Get the connection owner details for the script module.
265     *
266     * @param Manager $manager Connection manager instance.
267     * @return array|null Owner data or null if unavailable.
268     */
269    private static function get_connection_owner_data( $manager ) {
270        $owner = $manager->get_connection_owner();
271
272        if ( false === $owner ) {
273            return null;
274        }
275
276        $fields = static::resolve_user_fields( $owner, $manager->get_connected_user_data( $owner->ID ) );
277
278        $fields['localLogin'] = $owner->user_login;
279
280        return $fields;
281    }
282
283    /**
284     * Merge local WP user fields with WordPress.com user data.
285     *
286     * WPCOM values take precedence when available. Returns the common
287     * user shape used by both currentUser and connectionOwner.
288     *
289     * @param \WP_User|false $wp_user        Local WordPress user object (false if unavailable).
290     * @param array|false    $wpcom_user_data WPCOM user data from the connection manager.
291     * @return array User data with displayName, login, email, and avatar.
292     */
293    private static function resolve_user_fields( $wp_user, $wpcom_user_data ) {
294        $display_name = $wp_user ? $wp_user->display_name : '';
295        $login        = $wp_user ? $wp_user->user_login : '';
296        $email        = $wp_user ? $wp_user->user_email : '';
297
298        if ( is_array( $wpcom_user_data ) ) {
299            if ( ! empty( $wpcom_user_data['display_name'] ) ) {
300                $display_name = $wpcom_user_data['display_name'];
301            }
302            if ( ! empty( $wpcom_user_data['login'] ) ) {
303                $login = $wpcom_user_data['login'];
304            }
305            if ( ! empty( $wpcom_user_data['email'] ) ) {
306                $email = $wpcom_user_data['email'];
307            }
308        }
309
310        $user_id = $wp_user ? $wp_user->ID : 0;
311
312        return array(
313            'displayName' => $display_name,
314            'login'       => $login,
315            'email'       => $email,
316            'avatar'      => $user_id
317                ? get_avatar_url(
318                    $user_id,
319                    array(
320                        'size'    => 48,
321                        'default' => 'mysteryman',
322                    )
323                )
324                : '',
325        );
326    }
327
328    /**
329     * Check whether the given screen is the Connectors settings page.
330     *
331     * Handles both WP 7.0 core (`options-connectors`) and the Gutenberg
332     * plugin (`settings_page_options-connectors-wp-admin`).
333     *
334     * @param \WP_Screen $screen Current admin screen.
335     * @return bool
336     */
337    private static function is_connectors_screen( $screen ) {
338        return 'options-connectors' === $screen->id
339            || static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id;
340    }
341
342    /**
343     * Return the admin-relative path for the Connectors page.
344     *
345     * WP 7.0 core uses the standalone `options-connectors.php` file while
346     * the Gutenberg plugin registers a submenu page under options-general.php
347     * with slug `options-connectors-wp-admin`. Both set parent_file to
348     * `options-general.php` for menu highlighting, so we distinguish them by
349     * checking the actual script filename being served.
350     *
351     * Note: for the Gutenberg case we use the registered page slug directly,
352     * not `$screen->id`. WordPress auto-prefixes screen IDs for submenu pages
353     * (e.g. `settings_page_options-connectors-wp-admin`), so using `$screen->id`
354     * as the `page=` parameter produces an invalid URL.
355     *
356     * The result is suitable for the `redirect_uri` parameter accepted by the
357     * `jetpack/v4/connection/register` REST endpoint (which wraps it in `admin_url()`).
358     *
359     * @return string Admin-relative path, e.g. 'options-connectors.php'.
360     */
361    private static function get_connectors_page_path() {
362        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only compared against a hardcoded string.
363        $script = isset( $_SERVER['SCRIPT_NAME'] ) ? wp_basename( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) : '';
364
365        if ( 'options-connectors.php' === $script ) {
366            return 'options-connectors.php';
367        }
368
369        // Gutenberg plugin registers the page under options-general.php.
370        $screen = get_current_screen();
371        if ( $screen && static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id ) {
372            return 'options-general.php?page=' . static::GUTENBERG_CONNECTORS_PAGE_SLUG;
373        }
374
375        return 'options-connectors.php';
376    }
377
378    /**
379     * Store an authorization error in a short-lived transient.
380     *
381     * Hooked to `jetpack_client_authorize_error` which fires when
382     * the auth webhook fails. The transient is read on the next
383     * Connectors page load so the JS card can display the error.
384     *
385     * @since 8.2.0
386     *
387     * @param \WP_Error $error Authorization error.
388     */
389    public static function store_auth_error( $error ) {
390        if ( is_wp_error( $error ) ) {
391            $user_id = get_current_user_id();
392            if ( $user_id ) {
393                set_transient(
394                    'jetpack_connector_auth_error_' . $user_id,
395                    $error->get_error_message(),
396                    60
397                );
398            }
399        }
400    }
401
402    /**
403     * Read and delete a stored authorization error for the current user.
404     *
405     * @return string|false Error message or false if none.
406     */
407    private static function consume_auth_error() {
408        $user_id = get_current_user_id();
409        if ( ! $user_id ) {
410            return false;
411        }
412
413        $key   = 'jetpack_connector_auth_error_' . $user_id;
414        $error = get_transient( $key );
415        if ( false !== $error ) {
416            delete_transient( $key );
417        }
418
419        return $error;
420    }
421
422    /**
423     * Get connected plugins data for the script module.
424     *
425     * @param Manager $manager Connection manager instance.
426     * @return array List of connected plugin data.
427     */
428    private static function get_connected_plugins_data( $manager ) {
429        $plugins = $manager->get_connected_plugins();
430
431        if ( is_wp_error( $plugins ) || ! is_array( $plugins ) ) {
432            return array();
433        }
434
435        $result = array();
436
437        foreach ( $plugins as $slug => $plugin_data ) {
438            $name = $plugin_data['name'] ?? $slug;
439
440            $entry = array(
441                'name' => $name,
442                'slug' => $slug,
443            );
444
445            $logo_url = static::get_plugin_logo_url( $slug );
446            if ( $logo_url ) {
447                $entry['logoUrl'] = $logo_url;
448            }
449
450            $result[] = $entry;
451        }
452
453        return $result;
454    }
455
456    /**
457     * Detect which plugin families are using the connection.
458     *
459     * @since 8.5.0
460     *
461     * @return array{has_woo: bool, has_a4a: bool}
462     */
463    public static function get_connected_plugin_families() {
464        $plugins = Plugin_Storage::get_all();
465
466        $has_woo = false;
467        $has_a4a = false;
468
469        if ( is_array( $plugins ) ) {
470            foreach ( array_keys( $plugins ) as $slug ) {
471                if ( str_starts_with( $slug, 'woocommerce' ) ) {
472                    $has_woo = true;
473                }
474                if ( str_starts_with( $slug, 'automattic' ) ) {
475                    $has_a4a = true;
476                }
477            }
478        }
479
480        return array(
481            'has_woo' => $has_woo,
482            'has_a4a' => $has_a4a,
483        );
484    }
485
486    /**
487     * Determine the connector card logo based on which plugin families are connected.
488     *
489     * Priority:
490     * 1. Both Woo-family and A4A plugins → jetpack-connect-all.svg
491     * 2. Woo-family only                 → jetpack-connect-woo.svg
492     * 3. A4A only                        → jetpack-connect-a8c.svg
493     * 4. Default (Jetpack only or other) → jetpack-connect.svg
494     *
495     * @since 8.3.2
496     *
497     * @return string Logo URL.
498     */
499    public static function get_connector_logo_url() {
500        $families = self::get_connected_plugin_families();
501
502        if ( $families['has_woo'] && $families['has_a4a'] ) {
503            return plugins_url( 'images/jetpack-connect-all.svg', __FILE__ );
504        }
505
506        if ( $families['has_woo'] ) {
507            return plugins_url( 'images/jetpack-connect-woo.svg', __FILE__ );
508        }
509
510        if ( $families['has_a4a'] ) {
511            return plugins_url( 'images/jetpack-connect-a8c.svg', __FILE__ );
512        }
513
514        return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
515    }
516
517    /**
518     * Get the inline (single-row) connector logo for use in compact contexts like table cells.
519     *
520     * All circles are arranged horizontally in a single row, unlike the card
521     * logos which stack circles vertically for 3+ plugins.
522     *
523     * @since 8.5.0
524     *
525     * @return string Logo URL.
526     */
527    public static function get_inline_connector_logo_url() {
528        $families = self::get_connected_plugin_families();
529
530        if ( $families['has_woo'] && $families['has_a4a'] ) {
531            return plugins_url( 'images/jetpack-connect-all-inline.svg', __FILE__ );
532        }
533
534        if ( $families['has_woo'] ) {
535            return plugins_url( 'images/jetpack-connect-woo-inline.svg', __FILE__ );
536        }
537
538        if ( $families['has_a4a'] ) {
539            return plugins_url( 'images/jetpack-connect-a8c-inline.svg', __FILE__ );
540        }
541
542        return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
543    }
544
545    /**
546     * Map a plugin slug to a brand logo URL.
547     *
548     * Jetpack-family plugins get the Jetpack mark, WooCommerce-family
549     * plugins get the Woo mark, and Automattic for Agencies gets the
550     * Automattic mark. Unknown slugs fall through to the
551     * `jetpack_connection_plugin_logo_url` filter so that third-party
552     * plugins can register their own logo. Only SVG URLs are accepted
553     * to keep the icons sharp at every display density.
554     *
555     * @since 8.3.2
556     *
557     * @param string $slug Plugin slug.
558     * @return string|null Logo URL or null.
559     */
560    private static function get_plugin_logo_url( $slug ) {
561        if ( str_starts_with( $slug, 'jetpack' ) ) {
562            return plugins_url( 'images/jetpack-icon.svg', __FILE__ ); // str_starts_with() is polyfilled by WP since 5.9; this code only runs on WP 7.0+.
563        }
564
565        if ( str_starts_with( $slug, 'woocommerce' ) ) {
566            return plugins_url( 'images/woo-icon.svg', __FILE__ );
567        }
568
569        if ( str_starts_with( $slug, 'automattic' ) ) {
570            return plugins_url( 'images/automattic-icon.svg', __FILE__ );
571        }
572
573        /**
574         * Filters a map of plugin slugs to custom logo URLs for the
575         * Settings → Connectors card.
576         *
577         * Add entries as `$slug => $url` pairs. URLs must point to an
578         * SVG file (`.svg` extension required); non-SVG values are
579         * silently ignored and the generic fallback icon is shown.
580         *
581         * Example:
582         *
583         *     add_filter( 'jetpack_connection_plugin_logos', function ( $logos ) {
584         *         $logos['my-plugin'] = plugins_url( 'assets/logo.svg', __FILE__ );
585         *         return $logos;
586         *     } );
587         *
588         * @since 8.3.2
589         *
590         * @param array<string,string> $logos Map of plugin slug to SVG URL.
591         */
592        $logos = apply_filters( 'jetpack_connection_plugin_logos', array() );
593
594        if ( isset( $logos[ $slug ] ) && is_string( $logos[ $slug ] ) && str_ends_with( strtolower( $logos[ $slug ] ), '.svg' ) ) {
595            return esc_url( $logos[ $slug ] );
596        }
597
598        return null;
599    }
600}