PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
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
← All changes | jetpack_vendor/automattic/jetpack-admin-ui/src/class-admin-menu.php +414 -20 16.2 → 16.3-a.5 View file →
@@ -6,8 +6,9 @@
6 6 */
7 7
8 8 namespace Automattic\Jetpack\Admin_UI;
9 9
10 +use Automattic\Jetpack\Feature_Policy;
10 11 use Automattic\Jetpack\Tracking;
11 12 use Jetpack_Options;
12 13 use Jetpack_Tracks_Client;
13 14
@@ -16,9 +17,9 @@
16 17 * If the Jetpack top level was not previously registered by other plugin, it will be registered here.
17 18 */
18 19 class Admin_Menu {
19 20
20 - const PACKAGE_VERSION = '0.11.3';
21 + const PACKAGE_VERSION = '0.14.1';
21 22
22 23 /**
23 24 * Slug used for the upgrade menu item and redirect URL.
24 25 *
@@ -34,9 +35,70 @@
34 35 * @var string
35 36 */
36 37 const UPGRADE_MENU_FALLBACK_URL = 'https://jetpack.com/upgrade/';
37 38
39 + /*
40 + * The sidebar's position tiers. Items sharing a tier sort alphabetically by menu title, so a
41 + * product should pass no position and land in POSITION_DEFAULT. add_menu() treats any value
42 + * that is not a tier below as omitted, so an int of your own cannot opt an item out.
43 + */
44 +
38 45 /**
46 + * Owns the top-level Jetpack link, since WordPress points it at whichever item sorts first.
47 + *
48 + * @var int
49 + */
50 + const POSITION_FIRST = -10;
51 +
52 + /**
53 + * Takes the first slot when nothing claims POSITION_FIRST, as in offline mode.
54 + *
55 + * @var int
56 + */
57 + const POSITION_FIRST_FALLBACK = -5;
58 +
59 + /**
60 + * Products, in alphabetical order. Pass no position rather than this.
61 + *
62 + * @var int
63 + */
64 + const POSITION_DEFAULT = 0;
65 +
66 + /**
67 + * Links that leave wp-admin, grouped below the products.
68 + *
69 + * @var int
70 + */
71 + const POSITION_EXTERNAL = 100;
72 +
73 + /**
74 + * Site-level items that belong under everything else.
75 + *
76 + * @var int
77 + */
78 + const POSITION_LAST = 998;
79 +
80 + /**
81 + * The upgrade item this package adds, below every tier a caller can use.
82 + *
83 + * @var int
84 + */
85 + const POSITION_UPGRADE = 999;
86 +
87 + /**
88 + * The tiers add_menu() accepts. POSITION_UPGRADE is left out: only this class claims it.
89 + *
90 + * @var int[]
91 + */
92 + private const CALLER_POSITIONS = array(
93 + self::POSITION_FIRST,
94 + self::POSITION_FIRST_FALLBACK,
95 + self::POSITION_DEFAULT,
96 + self::POSITION_EXTERNAL,
97 + self::POSITION_LAST,
98 + );
99 +
100 + /**
39 101 * Handle for the shared, token-only WPDS design-tokens stylesheet.
40 102 *
41 103 * Registered once and enqueued on every Jetpack admin page so that
42 104 * `var(--wpds-*)` values resolve at runtime instead of falling back to
@@ -53,8 +115,29 @@
53 115 */
54 116 const HIDE_CORE_NOTICES_HANDLE = 'jetpack-admin-ui-hide-core-notices';
55 117
56 118 /**
119 + * Visibility state: show the item only when its declared gate is satisfied.
120 + *
121 + * @var string
122 + */
123 + const VISIBILITY_DEFAULT = 'default';
124 +
125 + /**
126 + * Visibility state: show the item whatever its gate says.
127 + *
128 + * @var string
129 + */
130 + const VISIBILITY_VISIBLE = 'visible';
131 +
132 + /**
133 + * Visibility state: keep the item out whatever its gate says.
134 + *
135 + * @var string
136 + */
137 + const VISIBILITY_HIDDEN = 'hidden';
138 +
139 + /**
57 140 * Whether this class has been initialized
58 141 *
59 142 * @var boolean
60 143 */
@@ -67,8 +150,15 @@
67 150 */
68 151 private static $menu_items = array();
69 152
70 153 /**
154 + * List of top level menu items enqueued to be added
155 + *
156 + * @var array
157 + */
158 + private static $top_level_items = array();
159 +
160 + /**
71 161 * Hook suffixes of the pages registered through this class.
72 162 *
73 163 * Used to scope the design-tokens stylesheet to Jetpack admin pages.
74 164 *
@@ -83,8 +173,41 @@
83 173 */
84 174 private static $connection_manager = null;
85 175
86 176 /**
177 + * Callback that answers whether a menu item's declared gate is satisfied.
178 + *
179 + * Set by My Jetpack, which owns the product classes the gates name. Stays null on a
180 + * site without it, where every gate then fails open.
181 + *
182 + * @var callable|null
183 + */
184 + private static $visibility_resolver = null;
185 +
186 + /**
187 + * Menu slugs registered this request but kept out of the rendered sidebar.
188 + *
189 + * @var string[]
190 + */
191 + private static $hidden_menu_slugs = array();
192 +
193 + /**
194 + * Top level menu slugs registered this request but kept out of the rendered sidebar.
195 + *
196 + * @var string[]
197 + */
198 + private static $hidden_top_level_slugs = array();
199 +
200 + /**
201 + * Whether the top level registration pass has been hooked.
202 + *
203 + * Separate from $initialized, which also builds the Jetpack menu.
204 + *
205 + * @var boolean
206 + */
207 + private static $top_level_initialized = false;
208 +
209 + /**
87 210 * Initialize the class and set up the main hook
88 211 *
89 212 * @return void
90 213 */
@@ -93,8 +216,9 @@
93 216 self::$initialized = true;
94 217 self::handle_akismet_menu();
95 218 add_action( 'admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
96 219 add_action( 'network_admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
220 + add_action( 'admin_head', array( __CLASS__, 'remove_hidden_menu_items' ) );
97 221 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'add_upgrade_menu_item_styles' ) );
98 222 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_design_tokens' ) );
99 223 }
100 224 }
@@ -99,8 +223,32 @@
99 223 }
100 224 }
101 225
102 226 /**
227 + * Drops every queued item and unhooks the registration passes.
228 + *
229 + * Intended for tests.
230 + *
231 + * @return void
232 + */
233 + public static function reset() {
234 + self::$menu_items = array();
235 + self::$top_level_items = array();
236 + self::$page_hooks = array();
237 + self::$hidden_menu_slugs = array();
238 + self::$hidden_top_level_slugs = array();
239 + self::$initialized = false;
240 + self::$top_level_initialized = false;
241 +
242 + remove_action( 'admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 );
243 + remove_action( 'network_admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 );
244 + remove_action( 'admin_menu', array( __CLASS__, 'top_level_menu_hook_callback' ), 1000 );
245 + remove_action( 'admin_head', array( __CLASS__, 'remove_hidden_menu_items' ) );
246 + remove_action( 'admin_enqueue_scripts', array( __CLASS__, 'add_upgrade_menu_item_styles' ) );
247 + remove_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_design_tokens' ) );
248 + }
249 +
250 + /**
103 251 * Handles the Akismet menu item when used alongside other stand-alone plugins
104 252 *
105 253 * 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 254 * we use this method to move the menu item.
@@ -129,11 +277,14 @@
129 277 */
130 278 public static function admin_menu_hook_callback() {
131 279 $can_see_toplevel_menu = true;
132 280 $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';
281 + $icon = 'dashicons-admin-plugins';
282 + if ( method_exists( '\Automattic\Jetpack\Assets\Logo', 'get_base64_admin_menu_logo' ) ) {
283 + $icon = ( new \Automattic\Jetpack\Assets\Logo() )->get_base64_admin_menu_logo();
284 + } elseif ( method_exists( '\Automattic\Jetpack\Assets\Logo', 'get_base64_logo' ) ) {
285 + $icon = ( new \Automattic\Jetpack\Assets\Logo() )->get_base64_logo();
286 + }
136 287
137 288 if ( ! $jetpack_plugin_present ) {
138 289 add_menu_page(
139 290 'Jetpack',
@@ -148,14 +299,11 @@
148 299 // 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 300 $can_see_toplevel_menu = false;
150 301 }
151 302
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).
303 + /*
304 + * Sort here and register in that order, without passing the position on: core splices an
305 + * int back in, and prepends 0 or less. See https://core.trac.wordpress.org/ticket/52035.
158 306 */
159 307 usort(
160 308 self::$menu_items,
161 309 function ( $a, $b ) {
@@ -172,14 +320,26 @@
172 320 return $result;
173 321 }
174 322 );
175 323
324 + $visibility = self::get_visibility_states();
325 +
326 + self::$hidden_menu_slugs = array();
327 +
176 328 foreach ( self::$menu_items as $menu_item ) {
177 329 if ( ! current_user_can( $menu_item['capability'] ) ) {
178 330 continue;
179 331 }
180 332
181 - $can_see_toplevel_menu = true;
333 + /*
334 + * A hidden item is still registered, so its page keeps resolving for links into it.
335 + * It leaves the submenu on admin_head instead: core's access check reads $submenu.
336 + */
337 + if ( self::is_menu_item_visible( $menu_item, $visibility ) ) {
338 + $can_see_toplevel_menu = true;
339 + } else {
340 + self::$hidden_menu_slugs[] = $menu_item['menu_slug'];
341 + }
182 342
183 343 add_submenu_page(
184 344 'jetpack',
185 345 $menu_item['page_title'],
@@ -185,10 +345,9 @@
185 345 $menu_item['page_title'],
186 346 $menu_item['menu_title'],
187 347 $menu_item['capability'],
188 348 $menu_item['menu_slug'],
189 - $menu_item['function'],
190 - $menu_item['position']
349 + $menu_item['function']
191 350 );
192 351 }
193 352
194 353 if ( ! $jetpack_plugin_present ) {
@@ -202,8 +361,82 @@
202 361 self::maybe_add_upgrade_menu_item();
203 362 }
204 363
205 364 /**
365 + * Hooks the top level registration pass, without building the Jetpack menu that init() does.
366 + *
367 + * @return void
368 + */
369 + private static function init_top_level() {
370 + if ( ! self::$top_level_initialized ) {
371 + self::$top_level_initialized = true;
372 + add_action( 'admin_menu', array( __CLASS__, 'top_level_menu_hook_callback' ), 1000 );
373 + add_action( 'admin_head', array( __CLASS__, 'remove_hidden_menu_items' ) );
374 + }
375 + }
376 +
377 + /**
378 + * Registers the queued top level items, skipping the ones that should not be seen.
379 + *
380 + * These sit beside the Jetpack menu, so they never count towards keeping it alive.
381 + *
382 + * @return void
383 + */
384 + public static function top_level_menu_hook_callback() {
385 + $visibility = self::get_visibility_states();
386 +
387 + self::$hidden_top_level_slugs = array();
388 +
389 + foreach ( self::$top_level_items as $menu_item ) {
390 + if ( ! current_user_can( $menu_item['capability'] ) ) {
391 + continue;
392 + }
393 +
394 + if ( ! self::is_menu_item_visible( $menu_item, $visibility ) ) {
395 + self::$hidden_top_level_slugs[] = $menu_item['menu_slug'];
396 + }
397 +
398 + add_menu_page(
399 + $menu_item['page_title'],
400 + $menu_item['menu_title'],
401 + $menu_item['capability'],
402 + $menu_item['menu_slug'],
403 + $menu_item['function'],
404 + $menu_item['icon_url'],
405 + $menu_item['position']
406 + );
407 + }
408 + }
409 +
410 + /**
411 + * Adds a top level menu item under the same visibility gate and filter as add_menu().
412 + *
413 + * Unlike add_menu(), the page gets neither the core-notice CSS nor the design tokens.
414 + * Parameters mirror add_menu_page(), with $args appended.
415 + *
416 + * @since 0.13.0
417 + *
418 + * @param string $page_title The text to be displayed in the title tags of the page when the menu
419 + * is selected.
420 + * @param string $menu_title The text to be used for the menu.
421 + * @param string $capability The capability required for this menu to be displayed to the user.
422 + * @param string $menu_slug The slug name to refer to this menu by. Should be unique for this menu.
423 + * @param callable|null $function The function to be called to output the content for this page.
424 + * @param string $icon_url The URL to the icon to be used for this menu, or a dashicons class.
425 + * @param int|null $position The position in the menu order this item should appear.
426 + * @param array $args Optional. Visibility declaration for this item; see add_menu().
427 + *
428 + * @return string The resulting page's hook_suffix
429 + */
430 + public static function add_top_level_menu( $page_title, $menu_title, $capability, $menu_slug, $function, $icon_url = '', $position = null, $args = array() ) {
431 + self::init_top_level();
432 + self::$top_level_items[] = compact( 'page_title', 'menu_title', 'capability', 'menu_slug', 'function', 'icon_url', 'position', 'args' );
433 +
434 + // Same derivation as get_plugin_page_hookname(), which strips ".php" anywhere in the slug.
435 + return 'toplevel_page_' . preg_replace( '!\.php!', '', plugin_basename( $menu_slug ) );
436 + }
437 +
438 + /**
206 439 * Adds a new submenu to the Jetpack Top level menu
207 440 *
208 441 * The parameters this method accepts are the same as @see add_submenu_page. This class will
209 442 * aggreagate all menu items registered by stand-alone plugins and make sure they all go under the same
@@ -216,16 +449,30 @@
216 449 * @param string $menu_slug The slug name to refer to this menu by. Should be unique for this menu
217 450 * and only include lowercase alphanumeric, dashes, and underscores characters
218 451 * to be compatible with sanitize_key().
219 452 * @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.
453 + * @param int|null $position One of the POSITION_* tiers; any other value is ignored. Leave empty typically.
454 + * @param array $args Optional. Visibility declaration for this item:
455 + * - 'product' (string) My Jetpack product slug whose activation gates the item.
456 + * - 'module' (string) Jetpack module name, for items with no product class.
457 + * - 'key' (string) The name hosts use for this item in the visibility
458 + * filter. Declare one on every item; see get_item_key().
459 + * An item that declares no gate is always shown.
221 460 *
222 461 * @return string The resulting page's hook_suffix
223 462 */
224 - public static function add_menu( $page_title, $menu_title, $capability, $menu_slug, $function, $position = null ) {
463 + public static function add_menu( $page_title, $menu_title, $capability, $menu_slug, $function, $position = null, $args = array() ) {
225 464 self::init();
226 - self::$menu_items[] = compact( 'page_title', 'menu_title', 'capability', 'menu_slug', 'function', 'position' );
227 465
466 + /*
467 + * Anything but a tier would opt the item out of the alphabetical order, so treat it as omitted.
468 + * @todo Add _doing_it_wrong() here after December 2026. Until all our own plugins ship tier-only
469 + * positions, it would have the latest release of one Jetpack plugin warning about another.
470 + */
471 + $position = is_numeric( $position ) && in_array( (int) $position, self::CALLER_POSITIONS, true ) ? (int) $position : null;
472 +
473 + self::$menu_items[] = compact( 'page_title', 'menu_title', 'capability', 'menu_slug', 'function', 'position', 'args' );
474 +
228 475 /**
229 476 * Let's return the page hook so consumers can use.
230 477 * We know all pages will be under Jetpack top level menu page, so we can hardcode the first part of the string.
231 478 * Using get_plugin_page_hookname here won't work because the top level page is not registered yet.
@@ -300,8 +547,144 @@
300 547 ';
301 548 }
302 549
303 550 /**
551 + * Sets the callback that resolves a menu item's declared gate.
552 + *
553 + * The callback receives the item's $args array and returns true (gate satisfied),
554 + * false (not satisfied), or null when it cannot answer — an unknown product slug,
555 + * for instance. Null is treated as satisfied, so a gate this package cannot resolve
556 + * never removes a menu item.
557 + *
558 + * This is the seam My Jetpack fills. Hosts wanting to shape the sidebar should use the
559 + * `jetpack_admin_menu_visibility` filter instead, which takes precedence: an item the
560 + * filter names is never put to this callback at all.
561 + *
562 + * @param callable|null $resolver Resolver callback, or null to clear it.
563 + * @return void
564 + */
565 + public static function set_visibility_resolver( $resolver ) {
566 + self::$visibility_resolver = $resolver;
567 + }
568 +
569 + /**
570 + * Takes hidden items out of the sidebar before it renders.
571 + *
572 + * Runs on admin_head, after core's access check has already resolved the current page,
573 + * so a hidden item's page stays reachable while its entry disappears.
574 + *
575 + * @return void
576 + */
577 + public static function remove_hidden_menu_items() {
578 + foreach ( self::$hidden_menu_slugs as $menu_slug ) {
579 + remove_submenu_page( 'jetpack', plugin_basename( $menu_slug ) );
580 + }
581 +
582 + foreach ( self::$hidden_top_level_slugs as $menu_slug ) {
583 + remove_menu_page( plugin_basename( $menu_slug ) );
584 + }
585 + }
586 +
587 + /**
588 + * Returns the name a host uses for a menu item in the visibility filter.
589 + *
590 + * The menu slug is only a fallback. It is the wrong thing to hand a host as an identifier:
591 + * several items register a URL as their slug, Blaze's is filterable, and VideoPress swaps
592 + * between two slugs depending on whether the module is active — so a host naming one of
593 + * them is naming a moving target, or only half an item.
594 + *
595 + * @param array $menu_item A registered menu item.
596 + * @return string
597 + */
598 + private static function get_item_key( array $menu_item ) {
599 + if ( ! empty( $menu_item['args']['key'] ) ) {
600 + return (string) $menu_item['args']['key'];
601 + }
602 +
603 + return (string) $menu_item['menu_slug'];
604 + }
605 +
606 + /**
607 + * Builds the item => state map and hands it to hosts to amend.
608 + *
609 + * @return array Map of item key to one of the VISIBILITY_* states.
610 + */
611 + private static function get_visibility_states() {
612 + // This filter is one a policy feeds, and nothing else need have read the policy this request.
613 + if ( method_exists( Feature_Policy::class, 'ensure_hooks' ) ) {
614 + Feature_Policy::ensure_hooks();
615 + }
616 +
617 + $states = array();
618 + $items = array_merge( self::$menu_items, self::$top_level_items );
619 +
620 + foreach ( $items as $menu_item ) {
621 + $states[ self::get_item_key( $menu_item ) ] = self::VISIBILITY_DEFAULT;
622 + }
623 +
624 + /**
625 + * Filters which Jetpack items appear in the wp-admin sidebar.
626 + *
627 + * Governs the sidebar entry only — a hidden item's page stays reachable by URL, so this
628 + * is not an access control. States: 'default' follows the item's feature, 'visible' shows it, 'hidden' removes it.
629 + *
630 + * @since 0.12.0
631 + *
632 + * @param array $states Map of item key (menu slug unless the item declared one) to state.
633 + * @param array $menu_items The registered menu items, for context.
634 + */
635 + $states = apply_filters( 'jetpack_admin_menu_visibility', $states, $items );
636 +
637 + return is_array( $states ) ? $states : array();
638 + }
639 +
640 + /**
641 + * Decides whether a single menu item should appear in the sidebar.
642 + *
643 + * @param array $menu_item A registered menu item.
644 + * @param array $visibility The resolved state map from get_visibility_states().
645 + * @return bool
646 + */
647 + private static function is_menu_item_visible( array $menu_item, array $visibility ) {
648 + $key = self::get_item_key( $menu_item );
649 + $state = $visibility[ $key ] ?? self::VISIBILITY_DEFAULT;
650 +
651 + if ( self::VISIBILITY_HIDDEN === $state ) {
652 + return false;
653 + }
654 +
655 + if ( self::VISIBILITY_VISIBLE === $state ) {
656 + return true;
657 + }
658 +
659 + return self::is_gate_satisfied( $menu_item['args'] ?? array() );
660 + }
661 +
662 + /**
663 + * Asks the resolver whether an item's declared gate is satisfied.
664 + *
665 + * Everything here fails open. An item that declares no gate, a site with no resolver
666 + * registered, and a gate the resolver does not recognize all keep the item in the
667 + * sidebar, so adopting this mechanism cannot remove an item nobody asked it to.
668 + *
669 + * @param array $args The item's visibility declaration.
670 + * @return bool
671 + */
672 + private static function is_gate_satisfied( array $args ) {
673 + if ( ! isset( $args['product'] ) && ! isset( $args['module'] ) ) {
674 + return true;
675 + }
676 +
677 + if ( ! is_callable( self::$visibility_resolver ) ) {
678 + return true;
679 + }
680 +
681 + $resolved = call_user_func( self::$visibility_resolver, $args );
682 +
683 + return null === $resolved ? true : (bool) $resolved;
684 + }
685 +
686 + /**
304 687 * Removes an already added submenu
305 688 *
306 689 * @param string $menu_slug The slug of the submenu to remove.
307 690 *
@@ -322,18 +705,29 @@
322 705
323 706 /**
324 707 * Gets the slug for the first item under the Jetpack top level menu
325 708 *
709 + * Skips hidden items rather than reading $submenu alone, because callers on admin_enqueue_scripts
710 + * and outside wp-admin ask before — or without — the admin_head pass that drops them.
711 + *
326 712 * @return string|null
327 713 */
328 714 public static function get_top_level_menu_item_slug() {
329 715 global $submenu;
330 - if ( ! empty( $submenu['jetpack'] ) ) {
331 - $item = reset( $submenu['jetpack'] );
332 - if ( isset( $item[2] ) ) {
716 +
717 + if ( empty( $submenu['jetpack'] ) ) {
718 + return null;
719 + }
720 +
721 + $hidden = array_map( 'plugin_basename', self::$hidden_menu_slugs );
722 +
723 + foreach ( $submenu['jetpack'] as $item ) {
724 + if ( isset( $item[2] ) && ! in_array( $item[2], $hidden, true ) ) {
333 725 return $item[2];
334 726 }
335 727 }
728 +
729 + return null;
336 730 }
337 731
338 732 /**
339 733 * Gets the URL for the first item under the Jetpack top level menu
@@ -484,9 +878,9 @@
484 878 $menu_title,
485 879 'manage_options',
486 880 esc_url( $upgrade_url ),
487 881 null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
488 - 999
882 + self::POSITION_UPGRADE
489 883 );
490 884
491 885 // Add a CSS class to the <li> element so styles can target it precisely.
492 886 global $submenu;