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