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.
*