diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 4c6db1ed830e0..f9a6413250171 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,7 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-users-abilities.php'; /** * Registers the core ability categories. * @@ -257,4 +258,5 @@ function wp_register_core_abilities(): void { ), ) ); + WP_Users_Abilities::register(); } diff --git a/src/wp-includes/abilities/class-wp-users-abilities.php b/src/wp-includes/abilities/class-wp-users-abilities.php new file mode 100644 index 0000000000000..38544dee98170 --- /dev/null +++ b/src/wp-includes/abilities/class-wp-users-abilities.php @@ -0,0 +1,354 @@ + __( 'Get User' ), + 'description' => __( 'Returns comprehensive profile details for a user by id, username, or email.' ), + 'category' => 'user', + 'input_schema' => self::get_user_input_schema(), + 'output_schema' => self::get_user_output_schema(), + 'execute_callback' => array( __CLASS__, 'execute_get_user' ), + 'permission_callback' => array( __CLASS__, 'check_get_user_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Gets the input schema for the get-user ability. + * + * @since 7.0.0 + * + * @return array The input schema. + */ + private static function get_user_input_schema(): array { + return array( + 'type' => 'object', + 'oneOf' => array( + array( 'required' => array( 'id' ) ), + array( 'required' => array( 'username' ) ), + array( 'required' => array( 'email' ) ), + ), + 'properties' => array( + 'email' => array( + 'type' => 'string', + 'format' => 'email', + 'description' => __( 'User email address.' ), + ), + 'id' => array( + 'type' => 'integer', + 'description' => __( 'User ID.' ), + ), + 'username' => array( + 'type' => 'string', + 'description' => __( 'User login name.' ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Determines whether sensitive fields can be returned for a user. + * + * @since 7.0.0 + * + * @param WP_User $user The target user. + * @return bool Whether sensitive fields can be returned. + */ + private static function can_view_sensitive_user_fields( WP_User $user ): bool { + return get_current_user_id() === $user->ID || current_user_can( 'edit_user', $user->ID ); + } + + /** + * Determines whether roles can be returned for a user. + * + * @since 7.0.0 + * + * @param WP_User $user The target user. + * @return bool Whether roles can be returned. + */ + private static function can_view_user_roles( WP_User $user ): bool { + return current_user_can( 'list_users' ) || current_user_can( 'edit_user', $user->ID ); + } + + /** + * Gets the output schema for the get-user ability. + * + * @since 7.0.0 + * + * @return array The output schema. + */ + private static function get_user_output_schema(): array { + $avatar_urls_schema = array( + 'type' => 'object', + 'description' => __( 'Avatar URLs for the user.' ), + ); + + if ( get_option( 'show_avatars' ) ) { + $avatar_properties = array(); + + foreach ( rest_get_avatar_sizes() as $size ) { + $avatar_properties[ $size ] = array( + /* translators: %d: Avatar image size in pixels. */ + 'description' => sprintf( __( 'Avatar URL with image size of %d pixels.' ), $size ), + 'type' => 'string', + 'format' => 'uri', + ); + } + + $avatar_urls_schema['properties'] = $avatar_properties; + } + + return array( + 'type' => 'object', + 'required' => array( + 'id', + 'username', + ), + 'properties' => array( + 'avatar_urls' => $avatar_urls_schema, + 'description' => array( + 'type' => 'string', + 'description' => __( 'Description of the user.' ), + ), + 'display_name' => array( + 'type' => 'string', + 'description' => __( 'Display name for the user.' ), + ), + 'email' => array( + 'type' => 'string', + 'format' => 'email', + 'description' => __( 'The email address for the user.' ), + ), + 'first_name' => array( + 'type' => 'string', + 'description' => __( 'First name for the user.' ), + ), + 'id' => array( + 'type' => 'integer', + 'description' => __( 'Unique identifier for the user.' ), + ), + 'last_name' => array( + 'type' => 'string', + 'description' => __( 'Last name for the user.' ), + ), + 'link' => array( + 'type' => 'string', + 'format' => 'uri', + 'description' => __( 'Author URL of the user.' ), + ), + 'locale' => array( + 'type' => 'string', + 'enum' => array_merge( array( '', 'en_US' ), get_available_languages() ), + 'description' => __( 'Locale for the user.' ), + ), + 'nickname' => array( + 'type' => 'string', + 'description' => __( 'The nickname for the user.' ), + ), + 'registered_date' => array( + 'type' => 'string', + 'format' => 'date-time', + 'description' => __( 'Registration date for the user in ISO 8601 format.' ), + ), + 'roles' => array( + 'type' => 'array', + 'description' => __( 'Roles assigned to the user when the current user can view them.' ), + 'items' => array( + 'type' => 'string', + ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'An alphanumeric identifier for the user.' ), + ), + 'url' => array( + 'type' => 'string', + 'format' => 'uri', + 'description' => __( 'URL of the user.' ), + ), + 'username' => array( + 'type' => 'string', + 'description' => __( 'Login name for the user.' ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Normalizes a list of values into an array of strings. + * + * @since 7.0.0 + * + * @param mixed $values Values to normalize. + * @return array Normalized string list. + */ + private static function normalize_string_list( $values ): array { + $normalized = array(); + + foreach ( (array) $values as $value ) { + if ( null === $value || is_scalar( $value ) || ( is_object( $value ) && is_callable( array( $value, '__toString' ) ) ) ) { + $normalized[] = (string) $value; + } + } + + return $normalized; + } + + /** + * Finds a user by id, username, or email from input parameters. + * + * @since 7.0.0 + * + * @param array $input The input parameters. + * @return WP_User|false The user object if found, false otherwise. + */ + private static function find_user( array $input ) { + if ( ! empty( $input['id'] ) && $input['id'] > 0 ) { + return get_user_by( 'ID', (int) $input['id'] ); + } + + if ( ! empty( $input['username'] ) ) { + return get_user_by( 'login', sanitize_user( $input['username'] ) ); + } + + if ( ! empty( $input['email'] ) ) { + return get_user_by( 'email', sanitize_email( $input['email'] ) ); + } + + return false; + } + + /** + * Permission callback for the get-user ability. + * + * @since 7.0.0 + * + * @param array $input The input parameters. + * @return bool Whether the user has permission. + */ + public static function check_get_user_permission( array $input = array() ): bool { + // Must be logged in. + if ( ! is_user_logged_in() ) { + return false; + } + + $user = self::find_user( $input ); + + // Allow privileged users to query unknown targets and receive not-found responses. + if ( ! $user || ! $user->exists() ) { + return current_user_can( 'edit_users' ); + } + + // Users can view their own profile. + if ( get_current_user_id() === $user->ID ) { + return true; + } + + // Otherwise require permission to edit the specific target user. + return current_user_can( 'edit_user', $user->ID ); + } + + /** + * Executes the get-user ability. + * + * @since 7.0.0 + * + * @param array $input The input parameters. + * @return array|WP_Error The user data or error. + */ + public static function execute_get_user( array $input = array() ) { + $input = is_array( $input ) ? $input : array(); + + $user = self::find_user( $input ); + + if ( ! $user || ! $user->exists() ) { + return new WP_Error( 'user_not_found', __( 'User not found.' ), array( 'status' => 404 ) ); + } + + $user_id = (int) $user->ID; + $can_view_sensitive_user_fields = self::can_view_sensitive_user_fields( $user ); + + $result = array( + 'id' => $user_id, + 'display_name' => (string) $user->display_name, + 'description' => (string) $user->description, + 'url' => (string) $user->user_url, + 'link' => (string) get_author_posts_url( $user_id, $user->user_nicename ), + 'slug' => (string) $user->user_nicename, + 'avatar_urls' => rest_get_avatar_urls( $user ), + ); + + if ( $can_view_sensitive_user_fields ) { + $result['username'] = (string) $user->user_login; + $result['email'] = (string) $user->user_email; + $result['first_name'] = (string) $user->first_name; + $result['last_name'] = (string) $user->last_name; + $result['nickname'] = (string) $user->nickname; + $result['locale'] = (string) get_user_locale( $user ); + + $registered_timestamp = strtotime( (string) $user->user_registered ); + if ( false !== $registered_timestamp ) { + $result['registered_date'] = gmdate( 'c', $registered_timestamp ); + } + } + + if ( self::can_view_user_roles( $user ) ) { + $result['roles'] = self::normalize_string_list( $user->roles ); + } + + return $result; + } +} diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php index 48cae6efd1dee..7fb0bcf89c9e7 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php @@ -154,6 +154,50 @@ public function test_core_get_current_user_info_returns_user_data(): void { $this->assertSame( get_userdata( $user_id )->display_name, $result['display_name'] ); } + /** + * Tests get-user output is normalized to the declared schema types. + * @ticket 64146 + */ + public function test_core_get_user_output_is_normalized_to_schema_types(): void { + $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + wp_set_current_user( $user_id ); + + $ability = wp_get_ability( 'core/get-user' ); + + $result = $ability->execute( + array( + 'id' => $user_id, + ) + ); + + $this->assertIsArray( $result ); + $this->assertIsInt( $result['id'] ); + $this->assertIsString( $result['display_name'] ); + $this->assertIsString( $result['description'] ); + $this->assertIsString( $result['url'] ); + $this->assertIsString( $result['link'] ); + $this->assertIsString( $result['slug'] ); + $this->assertIsString( $result['username'] ); + $this->assertIsString( $result['email'] ); + $this->assertIsString( $result['first_name'] ); + $this->assertIsString( $result['last_name'] ); + $this->assertIsString( $result['nickname'] ); + $this->assertIsString( $result['locale'] ); + $this->assertIsString( $result['registered_date'] ); + $this->assertNotFalse( rest_parse_date( $result['registered_date'] ) ); + + $this->assertIsArray( $result['roles'] ); + foreach ( $result['roles'] as $role ) { + $this->assertIsString( $role ); + } + + $this->assertIsArray( $result['avatar_urls'] ); + foreach ( $result['avatar_urls'] as $avatar_url ) { + $this->assertIsString( $avatar_url ); + } + } + /** * Tests executing the environment info ability. * @ticket 64146