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