Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
523defe
Refactor recipient extraction and add unit tests
pfefferle Sep 18, 2025
51fe673
Rename parameter from attribute to property in function
pfefferle Sep 18, 2025
3b79444
Merge branch 'trunk' into add/inbox-visibility
pfefferle Sep 18, 2025
d25fd5f
Refactor recipient extraction and object_to_uri handling
pfefferle Sep 18, 2025
70ce80f
Filter empty values from recipients array
pfefferle Sep 18, 2025
79429da
Improve object_to_uri fallback and update related tests
pfefferle Sep 18, 2025
858672f
Merge branch 'trunk' into add/inbox-visibility
pfefferle Sep 19, 2025
35dbe06
Refactor recipient extraction and add Audience trait
pfefferle Sep 19, 2025
615bd19
Add visibility parameter to ActivityPub inbox actions
pfefferle Sep 19, 2025
93cf677
Refactor handle_create to use visibility parameter
pfefferle Sep 19, 2025
5e823d1
Set default private visibility for certain activity types
pfefferle Sep 19, 2025
17fb6f9
Reorder activity types in visibility check
pfefferle Sep 19, 2025
2e62107
Add changelog
matticbot Sep 19, 2025
be0b67f
Add unit tests for Audience trait in REST API
pfefferle Sep 19, 2025
4d6bb2f
Merge branch 'trunk' into add/inbox-visibility
pfefferle Sep 19, 2025
17ca93c
Merge branch 'trunk' into add/inbox-visibility
pfefferle Sep 19, 2025
23b92ea
Use global namespace for array functions
pfefferle Sep 19, 2025
4ed4a4a
Update object_to_uri to return empty string on failure
pfefferle Sep 19, 2025
8a731d8
Move recipient extraction logic to Inbox_Controller
pfefferle Sep 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/2210-from-description
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: changed

Improved recipient handling for clarity and added better inbox support.
54 changes: 23 additions & 31 deletions includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -525,40 +525,32 @@ function extract_recipients_from_activity( $data ) {
$recipient_items = array();

foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) {
if ( array_key_exists( $i, $data ) ) {
if ( is_array( $data[ $i ] ) ) {
$recipient = $data[ $i ];
} else {
$recipient = array( $data[ $i ] );
}
$recipient_items = array_merge( $recipient_items, $recipient );
}

if ( is_array( $data['object'] ) && array_key_exists( $i, $data['object'] ) ) {
if ( is_array( $data['object'][ $i ] ) ) {
$recipient = $data['object'][ $i ];
} else {
$recipient = array( $data['object'][ $i ] );
}
$recipient_items = array_merge( $recipient_items, $recipient );
}
$recipient_items = array_merge( $recipient_items, extract_recipients_from_activity_property( $i, $data ) );
}

return array_unique( $recipient_items );
}

