array( 'identity', 'summary', 'hierarchical', 'labels', 'supports', 'taxonomies', 'rest' ), 'taxonomies' => array( 'identity', 'summary', 'hierarchical', 'labels', 'object_types', 'rest' ), 'block_types' => array( 'identity', 'summary', 'attributes', 'render', 'supports' ), 'block_patterns' => array( 'identity', 'summary', 'content' ), 'shortcodes' => array( 'identity', 'summary' ), 'roles' => array( 'identity', 'summary', 'capabilities' ), 'rest_routes' => array( 'identity', 'summary' ), 'hooks' => array( 'identity', 'summary' ), 'transients' => array( 'identity', 'summary' ), 'plugins' => array( 'identity', 'status', 'updates', 'health' ), 'themes' => array( 'identity', 'status', 'updates', 'supports' ), 'files' => array( 'identity', 'metadata', 'snippet', 'content' ), 'logs' => array( 'identity', 'summary', 'entries', 'excerpt' ), 'assets' => array( 'identity', 'source', 'owner', 'loading', 'localized', 'duplicates' ), 'runtime' => array( 'wordpress', 'php', 'debug', 'cache', 'multisite' ), 'server' => array( 'php', 'wordpress' ), 'site_health' => array( 'summary', 'tests' ), 'core' => array( 'core' ), ); public const CAPABILITIES = array( 'wp.post_types.query' => array( 'resource' => 'post_types', 'action' => 'query', ), 'wp.post_types.get' => array( 'resource' => 'post_types', 'action' => 'get', ), 'wp.taxonomies.query' => array( 'resource' => 'taxonomies', 'action' => 'query', ), 'wp.taxonomies.get' => array( 'resource' => 'taxonomies', 'action' => 'get', ), 'wp.block_types.query' => array( 'resource' => 'block_types', 'action' => 'query', ), 'wp.block_types.get' => array( 'resource' => 'block_types', 'action' => 'get', ), 'wp.block_patterns.query' => array( 'resource' => 'block_patterns', 'action' => 'query', ), 'wp.block_patterns.get' => array( 'resource' => 'block_patterns', 'action' => 'get', ), 'wp.shortcodes.query' => array( 'resource' => 'shortcodes', 'action' => 'query', ), 'wp.shortcodes.get' => array( 'resource' => 'shortcodes', 'action' => 'get', ), 'wp.roles.query' => array( 'resource' => 'roles', 'action' => 'query', ), 'wp.roles.get' => array( 'resource' => 'roles', 'action' => 'get', ), 'wp.rest_routes.query' => array( 'resource' => 'rest_routes', 'action' => 'query', ), 'wp.rest_routes.get' => array( 'resource' => 'rest_routes', 'action' => 'get', ), 'wp.hooks.query' => array( 'resource' => 'hooks', 'action' => 'query', ), 'wp.hooks.get' => array( 'resource' => 'hooks', 'action' => 'get', ), 'wp.transients.query' => array( 'resource' => 'transients', 'action' => 'query', ), 'wp.transients.get' => array( 'resource' => 'transients', 'action' => 'get', ), 'wp.transients.delete' => array( 'resource' => 'transients', 'action' => 'delete', ), 'wp.plugins.query' => array( 'resource' => 'plugins', 'action' => 'query', ), 'wp.plugins.get' => array( 'resource' => 'plugins', 'action' => 'get', ), 'wp.themes.query' => array( 'resource' => 'themes', 'action' => 'query', ), 'wp.themes.get' => array( 'resource' => 'themes', 'action' => 'get', ), 'wp.files.query' => array( 'resource' => 'files', 'action' => 'query', ), 'wp.files.get' => array( 'resource' => 'files', 'action' => 'get', ), 'wp.runtime.get' => array( 'resource' => 'runtime', 'action' => 'get', ), 'wp.server.get' => array( 'resource' => 'server', 'action' => 'get', ), 'wp.site_health.get' => array( 'resource' => 'site_health', 'action' => 'get', ), 'wp.core.get' => array( 'resource' => 'core', 'action' => 'get', ), 'wp.logs.query' => array( 'resource' => 'logs', 'action' => 'query', ), 'wp.assets.query' => array( 'resource' => 'assets', 'action' => 'query', ), 'wp.assets.get' => array( 'resource' => 'assets', 'action' => 'get', ), ); private string $resource; private string $action; public function __construct( string $capability ) { $metadata = self::CAPABILITIES[ $capability ] ?? array( 'resource' => '', 'action' => '', ); $this->resource = (string) $metadata['resource']; $this->action = (string) $metadata['action']; } /** * Run one grammar command. * * @param array $input Command input. * @param string $phase validate or execute. */ public function run( array $input, string $phase ): array { $validation = $this->validate_input( $input ); if ( isset( $validation['error'] ) ) { return $validation; } if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } if ( 'delete' === $this->action ) { return $this->delete_item( $input ); } if ( 'get' === $this->action ) { return $this->get_item( $input ); } return $this->query_items( $input ); } private function validate_input( array $input ): array { if ( isset( $input['select'] ) ) { if ( ! $this->is_select_list( $input['select'] ) ) { return $this->error( 'invalid_command', 'select must be a list of array-segment paths.', array( 'input', 'select' ) ); } foreach ( array_values( $this->normalize_select( $input['select'] ) ?? array() ) as $index => $path ) { if ( ! $this->path_root_known( $path ) ) { return $this->error( 'invalid_path', 'select path is not part of this resource document.', array( 'input', 'select', $index ) ); } } } if ( isset( $input['limit'] ) ) { if ( ! is_numeric( $input['limit'] ) || (int) $input['limit'] < 1 ) { return $this->error( 'invalid_value', 'limit must be a positive integer.', array( 'input', 'limit' ) ); } if ( (int) $input['limit'] > self::MAX_LIMIT ) { return $this->error( 'limit_exceeded', 'limit exceeds the registry read cap.', array( 'input', 'limit' ), array( 'max' => self::MAX_LIMIT ) ); } } if ( isset( $input['cursor'] ) && null !== $input['cursor'] && ! is_string( $input['cursor'] ) ) { return $this->error( 'invalid_value', 'cursor must be null or an opaque string.', array( 'input', 'cursor' ) ); } if ( 'query' === $this->action ) { $cursor = CommandPagination::validate( $input['cursor'] ?? null, $this->cursor_context( $input ) ); if ( isset( $cursor['error'] ) ) { return $cursor; } if ( isset( $input['where'] ) && is_array( $input['where'] ) ) { $where_validation = $this->validate_where_ops( $input['where'], array( 'input', 'where' ) ); if ( isset( $where_validation['error'] ) ) { return $where_validation; } } if ( isset( $input['order_by'] ) ) { $order_validation = $this->validate_order_by( $input['order_by'] ); if ( isset( $order_validation['error'] ) ) { return $order_validation; } } } if ( 'files' === $this->resource ) { return $this->validate_file_scope( $input ); } if ( 'logs' === $this->resource && ! $this->logs_enabled() ) { return $this->error( 'permission_denied', 'Log reads are disabled by ActionPanel settings.', array( 'input' ), array( 'layer' => 'settings', 'reason' => 'logs_disabled', ) ); } return array( 'data' => array( 'valid' => true ) ); } private function query_items( array $input ): array { $documents = $this->documents( $input ); if ( isset( $documents['error'] ) ) { return $documents; } $items = $documents['documents']; $select = $this->normalize_select( $input['select'] ?? null ); $items = $this->filter_documents( $items, $input['where'] ?? null ); $search = isset( $input['search'] ) && is_scalar( $input['search'] ) ? strtolower( (string) $input['search'] ) : ''; if ( '' !== $search ) { $items = array_values( array_filter( $items, static fn ( array $item ): bool => false !== strpos( strtolower( (string) json_encode( $item ) ), $search ) ) ); } $items = $this->order_documents( $items, $input['order_by'] ?? null ); $limit = isset( $input['limit'] ) ? (int) $input['limit'] : self::DEFAULT_LIMIT; $page = CommandPagination::page( $items, $limit, $input['cursor'] ?? null, $this->cursor_context( $input ) ); if ( isset( $page['error'] ) ) { return $page; } return array( 'data' => array( 'resource' => $this->resource, 'items' => array_map( fn ( array $document ): array => $this->item_result( $document, $select ), $page['items'] ), 'page' => array( 'next_cursor' => $page['next_cursor'], 'limit' => $limit, ), ), 'meta' => array( 'total' => count( $items ), 'returned_count' => count( $page['items'] ), ), ); } private function cursor_context( array $input ): array { return array( 'resource' => $this->resource, 'where' => $input['where'] ?? null, 'search' => $input['search'] ?? null, 'order_by' => $input['order_by'] ?? array(), ); } private function get_item( array $input ): array { $select = $this->normalize_select( $input['select'] ?? null ); if ( in_array( $this->resource, self::SINGLETON_RESOURCES, true ) ) { return array( 'data' => array( 'resource' => $this->resource, 'target' => array( 'resource' => $this->resource ), 'document' => $this->project_document( $this->singleton_document(), $select ), ), ); } $key = $this->target_key( $input ); if ( '' === $key && 'themes' === $this->resource ) { $document = $this->theme_document(); return array( 'data' => array( 'resource' => $this->resource, 'target' => $this->target_for_document( $document ), 'document' => $this->project_document( $document, $select ), ), ); } if ( '' === $key ) { return $this->error( 'invalid_target', 'get requires a scalar target identity.', array( 'input', 'target' ) ); } $documents = $this->documents( $input ); if ( isset( $documents['error'] ) ) { return $documents; } foreach ( $documents['documents'] as $document ) { if ( $key === $this->document_key( $document ) ) { return array( 'data' => array( 'resource' => $this->resource, 'target' => $this->target_for_document( $document ), 'document' => $this->project_document( $document, $select ), ), ); } } return $this->error( 'not_found', 'No registry item matched the requested target.', array( 'input', 'target' ) ); } private function delete_item( array $input ): array { $key = $this->target_key( $input ); if ( '' === $key ) { return $this->error( 'invalid_target', 'delete requires a scalar target key.', array( 'input', 'target' ) ); } $deleted = function_exists( 'delete_transient' ) ? (bool) delete_transient( $key ) : true; if ( ! $deleted ) { return $this->error( 'not_found', 'No stored transient matched the requested key.', array( 'input', 'target' ) ); } return array( 'data' => array( 'resource' => 'transients', 'target' => array( 'key' => $key ), 'deleted' => true, ), ); } private function documents( array $input ): array { $documents = match ( $this->resource ) { 'post_types' => $this->post_type_documents(), 'taxonomies' => $this->taxonomy_documents(), 'block_types' => $this->block_type_documents(), 'block_patterns' => $this->block_pattern_documents(), 'shortcodes' => $this->shortcode_documents(), 'roles' => $this->role_documents(), 'rest_routes' => $this->rest_route_documents(), 'hooks' => $this->hook_documents(), 'transients' => $this->transient_documents(), 'plugins' => $this->plugin_documents(), 'themes' => $this->theme_documents(), 'files' => $this->file_documents( $input ), 'logs' => $this->log_documents( $input ), 'assets' => $this->asset_documents( $input ), default => array(), }; if ( isset( $documents['error'] ) || isset( $documents['documents'] ) ) { return $documents; } return array( 'documents' => $documents ); } private function post_type_documents(): array { $post_types = function_exists( 'get_post_types' ) ? get_post_types( array(), 'objects' ) : array(); if ( ! is_array( $post_types ) || array() === $post_types ) { $post_types = array( 'post' => (object) array( 'name' => 'post', 'label' => 'Posts', 'show_in_rest' => true, ), 'page' => (object) array( 'name' => 'page', 'label' => 'Pages', 'show_in_rest' => true, ), ); } $documents = array(); foreach ( $post_types as $name => $post_type ) { $name = is_object( $post_type ) && isset( $post_type->name ) ? (string) $post_type->name : (string) $name; $provider = PostTypesWriteCapability::provider_owner_for_post_type( $name ); $documents[] = array( 'identity' => array( 'name' => $name, 'provider' => $provider, ), 'summary' => array( 'label' => is_object( $post_type ) && isset( $post_type->label ) ? (string) $post_type->label : $name, 'provider' => $provider, 'show_in_rest' => is_object( $post_type ) ? (bool) ( $post_type->show_in_rest ?? false ) : false, 'rest_base' => is_object( $post_type ) && isset( $post_type->rest_base ) ? (string) $post_type->rest_base : '', ), 'hierarchical' => is_object( $post_type ) ? (bool) ( $post_type->hierarchical ?? false ) : false, 'labels' => array( 'name' => is_object( $post_type ) && isset( $post_type->label ) ? (string) $post_type->label : $name, 'singular_name' => is_object( $post_type ) && isset( $post_type->labels->singular_name ) ? (string) $post_type->labels->singular_name : $name, ), 'supports' => $this->post_type_supports( $name, $post_type ), 'taxonomies' => function_exists( 'get_object_taxonomies' ) ? get_object_taxonomies( $name, 'names' ) : array(), 'rest' => array( 'show_in_rest' => is_object( $post_type ) ? (bool) ( $post_type->show_in_rest ?? false ) : false, 'rest_base' => is_object( $post_type ) && isset( $post_type->rest_base ) ? (string) $post_type->rest_base : '', ), ); } return $documents; } private function post_type_supports( string $name, object $post_type ): array { if ( function_exists( 'get_all_post_type_supports' ) ) { $supports = get_all_post_type_supports( $name ); return is_array( $supports ) ? array_values( array_keys( $supports ) ) : array(); } if ( isset( $post_type->supports ) && is_array( $post_type->supports ) ) { return array_values( array_map( 'strval', array_keys( $post_type->supports ) ) ); } return array(); } private function taxonomy_documents(): array { $taxonomies = function_exists( 'get_taxonomies' ) ? get_taxonomies( array(), 'objects' ) : array(); if ( ! is_array( $taxonomies ) || array() === $taxonomies ) { $names = array(); foreach ( $this->post_type_documents() as $post_type ) { foreach ( $post_type['taxonomies'] as $taxonomy ) { $names[ (string) $taxonomy ] = (string) $taxonomy; } } $taxonomies = array_map( static fn ( string $name ): object => (object) array( 'name' => $name, 'label' => $name, 'object_type' => array(), 'show_in_rest' => true, ), $names ); } $documents = array(); foreach ( $taxonomies as $name => $taxonomy ) { $name = is_object( $taxonomy ) && isset( $taxonomy->name ) ? (string) $taxonomy->name : (string) $name; $documents[] = array( 'identity' => array( 'name' => $name ), 'summary' => array( 'label' => is_object( $taxonomy ) && isset( $taxonomy->label ) ? (string) $taxonomy->label : $name, 'object_types' => is_object( $taxonomy ) && isset( $taxonomy->object_type ) && is_array( $taxonomy->object_type ) ? array_values( array_map( 'strval', $taxonomy->object_type ) ) : array(), 'show_in_rest' => is_object( $taxonomy ) ? (bool) ( $taxonomy->show_in_rest ?? false ) : false, 'rest_base' => is_object( $taxonomy ) && isset( $taxonomy->rest_base ) ? (string) $taxonomy->rest_base : '', ), 'hierarchical' => is_object( $taxonomy ) ? (bool) ( $taxonomy->hierarchical ?? false ) : false, 'labels' => array( 'name' => is_object( $taxonomy ) && isset( $taxonomy->label ) ? (string) $taxonomy->label : $name, 'singular_name' => is_object( $taxonomy ) && isset( $taxonomy->labels->singular_name ) ? (string) $taxonomy->labels->singular_name : $name, ), 'object_types' => is_object( $taxonomy ) && isset( $taxonomy->object_type ) && is_array( $taxonomy->object_type ) ? array_values( array_map( 'strval', $taxonomy->object_type ) ) : array(), 'rest' => array( 'show_in_rest' => is_object( $taxonomy ) ? (bool) ( $taxonomy->show_in_rest ?? false ) : false, 'rest_base' => is_object( $taxonomy ) && isset( $taxonomy->rest_base ) ? (string) $taxonomy->rest_base : '', ), ); } return $documents; } private function block_type_documents(): array { if ( ! class_exists( '\WP_Block_Type_Registry' ) || ! method_exists( '\WP_Block_Type_Registry', 'get_instance' ) ) { return array(); } $registry = \WP_Block_Type_Registry::get_instance(); $types = is_object( $registry ) && method_exists( $registry, 'get_all_registered' ) ? $registry->get_all_registered() : array(); $documents = array(); foreach ( is_array( $types ) ? $types : array() as $name => $type ) { $name = is_object( $type ) && isset( $type->name ) ? (string) $type->name : (string) $name; $dynamic = is_object( $type ) && ! empty( $type->render_callback ); $documents[] = array( 'identity' => array( 'name' => $name ), 'summary' => array( 'title' => is_object( $type ) && isset( $type->title ) ? (string) $type->title : $name, 'description' => is_object( $type ) && isset( $type->description ) ? (string) $type->description : '', 'category' => is_object( $type ) && isset( $type->category ) ? (string) $type->category : '', 'dynamic' => $dynamic, ), 'attributes' => is_object( $type ) && isset( $type->attributes ) && is_array( $type->attributes ) ? $type->attributes : array(), 'render' => array( 'dynamic' => $dynamic ), 'supports' => is_object( $type ) && isset( $type->supports ) && is_array( $type->supports ) ? $type->supports : array(), ); } return $documents; } private function block_pattern_documents(): array { if ( ! class_exists( '\WP_Block_Patterns_Registry' ) || ! method_exists( '\WP_Block_Patterns_Registry', 'get_instance' ) ) { return array(); } $registry = \WP_Block_Patterns_Registry::get_instance(); $patterns = is_object( $registry ) && method_exists( $registry, 'get_all_registered' ) ? $registry->get_all_registered() : array(); $documents = array(); foreach ( is_array( $patterns ) ? $patterns : array() as $name => $pattern ) { $pattern = is_array( $pattern ) ? $pattern : array(); $name = isset( $pattern['name'] ) && is_scalar( $pattern['name'] ) ? (string) $pattern['name'] : (string) $name; $content = isset( $pattern['content'] ) && is_scalar( $pattern['content'] ) ? (string) $pattern['content'] : ''; $documents[] = array( 'identity' => array( 'name' => $name ), 'summary' => array( 'title' => isset( $pattern['title'] ) && is_scalar( $pattern['title'] ) ? (string) $pattern['title'] : $name, 'categories' => isset( $pattern['categories'] ) && is_array( $pattern['categories'] ) ? array_values( array_map( 'strval', $pattern['categories'] ) ) : array(), 'content_length' => strlen( $content ), ), 'content' => array( 'raw' => $content ), ); } return $documents; } private function shortcode_documents(): array { global $shortcode_tags; $documents = array(); foreach ( is_array( $shortcode_tags ) ? $shortcode_tags : array() as $tag => $callback ) { $documents[] = array( 'identity' => array( 'name' => (string) $tag ), 'summary' => array( 'callback' => $this->callback_label( $callback ) ), ); } return $documents; } private function role_documents(): array { $roles = array(); if ( function_exists( 'wp_roles' ) ) { $wp_roles = wp_roles(); $roles = is_object( $wp_roles ) && isset( $wp_roles->roles ) && is_array( $wp_roles->roles ) ? $wp_roles->roles : array(); } else { global $wp_roles; $roles = is_object( $wp_roles ) && isset( $wp_roles->roles ) && is_array( $wp_roles->roles ) ? $wp_roles->roles : array(); } $documents = array(); foreach ( $roles as $name => $role ) { $capabilities = isset( $role['capabilities'] ) && is_array( $role['capabilities'] ) ? array_keys( array_filter( $role['capabilities'] ) ) : array(); $documents[] = array( 'identity' => array( 'name' => (string) $name ), 'summary' => array( 'display_name' => isset( $role['name'] ) ? (string) $role['name'] : (string) $name ), 'capabilities' => array_values( array_map( 'strval', $capabilities ) ), ); } return $documents; } private function rest_route_documents(): array { $routes = array(); if ( function_exists( 'rest_get_server' ) ) { $server = rest_get_server(); if ( is_object( $server ) && method_exists( $server, 'get_routes' ) ) { $routes = $server->get_routes(); } } return array_map( static fn ( array $route ): array => array( 'identity' => array( 'name' => (string) $route['route'] ), 'summary' => array( 'methods' => $route['methods'], 'callbacks' => $route['callbacks'] ?? array(), 'owners' => $route['owners'] ?? array(), ), ), RestRoutes::flatten( is_array( $routes ) ? $routes : array() ) ); } private function hook_documents(): array { global $wp_filter; $documents = array(); foreach ( is_array( $wp_filter ) ? $wp_filter : array() as $hook => $callbacks ) { $summary = $this->hook_summary( $callbacks ); $documents[] = array( 'identity' => array( 'name' => (string) $hook ), 'summary' => $summary, ); } return $documents; } private function hook_summary( $callbacks ): array { $raw = is_object( $callbacks ) && isset( $callbacks->callbacks ) ? $callbacks->callbacks : $callbacks; $priorities = array(); $samples = array(); $owners = array(); $count = 0; foreach ( is_array( $raw ) ? $raw : array() as $priority => $entries ) { $priorities[] = (int) $priority; foreach ( is_array( $entries ) ? $entries : array() as $entry ) { ++$count; $callback = is_array( $entry ) ? ( $entry['function'] ?? null ) : null; if ( count( $samples ) < 8 ) { $samples[] = $this->callback_label( $callback ); } $owner = $this->callback_owner( $callback ); if ( array() !== $owner ) { $owners[ implode( '|', array( $owner['type'] ?? '', $owner['slug'] ?? '', $owner['path'] ?? '' ) ) ] = $owner; } } } sort( $priorities ); return array( 'callback_count' => $count, 'priorities' => $priorities, 'callbacks' => $samples, 'owners' => array_values( $owners ), ); } private function transient_documents(): array { $keys = array(); $timeouts = array(); $collect = static function ( string $name, $value ) use ( &$keys, &$timeouts ): void { if ( str_starts_with( $name, '_transient_timeout_' ) ) { $timeouts[ substr( $name, strlen( '_transient_timeout_' ) ) ] = (int) $value; } elseif ( str_starts_with( $name, '_transient_' ) ) { $keys[ substr( $name, strlen( '_transient_' ) ) ] = true; } }; if ( function_exists( 'wp_load_alloptions' ) ) { foreach ( wp_load_alloptions() as $name => $value ) { $collect( (string) $name, $value ); } } global $wpdb; if ( is_object( $wpdb ) && isset( $wpdb->options ) && method_exists( $wpdb, 'get_results' ) && method_exists( $wpdb, 'prepare' ) ) { $like = method_exists( $wpdb, 'esc_like' ) ? $wpdb->esc_like( '_transient_' ) . '%' : '_transient_%'; // Core has no API that enumerates transient keys; this admin-only // scan reads live state on demand, so caching would serve stale // contract data. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $db_rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 500", $like ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching foreach ( is_array( $db_rows ) ? $db_rows : array() as $row ) { $collect( (string) ( $row['option_name'] ?? '' ), $row['option_value'] ?? null ); } } $documents = array(); foreach ( array_keys( $keys ) as $key ) { $key = (string) $key; $timeout = $timeouts[ $key ] ?? null; $documents[ $key ] = array( 'identity' => array( 'key' => $key, 'name' => $key, ), 'summary' => array( 'has_timeout' => null !== $timeout && $timeout > 0, 'expires_at' => null !== $timeout && $timeout > 0 ? gmdate( 'c', $timeout ) : null, ), ); } return array_values( $documents ); } private function plugin_documents(): array { if ( ! function_exists( 'get_plugins' ) && defined( 'ABSPATH' ) ) { $plugin_file = ABSPATH . 'wp-admin/includes/plugin.php'; if ( file_exists( $plugin_file ) ) { require_once $plugin_file; } } $plugins = function_exists( 'get_plugins' ) ? get_plugins() : array(); $active = function_exists( 'get_option' ) ? get_option( 'active_plugins', array() ) : array(); $active = is_array( $active ) ? array_map( 'strval', $active ) : array(); $docs = array(); foreach ( $plugins as $plugin => $metadata ) { $metadata = is_array( $metadata ) ? $metadata : array(); $docs[] = array( 'identity' => array( 'plugin' => (string) $plugin, 'name' => isset( $metadata['Name'] ) ? (string) $metadata['Name'] : basename( (string) $plugin ), 'version' => isset( $metadata['Version'] ) ? (string) $metadata['Version'] : '', ), 'status' => array( 'active' => in_array( (string) $plugin, $active, true ) ), 'updates' => array( 'available' => false ), 'health' => array(), ); } return $docs; } private function theme_documents(): array { if ( function_exists( 'wp_get_themes' ) ) { $themes = wp_get_themes(); if ( is_array( $themes ) && array() !== $themes ) { return array_values( array_map( fn ( $theme ): array => $this->theme_document( $theme ), $themes ) ); } } return array( $this->theme_document() ); } private function theme_document( $theme = null ): array { $theme = null !== $theme ? $theme : ( function_exists( 'wp_get_theme' ) ? wp_get_theme() : null ); $slug = is_object( $theme ) && method_exists( $theme, 'get_stylesheet' ) ? (string) $theme->get_stylesheet() : ''; $name = is_object( $theme ) && method_exists( $theme, 'get' ) ? (string) $theme->get( 'Name' ) : $slug; $active = function_exists( 'get_stylesheet' ) ? ( (string) get_stylesheet() === $slug ) : true; return array( 'identity' => array( 'theme' => $slug, 'name' => '' !== $name ? $name : $slug, 'version' => is_object( $theme ) && method_exists( $theme, 'get' ) ? (string) $theme->get( 'Version' ) : '', ), 'status' => array( 'active' => $active ), 'updates' => array( 'available' => false ), 'supports' => is_object( $theme ) && method_exists( $theme, 'get' ) ? array_filter( array_map( 'trim', explode( ',', (string) $theme->get( 'Tags' ) ) ) ) : array(), ); } private function file_documents( array $input ): array { $reader = new SafeFileReads(); if ( 'get' === $this->action ) { $result = $reader->get( $input ); return isset( $result['error'] ) ? $result : array( 'documents' => array( $result['document'] ) ); } return $reader->query( $input ); } private function log_documents( array $input ): array { $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 100; $inspection = DebugLogInspector::from_environment()->inspect(); $path = (string) ( $inspection['path'] ?? '' ); if ( 'available' !== ( $inspection['status'] ?? '' ) || '' === $path ) { return array( array( 'identity' => array( 'name' => 'wp_debug_log' ), 'summary' => array( 'status' => 'unavailable', 'reason' => (string) ( $inspection['reason'] ?? 'not_configured' ), ), 'entries' => array(), 'excerpt' => '', ), ); } $size = filesize( $path ); $mtime = filemtime( $path ); $excerpt = ( new SafeFileReads() )->tail_text_file( $path, $limit ); return array( array( 'identity' => array( 'name' => 'wp_debug_log' ), 'summary' => array( 'status' => 'available', 'path' => basename( $path ), 'size' => false === $size ? 0 : (int) $size, 'mtime_iso' => false === $mtime ? null : gmdate( 'c', (int) $mtime ), ), 'entries' => array_values( array_filter( explode( "\n", $excerpt ), static fn ( string $line ): bool => '' !== trim( $line ) ) ), 'excerpt' => $excerpt, ), ); } private function asset_documents( array $input ): array { $this->prime_asset_context( $input ); $documents = array_merge( $this->asset_documents_for_registry( 'script', function_exists( 'wp_scripts' ) ? wp_scripts() : null ), $this->asset_documents_for_registry( 'style', function_exists( 'wp_styles' ) ? wp_styles() : null ) ); $duplicates = $this->asset_duplicate_map( $documents ); foreach ( $documents as $index => $document ) { $handle = (string) ( $document['identity']['handle'] ?? '' ); $normalized = (string) ( $document['source']['normalized_src'] ?? '' ); $handles = isset( $duplicates[ $normalized ] ) ? array_values( array_diff( $duplicates[ $normalized ], array( $handle ) ) ) : array(); $documents[ $index ]['duplicates'] = array( 'has_duplicate_src' => '' !== $normalized && array() !== $handles, 'handles' => $handles, ); } return $documents; } private function asset_documents_for_registry( string $type, $registry ): array { if ( ! is_object( $registry ) || ! isset( $registry->registered ) || ! is_array( $registry->registered ) ) { return array(); } $queue = isset( $registry->queue ) && is_array( $registry->queue ) ? array_values( array_map( 'strval', $registry->queue ) ) : array(); $documents = array(); foreach ( $registry->registered as $handle => $asset ) { if ( ! is_object( $asset ) ) { continue; } $handle = isset( $asset->handle ) && is_scalar( $asset->handle ) ? (string) $asset->handle : (string) $handle; $src = isset( $asset->src ) && is_scalar( $asset->src ) ? (string) $asset->src : ''; $deps = isset( $asset->deps ) && is_array( $asset->deps ) ? array_values( array_map( 'strval', $asset->deps ) ) : array(); $version = isset( $asset->ver ) && is_scalar( $asset->ver ) ? (string) $asset->ver : ''; $extra = isset( $asset->extra ) && is_array( $asset->extra ) ? $asset->extra : array(); $absolute_src = $this->absolute_asset_src( $src ); $documents[] = array( 'identity' => array( 'handle' => $handle, 'name' => $handle, 'type' => $type, 'registered' => true, 'enqueued' => in_array( $handle, $queue, true ), ), 'source' => array( 'src' => $absolute_src, 'raw_src' => $src, 'normalized_src' => $this->normalized_asset_src( $absolute_src ), 'version' => $version, 'deps' => $deps, ), 'owner' => $this->asset_owner( $absolute_src, $handle ), 'loading' => $this->asset_loading( $extra ), 'localized' => $this->asset_localization( $extra ), ); } return $documents; } private function asset_duplicate_map( array $documents ): array { $map = array(); foreach ( $documents as $document ) { $src = (string) ( $document['source']['normalized_src'] ?? '' ); $handle = (string) ( $document['identity']['handle'] ?? '' ); if ( '' === $src || '' === $handle ) { continue; } $map[ $src ][] = $handle; } return array_filter( $map, static fn ( array $handles ): bool => count( array_unique( $handles ) ) > 1 ); } private function absolute_asset_src( string $src ): string { $src = trim( $src ); if ( '' === $src ) { return ''; } if ( str_starts_with( $src, '//' ) ) { return 'https:' . $src; } $parts = wp_parse_url( $src ); if ( is_array( $parts ) && isset( $parts['scheme'], $parts['host'] ) ) { return $src; } $base = function_exists( 'get_site_url' ) ? get_site_url() : ''; if ( '' === $base ) { $base = function_exists( 'home_url' ) ? home_url() : ''; } if ( '' === $base ) { return $src; } return rtrim( $base, '/' ) . '/' . ltrim( $src, '/' ); } private function normalized_asset_src( string $src ): string { $parts = wp_parse_url( $src ); if ( ! is_array( $parts ) ) { return $src; } $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) . '://' : ''; $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : ''; $port = isset( $parts['port'] ) ? ':' . (string) $parts['port'] : ''; $path = isset( $parts['path'] ) ? (string) $parts['path'] : ''; return $scheme . $host . $port . $path; } private function prime_asset_context( array $input ): void { $url = $this->asset_context_url( $input ); if ( '' === $url || ! function_exists( 'do_action' ) ) { return; } $context_post = $this->post_for_asset_context_url( $url ); if ( null === $context_post ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Intentionally primes the WordPress core enqueue hook for asset registry inspection. do_action( 'wp_enqueue_scripts' ); return; } global $wp_query; global $post; $previous_post = $post ?? null; $previous_wp_query = $wp_query ?? null; $post = $context_post; if ( is_object( $wp_query ) ) { $wp_query->queried_object = $context_post; $wp_query->queried_object_id = isset( $context_post->ID ) ? (int) $context_post->ID : 0; $wp_query->post = $context_post; $wp_query->posts = array( $context_post ); $wp_query->is_page = true; $wp_query->is_singular = true; $wp_query->is_home = false; $wp_query->is_archive = false; } if ( function_exists( 'setup_postdata' ) ) { setup_postdata( $context_post ); } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Intentionally primes the WordPress core enqueue hook for asset registry inspection. do_action( 'wp_enqueue_scripts' ); if ( function_exists( 'wp_reset_postdata' ) ) { wp_reset_postdata(); } $post = $previous_post; $wp_query = $previous_wp_query; } private function asset_context_url( array $input ): string { $where = isset( $input['where'] ) && is_array( $input['where'] ) ? $input['where'] : array(); foreach ( array( 'url', 'permalink', 'path' ) as $field ) { if ( isset( $where[ $field ] ) && is_scalar( $where[ $field ] ) ) { return trim( (string) $where[ $field ] ); } } return ''; } private function post_for_asset_context_url( string $url ): ?object { $post_id = 0; if ( function_exists( 'url_to_postid' ) ) { $post_id = (int) url_to_postid( $url ); } if ( $post_id > 0 && function_exists( 'get_post' ) ) { $post = get_post( $post_id ); return is_object( $post ) ? $post : null; } if ( ! function_exists( 'get_page_by_path' ) ) { return null; } $path = wp_parse_url( $url, PHP_URL_PATH ); if ( ! is_string( $path ) || '' === trim( $path, '/' ) ) { $path = $url; } $page = get_page_by_path( trim( $path, '/' ) ); return is_object( $page ) ? $page : null; } private function asset_owner( string $src, string $handle = '' ): array { $path = $this->asset_url_path( $src ); foreach ( array( '/wp-content/plugins/' => 'plugin', '/wp-content/mu-plugins/' => 'mu-plugin', '/wp-content/themes/' => 'theme', ) as $prefix => $type ) { $position = strpos( $path, $prefix ); if ( false === $position ) { continue; } $tail = substr( $path, $position + strlen( $prefix ) ); $slug = strtok( $tail, '/' ); $slug = false === $slug ? '' : (string) $slug; return array( 'type' => $type, 'slug' => $slug, 'path' => $path, ); } if ( '' === $path && '' !== $handle ) { $owner = $this->asset_owner_from_handle_source( $handle ); if ( null !== $owner ) { return $owner; } } if ( str_contains( $path, '/wp-includes/' ) || str_contains( $path, '/wp-admin/' ) ) { return array( 'type' => 'core', 'slug' => 'wordpress', 'path' => $path, ); } return array( 'type' => '' === $path ? 'unknown' : 'site', 'slug' => '', 'path' => $path, ); } private function asset_owner_from_handle_source( string $handle ): ?array { foreach ( CodeOwnerEvidence::roots() as $root ) { $owner = $this->asset_owner_from_handle_root( $handle, $root['path'], $root['type'] ); if ( null !== $owner ) { return $owner; } } return null; } private function asset_owner_from_handle_root( string $handle, string $root, string $type ): ?array { if ( '' === $root || ! is_dir( $root ) ) { return null; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ) ); $scanned = 0; foreach ( $iterator as $file ) { if ( ! $file instanceof \SplFileInfo || ! $file->isFile() ) { continue; } $relative = ltrim( str_replace( '\\', '/', substr( $file->getPathname(), strlen( rtrim( $root, '/\\' ) ) ) ), '/' ); if ( str_contains( $relative, '/vendor/' ) || str_contains( $relative, '/node_modules/' ) ) { continue; } $extension = strtolower( $file->getExtension() ); if ( ! in_array( $extension, array( 'php', 'js', 'mjs', 'cjs' ), true ) || $file->getSize() > 524288 ) { continue; } ++$scanned; if ( $scanned > 400 ) { break; } $contents = file_get_contents( $file->getPathname() ); if ( ! is_string( $contents ) || false === strpos( $contents, $handle ) ) { continue; } return array( 'type' => $type, 'slug' => CodeOwnerEvidence::slug_from_relative_path( $relative, $type ), 'name' => $this->asset_owner_name_from_source( $contents ), 'path' => $relative, ); } return null; } private function asset_owner_name_from_source( string $contents ): string { if ( preg_match( '/^[ \t\/*#@]*Plugin Name:\s*(.+)$/mi', $contents, $matches ) ) { return trim( (string) $matches[1] ); } return ''; } private function asset_url_path( string $src ): string { $parts = wp_parse_url( $src ); if ( is_array( $parts ) && isset( $parts['path'] ) ) { return (string) $parts['path']; } return $src; } private function asset_loading( array $extra ): array { $args = isset( $extra['args'] ) && is_array( $extra['args'] ) ? $extra['args'] : array(); $strategy = ''; foreach ( array( $extra['strategy'] ?? null, $args['strategy'] ?? null ) as $candidate ) { if ( is_scalar( $candidate ) && '' !== trim( (string) $candidate ) ) { $strategy = trim( (string) $candidate ); break; } } return array( 'strategy' => $strategy, 'in_footer' => ( isset( $extra['group'] ) && 1 === (int) $extra['group'] ) || true === ( $args['in_footer'] ?? false ), 'group' => isset( $extra['group'] ) && is_scalar( $extra['group'] ) ? (int) $extra['group'] : null, ); } private function asset_localization( array $extra ): array { $before = isset( $extra['before'] ) && is_array( $extra['before'] ) ? $extra['before'] : array(); $after = isset( $extra['after'] ) && is_array( $extra['after'] ) ? $extra['after'] : array(); $has_data = isset( $extra['data'] ) && is_scalar( $extra['data'] ) && '' !== trim( (string) $extra['data'] ); $translations = isset( $extra['translations'] ) && is_scalar( $extra['translations'] ) ? (string) $extra['translations'] : ''; return array( 'has_data' => $has_data, 'has_before' => array() !== $before, 'has_after' => array() !== $after, 'has_translations' => '' !== $translations, 'translations' => $translations, ); } private function singleton_document(): array { if ( 'runtime' === $this->resource ) { return array( 'wordpress' => array( 'version' => function_exists( 'get_bloginfo' ) ? get_bloginfo( 'version' ) : '' ), 'php' => array( 'version' => PHP_VERSION ), 'debug' => array( 'wp_debug' => defined( 'WP_DEBUG' ) && WP_DEBUG ), 'cache' => array(), 'multisite' => array( 'enabled' => function_exists( 'is_multisite' ) && is_multisite() ), ); } return match ( $this->resource ) { 'server' => $this->server_document(), 'site_health' => $this->site_health_document(), 'core' => $this->core_document(), default => array( 'summary' => array( 'status' => 'available' ) ), }; } private function server_document(): array { return array( 'php' => array( 'version' => PHP_VERSION, 'sapi' => PHP_SAPI, 'memory_limit' => ini_get( 'memory_limit' ), 'max_execution_time' => ini_get( 'max_execution_time' ), 'upload_max_filesize' => ini_get( 'upload_max_filesize' ), 'post_max_size' => ini_get( 'post_max_size' ), 'extensions' => array( 'curl' => extension_loaded( 'curl' ), 'gd' => extension_loaded( 'gd' ), 'imagick' => extension_loaded( 'imagick' ), 'intl' => extension_loaded( 'intl' ), 'mbstring' => extension_loaded( 'mbstring' ), 'zip' => extension_loaded( 'zip' ), ), ), 'wordpress' => array( 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'debug_log' => defined( 'WP_DEBUG_LOG' ) && (bool) WP_DEBUG_LOG, 'environment' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : '', 'multisite' => function_exists( 'is_multisite' ) && is_multisite(), ), ); } private function site_health_document(): array { $tests = array(); if ( class_exists( '\WP_Site_Health' ) && method_exists( '\WP_Site_Health', 'get_instance' ) ) { $health = \WP_Site_Health::get_instance(); if ( is_object( $health ) && method_exists( $health, 'get_tests' ) ) { $registered = $health->get_tests(); foreach ( array( 'direct', 'async' ) as $group ) { foreach ( $registered[ $group ] ?? array() as $test ) { if ( count( $tests ) >= 24 || ! is_array( $test ) || empty( $test['test'] ) ) { continue; } $tests[] = $this->site_health_test_summary( $health, $test, $group ); } } } } $status = $this->site_health_status_summary( function_exists( 'get_transient' ) ? get_transient( 'health-check-site-status-result' ) : null, array() === $tests ? 'unknown' : 'available' ); $registered_count = count( $tests ); $status_result_count = $this->site_health_status_result_count( $status ); return array( 'summary' => array( 'status' => $status, 'registered_test_count' => $registered_count, 'status_result_count' => $status_result_count, 'test_count' => $registered_count, ), 'tests' => $tests, ); } private function site_health_status_result_count( array $status ): ?int { $total = 0; foreach ( $status as $value ) { if ( ! is_int( $value ) ) { return null; } $total += $value; } return $total; } private function site_health_status_summary( $raw_status, string $fallback ): array { if ( is_string( $raw_status ) && '' !== $raw_status ) { $decoded = json_decode( $raw_status, true ); if ( is_array( $decoded ) ) { return $decoded; } return array( 'state' => $raw_status ); } if ( is_array( $raw_status ) ) { return $raw_status; } return array( 'state' => $fallback ); } private function site_health_test_summary( object $health, array $test, string $group ): array { $test_name = is_scalar( $test['test'] ?? null ) ? (string) $test['test'] : ''; if ( 'direct' === $group && is_callable( array( $health, 'perform_test' ) ) ) { $result = $health->perform_test( $test_name ); if ( is_array( $result ) ) { return array( 'test' => $test_name, 'type' => $group, 'label' => isset( $result['label'] ) ? $this->strip_tags( (string) $result['label'] ) : $test_name, 'status' => isset( $result['status'] ) ? (string) $result['status'] : '', 'badge' => isset( $result['badge']['label'] ) ? (string) $result['badge']['label'] : '', ); } } return array( 'test' => $test_name, 'type' => $group, 'label' => isset( $test['label'] ) && is_scalar( $test['label'] ) ? $this->strip_tags( (string) $test['label'] ) : $test_name, 'status' => 'registered', 'badge' => isset( $test['badge']['label'] ) && is_scalar( $test['badge']['label'] ) ? (string) $test['badge']['label'] : '', ); } private function core_document(): array { if ( ! function_exists( 'get_core_updates' ) && defined( 'ABSPATH' ) ) { $update_file = ABSPATH . 'wp-admin/includes/update.php'; if ( file_exists( $update_file ) ) { require_once $update_file; } } $updates = function_exists( 'get_core_updates' ) ? get_core_updates() : array(); return array( 'core' => array( 'version' => function_exists( 'get_bloginfo' ) ? get_bloginfo( 'version' ) : '', 'locale' => function_exists( 'get_locale' ) ? get_locale() : '', 'updates_count' => is_array( $updates ) ? count( $updates ) : 0, 'update_offers' => $this->core_update_summaries( is_array( $updates ) ? $updates : array() ), ), ); } private function core_update_summaries( array $updates ): array { $out = array(); foreach ( array_slice( $updates, 0, 5 ) as $update ) { $out[] = array( 'version' => is_object( $update ) && isset( $update->version ) ? (string) $update->version : '', 'response' => is_object( $update ) && isset( $update->response ) ? (string) $update->response : '', 'locale' => is_object( $update ) && isset( $update->locale ) ? (string) $update->locale : '', ); } return $out; } private function item_result( array $document, ?array $select ): array { return array( 'target' => $this->target_for_document( $document ), 'document' => $this->project_document( $document, $select ), ); } private function project_document( array $document, ?array $select ): array { if ( null === $select || array() === $select ) { return $document; } $projected = array(); foreach ( $select as $path ) { $segments = array_values( $path ); if ( ! $this->path_exists( $document, $segments ) ) { continue; } $projected = $this->path_set( $projected, $segments, $this->path_get( $document, $segments ) ); } return $projected; } private function normalize_select( $select ): ?array { if ( null === $select || ! is_array( $select ) ) { return null; } $paths = array(); foreach ( $select as $entry ) { if ( is_array( $entry ) ) { $paths[] = array_values( $entry ); } elseif ( is_scalar( $entry ) ) { $paths[] = array( (string) $entry ); } } return $paths; } /** * Reject where shapes registry filtering does not evaluate. * * A node is a group (`all`/`any`), a predicate (array-segment `path` plus a * supported op), or — at the top level only — the flat field-filter map. * Anything else fails instead of silently matching everything or nothing. * * @param array $where Where group, predicate, or top-level flat map. * @param array $path Error path prefix. * @param bool $is_top_level Whether this node is the where root. */ private function validate_where_ops( array $where, array $path, bool $is_top_level = true ): array { if ( isset( $where['all'] ) || isset( $where['any'] ) ) { foreach ( array( 'all', 'any' ) as $group ) { if ( ! isset( $where[ $group ] ) ) { continue; } if ( ! is_array( $where[ $group ] ) ) { return $this->error( 'invalid_command', 'where groups must be lists of predicates.', array_merge( $path, array( $group ) ) ); } foreach ( array_values( $where[ $group ] ) as $index => $predicate ) { if ( ! is_array( $predicate ) ) { return $this->error( 'invalid_command', 'where predicates must be objects.', array_merge( $path, array( $group, $index ) ) ); } $nested = $this->validate_where_ops( $predicate, array_merge( $path, array( $group, $index ) ), false ); if ( isset( $nested['error'] ) ) { return $nested; } } } return array( 'data' => array( 'valid' => true ) ); } if ( isset( $where['path'] ) || ! $is_top_level ) { if ( ! isset( $where['path'] ) || ! is_array( $where['path'] ) || array() === $where['path'] ) { return $this->error( 'invalid_command', 'where predicates require an array-segment path.', $path ); } if ( ! $this->path_root_known( array_values( $where['path'] ) ) ) { return $this->error( 'invalid_path', 'where path is not part of this resource document.', array_merge( $path, array( 'path' ) ) ); } $op = isset( $where['op'] ) && is_scalar( $where['op'] ) ? (string) $where['op'] : 'eq'; if ( ! in_array( $op, self::QUERY_OPS, true ) ) { return $this->error( 'invalid_value', 'where op is not supported by registry queries.', array_merge( $path, array( 'op' ) ), array( 'supported' => self::QUERY_OPS ) ); } return array( 'data' => array( 'valid' => true ) ); } foreach ( array_keys( $where ) as $field ) { if ( ! is_scalar( $field ) || ! $this->flat_where_field_allowed( (string) $field ) ) { return $this->error( 'invalid_path', 'where field is not a recognized flat filter.', array_merge( $path, array( (string) $field ) ) ); } } return array( 'data' => array( 'valid' => true ) ); } /** * Return whether a path's root segment exists in this resource's documents. * * @param array $path Array-segment path. */ private function path_root_known( array $path ): bool { $roots = self::DOCUMENT_ROOTS[ $this->resource ] ?? array(); if ( array() === $roots || array() === $path ) { return true; } return in_array( (string) $path[0], $roots, true ); } /** * Reject order_by shapes registry ordering does not evaluate. * * @param mixed $order_by Raw order_by input. */ private function validate_order_by( $order_by ): array { if ( ! is_array( $order_by ) ) { return $this->error( 'invalid_command', 'order_by must be a list of {path, dir} entries.', array( 'input', 'order_by' ) ); } foreach ( array_values( $order_by ) as $index => $order ) { if ( ! is_array( $order ) || ! isset( $order['path'] ) || ! is_array( $order['path'] ) || array() === $order['path'] ) { return $this->error( 'invalid_command', 'order_by entries require an array-segment path.', array( 'input', 'order_by', $index ) ); } if ( ! $this->path_root_known( array_values( $order['path'] ) ) ) { return $this->error( 'invalid_path', 'order_by path is not part of this resource document.', array( 'input', 'order_by', $index, 'path' ) ); } $dir = isset( $order['dir'] ) && is_scalar( $order['dir'] ) ? strtolower( (string) $order['dir'] ) : 'asc'; if ( ! in_array( $dir, array( 'asc', 'desc' ), true ) ) { return $this->error( 'invalid_value', 'order_by dir must be asc or desc.', array( 'input', 'order_by', $index, 'dir' ) ); } } return array( 'data' => array( 'valid' => true ) ); } /** * Sort documents by the validated order_by list. * * @param array $documents Filtered documents. * @param mixed $order_by Validated order_by input. */ private function order_documents( array $documents, $order_by ): array { if ( ! is_array( $order_by ) || array() === $order_by ) { return $documents; } $orders = array(); foreach ( $order_by as $order ) { $orders[] = array( 'path' => array_values( $order['path'] ), 'desc' => 'desc' === strtolower( (string) ( $order['dir'] ?? 'asc' ) ), ); } usort( $documents, function ( array $left, array $right ) use ( $orders ): int { foreach ( $orders as $order ) { $comparison = $this->compare_order_values( $this->path_get( $left, $order['path'] ), $this->path_get( $right, $order['path'] ) ); if ( 0 !== $comparison ) { return $order['desc'] ? -$comparison : $comparison; } } return 0; } ); return array_values( $documents ); } private function compare_order_values( $left, $right ): int { if ( is_numeric( $left ) && is_numeric( $right ) ) { return (float) $left <=> (float) $right; } $left_text = is_scalar( $left ) ? (string) $left : ''; $right_text = is_scalar( $right ) ? (string) $right : ''; return strcasecmp( $left_text, $right_text ) <=> 0; } /** * Return whether a flat where key is one this resource actually consumes. */ private function flat_where_field_allowed( string $field ): bool { if ( 'files' === $this->resource ) { return in_array( $field, array( 'scope', 'path', 'relative_path', 'theme', 'plugin' ), true ); } if ( 'assets' === $this->resource && in_array( $field, array( 'url', 'permalink', 'path' ), true ) ) { return true; } return array() !== $this->field_filter_path( $field ); } private function filter_documents( array $documents, $where ): array { if ( ! is_array( $where ) || array() === $where ) { return $documents; } return array_values( array_filter( $documents, fn ( array $document ): bool => $this->matches_where( $document, $where ) ) ); } private function matches_where( array $document, array $where ): bool { if ( isset( $where['all'] ) && is_array( $where['all'] ) ) { foreach ( $where['all'] as $predicate ) { if ( ! is_array( $predicate ) || ! $this->matches_where( $document, $predicate ) ) { return false; } } return true; } if ( isset( $where['any'] ) && is_array( $where['any'] ) ) { foreach ( $where['any'] as $predicate ) { if ( is_array( $predicate ) && $this->matches_where( $document, $predicate ) ) { return true; } } return false; } if ( isset( $where['path'] ) && is_array( $where['path'] ) ) { $path = array_values( $where['path'] ); $op = isset( $where['op'] ) && is_scalar( $where['op'] ) ? (string) $where['op'] : 'eq'; return $this->matches_predicate( $document, $path, $op, $where['value'] ?? null ); } foreach ( $where as $key => $value ) { if ( ! is_scalar( $key ) ) { continue; } $path = $this->field_filter_path( (string) $key ); if ( array() === $path ) { continue; } if ( ! $this->matches_predicate( $document, $path, 'eq', $value ) ) { return false; } } return true; } private function field_filter_path( string $key ): array { return match ( $key ) { 'handle', 'name' => array( 'identity', 'handle' ), 'type' => array( 'identity', 'type' ), 'src' => array( 'source', 'src' ), 'normalized_src' => array( 'source', 'normalized_src' ), 'dependency', 'dep' => array( 'source', 'deps' ), 'owner', 'owner_slug' => array( 'owner', 'slug' ), 'owner_type' => array( 'owner', 'type' ), default => array(), }; } private function matches_predicate( array $document, array $path, string $op, $expected ): bool { $exists = $this->path_exists( $document, $path ); if ( 'exists' === $op ) { return (bool) $expected === $exists; } if ( ! $exists ) { return false; } $actual = $this->path_get( $document, $path ); return match ( $op ) { 'eq' => $this->value_equals( $actual, $expected ), 'neq' => ! $this->value_equals( $actual, $expected ), 'in' => is_array( $expected ) && $this->value_in( $actual, $expected ), 'not_in' => is_array( $expected ) && ! $this->value_in( $actual, $expected ), 'contains' => $this->value_contains( $actual, $expected ), default => false, }; } private function value_equals( $actual, $expected ): bool { if ( is_array( $actual ) ) { foreach ( $actual as $item ) { if ( $this->value_equals( $item, $expected ) ) { return true; } } return false; } if ( is_bool( $actual ) || is_bool( $expected ) ) { return (bool) $actual === (bool) $expected; } return is_scalar( $actual ) && is_scalar( $expected ) && (string) $actual === (string) $expected; } private function value_in( $actual, array $expected ): bool { foreach ( $expected as $value ) { if ( $this->value_equals( $actual, $value ) ) { return true; } } return false; } private function value_contains( $actual, $expected ): bool { if ( is_array( $actual ) ) { return $this->value_equals( $actual, $expected ); } return is_scalar( $actual ) && is_scalar( $expected ) && false !== strpos( strtolower( (string) $actual ), strtolower( (string) $expected ) ); } private function target_for_document( array $document ): array { return match ( $this->resource ) { 'plugins' => array( 'plugin' => (string) ( $document['identity']['plugin'] ?? '' ) ), 'themes' => array( 'theme' => (string) ( $document['identity']['theme'] ?? '' ) ), 'files' => array( 'scope' => (string) ( $document['identity']['scope'] ?? '' ), 'path' => (string) ( $document['identity']['path'] ?? '' ), ), 'transients' => array( 'key' => (string) ( $document['identity']['key'] ?? $document['identity']['name'] ?? '' ) ), 'logs' => array( 'name' => (string) ( $document['identity']['name'] ?? 'wp_debug_log' ) ), 'assets' => array( 'handle' => (string) ( $document['identity']['handle'] ?? $document['identity']['name'] ?? '' ) ), default => array( 'name' => (string) ( $document['identity']['name'] ?? '' ) ), }; } private function document_key( array $document ): string { $target = $this->target_for_document( $document ); return (string) ( $target['plugin'] ?? $target['theme'] ?? $target['handle'] ?? $target['path'] ?? $target['key'] ?? $target['name'] ?? '' ); } private function target_key( array $input ): string { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); foreach ( array( 'plugin', 'theme', 'handle', 'key', 'path', 'name', 'id', 'slug' ) as $field ) { if ( isset( $target[ $field ] ) && is_scalar( $target[ $field ] ) ) { return (string) $target[ $field ]; } } return ''; } private function validate_file_scope( array $input ): array { $scope = ''; $where = isset( $input['where'] ) && is_array( $input['where'] ) ? $input['where'] : array(); if ( isset( $where['scope'] ) && is_scalar( $where['scope'] ) ) { $scope = (string) $where['scope']; } $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); if ( isset( $target['scope'] ) && is_scalar( $target['scope'] ) ) { $scope = (string) $target['scope']; } if ( '' !== $scope && ! in_array( $scope, self::FILE_SCOPES, true ) ) { return $this->error( 'invalid_target', 'File reads are limited to the configured sandbox scopes.', array( 'input', 'target', 'scope' ), array( 'allowed_scopes' => self::FILE_SCOPES ) ); } return array( 'data' => array( 'valid' => true ) ); } private function logs_enabled(): bool { return ( new SettingsRepository() )->get_allow_log_reads(); } private function callback_label( $callback ): string { if ( is_string( $callback ) ) { return $callback; } if ( is_array( $callback ) && isset( $callback[0], $callback[1] ) ) { $owner = is_object( $callback[0] ) ? get_class( $callback[0] ) : (string) $callback[0]; return $owner . '::' . (string) $callback[1]; } if ( $callback instanceof \Closure ) { return 'closure'; } if ( is_object( $callback ) && method_exists( $callback, '__invoke' ) ) { return get_class( $callback ) . '::__invoke'; } return 'unknown'; } private function callback_owner( $callback ): array { $file = $this->callback_file( $callback ); if ( '' === $file ) { return array(); } return CodeOwnerEvidence::from_absolute_file( $file ); } private function callback_file( $callback ): string { try { if ( $callback instanceof \Closure ) { return (string) ( new \ReflectionFunction( $callback ) )->getFileName(); } if ( is_string( $callback ) && function_exists( $callback ) ) { return (string) ( new \ReflectionFunction( $callback ) )->getFileName(); } if ( is_array( $callback ) && isset( $callback[0], $callback[1] ) ) { return (string) ( new \ReflectionMethod( $callback[0], (string) $callback[1] ) )->getFileName(); } if ( is_object( $callback ) && method_exists( $callback, '__invoke' ) ) { return (string) ( new \ReflectionMethod( $callback, '__invoke' ) )->getFileName(); } } catch ( \ReflectionException ) { return ''; } return ''; } private function strip_tags( string $value ): string { return wp_strip_all_tags( $value ); } private function is_select_list( $paths ): bool { if ( ! is_array( $paths ) ) { return false; } foreach ( $paths as $path ) { if ( is_scalar( $path ) ) { continue; } if ( ! is_array( $path ) ) { return false; } foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) ) { return false; } } } return true; } private function path_exists( array $document, array $path ): bool { $node = $document; foreach ( $path as $segment ) { $key = is_int( $segment ) ? $segment : (string) $segment; if ( ! is_array( $node ) || ! array_key_exists( $key, $node ) ) { return false; } $node = $node[ $key ]; } return true; } private function path_get( array $document, array $path ) { $node = $document; foreach ( $path as $segment ) { $node = $node[ is_int( $segment ) ? $segment : (string) $segment ]; } return $node; } private function path_set( array $document, array $path, $value ): array { $segment = array_shift( $path ); $key = is_int( $segment ) ? $segment : (string) $segment; if ( array() === $path ) { $document[ $key ] = $value; return $document; } $child = isset( $document[ $key ] ) && is_array( $document[ $key ] ) ? $document[ $key ] : array(); $document[ $key ] = $this->path_set( $child, $path, $value ); return $document; } private function error( string $code, string $message, ?array $path = null, array $details = array() ): array { $error = array( 'code' => $code, 'message' => $message, ); if ( null !== $path ) { $error['path'] = $path; } if ( array() !== $details ) { $error['details'] = $details; } return array( 'error' => $error ); } }