Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.75% covered (success)
93.75%
210 / 224
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Admin_Menu
95.02% covered (success)
95.02%
210 / 221
83.33% covered (warning)
83.33%
10 / 12
74
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%
67 / 67
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
68.97% covered (warning)
68.97%
20 / 29
0.00% covered (danger)
0.00%
0 / 1
19.86
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                'inlineIcon' => array(
188                    'description' => 'Dashicon slug to be displayed inline with the menu title.',
189                    'type'        => 'string',
190                ),
191                'badge'      => array(
192                    'description' => 'Badge to be added inline with the menu title.',
193                    'type'        => 'string',
194                ),
195                'slug'       => array(
196                    'type' => 'string',
197                ),
198                'children'   => array(
199                    'items' => array(
200                        'count'  => array(
201                            'description' => 'Core/Plugin/Theme update count or unread comments count.',
202                            'type'        => 'integer',
203                        ),
204                        'parent' => array(
205                            'type' => 'string',
206                        ),
207                        'slug'   => array(
208                            'type' => 'string',
209                        ),
210                        'title'  => array(
211                            'type' => 'string',
212                        ),
213                        'type'   => array(
214                            'enum' => array( 'submenu-item' ),
215                            'type' => 'string',
216                        ),
217                        'url'    => array(
218                            'format' => 'uri',
219                            'type'   => 'string',
220                        ),
221                    ),
222                    'type'  => 'array',
223                ),
224                'title'      => array(
225                    'type' => 'string',
226                ),
227                'type'       => array(
228                    'enum' => array( 'separator', 'menu-item' ),
229                    'type' => 'string',
230                ),
231                'url'        => array(
232                    'format' => 'uri',
233                    'type'   => 'string',
234                ),
235            ),
236        );
237    }
238
239    /**
240     * Sets up a menu item for consumption by Calypso.
241     *
242     * @param array $menu_item Menu item.
243     * @return array Prepared menu item.
244     */
245    private function prepare_menu_item( array $menu_item ) {
246        global $submenu;
247
248        $current_user_can_access_menu = current_user_can( $menu_item[1] );
249        $submenu_items                = isset( $submenu[ $menu_item[2] ] ) ? array_values( $submenu[ $menu_item[2] ] ) : array();
250        $has_first_menu_item          = isset( $submenu_items[0] );
251
252        // Exclude unauthorized menu items when the user does not have access to the menu and the first submenu item.
253        if ( ! $current_user_can_access_menu && $has_first_menu_item && ! current_user_can( $submenu_items[0][1] ) ) {
254            return array();
255        }
256
257        // Exclude unauthorized menu items that don't have submenus.
258        if ( ! $current_user_can_access_menu && ! $has_first_menu_item ) {
259            return array();
260        }
261
262        // Exclude hidden menu items.
263        if ( str_contains( $menu_item[4], 'hide-if-js' ) ) {
264            // Exclude submenu items as well.
265            if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
266                // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
267                $submenu[ $menu_item[2] ] = array();
268            }
269            return array();
270        }
271
272        // Handle menu separators.
273        if ( str_contains( $menu_item[4], 'wp-menu-separator' ) ) {
274            return array(
275                'type' => 'separator',
276            );
277        }
278
279        $url         = $menu_item[2];
280        $parent_slug = '';
281
282        // If there are submenus, the parent menu should always link to the first submenu.
283        // @see https://core.trac.wordpress.org/browser/trunk/src/wp-admin/menu-header.php?rev=49193#L152.
284        if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
285            $parent_slug        = $url;
286            $first_submenu_item = reset( $submenu[ $menu_item[2] ] );
287            $url                = $first_submenu_item[2];
288        }
289
290        $item = array(
291            'icon'  => $this->prepare_menu_item_icon( $menu_item[6] ),
292            'slug'  => sanitize_title_with_dashes( $menu_item[2] ),
293            'title' => $menu_item[0],
294            'type'  => 'menu-item',
295            'url'   => $this->prepare_menu_item_url( $url, $parent_slug ),
296        );
297
298        $parsed_item = $this->parse_menu_item( $item['title'] );
299        if ( ! empty( $parsed_item ) ) {
300            $item = array_merge( $item, $parsed_item );
301        }
302
303        return $item;
304    }
305
306    /**
307     * Sets up a submenu item for consumption by Calypso.
308     *
309     * @param array $submenu_item Submenu item.
310     * @param array $menu_item    Menu item.
311     * @return array Prepared submenu item.
312     */
313    private function prepare_submenu_item( array $submenu_item, array $menu_item ) {
314        // Exclude unauthorized submenu items.
315        if ( ! current_user_can( $submenu_item[1] ) ) {
316            return array();
317        }
318
319        // Exclude hidden submenu items.
320        if ( isset( $submenu_item[4] ) && str_contains( $submenu_item[4], 'hide-if-js' ) ) {
321            return array();
322        }
323
324        $item = array(
325            'parent' => sanitize_title_with_dashes( $menu_item[2] ),
326            'slug'   => sanitize_title_with_dashes( $submenu_item[2] ),
327            'title'  => $submenu_item[0],
328            'type'   => 'submenu-item',
329            'url'    => $this->prepare_menu_item_url( $submenu_item[2], $menu_item[2] ),
330        );
331
332        $parsed_item = $this->parse_menu_item( $item['title'] );
333        if ( ! empty( $parsed_item ) ) {
334            $item = array_merge( $item, $parsed_item );
335        }
336
337        return $item;
338    }
339
340    /**
341     * Prepares a menu icon for consumption by Calypso.
342     *
343     * @param string $icon Menu icon.
344     * @return string
345     */
346    private function prepare_menu_item_icon( $icon ) {
347        $img = 'dashicons-admin-generic';
348
349        if ( ! empty( $icon ) && 'none' !== $icon && 'div' !== $icon ) {
350            $img = esc_url( $icon );
351
352            if ( str_starts_with( $icon, 'data:image/svg+xml' ) ) {
353                $img = $icon;
354            } elseif ( str_starts_with( $icon, 'dashicons-' ) ) {
355                $img = $this->prepare_dashicon( $icon );
356            }
357        }
358
359        return $img;
360    }
361
362    /**
363     * Prepares the dashicon for consumption by Calypso. If the dashicon isn't found in a list of known icons
364     * we will return the default dashicon.
365     *
366     * @param string $icon The dashicon string to check.
367     *
368     * @return string If the dashicon exists in core we return the dashicon, otherwise we return the default dashicon.
369     */
370    private function prepare_dashicon( $icon ) {
371        if ( empty( $this->dashicon_set ) ) {
372            $this->dashicon_list = include JETPACK__PLUGIN_DIR . 'jetpack_vendor/automattic/jetpack-masterbar/src/admin-menu/dashicon-set.php';
373        }
374
375        if ( isset( $this->dashicon_list[ $icon ] ) && $this->dashicon_list[ $icon ] ) {
376            return $icon;
377        }
378
379        return 'dashicons-admin-generic';
380    }
381
382    /**
383     * Prepares a menu item url for consumption by Calypso.
384     *
385     * @param string $url         Menu slug.
386     * @param string $parent_slug Optional. Parent menu item slug. Default empty string.
387     * @return string
388     */
389    private function prepare_menu_item_url( $url, $parent_slug = '' ) {
390        // External URLS.
391        if ( preg_match( '/^https?:\/\//', $url ) ) {
392            // Allow URLs pointing to WordPress.com.
393            if ( str_starts_with( $url, 'https://wordpress.com/' ) ) {
394                // Calypso needs the domain removed so they're not interpreted as external links.
395                $url = str_replace( 'https://wordpress.com', '', $url );
396                // Replace special characters with their correct entities e.g. &amp; to &.
397                return wp_specialchars_decode( esc_url_raw( $url ) );
398            }
399
400            // Allow URLs pointing to Jetpack.com.
401            if ( str_starts_with( $url, 'https://jetpack.com/' ) ) {
402                // Replace special characters with their correct entities e.g. &amp; to &.
403                return wp_specialchars_decode( esc_url_raw( $url ) );
404            }
405
406            // Disallow other external URLs.
407            if ( ! str_starts_with( $url, get_site_url() ) ) {
408                return '';
409            }
410            // The URL matches that of the site, treat it as an internal URL.
411        }
412
413        // Internal URLs.
414        $menu_hook   = get_plugin_page_hook( $url, $parent_slug );
415        $menu_file   = wp_parse_url( $url, PHP_URL_PATH ); // Removes query args to get a file name.
416        $parent_file = wp_parse_url( $parent_slug, PHP_URL_PATH );
417
418        if (
419            ! empty( $menu_hook ) ||
420            (
421                'index.php' !== $url &&
422                file_exists( WP_PLUGIN_DIR . "/$menu_file" ) &&
423                ! file_exists( ABSPATH . "/wp-admin/$menu_file" )
424            )
425        ) {
426            $admin_is_parent = false;
427            if ( ! empty( $parent_slug ) ) {
428                $menu_hook       = get_plugin_page_hook( $parent_slug, 'admin.php' );
429                $admin_is_parent = ! empty( $menu_hook ) || ( ( 'index.php' !== $parent_slug ) && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! file_exists( ABSPATH . "/wp-admin/$parent_file" ) );
430            }
431
432            if (
433                ( false === $admin_is_parent && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! is_dir( WP_PLUGIN_DIR . "/$parent_file" ) ) ||
434                ( file_exists( ABSPATH . "/wp-admin/$parent_file" ) && ! is_dir( ABSPATH . "/wp-admin/$parent_file" ) )
435            ) {
436                $url = add_query_arg( array( 'page' => $url ), admin_url( $parent_slug ) );
437            } else {
438                $url = add_query_arg( array( 'page' => $url ), admin_url( 'admin.php' ) );
439            }
440        } elseif ( file_exists( ABSPATH . "/wp-admin/$menu_file" ) ) {
441            $url = admin_url( $url );
442        }
443
444        return wp_specialchars_decode( esc_url_raw( $url ) );
445    }
446
447    /**
448     * "Plugins", "Comments", "Updates" menu items have a count badge when there are updates available.
449     * This method parses that information, removes the associated markup and adds it to the response.
450     *
451     * Also sanitizes the titles from remaining unexpected markup.
452     *
453     * @param string $title Title to parse.
454     * @return array
455     */
456    private function parse_menu_item( $title ) {
457        // Handle non-string input
458        if ( ! is_string( $title ) ) {
459            return array();
460        }
461
462        $item = array();
463
464        if (
465            str_contains( $title, 'count-' )
466            && preg_match( '/<span class=".+\s?count-(\d*).+\s?<\/span><\/span>/', $title, $matches )
467        ) {
468
469            $count = (int) ( $matches[1] );
470            if ( $count > 0 ) {
471                // Keep the counter in the item array.
472                $item['count'] = $count;
473            }
474
475            // Finally remove the markup.
476            $title = trim( str_replace( $matches[0], '', $title ) );
477        }
478
479        if (
480            str_contains( $title, 'inline-text' )
481            && preg_match( '/<span class="inline-text".+\s?>(.+)<\/span>/', $title, $matches )
482        ) {
483
484            $text = $matches[1];
485            if ( $text ) {
486                // Keep the text in the item array.
487                $item['inlineText'] = $text;
488            }
489
490            // Finally remove the markup.
491            $title = trim( str_replace( $matches[0], '', $title ) );
492        }
493
494        if (
495            str_contains( $title, 'inline-icon' )
496            && preg_match( '/<span class="inline-icon dashicons (dashicons-[^"]+)"[^>]*><\/span>/', $title, $matches )
497        ) {
498
499            $icon = $matches[1];
500            if ( $icon ) {
501                // Keep the dashicon slug in the item array.
502                $item['inlineIcon'] = $icon;
503            }
504
505            // Finally remove the markup.
506            $title = trim( str_replace( $matches[0], '', $title ) );
507        }
508
509        if (
510            str_contains( $title, 'awaiting-mod' )
511            && preg_match( '/<span class="awaiting-mod">(.+)<\/span>/', $title, $matches )
512        ) {
513
514            $text = $matches[1];
515            if ( $text ) {
516                // Keep the text in the item array.
517                $item['badge'] = $text;
518            }
519
520            // Finally remove the markup.
521            $title = trim( str_replace( $matches[0], '', $title ) );
522        }
523
524        // It's important we sanitize the title after parsing data to remove any unexpected markup but keep the content.
525        // We are also capitalizing the first letter in case there was a counter (now parsed) in front of the title.
526        $item['title'] = ucfirst( wp_strip_all_tags( $title ) );
527
528        return $item;
529    }
530}
531
532wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Admin_Menu' );