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