PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
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 13.9.2 14.0.1 14.1.1 14.2.2 All 502 releases
jetpack / _inc / lib / core-api / wpcom-endpoints / class-wpcom-rest-api-v2-endpoint-admin-menu.php

class-wpcom-rest-api-v2-endpoint-admin-menu.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-admin-menu.php

1,072 lines 35.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API endpoint for admin menus.
4 *
5 * @package automattic/jetpack
6 * @since 9.1.0
7 */
8
9 use Automattic\Jetpack\Status\Host;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit( 0 );
13 }
14
15 /**
16 * Class WPCOM_REST_API_V2_Endpoint_Admin_Menu
17 */
18 class WPCOM_REST_API_V2_Endpoint_Admin_Menu extends WP_REST_Controller {
19
20 /**
21 * Namespace prefix.
22 *
23 * @var string
24 */
25 public $namespace = 'wpcom/v2';
26
27 /**
28 * Endpoint base route.
29 *
30 * @var string
31 */
32 public $rest_base = 'admin-menu';
33
34 /**
35 *
36 * Set of core dashicons.
37 *
38 * @var array
39 */
40 private $dashicon_list;
41
42 /**
43 * Lazily-built map of `menu_slug => nav-model entry`, populated from the
44 * `Sidebar_Classifier` nav model when the public `wp-admin-sidebar` plugin
45 * is loaded on the host. `null` means we have not yet attempted to build
46 * the index for this request; an empty array means the classifier ran but
47 * produced no items (e.g., gating denied).
48 *
49 * @var array<string, array>|null
50 */
51 private $sidebar_nav_index = null;
52
53 /**
54 * Group rows from the classifier nav model. Sibling to
55 * `$sidebar_nav_index`; `null` when not built, empty array when no groups.
56 *
57 * @var array<string, array>|null
58 */
59 private $sidebar_nav_groups = null;
60
61 /**
62 * WPCOM_REST_API_V2_Endpoint_Admin_Menu constructor.
63 */
64 public function __construct() {
65 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
66 }
67
68 /**
69 * Register routes.
70 */
71 public function register_routes() {
72 register_rest_route(
73 $this->namespace,
74 $this->rest_base . '/',
75 array(
76 array(
77 'methods' => WP_REST_Server::READABLE,
78 'callback' => array( $this, 'get_item' ),
79 'permission_callback' => array( $this, 'get_item_permissions_check' ),
80 ),
81 'schema' => array( $this, 'get_public_item_schema' ),
82 )
83 );
84 }
85
86 /**
87 * Checks if a given request has access to admin menus.
88 *
89 * @param WP_REST_Request $request Full details about the request.
90 * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
91 */
92 public function get_item_permissions_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
93 if ( ! current_user_can( 'read' ) ) {
94 return new WP_Error(
95 'rest_forbidden',
96 __( 'Sorry, you are not allowed to view menus on this site.', 'jetpack' ),
97 array( 'status' => rest_authorization_required_code() )
98 );
99 }
100
101 return true;
102 }
103
104 /**
105 * Retrieves the admin menu.
106 *
107 * @param WP_REST_Request $request Full details about the request.
108 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
109 */
110 public function get_item( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
111
112 /*
113 * Load the `Jetpack_Admin` class, since it's only loaded on admin requests (not on API requests), and this is where
114 * many Jetpack menus are registered. We don't need to run this on WPCOM because we replicate an admin request there.
115 *
116 * @see https://github.com/Automattic/jetpack/blob/dcdeb8fe772215b514bbbd6c4ddb38f6446e7ea1/projects/plugins/jetpack/load-jetpack.php#L61-L64
117 * @see https://github.com/Automattic/jetpack/blob/dcdeb8fe772215b514bbbd6c4ddb38f6446e7ea1/projects/plugins/wpcomsh/feature-plugins/masterbar.php#L29
118 */
119 if ( ! ( new Host() )->is_wpcom_platform() ) {
120 require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
121 }
122
123 // All globals need to be declared for menu items to properly register.
124 global $admin_page_hooks, $menu, $menu_order, $submenu, $_wp_menu_nopriv, $_wp_submenu_nopriv; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
125
126 require_once ABSPATH . 'wp-admin/includes/admin.php';
127 require_once ABSPATH . 'wp-admin/menu.php';
128
129 return rest_ensure_response( $this->prepare_menu_for_response( $menu ) );
130 }
131
132 /**
133 * Prepares the admin menu for the REST response.
134 *
135 * @param array $menu Admin menu.
136 * @return array Admin menu
137 */
138 public function prepare_menu_for_response( array $menu ) {
139 global $submenu;
140
141 // Best-effort hydration of the classifier nav model. Safe no-op when the
142 // public `wp-admin-sidebar` plugin is not installed (non-WPCOM Jetpack).
143 $this->maybe_build_sidebar_nav_index();
144
145 $data = array();
146
147 /**
148 * Note: if the shape of the API endpoint data changes it is important to also update
149 * the corresponding schema.js file.
150 * See: https://github.com/Automattic/wp-calypso/blob/ebde236ec9b21ea9621c0b0523bd5ea185523731/client/state/admin-menu/schema.js
151 */
152 foreach ( $menu as $menu_item ) {
153 $item = $this->prepare_menu_item( $menu_item );
154
155 // Are there submenu items to process?
156 if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
157 $submenu_items = array_values( $submenu[ $menu_item[2] ] );
158
159 // Add submenu items.
160 foreach ( $submenu_items as $submenu_item ) {
161 // As $submenu_item can be null or false due to combination of plugins/themes, its value
162 // must be checked before passing it to the prepare_submenu_item method. It may be related
163 // to the common usage of null as a "hidden" submenu item like was fixed in CRM in #29945.
164 if ( ! is_array( $submenu_item ) ) {
165 continue;
166 }
167 $submenu_item = $this->prepare_submenu_item( $submenu_item, $menu_item );
168 if ( ! empty( $submenu_item ) ) {
169 $item['children'][] = $submenu_item;
170 }
171 }
172 }
173
174 if ( ! empty( $item ) ) {
175 $data[] = $item;
176 }
177 }
178
179 $data = array_values( array_filter( $data ) );
180
181 // When the public `wp-admin-sidebar` plugin is loaded (WPCOM and any
182 // host that opts in), the response carries a sibling `groups[]` array
183 // describing the synthetic group rows the classifier emitted. Wrapping
184 // only happens when classifier data is available so non-WPCOM Jetpack
185 // installs continue to receive the legacy flat-array shape.
186 if ( null !== $this->sidebar_nav_groups ) {
187 $data = $this->order_sidebar_grouped_items( $data );
188 $response = array(
189 'menu' => $data,
190 'groups' => $this->prepare_groups_for_response(),
191 );
192
193 $layout_delta = $this->get_sidebar_layout_delta();
194 if ( null !== $layout_delta ) {
195 $response['layoutDelta'] = $layout_delta;
196 }
197
198 return $response;
199 }
200
201 return $data;
202 }
203
204 /**
205 * Hydrate the classifier-driven nav index from the public
206 * `wp-admin-sidebar` plugin. No-op when the plugin is not installed or
207 * gating denied building a model on this request.
208 */
209 private function maybe_build_sidebar_nav_index() {
210 if ( null !== $this->sidebar_nav_index ) {
211 return;
212 }
213
214 if ( ! class_exists( 'Sidebar_Classifier' ) || ! class_exists( 'Sidebar_Signals' ) ) {
215 return;
216 }
217
218 // Trigger a build if the classifier hasn't run yet on this request.
219 // The classifier short-circuits when `wp_admin_sidebar_enabled` is
220 // false, which is the default for any host that hasn't opted in.
221 // @phan-suppress-next-line PhanUndeclaredClassMethod -- Sidebar_Classifier is provided by WPCOM's wp-admin-sidebar mu-plugin; guarded by class_exists() above.
222 if ( null === Sidebar_Classifier::get_nav_model() ) {
223 // @phan-suppress-next-line PhanUndeclaredClassMethod -- Sidebar_Classifier is provided by WPCOM's wp-admin-sidebar mu-plugin.
224 Sidebar_Classifier::build_nav_model();
225 }
226
227 // @phan-suppress-next-line PhanUndeclaredClassMethod -- Sidebar_Classifier is provided by WPCOM's wp-admin-sidebar mu-plugin.
228 $nav_model = Sidebar_Classifier::get_nav_model();
229 if ( ! is_array( $nav_model ) ) {
230 return;
231 }
232
233 $index = array();
234 $collect = function ( array $items ) use ( &$index, &$collect ) {
235 foreach ( $items as $position => $item ) {
236 if ( isset( $item['menuSlug'] ) && '' !== $item['menuSlug'] ) {
237 if ( ! isset( $item['default_weight'] ) && ! isset( $item['_weight'] ) ) {
238 $item['default_weight'] = (int) $position;
239 }
240 $parent_slug = array_key_exists( 'parent', $item ) && null !== $item['parent'] ? (string) $item['parent'] : null;
241 $index[ $this->get_sidebar_nav_index_key( $item['menuSlug'], $parent_slug ) ] = $item;
242 if ( ! isset( $index[ $item['menuSlug'] ] ) ) {
243 $index[ $item['menuSlug'] ] = $item;
244 }
245 }
246 if ( ! empty( $item['children'] ) && is_array( $item['children'] ) ) {
247 $collect( $item['children'] );
248 }
249 }
250 };
251
252 if ( ! empty( $nav_model['top_level'] ) && is_array( $nav_model['top_level'] ) ) {
253 $collect( $nav_model['top_level'] );
254 }
255 if ( ! empty( $nav_model['groups'] ) && is_array( $nav_model['groups'] ) ) {
256 foreach ( $nav_model['groups'] as $group ) {
257 if ( ! empty( $group['children'] ) && is_array( $group['children'] ) ) {
258 $collect( $group['children'] );
259 }
260 }
261 }
262
263 $this->sidebar_nav_index = $index;
264 $this->sidebar_nav_groups = ! empty( $nav_model['groups'] ) && is_array( $nav_model['groups'] )
265 ? $nav_model['groups']
266 : array();
267 }
268
269 /**
270 * Keep grouped items in the classifier's child order while preserving the
271 * relative position of ungrouped top-level rows. The public classifier
272 * sorts each group before exposing the nav model, but Calypso receives a
273 * flat `menu` array and buckets grouped items in response order.
274 *
275 * @param array $items Prepared top-level menu items.
276 * @return array Prepared items with grouped rows sorted per group.
277 */
278 private function order_sidebar_grouped_items( array $items ) {
279 $grouped_items = array();
280 foreach ( $items as $item ) {
281 if ( isset( $item['group_id'] ) && is_string( $item['group_id'] ) && '' !== $item['group_id'] ) {
282 $grouped_items[ $item['group_id'] ][] = $item;
283 }
284 }
285
286 if ( empty( $grouped_items ) ) {
287 return $items;
288 }
289
290 foreach ( $grouped_items as &$group_items ) {
291 usort(
292 $group_items,
293 static function ( $a, $b ) {
294 $wa = (int) ( $a['default_weight'] ?? PHP_INT_MAX );
295 $wb = (int) ( $b['default_weight'] ?? PHP_INT_MAX );
296 if ( $wa === $wb ) {
297 return strcmp( (string) ( $a['itemId'] ?? $a['slug'] ?? '' ), (string) ( $b['itemId'] ?? $b['slug'] ?? '' ) );
298 }
299 return $wa <=> $wb;
300 }
301 );
302 }
303 unset( $group_items );
304
305 $group_offsets = array();
306 foreach ( $items as $index => $item ) {
307 if ( ! isset( $item['group_id'] ) || ! is_string( $item['group_id'] ) || '' === $item['group_id'] ) {
308 continue;
309 }
310
311 $group_id = $item['group_id'];
312 $offset = $group_offsets[ $group_id ] ?? 0;
313 if ( isset( $grouped_items[ $group_id ][ $offset ] ) ) {
314 $items[ $index ] = $grouped_items[ $group_id ][ $offset ];
315 }
316 $group_offsets[ $group_id ] = $offset + 1;
317 }
318
319 return $items;
320 }
321
322 /**
323 * Look up the classifier nav-model entry for a given menu slug.
324 *
325 * @param string $menu_slug Menu slug as registered in `$menu` / `$submenu`.
326 * @param string|null $parent_slug Parent menu slug, or null for top-level items.
327 * @return array|null Nav-model entry or null if the slug is not in the index.
328 */
329 private function get_sidebar_nav_entry( $menu_slug, $parent_slug = null ) {
330 if ( null === $this->sidebar_nav_index || '' === $menu_slug ) {
331 return null;
332 }
333 $key = $this->get_sidebar_nav_index_key( $menu_slug, $parent_slug );
334 if ( isset( $this->sidebar_nav_index[ $key ] ) ) {
335 return $this->sidebar_nav_index[ $key ];
336 }
337 return $this->sidebar_nav_index[ $menu_slug ] ?? null;
338 }
339
340 /**
341 * Compose an index key that distinguishes top-level rows from submenu rows
342 * sharing the same raw menu slug.
343 *
344 * @param string $menu_slug Menu slug as registered in `$menu` / `$submenu`.
345 * @param string|null $parent_slug Parent menu slug, or null for top-level items.
346 * @return string Index key.
347 */
348 private function get_sidebar_nav_index_key( $menu_slug, $parent_slug = null ) {
349 return ( null === $parent_slug ? '' : (string) $parent_slug ) . "\0" . (string) $menu_slug;
350 }
351
352 /**
353 * Build the schema-shaped `signal` subobject for an item from a classifier
354 * nav-model entry. Returns null when the entry has no signal data.
355 *
356 * The returned shape is fixed (all keys present, missing fields null) so
357 * Calypso can read it without optional-chaining gymnastics.
358 *
359 * @param array $nav_entry Classifier nav-model entry.
360 * @return array|null
361 */
362 private function map_signal_from_nav_entry( array $nav_entry ) {
363 if ( empty( $nav_entry['signal'] ) || ! is_array( $nav_entry['signal'] ) ) {
364 return null;
365 }
366 $signal = $nav_entry['signal'];
367 return array(
368 'count' => $signal['count'] ?? null,
369 'numeric_badge' => $signal['numeric_badge'] ?? null,
370 'badge' => $signal['badge'] ?? null,
371 'inline_text' => $signal['inline_text'] ?? null,
372 'inline_icon' => $signal['inline_icon'] ?? null,
373 'attention' => ! empty( $signal['attention'] ),
374 );
375 }
376
377 /**
378 * Attach classifier-derived `group_id` + `signal` fields to a prepared item.
379 * No-op when the classifier index is not available or the slug isn't found.
380 *
381 * @param array $item Prepared item (top-level or submenu).
382 * @param string $menu_slug Raw menu slug used to look up the nav entry.
383 * @param string|null $parent_slug Parent menu slug, or null for top-level items.
384 * @return array Item with fields possibly added.
385 */
386 private function attach_sidebar_fields( array $item, $menu_slug, $parent_slug = null ) {
387 $nav_entry = $this->get_sidebar_nav_entry( $menu_slug, $parent_slug );
388 if ( null === $nav_entry ) {
389 return $item;
390 }
391
392 // `default_group` in the classifier maps to `group_id` on the wire.
393 $default_group = $nav_entry['default_group'] ?? null;
394 $item['group_id'] = is_string( $default_group ) && '' !== $default_group ? $default_group : null;
395 $item['signal'] = $this->map_signal_from_nav_entry( $nav_entry );
396
397 if ( isset( $nav_entry['itemId'] ) && '' !== $nav_entry['itemId'] ) {
398 $item['itemId'] = (string) $nav_entry['itemId'];
399 }
400 if ( isset( $nav_entry['source'] ) && '' !== $nav_entry['source'] ) {
401 $item['source'] = (string) $nav_entry['source'];
402 }
403 if ( isset( $nav_entry['reassignable'] ) ) {
404 $item['reassignable'] = (bool) $nav_entry['reassignable'];
405 }
406 if ( isset( $nav_entry['default_weight'] ) ) {
407 $item['default_weight'] = (int) $nav_entry['default_weight'];
408 } elseif ( isset( $nav_entry['_weight'] ) ) {
409 $item['default_weight'] = (int) $nav_entry['_weight'];
410 }
411
412 return $item;
413 }
414
415 /**
416 * Build the response-shape `groups[]` array from the cached classifier rows.
417 *
418 * Each element carries the group's stable id, label, default collapsed
419 * state and the aggregated signal that the classifier already computed.
420 *
421 * @return array
422 */
423 private function prepare_groups_for_response() {
424 if ( empty( $this->sidebar_nav_groups ) ) {
425 return array();
426 }
427
428 $groups = array();
429 foreach ( $this->sidebar_nav_groups as $group ) {
430 if ( empty( $group['id'] ) ) {
431 continue;
432 }
433 $signal = isset( $group['signal'] ) && is_array( $group['signal'] ) ? $group['signal'] : array();
434 $groups[] = array(
435 'id' => (string) $group['id'],
436 'label' => isset( $group['title'] ) ? (string) $group['title'] : '',
437 'default_expanded' => false,
438 'signal' => array(
439 'attention' => ! empty( $signal['attention'] ),
440 'count' => isset( $signal['count'] ) ? (int) $signal['count'] : 0,
441 ),
442 );
443 }
444 return $groups;
445 }
446
447 /**
448 * Read the saved Calypso-compatible layout delta from the public plugin's
449 * storage adapter. Returns null when the public plugin storage API is not
450 * loaded, which keeps non-redesigned hosts on the legacy contract.
451 *
452 * @return array|null
453 */
454 private function get_sidebar_layout_delta() {
455 if ( ! interface_exists( 'Sidebar_Layout_Storage' ) || ! class_exists( 'WP_User_Meta_Storage' ) ) {
456 return null;
457 }
458
459 $user_id = get_current_user_id();
460 if ( ! $user_id ) {
461 return null;
462 }
463
464 $site_id = (int) get_current_blog_id();
465 $storage = $this->get_sidebar_layout_storage();
466 $layouts = $storage->get_layouts( $user_id );
467
468 if ( isset( $layouts[ $site_id ] ) && is_array( $layouts[ $site_id ] ) ) {
469 return $this->normalize_sidebar_layout_delta( $layouts[ $site_id ] );
470 }
471
472 return $this->empty_sidebar_layout_delta();
473 }
474
475 /**
476 * Resolve the public plugin storage adapter. Mirrors Sidebar_Data_Planner.
477 *
478 * @return object
479 */
480 private function get_sidebar_layout_storage() {
481 // @phan-suppress-next-line PhanUndeclaredClassMethod -- WP_User_Meta_Storage is provided by WPCOM's wp-admin-sidebar mu-plugin; guarded by class_exists() above.
482 $default = new WP_User_Meta_Storage();
483 $bound = apply_filters( 'wp_admin_sidebar_storage', $default );
484 $bound = apply_filters_deprecated(
485 'wpcom_admin_sidebar_storage',
486 array( $bound ),
487 '0.1.0',
488 'wp_admin_sidebar_storage'
489 );
490 // @phan-suppress-next-line PhanUndeclaredClassInstanceof -- Sidebar_Layout_Storage is provided by WPCOM's wp-admin-sidebar mu-plugin; guarded by interface_exists() above.
491 return $bound instanceof Sidebar_Layout_Storage ? $bound : $default;
492 }
493
494 /**
495 * Normalize a stored LayoutDelta into the response shape Calypso expects.
496 *
497 * @param array $delta Stored layout delta.
498 * @return array
499 */
500 private function normalize_sidebar_layout_delta( array $delta ) {
501 if ( ! isset( $delta['overrides'] ) || ! is_array( $delta['overrides'] ) ) {
502 return $this->empty_sidebar_layout_delta();
503 }
504
505 return array(
506 'version' => isset( $delta['version'] ) ? (int) $delta['version'] : 1,
507 'updated_at' => isset( $delta['updated_at'] ) ? (int) $delta['updated_at'] : 0,
508 'overrides' => array_values( $delta['overrides'] ),
509 );
510 }
511
512 /**
513 * Empty-delta shape used when a redesigned host has no saved layout yet.
514 *
515 * @return array
516 */
517 private function empty_sidebar_layout_delta() {
518 return array(
519 'version' => 1,
520 'updated_at' => 0,
521 'overrides' => array(),
522 );
523 }
524
525 /**
526 * Retrieves the admin menu's schema, conforming to JSON Schema.
527 *
528 * Note: if the shape of the API endpoint data changes it is important to also update
529 * the corresponding schema.js file.
530 *
531 * @see https://github.com/Automattic/wp-calypso/blob/ebde236ec9b21ea9621c0b0523bd5ea185523731/client/state/admin-menu/schema.js
532 *
533 * @return array Item schema data.
534 */
535 public function get_item_schema() {
536 // Shape of the optional `signal` subobject attached to each item by the
537 // public `wp-admin-sidebar` classifier (when loaded). Mirrors the snake
538 // case shape produced by `Sidebar_Signals::map_to_nav` so Calypso can
539 // consume one canonical field set end-to-end. All keys are present and
540 // nullable; consumers don't need optional-chaining.
541 $signal_schema = array(
542 'description' => 'Per-item attention/count/badge data emitted by the wp-admin-sidebar classifier. Null when no signal data was extracted.',
543 'type' => array( 'object', 'null' ),
544 'properties' => array(
545 'count' => array( 'type' => array( 'integer', 'null' ) ),
546 'numeric_badge' => array( 'type' => array( 'integer', 'null' ) ),
547 'badge' => array( 'type' => array( 'string', 'null' ) ),
548 'inline_text' => array( 'type' => array( 'string', 'null' ) ),
549 'inline_icon' => array( 'type' => array( 'string', 'null' ) ),
550 'attention' => array( 'type' => 'boolean' ),
551 ),
552 );
553
554 $submenu_item_schema = array(
555 'type' => 'object',
556 'properties' => array(
557 'count' => array(
558 'description' => 'Core/Plugin/Theme update count or unread comments count.',
559 'type' => 'integer',
560 ),
561 'parent' => array(
562 'type' => 'string',
563 ),
564 'slug' => array(
565 'type' => 'string',
566 ),
567 'title' => array(
568 'type' => 'string',
569 ),
570 'type' => array(
571 'enum' => array( 'submenu-item' ),
572 'type' => 'string',
573 ),
574 'url' => array(
575 'format' => 'uri',
576 'type' => 'string',
577 ),
578 'itemId' => array(
579 'description' => 'Compound sidebar item id emitted by the wp-admin-sidebar classifier. Only present when the classifier is loaded.',
580 'type' => 'string',
581 ),
582 'source' => array(
583 'description' => 'Classifier source kind, e.g. core, plugin, or wpcom. Only present when the classifier is loaded.',
584 'type' => 'string',
585 ),
586 'default_weight' => array(
587 'description' => 'Default ordering hint from the classifier. Only present when the classifier exposes it.',
588 'type' => 'integer',
589 ),
590 'reassignable' => array(
591 'description' => 'Whether the sidebar customizer may move this item. Only present when the classifier is loaded.',
592 'type' => 'boolean',
593 ),
594 'group_id' => array(
595 'description' => 'Group this submenu belongs to (e.g., "plugins"). Null for non-grouped items. Only present when the wp-admin-sidebar classifier is loaded.',
596 'type' => array( 'string', 'null' ),
597 ),
598 'signal' => $signal_schema,
599 ),
600 );
601
602 $menu_item_schema = array(
603 'type' => 'object',
604 'properties' => array(
605 'count' => array(
606 'description' => 'Core/Plugin/Theme update count or unread comments count.',
607 'type' => 'integer',
608 ),
609 'icon' => array(
610 'description' => 'Menu item icon. Dashicon slug or base64-encoded SVG.',
611 'type' => 'string',
612 ),
613 'inlineText' => array(
614 'description' => 'Additional text to be added inline with the menu title.',
615 'type' => 'string',
616 ),
617 'inlineIcon' => array(
618 'description' => 'Dashicon slug to be displayed inline with the menu title.',
619 'type' => 'string',
620 ),
621 'badge' => array(
622 'description' => 'Badge to be added inline with the menu title.',
623 'type' => 'string',
624 ),
625 'slug' => array(
626 'type' => 'string',
627 ),
628 'children' => array(
629 'type' => 'array',
630 'items' => $submenu_item_schema,
631 ),
632 'title' => array(
633 'type' => 'string',
634 ),
635 'type' => array(
636 'enum' => array( 'separator', 'menu-item' ),
637 'type' => 'string',
638 ),
639 'url' => array(
640 'format' => 'uri',
641 'type' => 'string',
642 ),
643 'itemId' => array(
644 'description' => 'Compound sidebar item id emitted by the wp-admin-sidebar classifier. Only present when the classifier is loaded.',
645 'type' => 'string',
646 ),
647 'source' => array(
648 'description' => 'Classifier source kind, e.g. core, plugin, or wpcom. Only present when the classifier is loaded.',
649 'type' => 'string',
650 ),
651 'default_weight' => array(
652 'description' => 'Default ordering hint from the classifier. Only present when the classifier exposes it.',
653 'type' => 'integer',
654 ),
655 'reassignable' => array(
656 'description' => 'Whether the sidebar customizer may move this item. Only present when the classifier is loaded.',
657 'type' => 'boolean',
658 ),
659 'group_id' => array(
660 'description' => 'Group this top-level item belongs to (e.g., "plugins"). Null for non-grouped items. Only present when the wp-admin-sidebar classifier is loaded.',
661 'type' => array( 'string', 'null' ),
662 ),
663 'signal' => $signal_schema,
664 ),
665 );
666
667 $group_schema = array(
668 'type' => 'object',
669 'properties' => array(
670 'id' => array( 'type' => 'string' ),
671 'label' => array( 'type' => 'string' ),
672 'default_expanded' => array( 'type' => 'boolean' ),
673 'signal' => array(
674 'type' => 'object',
675 'properties' => array(
676 'attention' => array( 'type' => 'boolean' ),
677 'count' => array( 'type' => 'integer' ),
678 ),
679 ),
680 ),
681 );
682
683 $layout_delta_schema = array(
684 'description' => 'Saved per-user sidebar layout delta. Only present when the wp-admin-sidebar storage API is loaded.',
685 'type' => array( 'object', 'null' ),
686 'properties' => array(
687 'version' => array( 'type' => 'integer' ),
688 'updated_at' => array( 'type' => 'integer' ),
689 'overrides' => array(
690 'type' => 'array',
691 'items' => array(
692 'type' => 'object',
693 'properties' => array(
694 'itemId' => array( 'type' => 'string' ),
695 'position' => array(
696 'type' => 'object',
697 'properties' => array(
698 'kind' => array(
699 'type' => 'string',
700 'enum' => array( 'top_level', 'in_group' ),
701 ),
702 'group_id' => array( 'type' => 'string' ),
703 'index' => array( 'type' => 'integer' ),
704 ),
705 ),
706 ),
707 ),
708 ),
709 ),
710 );
711
712 $legacy_response_schema = array(
713 'description' => 'Legacy flat admin menu response used when the wp-admin-sidebar classifier is not loaded or not enabled.',
714 'type' => 'array',
715 'items' => $menu_item_schema,
716 );
717
718 $redesigned_response_schema = array(
719 'description' => 'Wrapped admin menu response used when the wp-admin-sidebar classifier is loaded and enabled.',
720 'type' => 'object',
721 'required' => array( 'menu', 'groups' ),
722 'properties' => array(
723 'menu' => array(
724 'description' => 'Top-level menu items.',
725 'type' => 'array',
726 'items' => $menu_item_schema,
727 ),
728 'groups' => array(
729 'description' => 'Synthetic group rows describing the sidebar grouping shape. Empty array if no plugin items grouped.',
730 'type' => 'array',
731 'items' => $group_schema,
732 ),
733 'layoutDelta' => $layout_delta_schema,
734 ),
735 );
736
737 return array(
738 '$schema' => 'http://json-schema.org/draft-04/schema#',
739 'title' => 'Admin Menu',
740 'type' => array( 'array', 'object' ),
741 'anyOf' => array(
742 $legacy_response_schema,
743 $redesigned_response_schema,
744 ),
745 );
746 }
747
748 /**
749 * Sets up a menu item for consumption by Calypso.
750 *
751 * @param array $menu_item Menu item.
752 * @return array Prepared menu item.
753 */
754 private function prepare_menu_item( array $menu_item ) {
755 global $submenu;
756
757 $current_user_can_access_menu = current_user_can( $menu_item[1] );
758 $submenu_items = isset( $submenu[ $menu_item[2] ] ) ? array_values( $submenu[ $menu_item[2] ] ) : array();
759 $has_first_menu_item = isset( $submenu_items[0] );
760
761 // Exclude unauthorized menu items when the user does not have access to the menu and the first submenu item.
762 if ( ! $current_user_can_access_menu && $has_first_menu_item && ! current_user_can( $submenu_items[0][1] ) ) {
763 return array();
764 }
765
766 // Exclude unauthorized menu items that don't have submenus.
767 if ( ! $current_user_can_access_menu && ! $has_first_menu_item ) {
768 return array();
769 }
770
771 // Exclude hidden menu items.
772 if ( str_contains( $menu_item[4], 'hide-if-js' ) ) {
773 // Exclude submenu items as well.
774 if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
775 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
776 $submenu[ $menu_item[2] ] = array();
777 }
778 return array();
779 }
780
781 // Handle menu separators.
782 if ( str_contains( $menu_item[4], 'wp-menu-separator' ) ) {
783 return array(
784 'type' => 'separator',
785 );
786 }
787
788 $url = $menu_item[2];
789 $parent_slug = '';
790
791 // If there are submenus, the parent menu should always link to the first submenu.
792 // @see https://core.trac.wordpress.org/browser/trunk/src/wp-admin/menu-header.php?rev=49193#L152.
793 if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
794 $parent_slug = $url;
795 $first_submenu_item = reset( $submenu[ $menu_item[2] ] );
796 $url = $first_submenu_item[2];
797 }
798
799 $item = array(
800 'icon' => $this->prepare_menu_item_icon( $menu_item[6] ),
801 'slug' => sanitize_title_with_dashes( $menu_item[2] ),
802 'title' => $menu_item[0],
803 'type' => 'menu-item',
804 'url' => $this->prepare_menu_item_url( $url, $parent_slug ),
805 );
806
807 $parsed_item = $this->parse_menu_item( $item['title'] );
808 if ( ! empty( $parsed_item ) ) {
809 $item = array_merge( $item, $parsed_item );
810 }
811
812 // Additive: authoritative top-level total from the central menu-badges
813 // registry, when the package is loaded. Overlays any count parsed from
814 // title markup above.
815 if ( 'jetpack' === $menu_item[2] && class_exists( '\Automattic\Jetpack\Menu_Badges\Notification_Counts' ) ) {
816 $registry_total = \Automattic\Jetpack\Menu_Badges\Notification_Counts::get_total();
817 if ( $registry_total > 0 ) {
818 $item['count'] = $registry_total;
819 }
820 }
821
822 // Additive: classifier-derived `group_id` + `signal` for the redesigned
823 // sidebar. Match key is the raw menu slug (`$menu_item[2]`), which is
824 // what the public plugin's `Sidebar_Classifier` keys nav items by.
825 $item = $this->attach_sidebar_fields( $item, $menu_item[2] );
826
827 return $item;
828 }
829
830 /**
831 * Sets up a submenu item for consumption by Calypso.
832 *
833 * @param array $submenu_item Submenu item.
834 * @param array $menu_item Menu item.
835 * @return array Prepared submenu item.
836 */
837 private function prepare_submenu_item( array $submenu_item, array $menu_item ) {
838 // Exclude unauthorized submenu items.
839 if ( ! current_user_can( $submenu_item[1] ) ) {
840 return array();
841 }
842
843 // Exclude hidden submenu items.
844 if ( isset( $submenu_item[4] ) && str_contains( $submenu_item[4], 'hide-if-js' ) ) {
845 return array();
846 }
847
848 $item = array(
849 'parent' => sanitize_title_with_dashes( $menu_item[2] ),
850 'slug' => sanitize_title_with_dashes( $submenu_item[2] ),
851 'title' => $submenu_item[0],
852 'type' => 'submenu-item',
853 'url' => $this->prepare_menu_item_url( $submenu_item[2], $menu_item[2] ),
854 );
855
856 $parsed_item = $this->parse_menu_item( $item['title'] );
857 if ( ! empty( $parsed_item ) ) {
858 $item = array_merge( $item, $parsed_item );
859 }
860
861 // Additive: authoritative count from the central menu-badges registry,
862 // when the package is loaded. Overlays any count parsed from title
863 // markup above.
864 if ( class_exists( '\Automattic\Jetpack\Menu_Badges\Notification_Counts' ) ) {
865 $registry_count = \Automattic\Jetpack\Menu_Badges\Notification_Counts::get_for_menu( $submenu_item[2] );
866 if ( $registry_count > 0 ) {
867 $item['count'] = $registry_count;
868 }
869 }
870
871 // Additive: classifier-derived fields. Same lookup contract as the
872 // top-level branch above; submenu rows live under their own `menuSlug`
873 // in the nav model.
874 $item = $this->attach_sidebar_fields( $item, $submenu_item[2], $menu_item[2] );
875
876 return $item;
877 }
878
879 /**
880 * Prepares a menu icon for consumption by Calypso.
881 *
882 * @param string $icon Menu icon.
883 * @return string
884 */
885 private function prepare_menu_item_icon( $icon ) {
886 $img = 'dashicons-admin-generic';
887
888 if ( ! empty( $icon ) && 'none' !== $icon && 'div' !== $icon ) {
889 $img = esc_url( $icon );
890
891 if ( str_starts_with( $icon, 'data:image/svg+xml' ) ) {
892 $img = $icon;
893 } elseif ( str_starts_with( $icon, 'dashicons-' ) ) {
894 $img = $this->prepare_dashicon( $icon );
895 }
896 }
897
898 return $img;
899 }
900
901 /**
902 * Prepares the dashicon for consumption by Calypso. If the dashicon isn't found in a list of known icons
903 * we will return the default dashicon.
904 *
905 * @param string $icon The dashicon string to check.
906 *
907 * @return string If the dashicon exists in core we return the dashicon, otherwise we return the default dashicon.
908 */
909 private function prepare_dashicon( $icon ) {
910 if ( empty( $this->dashicon_set ) ) {
911 $this->dashicon_list = include JETPACK__PLUGIN_DIR . 'jetpack_vendor/automattic/jetpack-masterbar/src/admin-menu/dashicon-set.php';
912 }
913
914 if ( isset( $this->dashicon_list[ $icon ] ) && $this->dashicon_list[ $icon ] ) {
915 return $icon;
916 }
917
918 return 'dashicons-admin-generic';
919 }
920
921 /**
922 * Prepares a menu item url for consumption by Calypso.
923 *
924 * @param string $url Menu slug.
925 * @param string $parent_slug Optional. Parent menu item slug. Default empty string.
926 * @return string
927 */
928 private function prepare_menu_item_url( $url, $parent_slug = '' ) {
929 // External URLS.
930 if ( preg_match( '/^https?:\/\//', $url ) ) {
931 // Allow URLs pointing to WordPress.com.
932 if ( str_starts_with( $url, 'https://wordpress.com/' ) ) {
933 // Calypso needs the domain removed so they're not interpreted as external links.
934 $url = str_replace( 'https://wordpress.com', '', $url );
935 // Replace special characters with their correct entities e.g. &amp; to &.
936 return wp_specialchars_decode( esc_url_raw( $url ) );
937 }
938
939 // Allow URLs pointing to Jetpack.com.
940 if ( str_starts_with( $url, 'https://jetpack.com/' ) ) {
941 // Replace special characters with their correct entities e.g. &amp; to &.
942 return wp_specialchars_decode( esc_url_raw( $url ) );
943 }
944
945 // Disallow other external URLs.
946 if ( ! str_starts_with( $url, get_site_url() ) ) {
947 return '';
948 }
949 // The URL matches that of the site, treat it as an internal URL.
950 }
951
952 // Internal URLs.
953 $menu_hook = get_plugin_page_hook( $url, $parent_slug );
954 $menu_file = wp_parse_url( $url, PHP_URL_PATH ); // Removes query args to get a file name.
955 $parent_file = wp_parse_url( $parent_slug, PHP_URL_PATH );
956
957 if (
958 ! empty( $menu_hook ) ||
959 (
960 'index.php' !== $url &&
961 file_exists( WP_PLUGIN_DIR . "/$menu_file" ) &&
962 ! file_exists( ABSPATH . "/wp-admin/$menu_file" )
963 )
964 ) {
965 $admin_is_parent = false;
966 if ( ! empty( $parent_slug ) ) {
967 $menu_hook = get_plugin_page_hook( $parent_slug, 'admin.php' );
968 $admin_is_parent = ! empty( $menu_hook ) || ( ( 'index.php' !== $parent_slug ) && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! file_exists( ABSPATH . "/wp-admin/$parent_file" ) );
969 }
970
971 if (
972 ( ! $admin_is_parent && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! is_dir( WP_PLUGIN_DIR . "/$parent_file" ) ) ||
973 ( file_exists( ABSPATH . "/wp-admin/$parent_file" ) && ! is_dir( ABSPATH . "/wp-admin/$parent_file" ) )
974 ) {
975 $url = add_query_arg( array( 'page' => $url ), admin_url( $parent_slug ) );
976 } else {
977 $url = add_query_arg( array( 'page' => $url ), admin_url( 'admin.php' ) );
978 }
979 } elseif ( file_exists( ABSPATH . "/wp-admin/$menu_file" ) ) {
980 $url = admin_url( $url );
981 }
982
983 return wp_specialchars_decode( esc_url_raw( $url ) );
984 }
985
986 /**
987 * "Plugins", "Comments", "Updates" menu items have a count badge when there are updates available.
988 * This method parses that information, removes the associated markup and adds it to the response.
989 *
990 * Also sanitizes the titles from remaining unexpected markup.
991 *
992 * @param string $title Title to parse.
993 * @return array
994 */
995 private function parse_menu_item( $title ) {
996 // Handle non-string input
997 if ( ! is_string( $title ) ) {
998 return array();
999 }
1000
1001 $item = array();
1002
1003 if (
1004 str_contains( $title, 'count-' )
1005 && preg_match( '/<span class=".+\s?count-(\d*).+\s?<\/span><\/span>/', $title, $matches )
1006 ) {
1007
1008 $count = (int) ( $matches[1] );
1009 if ( $count > 0 ) {
1010 // Keep the counter in the item array.
1011 $item['count'] = $count;
1012 }
1013
1014 // Finally remove the markup.
1015 $title = trim( str_replace( $matches[0], '', $title ) );
1016 }
1017
1018 if (
1019 str_contains( $title, 'inline-text' )
1020 && preg_match( '/<span class="inline-text".+\s?>(.+)<\/span>/', $title, $matches )
1021 ) {
1022
1023 $text = $matches[1];
1024 if ( $text ) {
1025 // Keep the text in the item array.
1026 $item['inlineText'] = $text;
1027 }
1028
1029 // Finally remove the markup.
1030 $title = trim( str_replace( $matches[0], '', $title ) );
1031 }
1032
1033 if (
1034 str_contains( $title, 'inline-icon' )
1035 && preg_match( '/<span class="inline-icon dashicons (dashicons-[^"]+)"[^>]*><\/span>/', $title, $matches )
1036 ) {
1037
1038 $icon = $matches[1];
1039 if ( $icon ) {
1040 // Keep the dashicon slug in the item array.
1041 $item['inlineIcon'] = $icon;
1042 }
1043
1044 // Finally remove the markup.
1045 $title = trim( str_replace( $matches[0], '', $title ) );
1046 }
1047
1048 if (
1049 str_contains( $title, 'awaiting-mod' )
1050 && preg_match( '/<span class="awaiting-mod">(.+)<\/span>/', $title, $matches )
1051 ) {
1052
1053 $text = $matches[1];
1054 if ( $text ) {
1055 // Keep the text in the item array.
1056 $item['badge'] = $text;
1057 }
1058
1059 // Finally remove the markup.
1060 $title = trim( str_replace( $matches[0], '', $title ) );
1061 }
1062
1063 // It's important we sanitize the title after parsing data to remove any unexpected markup but keep the content.
1064 // We are also capitalizing the first letter in case there was a counter (now parsed) in front of the title.
1065 $item['title'] = ucfirst( wp_strip_all_tags( $title ) );
1066
1067 return $item;
1068 }
1069 }
1070
1071 wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Admin_Menu' );
1072