| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Sat Oct 17 08:20:33 2026 | Cross-referenced by PHPXref |