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