Skip to content

Commit 0705f0e

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 e46ac23 commit 0705f0e

2 files changed

Lines changed: 196 additions & 4 deletions

File tree

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

Lines changed: 49 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
*
@@ -602,6 +610,8 @@ private function execute_content_query( $input = array() ) {
602610
update_post_parent_caches( $query->posts );
603611
update_post_author_caches( $query->posts );
604612

613+
$this->visible_authors = array();
614+
605615
$posts = array();
606616
foreach ( $query->posts as $post ) {
607617
/*
@@ -1067,11 +1077,11 @@ private function get_post_properties(): array {
10671077
),
10681078
'name' => array(
10691079
'type' => array( 'string', 'null' ),
1070-
'description' => __( "The author's display name, as the REST API users endpoint returns it in `name`. Null when the author no longer exists." ),
1080+
'description' => __( "The author's display name, as the REST API users endpoint returns it in `name`. Null when the author no longer exists or that endpoint would not return them to the current user." ),
10711081
),
10721082
'slug' => array(
10731083
'type' => array( 'string', 'null' ),
1074-
'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." ),
1084+
'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 that endpoint would not return them to the current user." ),
10751085
),
10761086
),
10771087
'description' => __( 'The post author. Present when the post type supports authors.' ),
@@ -1297,6 +1307,38 @@ private function get_content_query_output_schema(): array {
12971307
);
12981308
}
12991309

1310+
/**
1311+
* Returns the author of a post when the current user may see them.
1312+
*
1313+
* Mirrors the REST API users endpoint, which returns a user to the current user when they
1314+
* are that user, can edit or list users, or the user has posts they can read in a post type
1315+
* exposed to the REST API. A user the current user may not see is reported like a missing
1316+
* one. The post count is kept per author for the query, since count_user_posts() is not
1317+
* cached.
1318+
*
1319+
* @since 7.2.0
1320+
*
1321+
* @param int $user_id The author's user ID.
1322+
* @return WP_User|null The author, or null when they no longer exist or may not be seen.
1323+
*/
1324+
private function get_visible_author( int $user_id ): ?WP_User {
1325+
$user = get_userdata( $user_id );
1326+
if ( ! $user ) {
1327+
return null;
1328+
}
1329+
1330+
if ( get_current_user_id() === $user_id || current_user_can( 'list_users' ) || current_user_can( 'edit_user', $user_id ) ) {
1331+
return $user;
1332+
}
1333+
1334+
if ( ! isset( $this->visible_authors[ $user_id ] ) ) {
1335+
$post_types = get_post_types( array( 'show_in_rest' => true ), 'names' );
1336+
$this->visible_authors[ $user_id ] = (int) count_user_posts( $user_id, $post_types ) > 0;
1337+
}
1338+
1339+
return $this->visible_authors[ $user_id ] ? $user : null;
1340+
}
1341+
13001342
/**
13011343
* Formats a post into the ability output shape.
13021344
*
@@ -1442,7 +1484,10 @@ private function build_post_fields( WP_Post $post, array $fields, bool $can_edit
14421484
}
14431485

14441486
if ( $this->is_field_requested( 'author', $fields ) && post_type_supports( $post_type, 'author' ) ) {
1445-
$user = get_userdata( (int) $post->post_author );
1487+
$user = null;
1488+
if ( $this->is_field_requested( 'author.name', $fields ) || $this->is_field_requested( 'author.slug', $fields ) ) {
1489+
$user = $this->get_visible_author( (int) $post->post_author );
1490+
}
14461491
$author = array();
14471492
if ( $this->is_field_requested( 'author.id', $fields ) ) {
14481493
$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
@@ -2351,6 +2351,153 @@ public function test_author_object_describes_the_author(): void {
23512351
);
23522352
}
23532353

2354+
/**
2355+
* The author's name and slug are returned to the users the REST API users endpoint returns
2356+
* the author to: the author, a user who can list users, and, once the author has a
2357+
* published post, any user who can read the post. The ID is kept either way.
2358+
*
2359+
* @ticket 66268
2360+
* @since 7.2.0
2361+
*
2362+
* @dataProvider data_author_visibility
2363+
*
2364+
* @param string $role The role that reads the post, or `self` for the author.
2365+
* @param string $post_status The status of the author's only post.
2366+
* @param bool $expected_visible Whether the name and slug should be returned.
2367+
*/
2368+
public function test_author_name_and_slug_follow_the_users_endpoint_visibility( string $role, string $post_status, bool $expected_visible ): void {
2369+
$author_id = self::factory()->user->create(
2370+
array(
2371+
'role' => 'contributor',
2372+
'display_name' => 'Hidden Contributor',
2373+
'user_nicename' => 'hidden-contributor',
2374+
)
2375+
);
2376+
$post_id = self::factory()->post->create(
2377+
array(
2378+
'post_author' => $author_id,
2379+
'post_status' => $post_status,
2380+
)
2381+
);
2382+
2383+
if ( 'self' === $role ) {
2384+
wp_set_current_user( $author_id );
2385+
} else {
2386+
$this->login_as( $role );
2387+
}
2388+
$this->register_ability();
2389+
2390+
$result = wp_get_ability( 'core/content-query' )->execute(
2391+
array(
2392+
'where' => array(
2393+
'type' => 'post',
2394+
'ids' => array( $post_id ),
2395+
),
2396+
'fields' => array( 'author' ),
2397+
)
2398+
);
2399+
2400+
$this->assertCount( 1, $result['items'], 'Precondition: the current user should read the post.' );
2401+
$this->assertSame(
2402+
array(
2403+
'id' => $author_id,
2404+
'name' => $expected_visible ? 'Hidden Contributor' : null,
2405+
'slug' => $expected_visible ? 'hidden-contributor' : null,
2406+
),
2407+
$result['items'][0]['author'],
2408+
$expected_visible ? 'The author object should name an author the users endpoint returns.' : 'The author object should keep the ID and null the name and slug of an author the users endpoint withholds.'
2409+
);
2410+
}
2411+
2412+
/**
2413+
* Data provider.
2414+
*
2415+
* @return array<string, array{role: string, post_status: string, expected_visible: bool}>
2416+
*/
2417+
public function data_author_visibility(): array {
2418+
return array(
2419+
'editor, pending post' => array(
2420+
'role' => 'editor',
2421+
'post_status' => 'pending',
2422+
'expected_visible' => false,
2423+
),
2424+
'editor, published post' => array(
2425+
'role' => 'editor',
2426+
'post_status' => 'publish',
2427+
'expected_visible' => true,
2428+
),
2429+
'subscriber, published post' => array(
2430+
'role' => 'subscriber',
2431+
'post_status' => 'publish',
2432+
'expected_visible' => true,
2433+
),
2434+
'administrator, pending post' => array(
2435+
'role' => 'administrator',
2436+
'post_status' => 'pending',
2437+
'expected_visible' => true,
2438+
),
2439+
'the author, own pending post' => array(
2440+
'role' => 'self',
2441+
'post_status' => 'pending',
2442+
'expected_visible' => true,
2443+
),
2444+
);
2445+
}
2446+
2447+
/**
2448+
* Whether an author has posts the current user may read is looked up once per author and
2449+
* query, not once per post, and only when the name or slug is requested.
2450+
*
2451+
* @ticket 66268
2452+
* @since 7.2.0
2453+
*/
2454+
public function test_author_visibility_is_looked_up_once_per_author(): void {
2455+
$post_ids = array();
2456+
foreach ( array( 'contributor', 'contributor', 'author_secondary' ) as $role ) {
2457+
$post_ids[] = self::factory()->post->create(
2458+
array(
2459+
'post_author' => self::$user_ids[ $role ],
2460+
'post_status' => 'pending',
2461+
)
2462+
);
2463+
}
2464+
2465+
$this->login_as( 'editor' );
2466+
$this->register_ability();
2467+
2468+
$looked_up = array();
2469+
add_filter(
2470+
'get_usernumposts',
2471+
static function ( $count, $user_id ) use ( &$looked_up ) {
2472+
$looked_up[] = $user_id;
2473+
return $count;
2474+
},
2475+
10,
2476+
2
2477+
);
2478+
2479+
$ability = wp_get_ability( 'core/content-query' );
2480+
$query = static function ( array $fields ) use ( $ability, $post_ids ) {
2481+
return $ability->execute(
2482+
array(
2483+
'where' => array(
2484+
'type' => 'post',
2485+
'status' => array( 'pending' ),
2486+
'ids' => $post_ids,
2487+
),
2488+
'fields' => $fields,
2489+
)
2490+
);
2491+
};
2492+
2493+
$ids_only = $query( array( 'author.id' ) );
2494+
$this->assertCount( 3, $ids_only['items'], 'Precondition: the editor should read the pending posts.' );
2495+
$this->assertSame( array(), $looked_up, 'A query for the author ID alone should not count any author\'s posts.' );
2496+
2497+
$query( array( 'author.name' ) );
2498+
$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.' );
2499+
}
2500+
23542501
/**
23552502
* Requested objects that do not apply to the post type are left out, and the post keeps its
23562503
* ID.

0 commit comments

Comments
 (0)