updated plugin ActivityPub version 8.3.0

This commit is contained in:
2026-06-03 21:28:46 +00:00
committed by Gitium
parent a4b78ec277
commit 6fe182458a
340 changed files with 43232 additions and 7568 deletions

View File

@ -0,0 +1,100 @@
<?php
/**
* Accept handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Collection\Following;
use Activitypub\Collection\Outbox;
use Activitypub\Collection\Remote_Actors;
use function Activitypub\object_to_uri;
/**
* Handle Accept requests.
*/
class Accept {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_accept', array( self::class, 'handle_accept' ), 10, 2 );
\add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
}
/**
* Handles "Accept" requests.
*
* @param array $accept The activity-object.
* @param int|int[] $user_ids The id of the local blog-user.
*/
public static function handle_accept( $accept, $user_ids ) {
// Validate that there is a Follow Activity.
$outbox_post = Outbox::get_by_guid( $accept['object']['id'] );
if (
\is_wp_error( $outbox_post ) ||
'Follow' !== \get_post_meta( $outbox_post->ID, '_activitypub_activity_type', true )
) {
return;
}
$actor_post = Remote_Actors::get_by_uri( object_to_uri( $accept['object']['object'] ) );
if ( \is_wp_error( $actor_post ) ) {
return;
}
$user_id = is_array( $user_ids ) ? reset( $user_ids ) : $user_ids;
$result = Following::accept( $actor_post, $user_id );
$success = ! \is_wp_error( $result );
/**
* Fires after an ActivityPub Accept activity has been handled.
*
* @param array $accept The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param \WP_Post|\WP_Error $result The remote actor post or error.
*/
\do_action( 'activitypub_handled_accept', $accept, (array) $user_ids, $success, $result );
}
/**
* Validate the object.
*
* @param bool $valid The validation state.
* @param string $param The object parameter.
* @param \WP_REST_Request $request The request object.
*
* @return bool The validation state: true if valid, false if not.
*/
public static function validate_object( $valid, $param, $request ) {
$activity = $request->get_json_params();
if ( empty( $activity['type'] ) ) {
return false;
}
if ( 'Accept' !== $activity['type'] ) {
return $valid;
}
if ( ! isset( $activity['actor'], $activity['object'] ) ) {
return false;
}
if ( ! \is_array( $activity['object'] ) ) {
return false;
}
if ( ! isset( $activity['object']['id'], $activity['object']['type'], $activity['object']['actor'], $activity['object']['object'] ) ) {
return false;
}
return $valid;
}
}

View File

