Skip to content

Commit 7a8f585

Browse files
Abilities API: Match the REST users controller for the author of a core/content-query post.
The `core/content-query` ability returned the author's display name and slug for every post the current user can read, which the REST users controller does not do when it returns a user: it only returns a user to themselves, to users who can edit or list users, and when the user has posts the current user can read in a post type exposed to the REST API. Match it, to fix possible inconsistencies between the two: `author.name` and `author.slug` are now null for an author that controller would not return to the current user, as they already are for an author who no longer exists. The author ID is still returned, as the REST posts controller returns it. The lookup runs once per author and query, and only when the name or slug is requested. Follow-up to [64388]. See #66268.
1 parent e29e51d commit 7a8f585

2 files changed

Lines changed: 195 additions & 4 deletions

File tree

‎src/wp-includes/abilities/class-wp-abilities-content.php‎

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* Registers the read-only `core/content-query` ability, which queries the readable posts of a
1616
* post type exposed to abilities via `show_in_abilities`, filtered by any of post IDs, slugs,
1717
* status, author, and parent. Raw fields are only returned for posts the current user can
18-
* edit.
18+
* edit, and the author's name and slug only for authors the current user may see.
1919
*
2020
* Unlike the core abilities registered directly in wp_register_core_abilities() as
2121
* self-contained closures, the content ability lives in a dedicated class because
@@ -92,6 +92,14 @@ final class WP_Abilities_Content {
9292
*/
9393
private const LOOP_GLOBALS = array( 'post', 'id', 'authordata', 'currentday', 'currentmonth', 'page', 'pages', 'multipage', 'more', 'numpages' );
9494

95+
/**
96+
* Whether each author seen in the current query has posts the current user may read.
97+
*
98+
* @since 7.2.0
99+
* @var array<int, bool>
100+
*/
101+
private $visible_authors = array();
102+
95103
/**
96104
* Registers all content abilities.
97105
*
@@ -660,6 +668,8 @@ private function execute_content_query( $input = array() ) {
660668
update_post_parent_caches( $query->posts );
661669
update_post_author_caches( $query->posts );
662670

671+
$this->visible_authors = array();
672+
663673
$posts = array();
664674
foreach ( $query->posts as $post ) {
665675
/*
@@ -1081,11 +1091,11 @@ private function get_post_properties(): array {
10811091
),
10821092
'name' => array(
10831093
'type' => array( 'string', 'null' ),
1084-
'description' => __( "The author's display name, as the REST API users endpoint returns it in `name`. Null when the author no longer exists." ),
1094+
'description' => __( "The author's display name, as the REST API users endpoint returns it in `name`. Null when the author no longer exists or the current user may not see them." ),
10851095
),
10861096
'slug' => array(
10871097
'type' => array( 'string', 'null' ),
1088-
'description' => __( "The author's user slug (nicename), as the REST API users endpoint returns it in `slug`, and as the `author.slug` filter takes it. Null when the author no longer exists." ),
1098+
'description' => __( "The author's user slug (nicename), as the REST API users endpoint returns it in `slug`, and as the `author.slug` filter takes it. Null when the author no longer exists or the current user may not see them." ),
10891099
),
10901100
),
10911101
'description' => __( 'The post author. Present when the post type supports authors.' ),
@@ -1324,6 +1334,37 @@ private function get_content_query_output_schema(): array {
13241334
);
13251335
}
13261336

1337+
/**
1338+
* Returns the author of a post when the current user may see them.
1339+
*
1340+
* The current user may see themselves, the users they can edit or list, and the authors of
1341+
* posts they can read in a post type exposed to the REST API. A user the current user may
1342+
* not see is reported like a missing one. The post count is kept per author for the query,
1343+
* since count_user_posts() is not cached.
1344+
*
1345+
* @since 7.2.0
1346+
*
1347+
* @param int $user_id The author's user ID.
1348+
* @return WP_User|null The author, or null when they no longer exist or may not be seen.
1349+
*/
1350+
private function get_visible_author( int $user_id ): ?WP_User {
1351+
$user = get_userdata( $user_id );
1352+
if ( ! $user ) {
1353+
return null;
1354+
}
1355+
1356+
if ( get_current_user_id() === $user_id || current_user_can( 'list_users' ) || current_user_can( 'edit_user', $user_id ) ) {
1357+
return $user;
1358+
}
1359+
1360+
if ( ! isset( $this->visible_authors[ $user_id ] ) ) {
1361+
$post_types = get_post_types( array( 'show_in_rest' => true ), 'names' );
1362+
$this->visible_authors[ $user_id ] = (int) count_user_posts( $user_id, $post_types ) > 0;
1363+
}
1364+
1365+
return $this->visible_authors[ $user_id ] ? $user : null;
1366+
}
1367+
13271368
/**
13281369
* Formats a post into the ability output shape.
13291370
*
@@ -1469,7 +1510,10 @@ private function build_post_fields( WP_Post $post, array $fields, bool $can_edit
14691510
}
14701511

14711512
if ( $this->is_field_requested( 'author', $fields ) && post_type_supports( $post_type, 'author' ) ) {
1472-
$user = get_userdata( (int) $post->post_author );
1513+
$user = null;
1514+
if ( $this->is_field_requested( 'author.name', $fields ) || $this->is_field_requested( 'author.slug', $fields ) ) {
1515+
$user = $this->get_visible_author( (int) $post->post_author );
1516+
}
14731517
$author = array();
14741518
if ( $this->is_field_requested( 'author.id', $fields ) ) {
14751519
$author['id'] = (int) $post->post_author;

‎tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2393,6 +2393,153 @@ public function test_author_object_describes_the_author(): void {
23932393
);
23942394
}
23952395

2396+
/**
2397+
* The author's name and slug are returned to the author, to a user who can list users, and,
2398+
* once the author has a published post, to any user who can read the post. Other users get
2399+
* the ID with a null name and slug.
2400+
*
2401+
* @ticket 66268
2402+
* @since 7.2.0
2403+
*
2404+
* @dataProvider data_author_visibility
2405+
*
2406+
* @param string $role The role that reads the post, or `self` for the author.
2407+
* @param string $post_status The status of the author's only post.
2408+
* @param bool $expected_visible Whether the name and slug should be returned.
2409+
*/
2410+
public function test_author_name_and_slug_are_returned_to_users_who_may_see_the_author( string $role, string $post_status, bool $expected_visible ): void {
2411+
$author_id = self::factory()->user->create(
2412+
array(
2413+
'role' => 'contributor',
2414+
'display_name' => 'Hidden Contributor',
2415+
'user_nicename' => 'hidden-contributor',
2416+
)
2417+
);
2418+
$post_id = self::factory()->post->create(
2419+
array(
2420+
'post_author' => $author_id,
2421+
'post_status' => $post_status,
2422+
)
2423+
);
2424+
2425+
if ( 'self' === $role ) {
2426+
wp_set_current_user( $author_id );
2427+
} else {
2428+
$this->login_as( $role );
2429+
}
2430+
$this->register_ability();
2431+
2432+
$result = wp_get_ability( 'core/content-query' )->execute(
2433+
array(
2434+
'where' => array(
2435+
'type' => 'post',
2436+
'ids' => array( $post_id ),
2437+
),
2438+
'fields' => array( 'author' ),
2439+
)
2440+
);
2441+
2442+
$this->assertCount( 1, $result['items'], 'Precondition: the current user should read the post.' );
2443+
$this->assertSame(
2444+
array(
2445+
'id' => $author_id,
2446+
'name' => $expected_visible ? 'Hidden Contributor' : null,
2447+
'slug' => $expected_visible ? 'hidden-contributor' : null,
2448+
),
2449+
$result['items'][0]['author'],
2450+
$expected_visible ? 'The author object should name an author the current user may see.' : 'The author object should keep the ID and null the name and slug of an author the current user may not see.'
2451+
);
2452+
}
2453+
2454+
/**
2455+
* Data provider.
2456+
*
2457+
* @return array<string, array{role: string, post_status: string, expected_visible: bool}>
2458+
*/
2459+
public function data_author_visibility(): array {
2460+
return array(
2461+
'editor, pending post' => array(
2462+
'role' => 'editor',
2463+
'post_status' => 'pending',
2464+
'expected_visible' => false,
2465+
),
2466+
'editor, published post' => array(
2467+
'role' => 'editor',
2468+
'post_status' => 'publish',
2469+
'expected_visible' => true,
2470+
),
2471+
'subscriber, published post' => array(
2472+
'role' => 'subscriber',
2473+
'post_status' => 'publish',
2474+
'expected_visible' => true,
2475+
),
2476+
'administrator, pending post' => array(
2477+
'role' => 'administrator',
2478+
'post_status' => 'pending',
2479+
'expected_visible' => true,
2480+
),
2481+
'the author, own pending post' => array(
2482+
'role' => 'self',
2483+
'post_status' => 'pending',
2484+
'expected_visible' => true,
2485+
),
2486+
);
2487+
}
2488+
2489+
/**
2490+
* Whether an author has posts the current user may read is looked up once per author and
2491+
* query, not once per post, and only when the name or slug is requested.
2492+
*
2493+
* @ticket 66268
2494+
* @since 7.2.0
2495+
*/
2496+
public function test_author_visibility_is_looked_up_once_per_author(): void {
2497+
$post_ids = array();
2498+
foreach ( array( 'contributor', 'contributor', 'author_secondary' ) as $role ) {
2499+
$post_ids[] = self::factory()->post->create(
2500+
array(
2501+
'post_author' => self::$user_ids[ $role ],
2502+
'post_status' => 'pending',
2503+
)
2504+
);
2505+
}
2506+
2507+
$this->login_as( 'editor' );
2508+
$this->register_ability();
2509+
2510+
$looked_up = array();
2511+
add_filter(
2512+
'get_usernumposts',
2513+
static function ( $count, $user_id ) use ( &$looked_up ) {
2514+
$looked_up[] = $user_id;
2515+
return $count;
2516+
},
2517+
10,
2518+
2
2519+
);
2520+
2521+
$ability = wp_get_ability( 'core/content-query' );
2522+
$query = static function ( array $fields ) use ( $ability, $post_ids ) {
2523+
return $ability->execute(
2524+
array(
2525+
'where' => array(
2526+
'type' => 'post',
2527+
'status' => array( 'pending' ),
2528+
'ids' => $post_ids,
2529+
),
2530+
'fields' => $fields,
2531+
)
2532+
);
2533+
};
2534+
2535+
$ids_only = $query( array( 'author.id' ) );
2536+
$this->assertCount( 3, $ids_only['items'], 'Precondition: the editor should read the pending posts.' );
2537+
$this->assertSame( array(), $looked_up, 'A query for the author ID alone should not count any author\'s posts.' );
2538+
2539+
$query( array( 'author.name' ) );
2540+
$this->assertSameSets( array( self::$user_ids['contributor'], self::$user_ids['author_secondary'] ), $looked_up, 'A query for the author name should count each author\'s posts once.' );
2541+
}
2542+
23962543
/**
23972544
* Requested objects that do not apply to the post type are left out, and the post keeps its
23982545
* ID.

0 commit comments

Comments
 (0)