| [ 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 fields, 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 fields are returned. The input schema has already 402 * validated the names against the supported set before the ability executes. 403 * 404 * The `id` field is always included, matching the REST users controller 405 * where `id` is present in every context. This also guarantees the result 406 * is never empty, so it always serializes as a JSON object. 407 * 408 * @since 7.2.0 409 * 410 * @param array<mixed> $input The ability input. 411 * @return string[] List of requested field names. 412 */ 413 private function normalize_fields( array $input ): array { 414 $fields = isset( $input['fields'] ) ? $this->normalize_string_list( $input['fields'] ) : array(); 415 if ( empty( $fields ) ) { 416 $fields = self::DEFAULT_FIELDS; 417 } 418 419 if ( ! in_array( 'id', $fields, true ) ) { 420 array_unshift( $fields, 'id' ); 421 } 422 423 return $fields; 424 } 425 426 /** 427 * Returns the user field definitions, keyed by field name in output order. 428 * 429 * This is the single source of truth for the ability's user fields: the output 430 * schema uses the definitions directly, while the input schema and field 431 * normalization use the keys. The field set is deliberately unconditional: 432 * the registered schemas are a registration-time snapshot, so conditional 433 * availability (such as `avatar_urls` honoring the `show_avatars` option) is 434 * enforced per call in {@see self::format_user()} instead of here, where the 435 * option could change between registration and use. 436 * 437 * @since 7.2.0 438 * 439 * @return array<string, mixed> User field definitions. 440 */ 441 private function get_user_properties(): array { 442 return array( 443 'id' => array( 444 'type' => 'integer', 445 'description' => __( 'The user ID.' ), 446 ), 447 'name' => array( 448 'type' => 'string', 449 'description' => __( 'The display name for the user.' ), 450 ), 451 'description' => array( 452 'type' => 'string', 453 'description' => __( 'Description of the user. Present when the current user can view it.' ), 454 ), 455 'url' => array( 456 /* 457 * Unlike the REST users controller, `url` declares no `uri` format. It is 458 * empty for users without a website, and clients that check formats, 459 * such as the abilities JS client when it re-validates the output, would 460 * reject the empty string and fail the whole call. 461 */ 462 'type' => 'string', 463 'description' => __( 'URL of the user. Present when the current user can view it.' ), 464 ), 465 'link' => array( 466 'type' => 'string', 467 'format' => 'uri', 468 'description' => __( 'Author archive URL for the user.' ), 469 ), 470 'slug' => array( 471 'type' => 'string', 472 'description' => __( 'An alphanumeric identifier for the user.' ), 473 ), 474 'avatar_urls' => array( 475 'type' => 'object', 476 '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.' ), 477 'additionalProperties' => array( 478 'type' => array( 'string', 'null' ), 479 'format' => 'uri', 480 ), 481 ), 482 'username' => array( 483 'type' => 'string', 484 'description' => __( 'Login name for the user. Present when the current user can view it.' ), 485 ), 486 'email' => array( 487 'type' => array( 'string', 'null' ), 488 'format' => 'email', 489 '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.' ), 490 ), 491 'first_name' => array( 492 'type' => 'string', 493 'description' => __( 'First name for the user. Present when the current user can view it.' ), 494 ), 495 'last_name' => array( 496 'type' => 'string', 497 'description' => __( 'Last name for the user. Present when the current user can view it.' ), 498 ), 499 'nickname' => array( 500 'type' => 'string', 501 'description' => __( 'The nickname for the user. Present when the current user can view it.' ), 502 ), 503 'locale' => array( 504 'type' => 'string', 505 'description' => __( 'Locale for the user. Present when the current user can view it.' ), 506 ), 507 'registered_date' => array( 508 'type' => array( 'string', 'null' ), 509 'format' => 'date-time', 510 'description' => __( 'Registration date for the user. Null when the stored date is not a valid date. Present when the current user can view it.' ), 511 ), 512 'roles' => array( 513 'type' => 'array', 514 'description' => __( 'Roles assigned to the user. Present when the current user can view them.' ), 515 /* 516 * Output roles are not pinned to an enum. The schema is a 517 * registration-time snapshot, but a role can be registered after 518 * registration and still be held by a returned user; a snapshot enum 519 * would reject that legitimate value during output validation and 520 * fail the whole call. This also matches the REST users controller, 521 * whose `roles` output items are plain strings. 522 */ 523 'items' => array( 524 'type' => 'string', 525 ), 526 ), 527 ); 528 } 529 530 /** 531 * Normalizes a mixed value into a list of user IDs. 532 * 533 * Accepts a list, a single ID, or a comma-separated string, which it parses with 534 * wp_parse_list(), as schema validation does. Each item is read with parse_filter_int(), 535 * so an item that is not a whole number of at least 1, such as a fraction or a negative 536 * number, is dropped rather than truncated or made positive, which would read it as the 537 * ID of another user. 538 * 539 * @since 7.2.0 540 * 541 * @param mixed $value Raw value. 542 * @return int[] Unique user IDs. 543 */ 544 private function normalize_id_list( $value ): array { 545 $items = is_array( $value ) || is_string( $value ) ? wp_parse_list( $value ) : array( $value ); 546 $ids = array(); 547 foreach ( $items as $item ) { 548 $ids[] = $this->parse_filter_int( $item, 1 ); 549 } 550 551 return array_values( array_unique( array_filter( $ids ) ) ); 552 } 553 554 /** 555 * Normalizes a mixed value into a list of strings. 556 * 557 * Accepts arrays and CSV strings, which it parses with wp_parse_list(), as schema 558 * validation does. Schema validation accepts a CSV string for an array, and only the 559 * REST run controller converts input to the schema types, so callers that bypass it, 560 * such as a direct WP_Ability::execute() call, can pass one. Empty and duplicate 561 * items need no handling here, because validation has already rejected them. 562 * 563 * @since 7.2.0 564 * 565 * @param mixed $value Raw value. 566 * @return string[] Normalized strings. 567 */ 568 private function normalize_string_list( $value ): array { 569 if ( ! is_array( $value ) && ! is_string( $value ) ) { 570 return array(); 571 } 572 573 return array_values( array_filter( wp_parse_list( $value ), 'is_string' ) ); 574 } 575 576 /** 577 * Normalizes the `has_published_posts` filter. 578 * 579 * Accepts the string and integer forms of `true` alongside the native boolean. 580 * Schema validation accepts them, and only the REST run controller converts input 581 * to the schema types, so callers that bypass it, such as a direct 582 * WP_Ability::execute() call, can pass one. 583 * 584 * @since 7.2.0 585 * 586 * @param array<mixed> $where The `where` filters. 587 * @return bool|string[]|null Normalized query value, or null when omitted. 588 */ 589 private function normalize_has_published_posts( array $where ) { 590 if ( ! array_key_exists( 'has_published_posts', $where ) ) { 591 return null; 592 } 593 594 $value = $where['has_published_posts']; 595 596 if ( rest_is_boolean( $value ) && rest_sanitize_boolean( $value ) ) { 597 return true; 598 } 599 600 $post_types = $this->normalize_string_list( $value ); 601 602 return empty( $post_types ) ? null : $post_types; 603 } 604 605 /** 606 * Builds the input schema for the `core/users-query` ability. 607 * 608 * The input has three members: `where` selects the users, `fields` selects the fields 609 * returned for each user, and `pagination` selects the page of results. The `where` 610 * filters can be combined in any way, and a user must match all of them. 611 * 612 * @since 7.2.0 613 * 614 * @return array<string, mixed> The input JSON Schema. 615 */ 616 private function get_users_query_input_schema(): array { 617 /* 618 * Input enums intentionally reflect roles and post types available at 619 * ability registration time. This makes the schema a stable contract that 620 * developers can filter when registering the ability. 621 */ 622 $role_names = array_keys( wp_roles()->roles ); 623 $public_post_types = $this->get_public_post_types(); 624 625 return array( 626 'type' => 'object', 627 'default' => array(), 628 'additionalProperties' => false, 629 'properties' => array( 630 'where' => array( 631 'type' => 'object', 632 'additionalProperties' => false, 633 'properties' => array( 634 'ids' => array( 635 'type' => 'array', 636 'uniqueItems' => true, 637 'minItems' => 1, 638 'maxItems' => self::MAX_PER_PAGE, 639 'items' => array( 640 'type' => 'integer', 641 'minimum' => 1, 642 ), 643 'description' => __( 'Limit results to users with these IDs. If `pagination.per_page` is omitted, every matching user is returned on the first page.' ), 644 ), 645 'slugs' => array( 646 'type' => 'array', 647 'uniqueItems' => true, 648 'minItems' => 1, 649 'maxItems' => self::MAX_PER_PAGE, 650 'items' => array( 651 'type' => 'string', 652 ), 653 'description' => __( 'Limit results to users with these slugs. If `pagination.per_page` is omitted, every matching user is returned on the first page.' ), 654 ), 655 'usernames' => array( 656 'type' => 'array', 657 'uniqueItems' => true, 658 'minItems' => 1, 659 'maxItems' => self::MAX_PER_PAGE, 660 'items' => array( 661 'type' => 'string', 662 ), 663 '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.' ), 664 ), 665 'roles' => array( 666 'type' => 'array', 667 'uniqueItems' => true, 668 'minItems' => 1, 669 'items' => array( 670 'type' => 'string', 671 'enum' => $role_names, 672 ), 673 'description' => __( 'Filter users by one or more roles. Requires permission to list users.' ), 674 ), 675 'has_published_posts' => array( 676 'anyOf' => array( 677 // True matches authors of any publicly viewable post type. 678 array( 679 'type' => 'boolean', 680 'enum' => array( true ), 681 ), 682 // A list matches authors of the listed post types. 683 array( 684 'type' => 'array', 685 'uniqueItems' => true, 686 'minItems' => 1, 687 'items' => array( 688 'type' => 'string', 689 'enum' => $public_post_types, 690 ), 691 ), 692 ), 693 'description' => __( 'Limit results to users with published posts. Use true for all publicly viewable post types, or provide post type names.' ), 694 ), 695 ), 696 '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.' ), 697 ), 698 'fields' => array( 699 'type' => 'array', 700 'uniqueItems' => true, 701 'items' => array( 702 'type' => 'string', 703 'enum' => array_keys( $this->get_user_properties() ), 704 ), 705 'description' => __( 'Limit each returned user to these fields. 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.' ), 706 ), 707 'pagination' => array( 708 'type' => 'object', 709 'additionalProperties' => false, 710 'properties' => array( 711 'page' => array( 712 'type' => 'integer', 713 'minimum' => 1, 714 '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.' ), 715 ), 716 'per_page' => array( 717 'type' => 'integer', 718 'minimum' => 1, 719 'maximum' => self::MAX_PER_PAGE, 720 'description' => __( 'Maximum number of users to return per page.' ), 721 ), 722 ), 723 'description' => __( 'Selects the page of results to return.' ), 724 ), 725 ), 726 ); 727 } 728 729 /** 730 * Builds the output schema for the `core/users-query` ability. 731 * 732 * Every request returns the same shape: a page of users in `items`, and the totals 733 * across all pages in `pagination`. 734 * 735 * Only `id` is required in a user, because it is always returned. The other fields 736 * are optional because the `fields` input lets the caller request any subset, and 737 * restricted fields are omitted when unavailable. 738 * 739 * @since 7.2.0 740 * 741 * @return array<string, mixed> The output JSON Schema. 742 */ 743 private function get_users_query_output_schema(): array { 744 return array( 745 'type' => 'object', 746 'additionalProperties' => false, 747 'required' => array( 'items', 'pagination' ), 748 'properties' => array( 749 'items' => array( 750 'type' => 'array', 751 'description' => __( 'The readable users on the requested page.' ), 752 'items' => array( 753 'type' => 'object', 754 'additionalProperties' => false, 755 'required' => array( 'id' ), 756 'properties' => $this->get_user_properties(), 757 ), 758 ), 759 'pagination' => array( 760 'type' => 'object', 761 'additionalProperties' => false, 762 'required' => array( 'total', 'total_pages' ), 763 'properties' => array( 764 'total' => array( 765 'type' => 'integer', 766 'description' => __( 'Total number of users matching the request, across all pages.' ), 767 ), 768 'total_pages' => array( 769 'type' => 'integer', 770 'description' => __( 'Total number of result pages available for the request.' ), 771 ), 772 ), 773 'description' => __( 'The totals of the request, across all pages.' ), 774 ), 775 ), 776 ); 777 } 778 779 /** 780 * Formats a user into the ability output shape. 781 * 782 * Only the requested fields the current user can see are included, except 783 * `id`, which {@see self::normalize_fields()} always requests. 784 * 785 * @since 7.2.0 786 * 787 * @param WP_User $user The user object. 788 * @param string[] $fields The requested field names. 789 * @return array<string, mixed> The formatted user data. 790 */ 791 private function format_user( WP_User $user, array $fields ): array { 792 $fields_requested = static function ( string $field ) use ( $fields ): bool { 793 return in_array( $field, $fields, true ); 794 }; 795 796 $can_view_sensitive = get_current_user_id() === $user->ID || current_user_can( 'edit_user', $user->ID ); 797 798 $data = array(); 799 800 if ( $fields_requested( 'id' ) ) { 801 $data['id'] = $user->ID; 802 } 803 if ( $fields_requested( 'name' ) ) { 804 $data['name'] = (string) $user->display_name; 805 } 806 if ( $fields_requested( 'description' ) ) { 807 $data['description'] = (string) $user->description; 808 } 809 if ( $fields_requested( 'url' ) ) { 810 $data['url'] = (string) $user->user_url; 811 } 812 if ( $fields_requested( 'link' ) ) { 813 $data['link'] = (string) get_author_posts_url( $user->ID, $user->user_nicename ); 814 } 815 if ( $fields_requested( 'slug' ) ) { 816 $data['slug'] = (string) $user->user_nicename; 817 } 818 /* 819 * The schemas always declare avatar_urls; availability is enforced here, 820 * since the option can change after the schemas are registered. 821 */ 822 if ( $fields_requested( 'avatar_urls' ) && get_option( 'show_avatars' ) ) { 823 $data['avatar_urls'] = array_map( 824 static function ( $url ) { 825 return is_string( $url ) ? $url : null; 826 }, 827 rest_get_avatar_urls( $user ) 828 ); 829 } 830 831 if ( $can_view_sensitive ) { 832 if ( $fields_requested( 'username' ) ) { 833 $data['username'] = (string) $user->user_login; 834 } 835 if ( $fields_requested( 'email' ) ) { 836 $data['email'] = is_email( $user->user_email ) ? (string) $user->user_email : null; 837 } 838 if ( $fields_requested( 'first_name' ) ) { 839 $data['first_name'] = (string) $user->first_name; 840 } 841 if ( $fields_requested( 'last_name' ) ) { 842 $data['last_name'] = (string) $user->last_name; 843 } 844 if ( $fields_requested( 'nickname' ) ) { 845 $data['nickname'] = (string) $user->nickname; 846 } 847 if ( $fields_requested( 'locale' ) ) { 848 $data['locale'] = (string) get_user_locale( $user ); 849 } 850 if ( $fields_requested( 'registered_date' ) ) { 851 /* 852 * The zero date, the column default, formats with a negative year that the 853 * `date-time` format rejects, so it is reported as null, like an unusable email. 854 */ 855 $registered_timestamp = strtotime( $user->user_registered ); 856 $registered_date = false !== $registered_timestamp ? gmdate( 'c', $registered_timestamp ) : ''; 857 $data['registered_date'] = rest_parse_date( $registered_date ) ? $registered_date : null; 858 } 859 } 860 861 /* 862 * Roles reveal a user's privilege level, so they are gated like the other 863 * sensitive fields: visible only for the current user or a user the caller 864 * can edit. `list_users` alone (which grants no edit rights) is not enough, 865 * matching the REST users controller, where `roles` is an edit-context 866 * field and rows the caller cannot edit are dropped from collections. 867 */ 868 if ( $fields_requested( 'roles' ) && $can_view_sensitive ) { 869 $data['roles'] = array_values( $user->roles ); 870 } 871 872 return $data; 873 } 874 875 /** 876 * Builds the ability output for a page of formatted users. 877 * 878 * @since 7.2.0 879 * 880 * @param array<int, array<string, mixed>> $items The formatted users on the page. 881 * @param int $total Total number of users across all pages. 882 * @param int $total_pages Total number of pages. 883 * @return array<string, mixed> The ability output. 884 */ 885 private function prepare_output( array $items, int $total, int $total_pages ): array { 886 return array( 887 'items' => $items, 888 'pagination' => array( 889 'total' => $total, 890 'total_pages' => $total_pages, 891 ), 892 ); 893 } 894 895 /** 896 * Builds the error for a page past the last page of results. 897 * 898 * A page that does not exist is reported as not found rather than as an empty list, 899 * so callers can tell it apart from a request that matches no users. 900 * 901 * @since 7.2.0 902 * 903 * @return WP_Error The invalid page number error. 904 */ 905 private function invalid_page_number_error(): WP_Error { 906 return new WP_Error( 907 'users_invalid_page_number', 908 __( 'The page number requested is larger than the number of pages available.' ), 909 array( 'status' => 404 ) 910 ); 911 } 912 913 /** 914 * Builds the error for a filter that cannot be honored. 915 * 916 * As in the REST API's `rest_invalid_param` errors, the error data maps the filter's 917 * input path to the message under `params`, so callers can tell which filter failed 918 * without parsing the translated message. 919 * 920 * @since 7.2.0 921 * 922 * @param string $param The filter's input path, such as `where[ids]`. 923 * @param string $message The error message. 924 * @return WP_Error The invalid filter error. 925 */ 926 private function invalid_filter_error( string $param, string $message ): WP_Error { 927 return new WP_Error( 928 'users_invalid_filter', 929 $message, 930 array( 931 'status' => 400, 932 'params' => array( $param => $message ), 933 ) 934 ); 935 } 936 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Thu Oct 15 08:20:32 2026 | Cross-referenced by PHPXref |