@ -7,12 +7,14 @@
namespace Activitypub\Handler;
use Activitypub\Http;
use Activitypub\Comment;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Interactions;
use Activitypub\Comment;
use Activitypub\Http;
use function Activitypub\object_to_uri;
use function Activitypub\is_activity;
use function Activitypub\is_activity_public;
use function Activitypub\object_to_uri;
/**
* Handle Create requests.
@ -22,34 +24,34 @@ class Announce {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
'activitypub_inbox_announce',
array( self::class, 'handle_announce' ),
10,
3
);
\add_action( 'activitypub_inbox_announce', array( self::class, 'handle_announce' ), 10, 3 );
}
/**
* Handles "Announce" requests.
*
* @param array $announcement The activity-object.
* @param int $user_id The id of the local blog-user.
* @param int|int[] $user_ids The id(s) of the local blog-user(s).
* @param \Activitypub\Activity\Activity $activity The activity object.
*/
public static function handle_announce( $announcement, $user_id, $activity = null ) {
public static function handle_announce( $announcement, $user_ids, $activity = null ) {
// Check if Activity is public or not.
if ( ! is_activity_public( $announcement ) ) {
// @todo maybe send email
return;
}
// Ignore announces from the blog actor.
if ( Actors::BLOG_USER_ID === Actors::get_id_by_resource( $announcement['actor'] ) ) {
return;
}
// Check if reposts are allowed.
if ( ! Comment::is_comment_type_enabled( 'repost' ) ) {
return;
}
self::maybe_save_announce( $announcement, $user_id );
self::maybe_save_announce( $announcement, $user_ids );
if ( is_string( $announcement['object'] ) ) {
$object = Http::get_remote_object( $announcement['object'] );
@ -61,7 +63,7 @@ class Announce {
return;
}
if ( ! isset( $object['type'] ) ) {
if ( ! is_activity( $object ) ) {
return;
}
@ -71,30 +73,30 @@ class Announce {
* Fires after an Announce has been received.
*
* @param array $object The object.
* @param int $user_id The id of the local blog-user.
* @param int[] $user_ids The ids of the local blog-users.
* @param string $type The type of the activity.
* @param \Activitypub\Activity\Activity|null $activity The activity object.
*/
\do_action( 'activitypub_inbox', $object, $user_id, $type, $activity );
\do_action( 'activitypub_inbox', $object, (array) $user_ids, $type, $activity );
/**
* Fires after an Announce of a specific type has been received.
*
* @param array $object The object.
* @param int $user_id The id of the local blog-user.
* @param int[] $user_ids The ids of the local blog-users.
* @param \Activitypub\Activity\Activity|null $activity The activity object.
*/
\do_action( "activitypub_inbox_{$type}", $object, $user_id, $activity );
\do_action( "activitypub_inbox_{$type}", $object, (array) $user_ids, $activity );
}
/**
* Try to save the Announce.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param array $activity The activity-object.
* @param int|int[] $user_ids The id of the local blog-user.
*/
public static function maybe_save_announce( $activity, $user_id ) {
$url = object_to_uri( $activity['object'] );
public static function maybe_save_announce( $activity, $user_ids ) {
$url = object_to_uri( $activity );
if ( empty( $url ) ) {
return;
@ -105,21 +107,27 @@ class Announce {
return;
}
$state = Interactions::add_reaction( $activity );
$reaction = null;
// If the object is a Create activity, extract the actual object from it.
if ( isset( $activity['object']['type'] ) && 'Create' === $activity['object']['type'] ) {
$activity['object'] = object_to_uri( $activity['object']['object'] );
}
if ( $state && ! is_wp_error( $state ) ) {
$reaction = get_comment( $state );
$success = false;
$result = Interactions::add_reaction( $activity );
if ( $result && ! is_wp_error( $result ) ) {
$success = true;
$result = get_comment( $result );
}
/**
* Fires after an Announce has been saved.
* Fires after an ActivityPub Announce activity has been handled.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param mixed $state The state of the reaction.
* @param mixed $reaction The reaction.
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param array|string|int|\WP_Error|false $result The WP_Comment object of the created announce/repost comment, or null if creation failed.
*/
do_action( 'activitypub_handled_announce', $activity, $user_id, $state, $reaction );
\do_action( 'activitypub_handled_announce', $activity, (array) $user_ids, $success, $result );
}
}

View File

@ -0,0 +1,225 @@
<?php
/**
* Collection Sync file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Remote_Actors;
use Activitypub\Signature;
use function Activitypub\get_url_authority;
/**
* Collection Sync Handler.
*
* Handles the Collection-Synchronization header (FEP-8fcf) for various collection types.
*
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
*/
class Collection_Sync {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_create', array( self::class, 'handle_collection_synchronization' ), 10, 2 );
// The Collection-Synchronization header needs to be part of the signature, so it must be added before signing.
\add_filter( 'http_request_args', array( self::class, 'maybe_add_headers' ), -1, 2 );
}
/**
* Process Collection-Synchronization header if present (FEP-8fcf).
*
* This method handles the FEP-8fcf Collection Synchronization protocol for any collection type.
* It detects the collection type from the URL and delegates to the appropriate handler.
*
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
*
* @param array $data The activity data.
* @param int|int[] $user_ids The user ID(s).
*/
public static function handle_collection_synchronization( $data, $user_ids ) {
if ( empty( $_SERVER['HTTP_COLLECTION_SYNCHRONIZATION'] ) ) {
return;
}
// Check if sync-header is part of signature (required by FEP).
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$signature = \wp_unslash( $_SERVER['HTTP_SIGNATURE'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? '' );
if ( false === \stripos( $signature, 'collection-synchronization' ) ) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
$sync_header = \wp_unslash( $_SERVER['HTTP_COLLECTION_SYNCHRONIZATION'] );
// Parse the header using the generic HTTP parser.
$params = Signature::parse_collection_sync_header( $sync_header );
if ( false === $params ) {
return;
}
// Check for followers collection.
$collection_type = null;
if ( preg_match( '#/followers(?:/sync)?(?:\?|$)#', $params['url'] ) ) {
$collection_type = 'followers';
}
if ( ! $collection_type ) {
// Unknown or unsupported collection type.
return;
}
// Get the actor URL for validation.
$actor_url = $data['actor'] ?? false;
if ( ! $actor_url ) {
return;
}
// Validate the header parameters.
if ( ! self::validate_header_params( $params, $actor_url ) ) {
return;
}
// Extract the user ID for cache key (collection sync is always for a single user).
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
$cache_key = 'activitypub_collection_sync_received_' . $user_id . '_' . md5( $actor_url );
if ( false === \get_transient( $cache_key ) ) {
$frequency = self::get_frequency();
\set_transient( $cache_key, time(), $frequency );
} else {
return;
}
/**
* Action triggered Collection Sync.
*
* This allows for async processing of the reconciliation.
*
* @param string $collection_type The collection type (e.g., 'followers', 'following', 'liked').
* @param int[] $user_ids The local user IDs.
* @param string $actor_url The remote actor URL.
* @param array $params The parsed Collection-Synchronization header parameters.
*/
\do_action( 'activitypub_collection_sync', $collection_type, (array) $user_ids, $actor_url, $params );
}
/**
* Add Collection-Synchronization header to `Create` activities (FEP-8fcf).
*
* This method adds the Collection-Synchronization header to outgoing `Create` activities.
*
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
*
* @param array $args The HTTP request arguments.
* @param string $url The request URL.
*
* @return array Modified HTTP request arguments.
*/
public static function maybe_add_headers( $args, $url ) {
if ( empty( $args['body'] ) ) {
return $args;
}
if ( ! is_array( $args['body'] ) ) {
$body = \json_decode( $args['body'], true );
if ( null === $body ) {
return $args;
}
} else {
$body = $args['body'];
}
if ( ! isset( $body['type'] ) || 'Create' !== $body['type'] ) {
return $args;
}
// Only send header if we haven't sent one to this authority in the last day.
$inbox_authority = get_url_authority( $url );
$user_id = $args['user_id'] ?? false;
if ( false === $user_id || ! $inbox_authority ) {
return $args;
}
// Check if we've already sent a sync header to this authority today.
$transient_key = 'activitypub_collection_sync_sent_' . $user_id . '_' . md5( $inbox_authority );
if ( false !== \get_transient( $transient_key ) ) {
return $args;
}
$sync_header = Followers::generate_sync_header( $user_id, $inbox_authority );
if ( $sync_header ) {
$args['headers']['Collection-Synchronization'] = $sync_header;
$frequency = self::get_frequency();
\set_transient( $transient_key, time(), $frequency );
}
return $args;
}
/**
* Validate Collection-Synchronization header parameters.
*
* @param array $params Parsed header parameters.
* @param string $actor_url The actor URL that sent the activity.
*
* @return bool True if valid, false otherwise.
*/
public static function validate_header_params( $params, $actor_url ) {
if ( empty( $params['collectionId'] ) || empty( $params['url'] ) ) {
return false;
}
$post = Remote_Actors::fetch_by_uri( $actor_url );
if ( \is_wp_error( $post ) ) {
return false;
}
$actor = Remote_Actors::get_actor( $post );
if ( \is_wp_error( $actor ) ) {
return false;
}
$expected_collection = $actor->get_followers();
if ( \is_wp_error( $expected_collection ) ) {
return false;
}
if ( trailingslashit( $params['collectionId'] ) !== trailingslashit( $expected_collection ) ) {
return false;
}
// Build authorities for comparison.
$collection_authority = get_url_authority( $params['collectionId'] );
$url_authority = get_url_authority( $params['url'] );
return $collection_authority === $url_authority;
}
/**
* Get the frequency for Collection-Synchronization headers.
*
* @return int Frequency in seconds.
*/
private static function get_frequency() {
/**
* Filter the frequency of Collection-Synchronization headers sent to a given authority.
*
* @param int $frequency The frequency in seconds. Default is one week.
* @param int $user_id The local user ID.
* @param string $inbox_authority The inbox authority.
*/
return \apply_filters( 'activitypub_collection_sync_frequency', WEEK_IN_SECONDS );
}
}

View File

@ -8,10 +8,13 @@
namespace Activitypub\Handler;
use Activitypub\Collection\Interactions;
use Activitypub\Collection\Remote_Posts;
use Activitypub\Tombstone;
use function Activitypub\is_self_ping;
use function Activitypub\get_activity_visibility;
use function Activitypub\is_activity_reply;
use function Activitypub\is_activity_public;
use function Activitypub\is_quote_activity;
use function Activitypub\is_self_ping;
use function Activitypub\object_id_to_comment;
/**
@ -22,72 +25,101 @@ 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_handled_inbox_create', array( self::class, 'handle_create' ), 10, 3 );
\add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'maybe_unbury' ), 10, 2 );
}
/**
* Handles "Create" requests.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param int|int[] $user_ids The id(s) of the local blog-user(s).
* @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null.
*/
public static function handle_create( $activity, $user_id, $activity_object = null ) {
// Check if Activity is public or not.
if (
! is_activity_public( $activity ) ||
! is_activity_reply( $activity )
) {
public static function handle_create( $activity, $user_ids, $activity_object = null ) {
// Check for private and/or direct messages.
if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) {
$result = false;
} elseif ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { // Check for replies and quotes.
$result = self::create_interaction( $activity, $user_ids, $activity_object );
} else { // Handle non-interaction objects.
$result = self::create_post( $activity, $user_ids, $activity_object );
}
if ( false === $result ) {
return;
}
$check_dupe = object_id_to_comment( $activity['object']['id'] );
$success = ! \is_wp_error( $result );
/**
* Fires after an ActivityPub Create activity has been handled.
*
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param \WP_Comment|\WP_Post|\WP_Error $result The WP_Comment object of the created comment, or null if creation failed.
*/
\do_action( 'activitypub_handled_create', $activity, (array) $user_ids, $success, $result );
}
/**
* Handle interactions like replies.
*
* @param array $activity The activity-object.
* @param int[] $user_ids The ids of the local blog-users.
* @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null.
*
* @return \WP_Comment|\WP_Error|false The created comment, WP_Error on failure, false if already exists or not processed.
*/
public static function create_interaction( $activity, $user_ids, $activity_object = null ) {
$existing_comment = object_id_to_comment( $activity['object']['id'] );
// If comment exists, call update action.
if ( $check_dupe ) {
/**
* Fires when a Create activity is received for an existing comment.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \Activitypub\Activity\Activity $activity_object The activity object.
*/
\do_action( 'activitypub_inbox_update', $activity, $user_id, $activity_object );
return;
if ( $existing_comment ) {
Update::handle_update( $activity, (array) $user_ids, $activity_object );
return false;
}
if ( is_self_ping( $activity['object']['id'] ) ) {
return;
return false;
}
$state = Interactions::add_comment( $activity );
$reaction = null;
$result = Interactions::add_comment( $activity );
if ( $state && ! \is_wp_error( $state ) ) {
$reaction = \get_comment( $state );
if ( ! $result || \is_wp_error( $result ) ) {
return $result;
}
/**
* Fires after a Create activity has been handled.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \WP_Comment|\WP_Error $state The comment object or WP_Error.
* @param \WP_Comment|\WP_Error|null $reaction The reaction object or WP_Error.
*/
\do_action( 'activitypub_handled_create', $activity, $user_id, $state, $reaction );
return \get_comment( $result );
}
/**
* Handle non-interaction posts like posts.
*
* @param array $activity The activity-object.
* @param int[] $user_ids The ids of the local blog-users.
* @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null.
*
* @return \WP_Post|\WP_Error|false The post on success, WP_Error on failure, false if already exists.
*/
public static function create_post( $activity, $user_ids, $activity_object = null ) {
if ( ! \get_option( 'activitypub_create_posts', false ) ) {
return false;
}
$existing_post = Remote_Posts::get_by_guid( $activity['object']['id'] );
// If post exists, call update action.
if ( $existing_post instanceof \WP_Post ) {
Update::handle_update( $activity, (array) $user_ids, $activity_object );
return false;
}
return Remote_Posts::add( $activity, $user_ids );
}
/**
@ -100,34 +132,46 @@ class Create {
* @return bool The validation state: true if valid, false if not.
*/
public static function validate_object( $valid, $param, $request ) {
$json_params = $request->get_json_params();
$activity = $request->get_json_params();
if ( empty( $json_params['type'] ) ) {
if ( empty( $activity['type'] ) ) {
return false;
}
if (
'Create' !== $json_params['type'] ||
is_wp_error( $request )
) {
if ( 'Create' !== $activity['type'] ) {
return $valid;
}
$object = $json_params['object'];
if ( ! is_array( $object ) ) {
if ( ! isset( $activity['object'] ) || ! \is_array( $activity['object'] ) ) {
return false;
}
$required = array(
'id',
'content',
);
if ( array_intersect( $required, array_keys( $object ) ) !== $required ) {
if ( ! isset( $activity['object']['id'], $activity['object']['content'] ) ) {
return false;
}
return $valid;
}
/**
* Remove a URL from the tombstone registry when a Create or Update activity is sent.
*
* This handles the case where a post was soft-deleted (visibility changed to local/private)
* and then later changed back to public. The Create/Update activity indicates the post is being
* re-federated, so we remove it from the tombstone registry.
*
* @param int $outbox_id The ID of the outbox activity.
* @param \Activitypub\Activity\Activity $activity The Activity object.
*/
public static function maybe_unbury( $outbox_id, $activity ) {
if ( ! in_array( $activity->get_type(), array( 'Create', 'Update' ), true ) ) {
return;
}
$object = $activity->get_object();
if ( $object ) {
Tombstone::remove( $object->get_id(), $object->get_url() );
}
}
}

View File

@ -7,10 +7,10 @@
namespace Activitypub\Handler;
use WP_REST_Request;
use Activitypub\Http;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Interactions;
use Activitypub\Collection\Remote_Actors;
use Activitypub\Collection\Remote_Posts;
use Activitypub\Tombstone;
use function Activitypub\object_to_uri;
@ -22,19 +22,24 @@ class Delete {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ) );
\add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 );
\add_action( 'activitypub_delete_actor_interactions', array( self::class, 'delete_interactions' ) );
\add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ), 10, 2 );
\add_filter( 'activitypub_skip_inbox_storage', array( self::class, 'skip_inbox_storage' ), 10, 2 );
\add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 3 );
\add_action( 'activitypub_delete_remote_actor_interactions', array( self::class, 'delete_interactions' ) );
\add_action( 'activitypub_delete_remote_actor_posts', array( self::class, 'delete_posts' ) );
\add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) );
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'maybe_bury' ), 10, 2 );
}
/**
* Handles "Delete" requests.
*
* @param array $activity The delete activity.
* @param array $activity The delete activity.
* @param int|int[] $user_ids The local user ID(s).
*/
public static function handle_delete( $activity ) {
$object_type = isset( $activity['object']['type'] ) ? $activity['object']['type'] : '';
public static function handle_delete( $activity, $user_ids ) {
$object_type = $activity['object']['type'] ?? '';
switch ( $object_type ) {
/*
@ -47,7 +52,7 @@ class Delete {
case 'Organization':
case 'Service':
case 'Application':
self::maybe_delete_follower( $activity );
self::delete_remote_actor( $activity, $user_ids );
break;
/*
@ -62,7 +67,7 @@ class Delete {
case 'Video':
case 'Event':
case 'Document':
self::maybe_delete_interaction( $activity );
self::delete_object( $activity, $user_ids );
break;
/*
@ -71,7 +76,7 @@ class Delete {
* @see: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
*/
case 'Tombstone':
self::maybe_delete_interaction( $activity );
self::delete_object( $activity, $user_ids );
break;
/*
@ -80,110 +85,252 @@ class Delete {
* @see https://www.w3.org/TR/activitystreams-core/#example-1
*/
default:
// Ignore non Minimal Activities.
if ( ! is_string( $activity['object'] ) ) {
return;
}
// Check if Object is an Actor.
if ( $activity['actor'] === $activity['object'] ) {
self::maybe_delete_follower( $activity );
} else { // Assume an interaction otherwise.
self::maybe_delete_interaction( $activity );
if ( object_to_uri( $activity['object'] ) === $activity['actor'] ) {
self::delete_remote_actor( $activity, $user_ids );
} else { // Assume an object otherwise.
self::delete_object( $activity, $user_ids );
}
// Maybe handle Delete Activity for other Object Types.
break;
}
}
/**
* Delete an Object.
*
* @param array $activity The Activity object.
* @param int|int[] $user_ids The user ID(s).
*/
public static function delete_object( $activity, $user_ids ) {
$result = self::maybe_delete_interaction( $activity );
if ( ! $result ) {
$result = self::maybe_delete_post( $activity );
}
$success = ( $result && ! \is_wp_error( $result ) );
/**
* Fires after an ActivityPub Delete activity has been handled.
*
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param mixed|null $result The result of the delete operation.
*/
\do_action( 'activitypub_handled_delete', $activity, (array) $user_ids, $success, $result );
}
/**
* Delete an Actor.
*
* @param array $activity The Activity object.
* @param int|int[] $user_ids The user ID(s).
*/
public static function delete_remote_actor( $activity, $user_ids ) {
$result = self::maybe_delete_follower( $activity );
$success = ( $result && ! \is_wp_error( $result ) );
/**
* Fires after an ActivityPub Delete activity has been handled.
*
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param mixed|null $result The result of the delete operation.
*/
\do_action( 'activitypub_handled_delete', $activity, (array) $user_ids, $success, $result );
return $result;
}
/**
* Delete a Follower if Actor-URL is a Tombstone.
*
* @param array $activity The delete activity.
*
* @return bool True on success, false otherwise.
*/
public static function maybe_delete_follower( $activity ) {
/* @var \Activitypub\Model\Follower $follower Follower object. */
$follower = Followers::get_follower_by_actor( $activity['actor'] );
$follower = Remote_Actors::get_by_uri( $activity['actor'] );
// Verify that Actor is deleted.
if ( $follower && Http::is_tombstone( $activity['actor'] ) ) {
$follower->delete();
self::maybe_delete_interactions( $activity );
if ( ! is_wp_error( $follower ) && Tombstone::exists( $activity['actor'] ) ) {
self::maybe_delete_interactions( $follower->ID );
self::maybe_delete_posts( $follower->ID );
$state = Remote_Actors::delete( $follower->ID );
}
return $state ?? false;
}
/**
* Delete Reactions if Actor-URL is a Tombstone.
* Schedule Deletion of Interactions of a Remote Actor.
*
* @param array $activity The delete activity.
* @param int $id The remote actor ID.
*/
public static function maybe_delete_interactions( $activity ) {
// Verify that Actor is deleted.
if ( Http::is_tombstone( $activity['actor'] ) ) {
\wp_schedule_single_event(
\time(),
'activitypub_delete_actor_interactions',
array( $activity['actor'] )
);
}
public static function maybe_delete_interactions( $id ) {
\wp_schedule_single_event(
\time(),
'activitypub_delete_remote_actor_interactions',
array( $id )
);
}
/**
* Delete comments from an Actor.
* Schedule Deletion of Reader Items of a Remote Actor.
*
* @param string $actor The URL of the actor whose comments to delete.
* @param int $id The remote actor ID.
*/
public static function delete_interactions( $actor ) {
$comments = Interactions::get_interactions_by_actor( $actor );
public static function maybe_delete_posts( $id ) {
\wp_schedule_single_event(
\time(),
'activitypub_delete_remote_actor_posts',
array( $id )
);
}
/**
* Delete Interactions from a Remote Actor.
*
* @param int $id The ID of the actor whose comments to delete.
*
* @return bool True on success, false otherwise.
*/
public static function delete_interactions( $id ) {
$comments = Interactions::get_by_remote_actor_id( $id );
foreach ( $comments as $comment ) {
wp_delete_comment( $comment, true );
\wp_delete_comment( $comment, true );
}
if ( $comments ) {
return true;
} else {
return false;
}
}
/**
* Delete Reader Items from an Actor.
*
* @param int $id The ID of the actor whose comments to delete.
*
* @return bool True on success, false otherwise.
*/
public static function delete_posts( $id ) {
$posts = Remote_Posts::get_by_remote_actor_id( $id );
foreach ( $posts as $post ) {
Remote_Posts::delete( $post->ID );
}
if ( $posts ) {
return true;
} else {
return false;
}
}
/**
* Delete a Reaction if URL is a Tombstone.
*
* Note: When comments are deleted, WordPress automatically deletes all associated
* comment meta including _activitypub_remote_actor_id. The remote actor post itself
* is not deleted, as it may be referenced by other comments or may be needed for
* future interactions.
*
* @param array $activity The delete activity.
*
* @return bool True on success, false otherwise.
*/
public static function maybe_delete_interaction( $activity ) {
if ( is_array( $activity['object'] ) ) {
$id = $activity['object']['id'];
} else {
$id = $activity['object'];
}
$id = object_to_uri( $activity['object'] );
$comments = Interactions::get_by_id( $id );
$comments = Interactions::get_interaction_by_id( $id );
if ( $comments && Http::is_tombstone( $id ) ) {
if ( $comments && Tombstone::exists( $id ) ) {
foreach ( $comments as $comment ) {
// WordPress will automatically delete all comment meta including _activitypub_remote_actor_id.
wp_delete_comment( $comment->comment_ID, true );
}
}
}
/**
* Defer signature verification for `Delete` requests.
*
* @param bool $defer Whether to defer signature verification.
* @param WP_REST_Request $request The request object.
*
* @return bool Whether to defer signature verification.
*/
public static function defer_signature_verification( $defer, $request ) {
$json = $request->get_json_params();
if ( isset( $json['type'] ) && 'Delete' === $json['type'] ) {
return true;
}
return false;
}
/**
* Delete a post from the Posts collection.
*
* @param array $activity The delete activity.
*
* @return bool|\WP_Error True on success, false or WP_Error on failure.
*/
public static function maybe_delete_post( $activity ) {
$id = object_to_uri( $activity['object'] );
// Check if the object exists and is a tombstone.
if ( Tombstone::exists( $id ) ) {
return Remote_Posts::delete_by_guid( $id );
}
return false;
}
/**
* Skip inbox storage for `Delete` requests.
*
* @param bool $skip Whether to skip inbox storage.
* @param array $data The activity data array.
*
* @return bool Whether to skip inbox storage.
*/
public static function skip_inbox_storage( $skip, $data ) {
if ( isset( $data['type'] ) && 'Delete' === $data['type'] ) {
return true;
}
return $skip;
}
/**
* Defer signature verification for `Delete` requests.
*
* Endpoints that opt in to mandatory signing by calling
* `verify_signature( $request, true )` must not be overridden — the
* Delete carve-out is only for the default inbox path where the
* remote actor's keys may legitimately be gone before the Delete
* arrives.
*
* @since 8.2.0 The `$force_signature` parameter is now respected.
*
* @param bool $defer Whether to defer signature verification.
* @param \WP_REST_Request $request The request object.
* @param bool $force_signature Whether the caller has forced signature verification.
*
* @return bool Whether to defer signature verification.
*/
public static function defer_signature_verification( $defer, $request, $force_signature = false ) {
if ( $force_signature ) {
return $defer;
}
$json = $request->get_json_params();
if ( isset( $json['type'] ) && 'Delete' === $json['type'] ) {
return true;
}
return $defer;
}
/**
* Set the object to the object ID.
*
* @param \Activitypub\Activity\Activity $activity The Activity object.
*
* @return \Activitypub\Activity\Activity The filtered Activity object.
*/
public static function outbox_activity( $activity ) {
@ -193,4 +340,28 @@ class Delete {
return $activity;
}
/**
* Add a URL to the tombstone registry when a Delete activity is sent.
*
* @param int $outbox_id The ID of the outbox activity.
* @param \Activitypub\Activity\Activity $activity The Activity object.
*/
public static function maybe_bury( $outbox_id, $activity ) {
if ( 'Delete' !== $activity->get_type() ) {
return;
}
$object = $activity->get_object();
if ( ! $object ) {
return;
}
Tombstone::bury( object_to_uri( $object ) );
if ( \is_object( $object ) ) {
Tombstone::bury( $object->get_id(), $object->get_url() );
}
}
}

View File

@ -7,10 +7,10 @@
namespace Activitypub\Handler;
use Activitypub\Notification;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Remote_Actors;
use function Activitypub\add_to_outbox;
@ -22,84 +22,94 @@ class Follow {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
'activitypub_inbox_follow',
array( self::class, 'handle_follow' )
);
\add_action(
'activitypub_followers_post_follow',
array( self::class, 'queue_accept' ),
10,
4
);
\add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow' ), 10, 2 );
\add_action( 'activitypub_handled_follow', array( self::class, 'queue_accept' ), 10, 4 );
}
/**
* Handle "Follow" requests.
*
* @param array $activity The activity object.
* @param array $activity The activity object.
* @param int|int[] $user_ids The user ID(s).
*/
public static function handle_follow( $activity ) {
$user = Actors::get_by_resource( $activity['object'] );
public static function handle_follow( $activity, $user_ids ) {
// Extract the user ID (follow requests are always for a single user).
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
if ( ! $user || is_wp_error( $user ) ) {
// If we can not find a user, we can not initiate a follow process.
if ( Actors::APPLICATION_USER_ID === $user_id ) {
self::queue_reject( $activity, $user_id );
return;
}
$user_id = $user->get__id();
// Check if the actor already follows the user.
$already_following = false;
$remote_actor = Remote_Actors::get_by_uri( $activity['actor'] );
if ( ! \is_wp_error( $remote_actor ) ) {
$already_following = Followers::follows( $remote_actor->ID, $user_id );
}
// Save follower.
$follower = Followers::add_follower(
$user_id,
$activity['actor']
);
// Save follower if not already following.
if ( $already_following ) {
$success = false;
} else {
$remote_actor = Followers::add( $user_id, $activity['actor'] );
$success = ! \is_wp_error( $remote_actor );
if ( $success ) {
$remote_actor = \get_post( $remote_actor );
}
}
/**
* Fires after a new follower has been added.
*
* @param string $actor The URL of the actor (follower) who initiated the follow.
* @param array $activity The complete activity data of the follow request.
* @param int $user_id The ID of the WordPress user being followed.
* @param \Activitypub\Model\Follower|\WP_Error $follower The Follower object containing the new follower's data.
* @deprecated 7.5.0 Use "activitypub_handled_follow" instead.
*
* @param string $actor The URL of the actor (follower) who initiated the follow.
* @param array $activity The complete activity data of the follow request.
* @param int $user_id The ID of the WordPress user being followed.
* @param \WP_Post|\WP_Error $remote_actor The Actor object containing the new follower's data.
*/
do_action( 'activitypub_followers_post_follow', $activity['actor'], $activity, $user_id, $follower );
\do_action_deprecated( 'activitypub_followers_post_follow', array( $activity['actor'], $activity, $user_id, $remote_actor ), '7.5.0', 'activitypub_handled_follow' );
// Send notification.
$notification = new Notification(
'follow',
$activity['actor'],
$activity,
$user_id
);
$notification->send();
/**
* Fires after a Follow activity has been handled.
*
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param \WP_Post|\WP_Error $remote_actor The remote actor/follower, or WP_Error if failed.
*/
\do_action( 'activitypub_handled_follow', $activity, (array) $user_ids, $success, $remote_actor );
}
/**
* Send Accept response.
*
* @param string $actor The Actor URL.
* @param array $activity_object The Activity object.
* @param int $user_id The ID of the WordPress User.
* @param \Activitypub\Model\Follower|\WP_Error $follower The Follower object.
* @param array $activity_object The ActivityPub activity data.
* @param int|int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param \WP_Post|\WP_Error $remote_actor The remote actor/follower, or WP_Error if failed.
*/
public static function queue_accept( $actor, $activity_object, $user_id, $follower ) {
if ( \is_wp_error( $follower ) ) {
public static function queue_accept( $activity_object, $user_ids, $success, $remote_actor ) {
if ( \is_wp_error( $remote_actor ) ) {
// Impossible to send a "Reject" because we can not get the Remote-Inbox.
return;
}
// Extract the user ID from the array (follow requests are always for a single user).
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
$actor = $activity_object['actor'];
// Only send minimal data.
$activity_object = array_intersect_key(
$activity_object,
array_flip(
array(
'id',
'type',
'actor',
'object',
)
array(
'id' => 1,
'type' => 1,
'actor' => 1,
'object' => 1,
)
);
@ -111,4 +121,31 @@ class Follow {
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
}
/**
* Send Reject response.
*
* @param array $activity The Activity array.
* @param int $user_id The ID of the WordPress User.
*/
public static function queue_reject( $activity, $user_id ) {
// Only send minimal data.
$origin_activity = array_intersect_key(
$activity,
array(
'id' => 1,
'type' => 1,
'actor' => 1,
'object' => 1,
)
);
$activity = new Activity();
$activity->set_type( 'Reject' );
$activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
$activity->set_object( $origin_activity );
$activity->set_to( array( $origin_activity['actor'] ) );
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
}
}

View File

@ -7,8 +7,8 @@
namespace Activitypub\Handler;
use Activitypub\Comment;
use Activitypub\Collection\Interactions;
use Activitypub\Comment;
use function Activitypub\object_to_uri;
@ -27,10 +27,10 @@ class Like {
/**
* Handles "Like" requests.
*
* @param array $like The Activity array.
* @param int $user_id The ID of the local blog user.
* @param array $like The Activity array.
* @param int|int[] $user_ids The user ID(s).
*/
public static function handle_like( $like, $user_id ) {
public static function handle_like( $like, $user_ids ) {
if ( ! Comment::is_comment_type_enabled( 'like' ) ) {
return;
}
@ -46,22 +46,23 @@ class Like {
return;
}
$state = Interactions::add_reaction( $like );
$reaction = null;
$success = false;
$result = Interactions::add_reaction( $like );
if ( $state && ! is_wp_error( $state ) ) {
$reaction = get_comment( $state );
if ( $result && ! is_wp_error( $result ) ) {
$success = true;
$result = get_comment( $result );
}
/**
* Fires after a Like has been handled.
* Fires after an ActivityPub Like activity has been handled.
*
* @param array $like The Activity array.
* @param int $user_id The ID of the local blog user.
* @param mixed $state The state of the reaction.
* @param mixed $reaction The reaction object.
* @param array $like The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param array|false|int|string|\WP_Comment|\WP_Error $result The WP_Comment object of the created like comment, or null if creation failed.
*/
do_action( 'activitypub_handled_like', $like, $user_id, $state, $reaction );
\do_action( 'activitypub_handled_like', $like, (array) $user_ids, $success, $result );
}
/**

View File

@ -7,8 +7,9 @@
namespace Activitypub\Handler;
use Activitypub\Http;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Remote_Actors;
use Activitypub\Http;
use function Activitypub\object_to_uri;
@ -24,98 +25,79 @@ class Move {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_move', array( self::class, 'handle_move' ) );
\add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) );
\add_action( 'activitypub_inbox_move', array( self::class, 'handle_move' ), 10, 2 );
}
/**
* Handle Move requests.
*
* @param array $activity The JSON "Move" Activity.
* @param array $activity The JSON "Move" Activity.
* @param int|int[] $user_ids The user ID(s).
*/
public static function handle_move( $activity ) {
$target = self::extract_target( $activity );
$origin = self::extract_origin( $activity );
public static function handle_move( $activity, $user_ids ) {
$target_uri = self::extract_target( $activity );
$origin_uri = self::extract_origin( $activity );
if ( ! $target || ! $origin ) {
if ( ! $target_uri || ! $origin_uri ) {
return;
}
$target_object = Http::get_remote_object( $target );
$origin_object = Http::get_remote_object( $origin );
$target_json = Http::get_remote_object( $target_uri );
$origin_json = Http::get_remote_object( $origin_uri );
$verified = self::verify_move( $target_object, $origin_object );
$verified = self::verify_move( $target_json, $origin_json );
if ( ! $verified ) {
return;
}
$target_follower = Followers::get_follower_by_actor( $target );
$origin_follower = Followers::get_follower_by_actor( $origin );
/*
* If the new target is followed, but the origin is not,
* everything is fine, so we can return.
*/
if ( $target_follower && ! $origin_follower ) {
return;
}
/*
* If the new target is not followed, but the origin is,
* update the origin follower to the new target.
*/
if ( ! $target_follower && $origin_follower ) {
$origin_follower->from_array( $target_object );
$origin_follower->set_id( $target );
$origin_id = $origin_follower->upsert();
$target_object = Remote_Actors::get_by_uri( $target_uri );
$origin_object = Remote_Actors::get_by_uri( $origin_uri );
$result = null;
$success = false;
// If the origin is followed but the target is not, update the origin to point to the target.
if ( \is_wp_error( $target_object ) && ! \is_wp_error( $origin_object ) ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$wpdb->posts,
array( 'guid' => sanitize_url( $target ) ),
array( 'ID' => sanitize_key( $origin_id ) )
array( 'guid' => sanitize_url( $target_uri ) ),
array( 'ID' => sanitize_key( $origin_object->ID ) )
);
// Clear the cache.
wp_cache_delete( $origin_id, 'posts' );
return;
\wp_cache_delete( $origin_object->ID, 'posts' );
$success = true;
$result = Remote_Actors::upsert( $target_json );
}
/*
* If the new target is followed, and the origin is followed,
* move users and delete the origin follower.
*/
if ( $target_follower && $origin_follower ) {
$origin_users = \get_post_meta( $origin_follower->get__id(), '_activitypub_user_id', false );
$target_users = \get_post_meta( $target_follower->get__id(), '_activitypub_user_id', false );
// If both the target and origin are followed, merge them.
if ( ! \is_wp_error( $target_object ) && ! \is_wp_error( $origin_object ) ) {
$origin_users = \get_post_meta( $origin_object->ID, Followers::FOLLOWER_META_KEY, false );
$target_users = \get_post_meta( $target_object->ID, Followers::FOLLOWER_META_KEY, false );
// Get all user ids from $origin_users that are not in $target_users.
$users = \array_diff( $origin_users, $target_users );
foreach ( $users as $user_id ) {
\add_post_meta( $target_follower->get__id(), '_activitypub_user_id', $user_id );
foreach ( $users as $follower_user_id ) {
\add_post_meta( $target_object->ID, Followers::FOLLOWER_META_KEY, $follower_user_id );
}
$origin_follower->delete();
}
}
/**
* Convert the object and origin to the correct format.
*
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @return \Activitypub\Activity\Activity The filtered Activity object.
*/
public static function outbox_activity( $activity ) {
if ( 'Move' === $activity->get_type() ) {
$activity->set_object( object_to_uri( $activity->get_object() ) );
$activity->set_origin( $activity->get_actor() );
$activity->set_target( $activity->get_object() );
$success = true;
$result = \wp_delete_post( $origin_object->ID );
}
return $activity;
/**
* Fires after an ActivityPub Move activity has been handled.
*
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param mixed $result The result of the operation (e.g., post ID, WP_Error, or status).
*/
\do_action( 'activitypub_handled_move', $activity, (array) $user_ids, $success, $result );
}
/**
@ -188,13 +170,23 @@ class Move {
return false;
}
// Check if the target has an alsoKnownAs property.
if ( empty( $target_object['also_known_as'] ) ) {
// Normalize alsoKnownAs to an array (some JSON-LD payloads may use a string).
$also_known_as = (array) ( $target_object['alsoKnownAs'] ?? array() );
if ( empty( $also_known_as ) ) {
return false;
}
// Check if the origin is in the alsoKnownAs property of the target.
if ( ! in_array( $origin_object['id'], $target_object['also_known_as'], true ) ) {
// Collect all possible origin identifiers (id, url, webfinger).
$origin_ids = array_filter(
array(
$origin_object['id'] ?? null,
$origin_object['url'] ?? null,
$origin_object['webfinger'] ?? null,
)
);
// Check if any origin identifier is in the alsoKnownAs property of the target.
if ( ! array_intersect( $origin_ids, $also_known_as ) ) {
return false;
}

View File

@ -0,0 +1,336 @@
<?php
/**
* Handler for QuoteRequest activities.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Inbox;
use Activitypub\Collection\Remote_Actors;
use function Activitypub\add_to_outbox;
use function Activitypub\object_to_uri;
use function Activitypub\user_can_activitypub;
/**
* Handler for QuoteRequest activities.
*
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
*/
class Quote_Request {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_quote_request', array( self::class, 'handle_quote_request' ), 10, 2 );
\add_action( 'activitypub_rest_inbox_disallowed', array( self::class, 'handle_blocked_request' ), 10, 3 );
\add_action( 'delete_comment', array( self::class, 'handle_quote_delete' ), 10, 2 );
\add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
}
/**
* Handle QuoteRequest activities.
*
* @param array $activity The activity object.
* @param int|int[] $user_ids The user ID(s).
*/
public static function handle_quote_request( $activity, $user_ids ) {
$state = true;
$post_id = \url_to_postid( object_to_uri( $activity['object'] ) );
$post = $post_id ? \get_post( $post_id ) : null;
if ( ! $post ) {
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
self::queue_reject( $activity, $user_id );
return;
}
// Use the post author as the responding actor — they own the quoted content.
$user_id = (int) $post->post_author;
$content_policy = \get_post_meta( $post_id, 'activitypub_interaction_policy_quote', true );
// Fall back to global default if not set.
if ( ! $content_policy ) {
$content_policy = \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE );
}
switch ( $content_policy ) {
case ACTIVITYPUB_INTERACTION_POLICY_ME:
self::queue_reject( $activity, $user_id );
$state = false;
break;
case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS:
$follower = Remote_Actors::get_by_uri( object_to_uri( $activity['actor'] ) );
if ( ! \is_wp_error( $follower ) && Followers::follows( $follower->ID, $user_id ) ) {
self::queue_accept( $activity, $user_id, $post_id );
} else {
self::queue_reject( $activity, $user_id );
$state = false;
}
break;
case ACTIVITYPUB_INTERACTION_POLICY_ANYONE:
default:
self::queue_accept( $activity, $user_id, $post_id );
break;
}
/**
* Fires after an ActivityPub QuoteRequest activity has been handled.
*
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param string $content_policy The content policy for the quoted post.
*/
\do_action( 'activitypub_handled_quote_request', $activity, (array) $user_ids, $state, $content_policy );
}
/**
* ActivityPub inbox disallowed activity.
*
* @param array $activity The activity array.
* @param int|int[]|null $user_ids The user ID(s).
* @param string $type The type of the activity.
*/
public static function handle_blocked_request( $activity, $user_ids, $type ) {
if ( ! in_array( strtolower( $type ), array( 'quoterequest', 'quote_request' ), true ) ) {
return;
}
// Extract the user ID (quote requests are always for a single user).
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
self::queue_reject( $activity, $user_id );
}
/**
* Handle deletion of a quote comment.
*
* When a local quote comment is deleted, send a Reject activity to revoke
* the previously accepted QuoteRequest.
*
* @param int $comment_id The comment ID being deleted.
* @param \WP_Comment|null $comment The comment object, or null if not available.
*/
public static function handle_quote_delete( $comment_id, $comment ) {
// Try to get comment if not provided.
if ( ! $comment ) {
$comment = \get_comment( $comment_id );
}
// Only handle quote comments.
if ( ! $comment || 'quote' !== $comment->comment_type ) {
return;
}
// Get the post being quoted.
$post_id = $comment->comment_post_ID;
if ( ! $post_id ) {
return;
}
// Get the instrument URL (the quote post URL) from comment meta.
$instrument_url = \get_comment_meta( $comment_id, 'source_url', true );
if ( ! $instrument_url ) {
$instrument_url = \get_comment_meta( $comment_id, 'source_id', true );
}
if ( ! $instrument_url ) {
return;
}
// Get the post author (who accepted the quote).
$post = \get_post( $post_id );
if ( ! $post || ! $post->post_author ) {
return;
}
/*
* Try to retrieve the original QuoteRequest from the inbox.
* For QuoteRequest activities, the inbox stores the instrument URL
* in _activitypub_object_id, so we can query by that.
*/
$activity_object = null;
$inbox_item = Inbox::get_by_type_and_object( 'QuoteRequest', $instrument_url );
if ( $inbox_item instanceof \WP_Post ) {
$activity_object = \json_decode( $inbox_item->post_content, true );
if ( JSON_ERROR_NONE !== \json_last_error() ) {
$activity_object = null;
}
}
// Fallback: If inbox item not found, reconstruct from available data.
if ( ! $activity_object ) {
$activity_object = array(
'type' => 'QuoteRequest',
'actor' => $comment->comment_author_url,
'object' => \get_permalink( $post_id ),
'instrument' => $instrument_url,
'published' => \gmdate( 'c' ),
);
}
// Remove from _activitypub_quoted_by meta.
\delete_post_meta( $post_id, '_activitypub_quoted_by', $instrument_url );
// Send Reject activity to revoke the quote permission.
self::queue_reject( $activity_object, $post->post_author );
/**
* Fires after a quote comment has been deleted and Reject activity sent.
*
* @param int $comment_id The deleted comment ID.
* @param int $post_id The post ID that was quoted.
* @param string $instrument_url The instrument URL (quote post).
* @param array $activity_object The QuoteRequest activity that was rejected.
*/
\do_action( 'activitypub_quote_comment_deleted', $comment_id, $post_id, $instrument_url, $activity_object );
}
/**
* Send an Accept activity in response to the QuoteRequest.
*
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md#example-accept
*
* @param array $activity_object The activity object.
* @param int $user_id The user ID.
* @param int $post_id The post ID.
*/
public static function queue_accept( $activity_object, $user_id, $post_id ) {
// Fall back to the blog actor if the user has ActivityPub disabled.
if ( ! user_can_activitypub( $user_id ) ) {
$user_id = Actors::BLOG_USER_ID;
}
$actor = Actors::get_by_id( $user_id );
if ( \is_wp_error( $actor ) ) {
return;
}
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
$post_meta = \get_post_meta( $post_id, '_activitypub_quoted_by', false );
if ( in_array( $activity_object['instrument'], $post_meta, true ) ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$meta_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_value = %s LIMIT 1",
$post_id,
'_activitypub_quoted_by',
$activity_object['instrument']
)
);
} else {
$meta_id = \add_post_meta( $post_id, '_activitypub_quoted_by', $activity_object['instrument'] );
}
// Only send minimal data.
$activity_object = array_intersect_key(
$activity_object,
array(
'id' => 1,
'type' => 1,
'actor' => 1,
'object' => 1,
'instrument' => 1,
)
);
$url = \add_query_arg(
array(
'p' => $post_id,
'stamp' => $meta_id,
),
\home_url( '/' )
);
$activity = new Activity();
$activity->set_type( 'Accept' );
$activity->set_actor( $actor->get_id() );
$activity->set_object( $activity_object );
$activity->set_result( $url );
$activity->add_to( object_to_uri( $activity_object['actor'] ) );
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
}
/**
* Send a Reject activity in response to the QuoteRequest.
*
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md#example-reject
*
* @param array $activity_object The activity object.
* @param int $user_id The user ID.
*/
public static function queue_reject( $activity_object, $user_id ) {
// Fall back to the blog actor if the user has ActivityPub disabled.
if ( ! user_can_activitypub( $user_id ) ) {
$user_id = Actors::BLOG_USER_ID;
}
$actor = Actors::get_by_id( $user_id );
if ( \is_wp_error( $actor ) ) {
return;
}
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
// Only send minimal data.
$activity_object = array_intersect_key(
$activity_object,
array(
'id' => 1,
'type' => 1,
'actor' => 1,
'object' => 1,
'instrument' => 1,
)
);
$activity = new Activity();
$activity->set_type( 'Reject' );
$activity->set_actor( $actor->get_id() );
$activity->set_object( $activity_object );
$activity->add_to( object_to_uri( $activity_object['actor'] ) );
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
}
/**
* Validate the object.
*
* @param bool $valid The validation state.
* @param string $param The object parameter.
* @param \WP_REST_Request $request The request object.
*
* @return bool The validation state: true if valid, false if not.
*/
public static function validate_object( $valid, $param, $request ) {
$activity = $request->get_json_params();
if ( empty( $activity['type'] ) ) {
return false;
}
if ( 'QuoteRequest' !== $activity['type'] ) {
return $valid;
}
if ( ! isset( $activity['actor'], $activity['object'], $activity['instrument'] ) ) {
return false;
}
return $valid;
}
}

View File

@ -0,0 +1,107 @@
<?php
/**
* Reject handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Collection\Following;
use Activitypub\Collection\Outbox;
use Activitypub\Collection\Remote_Actors;
use function Activitypub\object_to_uri;
/**
* Handle "Reject" requests.
*/
class Reject {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_reject', array( self::class, 'handle_reject' ), 10, 2 );
\add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
}
/**
* Handles "Reject" requests.
*
* @param array $reject The activity-object.
* @param int|int[] $user_ids The user ID(s).
*/
public static function handle_reject( $reject, $user_ids ) {
// Validate that there is a preceding Activity.
$outbox_post = Outbox::get_by_guid( $reject['object']['id'] );
if ( \is_wp_error( $outbox_post ) ) {
return;
}
// We currently only support reject for Follow activities. But we will support more in the future.
switch ( \get_post_meta( $outbox_post->ID, '_activitypub_activity_type', true ) ) {
case 'Follow':
self::reject_follow( $reject, $user_ids );
break;
default:
break;
}
}
/**
* Reject a "Follow" request.
*
* @param array $reject The activity-object.
* @param int|int[] $user_ids The user ID(s).
*/
private static function reject_follow( $reject, $user_ids ) {
$actor_uri = $reject['object']['actor'] ?? '';
$actor_post = Remote_Actors::get_by_uri( object_to_uri( $actor_uri ) );
if ( \is_wp_error( $actor_post ) ) {
return;
}
$user_id = is_array( $user_ids ) ? reset( $user_ids ) : $user_ids;
$result = Following::reject( $actor_post, $user_id );
$success = ! \is_wp_error( $result );
/**
* Fires after an ActivityPub Reject activity has been handled.
*
* @param array $reject The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param \WP_Post|\WP_Error $result Actor post on success, WP_Error on failure.
*/
\do_action( 'activitypub_handled_reject', $reject, (array) $user_ids, $success, $result );
}
/**
* Validate the object.
*
* @param bool $valid The validation state.
* @param string $param The object parameter.
* @param \WP_REST_Request $request The request object.
*
* @return bool The validation state: true if valid, false if not.
*/
public static function validate_object( $valid, $param, $request ) {
$activity = $request->get_json_params();
if ( empty( $activity['type'] ) ) {
return false;
}
if ( 'Reject' !== $activity['type'] ) {
return $valid;
}
if ( ! isset( $activity['actor'], $activity['object'] ) ) {
return false;
}
return $valid;
}
}

View File

@ -7,9 +7,7 @@
namespace Activitypub\Handler;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Comment;
use Activitypub\Collection\Inbox as Inbox_Collection;
use function Activitypub\object_to_uri;
@ -21,70 +19,67 @@ class Undo {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
'activitypub_inbox_undo',
array( self::class, 'handle_undo' ),
10,
2
);
\add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo' ), 10, 2 );
\add_action( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
}
/**
* Handle "Unfollow" requests.
*
* @param array $activity The JSON "Undo" Activity.
* @param int|null $user_id The ID of the user who initiated the "Undo" activity.
* @param array $activity The JSON "Undo" Activity.
* @param int|int[]|null $user_ids The user ID(s).
*/
public static function handle_undo( $activity, $user_id ) {
if (
! isset( $activity['object']['type'] ) ||
! isset( $activity['object']['object'] )
) {
return;
}
public static function handle_undo( $activity, $user_ids ) {
$success = false;
$result = Inbox_Collection::undo( object_to_uri( $activity['object'] ) );
$type = $activity['object']['type'];
$state = false;
// Handle "Unfollow" requests.
if ( 'Follow' === $type ) {
$id = object_to_uri( $activity['object']['object'] );
$user = Actors::get_by_resource( $id );
if ( ! $user || is_wp_error( $user ) ) {
// If we can not find a user, we can not initiate a follow process.
return;
}
$user_id = $user->get__id();
$actor = object_to_uri( $activity['actor'] );
$state = Followers::remove_follower( $user_id, $actor );
}
// Handle "Undo" requests for "Like" and "Create" activities.
if ( in_array( $type, array( 'Like', 'Create', 'Announce' ), true ) ) {
if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
return;
}
$object_id = object_to_uri( $activity['object'] );
$comment = Comment::object_id_to_comment( esc_url_raw( $object_id ) );
if ( empty( $comment ) ) {
return;
}
$state = wp_delete_comment( $comment, true );
if ( $result && ! \is_wp_error( $result ) ) {
$success = true;
}
/**
* Fires after an "Undo" activity has been handled.
* Fires after an ActivityPub Undo activity has been handled.
*
* @param array $activity The JSON "Undo" Activity.
* @param int|null $user_id The ID of the user who initiated the "Undo" activity otherwise null.
* @param mixed $state The state of the "Undo" activity.
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false on failure.
* @param \WP_Comment|string $result The target, based on the activity that is being undone.
*/
do_action( 'activitypub_handled_undo', $activity, $user_id, $state );
\do_action( 'activitypub_handled_undo', $activity, (array) $user_ids, $success, $result );
}
/**
* Validate the object.
*
* @param bool $valid The validation state.
* @param string $param The object parameter.
* @param \WP_REST_Request $request The request object.
*
* @return bool The validation state: true if valid, false if not.
*/
public static function validate_object( $valid, $param, $request ) {
$activity = $request->get_json_params();
if ( empty( $activity['type'] ) ) {
return false;
}
if ( 'Undo' !== $activity['type'] ) {
return $valid;
}
if ( ! isset( $activity['actor'], $activity['object'] ) ) {
return false;
}
if ( ! \is_array( $activity['object'] ) && ! \is_string( $activity['object'] ) ) {
return false;
}
if ( \is_array( $activity['object'] ) && ! isset( $activity['object']['id'] ) ) {
return false;
}
return $valid;
}
}

View File

@ -7,10 +7,12 @@
namespace Activitypub\Handler;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Interactions;
use Activitypub\Collection\Remote_Actors;
use Activitypub\Collection\Remote_Posts;
use Activitypub\Http;
use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\is_activity_reply;
/**
* Handle Update requests.
@ -20,19 +22,18 @@ class Update {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
'activitypub_inbox_update',
array( self::class, 'handle_update' )
);
\add_action( 'activitypub_handled_inbox_update', array( self::class, 'handle_update' ), 10, 3 );
}
/**
* Handle "Update" requests.
*
* @param array $activity The Activity object.
* @param array $activity The Activity object.
* @param int[] $user_ids The user IDs. Always null for Update activities.
* @param \Activitypub\Activity\Activity $activity_object The activity object. Default null.
*/
public static function handle_update( $activity ) {
$object_type = isset( $activity['object']['type'] ) ? $activity['object']['type'] : '';
public static function handle_update( $activity, $user_ids, $activity_object ) {
$object_type = $activity['object']['type'] ?? '';
switch ( $object_type ) {
/*
@ -45,7 +46,7 @@ class Update {
case 'Organization':
case 'Service':
case 'Application':
self::update_actor( $activity );
self::update_actor( $activity, $user_ids );
break;
/*
@ -60,7 +61,7 @@ class Update {
case 'Video':
case 'Event':
case 'Document':
self::update_interaction( $activity );
self::update_object( $activity, $user_ids, $activity_object );
break;
/*
@ -74,52 +75,94 @@ class Update {
}
/**
* Update an Interaction.
* Update an Object.
*
* @param array $activity The Activity object.
* @param array $activity The Activity object.
* @param int[]|null $user_ids The user IDs. Always null for Update activities.
* @param \Activitypub\Activity\Activity $activity_object The activity object. Default null.
*/
public static function update_interaction( $activity ) {
$commentdata = Interactions::update_comment( $activity );
$reaction = null;
public static function update_object( $activity, $user_ids, $activity_object ) {
$result = new \WP_Error( 'activitypub_update_failed', 'Update failed' );
$updated = true;
if ( ! empty( $commentdata['comment_ID'] ) ) {
$state = 1;
$reaction = \get_comment( $commentdata['comment_ID'] );
} else {
$state = $commentdata;
// Check for private and/or direct messages.
if ( is_activity_reply( $activity ) ) {
$comment_data = Interactions::update_comment( $activity );
if ( false === $comment_data ) {
$updated = false;
} elseif ( ! empty( $comment_data['comment_ID'] ) ) {
$result = \get_comment( $comment_data['comment_ID'] );
}
} elseif ( \get_option( 'activitypub_create_posts', false ) ) {
$result = Remote_Posts::update( $activity, $user_ids );
if ( \is_wp_error( $result ) && 'activitypub_post_not_found' === $result->get_error_code() ) {
$updated = false;
}
}
// There is no object to update, try to trigger create instead.
if ( ! $updated ) {
return Create::handle_create( $activity, $user_ids, $activity_object );
}
$success = ( $result && ! \is_wp_error( $result ) );
/**
* Fires after an Update activity has been handled.
* Fires after an ActivityPub Update activity has been handled.
*
* @param array $activity The complete Update activity data.
* @param null $user Always null for Update activities.
* @param int|array $state 1 if comment was updated successfully, error data otherwise.
* @param \WP_Comment|null $reaction The updated comment object if successful, null otherwise.
* @param array $activity The ActivityPub activity data.
* @param int[]|null $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
* @param \WP_Comment|\WP_Post|\WP_Error $result The updated post, comment, or error.
*/
\do_action( 'activitypub_handled_update', $activity, null, $state, $reaction );
\do_action( 'activitypub_handled_update', $activity, (array) $user_ids, $success, $result );
}
/**
* Update an Actor.
*
* @param array $activity The Activity object.
* @param array $activity The Activity object.
* @param int[]|null $user_ids The user IDs. Always null for Update activities.
*/
public static function update_actor( $activity ) {
// Update cache.
$actor = get_remote_metadata_by_actor( $activity['actor'], false );
public static function update_actor( $activity, $user_ids ) {
/*
* Prefer the actor data embedded in the activity object, as it contains
* the fresh data sent by the remote server.
*/
$actor = $activity['object'] ?? null;
if ( ! $actor || \is_wp_error( $actor ) || ! isset( $actor['id'] ) ) {
return;
/*
* The object may be a string IRI instead of an embedded object,
* in which case we need to fetch the actor data remotely.
* We use Http::get_remote_object() directly instead of
* get_remote_metadata_by_actor() because the latter returns the
* stale locally cached copy via fetch_by_uri().
*/
if ( ! \is_array( $actor ) || ! isset( $actor['id'] ) ) {
$object = Http::get_remote_object( $activity['actor'], false );
if ( ! \is_wp_error( $object ) && \is_array( $object ) ) {
$actor = $object;
}
}
$follower = Followers::get_follower_by_actor( $actor['id'] );
if ( ! $follower ) {
return;
if ( \is_array( $actor ) && isset( $actor['id'] ) ) {
$state = Remote_Actors::upsert( $actor );
} else {
$state = new \WP_Error( 'activitypub_update_failed', 'Update failed: missing or invalid actor object in Update activity' );
$actor = array();
}
$follower->from_array( $actor );
$follower->upsert();
/**
* Fires after an ActivityPub Update activity has been handled.
*
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param int|\WP_Error $state Actor post ID on success, WP_Error on failure.
* @param array $actor Remote actor meta data.
*/
\do_action( 'activitypub_handled_update', $activity, (array) $user_ids, $state, $actor );
}
}

View File

@ -0,0 +1,95 @@
<?php
/**
* Outbox Add handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use Activitypub\Collection\Actors;
use function Activitypub\object_to_uri;
/**
* Handle outgoing Add activities.
*
* Supports adding objects to an actor's featured collection
* by making the corresponding WordPress post sticky.
*/
class Add {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_outbox_add', array( self::class, 'handle_add' ), 10, 2 );
}
/**
* Handle outgoing "Add" activities from local actors.
*
* When the target is the actor's featured collection, the referenced
* post is made sticky. The sticky action triggers the scheduler which
* creates the outbox entry automatically.
*
* @since 8.1.0
*
* @param array $data The activity data array.
* @param int $user_id The user ID.
*
* @return \WP_Post|\WP_Error|array The post object on success, WP_Error on failure, or original data if unhandled.
*/
public static function handle_add( $data, $user_id = null ) {
$object_uri = object_to_uri( $data['object'] ?? '' );
$target = object_to_uri( $data['target'] ?? '' );
if ( empty( $object_uri ) || empty( $target ) ) {
return $data;
}
$actor = Actors::get_by_id( $user_id );
if ( \is_wp_error( $actor ) ) {
return $actor;
}
// Only handle featured collection targets.
if ( $target !== $actor->get_featured() ) {
return $data;
}
$post_id = \url_to_postid( $object_uri );
if ( ! $post_id ) {
return new \WP_Error(
'activitypub_object_not_found',
\__( 'The referenced object was not found.', 'activitypub' ),
array( 'status' => 404 )
);
}
$post = \get_post( $post_id );
if ( ! $post ) {
return new \WP_Error(
'activitypub_object_not_found',
\__( 'The referenced object was not found.', 'activitypub' ),
array( 'status' => 404 )
);
}
// Verify the user owns this post.
if ( $user_id > 0 && (int) $post->post_author !== $user_id ) {
return new \WP_Error(
'activitypub_forbidden',
\__( 'You can only feature your own posts.', 'activitypub' ),
array( 'status' => 403 )
);
}
// Making the post sticky triggers the scheduler which adds to outbox.
\stick_post( $post_id );
return $post;
}
}

View File

@ -0,0 +1,49 @@
<?php
/**
* Outbox Announce handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use function Activitypub\object_to_uri;
/**
* Handle outgoing Announce activities.
*/
class Announce {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_outbox_announce', array( self::class, 'handle_announce' ), 10, 2 );
}
/**
* Handle outgoing "Announce" activities from local actors.
*
* Records an announce/boost from the local user on remote content.
*
* @param array $data The activity data array.
* @param int $user_id The user ID.
*/
public static function handle_announce( $data, $user_id = null ) {
$object_url = object_to_uri( $data['object'] ?? '' );
if ( empty( $object_url ) ) {
return $data;
}
/**
* Fires after an outgoing Announce activity has been processed.
*
* @param string $object_url The URL of the announced object.
* @param array $data The activity data.
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_outbox_announce_sent', $object_url, $data, $user_id );
return $data;
}
}

View File

@ -0,0 +1,187 @@
<?php
/**
* Outbox Arrive handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use Activitypub\Collection\Posts;
use function Activitypub\add_to_outbox;
use function Activitypub\is_activity_public;
/**
* Handle outgoing Arrive activities.
*
* @since 8.1.0
*/
class Arrive {
/**
* Initialize the class, registering WordPress hooks.
*
* @since 8.1.0
*/
public static function init() {
\add_filter( 'activitypub_outbox_arrive', array( self::class, 'handle_arrive' ), 10, 3 );
}
/**
* Handle outgoing "Arrive" activities from local actors.
*
* Arrive is an intransitive activity (no object) indicating that
* the actor has arrived at a location. Per the ActivityPub spec,
* the server must preserve the original activity type, so this
* handler adds the Arrive directly to the outbox as-is.
*
* As a local side effect, a WordPress post is created so the
* check-in appears on the blog with location geodata.
*
* @since 8.1.0
*
* @param array $data The activity data array.
* @param int $user_id The user ID.
* @param string|null $visibility Content visibility.
*
* @return int|\WP_Error|false The outbox post ID, error, or false.
*/
public static function handle_arrive( $data, $user_id = null, $visibility = null ) {
// Create a blog post for public check-ins so they appear on the site.
if ( is_activity_public( $data ) ) {
$post = self::create_checkin_post( $data, $user_id, $visibility );
if ( ! \is_wp_error( $post ) ) {
$data['url'] = \get_permalink( $post );
}
}
/*
* Add the original Arrive activity to the outbox directly.
* This preserves the intransitive activity type per the
* ActivityPub spec (Section 6) instead of wrapping it in Create.
*/
$outbox_id = add_to_outbox( $data, null, $user_id, $visibility );
if ( ! $outbox_id ) {
return new \WP_Error(
'activitypub_outbox_error',
\__( 'Failed to add Arrive activity to outbox.', 'activitypub' ),
array( 'status' => 500 )
);
}
return $outbox_id;
}
/**
* Create a WordPress post from the Arrive activity.
*
* Creates a blog post with the check-in content and saves
* location geodata so it can be displayed on the site.
*
* @since 8.1.0
*
* @param array $data The activity data.
* @param int $user_id The user ID.
* @param string|null $visibility Content visibility.
*
* @return \WP_Post|\WP_Error The created post or error.
*/
private static function create_checkin_post( $data, $user_id, $visibility ) {
$location = $data['location'] ?? null;
$location_name = self::get_location_name( $location );
$title = $location_name
? sprintf(
/* translators: %s: location name */
\__( 'Checked in at %s', 'activitypub' ),
$location_name
)
: \__( 'Check-in', 'activitypub' );
$activity = array(
'object' => array(
'type' => 'Note',
'name' => $title,
'content' => $data['content'] ?? $data['summary'] ?? '',
),
'to' => $data['to'] ?? array(),
'cc' => $data['cc'] ?? array(),
);
$post = Posts::create( $activity, $user_id, $visibility );
if ( \is_wp_error( $post ) ) {
return $post;
}
self::save_location( $post->ID, $location );
/**
* Fires after an Arrive activity has created a local blog post.
*
* @since 8.1.0
*
* @param int $post_id The created post ID.
* @param array|null $location The location data from the activity.
* @param array $data The activity data.
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_outbox_arrive_sent', $post->ID, $location, $data, $user_id );
return $post;
}
/**
* Save location geodata on a post.
*
* Uses the standard `geo_*` meta keys that the Post transformer
* reads back when converting to ActivityPub Place objects.
*
* @since 8.1.0
*
* @param int $post_id The post ID.
* @param array|null $location The ActivityPub location data.
*/
private static function save_location( $post_id, $location ) {
if ( ! \is_array( $location ) ) {
return;
}
if ( ! empty( $location['name'] ) ) {
\update_post_meta( $post_id, 'geo_address', \sanitize_text_field( $location['name'] ) );
}
if ( isset( $location['latitude'] ) && \is_numeric( $location['latitude'] ) ) {
\update_post_meta( $post_id, 'geo_latitude', (float) $location['latitude'] );
}
if ( isset( $location['longitude'] ) && \is_numeric( $location['longitude'] ) ) {
\update_post_meta( $post_id, 'geo_longitude', (float) $location['longitude'] );
}
if ( ! empty( $location['name'] ) || ( isset( $location['latitude'] ) && isset( $location['longitude'] ) ) ) {
\update_post_meta( $post_id, 'geo_public', '1' );
}
}
/**
* Extract a human-readable name from an ActivityPub location.
*
* @param mixed $location The location data (array or string).
*
* @return string|null The location name or null.
*/
private static function get_location_name( $location ) {
if ( \is_array( $location ) && ! empty( $location['name'] ) ) {
return \sanitize_text_field( $location['name'] );
}
if ( \is_string( $location ) && ! empty( $location ) ) {
return \sanitize_text_field( $location );
}
return null;
}
}

View File

@ -0,0 +1,62 @@
<?php
/**
* Outbox Block handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use Activitypub\Moderation;
use function Activitypub\add_to_outbox;
use function Activitypub\object_to_uri;
/**
* Handle outgoing Block activities.
*/
class Block {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_outbox_block', array( self::class, 'handle_block' ), 10, 2 );
}
/**
* Handle outgoing "Block" activities from local actors.
*
* Blocks a remote actor using the Moderation system, then adds
* the activity to the outbox for federation.
*
* @since 8.1.0
*
* @param array $data The activity data array.
* @param int $user_id The user ID.
*
* @return array|int|\WP_Error The original data if unhandled, outbox post ID on success, or WP_Error on failure.
*/
public static function handle_block( $data, $user_id = null ) {
$actor_uri = object_to_uri( $data['object'] ?? '' );
if ( empty( $actor_uri ) ) {
return $data;
}
$result = Moderation::add_user_block( $user_id, Moderation::TYPE_ACTOR, $actor_uri );
if ( ! $result ) {
return new \WP_Error(
'activitypub_block_failed',
\__( 'Failed to block the actor.', 'activitypub' ),
array( 'status' => 500 )
);
}
// Block activities should only be sent to the blocked actor.
$data['to'] = array( $actor_uri );
unset( $data['cc'] );
return add_to_outbox( $data, 'Block', $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
}
}

View File

@ -0,0 +1,121 @@
<?php
/**
* Outbox Create handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use Activitypub\Collection\Interactions;
use Activitypub\Collection\Posts;
use function Activitypub\is_activity_public;
use function Activitypub\is_activity_reply;
use function Activitypub\is_quote_activity;
/**
* Handle outgoing Create activities (C2S).
*/
class Create {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_outbox_create', array( self::class, 'handle_create' ), 10, 3 );
}
/**
* Handle outgoing "Create" activities from local actors.
*
* Creates WordPress content and adds to outbox for federation.
*
* @param array $activity The activity data.
* @param int $user_id The local user ID.
* @param string|null $visibility Content visibility.
*
* @return int|\WP_Error|false The outbox ID on success, WP_Error on failure, false if not handled.
*/
public static function handle_create( $activity, $user_id = null, $visibility = null ) {
// Skip private/direct activities.
if ( ! is_activity_public( $activity ) ) {
return false;
}
$object = $activity['object'] ?? array();
if ( ! \is_array( $object ) ) {
return new \WP_Error( 'invalid_object', 'Invalid object in activity.' );
}
$object_type = $object['type'] ?? '';
// Only handle Note and Article types for now.
if ( ! \in_array( $object_type, array( 'Note', 'Article' ), true ) ) {
return false;
}
if ( is_activity_reply( $activity ) ) {
return self::create_comment( $activity, $user_id );
}
// TODO: Handle quotes differently.
if ( is_quote_activity( $activity ) ) {
return false;
}
return self::create_post( $activity, $user_id, $visibility );
}
/**
* Handle outgoing post from local actor.
*
* Creates a WordPress post. The scheduler will add it to the outbox.
*
* @param array $activity The activity data.
* @param int $user_id The local user ID.
* @param string|null $visibility Content visibility.
*
* @return \WP_Post|\WP_Error The created post on success, WP_Error on failure.
*/
private static function create_post( $activity, $user_id, $visibility ) {
$post = Posts::create( $activity, $user_id, $visibility );
if ( \is_wp_error( $post ) ) {
return $post;
}
/**
* Fires after a post has been created from an outgoing Create activity.
*
* @param int $post_id The created post ID.
* @param array $activity The activity data.
* @param int $user_id The user ID.
* @param string $visibility The content visibility.
*/
\do_action( 'activitypub_outbox_created_post', $post->ID, $activity, $user_id, $visibility );
return $post;
}
/**
* Handle outgoing reply from local actor.
*
* Creates a WordPress comment on the local post. The comment scheduler
* will add it to the outbox and federate it.
*
* @param array $activity The activity data.
* @param int $user_id The local user ID.
*
* @return \WP_Comment|false Comment on success, false if not a local reply.
*/
private static function create_comment( $activity, $user_id ) {
$result = Interactions::add_comment( $activity, $user_id );
if ( ! $result ) {
return false;
}
return \get_comment( $result );
}
}

View File

@ -0,0 +1,130 @@
<?php
/**
* Outbox Delete handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use Activitypub\Collection\Remote_Posts;
use function Activitypub\object_to_uri;
use function Activitypub\url_to_commentid;
/**
* Handle outgoing Delete activities.
*/
class Delete {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_outbox_delete', array( self::class, 'handle_delete' ), 10, 2 );
}
/**
* Handle outgoing "Delete" activities from local actors.
*
* Deletes a WordPress post or comment.
*
* @param array $data The activity data array.
* @param int $user_id The user ID.
*
* @return \WP_Post|\WP_Comment|false The deleted object, or false on failure.
*/
public static function handle_delete( $data, $user_id = null ) {
$object_id = object_to_uri( $data['object'] ?? '' );
if ( empty( $object_id ) ) {
return false;
}
// Try to delete a comment first, then fall back to a post.
$result = self::maybe_delete_comment( $object_id, $user_id );
if ( ! $result ) {
$result = self::maybe_delete_post( $object_id, $user_id );
}
if ( $result ) {
/**
* Fires after content has been deleted via an outgoing Delete activity.
*
* @param \WP_Post|\WP_Comment $result The deleted object.
* @param array $data The activity data.
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_outbox_handled_delete', $result, $data, $user_id );
}
return $result;
}
/**
* Try to delete a comment by its ActivityPub ID.
*
* @param string $object_id The ActivityPub object ID (URL).
* @param int $user_id The user ID.
*
* @return \WP_Comment|false The deleted comment, or false on failure.
*/
private static function maybe_delete_comment( $object_id, $user_id ) {
$comment_id = url_to_commentid( $object_id );
if ( ! $comment_id ) {
return false;
}
$comment = \get_comment( $comment_id );
if ( ! $comment ) {
return false;
}
// Verify the user owns this comment.
if ( (int) $comment->user_id !== $user_id && $user_id > 0 ) {
return false;
}
if ( \wp_trash_comment( $comment ) ) {
return $comment;
}
return false;
}
/**
* Try to delete a post by its ActivityPub ID.
*
* @param string $object_id The ActivityPub object ID (URL).
* @param int $user_id The user ID.
*
* @return \WP_Post|false The deleted post, or false on failure.
*/
private static function maybe_delete_post( $object_id, $user_id ) {
// Try to find a local post by permalink.
$post_id = \url_to_postid( $object_id );
$post = $post_id ? \get_post( $post_id ) : null;
// Fall back to Posts collection for remote posts (ap_post type).
if ( ! $post instanceof \WP_Post ) {
$post = Remote_Posts::get_by_guid( $object_id );
}
if ( ! $post instanceof \WP_Post ) {
return false;
}
// Verify the user owns this post.
if ( (int) $post->post_author !== $user_id && $user_id > 0 ) {
return false;
}
if ( \wp_trash_post( $post->ID ) ) {
return $post;
}
return false;
}
}

View File

@ -0,0 +1,44 @@
<?php
/**
* Outbox Follow handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use function Activitypub\follow;
use function Activitypub\object_to_uri;
/**
* Handle outgoing Follow activities.
*/
class Follow {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_outbox_follow', array( self::class, 'handle_follow' ), 10, 2 );
}
/**
* Handle outgoing "Follow" activities from local actors.
*
* Delegates to the follow() function which handles pending state,
* proper activity addressing, and adding to the outbox.
*
* @param array $data The activity data array.
* @param int $user_id The user ID.
*
* @return int|\WP_Error The outbox post ID on success, or WP_Error on failure.
*/
public static function handle_follow( $data, $user_id = null ) {
$object = object_to_uri( $data['object'] ?? '' );
if ( empty( $object ) ) {
return $data;
}
return follow( $object, $user_id );
}
}

View File

@ -0,0 +1,49 @@
<?php
/**
* Outbox Like handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use function Activitypub\object_to_uri;
/**
* Handle outgoing Like activities.
*/
class Like {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_outbox_like', array( self::class, 'handle_like' ), 10, 2 );
}
/**
* Handle outgoing "Like" activities from local actors.
*
* Records a like from the local user on remote content.
*
* @param array $data The activity data array.
* @param int $user_id The user ID.
*/
public static function handle_like( $data, $user_id = null ) {
$object_url = object_to_uri( $data['object'] ?? '' );
if ( empty( $object_url ) ) {
return $data;
}
/**
* Fires after an outgoing Like activity has been processed.
*
* @param string $object_url The URL of the liked object.
* @param array $data The activity data.
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_outbox_like_sent', $object_url, $data, $user_id );
return $data;
}
}

View File

@ -0,0 +1,95 @@
<?php
/**
* Outbox Remove handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use Activitypub\Collection\Actors;
use function Activitypub\object_to_uri;
/**
* Handle outgoing Remove activities.
*
* Supports removing objects from an actor's featured collection
* by unsticking the corresponding WordPress post.
*/
class Remove {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_outbox_remove', array( self::class, 'handle_remove' ), 10, 2 );
}
/**
* Handle outgoing "Remove" activities from local actors.
*
* When the target is the actor's featured collection, the referenced
* post is unstuck. The unstick action triggers the scheduler which
* creates the outbox entry automatically.
*
* @since 8.1.0
*
* @param array $data The activity data array.
* @param int $user_id The user ID.
*
* @return \WP_Post|\WP_Error|array The post object on success, WP_Error on failure, or original data if unhandled.
*/
public static function handle_remove( $data, $user_id = null ) {
$object_uri = object_to_uri( $data['object'] ?? '' );
$target = object_to_uri( $data['target'] ?? '' );
if ( empty( $object_uri ) || empty( $target ) ) {
return $data;
}
$actor = Actors::get_by_id( $user_id );
if ( \is_wp_error( $actor ) ) {
return $actor;
}
// Only handle featured collection targets.
if ( $target !== $actor->get_featured() ) {
return $data;
}
$post_id = \url_to_postid( $object_uri );
if ( ! $post_id ) {
return new \WP_Error(
'activitypub_object_not_found',
\__( 'The referenced object was not found.', 'activitypub' ),
array( 'status' => 404 )
);
}
$post = \get_post( $post_id );
if ( ! $post ) {
return new \WP_Error(
'activitypub_object_not_found',
\__( 'The referenced object was not found.', 'activitypub' ),
array( 'status' => 404 )
);
}
// Verify the user owns this post.
if ( $user_id > 0 && (int) $post->post_author !== $user_id ) {
return new \WP_Error(
'activitypub_forbidden',
\__( 'You can only unfeature your own posts.', 'activitypub' ),
array( 'status' => 403 )
);
}
// Unsticking the post triggers the scheduler which adds to outbox.
\unstick_post( $post_id );
return $post;
}
}

View File

@ -0,0 +1,116 @@
<?php
/**
* Outbox Undo handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox as Outbox_Collection;
use Activitypub\Moderation;
use function Activitypub\object_to_uri;
use function Activitypub\unfollow;
/**
* Handle outgoing Undo activities.
*/
class Undo {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_outbox_undo', array( self::class, 'handle_undo' ), 10, 2 );
}
/**
* Handle outgoing "Undo" activities from local actors.
*
* Resolves the referenced activity from the outbox and delegates
* to the appropriate collection method to reverse its side effects
* and create the Undo activity.
*
* @param array $data The activity data array.
* @param int $user_id The user ID.
*
* @return int|\WP_Error The undo outbox item ID, or WP_Error on failure.
*/
public static function handle_undo( $data, $user_id = null ) {
$object = $data['object'] ?? '';
$id = object_to_uri( $object );
if ( empty( $id ) ) {
/*
* The embedded object has no `id` — common for clients that
* inline the activity to undo. Mastodon and other major
* implementations match an id-less Undo→Follow on the inner
* Follow's target. Mirror that fallback here so spec-valid
* bodies don't bypass the local unfollow logic.
*/
if ( \is_array( $object ) && 'Follow' === ( $object['type'] ?? '' ) ) {
$embedded_actor = object_to_uri( $object['actor'] ?? '' );
$user_actor = Actors::get_by_id( $user_id );
if ( \is_wp_error( $user_actor ) || ! $embedded_actor || $embedded_actor !== $user_actor->get_id() ) {
return new \WP_Error(
'activitypub_forbidden',
\__( 'You can only undo your own activities.', 'activitypub' ),
array( 'status' => 403 )
);
}
$target = object_to_uri( $object['object'] ?? '' );
if ( $target ) {
return unfollow( $target, $user_id );
}
}
return $data;
}
$outbox_item = Outbox_Collection::get_by_guid( $id );
if ( \is_wp_error( $outbox_item ) ) {
return $data;
}
// Verify the user owns this outbox item (blog actor user_id === 0 can undo any).
if ( $user_id > 0 && (int) $outbox_item->post_author !== $user_id ) {
return new \WP_Error(
'activitypub_forbidden',
\__( 'You can only undo your own activities.', 'activitypub' ),
array( 'status' => 403 )
);
}
$activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
switch ( $activity_type ) {
case 'Follow':
$stored = \json_decode( $outbox_item->post_content, true );
$target = object_to_uri( $stored['object'] ?? '' );
if ( $target ) {
return unfollow( $target, $user_id );
}
return $data;
case 'Block':
$stored = \json_decode( $outbox_item->post_content, true );
$actor_uri = \is_array( $stored ) ? object_to_uri( $stored['object'] ?? '' ) : '';
if ( $actor_uri ) {
Moderation::remove_user_block( $user_id, Moderation::TYPE_ACTOR, $actor_uri );
}
return Outbox_Collection::undo( $outbox_item );
default:
return Outbox_Collection::undo( $outbox_item );
}
}
}

View File

@ -0,0 +1,114 @@
<?php
/**
* Outbox Update handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler\Outbox;
use Activitypub\Collection\Posts;
use Activitypub\Collection\Remote_Posts;
use function Activitypub\is_activity_public;
/**
* Handle outgoing Update activities (C2S).
*/
class Update {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_outbox_update', array( self::class, 'handle_update' ), 10, 3 );
}
/**
* Handle outgoing "Update" activities from local actors.
*
* Updates a WordPress post from the ActivityPub object. The post scheduler
* will add it to the outbox and federate it.
*
* @param array $activity The activity data.
* @param int $user_id The local user ID.
* @param string|null $visibility Content visibility.
*
* @return \WP_Post|\WP_Error|false The updated post on success, WP_Error on failure, false if not handled.
*/
public static function handle_update( $activity, $user_id = null, $visibility = null ) {
// Skip private/direct activities.
if ( ! is_activity_public( $activity ) ) {
return false;
}
$object = $activity['object'] ?? array();
if ( ! \is_array( $object ) ) {
return false;
}
$type = $object['type'] ?? '';
// Only handle Note and Article types.
if ( ! \in_array( $type, array( 'Note', 'Article' ), true ) ) {
return false;
}
$object_id = $object['id'] ?? '';
if ( empty( $object_id ) ) {
return false;
}
/*
* Find the post by its ActivityPub ID.
* First try to find a local post by permalink.
*/
$post_id = \url_to_postid( $object_id );
$post = $post_id ? \get_post( $post_id ) : null;
// Fall back to Posts collection for remote posts (ap_post type).
if ( ! $post instanceof \WP_Post ) {
$post = Remote_Posts::get_by_guid( $object_id );
}
if ( ! $post instanceof \WP_Post ) {
return false;
}
/*
* Verify the user owns this post.
* The blog actor ($user_id === 0) can update any post since it
* represents the site itself.
*/
if ( (int) $post->post_author !== $user_id && $user_id > 0 ) {
return false;
}
// Verify the user has permission to edit this post.
if ( $user_id > 0 && ! \user_can( $user_id, 'edit_post', $post->ID ) ) {
return new \WP_Error(
'activitypub_forbidden',
\__( 'You do not have permission to edit this post.', 'activitypub' ),
array( 'status' => 403 )
);
}
$post = Posts::update( $post, $activity, $visibility );
if ( \is_wp_error( $post ) ) {
return $post;
}
/**
* Fires after a post has been updated from an outgoing Update activity.
*
* @param int $post_id The updated post ID.
* @param array $activity The activity data.
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_outbox_updated_post', $post->ID, $activity, $user_id );
return $post;
}
}