'', 'route' => \esc_html__( 'Endpoint Route', '0-day-analytics' ), 'methods' => \esc_html__( 'Methods', '0-day-analytics' ), 'namespace' => \esc_html__( 'Namespace', '0-day-analytics' ), 'show_in_index' => \esc_html__( 'In Index', '0-day-analytics' ), 'is_public' => \esc_html__( 'Public', '0-day-analytics' ), 'status' => \esc_html__( 'Status', '0-day-analytics' ), ); static::$columns = $admin_columns; return $admin_columns; } /** * Get sortable columns. * * @return array * * @since 5.0.0 */ public function get_sortable_columns() { return array( 'route' => array( 'route', true ), 'namespace' => array( 'namespace', false ), 'status' => array( 'status', false ), ); } /** * Get bulk actions. * * @return array * * @since 5.0.0 */ public function get_bulk_actions() { return array( 'disable' => \esc_html__( 'Disable', '0-day-analytics' ), 'enable' => \esc_html__( 'Enable', '0-day-analytics' ), ); } /** * Column default handler. * * @param array $item The item data. * @param string $column_name The column name. * * @return string * * @since 5.0.0 */ protected function column_default( $item, $column_name ) { return self::format_column_value( $item, $column_name ); } /** * Route column with row actions. * * @param array $item The item data. * * @return string * * @since 5.0.0 */ protected function column_route( $item ) { $rules = Rest_Api_Helper::get_endpoint_rules(); $route = \esc_html( $item['route'] ); $is_obfuscated = isset( $rules[ $item['route'] ]['obfuscate'] ) && $rules[ $item['route'] ]['obfuscate']; $obfuscated_badge = $is_obfuscated ? ' ' : ''; $route_hash = \esc_attr( $item['route_hash'] ); $is_disabled = isset( $rules[ $item['route'] ]['disabled'] ) && $rules[ $item['route'] ]['disabled']; $list_params = self::get_preserved_list_params(); $edit_url = \add_query_arg( array_merge( $list_params, array( 'page' => self::REST_API_MENU_SLUG, 'action' => 'edit_route', 'route_hash' => $route_hash, '_wpnonce' => \wp_create_nonce( 'bulk-rest-api-endpoints' ), ) ), \admin_url( 'admin.php' ) ); $toggle_label = $is_disabled ? \esc_html__( 'Enable', '0-day-analytics' ) : \esc_html__( 'Disable', '0-day-analytics' ); $toggle_url = \wp_nonce_url( \add_query_arg( array_merge( $list_params, array( 'action' => self::TOGGLE_ACTION, 'route_hash' => $route_hash, 'toggle' => $is_disabled ? 'enable' : 'disable', ) ), \admin_url( 'admin-post.php' ) ), self::NONCE_NAME ); $actions = array( 'edit' => sprintf( '%s', \esc_url( $edit_url ), \esc_html__( 'Edit', '0-day-analytics' ) ), 'toggle' => sprintf( '%s', \esc_url( $toggle_url ), $toggle_label ), ); return '' . $route . '' . $obfuscated_badge . $this->row_actions( $actions ); } /** * Checkbox column. * * @param array $item The item data. * * @return string * * @since 5.0.0 */ protected function column_cb( $item ) { return sprintf( '', \esc_attr( $item['route_hash'] ) ); } /** * Format column value. * * @param array $item The item data. * @param string $column_name The column name. * * @return string * * @since 5.0.0 */ public static function format_column_value( $item, $column_name ): string { $rules = Rest_Api_Helper::get_endpoint_rules(); switch ( $column_name ) { case 'route': $route = \esc_html( $item['route'] ); $is_obfuscated = isset( $rules[ $item['route'] ]['obfuscate'] ) && $rules[ $item['route'] ]['obfuscate']; $obfuscated_badge = $is_obfuscated ? ' ' : ''; return '' . $route . '' . $obfuscated_badge; case 'methods': $methods = $item['methods']; $disabled_methods = isset( $rules[ $item['route'] ]['disabled_methods'] ) ? $rules[ $item['route'] ]['disabled_methods'] : array(); $output = ''; foreach ( $methods as $method ) { $is_disabled = in_array( $method, $disabled_methods, true ); $color = $is_disabled ? '#999; text-decoration: line-through' : self::get_method_color( $method ); $output .= sprintf( '%s', \esc_attr( $color ), \esc_html( $method ) ); } return $output; case 'namespace': return '' . \esc_html( $item['namespace'] ) . ''; case 'show_in_index': if ( ! empty( $item['show_in_index'] ) ) { return ''; } return ''; case 'is_public': if ( ! empty( $item['is_public'] ) ) { return '⚠ ' . \esc_html__( 'Public', '0-day-analytics' ) . ''; } return ' ' . \esc_html__( 'Protected', '0-day-analytics' ) . ''; case 'status': $is_disabled = isset( $rules[ $item['route'] ]['disabled'] ) && $rules[ $item['route'] ]['disabled']; if ( $is_disabled ) { return '' . \esc_html__( 'Disabled', '0-day-analytics' ) . ''; } $disabled_methods = isset( $rules[ $item['route'] ]['disabled_methods'] ) ? $rules[ $item['route'] ]['disabled_methods'] : array(); if ( ! empty( $disabled_methods ) ) { return '' . \esc_html__( 'Partial', '0-day-analytics' ) . ''; } return '' . \esc_html__( 'Active', '0-day-analytics' ) . ''; default: return ''; } } /** * Get the color for an HTTP method badge. * * @param string $method HTTP method. * * @return string Hex color code. * * @since 5.0.0 */ private static function get_method_color( string $method ): string { $colors = array( 'GET' => '#00a32a', 'POST' => '#2271b1', 'PUT' => '#dba617', 'PATCH' => '#dba617', 'DELETE' => '#d63638', 'OPTIONS' => '#8c8f94', 'HEAD' => '#8c8f94', ); return $colors[ strtoupper( $method ) ] ?? '#8c8f94'; } /** * Prepares items for display. * * @return void * * @since 5.0.0 */ public function prepare_items() { $columns = $this->get_columns(); $sortable = $this->get_sortable_columns(); $this->_column_headers = array( $columns, array(), $sortable ); $endpoints = Rest_Api_Helper::get_all_endpoints(); $search = self::escaped_search_input(); // Filter by search. if ( ! empty( $search ) ) { $endpoints = array_filter( $endpoints, function ( $item ) use ( $search ) { return false !== stripos( $item['route'], $search ) || false !== stripos( $item['namespace'], $search ); } ); } // Filter by namespace if set. $ns_filter = isset( $_REQUEST['namespace_filter'] ) ? \sanitize_text_field( \wp_unslash( $_REQUEST['namespace_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! empty( $ns_filter ) ) { $endpoints = array_filter( $endpoints, function ( $item ) use ( $ns_filter ) { return $item['namespace'] === $ns_filter; } ); } // Filter by status. $status_filter = isset( $_REQUEST['status_filter'] ) ? \sanitize_key( \wp_unslash( $_REQUEST['status_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! empty( $status_filter ) ) { $rules = Rest_Api_Helper::get_endpoint_rules(); $endpoints = array_filter( $endpoints, function ( $item ) use ( $status_filter, $rules ) { $is_disabled = isset( $rules[ $item['route'] ]['disabled'] ) && $rules[ $item['route'] ]['disabled']; $disabled_methods = isset( $rules[ $item['route'] ]['disabled_methods'] ) ? $rules[ $item['route'] ]['disabled_methods'] : array(); $is_obfuscated = isset( $rules[ $item['route'] ]['obfuscate'] ) && $rules[ $item['route'] ]['obfuscate']; if ( 'disabled' === $status_filter ) { return $is_disabled; } if ( 'active' === $status_filter ) { return ! $is_disabled && empty( $disabled_methods ); } if ( 'partial' === $status_filter ) { return ! $is_disabled && ! empty( $disabled_methods ); } if ( 'obfuscated' === $status_filter ) { return $is_obfuscated; } return true; } ); } // Filter by method. $method_filter = isset( $_REQUEST['method_filter'] ) ? \sanitize_text_field( \wp_unslash( $_REQUEST['method_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! empty( $method_filter ) ) { $method_filter_upper = strtoupper( $method_filter ); $endpoints = array_filter( $endpoints, function ( $item ) use ( $method_filter_upper ) { return in_array( $method_filter_upper, $item['methods'], true ); } ); } // Filter by access (public/protected). $access_filter = isset( $_REQUEST['access_filter'] ) ? \sanitize_key( \wp_unslash( $_REQUEST['access_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! empty( $access_filter ) ) { $endpoints = array_filter( $endpoints, function ( $item ) use ( $access_filter ) { if ( 'public' === $access_filter ) { return ! empty( $item['is_public'] ); } if ( 'protected' === $access_filter ) { return empty( $item['is_public'] ); } return true; } ); } // Compute real status based on rules so sorting works. $rules = Rest_Api_Helper::get_endpoint_rules(); foreach ( $endpoints as &$ep ) { $is_disabled = isset( $rules[ $ep['route'] ]['disabled'] ) && $rules[ $ep['route'] ]['disabled']; if ( $is_disabled ) { $ep['status'] = 'disabled'; } elseif ( ! empty( $rules[ $ep['route'] ]['disabled_methods'] ) ) { $ep['status'] = 'partial'; } else { $ep['status'] = 'active'; } } unset( $ep ); // Sort. $orderby = isset( $_REQUEST['orderby'] ) ? \sanitize_key( \wp_unslash( $_REQUEST['orderby'] ) ) : 'route'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order = isset( $_REQUEST['order'] ) ? \sanitize_key( \wp_unslash( $_REQUEST['order'] ) ) : 'asc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( in_array( $orderby, array( 'route', 'namespace', 'status' ), true ) ) { usort( $endpoints, function ( $a, $b ) use ( $orderby, $order ) { $result = strcmp( $a[ $orderby ] ?? '', $b[ $orderby ] ?? '' ); return 'desc' === $order ? -$result : $result; } ); } // Pagination. $per_page = $this->get_items_per_page( self::SCREEN_OPTIONS_SLUG . '_per_page', static::$rows_per_page ); $current_page = $this->get_pagenum(); $total_items = count( $endpoints ); $this->items = array_slice( $endpoints, ( $current_page - 1 ) * $per_page, $per_page ); $this->set_pagination_args( array( 'total_items' => $total_items, 'per_page' => $per_page, 'total_pages' => (int) ceil( $total_items / $per_page ), ) ); } /** * Process bulk actions early (on load-{page} hook) before any output. * * @return void * * @since 5.1.0 */ public static function process_bulk_actions(): void { if ( ! \current_user_can( 'manage_options' ) ) { return; } // Determine the current action from the bulk action dropdowns. $action = ''; if ( ! empty( $_REQUEST['action'] ) && '-1' !== $_REQUEST['action'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $action = \sanitize_key( \wp_unslash( $_REQUEST['action'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } elseif ( ! empty( $_REQUEST['action2'] ) && '-1' !== $_REQUEST['action2'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $action = \sanitize_key( \wp_unslash( $_REQUEST['action2'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } if ( ! in_array( $action, array( 'disable', 'enable' ), true ) ) { return; } WP_Helper::verify_admin_nonce( 'bulk-items' ); $routes = isset( $_REQUEST['rest_routes'] ) ? array_map( 'sanitize_text_field', (array) $_REQUEST['rest_routes'] ) : array(); if ( empty( $routes ) ) { return; } $all_endpoints = Rest_Api_Helper::get_all_endpoints(); $rules = Rest_Api_Helper::get_endpoint_rules(); foreach ( $routes as $route_hash ) { foreach ( $all_endpoints as $endpoint ) { if ( $endpoint['route_hash'] === $route_hash ) { if ( 'disable' === $action ) { $rules[ $endpoint['route'] ]['disabled'] = true; } elseif ( 'enable' === $action ) { unset( $rules[ $endpoint['route'] ]['disabled'] ); if ( empty( $rules[ $endpoint['route'] ] ) ) { unset( $rules[ $endpoint['route'] ] ); } } break; } } } Rest_Api_Helper::save_endpoint_rules( $rules ); $redirect_params = self::get_preserved_list_params(); $redirect_params['page'] = self::REST_API_MENU_SLUG; $redirect_params['updated'] = 'true'; \wp_safe_redirect( \add_query_arg( $redirect_params, \admin_url( 'admin.php' ) ) ); exit; } /** * Handle table actions (bulk actions). * * @return void * * @since 5.0.0 */ public function handle_table_actions() { if ( ! \current_user_can( 'manage_options' ) ) { return; } $action = $this->current_action(); if ( ! $action ) { return; } if ( ! in_array( $action, array( 'disable', 'enable' ), true ) ) { return; } WP_Helper::verify_admin_nonce( 'bulk-' . $this->_args['plural'] ); $routes = isset( $_REQUEST['rest_routes'] ) ? array_map( 'sanitize_text_field', (array) $_REQUEST['rest_routes'] ) : array(); if ( empty( $routes ) ) { return; } $all_endpoints = Rest_Api_Helper::get_all_endpoints(); $rules = Rest_Api_Helper::get_endpoint_rules(); foreach ( $routes as $route_hash ) { // Find the route by hash. foreach ( $all_endpoints as $endpoint ) { if ( $endpoint['route_hash'] === $route_hash ) { if ( 'disable' === $action ) { $rules[ $endpoint['route'] ]['disabled'] = true; } elseif ( 'enable' === $action ) { unset( $rules[ $endpoint['route'] ]['disabled'] ); // Clean up empty entries. if ( empty( $rules[ $endpoint['route'] ] ) ) { unset( $rules[ $endpoint['route'] ] ); } } break; } } } Rest_Api_Helper::save_endpoint_rules( $rules ); $redirect_params = self::get_preserved_list_params(); $redirect_params['page'] = self::REST_API_MENU_SLUG; $redirect_params['updated'] = 'true'; \wp_safe_redirect( \add_query_arg( $redirect_params, \admin_url( 'admin.php' ) ) ); exit; } /** * Display extra table navigation (filters). * * @param string $which Top or bottom. * * @return void * * @since 5.0.0 */ public function extra_tablenav( $which ) { if ( 'top' !== $which ) { return; } $namespaces = Rest_Api_Helper::get_all_namespaces(); $all_methods = Rest_Api_Helper::get_all_methods(); $current_ns = isset( $_REQUEST['namespace_filter'] ) ? \sanitize_text_field( \wp_unslash( $_REQUEST['namespace_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $current_status = isset( $_REQUEST['status_filter'] ) ? \sanitize_key( \wp_unslash( $_REQUEST['status_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $current_method = isset( $_REQUEST['method_filter'] ) ? \sanitize_text_field( \wp_unslash( $_REQUEST['method_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $current_access = isset( $_REQUEST['access_filter'] ) ? \sanitize_key( \wp_unslash( $_REQUEST['access_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended echo '
'; // Namespace filter. echo ''; // Method filter. echo ''; // Status filter. echo ''; // Access filter (public/protected). echo ''; \submit_button( \esc_html__( 'Filter', '0-day-analytics' ), '', 'filter_action', false, array( 'id' => 'post-query-submit' ) ); echo '
'; $search = self::escaped_search_input(); ?>
0, 'per_page' => 500, 'search' => '', 'namespace_filter' => '', 'status_filter' => '', 'method_filter' => '', 'access_filter' => '', ); $args = \wp_parse_args( $args, $defaults ); $endpoints = Rest_Api_Helper::get_all_endpoints(); $rules = Rest_Api_Helper::get_endpoint_rules(); // Filter by search. if ( ! empty( $args['search'] ) ) { $search = $args['search']; $endpoints = array_filter( $endpoints, function ( $item ) use ( $search ) { return false !== stripos( $item['route'], $search ) || false !== stripos( $item['namespace'], $search ); } ); } // Filter by namespace. if ( ! empty( $args['namespace_filter'] ) ) { $ns_filter = $args['namespace_filter']; $endpoints = array_filter( $endpoints, function ( $item ) use ( $ns_filter ) { return $item['namespace'] === $ns_filter; } ); } // Filter by status. if ( ! empty( $args['status_filter'] ) ) { $status_filter = $args['status_filter']; $endpoints = array_filter( $endpoints, function ( $item ) use ( $status_filter, $rules ) { $is_disabled = isset( $rules[ $item['route'] ]['disabled'] ) && $rules[ $item['route'] ]['disabled']; $disabled_methods = isset( $rules[ $item['route'] ]['disabled_methods'] ) ? $rules[ $item['route'] ]['disabled_methods'] : array(); $is_obfuscated = isset( $rules[ $item['route'] ]['obfuscate'] ) && $rules[ $item['route'] ]['obfuscate']; if ( 'disabled' === $status_filter ) { return $is_disabled; } if ( 'active' === $status_filter ) { return ! $is_disabled && empty( $disabled_methods ); } if ( 'partial' === $status_filter ) { return ! $is_disabled && ! empty( $disabled_methods ); } if ( 'obfuscated' === $status_filter ) { return $is_obfuscated; } return true; } ); } // Filter by method. if ( ! empty( $args['method_filter'] ) ) { $method_upper = strtoupper( $args['method_filter'] ); $endpoints = array_filter( $endpoints, function ( $item ) use ( $method_upper ) { return in_array( $method_upper, $item['methods'], true ); } ); } // Filter by access. if ( ! empty( $args['access_filter'] ) ) { $access_filter = $args['access_filter']; $endpoints = array_filter( $endpoints, function ( $item ) use ( $access_filter ) { if ( 'public' === $access_filter ) { return ! empty( $item['is_public'] ); } if ( 'protected' === $access_filter ) { return empty( $item['is_public'] ); } return true; } ); } // Re-index after filtering. $endpoints = array_values( $endpoints ); $total = count( $endpoints ); // Format for CSV. $slice = array_slice( $endpoints, $args['offset'], $args['per_page'] ); $rows = array(); foreach ( $slice as $ep ) { $is_disabled = isset( $rules[ $ep['route'] ]['disabled'] ) && $rules[ $ep['route'] ]['disabled']; $has_partial = ! empty( $rules[ $ep['route'] ]['disabled_methods'] ); $status = $is_disabled ? 'Disabled' : ( $has_partial ? 'Partial' : 'Active' ); $rows[] = array( 'Route' => $ep['route'], 'Methods' => implode( ', ', $ep['methods'] ), 'Namespace' => $ep['namespace'], 'In Index' => $ep['show_in_index'] ? 'Yes' : 'No', 'Access' => $ep['is_public'] ? 'Public' : 'Protected', 'Status' => $status, ); } return array( 'rows' => $rows, 'total' => $total, ); } } }