From cafa25544842945df8bdd39973a9ce9651828e2a Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 00:17:40 +0100 Subject: [PATCH 1/2] Abilities API: Add a core/content ability Adds a read-only `core/content` ability that retrieves one or more posts of a post type exposed to abilities via a new `show_in_abilities` post type argument (enabled for `post` and `page` by default). Fetch a single post by ID or by slug, or query multiple posts filtered by post type, status, author, or parent, selecting a support-aware set of fields per post. Permissions follow the REST posts model: a coarse status/capability gate plus an authoritative per-post read_post check, with password-protected content withheld from users who cannot edit the post and a uniform not-found response to avoid leaking the existence of posts. --- src/wp-includes/abilities.php | 13 + .../abilities/class-wp-content-abilities.php | 703 ++++++++++++++++++ src/wp-includes/class-wp-post-type.php | 13 + src/wp-includes/post.php | 7 + .../wpRegisterCoreContentAbility.php | 582 +++++++++++++++ .../wpRestAbilitiesContentController.php | 192 +++++ 6 files changed, 1510 insertions(+) create mode 100644 src/wp-includes/abilities/class-wp-content-abilities.php create mode 100644 tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php create mode 100644 tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 0eb87a4581589..5cd030d905940 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,8 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-content-abilities.php'; + /** * Registers the core ability categories. * @@ -30,6 +32,14 @@ function wp_register_core_ability_categories(): void { 'description' => __( 'Abilities that retrieve or modify user information and settings.' ), ) ); + + wp_register_ability_category( + 'content', + array( + 'label' => __( 'Content' ), + 'description' => __( 'Abilities that retrieve or manage posts and other content.' ), + ) + ); } /** @@ -351,4 +361,7 @@ function wp_register_core_abilities(): void { ), ) ); + + // Register the content abilities (currently the read-only `core/content`). + WP_Content_Abilities::register(); } diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php new file mode 100644 index 0000000000000..d6a94cbd4f7f0 --- /dev/null +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -0,0 +1,703 @@ + __( 'Get Content' ), + 'description' => __( 'Retrieves one or more posts of a post type exposed to abilities. Fetch a single post by ID or by slug, or query multiple posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post.' ), + 'category' => self::CATEGORY, + 'input_schema' => self::get_content_input_schema( $post_types, $statuses ), + 'output_schema' => self::get_content_output_schema(), + 'execute_callback' => array( self::class, 'execute_get_content' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + // Opt into REST-level pagination: query mode accepts `page`/`per_page` + // and returns `total`/`total_pages`, which the run controller turns into + // the standard X-WP-Total / X-WP-TotalPages response headers. + 'pagination' => true, + ), + ) + ); + } + + /** + * Permission callback for the `core/content` ability. + * + * Implements defense in depth: this gate decides whether the request may proceed at + * all (coarse, by post type capabilities and requested statuses), while the per-post + * `read_post` meta capability check in {@see self::execute_get_content()} is the + * authoritative, row-level enforcement of author-scoped visibility. + * + * @since 7.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return bool True if the request may proceed, false otherwise. + */ + public static function check_permission( $input = array() ): bool { + $input = is_array( $input ) ? $input : array(); + $exposed = self::get_exposed_post_types(); + + // Single-post mode (by ID). + if ( ! empty( $input['id'] ) ) { + $post = get_post( (int) $input['id'] ); + + /* + * For a missing post, an unexposed post type, or a post type that does not + * match the requested one, fall back to a generic capability check rather + * than a row-level check on a guessed ID, so the response cannot be used to + * enumerate IDs or probe post-type membership. Execution returns a uniform + * 404 in these cases. + */ + if ( ! $post + || ! isset( $exposed[ $post->post_type ] ) + || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) + ) { + return current_user_can( 'read' ); + } + + return current_user_can( 'read_post', $post->ID ); + } + + // Query / slug mode requires an exposed post type. + $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + return false; + } + + $post_type_object = $exposed[ $post_type ]; + + // Base gate: must be able to read this post type at all. + if ( ! current_user_can( $post_type_object->cap->read ?? 'read' ) ) { + return false; + } + + $statuses = self::normalize_statuses( $input ); + + // Only published posts requested: always allowed for readers. + if ( array( 'publish' ) === $statuses ) { + return true; + } + + // Editors/authors of this post type may request any status set. + if ( current_user_can( $post_type_object->cap->edit_posts ?? 'edit_posts' ) ) { + return true; + } + + // Otherwise, private posts are allowed only with read_private_posts. + if ( current_user_can( $post_type_object->cap->read_private_posts ?? 'read_private_posts' ) ) { + foreach ( $statuses as $status ) { + if ( 'private' !== $status && 'publish' !== $status ) { + return false; + } + } + return true; + } + + return false; + } + + /** + * Executes the `core/content` ability. + * + * @since 7.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array|WP_Error A map with a `posts` list, or a WP_Error on failure. + */ + public static function execute_get_content( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $exposed = self::get_exposed_post_types(); + $fields = self::normalize_fields( $input ); + + // Single-post mode (by ID). + if ( ! empty( $input['id'] ) ) { + $post = get_post( (int) $input['id'] ); + + if ( ! $post + || ! isset( $exposed[ $post->post_type ] ) + || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) + || ! current_user_can( 'read_post', $post->ID ) + ) { + return self::not_found_error(); + } + + return array( + 'posts' => array( self::format_post( $post, $fields ) ), + 'total' => 1, + 'total_pages' => 1, + ); + } + + // Query / slug mode. + $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + return self::not_found_error(); + } + + $per_page = self::normalize_per_page( $input ); + $page = isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1; + + $query_args = array( + 'post_type' => $post_type, + 'post_status' => self::normalize_statuses( $input ), + 'posts_per_page' => $per_page, + 'paged' => $page, + 'ignore_sticky_posts' => true, + ); + + if ( ! empty( $input['slug'] ) ) { + $query_args['name'] = sanitize_title( (string) $input['slug'] ); + } + + if ( ! empty( $input['author'] ) ) { + $query_args['author'] = (int) $input['author']; + } + + if ( isset( $input['parent'] ) ) { + $query_args['post_parent'] = (int) $input['parent']; + } + + $query = new WP_Query( $query_args ); + + $posts = array(); + foreach ( $query->posts as $post ) { + // Authoritative, row-level visibility check (author/status scoped). + if ( ! current_user_can( 'read_post', $post->ID ) ) { + continue; + } + $posts[] = self::format_post( $post, $fields ); + } + + return array( + 'posts' => $posts, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + ); + } + + /** + * Normalizes the requested per-page value to the supported bounds. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return int The clamped per-page value. + */ + protected static function normalize_per_page( array $input ): int { + $per_page = isset( $input['per_page'] ) ? (int) $input['per_page'] : self::DEFAULT_PER_PAGE; + + return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); + } + + /** + * Returns the post types exposed through the Abilities API, keyed by name. + * + * Only post types whose `show_in_abilities` argument is truthy are exposed. + * + * @since 7.1.0 + * + * @return array Exposed post type objects keyed by name. + */ + protected static function get_exposed_post_types(): array { + $exposed = array(); + + foreach ( get_post_types( array(), 'objects' ) as $post_type_object ) { + if ( empty( $post_type_object->show_in_abilities ) ) { + continue; + } + $exposed[ $post_type_object->name ] = $post_type_object; + } + + return $exposed; + } + + /** + * Returns the post statuses that may be requested through the ability. + * + * Internal statuses (auto-draft, inherit, trash) are excluded. + * + * @since 7.1.0 + * + * @return string[] List of public, non-internal post status slugs. + */ + protected static function get_available_statuses(): array { + return array_values( get_post_stati( array( 'internal' => false ) ) ); + } + + /** + * Normalizes the requested statuses to a non-empty, sanitized list defaulting to publish. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return string[] Normalized list of post status slugs. + */ + protected static function normalize_statuses( array $input ): array { + $statuses = $input['status'] ?? array( 'publish' ); + if ( ! is_array( $statuses ) || array() === $statuses ) { + return array( 'publish' ); + } + + return array_map( 'sanitize_key', $statuses ); + } + + /** + * Normalizes the requested fields to the supported set, defaulting to all fields. + * + * An empty or absent `fields` value selects every field. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return string[] List of requested field names. + */ + protected static function normalize_fields( array $input ): array { + if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { + return self::FIELDS; + } + + $fields = array_intersect( self::FIELDS, array_map( 'strval', $input['fields'] ) ); + + return array() === $fields ? self::FIELDS : array_values( $fields ); + } + + /** + * Builds the input schema for the `core/content` ability. + * + * @since 7.1.0 + * + * @param string[] $post_types Exposed post type names. + * @param string[] $statuses Requestable post status slugs. + * @return array The input JSON Schema. + */ + protected static function get_content_input_schema( array $post_types, array $statuses ): array { + return array( + 'type' => 'object', + 'default' => array(), + // `post_type` is required unless a single post is requested by `id`. + 'anyOf' => array( + array( 'required' => array( 'id' ) ), + array( 'required' => array( 'post_type' ) ), + ), + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Post type to retrieve. Required unless `id` is provided.' ), + ), + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Retrieve a single post by ID. When provided, `post_type` is optional.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'Retrieve posts by slug. Requires `post_type`, as slugs are not unique across post types.' ), + ), + 'status' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'default' => array( 'publish' ), + 'items' => array( + 'type' => 'string', + 'enum' => $statuses, + ), + 'description' => __( 'Filter by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.' ), + ), + 'author' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Filter by author user ID.' ), + ), + 'parent' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Filter by parent post ID, for hierarchical post types. Use 0 for top-level posts.' ), + ), + 'fields' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => self::FIELDS, + ), + 'description' => __( 'Limit each returned post to these fields. If omitted, all supported fields are returned.' ), + ), + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'default' => 1, + 'description' => __( 'Page of results to return in query mode. Ignored when retrieving a single post by ID.' ), + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_PER_PAGE, + 'default' => self::DEFAULT_PER_PAGE, + 'description' => __( 'Maximum number of posts to return per page in query mode.' ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the output schema for the `core/content` ability. + * + * No field is marked required because the `fields` input lets the caller request any + * subset, and a field is only present when its post type supports it. + * + * @since 7.1.0 + * + * @return array The output JSON Schema. + */ + protected static function get_content_output_schema(): array { + $post_schema = array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The post ID.' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'The post type.' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status.' ), + ), + 'date' => array( + 'type' => 'string', + 'description' => __( 'The publication date, in ISO 8601 format (GMT).' ), + ), + 'modified' => array( + 'type' => 'string', + 'description' => __( 'The last modified date, in ISO 8601 format (GMT).' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The post slug.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The permalink URL.' ), + ), + 'title' => array( + 'type' => 'string', + 'description' => __( 'The post title. Present when the post type supports titles.' ), + ), + 'excerpt' => array( + 'type' => 'string', + 'description' => __( 'The post excerpt. Present when the post type supports excerpts. Empty when withheld for a password-protected post.' ), + ), + 'raw_content' => array( + 'type' => 'string', + 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor. Empty when withheld for a password-protected post.' ), + ), + 'author' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The author user ID.' ), + ), + 'display_name' => array( + 'type' => 'string', + 'description' => __( 'The author display name.' ), + ), + ), + 'description' => __( 'The post author. Present when the post type supports authors.' ), + ), + 'parent' => array( + 'type' => 'integer', + 'description' => __( 'The parent post ID. Present for hierarchical post types.' ), + ), + ), + ); + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => __( 'The posts matching the request. A single-element list when requested by ID.' ), + 'items' => $post_schema, + ), + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Total number of posts matching the query, across all pages. Surfaced over REST as the X-WP-Total header.' ), + ), + 'total_pages' => array( + 'type' => 'integer', + 'description' => __( 'Total number of pages available. Surfaced over REST as the X-WP-TotalPages header.' ), + ), + ), + ); + } + + /** + * Formats a post into the ability output shape. + * + * Only the requested fields that the post type supports are included. Content and + * excerpt are withheld for password-protected posts unless the current user can edit + * the post, mirroring the REST API behavior. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @param string[] $fields The requested field names. + * @return array The formatted post data. + */ + protected static function format_post( WP_Post $post, array $fields ): array { + $type = $post->post_type; + $wants = static function ( string $field ) use ( $fields ): bool { + return in_array( $field, $fields, true ); + }; + $protected = post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ); + + $data = array(); + + if ( $wants( 'id' ) ) { + $data['id'] = (int) $post->ID; + } + if ( $wants( 'type' ) ) { + $data['type'] = $type; + } + if ( $wants( 'status' ) ) { + $data['status'] = $post->post_status; + } + if ( $wants( 'date' ) ) { + $data['date'] = self::format_gmt_date( $post, 'date' ); + } + if ( $wants( 'modified' ) ) { + $data['modified'] = self::format_gmt_date( $post, 'modified' ); + } + if ( $wants( 'slug' ) ) { + $data['slug'] = $post->post_name; + } + if ( $wants( 'link' ) ) { + $data['link'] = (string) get_permalink( $post ); + } + + if ( $wants( 'title' ) && post_type_supports( $type, 'title' ) ) { + $data['title'] = self::get_title( $post ); + } + + if ( $wants( 'excerpt' ) && post_type_supports( $type, 'excerpt' ) ) { + $data['excerpt'] = $protected ? '' : (string) get_the_excerpt( $post ); + } + + if ( $wants( 'raw_content' ) && post_type_supports( $type, 'editor' ) ) { + $data['raw_content'] = $protected ? '' : (string) $post->post_content; + } + + if ( $wants( 'author' ) && post_type_supports( $type, 'author' ) ) { + $author = get_userdata( (int) $post->post_author ); + $data['author'] = array( + 'id' => (int) $post->post_author, + 'display_name' => $author ? $author->display_name : '', + ); + } + + if ( $wants( 'parent' ) && is_post_type_hierarchical( $type ) ) { + $data['parent'] = (int) $post->post_parent; + } + + return $data; + } + + /** + * Returns the post title with the protected/private prefixes stripped. + * + * Mirrors the REST API, which removes the "Protected: " / "Private: " prefixes for + * machine consumers while still applying the_title filters. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @return string The post title. + */ + protected static function get_title( WP_Post $post ): string { + $strip = array( self::class, 'return_raw_title_format' ); + add_filter( 'protected_title_format', $strip ); + add_filter( 'private_title_format', $strip ); + $title = get_the_title( $post ); + remove_filter( 'protected_title_format', $strip ); + remove_filter( 'private_title_format', $strip ); + + return $title; + } + + /** + * Returns the raw title format, used to strip protected/private title prefixes. + * + * @since 7.1.0 + * + * @return string The unprefixed title format. + */ + public static function return_raw_title_format(): string { + return '%s'; + } + + /** + * Formats a post date field as an ISO 8601 string in GMT. + * + * Uses get_post_datetime() so that posts without a GMT timestamp (e.g. some drafts) + * still resolve to a valid date. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. + * @return string The ISO 8601 date, or an empty string if unavailable. + */ + protected static function format_gmt_date( WP_Post $post, string $field ): string { + $datetime = get_post_datetime( $post, $field, 'gmt' ); + if ( $datetime ) { + return $datetime->format( 'c' ); + } + + // Fallback for posts without a resolvable timestamp. + $local = 'modified' === $field ? $post->post_modified : $post->post_date; + $timestamp = mysql2date( 'U', $local, false ); + + return $timestamp ? gmdate( 'c', (int) $timestamp ) : ''; + } + + /** + * Builds the uniform not-found error. + * + * The same generic 404 is returned for a missing post, an unexposed post type, a + * type mismatch, or a post the user cannot read, so the ability cannot be used to + * enumerate IDs or probe post-type membership. + * + * @since 7.1.0 + * + * @return WP_Error The not-found error. + */ + protected static function not_found_error(): WP_Error { + return new WP_Error( + 'content_not_found', + __( 'The requested content was not found.' ), + array( 'status' => 404 ) + ); + } +} diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index b53a244d7de84..806c65d297a4c 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -371,6 +371,18 @@ final class WP_Post_Type { */ public $show_in_rest; + /** + * Whether this post type should be exposed through the Abilities API. + * + * Default false. When truthy, the post type's readable posts can be retrieved + * through the read-only `core/content` ability, subject to per-post capability + * checks. May be an array to enable specific operations in the future. + * + * @since 7.1.0 + * @var bool|array $show_in_abilities + */ + public $show_in_abilities; + /** * The base path for this post type's REST API endpoints. * @@ -551,6 +563,7 @@ public function set_props( $args ) { 'can_export' => true, 'delete_with_user' => null, 'show_in_rest' => false, + 'show_in_abilities' => false, 'rest_base' => false, 'rest_namespace' => false, 'rest_controller_class' => false, diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 005ccadd62e34..5f3a702a6474b 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -50,6 +50,7 @@ function create_initial_post_types() { 'post-formats', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'posts', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -84,6 +85,7 @@ function create_initial_post_types() { 'revisions', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'pages', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -1675,6 +1677,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @since 5.0.0 The `template` and `template_lock` arguments were added. * @since 5.3.0 The `supports` argument will now accept an array of arguments for a feature. * @since 5.9.0 The `rest_namespace` argument was added. + * @since 7.1.0 The `show_in_abilities` argument was added. * * @global array $wp_post_types List of post types. * @@ -1720,6 +1723,10 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * of $show_in_menu. * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. + * @type bool|array $show_in_abilities Whether to expose this post type through the Abilities API, so its + * readable posts can be retrieved via the read-only `core/content` + * ability (subject to per-post capability checks). Accepts a boolean + * or an array reserved for enabling specific operations. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. * @type string $rest_namespace To change the namespace URL of REST API route. Default is wp/v2. * @type string $rest_controller_class REST API controller class name. Default is 'WP_REST_Posts_Controller'. diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php new file mode 100644 index 0000000000000..ad539c0936df5 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -0,0 +1,582 @@ + true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor', 'excerpt', 'author' ), + ) + ); + + register_post_type( + self::HIDDEN_CPT, + array( + 'public' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + // Temporarily remove the unhook functions so we can register core abilities. + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * Cleans up registered abilities, categories, and post types. + * + * @since 7.1.0 + */ + public static function tear_down_after_class(): void { + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + unregister_post_type( self::EXPOSED_CPT ); + unregister_post_type( self::HIDDEN_CPT ); + + parent::tear_down_after_class(); + } + + /** + * Logs in as a user with the given role and returns the user ID. + * + * @param string $role The role to create the user with. + * @return int The new user ID. + */ + private function login_as( string $role ): int { + $user_id = self::factory()->user->create( array( 'role' => $role ) ); + wp_set_current_user( $user_id ); + return $user_id; + } + + /** + * Convenience accessor for the ability. + * + * @return WP_Ability The core/content ability. + */ + private function ability(): WP_Ability { + return wp_get_ability( 'core/content' ); + } + + /* + * ------------------------------------------------------------------------- + * Registration & schema + * ------------------------------------------------------------------------- + */ + + public function test_ability_is_registered_readonly_in_content_category(): void { + $ability = $this->ability(); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'content', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'] ); + $this->assertFalse( $annotations['destructive'] ); + $this->assertTrue( $annotations['idempotent'] ); + } + + public function test_input_schema_requires_id_or_post_type(): void { + $schema = $this->ability()->get_input_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertSame( + array( + array( 'required' => array( 'id' ) ), + array( 'required' => array( 'post_type' ) ), + ), + $schema['anyOf'] + ); + $this->assertFalse( $schema['additionalProperties'] ); + } + + public function test_input_schema_post_type_enum_only_includes_exposed_types(): void { + $enum = $this->ability()->get_input_schema()['properties']['post_type']['enum']; + + $this->assertContains( 'post', $enum ); + $this->assertContains( 'page', $enum ); + $this->assertContains( self::EXPOSED_CPT, $enum ); + $this->assertNotContains( self::HIDDEN_CPT, $enum ); + $this->assertNotContains( 'revision', $enum ); + } + + public function test_input_schema_status_and_fields_enums(): void { + $properties = $this->ability()->get_input_schema()['properties']; + + $status_enum = $properties['status']['items']['enum']; + $this->assertContains( 'publish', $status_enum ); + $this->assertContains( 'draft', $status_enum ); + $this->assertContains( 'private', $status_enum ); + $this->assertNotContains( 'trash', $status_enum ); + $this->assertNotContains( 'auto-draft', $status_enum ); + $this->assertSame( array( 'publish' ), $properties['status']['default'] ); + + $fields_enum = $properties['fields']['items']['enum']; + $this->assertContains( 'raw_content', $fields_enum ); + $this->assertContains( 'title', $fields_enum ); + $this->assertContains( 'author', $fields_enum ); + } + + public function test_output_schema_has_no_required_fields(): void { + $schema = $this->ability()->get_output_schema(); + $post_item = $schema['properties']['posts']['items']; + + $this->assertArrayNotHasKey( 'required', $post_item ); + $this->assertFalse( $post_item['additionalProperties'] ); + $this->assertArrayHasKey( 'raw_content', $post_item['properties'] ); + } + + /* + * ------------------------------------------------------------------------- + * Single-post retrieval + * ------------------------------------------------------------------------- + */ + + public function test_get_single_published_post_by_id(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Hello Content', + 'post_content' => 'Body here.', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $result['posts'] ); + $this->assertSame( $post_id, $result['posts'][0]['id'] ); + $this->assertSame( 'Hello Content', $result['posts'][0]['title'] ); + $this->assertSame( 'Body here.', $result['posts'][0]['raw_content'] ); + $this->assertSame( 'post', $result['posts'][0]['type'] ); + } + + public function test_get_by_id_with_mismatched_post_type_returns_not_found(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'post_type' => 'page', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'content_not_found', $result->get_error_code() ); + } + + public function test_get_by_missing_id_returns_generic_not_found(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'id' => 999999 ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'content_not_found', $result->get_error_code() ); + $this->assertSame( 404, $result->get_error_data()['status'] ); + } + + /* + * ------------------------------------------------------------------------- + * Query mode + * ------------------------------------------------------------------------- + */ + + public function test_query_returns_only_published_by_default(): void { + $this->login_as( 'administrator' ); + $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $published, $ids ); + $this->assertNotContains( $draft, $ids ); + } + + public function test_query_by_slug_requires_post_type(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'slug' => 'whatever' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + public function test_query_by_slug_within_post_type(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( + array( + 'post_name' => 'find-me', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'slug' => 'find-me', + ) + ); + + $this->assertCount( 1, $result['posts'] ); + $this->assertSame( $post_id, $result['posts'][0]['id'] ); + } + + public function test_query_filters_by_author(): void { + $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); + $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); + $post_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'publish', + ) + ); + self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'author' => $author_a, + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertSame( array( $post_a ), $ids ); + } + + public function test_query_filters_by_parent_for_hierarchical_types(): void { + $this->login_as( 'administrator' ); + $parent = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $child = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => $parent, + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'page', + 'parent' => $parent, + ) + ); + + $this->assertCount( 1, $result['posts'] ); + $this->assertSame( $child, $result['posts'][0]['id'] ); + $this->assertSame( $parent, $result['posts'][0]['parent'] ); + } + + /* + * ------------------------------------------------------------------------- + * fields filter + * ------------------------------------------------------------------------- + */ + + public function test_fields_filter_limits_returned_keys(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'title' ), + ) + ); + + $this->assertSame( array( 'id', 'title' ), array_keys( $result['posts'][0] ) ); + } + + public function test_unsupported_fields_are_omitted_for_post_type(): void { + $this->login_as( 'administrator' ); + // Pages do not support excerpt by default in this CPT, but `post` does; use the + // exposed CPT which does not support `comments`/`parent` to confirm omission. + $post_id = self::factory()->post->create( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + // `post` is not hierarchical, so `parent` must be absent even though requested implicitly. + $this->assertArrayNotHasKey( 'parent', $result['posts'][0] ); + } + + /* + * ------------------------------------------------------------------------- + * Permissions & visibility (security) + * ------------------------------------------------------------------------- + */ + + public function test_logged_out_user_is_denied(): void { + wp_set_current_user( 0 ); + + $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_subscriber_can_read_published_posts(): void { + $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $published, $ids ); + } + + public function test_subscriber_cannot_request_draft_status(): void { + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_subscriber_cannot_request_private_status(): void { + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'private' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_author_cannot_see_other_authors_drafts(): void { + $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); + $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + ) + ); + + // Author B can pass the status gate (has edit_posts) but only sees their own draft. + wp_set_current_user( $author_b ); + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $draft_b, $ids ); + $this->assertNotContains( $draft_a, $ids ); + } + + public function test_administrator_can_read_private_posts(): void { + $private = self::factory()->post->create( array( 'post_status' => 'private' ) ); + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'private' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $private, $ids ); + } + + public function test_unexposed_post_type_is_rejected_by_input_schema(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'post_type' => self::HIDDEN_CPT ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /* + * ------------------------------------------------------------------------- + * Password-protected posts + * ------------------------------------------------------------------------- + */ + + public function test_password_protected_content_withheld_from_non_editor(): void { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Top secret body.', + 'post_excerpt' => 'Secret excerpt.', + ) + ); + + $this->login_as( 'subscriber' ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'raw_content', 'excerpt' ), + ) + ); + + $this->assertSame( '', $result['posts'][0]['raw_content'] ); + $this->assertSame( '', $result['posts'][0]['excerpt'] ); + } + + public function test_password_protected_content_visible_to_editor(): void { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Top secret body.', + ) + ); + + $this->login_as( 'editor' ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'raw_content' ), + ) + ); + + $this->assertSame( 'Top secret body.', $result['posts'][0]['raw_content'] ); + } + + /* + * ------------------------------------------------------------------------- + * Pagination + * ------------------------------------------------------------------------- + */ + + public function test_query_paginates_and_reports_totals(): void { + $this->login_as( 'administrator' ); + self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $page1 = $this->ability()->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 1, + ) + ); + + $this->assertCount( 2, $page1['posts'] ); + $this->assertGreaterThanOrEqual( 3, $page1['total'] ); + $this->assertSame( (int) ceil( $page1['total'] / 2 ), $page1['total_pages'] ); + + $page2 = $this->ability()->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 2, + ) + ); + + $this->assertNotEmpty( $page2['posts'] ); + $this->assertSame( $page1['total'], $page2['total'] ); + } + + public function test_per_page_is_capped(): void { + $this->login_as( 'administrator' ); + + $schema = $this->ability()->get_input_schema(); + + $this->assertSame( WP_Content_Abilities::MAX_PER_PAGE, $schema['properties']['per_page']['maximum'] ); + $this->assertSame( WP_Content_Abilities::DEFAULT_PER_PAGE, $schema['properties']['per_page']['default'] ); + } + + public function test_single_post_reports_totals(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertSame( 1, $result['total'] ); + $this->assertSame( 1, $result['total_pages'] ); + } + + public function test_ability_opts_into_pagination(): void { + $this->assertTrue( (bool) $this->ability()->get_meta_item( 'pagination', false ) ); + } +} diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php new file mode 100644 index 0000000000000..837bea2b4639a --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -0,0 +1,192 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * Cleans up registered abilities and categories. + * + * @since 7.1.0 + */ + public static function tear_down_after_class(): void { + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down_after_class(); + } + + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + do_action( 'rest_api_init' ); + + wp_set_current_user( self::$admin_id ); + } + + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Builds a GET run request with the given ability input. + * + * @param array $input The ability input. + * @return WP_REST_Request The request. + */ + private function run_request( array $input ): WP_REST_Request { + $request = new WP_REST_Request( 'GET', self::RUN_ROUTE ); + $request->set_query_params( array( 'input' => $input ) ); + return $request; + } + + public function test_logged_out_user_receives_401(): void { + wp_set_current_user( 0 ); + + $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + + $this->assertSame( 401, $response->get_status() ); + } + + public function test_subscriber_requesting_drafts_receives_403(): void { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ) + ); + + $this->assertSame( 403, $response->get_status() ); + } + + public function test_admin_query_returns_published_posts(): void { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Published via REST', + 'post_status' => 'publish', + ) + ); + + $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'posts', $data ); + $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ) ); + } + + public function test_get_single_post_by_id(): void { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( $this->run_request( array( 'id' => $post_id ) ) ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $data['posts'] ); + $this->assertSame( $post_id, $data['posts'][0]['id'] ); + } + + public function test_wrong_http_method_returns_405(): void { + $request = new WP_REST_Request( 'POST', self::RUN_ROUTE ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array( 'post_type' => 'post' ) ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + $this->assertSame( 'rest_ability_invalid_method', $response->get_data()['code'] ); + } + + public function test_pagination_returns_totals_in_body(): void { + self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 1, + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 2, $data['posts'] ); + $this->assertGreaterThanOrEqual( 3, $data['total'] ); + $this->assertSame( (int) ceil( $data['total'] / 2 ), $data['total_pages'] ); + } +} From 87c0275724548c2ead2fd818afe75611aec21a87 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 15:49:11 +0100 Subject: [PATCH 2/2] Abilities API: apply core/settings review feedback to core/content. Mirrors the refinements from the core/settings review that also apply to core/content: - Memoize the exposed post types so the input schema and the permission/execute callbacks derive from a single walk of the registered post types. - Default the input schema to an empty object so the type:object default serializes as {}. - Harden input/value handling (type guards, a capability resolver, and a non-negative integer helper) against loosely-typed request data. --- .../abilities/class-wp-content-abilities.php | 96 ++++++++++++++----- 1 file changed, 73 insertions(+), 23 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index d6a94cbd4f7f0..091d445743a3b 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -81,6 +81,17 @@ class WP_Content_Abilities { */ const MAX_PER_PAGE = 100; + /** + * Post types exposed through the Abilities API, computed once at registration. + * + * Cached so the input schema and the permission/execute callbacks derive from the exact + * same set, and the post type list is only walked once per request. + * + * @since 7.1.0 + * @var array|null + */ + private static ?array $exposed_post_types = null; + /** * Registers all content abilities. * @@ -105,7 +116,10 @@ public static function register(): void { * @since 7.1.0 */ public static function register_get_content(): void { - $post_types = array_keys( self::get_exposed_post_types() ); + // Compute once; check_permission()/execute_get_content() reuse this set. + self::$exposed_post_types = self::get_exposed_post_types(); + + $post_types = array_keys( self::$exposed_post_types ); $statuses = self::get_available_statuses(); wp_register_ability( @@ -149,11 +163,11 @@ public static function register_get_content(): void { */ public static function check_permission( $input = array() ): bool { $input = is_array( $input ) ? $input : array(); - $exposed = self::get_exposed_post_types(); + $exposed = self::$exposed_post_types ?? self::get_exposed_post_types(); // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { - $post = get_post( (int) $input['id'] ); + $post = get_post( self::input_int( $input['id'] ) ); /* * For a missing post, an unexposed post type, or a post type that does not @@ -173,7 +187,7 @@ public static function check_permission( $input = array() ): bool { } // Query / slug mode requires an exposed post type. - $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { return false; } @@ -181,7 +195,7 @@ public static function check_permission( $input = array() ): bool { $post_type_object = $exposed[ $post_type ]; // Base gate: must be able to read this post type at all. - if ( ! current_user_can( $post_type_object->cap->read ?? 'read' ) ) { + if ( ! current_user_can( self::capability( $post_type_object, 'read', 'read' ) ) ) { return false; } @@ -193,12 +207,12 @@ public static function check_permission( $input = array() ): bool { } // Editors/authors of this post type may request any status set. - if ( current_user_can( $post_type_object->cap->edit_posts ?? 'edit_posts' ) ) { + if ( current_user_can( self::capability( $post_type_object, 'edit_posts', 'edit_posts' ) ) ) { return true; } // Otherwise, private posts are allowed only with read_private_posts. - if ( current_user_can( $post_type_object->cap->read_private_posts ?? 'read_private_posts' ) ) { + if ( current_user_can( self::capability( $post_type_object, 'read_private_posts', 'read_private_posts' ) ) ) { foreach ( $statuses as $status ) { if ( 'private' !== $status && 'publish' !== $status ) { return false; @@ -210,6 +224,34 @@ public static function check_permission( $input = array() ): bool { return false; } + /** + * Resolves a capability name from a post type's capability object, with a fallback. + * + * @since 7.1.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @param string $name Capability key on the post type's `cap` object. + * @param string $fallback Fallback capability name if unset or non-string. + * @return string The resolved capability name. + */ + protected static function capability( WP_Post_Type $post_type_object, string $name, string $fallback ): string { + $capability = $post_type_object->cap->$name ?? $fallback; + + return is_string( $capability ) ? $capability : $fallback; + } + + /** + * Casts a raw input value to a non-negative integer. + * + * @since 7.1.0 + * + * @param mixed $value The raw input value. + * @return int The value as a non-negative integer, or 0 when not scalar. + */ + protected static function input_int( $value ): int { + return is_scalar( $value ) ? absint( $value ) : 0; + } + /** * Executes the `core/content` ability. * @@ -220,12 +262,12 @@ public static function check_permission( $input = array() ): bool { */ public static function execute_get_content( $input = array() ) { $input = is_array( $input ) ? $input : array(); - $exposed = self::get_exposed_post_types(); + $exposed = self::$exposed_post_types ?? self::get_exposed_post_types(); $fields = self::normalize_fields( $input ); // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { - $post = get_post( (int) $input['id'] ); + $post = get_post( self::input_int( $input['id'] ) ); if ( ! $post || ! isset( $exposed[ $post->post_type ] ) @@ -243,13 +285,13 @@ public static function execute_get_content( $input = array() ) { } // Query / slug mode. - $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { return self::not_found_error(); } $per_page = self::normalize_per_page( $input ); - $page = isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1; + $page = isset( $input['page'] ) ? max( 1, self::input_int( $input['page'] ) ) : 1; $query_args = array( 'post_type' => $post_type, @@ -259,22 +301,25 @@ public static function execute_get_content( $input = array() ) { 'ignore_sticky_posts' => true, ); - if ( ! empty( $input['slug'] ) ) { - $query_args['name'] = sanitize_title( (string) $input['slug'] ); + if ( ! empty( $input['slug'] ) && is_string( $input['slug'] ) ) { + $query_args['name'] = sanitize_title( $input['slug'] ); } if ( ! empty( $input['author'] ) ) { - $query_args['author'] = (int) $input['author']; + $query_args['author'] = self::input_int( $input['author'] ); } if ( isset( $input['parent'] ) ) { - $query_args['post_parent'] = (int) $input['parent']; + $query_args['post_parent'] = self::input_int( $input['parent'] ); } $query = new WP_Query( $query_args ); $posts = array(); foreach ( $query->posts as $post ) { + if ( ! $post instanceof WP_Post ) { + continue; + } // Authoritative, row-level visibility check (author/status scoped). if ( ! current_user_can( 'read_post', $post->ID ) ) { continue; @@ -294,11 +339,11 @@ public static function execute_get_content( $input = array() ) { * * @since 7.1.0 * - * @param array $input The ability input. + * @param array $input The ability input. * @return int The clamped per-page value. */ protected static function normalize_per_page( array $input ): int { - $per_page = isset( $input['per_page'] ) ? (int) $input['per_page'] : self::DEFAULT_PER_PAGE; + $per_page = isset( $input['per_page'] ) ? self::input_int( $input['per_page'] ) : self::DEFAULT_PER_PAGE; return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); } @@ -343,16 +388,18 @@ protected static function get_available_statuses(): array { * * @since 7.1.0 * - * @param array $input The ability input. + * @param array $input The ability input. * @return string[] Normalized list of post status slugs. */ protected static function normalize_statuses( array $input ): array { $statuses = $input['status'] ?? array( 'publish' ); - if ( ! is_array( $statuses ) || array() === $statuses ) { + if ( ! is_array( $statuses ) ) { return array( 'publish' ); } - return array_map( 'sanitize_key', $statuses ); + $statuses = array_values( array_filter( $statuses, 'is_string' ) ); + + return array() === $statuses ? array( 'publish' ) : array_map( 'sanitize_key', $statuses ); } /** @@ -362,7 +409,7 @@ protected static function normalize_statuses( array $input ): array { * * @since 7.1.0 * - * @param array $input The ability input. + * @param array $input The ability input. * @return string[] List of requested field names. */ protected static function normalize_fields( array $input ): array { @@ -370,7 +417,8 @@ protected static function normalize_fields( array $input ): array { return self::FIELDS; } - $fields = array_intersect( self::FIELDS, array_map( 'strval', $input['fields'] ) ); + $requested = array_filter( $input['fields'], 'is_string' ); + $fields = array_intersect( self::FIELDS, $requested ); return array() === $fields ? self::FIELDS : array_values( $fields ); } @@ -387,7 +435,8 @@ protected static function normalize_fields( array $input ): array { protected static function get_content_input_schema( array $post_types, array $statuses ): array { return array( 'type' => 'object', - 'default' => array(), + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), // `post_type` is required unless a single post is requested by `id`. 'anyOf' => array( array( 'required' => array( 'id' ) ), @@ -670,6 +719,7 @@ public static function return_raw_title_format(): string { * @return string The ISO 8601 date, or an empty string if unavailable. */ protected static function format_gmt_date( WP_Post $post, string $field ): string { + $field = 'modified' === $field ? 'modified' : 'date'; $datetime = get_post_datetime( $post, $field, 'gmt' ); if ( $datetime ) { return $datetime->format( 'c' );