| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 <?php 2 /** 3 * Abilities API: WP_Abilities_Users 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 user-related abilities. 14 * 15 * Registers the read-only `core/users-query` ability, which retrieves a paginated 16 * list of readable WordPress users, optionally filtered by IDs, slugs, usernames, 17 * roles, or published-post authorship. Field-level access is enforced per user by 18 * omitting fields the current user cannot view. 19 * 20 * Unlike the core abilities registered directly in wp_register_core_abilities() as 21 * self-contained closures, the users ability lives in a dedicated class 22 * because its callbacks and schemas share helpers: the permission and execute 23 * callbacks read the input through the same code, and the input schema, output 24 * schema, and field normalization are built from the same field definitions. 25 * Future write-oriented user 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/users-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_Users { 37 38 /** 39 * The ability category used for user abilities. 40 * 41 * @since 7.2.0 42 * @var string 43 */ 44 private const CATEGORY = 'user'; 45 46 /** 47 * Default number of users returned per page, unless `where` lists users by ID, slug, or username. 48 * 49 * @since 7.2.0 50 * @var int 51 */ 52 private const DEFAULT_PER_PAGE = 10; 53 54 /** 55 * Maximum number of users returned per page. 56 * 57 * Each identifier list in `where` holds at most this many values, so the users it 58 * 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 * The WP_User_Query argument that each identifier list in `where` sets. 67 * 68 * The lists filter the results but do not order them, as in the REST users controller, 69 * so results keep WP_User_Query's default order, by login. 70 * 71 * @since 7.2.0 72 * @var array<string, string> 73 */ 74 private const IDENTIFIER_LISTS = array( 75 'ids' => 'include', 76 'slugs' => 'nicename__in', 77 'usernames' => 'login__in', 78 ); 79 80 /** 81 * Default fields returned when the caller does not request a field subset. 82 * 83 * @since 7.2.0 84 * @var string[] 85 */ 86 private const DEFAULT_FIELDS = array( 87 'id', 88 'name', 89 'link', 90 'slug', 91 'avatar_urls', 92 ); 93 94 /** 95 * Registers all user abilities. 96 * 97 * Must run on the `wp_abilities_api_init` hook. 98 * 99 * @since 7.2.0 100 */ 101 public function register(): void { 102 $this->register_users_query(); 103 } 104 105 /** 106 * Registers the read-only `core/users-query` ability. 107 * 108 * Unlike `core/get-user-info`, which only returns the current user's own profile, 109 * this ability is generic and reads any user the current user is allowed to see. 110 * 111 * @since 7.2.0 112 */ 113 private function register_users_query(): void { 114 wp_register_ability( 115 'core/users-query', 116 array( 117 'label' => __( 'Query Users' ), 118 'description' => __( 'Retrieves a paginated list of readable WordPress users, optionally filtered by IDs, slugs, usernames, roles, or published-post authorship. Callers without permission to list users only see public authors. To read your own profile, use `core/get-user-info`. Requires an authenticated user.' ), 119 'category' => self::CATEGORY, 120 'input_schema' => $this->get_users_query_input_schema(), 121 'output_schema' => $this->get_users_query_output_schema(), 122 'execute_callback' => function ( $input = array() ) { 123 return $this->execute_users_query( $input ); 124 }, 125 'permission_callback' => function ( $input = array() ): bool { 126 return $this->check_permission( $input ); 127 }, 128 'meta' => array( 129 'annotations' => array( 130 'readonly' => true, 131 'destructive' => false, 132 'idempotent' => true, 133 ), 134 'public' => true, 135 ), 136 ) 137 ); 138 } 139 140 /** 141 * Permission callback for the `core/users-query` ability. 142 * 143 * Performs request-level checks only, and never looks users up. Which users are 144 * returned is decided by the query arguments in {@see self::execute_users_query()}. 145 * 146 * @since 7.2.0 147 * 148 * @param mixed $input Optional. The ability input. Default empty array. 149 * @return bool True if the request may proceed, false otherwise. 150 */ 151 private function check_permission( $input = array() ): bool { 152 $input = rest_sanitize_object( $input ); 153 154 if ( ! is_user_logged_in() ) { 155 return false; 156 } 157 158 /* 159 * A `where` that is not an object is let through, so that the execute callback can 160 * report it as an invalid filter. 161 */ 162 $where = $this->get_input_object( $input, 'where' ) ?? array(); 163 164 // Roles and usernames are not public, so only callers who can list users may filter by them. 165 return ( empty( $where['roles'] ) && empty( $where['usernames'] ) ) || current_user_can( 'list_users' ); 166 } 167 168 /** 169 * Executes the `core/users-query` ability. 170 * 171 * @since 7.2.0 172 * 173 * @param mixed $input Optional. The ability input. Default empty array. 174 * @return array<string, mixed>|WP_Error A page of user data, or a WP_Error on failure. 175 */ 176 private function execute_users_query( $input = array() ) { 177 $input = rest_sanitize_object( $input ); 178 $fields = $this->normalize_fields( $input ); 179 180 /* 181 * A `where` that is not an object cannot be honored. Reject it rather than reading it 182 * as no filter at all, which would widen the request to every readable user. 183 */ 184 $where = $this->get_input_object( $input, 'where' ); 185 if ( null === $where ) { 186 return $this->invalid_filter_error( 'where', __( 'The where filter must be an object.' ) ); 187 } 188 189 // Pagination that is not an object falls back to the defaults, like page values that cannot be parsed. 190 $pagination = $this->get_input_object( $input, 'pagination' ) ?? array(); 191 $page = $this->parse_filter_int( $pagination['page'] ?? 1, 1 ) ?? 1; 192 193 /* 194 * On transports that skip schema validation, a filter that cannot be honored must 195 * not be dropped, which would silently widen the query. Reject it instead, so the 196 * query fails closed, as the content query does with its filters. WP_User_Query 197 * ignores an empty list, so a list with no valid value left would return every user. 198 */ 199 $query_args = array(); 200 foreach ( self::IDENTIFIER_LISTS as $key => $query_var ) { 201 if ( ! isset( $where[ $key ] ) ) { 202 continue; 203 } 204 205 $query_args[ $query_var ] = 'ids' === $key ? $this->normalize_id_list( $where[ $key ] ) : $this->normalize_string_list( $where[ $key ] ); 206 if ( empty( $query_args[ $query_var ] ) ) { 207 return $this->invalid_filter_error( 208 "where[{$key}]", 209 /* translators: %s: The name of the filter, such as ids. */ 210 sprintf( __( 'The %s filter must list one or more valid values.' ), $key ) 211 ); 212 } 213 } 214 215 /* 216 * An explicit `per_page` always wins. Otherwise a request that lists users by ID, slug, 217 * or username returns every matching user on the first page, since the input schema 218 * caps each list at the maximum page size. 219 */ 220 $default_per_page = empty( $query_args ) ? self::DEFAULT_PER_PAGE : self::MAX_PER_PAGE; 221 $per_page = min( self::MAX_PER_PAGE, $this->parse_filter_int( $pagination['per_page'] ?? null, 1 ) ?? $default_per_page ); 222 $query_args['number'] = $per_page; 223 $query_args['paged'] = $page; 224 225 if ( isset( $where['roles'] ) ) { 226 $query_args['role__in'] = $this->normalize_string_list( $where['roles'] ); 227 if ( empty( $query_args['role__in'] ) ) { 228 return $this->invalid_filter_error( 'where[roles]', __( 'The roles filter must list one or more role names.' ) ); 229 } 230 } 231 232 /* 233 * The permission callback already refuses the role and username filters to callers 234 * who cannot list users. Refuse them here too, rather than dropping them, which would 235 * widen the query to every public author, so a direct call fails closed. 236 */ 237 $can_list_users = current_user_can( 'list_users' ); 238 if ( isset( $where['roles'] ) && ! $can_list_users ) { 239 return new WP_Error( 240 'users_cannot_filter_by_role', 241 __( 'Sorry, you are not allowed to filter users by role.' ), 242 array( 'status' => rest_authorization_required_code() ) 243 ); 244 } 245 246 if ( isset( $where['usernames'] ) && ! $can_list_users ) { 247 return new WP_Error( 248 'users_cannot_filter_by_username', 249 __( 'Sorry, you are not allowed to filter users by username.' ), 250 array( 'status' => rest_authorization_required_code() ) 251 ); 252 } 253 254 $has_published_posts = $this->normalize_has_published_posts( $where ); 255 if ( array_key_exists( 'has_published_posts', $where ) && null === $has_published_posts ) { 256 return $this->invalid_filter_error( 'where[has_published_posts]', __( 'The has_published_posts filter must be true or list one or more post type names.' ) ); 257 } 258 259 /* 260 * Callers who cannot list users only see public authors, as in the REST users 261 * controller, so the filter is always applied for them. This intentionally excludes 262 * the caller's own account when they have no published posts, which 263 * `core/get-user-info` returns instead. 264 */ 265 if ( null !== $has_published_posts || ! $can_list_users ) { 266 /* 267 * The post types are always resolved here rather than passed as `true`. 268 * WP_User_Query reads `true` as `get_post_types( array( 'public' => true ) )`, 269 * which is not the same as the publicly viewable set the rest of the ability 270 * uses. Resolving here also picks up post types registered or unregistered 271 * after the input schema was built. 272 */ 273 $public_post_types = $this->get_public_post_types(); 274 275 $has_published_posts = is_array( $has_published_posts ) 276 ? array_values( array_intersect( $public_post_types, $has_published_posts ) ) 277 : $public_post_types; 278 279 if ( empty( $has_published_posts ) ) { 280 return $this->prepare_output( array(), 0, 0 ); 281 } 282 283 $query_args['has_published_posts'] = $has_published_posts; 284 } 285 286 $query = new WP_User_Query( $query_args ); 287 288 /* 289 * The rows and the totals come from the same query, so they agree. As in the REST 290 * users controller, rows are not filtered by site membership afterwards: on 291 * multisite, WP_User_Query already limits the query to members of the current site. 292 */ 293 $total_users = $query->get_total(); 294 $total_pages = (int) ceil( $total_users / $per_page ); 295 296 /* 297 * A page past the last one does not exist, so report it as not found instead of 298 * returning a bare empty list. A genuinely empty result set still returns zero 299 * totals and no error. 300 */ 301 if ( $total_users > 0 && $page > $total_pages ) { 302 return $this->invalid_page_number_error(); 303 } 304 305 $users = array(); 306 foreach ( $query->get_results() as $user ) { 307 if ( ! $user instanceof WP_User ) { 308 continue; 309 } 310 311 $users[] = $this->format_user( $user, $fields ); 312 } 313 314 return $this->prepare_output( $users, $total_users, $total_pages ); 315 } 316 317 /** 318 * Reads an object member of the input, such as `where` or `pagination`. 319 * 320 * Accepts what schema validation reads as an object: an array, a `stdClass`, or an 321 * empty string. Only the REST run controller converts input to the schema types, so 322 * callers that bypass it, such as a direct WP_Ability::execute() call, can pass any of them. 323 * 324 * @since 7.2.0 325 * 326 * @param array<mixed> $input The ability input. 327 * @param string $key The member name. 328 * @return array<mixed>|null The member as an array, an empty array when it is omitted, 329 * or null when it is not an object. 330 */ 331 private function get_input_object( array $input, string $key ): ?array { 332 if ( ! array_key_exists( $key, $input ) ) { 333 return array(); 334 } 335 336 return rest_is_object( $input[ $key ] ) ? rest_sanitize_object( $input[ $key ] ) : null; 337 } 338 339 /** 340 * Parses a raw input value into an integer of at least a minimum, or null when invalid. 341 * 342 * Accepts the whole numbers that the integer schema accepts: native integers, floats such 343 * as the 2.0 that JSON encoders produce, and integer strings such as "2", "2.0", or "+2". 344 * Only the REST run controller converts input to the schema types, so other callers, such 345 * as the MCP adapter or a direct WP_Ability::execute() call, pass them on unconverted. 346 * Anything else, such as a fraction, is rejected rather than coerced: a malformed ID would 347 * otherwise resolve to another user. 348 * 349 * @since 7.2.0 350 * 351 * @param mixed $value The raw input value. 352 * @param int $min The smallest acceptable value. 353 * @return int|null The parsed integer, or null when the value is not an integer >= $min. 354 */ 355 private function parse_filter_int( $value, int $min ): ?int { 356 if ( is_int( $value ) ) { 357 return $value >= $min ? $value : null; 358 } 359 360 if ( is_string( $value ) && '' !== $value && ctype_digit( $value ) ) { 361 $int = (int) $value; 362 363 return $int >= $min ? $int : null; 364 } 365 366 if ( ! rest_is_integer( $value ) ) { 367 return null; 368 } 369 370 /* 371 * Other whole numbers are read as floats, which hold every integer only up to 2 ** 53. 372 * Past that, the value may not be the one that was sent, and casting a float beyond the 373 * integer range wraps it to an arbitrary integer, such as the ID of another user. 374 */ 375 $number = (float) $value; 376 377 return $number >= $min && $number <= 2 ** 53 ? (int) $number : null; 378 } 379 380 /** 381 * Returns publicly viewable post types. 382 * 383 * Uses {@see is_post_type_viewable()} rather than the `public` registration 384 * argument, since `public` alone does not guarantee a post type is viewable 385 * on the front end. Deliberately resolved on every call rather than cached: 386 * post types can be unregistered or re-registered with different arguments 387 * between the ability being registered and the ability being used. 388 * 389 * @since 7.2.0 390 * 391 * @return string[] Publicly viewable post type names. 392 */ 393 private function get_public_post_types(): array { 394 return array_values( array_filter( get_post_types(), 'is_post_type_viewable' ) ); 395 } 396 397 /** 398 * Returns the requested field paths, or a lean default set when none are given. 399 * 400 * An empty or absent `fields` value selects a lean set of common read fields. 401 * Otherwise the requested paths are returned. The input schema has already 402 * validated them against the paths from {@see self::get_field_paths()} before the 403 * ability executes, and {@see self::format_user()} ignores any other path. 404 * 405 * The `id` field is always included, matching the REST users controller 406 * where `id` is present in every context. This also guarantees the result 407 * is never empty, so it always serializes as a JSON object. 408 * 409 * @since 7.2.0 410 * 411 * @param array<mixed> $input The ability input. 412 * @return string[] List of requested field paths. 413 */ 414 private function normalize_fields( array $input ): array { 415 $fields = isset( $input['fields'] ) ? $this->normalize_string_list( $input['fields'] ) : array(); 416 if ( empty( $fields ) ) { 417 $fields = self::DEFAULT_FIELDS; 418 } 419 420 if ( ! in_array( 'id', $fields, true ) ) { 421 array_unshift( $fields, 'id' ); 422 } 423 424 return $fields; 425 } 426 427 /** 428 * Returns the user field definitions, keyed by field name in output order. 429 * 430 * This is the single source of truth for the ability's user fields: the output 431 * schema uses the definitions directly, while the input schema `fields` enum lists 432 * each field, followed by the path of each of its sub-fields, such as 433 * `registered.gmt`. The field set is deliberately unconditional: 434 * the registered schemas are a registration-time snapshot, so conditional 435 * availability (such as `avatar_urls` honoring the `show_avatars` option) is 436 * enforced per call in {@see self::format_user()} instead of here, where the 437 * option could change between registration and use. 438 * 439 * @since 7.2.0 440 * 441 * @return array<string, mixed> User field definitions. 442 */ 443 private function get_user_properties(): array { 444 return array( 445 'id' => array( 446 'type' => 'integer', 447 'description' => __( 'The user ID.' ), 448 ), 449 'name' => array( 450 'type' => 'string', 451 'description' => __( 'The display name for the user.' ), 452 ), 453 'description' => array( 454 'type' => 'string', 455 'description' => __( 'Description of the user. Present when the current user can view it.' ), 456 ), 457 'url' => array( 458 /* 459 * Unlike the REST users controller, `url` declares no `uri` format. It is 460 * empty for users without a website, and clients that check formats, 461 * such as the abilities JS client when it re-validates the output, would 462 * reject the empty string and fail the whole call. 463 */ 464 'type' => 'string', 465 'description' => __( 'URL of the user. Present when the current user can view it.' ), 466 ), 467 'link' => array( 468 'type' => 'string', 469 'format' => 'uri', 470 'description' => __( 'Author archive URL for the user.' ), 471 ), 472 'slug' => array( 473 'type' => 'string', 474 'description' => __( 'An alphanumeric identifier for the user.' ), 475 ), 476 'avatar_urls' => array( 477 'type' => 'object', 478 'description' => __( 'Avatar URLs for the user, keyed by image size in pixels. A size is null when no avatar URL can be resolved for it. Present when the show_avatars option is enabled.' ), 479 'additionalProperties' => array( 480 'type' => array( 'string', 'null' ), 481 'format' => 'uri', 482 ), 483 ), 484 'username' => array( 485 'type' => 'string', 486 'description' => __( 'Login name for the user. Present when the current user can view it.' ), 487 ), 488 'email' => array( 489 'type' => array( 'string', 'null' ), 490 'format' => 'email', 491 'description' => __( 'The email address for the user. Null when the user has no stored address, or when the stored address is not a valid email. Present when the current user can view it.' ), 492 ), 493 'first_name' => array( 494 'type' => 'string', 495 'description' => __( 'First name for the user. Present when the current user can view it.' ), 496 ), 497 'last_name' => array( 498 'type' => 'string', 499 'description' => __( 'Last name for the user. Present when the current user can view it.' ), 500 ), 501 'nickname' => array( 502 'type' => 'string', 503 'description' => __( 'The nickname for the user. Present when the current user can view it.' ), 504 ), 505 'locale' => array( 506 'type' => 'string', 507 'description' => __( 'Locale for the user. Present when the current user can view it.' ), 508 ), 509 'registered' => array( 510 'type' => 'object', 511 'additionalProperties' => false, 512 'properties' => array( 513 'site' => array( 514 'type' => array( 'string', 'null' ), 515 'format' => 'date-time', 516 'description' => __( "The registration date, in ISO 8601 format using the site's timezone. Null when the stored date is not a valid date." ), 517 ), 518 'gmt' => array( 519 'type' => array( 'string', 'null' ), 520 'format' => 'date-time', 521 'description' => __( 'The registration date, in ISO 8601 format as GMT. Null when the stored date is not a valid date.' ), 522 ), 523 ), 524 'description' => __( 'The registration date for the user. Present when the current user can view it.' ), 525 ), 526 'roles' => array( 527 'type' => 'array', 528 'description' => __( 'Roles assigned to the user. Present when the current user can view them.' ), 529 /* 530 * Output roles are not pinned to an enum. The schema is a 531 * registration-time snapshot, but a role can be registered after 532 * registration and still be held by a returned user; a snapshot enum 533 * would reject that legitimate value during output validation and 534 * fail the whole call. This also matches the REST users controller, 535 * whose `roles` output items are plain strings. 536 */ 537 'items' => array( 538 'type' => 'string', 539 ), 540 ), 541 ); 542 } 543 544 /** 545 * Returns the field paths the `fields` input accepts, in output order. 546 * 547 * Each user field is followed by the path of each sub-field its definition declares, 548 * such as `registered`, `registered.site` and `registered.gmt`. Requesting a field 549 * returns all of its sub-fields, while a sub-field path returns only that sub-field. 550 * 551 * @since 7.2.0 552 * 553 * @return string[] The field paths. 554 */ 555 private function get_field_paths(): array { 556 $paths = array(); 557 foreach ( $this->get_user_properties() as $name => $schema ) { 558 $paths[] = $name; 559 560 foreach ( array_keys( $schema['properties'] ?? array() ) as $sub_field ) { 561 $paths[] = "{$name}.{$sub_field}"; 562 } 563 } 564 565 return $paths; 566 } 567 568 /** 569 * Checks whether a field path is requested. 570 * 571 * A field such as `registered` is requested when it or one of its paths, such as 572 * `registered.gmt`, is requested, and a path such as `registered.gmt` is requested 573 * when it or its whole field is. 574 * 575 * @since 7.2.0 576 * 577 * @param string $path The field path, such as `registered` or `registered.gmt`. 578 * @param string[] $fields The requested field paths. 579 * @return bool Whether the field path is requested. 580 */ 581 private function is_field_requested( string $path, array $fields ): bool { 582 return rest_is_field_included( $path, $fields ); 583 } 584 585 /** 586 * Normalizes a mixed value into a list of user IDs. 587 * 588 * Accepts a list, a single ID, or a comma-separated string, which it parses with 589 * wp_parse_list(), as schema validation does. Each item is read with parse_filter_int(), 590 * so an item that is not a whole number of at least 1, such as a fraction or a negative 591 * number, is dropped rather than truncated or made positive, which would read it as the 592 * ID of another user. 593 * 594 * @since 7.2.0 595 * 596 * @param mixed $value Raw value. 597 * @return int[] Unique user IDs. 598 */ 599 private function normalize_id_list( $value ): array { 600 $items = is_array( $value ) || is_string( $value ) ? wp_parse_list( $value ) : array( $value ); 601 $ids = array(); 602 foreach ( $items as $item ) { 603 $ids[] = $this->parse_filter_int( $item, 1 ); 604 } 605 606 return array_values( array_unique( array_filter( $ids ) ) ); 607 } 608 609 /** 610 * Normalizes a mixed value into a list of strings. 611 * 612 * Accepts arrays and CSV strings, which it parses with wp_parse_list(), as schema 613 * validation does. Schema validation accepts a CSV string for an array, and only the 614 * REST run controller converts input to the schema types, so callers that bypass it, 615 * such as a direct WP_Ability::execute() call, can pass one. Empty and duplicate 616 * items need no handling here, because validation has already rejected them. 617 * 618 * @since 7.2.0 619 * 620 * @param mixed $value Raw value. 621 * @return string[] Normalized strings. 622 */ 623 private function normalize_string_list( $value ): array { 624 if ( ! is_array( $value ) && ! is_string( $value ) ) { 625 return array(); 626 } 627 628 return array_values( array_filter( wp_parse_list( $value ), 'is_string' ) ); 629 } 630 631 /** 632 * Normalizes the `has_published_posts` filter. 633 * 634 * Accepts the string and integer forms of `true` alongside the native boolean. 635 * Schema validation accepts them, and only the REST run controller converts input 636 * to the schema types, so callers that bypass it, such as a direct 637 * WP_Ability::execute() call, can pass one. 638 * 639 * @since 7.2.0 640 * 641 * @param array<mixed> $where The `where` filters. 642 * @return bool|string[]|null Normalized query value, or null when omitted. 643 */ 644 private function normalize_has_published_posts( array $where ) { 645 if ( ! array_key_exists( 'has_published_posts', $where ) ) { 646 return null; 647 } 648 649 $value = $where['has_published_posts']; 650 651 if ( rest_is_boolean( $value ) && rest_sanitize_boolean( $value ) ) { 652 return true; 653 } 654 655 $post_types = $this->normalize_string_list( $value ); 656 657 return empty( $post_types ) ? null : $post_types; 658 } 659 660 /** 661 * Builds the input schema for the `core/users-query` ability. 662 * 663 * The input has three members: `where` selects the users, `fields` selects the fields 664 * returned for each user, and `pagination` selects the page of results. The `where` 665 * filters can be combined in any way, and a user must match all of them. 666 * 667 * @since 7.2.0 668 * 669 * @return array<string, mixed> The input JSON Schema. 670 */ 671 private function get_users_query_input_schema(): array { 672 /* 673 * Input enums intentionally reflect roles and post types available at 674 * ability registration time. This makes the schema a stable contract that 675 * developers can filter when registering the ability. 676 */ 677 $role_names = array_keys( wp_roles()->roles ); 678 $public_post_types = $this->get_public_post_types(); 679 680 return array( 681 'type' => 'object', 682 'default' => array(), 683 'additionalProperties' => false, 684 'properties' => array( 685 'where' => array( 686 'type' => 'object', 687 'additionalProperties' => false, 688 'properties' => array( 689 'ids' => array( 690 'type' => 'array', 691 'uniqueItems' => true, 692 'minItems' => 1, 693 'maxItems' => self::MAX_PER_PAGE, 694 'items' => array( 695 'type' => 'integer', 696 'minimum' => 1, 697 ), 698 'description' => __( 'Limit results to users with these IDs. If `pagination.per_page` is omitted, every matching user is returned on the first page.' ), 699 ), 700 'slugs' => array( 701 'type' => 'array', 702 'uniqueItems' => true, 703 'minItems' => 1, 704 'maxItems' => self::MAX_PER_PAGE, 705 'items' => array( 706 'type' => 'string', 707 ), 708 'description' => __( 'Limit results to users with these slugs. If `pagination.per_page` is omitted, every matching user is returned on the first page.' ), 709 ), 710 'usernames' => array( 711 'type' => 'array', 712 'uniqueItems' => true, 713 'minItems' => 1, 714 'maxItems' => self::MAX_PER_PAGE, 715 'items' => array( 716 'type' => 'string', 717 ), 718 'description' => __( 'Limit results to users with these usernames. Requires permission to list users. If `pagination.per_page` is omitted, every matching user is returned on the first page.' ), 719 ), 720 'roles' => array( 721 'type' => 'array', 722 'uniqueItems' => true, 723 'minItems' => 1, 724 'items' => array( 725 'type' => 'string', 726 'enum' => $role_names, 727 ), 728 'description' => __( 'Filter users by one or more roles. Requires permission to list users.' ), 729 ), 730 'has_published_posts' => array( 731 'anyOf' => array( 732 // True matches authors of any publicly viewable post type. 733 array( 734 'type' => 'boolean', 735 'enum' => array( true ), 736 ), 737 // A list matches authors of the listed post types. 738 array( 739 'type' => 'array', 740 'uniqueItems' => true, 741 'minItems' => 1, 742 'items' => array( 743 'type' => 'string', 744 'enum' => $public_post_types, 745 ), 746 ), 747 ), 748 'description' => __( 'Limit results to users with published posts. Use true for all publicly viewable post types, or provide post type names.' ), 749 ), 750 ), 751 'description' => __( 'Selects the users to return. Every given filter must match, and a list matches users with any of its values. Results are ordered by username, and values that match no readable user are left out rather than causing an error. If omitted, every readable user is returned. Callers without permission to list users only see public authors, who have published posts in a publicly viewable post type. To read your own profile, use the `core/get-user-info` ability.' ), 752 ), 753 'fields' => array( 754 'type' => 'array', 755 'uniqueItems' => true, 756 'items' => array( 757 'type' => 'string', 758 'enum' => $this->get_field_paths(), 759 ), 760 'description' => __( 'Limit each returned user to these fields. A field such as `registered` returns all of its sub-fields, while a path such as `registered.gmt` returns only that sub-field. If omitted or empty, a lean set of common read fields is returned. The `id` field is always included, and fields the current user cannot view are omitted rather than causing an error.' ), 761 ), 762 'pagination' => array( 763 'type' => 'object', 764 'additionalProperties' => false, 765 'properties' => array( 766 'page' => array( 767 'type' => 'integer', 768 'minimum' => 1, 769 'description' => __( 'Page of results to return. Requesting a page beyond the last one is an error. Check `pagination.total_pages` in the result before requesting later pages.' ), 770 ), 771 'per_page' => array( 772 'type' => 'integer', 773 'minimum' => 1, 774 'maximum' => self::MAX_PER_PAGE, 775 'description' => __( 'Maximum number of users to return per page.' ), 776 ), 777 ), 778 'description' => __( 'Selects the page of results to return.' ), 779 ), 780 ), 781 ); 782 } 783 784 /** 785 * Builds the output schema for the `core/users-query` ability. 786 * 787 * Every request returns the same shape: a page of users in `items`, and the totals 788 * across all pages in `pagination`. 789 * 790 * Only `id` is required in a user, because it is always returned. The other fields 791 * are optional because the `fields` input lets the caller request any subset, and 792 * restricted fields are omitted when unavailable. 793 * 794 * @since 7.2.0 795 * 796 * @return array<string, mixed> The output JSON Schema. 797 */ 798 private function get_users_query_output_schema(): array { 799 return array( 800 'type' => 'object', 801 'additionalProperties' => false, 802 'required' => array( 'items', 'pagination' ), 803 'properties' => array( 804 'items' => array( 805 'type' => 'array', 806 'description' => __( 'The readable users on the requested page.' ), 807 'items' => array( 808 'type' => 'object', 809 'additionalProperties' => false, 810 'required' => array( 'id' ), 811 'properties' => $this->get_user_properties(), 812 ), 813 ), 814 'pagination' => array( 815 'type' => 'object', 816 'additionalProperties' => false, 817 'required' => array( 'total', 'total_pages' ), 818 'properties' => array( 819 'total' => array( 820 'type' => 'integer', 821 'description' => __( 'Total number of users matching the request, across all pages.' ), 822 ), 823 'total_pages' => array( 824 'type' => 'integer', 825 'description' => __( 'Total number of result pages available for the request.' ), 826 ), 827 ), 828 'description' => __( 'The totals of the request, across all pages.' ), 829 ), 830 ), 831 ); 832 } 833 834 /** 835 * Formats a user into the ability output shape. 836 * 837 * Fields are selected with {@see self::is_field_requested()}, so `registered` returns 838 * both of its sub-fields, while `registered.site` or `registered.gmt` returns only that 839 * one. Only the requested fields the current user can see are included, except `id`, 840 * which {@see self::normalize_fields()} always requests. 841 * 842 * @since 7.2.0 843 * 844 * @param WP_User $user The user object. 845 * @param string[] $fields The requested field paths. 846 * @return array<string, mixed> The formatted user data. 847 */ 848 private function format_user( WP_User $user, array $fields ): array { 849 $can_view_sensitive = get_current_user_id() === $user->ID || current_user_can( 'edit_user', $user->ID ); 850 851 $data = array(); 852 853 if ( $this->is_field_requested( 'id', $fields ) ) { 854 $data['id'] = $user->ID; 855 } 856 if ( $this->is_field_requested( 'name', $fields ) ) { 857 $data['name'] = (string) $user->display_name; 858 } 859 if ( $this->is_field_requested( 'description', $fields ) ) { 860 $data['description'] = (string) $user->description; 861 } 862 if ( $this->is_field_requested( 'url', $fields ) ) { 863 $data['url'] = (string) $user->user_url; 864 } 865 if ( $this->is_field_requested( 'link', $fields ) ) { 866 $data['link'] = (string) get_author_posts_url( $user->ID, $user->user_nicename ); 867 } 868 if ( $this->is_field_requested( 'slug', $fields ) ) { 869 $data['slug'] = (string) $user->user_nicename; 870 } 871 /* 872 * The schemas always declare avatar_urls; availability is enforced here, 873 * since the option can change after the schemas are registered. 874 */ 875 if ( $this->is_field_requested( 'avatar_urls', $fields ) && get_option( 'show_avatars' ) ) { 876 $data['avatar_urls'] = array_map( 877 static function ( $url ) { 878 return is_string( $url ) ? $url : null; 879 }, 880 rest_get_avatar_urls( $user ) 881 ); 882 } 883 884 if ( $can_view_sensitive ) { 885 if ( $this->is_field_requested( 'username', $fields ) ) { 886 $data['username'] = (string) $user->user_login; 887 } 888 if ( $this->is_field_requested( 'email', $fields ) ) { 889 $data['email'] = is_email( $user->user_email ) ? (string) $user->user_email : null; 890 } 891 if ( $this->is_field_requested( 'first_name', $fields ) ) { 892 $data['first_name'] = (string) $user->first_name; 893 } 894 if ( $this->is_field_requested( 'last_name', $fields ) ) { 895 $data['last_name'] = (string) $user->last_name; 896 } 897 if ( $this->is_field_requested( 'nickname', $fields ) ) { 898 $data['nickname'] = (string) $user->nickname; 899 } 900 if ( $this->is_field_requested( 'locale', $fields ) ) { 901 $data['locale'] = (string) get_user_locale( $user ); 902 } 903 904 $registered = array(); 905 if ( $this->is_field_requested( 'registered.site', $fields ) ) { 906 $registered['site'] = $this->format_registered_date( $user, wp_timezone() ); 907 } 908 if ( $this->is_field_requested( 'registered.gmt', $fields ) ) { 909 $registered['gmt'] = $this->format_registered_date( $user, new DateTimeZone( 'UTC' ) ); 910 } 911 if ( ! empty( $registered ) ) { 912 $data['registered'] = $registered; 913 } 914 } 915 916 /* 917 * Roles reveal a user's privilege level, so they are gated like the other 918 * sensitive fields: visible only for the current user or a user the caller 919 * can edit. `list_users` alone (which grants no edit rights) is not enough, 920 * matching the REST users controller, where `roles` is an edit-context 921 * field and rows the caller cannot edit are dropped from collections. 922 */ 923 if ( $this->is_field_requested( 'roles', $fields ) && $can_view_sensitive ) { 924 $data['roles'] = array_values( $user->roles ); 925 } 926 927 return $data; 928 } 929 930 /** 931 * Formats the user's registration date as an ISO 8601 string in a timezone. 932 * 933 * The `user_registered` column holds the date in GMT, so it is parsed as UTC 934 * whatever the PHP default timezone is, then converted to the given timezone. 935 * 936 * @since 7.2.0 937 * 938 * @param WP_User $user The user object. 939 * @param DateTimeZone $timezone The timezone to format the date in. 940 * @return string|null The ISO 8601 date, or null when the stored date is not a valid date. 941 */ 942 private function format_registered_date( WP_User $user, DateTimeZone $timezone ): ?string { 943 $datetime = date_create_immutable_from_format( 'Y-m-d H:i:s', (string) $user->user_registered, new DateTimeZone( 'UTC' ) ); 944 if ( ! $datetime ) { 945 return null; 946 } 947 948 /* 949 * A malformed stored date can still parse, since out-of-range parts roll over: the 950 * zero date, the column default, formats with a negative year, and converting a 951 * date at either end of the range can push its year outside four digits. The 952 * `date-time` format rejects these, which would fail output validation for the 953 * whole call, so they are reported as null too, like an unusable email. 954 */ 955 $date = $datetime->setTimezone( $timezone )->format( 'c' ); 956 957 return false !== rest_parse_date( $date ) ? $date : null; 958 } 959 960 /** 961 * Builds the ability output for a page of formatted users. 962 * 963 * @since 7.2.0 964 * 965 * @param array<int, array<string, mixed>> $items The formatted users on the page. 966 * @param int $total Total number of users across all pages. 967 * @param int $total_pages Total number of pages. 968 * @return array<string, mixed> The ability output. 969 */ 970 private function prepare_output( array $items, int $total, int $total_pages ): array { 971 return array( 972 'items' => $items, 973 'pagination' => array( 974 'total' => $total, 975 'total_pages' => $total_pages, 976 ), 977 ); 978 } 979 980 /** 981 * Builds the error for a page past the last page of results. 982 * 983 * A page that does not exist is reported as not found rather than as an empty list, 984 * so callers can tell it apart from a request that matches no users. 985 * 986 * @since 7.2.0 987 * 988 * @return WP_Error The invalid page number error. 989 */ 990 private function invalid_page_number_error(): WP_Error { 991 return new WP_Error( 992 'users_invalid_page_number', 993 __( 'The page number requested is larger than the number of pages available.' ), 994 array( 'status' => 404 ) 995 ); 996 } 997 998 /** 999 * Builds the error for a filter that cannot be honored. 1000 * 1001 * As in the REST API's `rest_invalid_param` errors, the error data maps the filter's 1002 * input path to the message under `params`, so callers can tell which filter failed 1003 * without parsing the translated message. 1004 * 1005 * @since 7.2.0 1006 * 1007 * @param string $param The filter's input path, such as `where[ids]`. 1008 * @param string $message The error message. 1009 * @return WP_Error The invalid filter error. 1010 */ 1011 private function invalid_filter_error( string $param, string $message ): WP_Error { 1012 return new WP_Error( 1013 'users_invalid_filter', 1014 $message, 1015 array( 1016 'status' => 400, 1017 'params' => array( $param => $message ), 1018 ) 1019 ); 1020 } 1021 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Thu Nov 5 08:20:37 2026 | Cross-referenced by PHPXref |