Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.33% covered (success)
95.33%
204 / 214
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Admin_Menu
96.68% covered (success)
96.68%
204 / 211
83.33% covered (warning)
83.33%
10 / 12
71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 get_item_permissions_check
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_item
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 prepare_menu_for_response
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 get_item_schema
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
1
 prepare_menu_item
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
12.04
 prepare_submenu_item
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 prepare_menu_item_icon
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 prepare_dashicon
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 prepare_menu_item_url
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
19
 parse_menu_item
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
12.24
1<?php
2/**
3 * REST API endpoint for admin menus.
4 *
5 * @package automattic/jetpack
6 * @since 9.1.0
7 */
8
9use Automattic\Jetpack\Status\Host;
10
11if ( ! defined( 'ABSPATH' ) ) {
12    exit( 0 );
13}
14
15/**
16 * Class WPCOM_REST_API_V2_Endpoint_Admin_Menu
17 */
18class 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     * WPCOM_REST_API_V2_Endpoint_Admin_Menu constructor.
44     */
45    public function __construct() {
46        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
47    }
48
49    /**
50     * Register routes.
51     */
52    public function register_routes() {
53        register_rest_route(
54            $this->namespace,
55            $this->rest_base . '/',
56            array(
57                array(
58                    'methods'             => WP_REST_Server::READABLE,
59                    'callback'            => array( $this, 'get_item' ),
60                    'permission_callback' => array( $this, 'get_item_permissions_check' ),
61                ),
62                'schema' => array( $this, 'get_public_item_schema' ),
63            )
64        );
65    }
66
67    /**
68     * Checks if a given request has access to admin menus.
69     *
70     * @param WP_REST_Request $request Full details about the request.
71     * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
72     */
73    public function get_item_permissions_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
74        if ( ! current_user_can( 'read' ) ) {
75            return new WP_Error(
76                'rest_forbidden',
77                __( 'Sorry, you are not allowed to view menus on this site.', 'jetpack' ),
78                array( 'status' => rest_authorization_required_code() )
79            );
80        }
81
82        return true;
83    }
84
85    /**
86     * Retrieves the admin menu.
87     *
88     * @param WP_REST_Request $request Full details about the request.
89     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
90     */
91    public function get_item( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
92
93        /*
94         * Load the `Jetpack_Admin` class, since it's only loaded on admin requests (not on API requests), and this is where
95         * many Jetpack menus are registered. We don't need to run this on WPCOM because we replicate an admin request there.
96         *
97         * @see https://github.com/Automattic/jetpack/blob/dcdeb8fe772215b514bbbd6c4ddb38f6446e7ea1/projects/plugins/jetpack/load-jetpack.php#L61-L64
98         * @see https://github.com/Automattic/jetpack/blob/dcdeb8fe772215b514bbbd6c4ddb38f6446e7ea1/projects/plugins/wpcomsh/feature-plugins/masterbar.php#L29
99         */
100        if ( ! ( new Host() )->is_wpcom_platform() ) {
101            require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
102        }
103
104        // All globals need to be declared for menu items to properly register.
105        global $admin_page_hooks, $menu, $menu_order, $submenu, $_wp_menu_nopriv, $_wp_submenu_nopriv; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
106
107        require_once ABSPATH . 'wp-admin/includes/admin.php';
108        require_once ABSPATH . 'wp-admin/menu.php';
109
110        return rest_ensure_response( $this->prepare_menu_for_response( $menu ) );
111    }
112
113    /**
114     * Prepares the admin menu for the REST response.
115     *
116     * @param array $menu Admin menu.
117     * @return array Admin menu
118     */
119    public function prepare_menu_for_response( array $menu ) {
120        global $submenu;
121
122        $data = array();
123
124        /**
125         * Note: if the shape of the API endpoint data changes it is important to also update
126         * the corresponding schema.js file.
127         * See: https://github.com/Automattic/wp-calypso/blob/ebde236ec9b21ea9621c0b0523bd5ea185523731/client/state/admin-menu/schema.js
128         */
129        foreach ( $menu as $menu_item ) {
130            $item = $this->prepare_menu_item( $menu_item );
131
132            // Are there submenu items to process?
133            if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
134                $submenu_items = array_values( $submenu[ $menu_item[2] ] );
135
136                // Add submenu items.
137                foreach ( $submenu_items as $submenu_item ) {
138                    // As $submenu_item can be null or false due to combination of plugins/themes, its value
139                    // must be checked before passing it to the prepare_submenu_item method. It may be related
140                    // to the common usage of null as a "hidden" submenu item like was fixed in CRM in #29945.
141                    if ( ! is_array( $submenu_item ) ) {
142                        continue;
143                    }
144                    $submenu_item = $this->prepare_submenu_item( $submenu_item, $menu_item );
145                    if ( ! empty( $submenu_item ) ) {
146                        $item['children'][] = $submenu_item;
147                    }
148                }
149            }
150
151            if ( ! empty( $item ) ) {
152                $data[] = $item;
153            }
154        }
155
156        return array_filter( $data );
157    }
158
159    /**
160     * Retrieves the admin menu's schema, conforming to JSON Schema.
161     *
162     * Note: if the shape of the API endpoint data changes it is important to also update
163     * the corresponding schema.js file.
164     *
165     * @see https://github.com/Automattic/wp-calypso/blob/ebde236ec9b21ea9621c0b0523bd5ea185523731/client/state/admin-menu/schema.js
166     *
167     * @return array Item schema data.
168     */
169    public function get_item_schema() {
170        return array(
171            '$schema'    => 'http://json-schema.org/draft-04/schema#',
172            'title'      => 'Admin Menu',
173            'type'       => 'object',
174            'properties' => array(
175                'count'      => array(
176                    'description' => 'Core/Plugin/Theme update count or unread comments count.',
177                    'type'        => 'integer',
178                ),
179                'icon'       => array(
180                    'description' => 'Menu item icon. Dashicon slug or base64-encoded SVG.',
181                    'type'        => 'string',
182                ),
183                'inlineText' => array(
184                    'description' => 'Additional text to be added inline with the menu title.',
185                    'type'        => 'string',
186                ),
187                'badge'      => array(
188                    'description' => 'Badge to be added inline with the menu title.',
189                    'type'        => 'string',
190                ),
191                'slug'       => array(
192                    'type' => 'string',
193                ),
194                'children'   => array(
195                    'items' => array(
196                        'count'  => array(
197                            'description' => 'Core/Plugin/Theme update count or unread comments count.',
198                            'type'        => 'integer',
199                        ),
200                        'parent' => array(
201                            'type' => 'string',
202                        ),
203                        'slug'   => array(
204                            'type' => 'string',
205                        ),
206                        'title'  => array(
207                            'type' => 'string',
208                        ),
209                        'type'   => array(
210                            'enum' => array( 'submenu-item' ),
211                            'type' => 'string',
212                        ),
213                        'url'    => array(
214                            'format' => 'uri',
215                            'type'   => 'string',
216                        ),
217                    ),
218                    'type'  => 'array',
219                ),
220                'title'      => array(
221                    'type' => 'string',
222                ),
223                'type'       => array(
224                    'enum' => array( 'separator', 'menu-item' ),
225                    'type' => 'string',
226                ),
227                'url'        => array(
228                    'format' => 'uri',
229                    'type'   => 'string',
230                ),
231            ),
232        );
233    }
234
235    /**
236     * Sets up a menu item for consumption by Calypso.
237     *
238     * @param array $menu_item Menu item.
239     * @return array Prepared menu item.
240     */
241    private function prepare_menu_item( array $menu_item ) {
242        global $submenu;
243
244        $current_user_can_access_menu = current_user_can( $menu_item[1] );
245        $submenu_items                = isset( $submenu[ $menu_item[2] ] ) ? array_values( $submenu[ $menu_item[2] ] ) : array();
246        $has_first_menu_item          = isset( $submenu_items[0] );
247
248        // Exclude unauthorized menu items when the user does not have access to the menu and the first submenu item.
249        if ( ! $current_user_can_access_menu && $has_first_menu_item && ! current_user_can( $submenu_items[0][1] ) ) {
250            return array();
251        }
252
253        // Exclude unauthorized menu items that don't have submenus.
254        if ( ! $current_user_can_access_menu && ! $has_first_menu_item ) {
255            return array();
256        }
257
258        // Exclude hidden menu items.
259        if ( str_contains( $menu_item[4], 'hide-if-js' ) ) {
260            // Exclude submenu items as well.
261            if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
262                // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
263                $submenu[ $menu_item[2] ] = array();
264            }
265            return array();
266        }
267
268        // Handle menu separators.
269        if ( str_contains( $menu_item[4], 'wp-menu-separator' ) ) {
270            return array(
271                'type' => 'separator',
272            );
273        }
274
275        $url         = $menu_item[2];
276        $parent_slug = '';
277
278        // If there are submenus, the parent menu should always link to the first submenu.
279        // @see https://core.trac.wordpress.org/browser/trunk/src/wp-admin/menu-header.php?rev=49193#L152.
280        if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
281            $parent_slug        = $url;
282            $first_submenu_item = reset( $submenu[ $menu_item[2] ] );
283            $url                = $first_submenu_item[2];
284        }
285
286        $item = array(
287            'icon'  => $this->prepare_menu_item_icon( $menu_item[6] ),
288            'slug'  => sanitize_title_with_dashes( $menu_item[2] ),
289            'title' => $menu_item[0],
290            'type'  => 'menu-item',
291            'url'   => $this->prepare_menu_item_url( $url, $parent_slug ),
292        );
293
294        $parsed_item = $this->parse_menu_item( $item['title'] );
295        if ( ! empty( $parsed_item ) ) {
296            $item = array_merge( $item, $parsed_item );
297        }
298
299        return $item;
300    }
301
302    /**
303     * Sets up a submenu item for consumption by Calypso.
304     *
305     * @param array $submenu_item Submenu item.
306     * @param array $menu_item    Menu item.
307     * @return array Prepared submenu item.
308     */
309    private function prepare_submenu_item( array $submenu_item, array $menu_item ) {
310        // Exclude unauthorized submenu items.
311        if ( ! current_user_can( $submenu_item[1] ) ) {
312            return array();
313        }
314
315        // Exclude hidden submenu items.
316        if ( isset( $submenu_item[4] ) && str_contains( $submenu_item[4], 'hide-if-js' ) ) {
317            return array();
318        }
319
320        $item = array(
321            'parent' => sanitize_title_with_dashes( $menu_item[2] ),
322            'slug'   => sanitize_title_with_dashes( $submenu_item[2] ),
323            'title'  => $submenu_item[0],
324            'type'   => 'submenu-item',
325            'url'    => $this->prepare_menu_item_url( $submenu_item[2], $menu_item[2] ),
326        );
327
328        $parsed_item = $this->parse_menu_item( $item['title'] );
329        if ( ! empty( $parsed_item ) ) {
330            $item = array_merge( $item, $parsed_item );
331        }
332
333        return $item;
334    }
335
336    /**
337     * Prepares a menu icon for consumption by Calypso.
338     *
339     * @param string $icon Menu icon.
340     * @return string
341     */
342    private function prepare_menu_item_icon( $icon ) {
343        $img = 'dashicons-admin-generic';
344
345        if ( ! empty( $icon ) && 'none' !== $icon && 'div' !== $icon ) {
346            $img = esc_url( $icon );
347
348            if ( str_starts_with( $icon, 'data:image/svg+xml' ) ) {
349                $img = $icon;
350            } elseif ( str_starts_with( $icon, 'dashicons-' ) ) {
351                $img = $this->prepare_dashicon( $icon );
352            }
353        }
354
355        return $img;
356    }
357
358    /**
359     * Prepares the dashicon for consumption by Calypso. If the dashicon isn't found in a list of known icons
360     * we will return the default dashicon.
361     *
362     * @param string $icon The dashicon string to check.
363     *
364     * @return string If the dashicon exists in core we return the dashicon, otherwise we return the default dashicon.
365     */
366    private function prepare_dashicon( $icon ) {
367        if ( empty( $this->dashicon_set ) ) {
368            $this->dashicon_list = include JETPACK__PLUGIN_DIR . 'jetpack_vendor/automattic/jetpack-masterbar/src/admin-menu/dashicon-set.php';
369        }
370
371        if ( isset( $this->dashicon_list[ $icon ] ) && $this->dashicon_list[ $icon ] ) {
372            return $icon;
373        }
374
375        return 'dashicons-admin-generic';
376    }
377
378    /**
379     * Prepares a menu item url for consumption by Calypso.
380     *
381     * @param string $url         Menu slug.
382     * @param string $parent_slug Optional. Parent menu item slug. Default empty string.
383     * @return string
384     */
385    private function prepare_menu_item_url( $url, $parent_slug = '' ) {
386        // External URLS.
387        if ( preg_match( '/^https?:\/\//', $url ) ) {
388            // Allow URLs pointing to WordPress.com.
389            if ( str_starts_with( $url, 'https://wordpress.com/' ) ) {
390                // Calypso needs the domain removed so they're not interpreted as external links.
391                $url = str_replace( 'https://wordpress.com', '', $url );
392                // Replace special characters with their correct entities e.g. &amp; to &.
393                return wp_specialchars_decode( esc_url_raw( $url ) );
394            }
395
396            // Allow URLs pointing to Jetpack.com.
397            if ( str_starts_with( $url, 'https://jetpack.com/' ) ) {
398                // Replace special characters with their correct entities e.g. &amp; to &.
399                return wp_specialchars_decode( esc_url_raw( $url ) );
400            }
401
402            // Disallow other external URLs.
403            if ( ! str_starts_with( $url, get_site_url() ) ) {
404                return '';
405            }
406            // The URL matches that of the site, treat it as an internal URL.
407        }
408
409        // Internal URLs.
410        $menu_hook   = get_plugin_page_hook( $url, $parent_slug );
411        $menu_file   = wp_parse_url( $url, PHP_URL_PATH ); // Removes query args to get a file name.
412        $parent_file = wp_parse_url( $parent_slug, PHP_URL_PATH );
413
414        if (
415            ! empty( $menu_hook ) ||
416            (
417                'index.php' !== $url &&
418                file_exists( WP_PLUGIN_DIR . "/$menu_file" ) &&
419                ! file_exists( ABSPATH . "/wp-admin/$menu_file" )
420            )
421        ) {
422            $admin_is_parent = false;
423            if ( ! empty( $parent_slug ) ) {
424                $menu_hook       = get_plugin_page_hook( $parent_slug, 'admin.php' );
425                $admin_is_parent = ! empty( $menu_hook ) || ( ( 'index.php' !== $parent_slug ) && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! file_exists( ABSPATH . "/wp-admin/$parent_file" ) );
426            }
427
428            if (
429                ( false === $admin_is_parent && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! is_dir( WP_PLUGIN_DIR . "/$parent_file" ) ) ||
430                ( file_exists( ABSPATH . "/wp-admin/$parent_file" ) && ! is_dir( ABSPATH . "/wp-admin/$parent_file" ) )
431            ) {
432                $url = add_query_arg( array( 'page' => $url ), admin_url( $parent_slug ) );
433            } else {
434                $url = add_query_arg( array( 'page' => $url ), admin_url( 'admin.php' ) );
435            }
436        } elseif ( file_exists( ABSPATH . "/wp-admin/$menu_file" ) ) {
437            $url = admin_url( $url );
438        }
439
440        return wp_specialchars_decode( esc_url_raw( $url ) );
441    }
442
443    /**
444     * "Plugins", "Comments", "Updates" menu items have a count badge when there are updates available.
445     * This method parses that information, removes the associated markup and adds it to the response.
446     *
447     * Also sanitizes the titles from remaining unexpected markup.
448     *
449     * @param string $title Title to parse.
450     * @return array
451     */
452    private function parse_menu_item( $title ) {
453        // Handle non-string input
454        if ( ! is_string( $title ) ) {
455            return array();
456        }
457
458        $item = array();
459
460        if (
461            str_contains( $title, 'count-' )
462            && preg_match( '/<span class=".+\s?count-(\d*).+\s?<\/span><\/span>/', $title, $matches )
463        ) {
464
465            $count = (int) ( $matches[1] );
466            if ( $count > 0 ) {
467                // Keep the counter in the item array.
468                $item['count'] = $count;
469            }
470
471            // Finally remove the markup.
472            $title = trim( str_replace( $matches[0], '', $title ) );
473        }
474
475        if (
476            str_contains( $title, 'inline-text' )
477            && preg_match( '/<span class="inline-text".+\s?>(.+)<\/span>/', $title, $matches )
478        ) {
479
480            $text = $matches[1];
481            if ( $text ) {
482                // Keep the text in the item array.
483                $item['inlineText'] = $text;
484            }
485
486            // Finally remove the markup.
487            $title = trim( str_replace( $matches[0], '', $title ) );
488        }
489
490        if (
491            str_contains( $title, 'awaiting-mod' )
492            && preg_match( '/<span class="awaiting-mod">(.+)<\/span>/', $title, $matches )
493        ) {
494
495            $text = $matches[1];
496            if ( $text ) {
497                // Keep the text in the item array.
498                $item['badge'] = $text;
499            }
500
501            // Finally remove the markup.
502            $title = trim( str_replace( $matches[0], '', $title ) );
503        }
504
505        // It's important we sanitize the title after parsing data to remove any unexpected markup but keep the content.
506        // We are also capitalizing the first letter in case there was a counter (now parsed) in front of the title.
507        $item['title'] = ucfirst( wp_strip_all_tags( $title ) );
508
509        return $item;
510    }
511}
512
513wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Admin_Menu' );