diff --git a/.github/changelog/restore-akismet-nonce-after-federated-comment b/.github/changelog/restore-akismet-nonce-after-federated-comment new file mode 100644 index 0000000000..7f82bd9851 --- /dev/null +++ b/.github/changelog/restore-akismet-nonce-after-federated-comment @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Restore spam-protection checks for regular comments after a comment arrives from the fediverse. diff --git a/includes/collection/class-actors.php b/includes/collection/class-actors.php index 481527f132..e904177a49 100644 --- a/includes/collection/class-actors.php +++ b/includes/collection/class-actors.php @@ -235,13 +235,8 @@ public static function get_id_by_resource( $uri ) { ); } - $scheme = 'acct'; - $match = array(); - // Try to extract the scheme and the host. - if ( \preg_match( '/^([a-zA-Z^:]+):(.*)$/i', $uri, $match ) ) { - // Extract the scheme. - $scheme = \esc_attr( $match[1] ); - } + // Extract the scheme, defaulting to `acct` for bare `user@host` handles. + $scheme = \wp_parse_url( $uri, PHP_URL_SCHEME ) ?: 'acct'; // @todo: handle old domain URIs here before we serve a new domain below when we shouldn't. // Although maybe passing through to ::get_by_username() is enough? diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index beb71fa95e..b9c297da9f 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -62,7 +62,6 @@ public static function add( $user_id, $actor ) { if ( \is_array( $post_meta ) && ! \in_array( (string) $user_id, $post_meta, true ) ) { \add_post_meta( $post_id, self::FOLLOWER_META_KEY, $user_id ); \wp_cache_delete( \sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' ); - \wp_cache_delete( Remote_Actors::CACHE_KEY_INBOXES, 'activitypub' ); } return $post_id; @@ -100,7 +99,6 @@ public static function remove( $post_or_id, $user_id ) { } \wp_cache_delete( \sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' ); - \wp_cache_delete( Remote_Actors::CACHE_KEY_INBOXES, 'activitypub' ); /** * Fires before a Follower is removed. @@ -330,49 +328,29 @@ public static function get_inboxes( $user_id ) { $cache_key = \sprintf( self::CACHE_KEY_INBOXES, $user_id ); $inboxes = \wp_cache_get( $cache_key, 'activitypub' ); - if ( $inboxes ) { + if ( false !== $inboxes ) { return $inboxes; } - // Get all Followers of an ID of the WordPress User. - $posts = new \WP_Query( - array( - 'nopaging' => true, - 'post_type' => Remote_Actors::POST_TYPE, - 'fields' => 'ids', - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - 'relation' => 'AND', - array( - 'key' => '_activitypub_inbox', - 'compare' => 'EXISTS', - ), - array( - 'key' => self::FOLLOWER_META_KEY, - 'value' => $user_id, - ), - array( - 'key' => '_activitypub_inbox', - 'value' => '', - 'compare' => '!=', - ), - ), - ) - ); - - if ( ! $posts->posts ) { - return array(); - } - global $wpdb; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_col( $wpdb->prepare( - "SELECT DISTINCT meta_value FROM {$wpdb->postmeta} - WHERE post_id IN (" . \implode( ', ', \array_fill( 0, \absint( $posts->post_count ), '%d' ) ) . ") - AND meta_key = '_activitypub_inbox' - AND meta_value IS NOT NULL", - $posts->posts + "SELECT DISTINCT inbox.meta_value + FROM {$wpdb->postmeta} inbox + INNER JOIN {$wpdb->postmeta} follower ON follower.post_id = inbox.post_id + INNER JOIN {$wpdb->posts} actor ON actor.ID = inbox.post_id + WHERE actor.post_type = %s + AND actor.post_status = %s + AND inbox.meta_key = '_activitypub_inbox' + AND inbox.meta_value <> '' + AND follower.meta_key = %s + AND follower.meta_value = %d", + Remote_Actors::POST_TYPE, + 'publish', + self::FOLLOWER_META_KEY, + $user_id ) ); @@ -433,12 +411,12 @@ public static function remove_blocked_actors( $value, $type, $user_id ) { return; } - $actor_id = Actors::get_id_by_various( $value ); - if ( \is_wp_error( $actor_id ) ) { + $remote_actor = Remote_Actors::get_by_uri( $value ); + if ( \is_wp_error( $remote_actor ) ) { return; } - self::remove( $actor_id, $user_id ); + self::remove( $remote_actor, $user_id ); } /** diff --git a/includes/collection/class-following.php b/includes/collection/class-following.php index 1824a560d6..dc0cd927d8 100644 --- a/includes/collection/class-following.php +++ b/includes/collection/class-following.php @@ -542,12 +542,12 @@ public static function remove_blocked_actors( $value, $type, $user_id ) { return; } - $actor_id = Actors::get_id_by_various( $value ); - if ( \is_wp_error( $actor_id ) ) { + $remote_actor = Remote_Actors::get_by_uri( $value ); + if ( \is_wp_error( $remote_actor ) ) { return; } - self::unfollow( $actor_id, $user_id ); + self::unfollow( $remote_actor, $user_id ); } /** diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 1481f17425..896265cd77 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -9,6 +9,7 @@ use Activitypub\Comment; use Activitypub\Emoji; +use Activitypub\Sanitize; use Activitypub\Webfinger; use function Activitypub\get_remote_metadata_by_actor; @@ -37,14 +38,9 @@ class Interactions { * @return int|false|\WP_Error The comment ID or false or WP_Error on failure. */ public static function add_comment( $activity, $user_id = null ) { - $comment_data = self::activity_to_comment( $activity, $user_id ); - - if ( ! $comment_data ) { - return false; - } - // Determine target URL from reply or quote. $parent_comment_id = 0; + $is_quote = false; if ( ! empty( $activity['object']['inReplyTo'] ) ) { // Regular reply. @@ -58,39 +54,32 @@ public static function add_comment( $activity, $user_id = null ) { return false; } - // Mark as quote and clean content. - $comment_data['comment_type'] = 'quote'; - - if ( ! empty( $activity['object']['content'] ) ) { - $pattern = '/]*class=["\']quote-inline["\'][^>]*>.*?<\/p>/is'; - $cleaned_content = \preg_replace( $pattern, '', $activity['object']['content'], 1 ); - $comment_data['comment_content'] = \wp_kses_post( $cleaned_content ); - } + $is_quote = true; } - // Get post ID from target URL. - $target_url = \esc_url_raw( $target_url ); - $comment_post_id = \url_to_postid( $target_url ); + $comment_post_id = self::resolve_post_id( $target_url, $parent_comment_id ); if ( ! $comment_post_id ) { - // Check for `ap_post`. - $comment_post = Remote_Posts::get_by_guid( $target_url ); - if ( $comment_post instanceof \WP_Post ) { - $comment_post_id = $comment_post->ID; - } + // Not a reply to a post or comment. + return false; } - // Handle nested replies (replies to comments). - if ( ! $comment_post_id && $parent_comment_id ) { - $parent_comment = \get_comment( $parent_comment_id ); - $comment_post_id = $parent_comment->comment_post_ID; - } + $comment_data = self::activity_to_comment( $activity, $user_id ); - if ( ! $comment_post_id ) { - // Not a reply to a post or comment. + if ( ! $comment_data ) { return false; } + if ( $is_quote ) { + $comment_data['comment_type'] = 'quote'; + + if ( ! empty( $activity['object']['content'] ) ) { + $pattern = '/]*class=["\']quote-inline["\'][^>]*>.*?<\/p>/is'; + $cleaned_content = \preg_replace( $pattern, '', $activity['object']['content'], 1 ); + $comment_data['comment_content'] = \wp_kses_post( $cleaned_content ); + } + } + $comment_data['comment_post_ID'] = $comment_post_id; $comment_data['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0; @@ -164,21 +153,8 @@ public static function update_comment( $activity ) { */ public static function add_reaction( $activity ) { $url = object_to_uri( $activity['object'] ); - $comment_post_id = \url_to_postid( $url ); $parent_comment_id = url_to_commentid( $url ); - - if ( ! $comment_post_id ) { - // Check for `ap_post`. - $comment_post = Remote_Posts::get_by_guid( $url ); - if ( $comment_post instanceof \WP_Post ) { - $comment_post_id = $comment_post->ID; - } - } - - if ( ! $comment_post_id && $parent_comment_id ) { - $parent_comment = \get_comment( $parent_comment_id ); - $comment_post_id = $parent_comment->comment_post_ID; - } + $comment_post_id = self::resolve_post_id( $url, $parent_comment_id ); if ( ! $comment_post_id ) { // Not a reply to a post or comment. @@ -205,6 +181,41 @@ public static function add_reaction( $activity ) { return self::persist( $comment_data ); } + /** + * Resolve an interaction target to its WordPress post ID. + * + * @since unreleased + * + * @param string $url The target URL. + * @param int $parent_comment_id Optional. The resolved parent comment ID. + * + * @return int The post ID, or 0 when the target is unknown. + */ + private static function resolve_post_id( $url, $parent_comment_id = 0 ) { + if ( ! $url ) { + return 0; + } + + $url = \esc_url_raw( $url ); + $post_id = \url_to_postid( $url ); + + if ( ! $post_id ) { + $remote_post = Remote_Posts::get_by_guid( $url ); + if ( $remote_post instanceof \WP_Post ) { + $post_id = $remote_post->ID; + } + } + + if ( ! $post_id && $parent_comment_id ) { + $parent_comment = \get_comment( $parent_comment_id ); + if ( $parent_comment instanceof \WP_Comment ) { + $post_id = $parent_comment->comment_post_ID; + } + } + + return (int) $post_id; + } + /** * Get interaction(s) by ID. * @@ -362,6 +373,19 @@ public static function allowed_comment_html( $allowed_tags, $context = '' ) { return $allowed_tags; } + /** + * Force Akismet's comment nonce check to `inactive` while persisting. + * + * Inbound activities have no browser-issued nonce, so Akismet's nonce + * verification cannot apply to this submission route. A named method (rather + * than an anonymous closure) is used so it can be removed by reference again. + * + * @return string Always `inactive`. + */ + public static function akismet_comment_nonce_inactive() { + return 'inactive'; + } + /** * Convert an Activity to a WP_Comment. * @@ -374,95 +398,139 @@ public static function allowed_comment_html( $allowed_tags, $context = '' ) { * @return array|false The comment data or false on failure. */ public static function activity_to_comment( $activity, $user_id = null ) { - $comment_content = null; + $comment = $user_id + ? self::prepare_local_comment_data( $activity, $user_id ) + : self::prepare_remote_comment_data( $activity ); - if ( $user_id ) { - // Outbox: resolve author from the local WordPress user. - $user = \get_userdata( $user_id ); + if ( ! $comment ) { + return false; + } - if ( ! $user ) { - return false; - } + $published = $activity['object']['published'] ?? $activity['published'] ?? 'now'; + $gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $published ) ); - $comment_author = $user->display_name; - $comment_author_url = $user->user_url; - $comment_author_email = $user->user_email; - $comment_content = \wp_kses_post( $activity['object']['content'] ?? '' ); - } else { - // S2S: resolve author from remote actor metadata. - $actor = object_to_uri( $activity['actor'] ?? null ); - $actor = get_remote_metadata_by_actor( $actor ); + return \array_merge( + $comment, + array( + 'comment_type' => 'comment', + 'comment_date' => \get_date_from_gmt( $gm_date ), + 'comment_date_gmt' => $gm_date, + ) + ); + } - if ( ! $actor || \is_wp_error( $actor ) ) { - return false; - } + /** + * Prepare comment fields for a local author. + * + * @since unreleased + * + * @param array $activity The Activity array. + * @param int $user_id The local WordPress user ID. + * + * @return array|false The prepared fields, or false when the user is unavailable. + */ + private static function prepare_local_comment_data( $activity, $user_id ) { + $user = \get_userdata( $user_id ); - $comment_author = null; - if ( ! empty( $actor['name'] ) ) { - $comment_author = $actor['name']; - } elseif ( ! empty( $actor['preferredUsername'] ) ) { - $comment_author = $actor['preferredUsername']; - } + if ( ! $user ) { + return false; + } - if ( empty( $comment_author ) && \get_option( 'require_name_email' ) ) { - return false; - } + return array( + 'comment_author' => $user->display_name, + 'comment_author_url' => $user->user_url, + 'comment_content' => \wp_kses_post( $activity['object']['content'] ?? '' ), + 'comment_author_email' => $user->user_email, + 'comment_meta' => array(), + 'user_id' => $user_id, + ); + } - $comment_author = $comment_author ?? \__( 'Anonymous', 'activitypub' ); - $comment_author_url = \esc_url_raw( object_to_uri( $actor['url'] ?? $actor['id'] ) ); + /** + * Prepare comment fields for a remote author. + * + * @since unreleased + * + * @param array $activity The Activity array. + * + * @return array|false The prepared fields, or false when actor data is unavailable. + */ + private static function prepare_remote_comment_data( $activity ) { + $actor = object_to_uri( $activity['actor'] ?? null ); + $actor = get_remote_metadata_by_actor( $actor ); - $webfinger = Webfinger::uri_to_acct( $comment_author_url ); - if ( \is_wp_error( $webfinger ) ) { - $comment_author_email = ''; - } else { - $comment_author_email = \str_replace( 'acct:', '', $webfinger ); - } + if ( ! $actor || \is_wp_error( $actor ) ) { + return false; + } - if ( isset( $activity['object']['content'] ) ) { - /* - * Wrap emoji in content with blocks for runtime replacement. - * Note: Remote images in comments are stripped for security (only emoji allowed). - */ - $content = Emoji::wrap_in_content( $activity['object']['content'], $activity['object'] ); - $comment_content = \addslashes( $content ); - } + $comment_author = null; + if ( ! empty( $actor['name'] ) ) { + $comment_author = $actor['name']; + } elseif ( ! empty( $actor['preferredUsername'] ) ) { + $comment_author = $actor['preferredUsername']; } - $published = $activity['object']['published'] ?? $activity['published'] ?? 'now'; - $gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $published ) ); + if ( empty( $comment_author ) && \get_option( 'require_name_email' ) ) { + return false; + } + + $comment_author = $comment_author ?? \__( 'Anonymous', 'activitypub' ); + $comment_author_url = \esc_url_raw( object_to_uri( $actor['url'] ?? $actor['id'] ) ); + $comment_content = null; + $webfinger = Webfinger::uri_to_acct( $comment_author_url ); + + if ( \is_wp_error( $webfinger ) ) { + $comment_author_email = ''; + } else { + $comment_author_email = Sanitize::webfinger( $webfinger ); + } - $comment_data = array( + if ( isset( $activity['object']['content'] ) ) { + /* + * Wrap emoji in content with blocks for runtime replacement. + * Note: Remote images in comments are stripped for security (only emoji allowed). + */ + $content = Emoji::wrap_in_content( $activity['object']['content'], $activity['object'] ); + $comment_content = \addslashes( $content ); + } + + return array( 'comment_author' => $comment_author, 'comment_author_url' => $comment_author_url, 'comment_content' => $comment_content, - 'comment_type' => 'comment', 'comment_author_email' => $comment_author_email, - 'comment_date' => \get_date_from_gmt( $gm_date ), - 'comment_date_gmt' => $gm_date, - 'comment_meta' => array(), + 'comment_meta' => self::prepare_remote_comment_meta( $activity ), ); + } - if ( $user_id ) { - $comment_data['user_id'] = $user_id; - } else { - $comment_data['comment_meta']['protocol'] = 'activitypub'; - $comment_data['comment_meta']['source_id'] = \esc_url_raw( object_to_uri( $activity['object'] ) ); - - // Store reference to remote actor post. - $actor_uri = object_to_uri( $activity['actor'] ?? null ); - if ( $actor_uri ) { - $remote_actor = Remote_Actors::get_by_uri( $actor_uri ); - if ( ! \is_wp_error( $remote_actor ) ) { - $comment_data['comment_meta']['_activitypub_remote_actor_id'] = $remote_actor->ID; - } - } + /** + * Prepare ActivityPub-specific comment metadata. + * + * @since unreleased + * + * @param array $activity The Activity array. + * + * @return array The comment metadata. + */ + private static function prepare_remote_comment_meta( $activity ) { + $comment_meta = array( + 'protocol' => 'activitypub', + 'source_id' => \esc_url_raw( object_to_uri( $activity['object'] ) ), + ); - if ( isset( $activity['object']['url'] ) ) { - $comment_data['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) ); + $actor_uri = object_to_uri( $activity['actor'] ?? null ); + if ( $actor_uri ) { + $remote_actor = Remote_Actors::get_by_uri( $actor_uri ); + if ( ! \is_wp_error( $remote_actor ) ) { + $comment_meta['_activitypub_remote_actor_id'] = $remote_actor->ID; } } - return $comment_data; + if ( isset( $activity['object']['url'] ) ) { + $comment_meta['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) ); + } + + return $comment_meta; } /** @@ -481,35 +549,86 @@ public static function persist( $comment_data, $action = self::INSERT ) { return false; } - // Disable flood control. - \remove_action( 'check_comment_flood', 'check_comment_flood_db' ); - // Do not require email for AP entries. - \add_filter( 'pre_option_require_name_email', '__return_false' ); - // No nonce possible for this submission route. - \add_filter( - 'akismet_comment_nonce', - static function () { - return 'inactive'; - } - ); - \add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 ); + $is_insert = self::INSERT === $action; + $flood_priority = \has_action( 'check_comment_flood', 'check_comment_flood_db' ); + $akismet_callback = array( self::class, 'akismet_comment_nonce_inactive' ); + $kses_callback = array( self::class, 'allowed_comment_html' ); - if ( self::INSERT === $action ) { + // Disable flood control, restoring it at its original priority afterwards. + if ( false !== $flood_priority ) { + \remove_action( 'check_comment_flood', 'check_comment_flood_db', $flood_priority ); + } + + \add_filter( 'pre_option_require_name_email', '__return_false' ); // Do not require email for AP entries. + \add_filter( 'akismet_comment_nonce', $akismet_callback ); // No nonce possible for this submission route. + \add_filter( 'wp_kses_allowed_html', $kses_callback, 10, 2 ); + + if ( $is_insert ) { $state = \wp_new_comment( $comment_data, true ); } else { $state = \wp_update_comment( $comment_data, true ); } - \remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ) ); + \remove_filter( 'wp_kses_allowed_html', $kses_callback ); + \remove_filter( 'akismet_comment_nonce', $akismet_callback ); \remove_filter( 'pre_option_require_name_email', '__return_false' ); - // Restore flood control. - \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); - if ( 1 === $state ) { + if ( false !== $flood_priority ) { + \add_action( 'check_comment_flood', 'check_comment_flood_db', $flood_priority, 4 ); + } + + if ( ! $is_insert && 1 === $state ) { return $comment_data; - } else { - return $state; // Either WP_Comment, false, a WP_Error, 0, or 1! } + + return $state; // Either a comment ID, false, a WP_Error, or 0. + } + + /** + * Get interaction counts grouped by comment type. + * + * Results are cached against WordPress's comment cache generation so inserts, + * updates, deletions, and comment meta changes invalidate them automatically. + * + * @since unreleased + * + * @param int $post_id The post ID. + * + * @return array Counts keyed by comment type. + */ + public static function get_counts( $post_id ) { + $post_id = \absint( $post_id ); + $last_changed = \wp_cache_get_last_changed( 'comment' ); + $cache_key = "interaction_counts_{$post_id}:{$last_changed}"; + $counts = \wp_cache_get( $cache_key, 'activitypub' ); + + if ( false !== $counts ) { + return $counts; + } + + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cached against the core comment cache generation. + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT comment_type, COUNT(*) AS total + FROM {$wpdb->comments} + WHERE comment_post_ID = %d + AND comment_approved = '1' + GROUP BY comment_type", + $post_id + ), + ARRAY_A + ); + + $counts = array(); + foreach ( $results as $result ) { + $counts[ $result['comment_type'] ] = (int) $result['total']; + } + + \wp_cache_set( $cache_key, $counts, 'activitypub' ); + + return $counts; } /** @@ -521,16 +640,10 @@ static function () { * @return int The total number of interactions. */ public static function count_by_type( $post_id, $type ) { - return \get_comments( - array( - 'post_id' => $post_id, - 'status' => 'approve', - 'type' => $type, - 'count' => true, - 'paging' => false, - 'fields' => 'ids', - ) - ); + $counts = self::get_counts( $post_id ); + $type = \sanitize_key( $type ); + + return $counts[ $type ] ?? 0; } /** diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 2b600cfef2..481638358e 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -524,7 +524,7 @@ private static function get_object_id( $data ) { * @return string The title. */ private static function get_object_title( $activity_object ) { - if ( ! $activity_object ) { + if ( ! $activity_object || \is_array( $activity_object ) ) { return ''; } diff --git a/includes/collection/class-remote-actors.php b/includes/collection/class-remote-actors.php index 69c366b8bb..68ae2ac756 100644 --- a/includes/collection/class-remote-actors.php +++ b/includes/collection/class-remote-actors.php @@ -42,16 +42,25 @@ class Remote_Actors { public static function get_inboxes() { $inboxes = \wp_cache_get( self::CACHE_KEY_INBOXES, 'activitypub' ); - if ( $inboxes ) { + if ( false !== $inboxes ) { return $inboxes; } global $wpdb; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_col( - "SELECT DISTINCT meta_value FROM {$wpdb->postmeta} - WHERE meta_key = '_activitypub_inbox' - AND meta_value IS NOT NULL" + $wpdb->prepare( + "SELECT DISTINCT inbox.meta_value + FROM {$wpdb->postmeta} inbox + INNER JOIN {$wpdb->posts} actor ON actor.ID = inbox.post_id + WHERE actor.post_type = %s + AND actor.post_status = %s + AND inbox.meta_key = '_activitypub_inbox' + AND inbox.meta_value <> ''", + self::POST_TYPE, + 'publish' + ) ); $inboxes = \array_filter( $results ); @@ -106,30 +115,13 @@ public static function upsert( $actor ) { * @return int|\WP_Error Post ID on success, WP_Error on failure. */ public static function create( $actor ) { - if ( \is_array( $actor ) ) { - $actor = Actor::init_from_array( $actor ); - } - $args = self::prepare_custom_post_type( $actor ); if ( \is_wp_error( $args ) ) { return $args; } - $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' ); - if ( $has_kses ) { - // Prevent KSES from corrupting JSON in post_content. - \kses_remove_filters(); - } - - $post_id = \wp_insert_post( $args ); - - if ( $has_kses ) { - // Restore KSES filters. - \kses_init_filters(); - } - - return $post_id; + return self::persist( $args ); } /** @@ -141,10 +133,6 @@ public static function create( $actor ) { * @return int|\WP_Error The post ID or WP_Error. */ public static function update( $post, $actor ) { - if ( \is_array( $actor ) ) { - $actor = Actor::init_from_array( $actor ); - } - $post = \get_post( $post, ARRAY_A ); if ( ! $post ) { @@ -161,7 +149,20 @@ public static function update( $post, $actor ) { return $args; } - $args = \wp_parse_args( $args, $post ); + return self::persist( \wp_parse_args( $args, $post ) ); + } + + /** + * Persist prepared remote actor post data. + * + * @since unreleased + * + * @param array $args Prepared post data. An ID indicates an update. + * + * @return int|\WP_Error Post ID on success, WP_Error on failure. + */ + private static function persist( $args ) { + $is_update = ! empty( $args['ID'] ); $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' ); if ( $has_kses ) { @@ -169,13 +170,17 @@ public static function update( $post, $actor ) { \kses_remove_filters(); } - $post_id = \wp_update_post( $args ); + $post_id = $is_update ? \wp_update_post( $args ) : \wp_insert_post( $args ); if ( $has_kses ) { // Restore KSES filters. \kses_init_filters(); } + if ( $post_id && ! \is_wp_error( $post_id ) ) { + self::clear_inbox_caches( $post_id ); + } + return $post_id; } @@ -187,9 +192,29 @@ public static function update( $post, $actor ) { * @return bool True on success, false on failure. */ public static function delete( $post_id ) { + self::clear_inbox_caches( $post_id ); + return \wp_delete_post( $post_id ); } + /** + * Clear cached inbox lists affected by a remote actor change. + * + * @since unreleased + * + * @param int $post_id The remote actor post ID. + */ + private static function clear_inbox_caches( $post_id ) { + \wp_cache_delete( self::CACHE_KEY_INBOXES, 'activitypub' ); + + $user_ids = \get_post_meta( $post_id, Followers::FOLLOWER_META_KEY, false ); + $user_ids = \array_unique( \array_map( 'intval', $user_ids ) ); + + foreach ( $user_ids as $user_id ) { + \wp_cache_delete( \sprintf( Followers::CACHE_KEY_INBOXES, $user_id ), 'activitypub' ); + } + } + /** * Get a remote actor post by actor URI (guid). * @@ -551,11 +576,15 @@ public static function get_actor( $post ) { /** * Prepare actor object for insert or update as a custom post type. * - * @param Actor $actor The actor data. + * @param array|Actor $actor The actor data. * * @return array|\WP_Error Array of post arguments or WP_Error on failure. */ private static function prepare_custom_post_type( $actor ) { + if ( \is_array( $actor ) ) { + $actor = Actor::init_from_array( $actor ); + } + /* * Reject non-actor objects here, the single chokepoint every * create/update/upsert funnels through, so callers do not each have to @@ -588,39 +617,7 @@ private static function prepare_custom_post_type( $actor ) { $webfinger = \is_wp_error( $webfinger ) ? Webfinger::guess( $actor ) : Sanitize::webfinger( $webfinger ); } - /* - * Temporarily remove mention/hashtag/link filters to prevent infinite recursion when - * storing remote actors with mentions/hashtags in their bios. - * - * PROBLEM: These filters are globally registered on 'init' for all to_json() calls, - * but they're designed for OUTGOING content (federation). When processing mentions in - * an actor's bio during storage, the Mention filter fetches the mentioned actor, which - * then processes mentions in THEIR bio, creating infinite recursion. - * - * SHORTCOMINGS: - * - Fragile: Easy to forget when adding new storage locations (e.g., Inbox storage). - * - Scattered: Same pattern would need to be repeated anywhere we store remote content. - * - Race conditions: If filters are re-added/removed elsewhere, this could break. - * - Not semantic: We're working around a design issue rather than fixing it. - * - * BETTER LONG-TERM SOLUTION: - * Distinguish between "incoming" (storage) and "outgoing" (federation) contexts: - * - INCOMING: Store received ActivityPub data as-is, don't process mentions/hashtags. - * (Remote_Actors::prepare_custom_post_type, Inbox storage) - * - OUTGOING: Process mentions/hashtags when serving our content to other servers. - * (Dispatcher, REST API controllers, Transformers) - */ - \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Mention', 'filter_activity_object' ), 99 ); - \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Hashtag', 'filter_activity_object' ), 99 ); - \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Link', 'filter_activity_object' ), 99 ); - - $actor_json = $actor->to_json(); - $actor_array = $actor->to_array(); - - // Re-add the filters. - \add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Mention', 'filter_activity_object' ), 99 ); - \add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Hashtag', 'filter_activity_object' ), 99 ); - \add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Link', 'filter_activity_object' ), 99 ); + list( $actor_json, $actor_array ) = self::serialize_for_storage( $actor ); $meta_input = array( '_activitypub_inbox' => $inbox, @@ -643,6 +640,45 @@ private static function prepare_custom_post_type( $actor ) { ); } + /** + * Serialize a remote actor without outbound content filters. + * + * Workaround: the Mention/Hashtag/Link filters process content for the *outgoing* (federation) + * context and must not run when storing *incoming* remote data. Suspending them here is the + * stopgap for a missing incoming/outgoing serialization-context split; until that exists, every + * storage path (see also Inbox storage) needs the same treatment. + * + * @since unreleased + * + * @param Actor $actor The actor to serialize. + * + * @return array The actor JSON and array representations. + */ + private static function serialize_for_storage( $actor ) { + $callbacks = array( + array( 'Activitypub\Mention', 'filter_activity_object' ), + array( 'Activitypub\Hashtag', 'filter_activity_object' ), + array( 'Activitypub\Link', 'filter_activity_object' ), + ); + $registered = array(); + + foreach ( $callbacks as $callback ) { + $priority = \has_filter( 'activitypub_activity_object_array', $callback ); + if ( false !== $priority ) { + $registered[] = array( $callback, $priority ); + \remove_filter( 'activitypub_activity_object_array', $callback, $priority ); + } + } + + $serialized = array( $actor->to_json(), $actor->to_array() ); + + foreach ( $registered as $filter ) { + \add_filter( 'activitypub_activity_object_array', $filter[0], $filter[1] ); + } + + return $serialized; + } + /** * Normalize actor identifier to a URI. * diff --git a/tests/phpunit/tests/includes/collection/class-test-followers.php b/tests/phpunit/tests/includes/collection/class-test-followers.php index 00ff5784b8..8f342c1cbe 100644 --- a/tests/phpunit/tests/includes/collection/class-test-followers.php +++ b/tests/phpunit/tests/includes/collection/class-test-followers.php @@ -275,6 +275,32 @@ public function test_remove() { $this->assertEquals( 1, count( $followers ) ); } + /** + * Blocking a remote actor removes it from the follower collection. + * + * @covers ::remove_blocked_actors + */ + public function test_remove_blocked_actor_removes_remote_follower() { + $actor_uri = 'https://remote.example/@admin'; + $remote_actor_id = Remote_Actors::upsert( + array( + 'id' => $actor_uri, + 'type' => 'Person', + 'inbox' => 'https://remote.example/inbox', + 'name' => 'Remote Admin', + 'preferredUsername' => 'admin', + ) + ); + + $this->assertIsInt( $remote_actor_id ); + \add_post_meta( $remote_actor_id, Followers::FOLLOWER_META_KEY, 1 ); + $this->assertTrue( Followers::follows( $remote_actor_id, 1 ) ); + + Followers::remove_blocked_actors( $actor_uri, 'actor', 1 ); + + $this->assertFalse( Followers::follows( $remote_actor_id, 1 ) ); + } + /** * Tests add_duplicate_follower. * @@ -390,6 +416,8 @@ function ( $url ) { * @covers ::get_inboxes */ public function test_get_inboxes() { + \wp_cache_delete( \sprintf( Followers::CACHE_KEY_INBOXES, 1 ), 'activitypub' ); + for ( $i = 0; $i < 30; $i++ ) { $meta = array( 'id' => 'https://example.org/users/' . $i, @@ -407,8 +435,42 @@ public function test_get_inboxes() { \add_post_meta( $id, Followers::FOLLOWER_META_KEY, 1 ); } - $inboxes = Followers::get_inboxes( 1 ); + $other_actor = Remote_Actors::upsert( + array( + 'id' => 'https://example.org/users/other', + 'type' => 'Person', + 'inbox' => 'https://example.org/users/other/inbox', + 'name' => 'other', + 'preferredUsername' => 'other', + ) + ); + \add_post_meta( $other_actor, Followers::FOLLOWER_META_KEY, 2 ); + + $non_actor = \wp_insert_post( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Not an actor', + ) + ); + \add_post_meta( $non_actor, '_activitypub_inbox', 'https://example.org/not-an-actor/inbox' ); + \add_post_meta( $non_actor, Followers::FOLLOWER_META_KEY, 1 ); + + $draft_actor = \wp_insert_post( + array( + 'post_type' => Remote_Actors::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'Draft actor', + ) + ); + \add_post_meta( $draft_actor, '_activitypub_inbox', 'https://example.org/draft/inbox' ); + \add_post_meta( $draft_actor, Followers::FOLLOWER_META_KEY, 1 ); + + global $wpdb; + $query_count = $wpdb->num_queries; + $inboxes = Followers::get_inboxes( 1 ); + $this->assertSame( 1, $wpdb->num_queries - $query_count, 'Follower inboxes should be loaded with one query.' ); $this->assertCount( 30, $inboxes ); wp_cache_delete( sprintf( Followers::CACHE_KEY_INBOXES, 1 ), 'activitypub' ); @@ -437,6 +499,29 @@ public function test_get_inboxes() { $this->assertCount( 30, $inboxes2 ); } + /** + * Empty follower inbox results are served from cache. + * + * @covers ::get_inboxes + */ + public function test_get_inboxes_caches_empty_results() { + $user_id = 999; + $cache_key = \sprintf( Followers::CACHE_KEY_INBOXES, $user_id ); + + \wp_cache_delete( $cache_key, 'activitypub' ); + + global $wpdb; + $query_count = $wpdb->num_queries; + + $this->assertSame( array(), Followers::get_inboxes( $user_id ) ); + $this->assertSame( 1, $wpdb->num_queries - $query_count ); + + $query_count = $wpdb->num_queries; + + $this->assertSame( array(), Followers::get_inboxes( $user_id ) ); + $this->assertSame( 0, $wpdb->num_queries - $query_count ); + } + /** * Tests get_inboxes_for_activity method. * diff --git a/tests/phpunit/tests/includes/collection/class-test-following.php b/tests/phpunit/tests/includes/collection/class-test-following.php index d7710db6c8..064f53654b 100644 --- a/tests/phpunit/tests/includes/collection/class-test-following.php +++ b/tests/phpunit/tests/includes/collection/class-test-following.php @@ -361,6 +361,32 @@ public function test_unfollow_removes_user() { $this->assertNotContains( (string) $user_id, $pending ); } + /** + * Blocking a remote actor removes it from the following collection. + * + * @covers ::remove_blocked_actors + */ + public function test_remove_blocked_actor_removes_remote_following() { + $actor_uri = 'https://remote.example/@admin'; + $remote_actor_id = Remote_Actors::upsert( + array( + 'id' => $actor_uri, + 'type' => 'Person', + 'inbox' => 'https://remote.example/inbox', + 'name' => 'Remote Admin', + 'preferredUsername' => 'admin', + ) + ); + + $this->assertIsInt( $remote_actor_id ); + \add_post_meta( $remote_actor_id, Following::FOLLOWING_META_KEY, 1 ); + $this->assertSame( Following::ACCEPTED, Following::check_status( 1, $remote_actor_id ) ); + + Following::remove_blocked_actors( $actor_uri, 'actor', 1 ); + + $this->assertFalse( Following::check_status( 1, $remote_actor_id ) ); + } + /** * Tests unfollow method. * diff --git a/tests/phpunit/tests/includes/collection/class-test-interactions.php b/tests/phpunit/tests/includes/collection/class-test-interactions.php index 7309e34bd7..1fb64ece2a 100644 --- a/tests/phpunit/tests/includes/collection/class-test-interactions.php +++ b/tests/phpunit/tests/includes/collection/class-test-interactions.php @@ -1222,7 +1222,8 @@ public function test_add_comment_to_ap_post_when_disabled() { * @covers ::add_comment */ public function test_add_comment_returns_false_when_no_post_id() { - $activity = array( + $metadata_requests = 0; + $activity = array( 'actor' => 'https://example.com/users/someone', 'id' => 'https://example.com/activities/orphan', 'object' => array( @@ -1232,22 +1233,18 @@ public function test_add_comment_returns_false_when_no_post_id() { ), ); - // Mock actor metadata. - $metadata_filter = static function () { - return array( - 'name' => 'Someone', - 'preferredUsername' => 'someone', - 'id' => 'https://example.com/users/someone', - 'url' => 'https://example.com/@someone', - ); + $metadata_filter = static function ( $pre ) use ( &$metadata_requests ) { + ++$metadata_requests; + return $pre; }; - \add_filter( 'pre_get_remote_metadata_by_actor', $metadata_filter ); + \add_filter( 'pre_get_remote_metadata_by_actor', $metadata_filter, 99 ); $result = Interactions::add_comment( $activity ); $this->assertFalse( $result, 'Should return false when inReplyTo does not resolve to a post or comment' ); + $this->assertSame( 0, $metadata_requests, 'Unknown targets should be rejected before actor metadata is loaded.' ); - \remove_filter( 'pre_get_remote_metadata_by_actor', $metadata_filter ); + \remove_filter( 'pre_get_remote_metadata_by_actor', $metadata_filter, 99 ); } /** @@ -1549,4 +1546,150 @@ public function test_update_comment_rejects_foreign_actor() { \remove_filter( 'pre_get_remote_metadata_by_actor', $metadata_filter, 10 ); } + + /** + * Persisting a comment must not leave global comment filters attached. + * + * The Akismet nonce override and the other guards are only meant to apply to the + * inbound activity being stored; if they linger they change how every later comment + * in the request is handled. + * + * @covers ::persist + */ + public function test_persist_restores_global_filters() { + Interactions::add_comment( $this->create_test_object( 'https://example.com/persist-cleanup' ) ); + + $this->assertFalse( \has_filter( 'akismet_comment_nonce', array( Interactions::class, 'akismet_comment_nonce_inactive' ) ), 'The Akismet nonce override must be removed after persisting.' ); + $this->assertFalse( \has_filter( 'pre_option_require_name_email', '__return_false' ), 'The require-name-email override must be removed after persisting.' ); + $this->assertFalse( \has_filter( 'wp_kses_allowed_html', array( Interactions::class, 'allowed_comment_html' ) ), 'The KSES override must be removed after persisting.' ); + $this->assertNotFalse( \has_action( 'check_comment_flood', 'check_comment_flood_db' ), 'Flood control must be restored after persisting.' ); + } + + /** + * Interaction counts should share one cached grouped query per post. + * + * @covers ::count_by_type + * @covers ::get_counts + */ + public function test_interaction_counts_use_one_cached_query() { + $post_id = self::factory()->post->create(); + + foreach ( array( 'like', 'like', 'repost', 'quote' ) as $type ) { + \wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => $type, + 'comment_type' => $type, + 'comment_approved' => 1, + ) + ); + } + + \wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'Pending like', + 'comment_type' => 'like', + 'comment_approved' => 0, + ) + ); + + global $wpdb; + $query_count = $wpdb->num_queries; + + $this->assertSame( 2, Interactions::count_by_type( $post_id, 'like' ) ); + $this->assertSame( 1, Interactions::count_by_type( $post_id, 'repost' ) ); + $this->assertSame( 1, Interactions::count_by_type( $post_id, 'quote' ) ); + $this->assertSame( 0, Interactions::count_by_type( $post_id, 'unknown' ) ); + $this->assertSame( 1, $wpdb->num_queries - $query_count, 'All interaction types should be counted by one query.' ); + + \wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'Another like', + 'comment_type' => 'like', + 'comment_approved' => 1, + ) + ); + + $query_count = $wpdb->num_queries; + $this->assertSame( 3, Interactions::count_by_type( $post_id, 'like' ) ); + $this->assertSame( 1, $wpdb->num_queries - $query_count, 'Inserting a comment should invalidate the grouped count cache.' ); + } + + /** + * Persist must distinguish insert IDs from update status values. + * + * @covers ::persist + */ + public function test_persist_returns_insert_id_and_updated_comment_data() { + $comment_data = array( + 'comment_post_ID' => self::$post_id, + 'comment_author' => 'Persistence Test', + 'comment_author_email' => 'persist@example.com', + 'comment_author_url' => 'https://example.com/persist', + 'comment_content' => 'Initial content', + 'comment_type' => 'comment', + ); + + $comment_id = Interactions::persist( $comment_data ); + $this->assertIsInt( $comment_id ); + + $updated_comment = \get_comment( $comment_id, ARRAY_A ); + $updated_comment['comment_content'] = 'Updated content'; + $result = Interactions::persist( $updated_comment, Interactions::UPDATE ); + + $this->assertIsArray( $result ); + $this->assertSame( $comment_id, (int) $result['comment_ID'] ); + } + + /** + * Persist must not remove or relocate hooks registered by another component. + * + * @covers ::persist + */ + public function test_persist_preserves_existing_hook_state() { + $callbacks = array( + array( 'pre_option_require_name_email', '__return_false', 1 ), + array( 'akismet_comment_nonce', array( Interactions::class, 'akismet_comment_nonce_inactive' ), 1 ), + array( 'wp_kses_allowed_html', array( Interactions::class, 'allowed_comment_html' ), 2 ), + ); + $original_priorities = array(); + $flood_priority = \has_action( 'check_comment_flood', 'check_comment_flood_db' ); + + foreach ( $callbacks as $callback ) { + $priority = \has_filter( $callback[0], $callback[1] ); + $original_priorities[] = $priority; + if ( false !== $priority ) { + \remove_filter( $callback[0], $callback[1], $priority ); + } + \add_filter( $callback[0], $callback[1], 42, $callback[2] ); + } + + if ( false !== $flood_priority ) { + \remove_action( 'check_comment_flood', 'check_comment_flood_db', $flood_priority ); + } + \add_action( 'check_comment_flood', 'check_comment_flood_db', 42, 4 ); + + try { + Interactions::add_comment( $this->create_test_object( 'https://example.com/persist-existing-hooks' ) ); + + foreach ( $callbacks as $callback ) { + $this->assertSame( 42, \has_filter( $callback[0], $callback[1] ) ); + } + $this->assertSame( 42, \has_action( 'check_comment_flood', 'check_comment_flood_db' ) ); + } finally { + foreach ( $callbacks as $index => $callback ) { + \remove_filter( $callback[0], $callback[1], 42 ); + if ( false !== $original_priorities[ $index ] ) { + \add_filter( $callback[0], $callback[1], $original_priorities[ $index ], $callback[2] ); + } + } + + \remove_action( 'check_comment_flood', 'check_comment_flood_db', 42 ); + if ( false !== $flood_priority ) { + \add_action( 'check_comment_flood', 'check_comment_flood_db', $flood_priority, 4 ); + } + } + } } diff --git a/tests/phpunit/tests/includes/collection/class-test-remote-actors.php b/tests/phpunit/tests/includes/collection/class-test-remote-actors.php index b6f4410233..884813a6be 100644 --- a/tests/phpunit/tests/includes/collection/class-test-remote-actors.php +++ b/tests/phpunit/tests/includes/collection/class-test-remote-actors.php @@ -7,6 +7,7 @@ namespace Activitypub\Tests\Collection; +use Activitypub\Collection\Followers; use Activitypub\Collection\Remote_Actors; use Activitypub\Mention; @@ -99,6 +100,56 @@ public function test_create_actor() { $this->assertEquals( 'https://remote.example.com/actor/jane-create', $post->guid ); } + /** + * Storage serialization must restore only the outbound filters that existed. + * + * @covers ::create + * @covers ::serialize_for_storage + */ + public function test_create_restores_registered_object_filters() { + $callbacks = array( + array( 'Activitypub\Mention', 'filter_activity_object' ), + array( 'Activitypub\Hashtag', 'filter_activity_object' ), + array( 'Activitypub\Link', 'filter_activity_object' ), + ); + $original_priorities = array(); + + foreach ( $callbacks as $callback ) { + $priority = \has_filter( 'activitypub_activity_object_array', $callback ); + $original_priorities[] = $priority; + if ( false !== $priority ) { + \remove_filter( 'activitypub_activity_object_array', $callback, $priority ); + } + } + + \add_filter( 'activitypub_activity_object_array', $callbacks[0], 42 ); + + try { + $post_id = Remote_Actors::create( + array( + 'id' => 'https://remote.example.com/actor/filter-restore', + 'type' => 'Person', + 'inbox' => 'https://remote.example.com/actor/filter-restore/inbox', + 'name' => 'Filter Restore', + 'preferredUsername' => 'filter-restore', + ) + ); + + $this->assertIsInt( $post_id ); + $this->assertSame( 42, \has_filter( 'activitypub_activity_object_array', $callbacks[0] ) ); + $this->assertFalse( \has_filter( 'activitypub_activity_object_array', $callbacks[1] ) ); + $this->assertFalse( \has_filter( 'activitypub_activity_object_array', $callbacks[2] ) ); + } finally { + \remove_filter( 'activitypub_activity_object_array', $callbacks[0], 42 ); + + foreach ( $callbacks as $index => $callback ) { + if ( false !== $original_priorities[ $index ] ) { + \add_filter( 'activitypub_activity_object_array', $callback, $original_priorities[ $index ] ); + } + } + } + } + /** * Non-actor objects must never be cached, even when they carry the fields the * cache otherwise needs. The type guard lives at the persistence chokepoint so @@ -171,6 +222,77 @@ public function test_update_actor() { $this->assertEquals( 'Jane Doe', $actor_obj->get_name() ); } + /** + * Actor persistence invalidates global and follower-specific inbox caches. + * + * @covers ::create + * @covers ::update + * @covers ::delete + * @covers ::get_inboxes + * @covers ::clear_inbox_caches + */ + public function test_actor_changes_invalidate_inbox_caches() { + $actor = array( + 'id' => 'https://remote.example.com/actor/cache-invalidation', + 'type' => 'Person', + 'url' => 'https://remote.example.com/actor/cache-invalidation', + 'inbox' => 'https://remote.example.com/actor/cache-invalidation/inbox', + 'name' => 'Cache Invalidation', + 'preferredUsername' => 'cache-invalidation', + ); + + \wp_cache_delete( Remote_Actors::CACHE_KEY_INBOXES, 'activitypub' ); + $this->assertSame( array(), Remote_Actors::get_inboxes() ); + + $post_id = Remote_Actors::create( $actor ); + $this->assertIsInt( $post_id ); + $this->assertSame( array( $actor['inbox'] ), Remote_Actors::get_inboxes() ); + + $user_id = 42; + \add_post_meta( $post_id, Followers::FOLLOWER_META_KEY, $user_id ); + $this->assertSame( array( $actor['inbox'] ), Followers::get_inboxes( $user_id ) ); + + $actor['inbox'] = 'https://remote.example.com/actor/cache-invalidation/new-inbox'; + $this->assertSame( $post_id, Remote_Actors::update( $post_id, $actor ) ); + $this->assertSame( array( $actor['inbox'] ), Remote_Actors::get_inboxes() ); + $this->assertSame( array( $actor['inbox'] ), Followers::get_inboxes( $user_id ) ); + + $this->assertInstanceOf( '\WP_Post', Remote_Actors::delete( $post_id ) ); + $this->assertSame( array(), Remote_Actors::get_inboxes() ); + $this->assertSame( array(), Followers::get_inboxes( $user_id ) ); + } + + /** + * The global inbox cache excludes unrelated and unpublished posts and caches an empty result. + * + * @covers ::get_inboxes + */ + public function test_get_inboxes_caches_empty_published_actor_query() { + $non_actor = self::factory()->post->create(); + \add_post_meta( $non_actor, '_activitypub_inbox', 'https://remote.example.com/non-actor/inbox' ); + + $draft_actor = self::factory()->post->create( + array( + 'post_type' => Remote_Actors::POST_TYPE, + 'post_status' => 'draft', + ) + ); + \add_post_meta( $draft_actor, '_activitypub_inbox', 'https://remote.example.com/draft/inbox' ); + + \wp_cache_delete( Remote_Actors::CACHE_KEY_INBOXES, 'activitypub' ); + + global $wpdb; + $query_count = $wpdb->num_queries; + + $this->assertSame( array(), Remote_Actors::get_inboxes() ); + $this->assertSame( 1, $wpdb->num_queries - $query_count ); + + $query_count = $wpdb->num_queries; + + $this->assertSame( array(), Remote_Actors::get_inboxes() ); + $this->assertSame( 0, $wpdb->num_queries - $query_count ); + } + /** * Test the delete (wp_delete_post) operation for remote actors. *