PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-connection / src / connectors / class-jetpack-connector.php

class-jetpack-connector.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3, at jetpack_vendor/automattic/jetpack-connection/src/connectors/class-jetpack-connector.php

680 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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
12 namespace Automattic\Jetpack\Connection;
13
14 use Automattic\Jetpack\Assets;
15 use Automattic\Jetpack\Identity_Crisis;
16 use Automattic\Jetpack\Modules;
17 use Automattic\Jetpack\Status;
18 use Automattic\Jetpack\Status\Host;
19
20 /**
21 * Jetpack connector card handler.
22 *
23 * @since 8.2.0
24 */
25 class 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 * Screen ID assigned by WordPress to the Gutenberg plugin's connectors submenu page.
43 *
44 * @var string
45 */
46 const GUTENBERG_CONNECTORS_SCREEN_ID = 'settings_page_options-connectors-wp-admin';
47
48 /**
49 * Page slug registered by the Gutenberg plugin for the connectors submenu page.
50 *
51 * @var string
52 */
53 const GUTENBERG_CONNECTORS_PAGE_SLUG = 'options-connectors-wp-admin';
54
55 /**
56 * Initialize the connector.
57 */
58 public static function init() {
59 if ( static::$initialized ) {
60 return;
61 }
62 static::$initialized = true;
63
64 add_action( 'wp_connectors_init', array( static::class, 'register_connector' ), 20 );
65 add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_script_module' ) );
66 add_action( 'jetpack_client_authorize_error', array( static::class, 'store_auth_error' ) );
67 }
68
69 /**
70 * Register Jetpack as a connector in the WP core Connectors screen.
71 *
72 * The wp_connectors_init action is available in WordPress 7.0+.
73 * On older versions this action never fires, so the hook is safely a no-op.
74 *
75 * @since 8.2.0
76 *
77 * @param \WP_Connector_Registry $registry Connector registry instance.
78 */
79 public static function register_connector( $registry ) {
80 $registry->register(
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 // Assets::enqueue_script also loads the stylesheet registered with the handle.
133 if ( static::should_enqueue_protected_owner_dialogs( new Manager() ) ) {
134 Assets::enqueue_script( 'jetpack-connection' );
135 }
136
137 add_filter(
138 'script_module_data_' . static::MODULE_ID,
139 array( static::class, 'get_connector_data' )
140 );
141 }
142
143 /**
144 * Build the data passed to the script module via the script_module_data_ filter.
145 *
146 * @since 8.2.0
147 *
148 * @param array $data Existing script module data.
149 * @return array Filtered script module data.
150 */
151 public static function get_connector_data( $data ) {
152 $manager = new Manager();
153 $is_registered = $manager->is_connected();
154 $is_connected = $is_registered && $manager->has_connected_owner();
155
156 $data['isConnected'] = $is_connected;
157 $data['isRegistered'] = $is_registered;
158 $data['isOfflineMode'] = ( new Status() )->is_offline_mode();
159 $data['isFirstConnection'] = ! $is_registered && ! (bool) \Jetpack_Options::get_option( 'id' );
160 $data['apiRoot'] = esc_url_raw( rest_url() );
161 $data['apiNonce'] = wp_create_nonce( 'wp_rest' );
162 $data['redirectUri'] = static::get_connectors_page_path();
163 $data['connectorName'] = 'Jetpack Connection';
164 $data['connectorDescription'] = __( 'Enhanced functionality for Jetpack and WooCommerce with WordPress.com.', 'jetpack-connection' );
165 $data['connectorLogoUrl'] = static::get_connector_logo_url();
166
167 $data['connectedPlugins'] = static::get_connected_plugins_data( $manager );
168
169 if ( $is_registered ) {
170 $data['siteDetails'] = array(
171 'blogId' => (int) \Jetpack_Options::get_option( 'id' ),
172 'siteUrl' => site_url(),
173 'homeUrl' => home_url(),
174 );
175
176 if ( in_array( 'jetpack', array_column( $data['connectedPlugins'], 'slug' ), true ) ) {
177 $data['ssoStatus'] = ( new Modules() )->is_active( 'sso', false );
178 }
179
180 static::add_identity_crisis_data( $data );
181 }
182
183 if ( $is_connected ) {
184 $data['currentUser'] = static::get_current_user_data( $manager );
185 $data['connectionOwner'] = static::get_connection_owner_data( $manager );
186 }
187
188 $protected_owner = static::get_protected_owner_card_state( $manager );
189 if ( null !== $protected_owner ) {
190 $data['protectedOwner'] = $protected_owner;
191 }
192
193 $host = new Host();
194 $data['isWoaSite'] = $host->is_woa_site();
195 $data['isVipSite'] = $host->is_vip_site();
196
197 $auth_error = static::consume_auth_error();
198 if ( $auth_error ) {
199 $data['authError'] = $auth_error;
200 }
201
202 return $data;
203 }
204
205 /**
206 * Add Jetpack Identity Crisis (Safe Mode) data to the script module data.
207 *
208 * The connector card uses this to swap the status badge to "Safe Mode" and
209 * to render IDC resolution options (migrate / start fresh / stay in safe
210 * mode) in the expanded details. Mirrors the data assembled by
211 * \Automattic\Jetpack\IdentityCrisis\UI::get_initial_state_data().
212 *
213 * @since 8.7.0
214 *
215 * @param array $data Script module data passed by reference.
216 */
217 private static function add_identity_crisis_data( &$data ) {
218 if ( ! class_exists( Identity_Crisis::class ) ) {
219 return;
220 }
221
222 $in_safe_mode = ( new Status() )->in_safe_mode();
223
224 $data['isInSafeMode'] = $in_safe_mode;
225
226 if ( ! $in_safe_mode ) {
227 return;
228 }
229
230 $idc_urls = Identity_Crisis::get_mismatched_urls();
231
232 $data['isSafeModeConfirmed'] = (bool) Identity_Crisis::$is_safe_mode_confirmed;
233 $data['idc'] = array(
234 'currentUrl' => ( is_array( $idc_urls ) && array_key_exists( 'current_url', $idc_urls ) ) ? $idc_urls['current_url'] : home_url(),
235 'wpcomHomeUrl' => ( is_array( $idc_urls ) && array_key_exists( 'wpcom_url', $idc_urls ) ) ? $idc_urls['wpcom_url'] : '',
236 'isDevelopmentSite' => (bool) Status::is_development_site(),
237 'possibleDynamicSiteUrlDetected' => (bool) Identity_Crisis::detect_possible_dynamic_site_url(),
238 );
239 }
240
241 /**
242 * Protected-owner state for the connector card.
243 *
244 * Omitted when nothing requests a protected owner and no anchor is stored,
245 * so the card keeps its current account sections. The status is
246 * Manager::resolve_protected_owner_state(), which the card switches on.
247 *
248 * `viewerIsConfirmedOwner` is that method's `is_current_user_the_po`. It tells the recovery
249 * copy apart: an owner whose own token broke is asked to reconnect, not to connect.
250 *
251 * @since 9.9.0
252 *
253 * @param Manager $manager Connection manager instance.
254 * @return array{status: string, viewerIsConfirmedOwner: bool}|null
255 */
256 private static function get_protected_owner_card_state( $manager ) {
257 $requires = $manager->requires_protected_owner();
258 $anchor = Protected_Owner::get_locked();
259
260 if ( ! $requires && ! $anchor ) {
261 return null;
262 }
263
264 $state = $manager->resolve_protected_owner_state();
265
266 // No anchor and this viewer cannot confirm: leave the card as it is.
267 if ( ! $anchor && Manager::PO_STATE_NOT_ELIGIBLE === $state['status'] ) {
268 return null;
269 }
270
271 return array(
272 'status' => $state['status'],
273 'viewerIsConfirmedOwner' => $state['is_current_user_the_po'],
274 );
275 }
276
277 /**
278 * Whether the card can open one of the shared protected-owner dialogs.
279 *
280 * Confirming is only reachable for a connected administrator, on a site that has asked for a
281 * protected owner and does not have one yet. Releasing is only reachable for the confirmed
282 * owner, and deliberately does not depend on a consumer still asking: a site has to be able
283 * to give up a lock after the plugin that wanted it is gone.
284 *
285 * Both read the one state call rather than asking again, so neither adds a WordPress.com
286 * round trip to a screen load.
287 *
288 * The card always uses the package dialogs. `jetpack_connection_protected_owner_default_ui`
289 * is for a consumer's own surface, not this one.
290 *
291 * @since 9.9.0
292 *
293 * @param Manager $manager Connection manager instance.
294 * @return bool
295 */
296 private static function should_enqueue_protected_owner_dialogs( $manager ) {
297 $state = $manager->resolve_protected_owner_state();
298
299 if ( $manager->requires_protected_owner() && Manager::PO_STATE_CAN_ESTABLISH === $state['status'] ) {
300 return true;
301 }
302
303 // `RE_EVALUATE` is the anchored state where the connection owner matches the anchor, so
304 // pinning the viewer to that owner is the same gate the release endpoint applies — a
305 // matching binding alone would offer a dialog the endpoint then refuses.
306 return Manager::PO_STATE_RE_EVALUATE === $state['status']
307 && get_current_user_id() === (int) $manager->get_connection_owner_id();
308 }
309
310 /**
311 * Get the current (logged-in) user's connection details.
312 *
313 * @param Manager $manager Connection manager instance.
314 * @return array|null Current user data or null if not connected.
315 */
316 private static function get_current_user_data( $manager ) {
317 $user_id = get_current_user_id();
318
319 if ( ! $user_id || ! $manager->is_user_connected( $user_id ) ) {
320 return null;
321 }
322
323 $user = get_userdata( $user_id );
324 $user_info = static::resolve_user_fields( $user, $manager->get_connected_user_data( $user_id ) );
325 $is_owner = $manager->is_connection_owner( $user_id );
326
327 $has_other_connected_users = false;
328 if ( $is_owner ) {
329 $connected_users = $manager->get_connected_users( 'any', 2 );
330 $has_other_connected_users = count( $connected_users ) > 1;
331 }
332
333 return array_merge(
334 $user_info,
335 array(
336 'isOwner' => $is_owner,
337 'hasOtherConnectedUsers' => $has_other_connected_users,
338 )
339 );
340 }
341
342 /**
343 * Get the connection owner details for the script module.
344 *
345 * @param Manager $manager Connection manager instance.
346 * @return array|null Owner data or null if unavailable.
347 */
348 private static function get_connection_owner_data( $manager ) {
349 $owner = $manager->get_connection_owner();
350
351 if ( false === $owner ) {
352 return null;
353 }
354
355 $fields = static::resolve_user_fields( $owner, $manager->get_connected_user_data( $owner->ID ) );
356
357 $fields['localLogin'] = $owner->user_login;
358
359 return $fields;
360 }
361
362 /**
363 * Merge local WP user fields with WordPress.com user data.
364 *
365 * WPCOM values take precedence when available. Returns the common
366 * user shape used by both currentUser and connectionOwner.
367 *
368 * @param \WP_User|false $wp_user Local WordPress user object (false if unavailable).
369 * @param array|false $wpcom_user_data WPCOM user data from the connection manager.
370 * @return array User data with displayName, login, email, and avatar.
371 */
372 private static function resolve_user_fields( $wp_user, $wpcom_user_data ) {
373 $display_name = $wp_user ? $wp_user->display_name : '';
374 $login = $wp_user ? $wp_user->user_login : '';
375 $email = $wp_user ? $wp_user->user_email : '';
376
377 if ( is_array( $wpcom_user_data ) ) {
378 if ( ! empty( $wpcom_user_data['display_name'] ) ) {
379 $display_name = $wpcom_user_data['display_name'];
380 }
381 if ( ! empty( $wpcom_user_data['login'] ) ) {
382 $login = $wpcom_user_data['login'];
383 }
384 if ( ! empty( $wpcom_user_data['email'] ) ) {
385 $email = $wpcom_user_data['email'];
386 }
387 }
388
389 $user_id = $wp_user ? $wp_user->ID : 0;
390
391 return array(
392 'displayName' => $display_name,
393 'login' => $login,
394 'email' => $email,
395 'avatar' => $user_id
396 ? get_avatar_url(
397 $user_id,
398 array(
399 'size' => 48,
400 'default' => 'mysteryman',
401 )
402 )
403 : '',
404 );
405 }
406
407 /**
408 * Check whether the given screen is the Connectors settings page.
409 *
410 * Handles both WP 7.0 core (`options-connectors`) and the Gutenberg
411 * plugin (`settings_page_options-connectors-wp-admin`).
412 *
413 * @param \WP_Screen $screen Current admin screen.
414 * @return bool
415 */
416 private static function is_connectors_screen( $screen ) {
417 return 'options-connectors' === $screen->id
418 || static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id;
419 }
420
421 /**
422 * Return the admin-relative path for the Connectors page.
423 *
424 * WP 7.0 core uses the standalone `options-connectors.php` file while
425 * the Gutenberg plugin registers a submenu page under options-general.php
426 * with slug `options-connectors-wp-admin`. Both set parent_file to
427 * `options-general.php` for menu highlighting, so we distinguish them by
428 * checking the actual script filename being served.
429 *
430 * Note: for the Gutenberg case we use the registered page slug directly,
431 * not `$screen->id`. WordPress auto-prefixes screen IDs for submenu pages
432 * (e.g. `settings_page_options-connectors-wp-admin`), so using `$screen->id`
433 * as the `page=` parameter produces an invalid URL.
434 *
435 * The result is suitable for the `redirect_uri` parameter accepted by the
436 * `jetpack/v4/connection/register` REST endpoint (which wraps it in `admin_url()`).
437 *
438 * @return string Admin-relative path, e.g. 'options-connectors.php'.
439 */
440 private static function get_connectors_page_path() {
441 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only compared against a hardcoded string.
442 $script = isset( $_SERVER['SCRIPT_NAME'] ) ? wp_basename( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) : '';
443
444 if ( 'options-connectors.php' === $script ) {
445 return 'options-connectors.php';
446 }
447
448 // Gutenberg plugin registers the page under options-general.php.
449 $screen = get_current_screen();
450 if ( $screen && static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id ) {
451 return 'options-general.php?page=' . static::GUTENBERG_CONNECTORS_PAGE_SLUG;
452 }
453
454 return 'options-connectors.php';
455 }
456
457 /**
458 * Store an authorization error in a short-lived transient.
459 *
460 * Hooked to `jetpack_client_authorize_error` which fires when
461 * the auth webhook fails. The transient is read on the next
462 * Connectors page load so the JS card can display the error.
463 *
464 * @since 8.2.0
465 *
466 * @param \WP_Error $error Authorization error.
467 */
468 public static function store_auth_error( $error ) {
469 if ( is_wp_error( $error ) ) {
470 $user_id = get_current_user_id();
471 if ( $user_id ) {
472 set_transient(
473 'jetpack_connector_auth_error_' . $user_id,
474 $error->get_error_message(),
475 60
476 );
477 }
478 }
479 }
480
481 /**
482 * Read and delete a stored authorization error for the current user.
483 *
484 * @return string|false Error message or false if none.
485 */
486 private static function consume_auth_error() {
487 $user_id = get_current_user_id();
488 if ( ! $user_id ) {
489 return false;
490 }
491
492 $key = 'jetpack_connector_auth_error_' . $user_id;
493 $error = get_transient( $key );
494 if ( false !== $error ) {
495 delete_transient( $key );
496 }
497
498 return $error;
499 }
500
501 /**
502 * Get connected plugins data for the script module.
503 *
504 * @param Manager $manager Connection manager instance.
505 * @return array List of connected plugin data.
506 */
507 private static function get_connected_plugins_data( $manager ) {
508 $plugins = $manager->get_connected_plugins();
509
510 if ( is_wp_error( $plugins ) || ! is_array( $plugins ) ) {
511 return array();
512 }
513
514 $result = array();
515
516 foreach ( $plugins as $slug => $plugin_data ) {
517 $name = $plugin_data['name'] ?? $slug;
518
519 $entry = array(
520 'name' => $name,
521 'slug' => $slug,
522 );
523
524 $logo_url = static::get_plugin_logo_url( $slug );
525 if ( $logo_url ) {
526 $entry['logoUrl'] = $logo_url;
527 }
528
529 $result[] = $entry;
530 }
531
532 return $result;
533 }
534
535 /**
536 * Detect which plugin families are using the connection.
537 *
538 * @since 8.5.0
539 *
540 * @return array{has_woo: bool, has_a4a: bool}
541 */
542 public static function get_connected_plugin_families() {
543 $plugins = Plugin_Storage::get_all();
544
545 $has_woo = false;
546 $has_a4a = false;
547
548 if ( is_array( $plugins ) ) {
549 foreach ( array_keys( $plugins ) as $slug ) {
550 if ( str_starts_with( $slug, 'woocommerce' ) ) {
551 $has_woo = true;
552 }
553 if ( str_starts_with( $slug, 'automattic' ) ) {
554 $has_a4a = true;
555 }
556 }
557 }
558
559 return array(
560 'has_woo' => $has_woo,
561 'has_a4a' => $has_a4a,
562 );
563 }
564
565 /**
566 * Determine the connector card logo based on which plugin families are connected.
567 *
568 * Priority:
569 * 1. Both Woo-family and A4A plugins → jetpack-connect-all.svg
570 * 2. Woo-family only → jetpack-connect-woo.svg
571 * 3. A4A only → jetpack-connect-a8c.svg
572 * 4. Default (Jetpack only or other) → jetpack-connect.svg
573 *
574 * @since 8.3.2
575 *
576 * @return string Logo URL.
577 */
578 public static function get_connector_logo_url() {
579 $families = self::get_connected_plugin_families();
580
581 if ( $families['has_woo'] && $families['has_a4a'] ) {
582 return plugins_url( 'images/jetpack-connect-all.svg', __FILE__ );
583 }
584
585 if ( $families['has_woo'] ) {
586 return plugins_url( 'images/jetpack-connect-woo.svg', __FILE__ );
587 }
588
589 if ( $families['has_a4a'] ) {
590 return plugins_url( 'images/jetpack-connect-a8c.svg', __FILE__ );
591 }
592
593 return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
594 }
595
596 /**
597 * Get the inline (single-row) connector logo for use in compact contexts like table cells.
598 *
599 * All circles are arranged horizontally in a single row, unlike the card
600 * logos which stack circles vertically for 3+ plugins.
601 *
602 * @since 8.5.0
603 *
604 * @return string Logo URL.
605 */
606 public static function get_inline_connector_logo_url() {
607 $families = self::get_connected_plugin_families();
608
609 if ( $families['has_woo'] && $families['has_a4a'] ) {
610 return plugins_url( 'images/jetpack-connect-all-inline.svg', __FILE__ );
611 }
612
613 if ( $families['has_woo'] ) {
614 return plugins_url( 'images/jetpack-connect-woo-inline.svg', __FILE__ );
615 }
616
617 if ( $families['has_a4a'] ) {
618 return plugins_url( 'images/jetpack-connect-a8c-inline.svg', __FILE__ );
619 }
620
621 return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
622 }
623
624 /**
625 * Map a plugin slug to a brand logo URL.
626 *
627 * Jetpack-family plugins get the Jetpack mark, WooCommerce-family
628 * plugins get the Woo mark, and Automattic for Agencies gets the
629 * Automattic mark. Unknown slugs fall through to the
630 * `jetpack_connection_plugin_logo_url` filter so that third-party
631 * plugins can register their own logo. Only SVG URLs are accepted
632 * to keep the icons sharp at every display density.
633 *
634 * @since 8.3.2
635 *
636 * @param string $slug Plugin slug.
637 * @return string|null Logo URL or null.
638 */
639 private static function get_plugin_logo_url( $slug ) {
640 if ( str_starts_with( $slug, 'jetpack' ) ) {
641 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+.
642 }
643
644 if ( str_starts_with( $slug, 'woocommerce' ) ) {
645 return plugins_url( 'images/woo-icon.svg', __FILE__ );
646 }
647
648 if ( str_starts_with( $slug, 'automattic' ) ) {
649 return plugins_url( 'images/automattic-icon.svg', __FILE__ );
650 }
651
652 /**
653 * Filters a map of plugin slugs to custom logo URLs for the
654 * Settings → Connectors card.
655 *
656 * Add entries as `$slug => $url` pairs. URLs must point to an
657 * SVG file (`.svg` extension required); non-SVG values are
658 * silently ignored and the generic fallback icon is shown.
659 *
660 * Example:
661 *
662 * add_filter( 'jetpack_connection_plugin_logos', function ( $logos ) {
663 * $logos['my-plugin'] = plugins_url( 'assets/logo.svg', __FILE__ );
664 * return $logos;
665 * } );
666 *
667 * @since 8.3.2
668 *
669 * @param array<string,string> $logos Map of plugin slug to SVG URL.
670 */
671 $logos = apply_filters( 'jetpack_connection_plugin_logos', array() );
672
673 if ( isset( $logos[ $slug ] ) && is_string( $logos[ $slug ] ) && str_ends_with( strtolower( $logos[ $slug ] ), '.svg' ) ) {
674 return esc_url( $logos[ $slug ] );
675 }
676
677 return null;
678 }
679 }
680