[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/abilities/ -> class-wp-abilities-content.php (source)

   1  <?php
   2  /**
   3   * Abilities API: WP_Abilities_Content class.
   4   *
   5   * @package WordPress
   6   * @subpackage Abilities API
   7   * @since 7.2.0
   8   */
   9  
  10  declare( strict_types = 1 );
  11  
  12  /**
  13   * Core class used to register content-related abilities.
  14   *
  15   * Registers the read-only `core/content-query` ability, which queries the readable posts of a
  16   * post type exposed to abilities via `show_in_abilities`, filtered by any of post IDs, slugs,
  17   * status, author, and parent. Raw fields are only returned for posts the current user can
  18   * edit.
  19   *
  20   * Unlike the core abilities registered directly in wp_register_core_abilities() as
  21   * self-contained closures, the content ability lives in a dedicated class because
  22   * its callbacks and schemas share helpers: the permission and execute callbacks read the
  23   * post type and the filters through the same code, and the input schema, output schema,
  24   * and field projection are built from the same field definitions. Future write-oriented
  25   * content abilities can reuse them as well.
  26   *
  27   * Only register() is public. The ability callbacks are closures that call private
  28   * methods, so callers go through the Abilities API, such as
  29   * `wp_get_ability( 'core/content-query' )->execute()`, which validates the input and
  30   * checks permissions before running them.
  31   *
  32   * @since 7.2.0
  33   *
  34   * @access private
  35   */
  36  final class WP_Abilities_Content {
  37  
  38      /**
  39       * The ability category used for content abilities.
  40       *
  41       * @since 7.2.0
  42       * @var string
  43       */
  44      private const CATEGORY = 'content';
  45  
  46      /**
  47       * Default number of posts returned per page, unless `where` lists posts by ID or slug.
  48       *
  49       * @since 7.2.0
  50       * @var int
  51       */
  52      private const DEFAULT_PER_PAGE = 10;
  53  
  54      /**
  55       * Maximum number of posts returned per page.
  56       *
  57       * The `ids` and `slugs` lists in `where` each hold at most this many values, so the posts
  58       * that `ids` lists fit on one page.
  59       *
  60       * @since 7.2.0
  61       * @var int
  62       */
  63      private const MAX_PER_PAGE = 100;
  64  
  65      /**
  66       * Field paths whose output may read post meta or terms.
  67       *
  68       * Requests that include any of these, directly or through their whole object, prime
  69       * the post meta and term caches for the page. Rendered excerpts and content may read
  70       * either, and permalinks read terms when the permalink structure contains `%category%`.
  71       * Other fields, such as the rendered title, do not need that cache priming.
  72       *
  73       * @since 7.2.0
  74       * @var list<string>
  75       */
  76      private const CACHE_PRIMING_FIELDS = array( 'link', 'excerpt.rendered', 'content.rendered' );
  77  
  78      /**
  79       * Default field paths returned when the caller does not request a field subset.
  80       *
  81       * @since 7.2.0
  82       * @var list<string>
  83       */
  84      private const DEFAULT_FIELDS = array( 'id', 'type', 'status', 'date', 'slug', 'title.rendered' );
  85  
  86      /**
  87       * Globals that rendering a post changes: the global post and the globals that
  88       * setup_postdata() populates.
  89       *
  90       * @since 7.2.0
  91       * @var list<string>
  92       */
  93      private const LOOP_GLOBALS = array( 'post', 'id', 'authordata', 'currentday', 'currentmonth', 'page', 'pages', 'multipage', 'more', 'numpages' );
  94  
  95      /**
  96       * Registers all content abilities.
  97       *
  98       * Must run on the `wp_abilities_api_init` hook.
  99       *
 100       * @since 7.2.0
 101       */
 102  	public function register(): void {
 103          $this->register_content_query();
 104  
 105          /*
 106           * Future write-oriented content abilities can be registered here, reusing the
 107           * shared helpers below (get_exposed_post_type(), format_post(), check_permission()).
 108           */
 109      }
 110  
 111      /**
 112       * Registers the read-only `core/content-query` ability.
 113       *
 114       * @since 7.2.0
 115       */
 116  	private function register_content_query(): void {
 117          /*
 118           * Post types must be registered with `show_in_abilities` before the ability is
 119           * registered so they are included in its input schema.
 120           */
 121          $post_types = array_values( get_post_types( array( 'show_in_abilities' => true ) ) );
 122          if ( empty( $post_types ) ) {
 123              return;
 124          }
 125  
 126          /*
 127           * Internal statuses (e.g. `inherit`) are excluded here and from the statuses a query
 128           * covers by default, so the posts of post types that rely on them (attachments)
 129           * cannot be queried, not even by ID. Revisit if such a post type is ever exposed via
 130           * `show_in_abilities`.
 131           */
 132          $statuses = array_values( get_post_stati( array( 'internal' => false ) ) );
 133  
 134          wp_register_ability(
 135              'core/content-query',
 136              array(
 137                  'label'               => __( 'Query Content' ),
 138                  'description'         => __( 'Retrieves a paginated list of the readable posts of a post type exposed to abilities, filtered by any of post IDs, slugs, status, author, and parent. Requires an authenticated user. Filters are exact-match only; the ability does not perform full-text search.' ),
 139                  'category'            => self::CATEGORY,
 140                  'input_schema'        => $this->get_content_query_input_schema( $post_types, $statuses ),
 141                  'output_schema'       => $this->get_content_query_output_schema(),
 142                  'execute_callback'    => function ( $input = array() ) {
 143                      return $this->execute_content_query( $input );
 144                  },
 145                  'permission_callback' => function ( $input = array() ): bool {
 146                      return $this->check_permission( $input );
 147                  },
 148                  'meta'                => array(
 149                      'annotations' => array(
 150                          'readonly'    => true,
 151                          'destructive' => false,
 152                          'idempotent'  => true,
 153                      ),
 154                      'public'      => true,
 155                  ),
 156              )
 157          );
 158      }
 159  
 160      /**
 161       * Permission callback for the `core/content-query` ability.
 162       *
 163       * The request is only gated as a whole here: it needs a logged-in user, a post type
 164       * exposed to abilities, and the capabilities to query the explicitly requested statuses.
 165       * It reads no post. {@see self::execute_content_query()} enforces row-level read
 166       * permissions, since individual rows are unknown until the query runs.
 167       *
 168       * @since 7.2.0
 169       *
 170       * @param mixed $input Optional. The ability input. Default empty array.
 171       * @return bool True if the request may proceed, false otherwise.
 172       */
 173  	private function check_permission( $input = array() ): bool {
 174          $input = rest_sanitize_object( $input );
 175  
 176          if ( ! is_user_logged_in() ) {
 177              return false;
 178          }
 179  
 180          /*
 181           * A `where` that is not an object is let through, so that the execute callback can
 182           * report it as an invalid filter.
 183           */
 184          $where = $this->get_input_object( $input, 'where' );
 185          if ( null === $where ) {
 186              return true;
 187          }
 188  
 189          // A query requires an exposed post type.
 190          $post_type_object = $this->get_exposed_post_type( $where['type'] ?? null );
 191          if ( ! $post_type_object ) {
 192              return false;
 193          }
 194  
 195          return $this->can_query_statuses( $where, $post_type_object );
 196      }
 197  
 198      /**
 199       * Parses a raw input value into an integer of at least a minimum, or null when invalid.
 200       *
 201       * Accepts the whole numbers that the integer schema accepts: native integers, floats such
 202       * as the 2.0 that JSON encoders produce, and integer strings such as "2", "2.0", or "+2".
 203       * Only the REST run controller converts input to the schema types, so other callers, such
 204       * as the MCP adapter or a direct WP_Ability::execute() call, pass them on unconverted.
 205       * Anything else, such as a fraction, is rejected rather than coerced: a malformed ID would
 206       * otherwise resolve to another post, and a malformed `parent` filter would be read as 0,
 207       * which asks for top-level posts.
 208       *
 209       * @since 7.2.0
 210       *
 211       * @param mixed $value The raw input value.
 212       * @param int   $min   The smallest acceptable value.
 213       * @return int|null The parsed integer, or null when the value is not an integer >= $min.
 214       */
 215  	private function parse_filter_int( $value, int $min ): ?int {
 216          if ( is_int( $value ) ) {
 217              return $value >= $min ? $value : null;
 218          }
 219  
 220          if ( is_string( $value ) && '' !== $value && ctype_digit( $value ) ) {
 221              $int = (int) $value;
 222  
 223              return $int >= $min ? $int : null;
 224          }
 225  
 226          if ( ! rest_is_integer( $value ) ) {
 227              return null;
 228          }
 229  
 230          /*
 231           * Other whole numbers are read as floats, which hold every integer only up to 2 ** 53.
 232           * Past that, the value may not be the one that was sent, and casting a float beyond the
 233           * integer range wraps it to an arbitrary integer, such as the ID of another post.
 234           */
 235          $number = (float) $value;
 236  
 237          return $number >= $min && $number <= 2 ** 53 ? (int) $number : null;
 238      }
 239  
 240      /**
 241       * Parses a raw list input into a list of strings.
 242       *
 243       * Like schema validation, it also accepts a scalar or a comma-separated string, and
 244       * parses it the same way, with wp_parse_list(). A number, such as a slug sent as 2024,
 245       * is read as its string form.
 246       *
 247       * @since 7.2.0
 248       *
 249       * @param array<mixed> $input The ability input, or one of its objects, such as `where`.
 250       * @param string       $key   The input key holding the list.
 251       * @return list<string> The parsed string values; empty when absent or unparseable.
 252       */
 253  	private function parse_list_input( array $input, string $key ): array {
 254          $value = $input[ $key ] ?? null;
 255          if ( is_scalar( $value ) ) {
 256              $value = (string) $value;
 257          } elseif ( ! is_array( $value ) ) {
 258              return array();
 259          }
 260  
 261          return array_values( array_filter( wp_parse_list( $value ), 'is_string' ) );
 262      }
 263  
 264      /**
 265       * Checks whether the current user may query the explicitly requested statuses.
 266       *
 267       * The statuses a query covers by default, see {@see self::normalize_statuses()}, only
 268       * hold statuses the current user may query, so they need no check.
 269       *
 270       * @since 7.2.0
 271       *
 272       * @param array<mixed> $where            The `where` filters.
 273       * @param WP_Post_Type $post_type_object The post type object.
 274       * @return bool True if the requested statuses may be queried.
 275       */
 276  	private function can_query_statuses( array $where, WP_Post_Type $post_type_object ): bool {
 277          foreach ( $this->get_requested_statuses( $where ) as $status ) {
 278              if ( ! $this->can_query_status( $status, $post_type_object ) ) {
 279                  return false;
 280              }
 281          }
 282  
 283          return true;
 284      }
 285  
 286      /**
 287       * Checks whether the current user may query posts in a status.
 288       *
 289       * Like the REST posts controller, any user may query `publish`, while other statuses, such
 290       * as `draft`, require edit access. Any user may also query the other publicly viewable
 291       * statuses, and `private`, since the query only covers the private posts the user can
 292       * read, see {@see self::execute_content_query()}.
 293       *
 294       * @since 7.2.0
 295       *
 296       * @param string       $status           The post status slug.
 297       * @param WP_Post_Type $post_type_object The post type object.
 298       * @return bool True if the status may be queried.
 299       */
 300  	private function can_query_status( string $status, WP_Post_Type $post_type_object ): bool {
 301          return in_array( $status, array( 'publish', 'private' ), true ) || is_post_status_viewable( $status ) || current_user_can( $post_type_object->cap->edit_posts );
 302      }
 303  
 304      /**
 305       * Checks if a post can be read by the current user.
 306       *
 307       * Mirrors the REST posts controller's read permission, while keeping this ability
 308       * authenticated-only via {@see self::check_permission()}.
 309       *
 310       * @since 7.2.0
 311       *
 312       * @param WP_Post          $post             Post object.
 313       * @param array<int, true> $checked_post_ids Post IDs already checked while walking inherited parents.
 314       * @return bool Whether the post can be read.
 315       */
 316  	private function check_read_permission( WP_Post $post, array $checked_post_ids = array() ): bool {
 317          if ( isset( $checked_post_ids[ $post->ID ] ) ) {
 318              return false;
 319          }
 320  
 321          $checked_post_ids[ $post->ID ] = true;
 322  
 323          if ( ! $this->get_exposed_post_type( $post->post_type ) ) {
 324              return false;
 325          }
 326  
 327          /*
 328           * Treat publicly viewable posts as readable. This checks both the post type
 329           * and post status using Core's viewability helpers, which is stricter than
 330           * checking the status object's `public` flag alone.
 331           */
 332          if ( is_post_publicly_viewable( $post ) ) {
 333              return true;
 334          }
 335  
 336          /*
 337           * Use the normalized status for the status object lookup. For attachments,
 338           * get_post_status() resolves `inherit` through the parent before returning.
 339           */
 340          $post_status = get_post_status( $post );
 341          if ( ! is_string( $post_status ) ) {
 342              return false;
 343          }
 344  
 345          $post_status_object = get_post_status_object( $post_status );
 346          if ( ! $post_status_object instanceof stdClass ) {
 347              return false;
 348          }
 349  
 350          /*
 351           * Core maps `read_post` for public statuses to the post type's plain `read`
 352           * capability. Publicly viewable posts already returned above, so a remaining
 353           * public status is public but not viewable and should require edit access.
 354           */
 355          if ( $post_status_object->public ) {
 356              return current_user_can( 'edit_post', $post->ID );
 357          }
 358  
 359          /*
 360           * For non-public statuses, defer to Core's meta-capability mapping. This
 361           * handles own drafts, private posts, and statuses that require edit access.
 362           */
 363          if ( current_user_can( 'read_post', $post->ID ) ) {
 364              return true;
 365          }
 366  
 367          /*
 368           * Mirror the REST posts controller's inherited-parent behavior, but keep the
 369           * ability fail-closed for missing parents or parent loops. No input can request
 370           * `inherit`, since internal statuses are left out of the input schema and of the
 371           * statuses a query covers by default, but a query filter such as a `pre_get_posts`
 372           * callback can still add it, so such posts keep their parent's read check.
 373           */
 374          if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) {
 375              $parent = get_post( $post->post_parent );
 376              if ( $parent instanceof WP_Post ) {
 377                  return $this->check_read_permission( $parent, $checked_post_ids );
 378              }
 379          }
 380  
 381          return false;
 382      }
 383  
 384      /**
 385       * Executes the `core/content-query` ability.
 386       *
 387       * {@see WP_Ability::execute()} always runs {@see self::check_permission()} first, which
 388       * gates the request as a whole. The query still filters every row by read permission,
 389       * because the gate cannot know the rows before the query runs.
 390       *
 391       * @since 7.2.0
 392       *
 393       * @param mixed $input Optional. The ability input. Default empty array.
 394       * @return array<string, mixed>|WP_Error A page of posts, or a WP_Error on failure.
 395       */
 396  	private function execute_content_query( $input = array() ) {
 397          $input  = rest_sanitize_object( $input );
 398          $fields = $this->normalize_fields( $input );
 399  
 400          /*
 401           * A `where` that is not an object cannot be honored. Reject it rather than reading it
 402           * as no filter at all.
 403           */
 404          $where = $this->get_input_object( $input, 'where' );
 405          if ( null === $where ) {
 406              return $this->invalid_filter_error( 'where', __( 'The where filter must be an object.' ) );
 407          }
 408  
 409          // Pagination that is not an object falls back to the defaults, like page values that cannot be parsed.
 410          $pagination = $this->get_input_object( $input, 'pagination' ) ?? array();
 411          $page       = $this->parse_filter_int( $pagination['page'] ?? 1, 1 ) ?? 1;
 412  
 413          /*
 414           * Unreachable through gated transports, which run check_permission() first and deny a
 415           * request without a post type exposed to abilities. A direct call to the execute
 416           * callback still fails closed here, but it bypasses the checks of the request as a
 417           * whole that the permission callback performed, such as the status capabilities, so
 418           * only invoke the callback through WP_Ability::execute().
 419           */
 420          $post_type_object = $this->get_exposed_post_type( $where['type'] ?? null );
 421          if ( ! $post_type_object ) {
 422              return new WP_Error(
 423                  'content_not_found',
 424                  __( 'The requested content was not found.' ),
 425                  array( 'status' => 404 )
 426              );
 427          }
 428  
 429          $post_type = $post_type_object->name;
 430  
 431          /*
 432           * REST only registers the equivalent collection filters for post types that
 433           * support them; a shared input schema cannot express that per post type. On
 434           * transports that skip schema validation a malformed value would otherwise
 435           * coerce to a benign default and silently *widen* the query (`author => 0`
 436           * drops the author filter, an empty `post__in` or `post_name__in` is ignored,
 437           * `post_parent => 0` becomes a top-level query). Reject unsupported filters and
 438           * invalid filter values loudly so a filter that cannot be honored fails closed
 439           * instead.
 440           */
 441          $parent = null;
 442          if ( isset( $where['parent'] ) ) {
 443              if ( ! is_post_type_hierarchical( $post_type ) ) {
 444                  return $this->invalid_filter_error( 'where[parent]', __( 'The parent filter is only supported for hierarchical post types.' ) );
 445              }
 446  
 447              $parent = $this->parse_filter_int( $where['parent'], 0 );
 448              if ( null === $parent ) {
 449                  return $this->invalid_filter_error( 'where[parent]', __( 'The parent filter must be a non-negative integer.' ) );
 450              }
 451          }
 452  
 453          $author = null;
 454          if ( isset( $where['author_slug'] ) ) {
 455              if ( ! post_type_supports( $post_type, 'author' ) ) {
 456                  return $this->invalid_filter_error(
 457                      'where[author_slug]',
 458                      /* translators: %s: Parameter. */
 459                      sprintf( __( 'The %s filter is only supported for post types that support authors.' ), 'author_slug' )
 460                  );
 461              }
 462  
 463              $author = $this->get_author_by_slug( $where['author_slug'], $post_type_object );
 464              if ( ! $author ) {
 465                  return $this->invalid_filter_error(
 466                      'where[author_slug]',
 467                      /* translators: %s: Parameter. */
 468                      sprintf( __( 'The %s filter must be the slug of an existing user.' ), 'author_slug' )
 469                  );
 470              }
 471          }
 472  
 473          /*
 474           * An `ids` or `slugs` filter that was given but kept no valid entry must not fall
 475           * through to an unrestricted query: WP_Query ignores an empty `post__in` or
 476           * `post_name__in`, which would return every post of the type, the opposite of the
 477           * caller's intent.
 478           */
 479          $ids = null;
 480          if ( isset( $where['ids'] ) ) {
 481              $ids = $this->normalize_ids( $where );
 482              if ( empty( $ids ) ) {
 483                  return $this->invalid_filter_error(
 484                      'where[ids]',
 485                      /* translators: %s: Parameter. */
 486                      sprintf( __( 'The %s filter must list one or more valid post IDs.' ), 'ids' )
 487                  );
 488              }
 489          }
 490  
 491          $slugs = null;
 492          if ( isset( $where['slugs'] ) ) {
 493              $slugs = $this->normalize_slugs( $where );
 494              if ( empty( $slugs ) ) {
 495                  return $this->invalid_filter_error(
 496                      'where[slugs]',
 497                      /* translators: %s: Parameter. */
 498                      sprintf( __( 'The %s filter must list one or more valid post slugs.' ), 'slugs' )
 499                  );
 500              }
 501          }
 502  
 503          /*
 504           * A `status` filter that was given but kept no status must not fall through to the
 505           * default statuses, which with `ids` or `slugs` cover every status the current user may
 506           * query, wider than the caller asked for.
 507           */
 508          $statuses = $this->get_requested_statuses( $where );
 509          if ( isset( $where['status'] ) && empty( $statuses ) ) {
 510              return $this->invalid_filter_error(
 511                  'where[status]',
 512                  /* translators: %s: Parameter. */
 513                  sprintf( __( 'The %s filter must list one or more post statuses.' ), 'status' )
 514              );
 515          }
 516  
 517          /*
 518           * The input schema lists the statuses registered when the ability was, so check them
 519           * again: WP_Query ignores a status that is not registered, and drops the status clause
 520           * when none is left, which would cover every status, including internal ones such as
 521           * `trash`.
 522           */
 523          if ( ! empty( array_diff( $statuses, get_post_stati( array( 'internal' => false ) ) ) ) ) {
 524              return $this->invalid_filter_error(
 525                  'where[status]',
 526                  /* translators: %s: Parameter. */
 527                  sprintf( __( 'The %s filter must list valid post statuses.' ), 'status' )
 528              );
 529          }
 530  
 531          $lists_posts = null !== $ids || null !== $slugs;
 532          $per_page    = $this->normalize_per_page( $pagination, $lists_posts );
 533  
 534          $prime_post_caches = $this->should_prime_post_caches( $fields );
 535  
 536          /*
 537           * `orderby` is left unset, which orders by `post_date` descending, matching the
 538           * default of the REST posts controller.
 539           *
 540           * `perm` leaves out the other users' private posts for a user who cannot read them,
 541           * so the totals do not count them. It is not set for a user who can: when a query
 542           * filter adds post types, WP_Query checks `read_private_multiple_post_types`
 543           * instead, which no role has, and would leave the posts out too.
 544           */
 545          $query_args = array(
 546              'post_type'              => $post_type,
 547              'post_status'            => $this->normalize_statuses( $statuses, $post_type_object, $lists_posts ),
 548              'posts_per_page'         => $per_page,
 549              'paged'                  => $page,
 550              'perm'                   => current_user_can( $post_type_object->cap->read_private_posts ) ? '' : 'readable',
 551              'ignore_sticky_posts'    => true,
 552              'update_post_meta_cache' => $prime_post_caches,
 553              'update_post_term_cache' => $prime_post_caches,
 554          );
 555  
 556          if ( null !== $ids ) {
 557              $query_args['post__in'] = $ids;
 558          }
 559  
 560          if ( null !== $slugs ) {
 561              $query_args['post_name__in'] = $slugs;
 562          }
 563  
 564          if ( null !== $author ) {
 565              $query_args['author'] = $author->ID;
 566          }
 567  
 568          if ( null !== $parent ) {
 569              $query_args['post_parent'] = $parent;
 570          }
 571  
 572          $query = new WP_Query( $query_args );
 573          $total = $this->get_query_total( $query, $query_args, $page );
 574  
 575          /*
 576           * Count the pages with the page size the query ran with, as the REST posts controller
 577           * does, since query filters such as `pre_get_posts` callbacks can change it. Without
 578           * paging, the query returns every post on one page.
 579           */
 580          $query_per_page = (int) $query->get( 'posts_per_page' );
 581          if ( $query->get( 'nopaging' ) || $query_per_page < 1 ) {
 582              $total_pages = $total > 0 ? 1 : 0;
 583          } else {
 584              $total_pages = (int) ceil( $total / $query_per_page );
 585          }
 586  
 587          /*
 588           * A page past the last one does not exist, so report it as not found instead of
 589           * returning a bare empty list. A genuinely empty result set still returns zero
 590           * totals and no error.
 591           */
 592          if ( $total > 0 && $page > $total_pages ) {
 593              return $this->invalid_page_number_error();
 594          }
 595  
 596          /*
 597           * Prime the parent and author caches with a single query each instead of one
 598           * lookup per post, as the REST posts controller does. Hierarchical permalinks and
 599           * inherited read permissions read the parent, and format_post() sets up every post
 600           * with setup_postdata(), which reads the author.
 601           */
 602          update_post_parent_caches( $query->posts );
 603          update_post_author_caches( $query->posts );
 604  
 605          $posts = array();
 606          foreach ( $query->posts as $post ) {
 607              /*
 608               * Skip posts of other post types, which query filters can add, such as a
 609               * `pre_get_posts` callback that adds post types to the blog home without an
 610               * is_main_query() check.
 611               */
 612              if ( ! $post instanceof WP_Post || $post_type !== $post->post_type ) {
 613                  continue;
 614              }
 615              if ( ! $this->check_read_permission( $post ) ) {
 616                  continue;
 617              }
 618              $posts[] = $this->format_post( $post, $fields );
 619          }
 620  
 621          /*
 622           * Mirror the REST posts controller: totals come from the underlying WP_Query,
 623           * while the row-level checks above may withhold individual returned rows. The
 624           * totals can therefore count posts the current user cannot read, such as another
 625           * author's draft listed in `ids`. This is deliberate: it only reveals that such a
 626           * post exists, as a REST collection request with `include` and `status` does.
 627           */
 628          return $this->prepare_output( $posts, $total, $total_pages );
 629      }
 630  
 631      /**
 632       * Reads an object member of the input, such as `where` or `pagination`.
 633       *
 634       * Accepts what schema validation reads as an object: an array, a `stdClass`, or an
 635       * empty string. Only the REST run controller converts input to the schema types, so
 636       * callers that bypass it, such as a direct WP_Ability::execute() call, can pass any of them.
 637       *
 638       * @since 7.2.0
 639       *
 640       * @param array<mixed> $input The ability input.
 641       * @param string       $key   The member name.
 642       * @return array<mixed>|null The member as an array, an empty array when it is omitted,
 643       *                           or null when it is not an object.
 644       */
 645  	private function get_input_object( array $input, string $key ): ?array {
 646          if ( ! array_key_exists( $key, $input ) ) {
 647              return array();
 648          }
 649  
 650          return rest_is_object( $input[ $key ] ) ? rest_sanitize_object( $input[ $key ] ) : null;
 651      }
 652  
 653      /**
 654       * Normalizes the requested per-page value to the supported bounds.
 655       *
 656       * An explicit `per_page` always wins. Otherwise a request that lists posts by ID or slug
 657       * pages to the maximum, as `core/users-query` does for its lists, so a caller loading a
 658       * known set of posts receives them in one call rather than silently losing the ones past
 659       * the default page size. The input schema caps each list at {@see self::MAX_PER_PAGE},
 660       * so every post that `ids` lists fits on the first page. A slug can match several posts,
 661       * though, so the matches of `slugs` only continue past the first page when more than
 662       * that many posts match.
 663       *
 664       * @since 7.2.0
 665       *
 666       * @param array<mixed> $pagination  The `pagination` input.
 667       * @param bool         $lists_posts Whether the query lists `ids` or `slugs`.
 668       * @return int The clamped per-page value.
 669       */
 670  	private function normalize_per_page( array $pagination, bool $lists_posts ): int {
 671          $default_per_page = $lists_posts ? self::MAX_PER_PAGE : self::DEFAULT_PER_PAGE;
 672  
 673          return min( self::MAX_PER_PAGE, $this->parse_filter_int( $pagination['per_page'] ?? null, 1 ) ?? $default_per_page );
 674      }
 675  
 676      /**
 677       * Returns the query total, recovering it when WP_Query skipped the count.
 678       *
 679       * WP_Query leaves `found_posts` at 0 when a requested page has no rows. Re-run a
 680       * minimal unpaged query so the caller can distinguish an out-of-range page from
 681       * an empty result set, matching the REST posts controller behavior.
 682       *
 683       * @since 7.2.0
 684       *
 685       * @param WP_Query     $query      The executed query.
 686       * @param array<mixed> $query_args The arguments used for the executed query.
 687       * @param int          $page       The requested page.
 688       * @return int Total matching rows across all pages.
 689       */
 690  	private function get_query_total( WP_Query $query, array $query_args, int $page ): int {
 691          $total = (int) $query->found_posts;
 692  
 693          if ( $total > 0 || $page <= 1 ) {
 694              return $total;
 695          }
 696  
 697          $count_args                           = $query_args;
 698          $count_args['fields']                 = 'ids';
 699          $count_args['posts_per_page']         = 1;
 700          $count_args['update_post_meta_cache'] = false;
 701          $count_args['update_post_term_cache'] = false;
 702          unset( $count_args['paged'] );
 703  
 704          $count_query = new WP_Query( $count_args );
 705  
 706          return (int) $count_query->found_posts;
 707      }
 708  
 709      /**
 710       * Checks whether requested fields benefit from page-level cache priming.
 711       *
 712       * A whole object counts as a request for its sub-fields, so `content` primes the
 713       * caches as `content.rendered` does.
 714       *
 715       * @since 7.2.0
 716       *
 717       * @param list<string> $fields The requested field paths.
 718       * @return bool True when post meta and term caches should be primed.
 719       */
 720  	private function should_prime_post_caches( array $fields ): bool {
 721          foreach ( self::CACHE_PRIMING_FIELDS as $field ) {
 722              if ( $this->is_field_requested( $field, $fields ) ) {
 723                  return true;
 724              }
 725          }
 726  
 727          return false;
 728      }
 729  
 730      /**
 731       * Checks whether a field path is requested.
 732       *
 733       * Matches paths as the REST API's `_fields` parameter does, with rest_is_field_included():
 734       * a field such as `title` is requested when it or one of its paths, such as
 735       * `title.rendered`, is requested, and a path such as `title.rendered` is requested when it
 736       * or its whole field is.
 737       *
 738       * @since 7.2.0
 739       *
 740       * @param string       $path   The field path, such as `title` or `title.rendered`.
 741       * @param list<string> $fields The requested field paths.
 742       * @return bool Whether the field path is requested.
 743       */
 744  	private function is_field_requested( string $path, array $fields ): bool {
 745          return rest_is_field_included( $path, $fields );
 746      }
 747  
 748      /**
 749       * Looks up the user an author slug names.
 750       *
 751       * The slug is the user's nicename, which the REST API users endpoint returns as `slug`. It is
 752       * looked up as given, like that endpoint's `slug` filter, so the database collation decides
 753       * whether a variant in another case matches. A user the current user may not see is reported
 754       * like a missing one. The current user can see themselves, any user of the site when they can
 755       * list users, and authors with posts in a publicly viewable post type. A user who can edit
 756       * others' posts of the post type can also see any user of the site, since they may make any of
 757       * them the author, so for them the lookup does tell whether such an account exists.
 758       *
 759       * On multisite the lookup searches the whole network, so posts by authors who are not
 760       * members of the site, such as super admins, can still be filtered. Those users are only
 761       * visible as the current user or as authors with posts in a publicly viewable post type, so
 762       * the lookup does not tell whether an account exists elsewhere on the network.
 763       *
 764       * @since 7.2.0
 765       *
 766       * @param mixed        $slug             The author slug.
 767       * @param WP_Post_Type $post_type_object The post type the author is looked up for.
 768       * @return WP_User|null The user, or null when the slug does not name a visible user.
 769       */
 770  	private function get_author_by_slug( $slug, WP_Post_Type $post_type_object ): ?WP_User {
 771          $user = is_string( $slug ) ? get_user_by( 'slug', $slug ) : false;
 772          if ( ! $user ) {
 773              return null;
 774          }
 775  
 776          if ( get_current_user_id() === $user->ID ) {
 777              return $user;
 778          }
 779  
 780          // The capabilities only reveal users of the site, not of the whole network.
 781          if ( is_user_member_of_blog( $user->ID )
 782              && ( current_user_can( 'list_users' ) || current_user_can( $post_type_object->cap->edit_others_posts ) )
 783          ) {
 784              return $user;
 785          }
 786  
 787          $public_post_types = array_values( array_filter( get_post_types(), 'is_post_type_viewable' ) );
 788  
 789          return ! empty( $public_post_types ) && count_user_posts( $user->ID, $public_post_types ) > 0 ? $user : null;
 790      }
 791  
 792      /**
 793       * Returns a post type exposed through the Abilities API.
 794       *
 795       * Deliberately resolved on every call rather than cached: post types can be
 796       * unregistered or re-registered with different arguments between the ability
 797       * being registered and the ability being used.
 798       *
 799       * @since 7.2.0
 800       *
 801       * @param mixed $post_type The post type name.
 802       * @return WP_Post_Type|null The post type object, or null when the post type is not exposed.
 803       */
 804  	private function get_exposed_post_type( $post_type ): ?WP_Post_Type {
 805          $post_type_object = is_string( $post_type ) ? get_post_type_object( $post_type ) : null;
 806  
 807          return empty( $post_type_object->show_in_abilities ) ? null : $post_type_object;
 808      }
 809  
 810      /**
 811       * Returns the statuses the `status` filter explicitly requests.
 812       *
 813       * @since 7.2.0
 814       *
 815       * @param array<mixed> $where The `where` filters.
 816       * @return list<string> Sanitized post status slugs; empty when none is requested.
 817       */
 818  	private function get_requested_statuses( array $where ): array {
 819          return array_map( 'sanitize_key', $this->parse_list_input( $where, 'status' ) );
 820      }
 821  
 822      /**
 823       * Normalizes the statuses a query covers.
 824       *
 825       * Explicitly requested statuses are queried as given, once the permission callback has
 826       * allowed them. Otherwise a query covers `publish`, as in the REST posts controller,
 827       * unless it lists `ids` or `slugs`. Such a query names the posts it wants, which may be
 828       * drafts or private posts, so it covers every non-internal status that
 829       * {@see self::can_query_status()} allows instead. The row-level read checks still
 830       * withhold the posts the current user cannot read, such as other users' drafts.
 831       *
 832       * @since 7.2.0
 833       *
 834       * @param list<string> $statuses         The explicitly requested statuses; empty when none is requested.
 835       * @param WP_Post_Type $post_type_object The post type object.
 836       * @param bool         $lists_posts      Whether the query lists `ids` or `slugs`.
 837       * @return list<string> Post status slugs.
 838       */
 839  	private function normalize_statuses( array $statuses, WP_Post_Type $post_type_object, bool $lists_posts ): array {
 840          if ( ! empty( $statuses ) ) {
 841              return $statuses;
 842          }
 843  
 844          if ( ! $lists_posts ) {
 845              return array( 'publish' );
 846          }
 847  
 848          foreach ( get_post_stati( array( 'internal' => false ) ) as $status ) {
 849              if ( $this->can_query_status( $status, $post_type_object ) ) {
 850                  $statuses[] = $status;
 851              }
 852          }
 853  
 854          return $statuses;
 855      }
 856  
 857      /**
 858       * Normalizes the post IDs the `ids` filter lists.
 859       *
 860       * Like schema validation, it also accepts a single ID or a comma-separated string. Each
 861       * ID is read with {@see self::parse_filter_int()}, and one that is not a whole number of
 862       * at least 1, such as a fraction, a negative number, or a float too large to hold its
 863       * integer exactly, is dropped rather than coerced, so it can never select another post.
 864       * A digit string beyond the integer range saturates to PHP_INT_MAX, as REST sanitization
 865       * does, which selects no post either.
 866       *
 867       * @since 7.2.0
 868       *
 869       * @param array<mixed> $where The `where` filters.
 870       * @return list<int> Unique post IDs; empty when none is valid.
 871       */
 872  	private function normalize_ids( array $where ): array {
 873          $ids = $where['ids'] ?? null;
 874          if ( is_string( $ids ) ) {
 875              $ids = wp_parse_list( $ids );
 876          } elseif ( is_int( $ids ) || is_float( $ids ) ) {
 877              $ids = array( $ids );
 878          } elseif ( ! is_array( $ids ) ) {
 879              return array();
 880          }
 881  
 882          $post_ids = array();
 883          foreach ( $ids as $id ) {
 884              $post_id = $this->parse_filter_int( $id, 1 );
 885              if ( null !== $post_id ) {
 886                  $post_ids[] = $post_id;
 887              }
 888          }
 889  
 890          return array_values( array_unique( $post_ids ) );
 891      }
 892  
 893      /**
 894       * Normalizes the post slugs the `slugs` filter lists.
 895       *
 896       * Like schema validation, it also accepts a single slug, even one sent as a number, or a
 897       * comma-separated string. Each slug is sanitized with sanitize_title(), as WordPress
 898       * sanitizes a post slug when it saves the post, so `Hello-World` matches `hello-world`.
 899       * A slug that sanitizes to an empty string, such as `!!!`, is dropped: it names no post,
 900       * and querying it would match the posts that have no slug yet, such as drafts.
 901       *
 902       * @since 7.2.0
 903       *
 904       * @param array<mixed> $where The `where` filters.
 905       * @return list<string> Unique post slugs; empty when none is valid.
 906       */
 907  	private function normalize_slugs( array $where ): array {
 908          $slugs = array();
 909          foreach ( $this->parse_list_input( $where, 'slugs' ) as $slug ) {
 910              $slug = sanitize_title( $slug );
 911              if ( '' !== $slug ) {
 912                  $slugs[] = $slug;
 913              }
 914          }
 915  
 916          return array_values( array_unique( $slugs ) );
 917      }
 918  
 919      /**
 920       * Returns the requested field paths, or a lean default set when none are given.
 921       *
 922       * An empty or absent `fields` value selects a lean set of common read fields.
 923       * Otherwise the requested paths are returned. The input schema has already
 924       * validated them against the supported set before the ability executes, and
 925       * {@see self::build_post_fields()} ignores any other path.
 926       *
 927       * @since 7.2.0
 928       *
 929       * @param array<mixed> $input The ability input.
 930       * @return list<string> List of requested field paths.
 931       */
 932  	private function normalize_fields( array $input ): array {
 933          $fields = $this->parse_list_input( $input, 'fields' );
 934  
 935          return empty( $fields ) ? self::DEFAULT_FIELDS : $fields;
 936      }
 937  
 938      /**
 939       * Returns the post field definitions, keyed by field name in output order.
 940       *
 941       * This is the single source of truth for the ability's post fields: the output
 942       * schema uses the definitions directly, while the input schema `fields` enum lists
 943       * each field, followed by the path of each of its sub-fields, such as `title.rendered`.
 944       * Read-context fields are returned for readable posts; the `raw` sub-fields only for
 945       * posts the current user can edit.
 946       *
 947       * @since 7.2.0
 948       *
 949       * @return array<string, mixed> Post field definitions.
 950       */
 951  	private function get_post_properties(): array {
 952          return array(
 953              'id'       => array(
 954                  'type'        => 'integer',
 955                  'description' => __( 'The post ID.' ),
 956              ),
 957              'type'     => array(
 958                  'type'        => 'string',
 959                  'description' => __( 'The post type.' ),
 960              ),
 961              'status'   => array(
 962                  'type'        => 'string',
 963                  'description' => __( 'The post status.' ),
 964              ),
 965              'date'     => array(
 966                  'type'                 => 'object',
 967                  'additionalProperties' => false,
 968                  'properties'           => array(
 969                      'local' => array(
 970                          'type'        => array( 'string', 'null' ),
 971                          'format'      => 'date-time',
 972                          'description' => __( "The publication date, in ISO 8601 format using the site's timezone. Null when the date cannot be resolved." ),
 973                      ),
 974                      'gmt'   => array(
 975                          'type'        => array( 'string', 'null' ),
 976                          'format'      => 'date-time',
 977                          'description' => __( 'The publication date, in ISO 8601 format as GMT. Null when the date cannot be resolved.' ),
 978                      ),
 979                  ),
 980                  'description'          => __( 'The publication date.' ),
 981              ),
 982              'modified' => array(
 983                  'type'                 => 'object',
 984                  'additionalProperties' => false,
 985                  'properties'           => array(
 986                      'local' => array(
 987                          'type'        => array( 'string', 'null' ),
 988                          'format'      => 'date-time',
 989                          'description' => __( "The last modified date, in ISO 8601 format using the site's timezone. Null when the date cannot be resolved." ),
 990                      ),
 991                      'gmt'   => array(
 992                          'type'        => array( 'string', 'null' ),
 993                          'format'      => 'date-time',
 994                          'description' => __( 'The last modified date, in ISO 8601 format as GMT. Null when the date cannot be resolved.' ),
 995                      ),
 996                  ),
 997                  'description'          => __( 'The last modified date.' ),
 998              ),
 999              'slug'     => array(
1000                  'type'        => 'string',
1001                  'description' => __( 'The post slug.' ),
1002              ),
1003              'link'     => array(
1004                  'type'        => 'string',
1005                  'description' => __( 'The permalink URL.' ),
1006              ),
1007              'title'    => array(
1008                  'type'                 => 'object',
1009                  'additionalProperties' => false,
1010                  'properties'           => array(
1011                      'raw'      => array(
1012                          'type'        => 'string',
1013                          'description' => __( 'The raw post title. Present when the current user can edit the post.' ),
1014                      ),
1015                      'rendered' => array(
1016                          'type'        => 'string',
1017                          'description' => __( 'The rendered post title.' ),
1018                      ),
1019                  ),
1020                  'description'          => __( 'The post title. Present when the post type supports titles.' ),
1021              ),
1022              'excerpt'  => array(
1023                  'type'                 => 'object',
1024                  'additionalProperties' => false,
1025                  'properties'           => array(
1026                      'raw'       => array(
1027                          'type'        => 'string',
1028                          'description' => __( 'The raw post excerpt. Present when the current user can edit the post.' ),
1029                      ),
1030                      'rendered'  => array(
1031                          'type'        => 'string',
1032                          'description' => __( 'The rendered post excerpt (HTML). Empty when withheld for a password-protected post.' ),
1033                      ),
1034                      'protected' => array(
1035                          'type'        => 'boolean',
1036                          'description' => __( 'Whether the excerpt is protected with a password.' ),
1037                      ),
1038                  ),
1039                  'description'          => __( 'The post excerpt. Present when the post type supports excerpts.' ),
1040              ),
1041              'content'  => array(
1042                  'type'                 => 'object',
1043                  'additionalProperties' => false,
1044                  'properties'           => array(
1045                      'raw'       => array(
1046                          'type'        => 'string',
1047                          'description' => __( 'The raw, unfiltered post content (block markup). Present when the current user can edit the post.' ),
1048                      ),
1049                      'rendered'  => array(
1050                          'type'        => 'string',
1051                          'description' => __( 'The rendered post content. Empty when withheld for a password-protected post.' ),
1052                      ),
1053                      'protected' => array(
1054                          'type'        => 'boolean',
1055                          'description' => __( 'Whether the content is protected with a password.' ),
1056                      ),
1057                  ),
1058                  'description'          => __( 'The post content. Present when the post type supports the editor.' ),
1059              ),
1060              'author'   => array(
1061                  'type'                 => 'object',
1062                  'additionalProperties' => false,
1063                  'properties'           => array(
1064                      'id'   => array(
1065                          'type'        => 'integer',
1066                          'description' => __( 'The user ID of the author.' ),
1067                      ),
1068                      'name' => array(
1069                          'type'        => array( 'string', 'null' ),
1070                          'description' => __( "The author's display name, as the REST API users endpoint returns it in `name`. Null when the author no longer exists." ),
1071                      ),
1072                      'slug' => array(
1073                          'type'        => array( 'string', 'null' ),
1074                          'description' => __( "The author's user slug (nicename), as the REST API users endpoint returns it in `slug`, and as the `author_slug` filter takes it. Null when the author no longer exists." ),
1075                      ),
1076                  ),
1077                  'description'          => __( 'The post author. Present when the post type supports authors.' ),
1078              ),
1079              'parent'   => array(
1080                  'type'        => 'integer',
1081                  'description' => __( 'The parent post ID. Present for hierarchical post types.' ),
1082              ),
1083          );
1084      }
1085  
1086      /**
1087       * Returns the field paths the `fields` input accepts, in output order.
1088       *
1089       * Each post field is followed by the path of each of its sub-fields, such as `title`,
1090       * `title.raw` and `title.rendered`. Requesting a field returns all of its sub-fields
1091       * that apply, while a sub-field path returns only that sub-field, as the REST API's
1092       * `_fields` parameter does.
1093       *
1094       * @since 7.2.0
1095       *
1096       * @return list<string> The field paths.
1097       */
1098  	private function get_field_paths(): array {
1099          $paths = array();
1100          foreach ( $this->get_post_properties() as $name => $schema ) {
1101              $paths[] = $name;
1102  
1103              foreach ( array_keys( $schema['properties'] ?? array() ) as $sub_field ) {
1104                  $paths[] = "{$name}.{$sub_field}";
1105              }
1106          }
1107  
1108          return $paths;
1109      }
1110  
1111      /**
1112       * Builds the schema of the `fields` input, shared by all content abilities.
1113       *
1114       * @since 7.2.0
1115       *
1116       * @return array<string, mixed> The `fields` JSON Schema.
1117       */
1118  	private function get_fields_input_schema(): array {
1119          return array(
1120              'type'        => 'array',
1121              'uniqueItems' => true,
1122              'items'       => array(
1123                  'type' => 'string',
1124                  'enum' => $this->get_field_paths(),
1125              ),
1126              'description' => sprintf(
1127                  /* translators: %s: Comma-separated list of field paths. */
1128                  __( 'Limit each returned post to these fields. A field such as `title` returns all of its sub-fields the current user can see, while a path such as `title.rendered` returns only that sub-field. The `id` is always included. If omitted or empty, the default fields are returned: %s. Requested fields that do not apply to a post are left out of it: raw sub-fields, such as `content.raw`, when the current user cannot edit the post, and fields its post type does not support, such as `parent` for a post type that is not hierarchical. A returned post may therefore hold only its `id`. Password-protected posts differ: when the current user cannot edit one, its `excerpt.rendered` and `content.rendered` are returned as empty strings rather than left out, with `protected` set to true.' ),
1129                  '`' . implode( '`, `', self::DEFAULT_FIELDS ) . '`'
1130              ),
1131          );
1132      }
1133  
1134      /**
1135       * Builds the input schema for the `core/content-query` ability.
1136       *
1137       * The input has three members: `where` selects the posts, `fields` selects the fields
1138       * returned for each post, and `pagination` selects the page of results. `where` holds
1139       * the post type to query in `type`, and any of the filters `ids`, `slugs`, `status`,
1140       * `author_slug`, and `parent`, which all combine. Every object sets
1141       * `additionalProperties: false`, so an unknown key, such as a misspelled filter, fails
1142       * validation instead of being silently ignored.
1143       *
1144       * @since 7.2.0
1145       *
1146       * @param list<string> $post_types Exposed post type names.
1147       * @param list<string> $statuses   Requestable post status slugs.
1148       * @return array<string, mixed> The input JSON Schema.
1149       */
1150  	private function get_content_query_input_schema( array $post_types, array $statuses ): array {
1151          return array(
1152              'type'                 => 'object',
1153              'additionalProperties' => false,
1154              'required'             => array( 'where' ),
1155              'properties'           => array(
1156                  'where'      => array(
1157                      'type'                 => 'object',
1158                      'additionalProperties' => false,
1159                      'required'             => array( 'type' ),
1160                      'properties'           => array(
1161                          'type'        => array(
1162                              'type'        => 'string',
1163                              'enum'        => $post_types,
1164                              'description' => __( 'Post type to query for readable posts.' ),
1165                          ),
1166                          'ids'         => array(
1167                              'type'        => 'array',
1168                              'uniqueItems' => true,
1169                              'minItems'    => 1,
1170                              'maxItems'    => self::MAX_PER_PAGE,
1171                              'items'       => array(
1172                                  'type'    => 'integer',
1173                                  'minimum' => 1,
1174                              ),
1175                              'description' => __( 'Limit the query to these post IDs. The order of the IDs does not affect the order of the results. If `pagination.per_page` is omitted, every matching post is returned on the first page.' ),
1176                          ),
1177                          'slugs'       => array(
1178                              'type'        => 'array',
1179                              'uniqueItems' => true,
1180                              'minItems'    => 1,
1181                              'maxItems'    => self::MAX_PER_PAGE,
1182                              'items'       => array(
1183                                  'type'      => 'string',
1184                                  'minLength' => 1,
1185                              ),
1186                              'description' => __( 'Limit the query to posts with these slugs. Each slug is sanitized as a post slug is, so `Hello-World` matches `hello-world`. A slug can match several posts, such as pages under different parents, and every match is returned. The order of the slugs does not affect the order of the results. If `pagination.per_page` is omitted, every matching post is returned on the first page, up to the `per_page` maximum.' ),
1187                          ),
1188                          'status'      => array(
1189                              'type'        => 'array',
1190                              'uniqueItems' => true,
1191                              'minItems'    => 1,
1192                              'items'       => array(
1193                                  'type' => 'string',
1194                                  'enum' => $statuses,
1195                              ),
1196                              'description' => __( 'Filter readable posts by one or more post statuses. Defaults to publish, or, when `ids` or `slugs` is given, to every status the current user may query. Statuses that are not publicly viewable, other than private, require edit access.' ),
1197                          ),
1198                          'author_slug' => array(
1199                              'type'        => 'string',
1200                              'minLength'   => 1,
1201                              'description' => __( "Filter by the author's user slug (nicename), as the REST API users endpoint returns it in `slug`. Only supported for post types that support authors." ),
1202                          ),
1203                          'parent'      => array(
1204                              'type'        => 'integer',
1205                              'minimum'     => 0,
1206                              'description' => __( 'Filter by parent post ID. Only supported for hierarchical post types. Use 0 for top-level posts.' ),
1207                          ),
1208                      ),
1209                      'description'          => __( 'Selects the posts to return: the readable posts of a `type`, filtered by any of `ids`, `slugs`, `status`, `author_slug`, and `parent`. A post must match every given filter.' ),
1210                  ),
1211                  'fields'     => $this->get_fields_input_schema(),
1212                  'pagination' => array(
1213                      'type'                 => 'object',
1214                      'additionalProperties' => false,
1215                      'properties'           => array(
1216                          'page'     => array(
1217                              'type'        => 'integer',
1218                              'minimum'     => 1,
1219                              'description' => __( 'Page of results to return. Defaults to 1. Requesting a page beyond the last one is an error, unless no post matches the query: then any page returns empty `items` and zero totals. Check `pagination.total_pages` in the result before requesting later pages.' ),
1220                          ),
1221                          'per_page' => array(
1222                              'type'        => 'integer',
1223                              'minimum'     => 1,
1224                              'maximum'     => self::MAX_PER_PAGE,
1225                              'description' => sprintf(
1226                                  /* translators: 1: Default number of posts per page, 2: Maximum number of posts per page. */
1227                                  __( 'Maximum number of posts to return per page. Defaults to %1$d, or to %2$d when `where.ids` or `where.slugs` is given.' ),
1228                                  self::DEFAULT_PER_PAGE,
1229                                  self::MAX_PER_PAGE
1230                              ),
1231                          ),
1232                      ),
1233                      'description'          => __( 'Selects the page of results to return.' ),
1234                  ),
1235              ),
1236          );
1237      }
1238  
1239      /**
1240       * Builds the output schema of a single post, shared by all content abilities.
1241       *
1242       * Only `id` is required, because it is always returned. The other fields are optional
1243       * because the `fields` input lets the caller request any subset, and a field is only
1244       * present when its post type supports it.
1245       *
1246       * @since 7.2.0
1247       *
1248       * @return array<string, mixed> The post JSON Schema.
1249       */
1250  	private function get_content_output_schema(): array {
1251          return array(
1252              'type'                 => 'object',
1253              'additionalProperties' => false,
1254              'required'             => array( 'id' ),
1255              'properties'           => $this->get_post_properties(),
1256          );
1257      }
1258  
1259      /**
1260       * Builds the output schema for the `core/content-query` ability.
1261       *
1262       * Every request returns the same shape: a page of posts in `items`, and the totals
1263       * across all pages in `pagination`.
1264       *
1265       * @since 7.2.0
1266       *
1267       * @return array<string, mixed> The output JSON Schema.
1268       */
1269  	private function get_content_query_output_schema(): array {
1270          return array(
1271              'type'                 => 'object',
1272              'additionalProperties' => false,
1273              'required'             => array( 'items', 'pagination' ),
1274              'properties'           => array(
1275                  'items'      => array(
1276                      'type'        => 'array',
1277                      'description' => __( 'The readable posts on the requested page, ordered by post date, newest first. Can be empty even when `pagination.total` is not zero, because the totals can count matching posts the current user cannot read.' ),
1278                      'items'       => $this->get_content_output_schema(),
1279                  ),
1280                  'pagination' => array(
1281                      'type'                 => 'object',
1282                      'additionalProperties' => false,
1283                      'required'             => array( 'total', 'total_pages' ),
1284                      'properties'           => array(
1285                          'total'       => array(
1286                              'type'        => 'integer',
1287                              'description' => __( 'Total number of posts matching the underlying query, across all pages. May exceed the number of returned posts when row-level permission checks withhold some of them.' ),
1288                          ),
1289                          'total_pages' => array(
1290                              'type'        => 'integer',
1291                              'description' => __( 'Total number of query result pages available for the underlying query. May include pages whose rows are withheld by row-level permission checks.' ),
1292                          ),
1293                      ),
1294                      'description'          => __( 'The totals of the request, across all pages.' ),
1295                  ),
1296              ),
1297          );
1298      }
1299  
1300      /**
1301       * Formats a post into the ability output shape.
1302       *
1303       * As the REST posts controller does, the post is set up as the global post while its
1304       * fields are built, so filters that rely on loop globals, including title filters, see
1305       * the requested post. For an editor of a password-protected post, the cookie-based
1306       * password gate is also suspended, so rendered fields resolve to real values instead of
1307       * protected-post placeholders. The field projection itself is delegated to
1308       * {@see self::build_post_fields()}.
1309       *
1310       * @since 7.2.0
1311       *
1312       * @param WP_Post      $post   The post object.
1313       * @param list<string> $fields The requested field paths.
1314       * @return array<string, mixed> The formatted post data.
1315       */
1316  	private function format_post( WP_Post $post, array $fields ): array {
1317          $can_edit          = current_user_can( 'edit_post', $post->ID );
1318          $password_required = post_password_required( $post );
1319          $unlock_password   = $password_required && $can_edit;
1320          $previous_context  = $this->get_post_context();
1321          $the_post_fired    = did_action( 'the_post' ) > 0;
1322  
1323          /*
1324           * The filter unlocks only posts the current user can edit, mirroring the REST posts
1325           * controller's check_password_required(): an unconditional bypass (e.g. __return_false)
1326           * would also expose other protected posts that the content filter may render, such as
1327           * posts pulled in by a Query Loop block. The closure is kept in a variable, so the same
1328           * instance can be removed again.
1329           */
1330          $allow_password_content = function ( $required, $checked_post ): bool {
1331              return $this->allow_password_content( $required, $checked_post );
1332          };
1333  
1334          /*
1335           * Set up both inside the try block and undo them in the finally block, so a throw from
1336           * a `the_post` callback or mid-render cannot leave the password gate disabled or the
1337           * global post pointing at this post for the rest of the request.
1338           */
1339          try {
1340              $this->set_up_post_context( $post );
1341              if ( $unlock_password ) {
1342                  add_filter( 'post_password_required', $allow_password_content, 10, 2 );
1343              }
1344  
1345              return $this->build_post_fields( $post, $fields, $can_edit, $password_required && ! $can_edit );
1346          } finally {
1347              if ( $unlock_password ) {
1348                  remove_filter( 'post_password_required', $allow_password_content, 10 );
1349              }
1350  
1351              $this->restore_post_context( $previous_context, $the_post_fired );
1352          }
1353      }
1354  
1355      /**
1356       * Builds the requested field projection for a post.
1357       *
1358       * Fields are selected as the REST API's `_fields` parameter selects them, with
1359       * {@see self::is_field_requested()}. The `id` is always included, whether or not it
1360       * was requested, so every returned post can be identified. This also means a post is
1361       * never empty, so it always encodes as a JSON object.
1362       *
1363       * Only the fields that the post type supports and the current user can see are included,
1364       * and a field none of whose requested sub-fields remain is left out, so it never encodes
1365       * as an empty JSON array. Raw sub-fields are edit-context fields, included only when the
1366       * current user can edit the post, whether they were requested on their own or through
1367       * their field. Rendered sub-fields are read-context fields and are withheld for
1368       * password-protected posts unless the current user can edit the post, mirroring the REST
1369       * API behavior.
1370       *
1371       * @since 7.2.0
1372       *
1373       * @param WP_Post      $post         The post object.
1374       * @param list<string> $fields       The requested field paths.
1375       * @param bool         $can_edit     Whether the current user can edit the post.
1376       * @param bool         $is_protected Whether rendered fields must be withheld as password-protected.
1377       * @return array<string, mixed> The formatted post data.
1378       */
1379  	private function build_post_fields( WP_Post $post, array $fields, bool $can_edit, bool $is_protected ): array {
1380          $post_type = $post->post_type;
1381          $data      = array( 'id' => (int) $post->ID );
1382  
1383          if ( $this->is_field_requested( 'type', $fields ) ) {
1384              $data['type'] = $post_type;
1385          }
1386          if ( $this->is_field_requested( 'status', $fields ) ) {
1387              $data['status'] = $post->post_status;
1388          }
1389  
1390          foreach ( array( 'date', 'modified' ) as $field ) {
1391              $dates = array();
1392              if ( $this->is_field_requested( "{$field}.local", $fields ) ) {
1393                  $dates['local'] = $this->format_date( $post, $field, false );
1394              }
1395              if ( $this->is_field_requested( "{$field}.gmt", $fields ) ) {
1396                  $dates['gmt'] = $this->format_date( $post, $field, true );
1397              }
1398              if ( ! empty( $dates ) ) {
1399                  $data[ $field ] = $dates;
1400              }
1401          }
1402  
1403          if ( $this->is_field_requested( 'slug', $fields ) ) {
1404              $data['slug'] = $post->post_name;
1405          }
1406          if ( $this->is_field_requested( 'link', $fields ) ) {
1407              $data['link'] = (string) get_permalink( $post );
1408          }
1409  
1410          /*
1411           * The rendered fields share a shape: `raw` for a user who can edit the post,
1412           * `rendered`, and `protected` for the excerpt and content, whose rendered values are
1413           * withheld for a password-protected post. The title is never withheld, so it has no
1414           * `protected` sub-field. Each field lists the feature the post type must support, the
1415           * post property holding the raw value, the method rendering it, and whether it can be
1416           * protected.
1417           */
1418          $rendered_fields = array(
1419              'title'   => array( 'title', 'post_title', 'get_title', false ),
1420              'excerpt' => array( 'excerpt', 'post_excerpt', 'get_rendered_excerpt', true ),
1421              'content' => array( 'editor', 'post_content', 'get_rendered_content', true ),
1422          );
1423          foreach ( $rendered_fields as $field => $spec ) {
1424              list( $feature, $property, $render, $protectable ) = $spec;
1425              if ( ! $this->is_field_requested( $field, $fields ) || ! post_type_supports( $post_type, $feature ) ) {
1426                  continue;
1427              }
1428  
1429              $value = array();
1430              if ( $can_edit && $this->is_field_requested( "{$field}.raw", $fields ) ) {
1431                  $value['raw'] = $post->$property;
1432              }
1433              if ( $this->is_field_requested( "{$field}.rendered", $fields ) ) {
1434                  $value['rendered'] = ( $protectable && $is_protected ) ? '' : $this->$render( $post );
1435              }
1436              if ( $protectable && $this->is_field_requested( "{$field}.protected", $fields ) ) {
1437                  $value['protected'] = (bool) $post->post_password;
1438              }
1439              if ( ! empty( $value ) ) {
1440                  $data[ $field ] = $value;
1441              }
1442          }
1443  
1444          if ( $this->is_field_requested( 'author', $fields ) && post_type_supports( $post_type, 'author' ) ) {
1445              $user   = get_userdata( (int) $post->post_author );
1446              $author = array();
1447              if ( $this->is_field_requested( 'author.id', $fields ) ) {
1448                  $author['id'] = (int) $post->post_author;
1449              }
1450              if ( $this->is_field_requested( 'author.name', $fields ) ) {
1451                  $author['name'] = $user ? $user->display_name : null;
1452              }
1453              if ( $this->is_field_requested( 'author.slug', $fields ) ) {
1454                  $author['slug'] = $user ? $user->user_nicename : null;
1455              }
1456              if ( ! empty( $author ) ) {
1457                  $data['author'] = $author;
1458              }
1459          }
1460  
1461          if ( $this->is_field_requested( 'parent', $fields ) && is_post_type_hierarchical( $post_type ) ) {
1462              $data['parent'] = (int) $post->post_parent;
1463          }
1464  
1465          return $data;
1466      }
1467  
1468      /**
1469       * Filters {@see post_password_required()} to unlock only posts the current user can edit.
1470       *
1471       * Added by {@see self::format_post()} while formatting a password-protected post the
1472       * current user can edit, so rendered fields resolve to real values without also unlocking
1473       * other protected posts that the content filter may render. Mirrors the REST posts
1474       * controller's check_password_required().
1475       *
1476       * @since 7.2.0
1477       *
1478       * @param mixed $required Whether the post currently requires a password.
1479       * @param mixed $post     The post being checked; a WP_Post when invoked by the core filter.
1480       * @return bool Whether the post still requires a password.
1481       */
1482  	private function allow_password_content( $required, $post ): bool {
1483          if ( ! $required || ! $post instanceof WP_Post ) {
1484              return (bool) $required;
1485          }
1486  
1487          return ! current_user_can( 'edit_post', $post->ID );
1488      }
1489  
1490      /**
1491       * Returns the post title with the protected/private prefixes stripped.
1492       *
1493       * @since 7.2.0
1494       *
1495       * @param WP_Post $post The post object.
1496       * @return string The post title.
1497       */
1498  	private function get_title( WP_Post $post ): string {
1499          $strip = static function (): string {
1500              return '%s';
1501          };
1502          add_filter( 'protected_title_format', $strip );
1503          add_filter( 'private_title_format', $strip );
1504  
1505          /*
1506           * The format filters are removed in a finally block so a throw from a title
1507           * filter cannot leave them attached for the rest of the request.
1508           */
1509          try {
1510              $title = get_the_title( $post );
1511  
1512              /*
1513               * A title filter that returns a non-string would fail the return type, so guard
1514               * it as the excerpt and content are.
1515               */
1516              return is_string( $title ) ? $title : '';
1517          } finally {
1518              remove_filter( 'protected_title_format', $strip );
1519              remove_filter( 'private_title_format', $strip );
1520          }
1521      }
1522  
1523      /**
1524       * Returns the post excerpt transformed for display.
1525       *
1526       * Applies the `get_the_excerpt` and `the_excerpt` filter chains, as the REST posts
1527       * controller does. {@see self::format_post()} has set the post up as the global post.
1528       *
1529       * @since 7.2.0
1530       *
1531       * @param WP_Post $post The post object.
1532       * @return string Rendered post excerpt.
1533       */
1534  	private function get_rendered_excerpt( WP_Post $post ): string {
1535          /** This filter is documented in wp-includes/post-template.php */
1536          $excerpt = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post );
1537  
1538          /** This filter is documented in wp-includes/post-template.php */
1539          $excerpt = apply_filters( 'the_excerpt', $excerpt );
1540  
1541          return is_string( $excerpt ) ? $excerpt : '';
1542      }
1543  
1544      /**
1545       * Returns post content transformed for display.
1546       *
1547       * Applies `the_content`, as the REST posts controller does. {@see self::format_post()}
1548       * has set the post up as the global post.
1549       *
1550       * @since 7.2.0
1551       *
1552       * @param WP_Post $post The post object.
1553       * @return string Rendered post content.
1554       */
1555  	private function get_rendered_content( WP_Post $post ): string {
1556          /** This filter is documented in wp-includes/post-template.php */
1557          $content = apply_filters( 'the_content', $post->post_content );
1558  
1559          return is_string( $content ) ? $content : '';
1560      }
1561  
1562      /**
1563       * Returns the global post context, so it can be restored after a post is rendered.
1564       *
1565       * @since 7.2.0
1566       *
1567       * @return array<string, mixed> The loop globals, keyed by name, leaving out those that are not set.
1568       */
1569  	private function get_post_context(): array {
1570          /*
1571           * Copy each global by value. A calling function that binds a global with `global`,
1572           * as load_template() and WP_Block::render() do, makes it a reference, which
1573           * array_intersect_key( $GLOBALS, ... ) would keep. The saved copy would then follow
1574           * the global to the rendered post, and the restore would put that post back.
1575           */
1576          $context = array();
1577          foreach ( self::LOOP_GLOBALS as $name ) {
1578              if ( ! array_key_exists( $name, $GLOBALS ) ) {
1579                  continue;
1580              }
1581  
1582              $context[ $name ] = $GLOBALS[ $name ];
1583          }
1584  
1585          return $context;
1586      }
1587  
1588      /**
1589       * Sets up the global post context for rendering a post.
1590       *
1591       * Sets the global post and calls setup_postdata(), as the REST posts controller does
1592       * before it renders a post, so filters that rely on loop globals render against the
1593       * requested post.
1594       *
1595       * @since 7.2.0
1596       *
1597       * @param WP_Post $post The post to render.
1598       */
1599  	private function set_up_post_context( WP_Post $post ): void {
1600          // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for rendering.
1601          $GLOBALS['post'] = $post;
1602          setup_postdata( $post );
1603      }
1604  
1605      /**
1606       * Restores the global post context saved by {@see self::get_post_context()}.
1607       *
1608       * Each loop global gets its previous value back, or is unset again when it was not set.
1609       * wp_reset_postdata() alone is not enough: it does nothing when the main query has no
1610       * post, which would leave the rendered post's data in the globals. When a post was set
1611       * up before, setup_postdata() first runs for it again, as wp_reset_postdata() would, so
1612       * callbacks on the `the_post` action can restore their own globals too. The saved values
1613       * are put back even when such a callback throws. A global post that was never set up
1614       * keeps the loop globals that setup_postdata() gives it. The `the_post` count is reset
1615       * only when the ability was the first thing to fire that action and no global post was
1616       * set, so rendering a post before the main query does not count as starting the loop.
1617       *
1618       * @since 7.2.0
1619       *
1620       * @param array<string, mixed> $previous_context The loop globals that get_post_context() returned.
1621       * @param bool                 $the_post_fired   Whether the `the_post` action had fired before the ability set up the post.
1622       */
1623  	private function restore_post_context( array $previous_context, bool $the_post_fired ): void {
1624          $previous_post = $previous_context['post'] ?? null;
1625  
1626          /*
1627           * A global post that was never set up, such as the main post before the loop
1628           * starts, keeps what setup_postdata() gives it below. Do not put back the values
1629           * saved before: `the_post` has fired then, so get_the_content() called without a
1630           * post, as the Post Content block and the_content() outside the loop do, reads the
1631           * loop globals instead of the post, and an unset `$pages` makes it throw a
1632           * TypeError that breaks the page being rendered.
1633           */
1634          $restore_saved_values = ! $previous_post instanceof WP_Post || is_array( $previous_context['pages'] ?? null );
1635  
1636          try {
1637              if ( $previous_post instanceof WP_Post ) {
1638                  // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context.
1639                  $GLOBALS['post'] = $previous_post;
1640                  setup_postdata( $previous_post );
1641              }
1642          } finally {
1643              if ( $restore_saved_values ) {
1644                  foreach ( self::LOOP_GLOBALS as $name ) {
1645                      if ( array_key_exists( $name, $previous_context ) ) {
1646                          $GLOBALS[ $name ] = $previous_context[ $name ];
1647                      } else {
1648                          unset( $GLOBALS[ $name ] );
1649                      }
1650                  }
1651              }
1652  
1653              /*
1654               * Only reset the `the_post` count when the ability was the first thing to fire it
1655               * and no global post was set. Setting up the rendered post counted the action, and
1656               * once it has fired, get_the_content() called without a post reads the loop globals
1657               * unset above instead of the post. A global post set later without being set up,
1658               * as WP::register_globals() sets the main post after the ability runs on `init`,
1659               * would then make it throw a TypeError. When a loop had already fired the action,
1660               * or a post was set, the count is left alone.
1661               */
1662              if ( ! $previous_post instanceof WP_Post && ! $the_post_fired ) {
1663                  unset( $GLOBALS['wp_actions']['the_post'] );
1664              }
1665          }
1666      }
1667  
1668      /**
1669       * Formats a post date field as an ISO 8601 string, in the site's timezone or in GMT.
1670       *
1671       * In GMT, it reads the stored GMT date, deriving it from the local date when it is
1672       * missing (e.g. drafts), mirroring the REST posts controller.
1673       *
1674       * @since 7.2.0
1675       *
1676       * @param WP_Post $post  The post object.
1677       * @param string  $field Either 'date' or 'modified'.
1678       * @param bool    $gmt   Whether to format the date in GMT instead of the site's timezone.
1679       * @return string|null The ISO 8601 date, or null if unavailable.
1680       */
1681  	private function format_date( WP_Post $post, string $field, bool $gmt ): ?string {
1682          $datetime = $gmt ? get_post_datetime( $post, $field, 'gmt' ) : false;
1683          if ( ! $datetime ) {
1684              $datetime = get_post_datetime( $post, $field );
1685          }
1686  
1687          if ( ! $datetime ) {
1688              return null;
1689          }
1690  
1691          /*
1692           * A malformed stored date can still parse: a zero month formats with a negative
1693           * year. The `date-time` format rejects it, which would fail output validation for
1694           * the whole call, so it is reported as null too.
1695           */
1696          $date = ( $gmt ? $datetime->setTimezone( new DateTimeZone( 'UTC' ) ) : $datetime )->format( 'c' );
1697  
1698          return rest_parse_date( $date ) ? $date : null;
1699      }
1700  
1701      /**
1702       * Builds the ability output for a page of formatted posts.
1703       *
1704       * @since 7.2.0
1705       *
1706       * @param array<int, array<string, mixed>> $items       The formatted posts on the page.
1707       * @param int                              $total       Total number of posts across all pages.
1708       * @param int                              $total_pages Total number of pages.
1709       * @return array<string, mixed> The ability output.
1710       */
1711  	private function prepare_output( array $items, int $total, int $total_pages ): array {
1712          return array(
1713              'items'      => $items,
1714              'pagination' => array(
1715                  'total'       => $total,
1716                  'total_pages' => $total_pages,
1717              ),
1718          );
1719      }
1720  
1721      /**
1722       * Builds the error for a page past the last page of results.
1723       *
1724       * A page that does not exist is reported as not found rather than as an empty list,
1725       * so callers can tell it apart from a request that matches no posts.
1726       *
1727       * @since 7.2.0
1728       *
1729       * @return WP_Error The invalid page number error.
1730       */
1731  	private function invalid_page_number_error(): WP_Error {
1732          return new WP_Error(
1733              'content_invalid_page_number',
1734              __( 'The page number requested is larger than the number of pages available.' ),
1735              array( 'status' => 404 )
1736          );
1737      }
1738  
1739      /**
1740       * Builds the error for a filter that cannot be honored.
1741       *
1742       * As in the REST API's `rest_invalid_param` errors, the error data maps the filter's
1743       * input path to the message under `params`, so callers can tell which filter failed
1744       * without parsing the translated message.
1745       *
1746       * @since 7.2.0
1747       *
1748       * @param string $param   The filter's input path, such as `where[ids]`.
1749       * @param string $message The error message.
1750       * @return WP_Error The invalid filter error.
1751       */
1752  	private function invalid_filter_error( string $param, string $message ): WP_Error {
1753          return new WP_Error(
1754              'content_invalid_filter',
1755              $message,
1756              array(
1757                  'status' => 400,
1758                  'params' => array( $param => $message ),
1759              )
1760          );
1761      }
1762  }


Generated : Sat Oct 17 08:20:33 2026 Cross-referenced by PHPXref