[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

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

   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  }


Generated : Thu Oct 15 08:20:32 2026 Cross-referenced by PHPXref