Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 94 additions & 67 deletions src/wp-includes/abilities/class-wp-abilities-content.php
Original file line number Diff line number Diff line change
Expand Up @@ -450,24 +450,72 @@ private function execute_content_query( $input = array() ) {
}
}

$author = null;
if ( isset( $where['author_slug'] ) ) {
/*
* The author filter is an object with an `id` or a `slug`, the keys the `author`
* field returns. Null, int, or false: no filter, the user ID to query, or a filter
* that names no user, which matches no post (see below).
*/
$author_id = null;
if ( array_key_exists( 'author', $where ) ) {
if ( ! post_type_supports( $post_type, 'author' ) ) {
return $this->invalid_filter_error(
'where[author_slug]',
'where[author]',
/* translators: %s: Parameter. */
sprintf( __( 'The %s filter is only supported for post types that support authors.' ), 'author_slug' )
sprintf( __( 'The %s filter is only supported for post types that support authors.' ), 'author' )
);
}

$author = $this->get_author_by_slug( $where['author_slug'], $post_type_object );
if ( ! $author ) {
/*
* An object without `id` or `slug`, such as `{}` or an empty string, and one with
* an unknown key, such as a list or a misspelled property, name no user and must not
* fall through to an unrestricted query, like an empty `ids` filter.
*/
$author_filter = $this->get_input_object( $where, 'author' );
if ( empty( $author_filter ) || ! empty( array_diff_key( $author_filter, array_flip( array( 'id', 'slug' ) ) ) ) ) {
return $this->invalid_filter_error(
'where[author_slug]',
'where[author]',
/* translators: %s: Parameter. */
sprintf( __( 'The %s filter must be the slug of an existing user.' ), 'author_slug' )
sprintf( __( 'The %s filter must be an object with an id or a slug.' ), 'author' )
);
}

/*
* An `id` of 0 must not reach the query: WP_Query reads `author => 0` as no author
* filter, which would return every post of the type.
*/
if ( array_key_exists( 'id', $author_filter ) ) {
$author_id = $this->parse_filter_int( $author_filter['id'], 1 );
if ( null === $author_id ) {
return $this->invalid_filter_error(
'where[author][id]',
/* translators: %s: Parameter. */
sprintf( __( 'The %s ID must be a positive integer.' ), 'author' )
);
}
}

if ( array_key_exists( 'slug', $author_filter ) ) {
if ( ! is_string( $author_filter['slug'] ) || '' === $author_filter['slug'] ) {
return $this->invalid_filter_error(
'where[author][slug]',
/* translators: %s: Parameter. */
sprintf( __( 'The %s slug must be a non-empty string.' ), 'author' )
);
}

/*
* The slug is looked up as given, like the `slug` of the users endpoint: the
* collation of the users table decides whether the case matters. A slug that
* names no user, or names another user than `id`, matches no post, as a post
* must match every given filter.
*/
$user = get_user_by( 'slug', $author_filter['slug'] );
if ( ! $user || ( null !== $author_id && $user->ID !== $author_id ) ) {
$author_id = false;
} else {
$author_id = $user->ID;
}
}
}

/*
Expand Down Expand Up @@ -561,14 +609,24 @@ private function execute_content_query( $input = array() ) {
$query_args['post_name__in'] = $slugs;
}

if ( null !== $author ) {
$query_args['author'] = $author->ID;
if ( is_int( $author_id ) ) {
$query_args['author'] = $author_id;
}

if ( null !== $parent ) {
$query_args['post_parent'] = $parent;
}

/*
* A slug that names no user, or an ID and a slug that name different users, match no
* post, like IDs and slugs that name no post, rather than failing the query. An `id` is
* not looked up: the query matches the stored author ID, so the posts of a deleted user
* still match it.
*/
Comment on lines +620 to +625

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is not correct for IDs. WP_Query matches the author ID saved on the post (the post_author column). It does not check if the user exists. If a user is deleted, their posts can keep the old ID, and { id: <old ID> } still returns them.

I think this is good behavior. The output still returns author.id for these posts, so the caller can use it as a filter. Let's only fix the comment:

Suggested change
/*
* An author filter that names no user matches no post, like IDs and slugs that name
* no post, rather than failing the query. An `id` that names no user needs no lookup:
* the query matches no post on its own.
*/
/*
* A slug that names no user, or an ID and a slug that name different users, match no
* post, like IDs and slugs that name no post, rather than failing the query. An `id` is
* not looked up: the query matches the stored author ID, so the posts of a deleted user
* still match it.
*/

Could you also add a test? Create a post with post_author => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER (like in test_author_object_describes_the_author()), then check that { id: REST_TESTS_IMPOSSIBLY_HIGH_NUMBER } returns it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @gziolo, thank you. The suggestion was applied, and test_author_filter_by_id_matches_the_posts_of_a_deleted_user() now checks that { id: REST_TESTS_IMPOSSIBLY_HIGH_NUMBER } returns the post with that stored author ID.

if ( false === $author_id ) {
return $this->prepare_output( array(), 0, 0 );
}

$query = new WP_Query( $query_args );
$total = $this->get_query_total( $query, $query_args, $page );

Expand Down Expand Up @@ -745,50 +803,6 @@ private function is_field_requested( string $path, array $fields ): bool {
return rest_is_field_included( $path, $fields );
}

/**
* Looks up the user an author slug names.
*
* The slug is the user's nicename, which the REST API users endpoint returns as `slug`. It is
* looked up as given, like that endpoint's `slug` filter, so the database collation decides
* whether a variant in another case matches. A user the current user may not see is reported
* like a missing one. The current user can see themselves, any user of the site when they can
* list users, and authors with posts in a publicly viewable post type. A user who can edit
* others' posts of the post type can also see any user of the site, since they may make any of
* them the author, so for them the lookup does tell whether such an account exists.
*
* On multisite the lookup searches the whole network, so posts by authors who are not
* members of the site, such as super admins, can still be filtered. Those users are only
* visible as the current user or as authors with posts in a publicly viewable post type, so
* the lookup does not tell whether an account exists elsewhere on the network.
*
* @since 7.2.0
*
* @param mixed $slug The author slug.
* @param WP_Post_Type $post_type_object The post type the author is looked up for.
* @return WP_User|null The user, or null when the slug does not name a visible user.
*/
private function get_author_by_slug( $slug, WP_Post_Type $post_type_object ): ?WP_User {
$user = is_string( $slug ) ? get_user_by( 'slug', $slug ) : false;
if ( ! $user ) {
return null;
}

if ( get_current_user_id() === $user->ID ) {
return $user;
}

// The capabilities only reveal users of the site, not of the whole network.
if ( is_user_member_of_blog( $user->ID )
&& ( current_user_can( 'list_users' ) || current_user_can( $post_type_object->cap->edit_others_posts ) )
) {
return $user;
}

$public_post_types = array_values( array_filter( get_post_types(), 'is_post_type_viewable' ) );

return ! empty( $public_post_types ) && count_user_posts( $user->ID, $public_post_types ) > 0 ? $user : null;
}

/**
* Returns a post type exposed through the Abilities API.
*
Expand Down Expand Up @@ -1063,15 +1077,15 @@ private function get_post_properties(): array {
'properties' => array(
'id' => array(
'type' => 'integer',
'description' => __( 'The user ID of the author.' ),
'description' => __( 'The user ID of the author, as the `author.id` filter takes it.' ),
),
'name' => array(
'type' => array( 'string', 'null' ),
'description' => __( "The author's display name, as the REST API users endpoint returns it in `name`. Null when the author no longer exists." ),
),
'slug' => array(
'type' => array( 'string', 'null' ),
'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." ),
'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." ),
),
),
'description' => __( 'The post author. Present when the post type supports authors.' ),
Expand Down Expand Up @@ -1137,7 +1151,7 @@ private function get_fields_input_schema(): array {
* The input has three members: `where` selects the posts, `fields` selects the fields
* returned for each post, and `pagination` selects the page of results. `where` holds
* the post type to query in `type`, and any of the filters `ids`, `slugs`, `status`,
* `author_slug`, and `parent`, which all combine. Every object sets
* `author`, and `parent`, which all combine. Every object sets
* `additionalProperties: false`, so an unknown key, such as a misspelled filter, fails
* validation instead of being silently ignored.
*
Expand All @@ -1158,12 +1172,12 @@ private function get_content_query_input_schema( array $post_types, array $statu
'additionalProperties' => false,
'required' => array( 'type' ),
'properties' => array(
'type' => array(
'type' => array(
'type' => 'string',
'enum' => $post_types,
'description' => __( 'Post type to query for readable posts.' ),
),
'ids' => array(
'ids' => array(
'type' => 'array',
'uniqueItems' => true,
'minItems' => 1,
Expand All @@ -1174,7 +1188,7 @@ private function get_content_query_input_schema( array $post_types, array $statu
),
'description' => __( 'Limit the query to these post IDs. The order of the IDs does not affect the order of the results. If `pagination.per_page` is omitted, every matching post is returned on the first page.' ),
),
'slugs' => array(
'slugs' => array(
'type' => 'array',
'uniqueItems' => true,
'minItems' => 1,
Expand All @@ -1185,7 +1199,7 @@ private function get_content_query_input_schema( array $post_types, array $statu
),
'description' => __( 'Limit the query to posts with these slugs. Each slug is sanitized as a post slug is, so `Hello-World` matches `hello-world`. A slug can match several posts, such as pages under different parents, and every match is returned. The order of the slugs does not affect the order of the results. If `pagination.per_page` is omitted, every matching post is returned on the first page, up to the `per_page` maximum.' ),
),
'status' => array(
'status' => array(
'type' => 'array',
'uniqueItems' => true,
'minItems' => 1,
Expand All @@ -1195,18 +1209,31 @@ private function get_content_query_input_schema( array $post_types, array $statu
),
'description' => __( 'Filter readable posts by one or more post statuses. Defaults to publish, or, when `ids` or `slugs` is given, to every status the current user may query. Statuses that are not publicly viewable, other than private, require edit access.' ),
),
'author_slug' => array(
'type' => 'string',
'minLength' => 1,
'description' => __( "Filter by the author's user slug (nicename), as the REST API users endpoint returns it in `slug`. Only supported for post types that support authors." ),
'author' => array(
'type' => 'object',
'additionalProperties' => false,
'minProperties' => 1,
'properties' => array(
'id' => array(
'type' => 'integer',
'minimum' => 1,
'description' => __( 'The user ID of the author, as `author.id` is returned.' ),
),
'slug' => array(
'type' => 'string',
'minLength' => 1,
'description' => __( "The author's user slug (nicename), as `author.slug` is returned and as the REST API users endpoint returns it in `slug`." ),
),
),
'description' => __( 'Filter by the post author, given as the `id` or the `slug` the `author` field of a post returns. An `id` matches the author ID stored on the post, even when that user no longer exists. A `slug` that names no user, or an `id` and a `slug` that name different users, match no post. Only supported for post types that support authors.' ),
),
'parent' => array(
'parent' => array(
'type' => 'integer',
'minimum' => 0,
'description' => __( 'Filter by parent post ID. Only supported for hierarchical post types. Use 0 for top-level posts.' ),
),
),
'description' => __( 'Selects the posts to return: the readable posts of a `type`, filtered by any of `ids`, `slugs`, `status`, `author_slug`, and `parent`. A post must match every given filter.' ),
'description' => __( 'Selects the posts to return: the readable posts of a `type`, filtered by any of `ids`, `slugs`, `status`, `author`, and `parent`. A post must match every given filter.' ),
),
'fields' => $this->get_fields_input_schema(),
'pagination' => array(
Expand Down
Loading
Loading