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