PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
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 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-admin-ui / src / class-admin-menu.php

class-admin-menu.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at jetpack_vendor/automattic/jetpack-admin-ui/src/class-admin-menu.php

634 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin Menu Registration
4 *
5 * @package automattic/jetpack-admin-ui
6 */
7
8 namespace Automattic\Jetpack\Admin_UI;
9
10 use Automattic\Jetpack\Tracking;
11 use Jetpack_Options;
12 use Jetpack_Tracks_Client;
13
14 /**
15 * This class offers a wrapper to add_submenu_page and makes sure stand-alone plugin's menu items are always added under the Jetpack top level menu.
16 * If the Jetpack top level was not previously registered by other plugin, it will be registered here.
17 */
18 class Admin_Menu {
19
20 const PACKAGE_VERSION = '0.11.3';
21
22 /**
23 * Slug used for the upgrade menu item and redirect URL.
24 *
25 * Keep the slug in sync with `$upgrade-menu-slug` at admin-ui-upgrade-menu.scss
26 *
27 * @var string
28 */
29 const UPGRADE_MENU_SLUG = 'jetpack-wpadmin-sidebar-free-plan-upsell-menu-item';
30
31 /**
32 * Fallback upgrade URL when the Redirect class is unavailable.
33 *
34 * @var string
35 */
36 const UPGRADE_MENU_FALLBACK_URL = 'https://jetpack.com/upgrade/';
37
38 /**
39 * Handle for the shared, token-only WPDS design-tokens stylesheet.
40 *
41 * Registered once and enqueued on every Jetpack admin page so that
42 * `var(--wpds-*)` values resolve at runtime instead of falling back to
43 * their hand-written hex defaults.
44 *
45 * @var string
46 */
47 const DESIGN_TOKENS_HANDLE = 'jetpack-admin-ui-design-tokens';
48
49 /**
50 * Handle for the source-less stylesheet that hides WordPress core admin notices.
51 *
52 * @var string
53 */
54 const HIDE_CORE_NOTICES_HANDLE = 'jetpack-admin-ui-hide-core-notices';
55
56 /**
57 * Whether this class has been initialized
58 *
59 * @var boolean
60 */
61 private static $initialized = false;
62
63 /**
64 * List of menu items enqueued to be added
65 *
66 * @var array
67 */
68 private static $menu_items = array();
69
70 /**
71 * Hook suffixes of the pages registered through this class.
72 *
73 * Used to scope the design-tokens stylesheet to Jetpack admin pages.
74 *
75 * @var array
76 */
77 private static $page_hooks = array();
78
79 /**
80 * Optional connection manager dependency.
81 *
82 * @var object|null
83 */
84 private static $connection_manager = null;
85
86 /**
87 * Initialize the class and set up the main hook
88 *
89 * @return void
90 */
91 public static function init() {
92 if ( ! self::$initialized ) {
93 self::$initialized = true;
94 self::handle_akismet_menu();
95 add_action( 'admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
96 add_action( 'network_admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
97 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'add_upgrade_menu_item_styles' ) );
98 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_design_tokens' ) );
99 }
100 }
101
102 /**
103 * Handles the Akismet menu item when used alongside other stand-alone plugins
104 *
105 * When Jetpack plugin is present, Akismet menu item is moved under the Jetpack top level menu, but if Akismet is active alongside other stand-alone plugins,
106 * we use this method to move the menu item.
107 */
108 private static function handle_akismet_menu() {
109 if ( class_exists( 'Akismet_Admin' ) ) {
110 add_action(
111 'admin_menu',
112 function () {
113 // Prevent Akismet from adding a menu item.
114 remove_action( 'admin_menu', array( 'Akismet_Admin', 'admin_menu' ), 5 );
115
116 // Add an Anti-spam menu item for Jetpack.
117 self::add_menu( __( 'Akismet Anti-spam', 'jetpack-admin-ui' ), __( 'Akismet Anti-spam', 'jetpack-admin-ui' ), 'manage_options', 'akismet-key-config', array( 'Akismet_Admin', 'display_page' ) );
118 },
119 4
120 );
121
122 }
123 }
124
125 /**
126 * Callback to the admin_menu and network_admin_menu hooks that will register the enqueued menu items
127 *
128 * @return void
129 */
130 public static function admin_menu_hook_callback() {
131 $can_see_toplevel_menu = true;
132 $jetpack_plugin_present = class_exists( 'Jetpack_React_Page' );
133 $icon = method_exists( '\Automattic\Jetpack\Assets\Logo', 'get_base64_logo' )
134 ? ( new \Automattic\Jetpack\Assets\Logo() )->get_base64_logo()
135 : 'dashicons-admin-plugins';
136
137 if ( ! $jetpack_plugin_present ) {
138 add_menu_page(
139 'Jetpack',
140 'Jetpack',
141 'edit_posts',
142 'jetpack',
143 '__return_null',
144 $icon,
145 3
146 );
147
148 // If Jetpack plugin is not present, user will only be able to see this menu if they have enough capability to at least one of the sub menus being added.
149 $can_see_toplevel_menu = false;
150 }
151
152 /**
153 * The add_sub_menu function has a bug and will not keep the right order of menu items.
154 *
155 * @see https://core.trac.wordpress.org/ticket/52035
156 * Let's order the items before registering them.
157 * Since this all happens after the Jetpack plugin menu items were added, all items will be added after Jetpack plugin items - unless position is very low number (smaller than the number of menu items present in Jetpack plugin).
158 */
159 usort(
160 self::$menu_items,
161 function ( $a, $b ) {
162 $position_a = empty( $a['position'] ) ? 0 : $a['position'];
163 $position_b = empty( $b['position'] ) ? 0 : $b['position'];
164 $result = $position_a <=> $position_b;
165
166 if ( 0 === $result ) {
167 // Case-insensitive and number-aware, so "eCommerce" sorts with the Es.
168 // Still a byte compare: a leading accented character sorts after Z.
169 $result = strnatcasecmp( $a['menu_title'], $b['menu_title'] );
170 }
171
172 return $result;
173 }
174 );
175
176 foreach ( self::$menu_items as $menu_item ) {
177 if ( ! current_user_can( $menu_item['capability'] ) ) {
178 continue;
179 }
180
181 $can_see_toplevel_menu = true;
182
183 add_submenu_page(
184 'jetpack',
185 $menu_item['page_title'],
186 $menu_item['menu_title'],
187 $menu_item['capability'],
188 $menu_item['menu_slug'],
189 $menu_item['function'],
190 $menu_item['position']
191 );
192 }
193
194 if ( ! $jetpack_plugin_present ) {
195 remove_submenu_page( 'jetpack', 'jetpack' );
196 }
197
198 if ( ! $can_see_toplevel_menu ) {
199 remove_menu_page( 'jetpack' );
200 }
201
202 self::maybe_add_upgrade_menu_item();
203 }
204
205 /**
206 * Adds a new submenu to the Jetpack Top level menu
207 *
208 * The parameters this method accepts are the same as @see add_submenu_page. This class will
209 * aggreagate all menu items registered by stand-alone plugins and make sure they all go under the same
210 * Jetpack top level menu. It will also handle the top level menu registration in case the Jetpack plugin is not present.
211 *
212 * @param string $page_title The text to be displayed in the title tags of the page when the menu
213 * is selected.
214 * @param string $menu_title The text to be used for the menu.
215 * @param string $capability The capability required for this menu to be displayed to the user.
216 * @param string $menu_slug The slug name to refer to this menu by. Should be unique for this menu
217 * and only include lowercase alphanumeric, dashes, and underscores characters
218 * to be compatible with sanitize_key().
219 * @param callable|null $function The function to be called to output the content for this page.
220 * @param int $position The position in the menu order this item should appear. Leave empty typically.
221 *
222 * @return string The resulting page's hook_suffix
223 */
224 public static function add_menu( $page_title, $menu_title, $capability, $menu_slug, $function, $position = null ) {
225 self::init();
226 self::$menu_items[] = compact( 'page_title', 'menu_title', 'capability', 'menu_slug', 'function', 'position' );
227
228 /**
229 * Let's return the page hook so consumers can use.
230 * We know all pages will be under Jetpack top level menu page, so we can hardcode the first part of the string.
231 * Using get_plugin_page_hookname here won't work because the top level page is not registered yet.
232 */
233 $hook = 'jetpack_page_' . $menu_slug;
234
235 // Track the page hook so the design-tokens stylesheet can be scoped to it.
236 self::$page_hooks[] = $hook;
237
238 // Hide WordPress core admin notices on this Jetpack page. The load-<hook>
239 // action only fires when the matching screen is being rendered, so this
240 // stays scoped to Jetpack pages and reaches every page registered here.
241 add_action( 'load-' . $hook, array( __CLASS__, 'hide_core_admin_notices' ) );
242 add_action( 'load-' . $hook . '-network', array( __CLASS__, 'hide_core_admin_notices' ) );
243
244 return $hook;
245 }
246
247 /**
248 * Enqueues the stylesheet that hides WordPress core admin notices on the current Jetpack page.
249 *
250 * Hooked from the page's load-<hook> action so it only runs on Jetpack screens. That action
251 * runs before admin_enqueue_scripts, so the handle is queued in time to be printed.
252 *
253 * @return void
254 */
255 public static function hide_core_admin_notices() {
256 // wp_add_inline_style() appends, so the CSS is attached only while the handle is new to the request.
257 if ( ! wp_style_is( self::HIDE_CORE_NOTICES_HANDLE, 'registered' ) ) {
258 wp_register_style( self::HIDE_CORE_NOTICES_HANDLE, false, array(), self::PACKAGE_VERSION );
259 wp_add_inline_style( self::HIDE_CORE_NOTICES_HANDLE, self::get_hide_core_admin_notices_styles() );
260 }
261
262 wp_enqueue_style( self::HIDE_CORE_NOTICES_HANDLE );
263 }
264
265 /**
266 * Enqueues the CSS that hides WordPress core admin notices.
267 *
268 * Callers must run this before WordPress flushes the style queue in
269 * print_admin_styles() (admin_print_styles, priority 20). Later than that,
270 * the handle is never printed. The previous admin_print_styles priority-10
271 * hook still works.
272 *
273 * @deprecated 0.10.0 Use hide_core_admin_notices(), which enqueues the CSS.
274 *
275 * @return void
276 */
277 public static function print_hide_core_admin_notices_style() {
278 _deprecated_function( __METHOD__, 'admin-ui-0.10.0', __CLASS__ . '::hide_core_admin_notices' );
279 self::hide_core_admin_notices();
280 }
281
282 /**
283 * Gets the CSS that hides WordPress core admin notices.
284 *
285 * We only target direct children of #wpbody-content (where core renders notices via the
286 * admin_notices / all_admin_notices hooks). This intentionally leaves JITMs untouched —
287 * they output `.jetpack-jitm-message`, not `.notice` — and leaves in-app/React notices
288 * untouched, since those render deeper inside `.wrap`. The CSS rides on a source-less
289 * handle rather than a build asset so it also reaches older Jetpack pages that ship no
290 * stylesheet of their own.
291 *
292 * @return string CSS rules.
293 */
294 private static function get_hide_core_admin_notices_styles() {
295 return '
296 #wpbody-content > .notice,
297 #wpbody-content > .update-nag,
298 #wpbody-content > .updated,
299 #wpbody-content > .error { display: none !important; }
300 ';
301 }
302
303 /**
304 * Removes an already added submenu
305 *
306 * @param string $menu_slug The slug of the submenu to remove.
307 *
308 * @return array|false The removed submenu on success, false if not found.
309 */
310 public static function remove_menu( $menu_slug ) {
311
312 foreach ( self::$menu_items as $index => $menu_item ) {
313 if ( $menu_item['menu_slug'] === $menu_slug ) {
314 unset( self::$menu_items[ $index ] );
315
316 return $menu_item;
317 }
318 }
319
320 return false;
321 }
322
323 /**
324 * Gets the slug for the first item under the Jetpack top level menu
325 *
326 * @return string|null
327 */
328 public static function get_top_level_menu_item_slug() {
329 global $submenu;
330 if ( ! empty( $submenu['jetpack'] ) ) {
331 $item = reset( $submenu['jetpack'] );
332 if ( isset( $item[2] ) ) {
333 return $item[2];
334 }
335 }
336 }
337
338 /**
339 * Gets the URL for the first item under the Jetpack top level menu
340 *
341 * @param string $fallback If Jetpack menu is not there or no children is found, return this fallback instead. Default to admin_url().
342 * @return string
343 */
344 public static function get_top_level_menu_item_url( $fallback = false ) {
345 $slug = self::get_top_level_menu_item_slug();
346
347 if ( $slug ) {
348 $url = menu_page_url( $slug, false );
349 return $url;
350 }
351
352 $url = $fallback ? $fallback : admin_url();
353 return $url;
354 }
355
356 /**
357 * Checks whether the current site should show the upgrade menu item.
358 *
359 * The upgrade menu is only shown to administrators on free-plan sites
360 * that are not hosted on WordPress.com.
361 *
362 * @return bool True if the upgrade menu should be shown.
363 */
364 private static function should_show_upgrade_menu() {
365
366 // Only show to administrators.
367 if ( ! current_user_can( 'manage_options' ) ) {
368 return false;
369 }
370
371 // Don't show upsells on WordPress.com platform.
372 if ( class_exists( '\Automattic\Jetpack\Status\Host' ) ) {
373 $host = new \Automattic\Jetpack\Status\Host();
374 if ( $host->is_wpcom_platform() ) {
375 return false;
376 }
377 }
378
379 // Don't show upsells in offline/development mode.
380 if ( class_exists( '\Automattic\Jetpack\Status' ) ) {
381 $status = new \Automattic\Jetpack\Status();
382 if ( $status->is_offline_mode() ) {
383 return false;
384 }
385 }
386
387 // Only show after the site and current user are connected.
388 if ( ! self::is_site_and_user_connected() ) {
389 return false;
390 }
391
392 // Only show to free-plan sites.
393 return self::is_free_plan();
394 }
395
396 /**
397 * Checks whether the site and current user are connected to WordPress.com.
398 *
399 * @return bool True if site and current user are connected.
400 */
401 private static function is_site_and_user_connected() {
402 $connection_manager = self::$connection_manager;
403 if ( ! $connection_manager && class_exists( '\Automattic\Jetpack\Connection\Manager' ) ) {
404 $connection_manager = new \Automattic\Jetpack\Connection\Manager();
405 self::$connection_manager = $connection_manager;
406 }
407
408 if (
409 $connection_manager
410 && is_callable( array( $connection_manager, 'is_connected' ) )
411 && is_callable( array( $connection_manager, 'is_user_connected' ) )
412 ) {
413 return (bool) $connection_manager->is_connected()
414 && (bool) $connection_manager->is_user_connected( get_current_user_id() );
415 }
416
417 return false;
418 }
419
420 /**
421 * Sets the connection manager dependency; used by tests.
422 *
423 * @param object|null $connection_manager Connection manager object.
424 * @return void
425 */
426 public static function set_connection_manager( $connection_manager ) {
427 self::$connection_manager = $connection_manager;
428 }
429
430 /**
431 * Checks whether the current site is on a free Jetpack plan with no active paid license.
432 *
433 * @return bool True if the site has no paid plan.
434 */
435 private static function is_free_plan() {
436 // Check the active plan - use the is_free field or product_slug.
437 $plan = get_option( 'jetpack_active_plan', array() );
438
439 // Back-compat: older plan payloads use class to indicate paid plans.
440 if ( isset( $plan['class'] ) && 'free' !== $plan['class'] ) {
441 return false;
442 }
443
444 // If the plan explicitly says it's not free, trust that.
445 if ( isset( $plan['is_free'] ) && false === $plan['is_free'] ) {
446 return false;
447 }
448
449 // Check if the product slug indicates a paid plan.
450 if ( isset( $plan['product_slug'] ) && 'jetpack_free' !== $plan['product_slug'] ) {
451 return false;
452 }
453
454 // Also check for site products (licenses can add products without changing plan).
455 $products = get_option( 'jetpack_site_products', array() );
456 if ( ! empty( $products ) && is_array( $products ) ) {
457 return false;
458 }
459
460 return true;
461 }
462
463 /**
464 * Conditionally adds an "Upgrade Jetpack" submenu item for free-plan sites.
465 *
466 * Only shown to users with manage_options capability on self-hosted sites without a paid Jetpack plan or license.
467 *
468 * @return void
469 */
470 private static function maybe_add_upgrade_menu_item() {
471 if ( ! self::should_show_upgrade_menu() ) {
472 return;
473 }
474
475 $upgrade_url = class_exists( '\Automattic\Jetpack\Redirect' )
476 ? \Automattic\Jetpack\Redirect::get_url( self::UPGRADE_MENU_SLUG )
477 : self::UPGRADE_MENU_FALLBACK_URL;
478
479 $menu_title = esc_html__( 'Upgrade Jetpack', 'jetpack-admin-ui' );
480
481 add_submenu_page(
482 'jetpack',
483 $menu_title,
484 $menu_title,
485 'manage_options',
486 esc_url( $upgrade_url ),
487 null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
488 999
489 );
490
491 // Add a CSS class to the <li> element so styles can target it precisely.
492 global $submenu;
493 if ( ! empty( $submenu['jetpack'] ) ) {
494 foreach ( $submenu['jetpack'] as $index => $item ) {
495 if ( isset( $item[2] ) && false !== strpos( $item[2], self::UPGRADE_MENU_SLUG ) ) {
496 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
497 $submenu['jetpack'][ $index ][4] = ( ! empty( $item[4] ) ? $item[4] . ' ' : '' ) . self::UPGRADE_MENU_SLUG;
498 break;
499 }
500 }
501 }
502 }
503
504 /**
505 * Enqueues admin styles for the "Upgrade Jetpack" menu item.
506 *
507 * The sidebar menu is visible on every admin page, so styles load globally.
508 * Only enqueues for free-plan sites on self-hosted installs.
509 *
510 * @return void
511 */
512 public static function add_upgrade_menu_item_styles() {
513 if ( ! self::should_show_upgrade_menu() ) {
514 return;
515 }
516
517 $asset_file = dirname( __DIR__ ) . '/build/admin-ui-upgrade-menu.asset.php';
518 $asset = file_exists( $asset_file ) ? require $asset_file : array();
519
520 wp_enqueue_style(
521 'jetpack-admin-ui-upgrade-menu',
522 plugins_url( '../build/admin-ui-upgrade-menu.css', __FILE__ ),
523 $asset['dependencies'] ?? array(),
524 $asset['version'] ?? self::PACKAGE_VERSION
525 );
526
527 self::enqueue_upgrade_menu_tracks_script( $asset );
528 }
529
530 /**
531 * Enqueues the shared, token-only WPDS design-tokens stylesheet.
532 *
533 * Single entry point for any consumer that needs WPDS `var(--wpds-*)` values
534 * to resolve at runtime on a Jetpack admin page. Registers the handle on
535 * first use (idempotent) and enqueues it; the caller is responsible for
536 * scoping the call to the right page(s). Since admin-ui is a dependency of
537 * the Jetpack plugin and the modernized packages, both the plugin's
538 * legacy/wrap_ui gate and this package's own dashboards call through here,
539 * so the handle has a single owner and there is no duplicated enqueue logic.
540 *
541 * @return void
542 */
543 public static function enqueue_design_tokens() {
544 self::register_design_tokens_style();
545 wp_enqueue_style( self::DESIGN_TOKENS_HANDLE );
546 }
547
548 /**
549 * Registers the shared, token-only WPDS design-tokens stylesheet.
550 *
551 * The stylesheet only defines `:root{--wpds-*}` custom properties (no
552 * component or class styles), giving every Jetpack admin page a single
553 * runtime source for design tokens. It is safe to call repeatedly:
554 * wp_register_style() is a no-op once the handle is registered.
555 *
556 * @return void
557 */
558 private static function register_design_tokens_style() {
559 if ( wp_style_is( self::DESIGN_TOKENS_HANDLE, 'registered' ) ) {
560 return;
561 }
562
563 $asset_file = dirname( __DIR__ ) . '/build/design-tokens.asset.php';
564 $asset = file_exists( $asset_file ) ? require $asset_file : array();
565
566 wp_register_style(
567 self::DESIGN_TOKENS_HANDLE,
568 plugins_url( '../build/design-tokens.css', __FILE__ ),
569 $asset['dependencies'] ?? array(),
570 $asset['version'] ?? self::PACKAGE_VERSION
571 );
572 }
573
574 /**
575 * Enqueues the design tokens on the pages registered through this class.
576 *
577 * This is the admin_enqueue_scripts callback for the modernized Jetpack
578 * dashboards. Scoped to self::$page_hooks so the tokens load wherever a
579 * modernized dashboard renders, regardless of plan or connection state; the
580 * actual enqueue is delegated to the reusable enqueue_design_tokens() API.
581 *
582 * @param string $hook_suffix The current admin page's hook suffix.
583 * @return void
584 */
585 public static function maybe_enqueue_design_tokens( $hook_suffix ) {
586 if ( ! in_array( $hook_suffix, self::$page_hooks, true ) ) {
587 return;
588 }
589
590 self::enqueue_design_tokens();
591 }
592
593 /**
594 * Enqueues Tracks for the upgrade submenu item.
595 *
596 * @param array $asset Parsed contents of admin-ui-upgrade-menu.asset.php.
597 * @return void
598 */
599 private static function enqueue_upgrade_menu_tracks_script( $asset ) {
600 if ( ! class_exists( '\Automattic\Jetpack\Tracking' ) ) {
601 return;
602 }
603
604 Tracking::register_tracks_functions_scripts( true );
605
606 wp_enqueue_script(
607 'jetpack-admin-ui-upgrade-menu-tracking',
608 plugins_url( '../build/admin-ui-upgrade-menu-tracking.js', __FILE__ ),
609 $asset['dependencies'] ?? array(),
610 $asset['version'] ?? self::PACKAGE_VERSION,
611 true
612 );
613
614 $current_screen = get_current_screen();
615 $is_admin = current_user_can( 'jetpack_disconnect' );
616 $site_id = class_exists( 'Jetpack_Options' ) ? Jetpack_Options::get_option( 'id' ) : null;
617 $tracks_user_data = class_exists( 'Jetpack_Tracks_Client' ) ? Jetpack_Tracks_Client::get_connected_user_tracks_identity() : null;
618
619 wp_localize_script(
620 'jetpack-admin-ui-upgrade-menu-tracking',
621 'jetpackAdminUiUpgradeMenu',
622 array(
623 'menuItemClass' => self::UPGRADE_MENU_SLUG,
624 'tracksUserData' => $tracks_user_data,
625 'tracksEventData' => array(
626 'is_admin' => $is_admin,
627 'current_screen' => $current_screen ? $current_screen->id : false,
628 'blog_id' => $site_id,
629 ),
630 )
631 );
632 }
633 }
634