Skip to content

Commit e59d8f0

Browse files
authored
Shared-Inbox: Add support for public activity delivery to all users (#2349)
1 parent a10a0e6 commit e59d8f0

File tree

7 files changed

+290
-21
lines changed

7 files changed

+290
-21
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: changed
3+
4+
Improved delivery of public and follower activities by expanding local recipient handling to include all ActivityPub-capable users and follower collections.

includes/collection/class-actors.php

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -370,38 +370,45 @@ public static function get_collection() {
370370
/**
371371
* Get all active actors, including the Blog actor if enabled.
372372
*
373-
* @return array Array of User and Blog actor objects.
373+
* @return int[] Array of User and Blog actor IDs.
374374
*/
375-
public static function get_all() {
376-
$return = array();
375+
public static function get_all_ids() {
376+
$user_ids = array();
377377

378378
if ( ! is_user_type_disabled( 'user' ) ) {
379-
$users = \get_users(
379+
$user_ids = \get_users(
380380
array(
381+
'fields' => 'ID',
381382
'capability__in' => array( 'activitypub' ),
382383
)
383384
);
384-
385-
foreach ( $users as $user ) {
386-
$actor = User::from_wp_user( $user->ID );
387-
388-
if ( \is_wp_error( $actor ) ) {
389-
continue;
390-
}
391-
392-
$return[] = $actor;
393-
}
394385
}
395386

396387
// Also include the blog actor if active.
397388
if ( ! is_user_type_disabled( 'blog' ) ) {
398-
$blog_actor = self::get_by_id( self::BLOG_USER_ID );
399-
if ( ! \is_wp_error( $blog_actor ) ) {
400-
$return[] = $blog_actor;
401-
}
389+
$user_ids[] = self::BLOG_USER_ID;
402390
}
403391

404-
return $return;
392+
return array_map( 'intval', $user_ids );
393+
}
394+
395+
/**
396+
* Get all active actors, including the Blog actor if enabled.
397+
*
398+
* @return Actor[] Array of User and Blog actor objects.
399+
*/
400+
public static function get_all() {
401+
$user_ids = self::get_all_ids();
402+
403+
$actors = array_map( array( self::class, 'get_by_id' ), $user_ids );
404+
405+
// Filter out any WP_Error instances.
406+
return array_filter(
407+
$actors,
408+
function ( $actor ) {
409+
return ! \is_wp_error( $actor );
410+
}
411+
);
405412
}
406413

407414
/**

includes/collection/class-following.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,27 @@ public static function check_status( $user_id, $post_id ) {
419419
return false;
420420
}
421421

422+
/**
423+
* Get local user IDs following a given remote actor.
424+
*
425+
* @param string $actor_url The actor URL.
426+
*
427+
* @return int[] List of local user IDs following the actor.
428+
*/
429+
public static function get_follower_ids( $actor_url ) {
430+
$actor = Remote_Actors::get_by_uri( $actor_url );
431+
if ( \is_wp_error( $actor ) ) {
432+
return array();
433+
}
434+
435+
$user_ids = \get_post_meta( $actor->ID, self::FOLLOWING_META_KEY, false );
436+
if ( ! is_array( $user_ids ) || empty( $user_ids ) ) {
437+
return array();
438+
}
439+
440+
return array_map( 'intval', $user_ids );
441+
}
442+
422443
/**
423444
* Remove blocked actors from following list.
424445
*

includes/functions.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1712,6 +1712,26 @@ function is_actor( $data ) {
17121712
return _is_type_of( $data, $types );
17131713
}
17141714

1715+
/**
1716+
* Check if an `$data` is a Collection.
1717+
*
1718+
* @see https://www.w3.org/ns/activitystreams#collections
1719+
*
1720+
* @param array|object|string $data The data to check.
1721+
*
1722+
* @return boolean True if the `$data` is a Collection, false otherwise.
1723+
*/
1724+
function is_collection( $data ) {
1725+
/**
1726+
* Filters the collection types.
1727+
*
1728+
* @param array $types The collection types.
1729+
*/
1730+
$types = apply_filters( 'activitypub_collection_types', array( 'Collection', 'OrderedCollection', 'CollectionPage', 'OrderedCollectionPage' ) );
1731+
1732+
return _is_type_of( $data, $types );
1733+
}
1734+
17151735
/**
17161736
* Private helper to check if $data is of a given type set.
17171737
*

includes/rest/class-inbox-controller.php

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@
99

1010
use Activitypub\Activity\Activity;
1111
use Activitypub\Collection\Actors;
12+
use Activitypub\Collection\Following;
13+
use Activitypub\Http;
1214
use Activitypub\Moderation;
1315

1416
use function Activitypub\camel_to_snake_case;
1517
use function Activitypub\extract_recipients_from_activity;
18+
use function Activitypub\is_activity_public;
19+
use function Activitypub\is_collection;
1620
use function Activitypub\is_same_domain;
1721
use function Activitypub\user_can_activitypub;
1822

@@ -284,13 +288,29 @@ public function get_item_schema() {
284288
* @return array An array of user IDs who are the recipients of the activity.
285289
*/
286290
private function get_local_recipients( $activity ) {
291+
// Public activity, deliver to all local ActivityPub users.
292+
if ( is_activity_public( $activity ) ) {
293+
return Actors::get_all_ids();
294+
}
295+
287296
$recipients = extract_recipients_from_activity( $activity );
288297
$user_ids = array();
289298

290299
foreach ( $recipients as $recipient ) {
291300

292301
if ( ! is_same_domain( $recipient ) ) {
293-
continue;
302+
$collection = Http::get_remote_object( $recipient );
303+
304+
// If it is a remote actor we can skip it.
305+
if ( \is_wp_error( $collection ) ) {
306+
continue;
307+
}
308+
309+
if ( is_collection( $collection ) ) {
310+
$_user_ids = Following::get_follower_ids( $activity['actor'] );
311+
$user_ids = array_merge( $user_ids, $_user_ids );
312+
continue;
313+
}
294314
}
295315

296316
$user_id = Actors::get_id_by_resource( $recipient );
@@ -306,6 +326,6 @@ private function get_local_recipients( $activity ) {
306326
$user_ids[] = $user_id;
307327
}
308328

309-
return $user_ids;
329+
return array_unique( array_map( 'intval', $user_ids ) );
310330
}
311331
}

tests/phpunit/tests/includes/collection/class-test-following.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,49 @@ public function test_unfollow() {
441441
$this->assertCount( 1, $posts );
442442
}
443443

444+
/**
445+
* Test get_follower_ids method.
446+
*
447+
* @covers ::get_follower_ids
448+
*/
449+
public function test_get_follower_ids() {
450+
\add_filter( 'activitypub_pre_http_get_remote_object', array( $this, 'mock_remote_actor' ), 10, 2 );
451+
452+
// Create a remote actor by fetching (which will use the mock).
453+
$remote_actor = Remote_Actors::fetch_by_uri( 'https://example.com/actor/1' );
454+
$this->assertNotWPError( $remote_actor );
455+
456+
// Test with no followers.
457+
$user_ids = Following::get_follower_ids( 'https://example.com/actor/1' );
458+
$this->assertIsArray( $user_ids );
459+
$this->assertEmpty( $user_ids );
460+
461+
// Add some followers.
462+
$user_id_1 = 1;
463+
$user_id_2 = 2;
464+
$user_id_3 = 3;
465+
466+
\add_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, $user_id_1 );
467+
\add_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, $user_id_2 );
468+
\add_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, $user_id_3 );
469+
470+
// Get user IDs.
471+
$user_ids = Following::get_follower_ids( 'https://example.com/actor/1' );
472+
$this->assertIsArray( $user_ids );
473+
$this->assertCount( 3, $user_ids );
474+
$this->assertContains( $user_id_1, $user_ids );
475+
$this->assertContains( $user_id_2, $user_ids );
476+
$this->assertContains( $user_id_3, $user_ids );
477+
478+
// Test with non-existent actor URL.
479+
$user_ids = Following::get_follower_ids( 'https://example.com/actor/nonexistent' );
480+
$this->assertIsArray( $user_ids );
481+
$this->assertEmpty( $user_ids );
482+
483+
\wp_delete_post( $remote_actor->ID );
484+
\remove_filter( 'activitypub_pre_http_get_remote_object', array( $this, 'mock_remote_actor' ) );
485+
}
486+
444487
/**
445488
* Mock remote actor.
446489
*

tests/phpunit/tests/includes/rest/class-test-inbox-controller.php

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,4 +542,158 @@ public function test_get_local_recipients_with_malformed_urls() {
542542
$result = $method->invoke( $this->inbox_controller, $activity );
543543
$this->assertEmpty( $result, 'Should handle malformed URLs gracefully' );
544544
}
545+
546+
/**
547+
* Test get_local_recipients with public activity.
548+
*
549+
* @covers ::get_local_recipients
550+
*/
551+
public function test_get_local_recipients_public_activity() {
552+
// Enable actor mode to allow user actors.
553+
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE );
554+
555+
// Create additional test users (authors have activitypub capability by default).
556+
$user_id_1 = self::factory()->user->create( array( 'role' => 'author' ) );
557+
$user_id_2 = self::factory()->user->create( array( 'role' => 'author' ) );
558+
$user_id_3 = self::factory()->user->create( array( 'role' => 'editor' ) );
559+
560+
// Create a remote actor and make our users follow them.
561+
$remote_actor_url = 'https://example.com/actor/1';
562+
563+
// Mock the remote actor fetch.
564+
\add_filter(
565+
'activitypub_pre_http_get_remote_object',
566+
function ( $pre, $url ) use ( $remote_actor_url ) {
567+
if ( $url === $remote_actor_url ) {
568+
return array(
569+
'@context' => 'https://www.w3.org/ns/activitystreams',
570+
'id' => $remote_actor_url,
571+
'type' => 'Person',
572+
'preferredUsername' => 'testactor',
573+
'name' => 'Test Actor',
574+
'inbox' => 'https://example.com/actor/1/inbox',
575+
);
576+
}
577+
return $pre;
578+
},
579+
10,
580+
2
581+
);
582+
583+
$remote_actor = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $remote_actor_url );
584+
585+
// Make users follow the remote actor.
586+
\add_post_meta( $remote_actor->ID, '_activitypub_followers', self::$user_id );
587+
\add_post_meta( $remote_actor->ID, '_activitypub_followers', $user_id_1 );
588+
\add_post_meta( $remote_actor->ID, '_activitypub_followers', $user_id_2 );
589+
\add_post_meta( $remote_actor->ID, '_activitypub_followers', $user_id_3 );
590+
591+
// Public activity with "to" containing the public collection.
592+
$activity = array(
593+
'type' => 'Create',
594+
'actor' => $remote_actor_url,
595+
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
596+
'cc' => array( 'https://external.example.com/followers' ),
597+
);
598+
599+
// Use reflection to test the private method.
600+
$reflection = new \ReflectionClass( $this->inbox_controller );
601+
$method = $reflection->getMethod( 'get_local_recipients' );
602+
$method->setAccessible( true );
603+
604+
$result = $method->invoke( $this->inbox_controller, $activity );
605+
606+
// Should return users who follow the remote actor.
607+
$this->assertNotEmpty( $result, 'Should return users for public activity' );
608+
$this->assertContains( self::$user_id, $result, 'Should contain test user' );
609+
$this->assertContains( $user_id_1, $result, 'Should contain user 1' );
610+
$this->assertContains( $user_id_2, $result, 'Should contain user 2' );
611+
$this->assertContains( $user_id_3, $result, 'Should contain user 3' );
612+
613+
// Verify it returns exactly the followers we added.
614+
// Note: May include blog user (0) if blog mode is enabled.
615+
$this->assertGreaterThanOrEqual( 4, count( $result ), 'Should return at least 4 followers' );
616+
$this->assertLessThanOrEqual( 5, count( $result ), 'Should return at most 5 followers (4 users + optional blog)' );
617+
618+
// Clean up.
619+
\wp_delete_post( $remote_actor->ID, true );
620+
\wp_delete_user( $user_id_1 );
621+
\wp_delete_user( $user_id_2 );
622+
\wp_delete_user( $user_id_3 );
623+
\delete_option( 'activitypub_actor_mode' );
624+
\remove_all_filters( 'activitypub_pre_http_get_remote_object' );
625+
}
626+
627+
/**
628+
* Test get_local_recipients with public activity using "cc" field.
629+
*
630+
* @covers ::get_local_recipients
631+
*/
632+
public function test_get_local_recipients_public_activity_in_cc() {
633+
// Enable actor mode to allow user actors.
634+
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE );
635+
636+
// Create a test user (authors have activitypub capability by default).
637+
$user_id = self::factory()->user->create( array( 'role' => 'author' ) );
638+
639+
// Create a remote actor and make our users follow them.
640+
$remote_actor_url = 'https://example.com/actor/1';
641+
642+
// Mock the remote actor fetch.
643+
\add_filter(
644+
'activitypub_pre_http_get_remote_object',
645+
function ( $pre, $url ) use ( $remote_actor_url ) {
646+
if ( $url === $remote_actor_url ) {
647+
return array(
648+
'@context' => 'https://www.w3.org/ns/activitystreams',
649+
'id' => $remote_actor_url,
650+
'type' => 'Person',
651+
'preferredUsername' => 'testactor',
652+
'name' => 'Test Actor',
653+
'inbox' => 'https://example.com/actor/1/inbox',
654+
);
655+
}
656+
return $pre;
657+
},
658+
10,
659+
2
660+
);
661+
662+
$remote_actor = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $remote_actor_url );
663+
664+
// Make users follow the remote actor.
665+
\add_post_meta( $remote_actor->ID, '_activitypub_followers', self::$user_id );
666+
\add_post_meta( $remote_actor->ID, '_activitypub_followers', $user_id );
667+
668+
// Public activity with "cc" containing the public collection.
669+
$activity = array(
670+
'type' => 'Create',
671+
'actor' => $remote_actor_url,
672+
'to' => array( 'https://external.example.com/user/specific' ),
673+
'cc' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
674+
);
675+
676+
// Use reflection to test the private method.
677+
$reflection = new \ReflectionClass( $this->inbox_controller );
678+
$method = $reflection->getMethod( 'get_local_recipients' );
679+
$method->setAccessible( true );
680+
681+
$result = $method->invoke( $this->inbox_controller, $activity );
682+
683+
// Should return users who follow the remote actor because activity is public.
684+
$this->assertNotEmpty( $result, 'Should return users for public activity in cc' );
685+
$this->assertContains( self::$user_id, $result, 'Should contain original test user' );
686+
$this->assertContains( $user_id, $result, 'Should contain new test user' );
687+
688+
// Verify it returns exactly the followers we added.
689+
// Note: May include blog user (0) if blog mode is enabled.
690+
$this->assertGreaterThanOrEqual( 2, count( $result ), 'Should return at least 2 followers' );
691+
$this->assertLessThanOrEqual( 3, count( $result ), 'Should return at most 3 followers (2 users + optional blog)' );
692+
693+
// Clean up.
694+
\wp_delete_post( $remote_actor->ID, true );
695+
\wp_delete_user( $user_id );
696+
\delete_option( 'activitypub_actor_mode' );
697+
\remove_all_filters( 'activitypub_pre_http_get_remote_object' );
698+
}
545699
}

0 commit comments

Comments
 (0)