/**
* Extract recipient URLs from a specific property of an Activity object.
*
* @param string $property The property to extract recipients from (e.g., 'to', 'cc').
* @param array $data The Activity object as array.
*
* @return array The list of user URLs.
*/
function extract_recipients_from_activity_property( $property, $data ) {
$recipients = array();

// Flatten array.
foreach ( $recipient_items as $recipient ) {
if ( is_array( $recipient ) ) {
// Check if recipient is an object.
if ( array_key_exists( 'id', $recipient ) ) {
$recipients[] = $recipient['id'];
}
} else {
$recipients[] = $recipient;
}
if ( ! empty( $data[ $property ] ) ) {
$recipients = $data[ $property ];
} elseif ( ! empty( $data['object'][ $property ] ) ) {
$recipients = $data['object'][ $property ];
}

return array_unique( $recipients );
$recipients = \array_map( '\Activitypub\object_to_uri', (array) $recipients );

return \array_unique( \array_filter( $recipients ) );
}

/**
Expand Down Expand Up @@ -696,7 +688,7 @@ function url_to_commentid( $url ) {
*
* @param array|string $data The ActivityPub object.
*
* @return string The URI of the ActivityPub object
* @return string|false The URI of the ActivityPub object or false if not found.
Copy link
Member

Choose a reason for hiding this comment

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

One of the strengths of object_to_uri() is that it reliably returns a string and we don't need to check whether it failed. That is rare(!) and I think we should do what we can to keep it that way.

I assume this change is meant to facilitate running array_filter() over the output? Could we use a specific callback in that instance instead?

Copy link
Member Author

@pfefferle pfefferle Sep 19, 2025

Choose a reason for hiding this comment

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

That is not completely true! We have way to often an issue, because of missing id fields! I am fine to change it to empty string, but simply not checking if an attribute is available is not "we don't need to check whether it failed"!

Copy link
Member Author

Choose a reason for hiding this comment

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

From the spec:

All properties are optional (including the id and type).

*/
function object_to_uri( $data ) {
// Check whether it is already simple.
Expand Down Expand Up @@ -733,10 +725,10 @@ function object_to_uri( $data ) {
$data = object_to_uri( $data['url'] );
break;
case 'Link':
$data = $data['href'];
$data = $data['href'] ?? false;
break;
default:
$data = $data['id'];
$data = $data['id'] ?? $data['url'] ?? false;
break;
}

Expand Down
21 changes: 5 additions & 16 deletions includes/handler/class-create.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

use Activitypub\Collection\Interactions;

use function Activitypub\is_activity_public;
use function Activitypub\is_activity_reply;
use function Activitypub\is_self_ping;
use function Activitypub\object_id_to_comment;
Expand All @@ -22,19 +21,8 @@ class Create {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
'activitypub_inbox_create',
array( self::class, 'handle_create' ),
10,
3
);

\add_filter(
'activitypub_validate_object',
array( self::class, 'validate_object' ),
10,
3
);
\add_action( 'activitypub_inbox_create', array( self::class, 'handle_create' ), 10, 4 );
\add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
}

/**
Expand All @@ -43,11 +31,12 @@ public static function init() {
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null.
* @param string $visibility The visibility of the activity.
*/
public static function handle_create( $activity, $user_id, $activity_object = null ) {
public static function handle_create( $activity, $user_id, $activity_object = null, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ) {
// Check if Activity is public or not.
if (
! is_activity_public( $activity ) ||
ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ||
! is_activity_reply( $activity )
) {
return;
Expand Down
23 changes: 14 additions & 9 deletions includes/rest/class-actors-inbox-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/
class Actors_Inbox_Controller extends Actors_Controller {
use Collection;
use Audience;

/**
* Register routes.
Expand Down Expand Up @@ -177,24 +178,28 @@ public function create_item( $request ) {
*/
do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity );
} else {
$visibility = $this->determine_visibility( $data );

/**
* ActivityPub inbox action.
*
* @param array $data The data array.
* @param int|null $user_id The user ID.
* @param string $type The type of the activity.
* @param Activity|\WP_Error $activity The Activity object.
* @param array $data The data array.
* @param int|null $user_id The user ID.
* @param string $type The type of the activity.
* @param Activity|\WP_Error $activity The Activity object.
* @param string $visibility The visibility of the activity.
*/
\do_action( 'activitypub_inbox', $data, $user_id, $type, $activity );
\do_action( 'activitypub_inbox', $data, $user_id, $type, $activity, $visibility );
Copy link
Member

Choose a reason for hiding this comment

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

It's not quite clear to me why the visibility stuff needs to be moved up a level, when (so far) it's only Create that's concerned about it. Can we not just expand is_activity_public() with any improvements this PR aims to make?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, we are currently only use it for Create, but the idea is that implementers do not have to care and simply can hook into the inbox to get all needed infos.

Aside from that, we are about to implement the reader, so we might want to have a more detailed visibility handling and not only a "Public or not".


/**
* ActivityPub inbox action for specific activity types.
*
* @param array $data The data array.
* @param int|null $user_id The user ID.
* @param Activity|\WP_Error $activity The Activity object.
* @param array $data The data array.
* @param int|null $user_id The user ID.
* @param Activity|\WP_Error $activity The Activity object.
* @param string $visibility The visibility of the activity.
*/
\do_action( 'activitypub_inbox_' . $type, $data, $user_id, $activity );
\do_action( 'activitypub_inbox_' . $type, $data, $user_id, $activity, $visibility );
}

$response = \rest_ensure_response(
Expand Down
48 changes: 17 additions & 31 deletions includes/rest/class-inbox-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,8 @@
namespace Activitypub\Rest;

use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Moderation;

use function Activitypub\extract_recipients_from_activity;
use function Activitypub\is_same_domain;
use function Activitypub\user_can_activitypub;

/**
* Inbox_Controller class.
*
Expand All @@ -23,6 +18,8 @@
* @see https://www.w3.org/TR/activitypub/#inbox
*/
class Inbox_Controller extends \WP_REST_Controller {
use Audience;

/**
* The namespace of this controller's route.
*
Expand Down Expand Up @@ -149,25 +146,12 @@ public function create_item( $request ) {
*/
do_action( 'activitypub_rest_inbox_disallowed', $data, null, $type, $activity );
} else {
$recipients = extract_recipients_from_activity( $data );
$recipients = $this->determine_local_recipients( $data );
$visibility = $this->determine_visibility( $data );

foreach ( $recipients as $recipient ) {
if ( ! is_same_domain( $recipient ) ) {
continue;
}

$user_id = Actors::get_id_by_various( $recipient );

if ( \is_wp_error( $user_id ) ) {
continue;
}

if ( ! user_can_activitypub( $user_id ) ) {
continue;
}

// Check user-specific blocks for this recipient.
if ( Moderation::activity_is_blocked_for_user( $activity, $user_id ) ) {
if ( Moderation::activity_is_blocked_for_user( $activity, $recipient ) ) {
/**
* ActivityPub inbox disallowed activity for specific user.
*
Expand All @@ -176,28 +160,30 @@ public function create_item( $request ) {
* @param string $type The type of the activity.
* @param Activity|\WP_Error $activity The Activity object.
*/
\do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity );
\do_action( 'activitypub_rest_inbox_disallowed', $data, $recipient, $type, $activity );
continue;
}

/**
* ActivityPub inbox action.
*
* @param array $data The data array.
* @param int $user_id The user ID.
* @param string $type The type of the activity.
* @param Activity|\WP_Error $activity The Activity object.
* @param array $data The data array.
* @param int $user_id The user ID.
* @param string $type The type of the activity.
* @param Activity|\WP_Error $activity The Activity object.
* @param string $visibility The visibility of the activity.
*/
\do_action( 'activitypub_inbox', $data, $user_id, $type, $activity );
\do_action( 'activitypub_inbox', $data, $recipient, $type, $activity, $visibility );

/**
* ActivityPub inbox action for specific activity types.
*
* @param array $data The data array.
* @param int $user_id The user ID.
* @param Activity|\WP_Error $activity The Activity object.
* @param array $data The data array.
* @param int $user_id The user ID.
* @param Activity|\WP_Error $activity The Activity object.
* @param string $visibility The visibility of the activity.
*/
\do_action( 'activitypub_inbox_' . $type, $data, $user_id, $activity );
\do_action( 'activitypub_inbox_' . $type, $data, $recipient, $activity, $visibility );
}
}

Expand Down
83 changes: 83 additions & 0 deletions includes/rest/trait-audience.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php
/**
* Audience Trait file.
*
* @package Activitypub
*/

namespace Activitypub\Rest;

use Activitypub\Collection\Actors;

use function Activitypub\extract_recipients_from_activity;
use function Activitypub\extract_recipients_from_activity_property;
use function Activitypub\is_same_domain;
use function Activitypub\user_can_activitypub;

/**
* Audience Trait.
*
* Provides methods for handling ActivityPub audience and recipient extraction.
*/
trait Audience {
Copy link
Member

Choose a reason for hiding this comment

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

Could these be methods in Collections\Actors instead? They feel more like utility functions than something that's specific to rest api controllers. A trait doesn't feel like the best place for these.

Copy link
Member Author

Choose a reason for hiding this comment

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

Even if we check the Audience, this is not a function I see in the Actors Collection!

I will see if I can move it to the Activity.

/**
* Extract recipients from the given Activity.
*
* @param array $activity The activity data.
*
* @return array An array of user IDs who are the recipients of the activity.
*/
public function determine_local_recipients( $activity ) {
$recipients = extract_recipients_from_activity( $activity );
$user_ids = array();

foreach ( $recipients as $recipient ) {

if ( ! is_same_domain( $recipient ) ) {
continue;
}

$user_id = Actors::get_id_by_resource( $recipient );

if ( \is_wp_error( $user_id ) ) {
continue;
}

if ( ! user_can_activitypub( $user_id ) ) {
continue;
}

$user_ids[] = $user_id;
}

return $user_ids;
}

/**
* Determine the visibility of the activity based on its recipients.
*
* @param array $activity The activity data.
*
* @return string The visibility level: 'public', 'private', or 'direct'.
*/
public function determine_visibility( $activity ) {
// Set default visibility for specific activity types.
if ( in_array( $activity['type'], array( 'Accept', 'Delete', 'Follow', 'Reject', 'Undo' ), true ) ) {
return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
}

// Check 'to' field for public visibility.
$to = extract_recipients_from_activity_property( 'to', $activity );
if ( ! empty( array_intersect( $to, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) {
return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
}

// Check 'cc' field for quiet public visibility.
$cc = extract_recipients_from_activity_property( 'cc', $activity );
if ( ! empty( array_intersect( $cc, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) {
return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC;
}

return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
}
}
Loading