updated plugin ActivityPub version 9.1.0
This commit is contained in:
@ -11,6 +11,7 @@ use Activitypub\Collection\Following;
|
||||
use Activitypub\Collection\Outbox;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
use function Activitypub\is_same_actor;
|
||||
use function Activitypub\object_to_uri;
|
||||
|
||||
/**
|
||||
@ -42,13 +43,22 @@ class Accept {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor_post = Remote_Actors::get_by_uri( object_to_uri( $accept['object']['object'] ) );
|
||||
/*
|
||||
* For a Follow Accept, the sender must be the actor that was followed.
|
||||
* Without this, a signed Accept from one actor could confirm a Follow that
|
||||
* targeted another actor by referencing that pending Follow's outbox GUID.
|
||||
*/
|
||||
if ( ! is_same_actor( $accept['actor'] ?? '', $accept['object']['object'] ?? '' ) ) {
|
||||
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;
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
$result = Following::accept( $actor_post, $user_id );
|
||||
$success = ! \is_wp_error( $result );
|
||||
|
||||
|
||||
@ -53,13 +53,29 @@ class Announce {
|
||||
|
||||
self::maybe_save_announce( $announcement, $user_ids );
|
||||
|
||||
if ( is_string( $announcement['object'] ) ) {
|
||||
$object = Http::get_remote_object( $announcement['object'] );
|
||||
} else {
|
||||
$object = $announcement['object'];
|
||||
}
|
||||
$object_url = object_to_uri( $announcement['object'] );
|
||||
|
||||
if ( ! $object || is_wp_error( $object ) ) {
|
||||
// Force no redirects for this object's request only, so the requested host stays the authoritative origin.
|
||||
$no_redirects = static function ( $args, $url ) use ( $object_url ) {
|
||||
if ( $url === $object_url ) {
|
||||
$args['redirection'] = 0;
|
||||
}
|
||||
return $args;
|
||||
};
|
||||
|
||||
/*
|
||||
* Fetch the activity from its own id rather than the inline copy the Announce
|
||||
* carries: that copy is the announcer's, who is not necessarily the activity's
|
||||
* author. Redirects are forbidden (above) and the cache is bypassed so the
|
||||
* requested host is the authoritative origin — otherwise a redirect, or a
|
||||
* response cached from an earlier redirect-following fetch, could resolve to
|
||||
* attacker content while the host check below still saw the trusted host.
|
||||
*/
|
||||
\add_filter( 'http_request_args', $no_redirects, 10, 2 );
|
||||
$object = Http::get_remote_object( $object_url, false );
|
||||
\remove_filter( 'http_request_args', $no_redirects, 10 );
|
||||
|
||||
if ( ! $object || \is_wp_error( $object ) || ! \is_array( $object ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -67,6 +83,19 @@ class Announce {
|
||||
return;
|
||||
}
|
||||
|
||||
$origin_host = \strtolower( (string) \wp_parse_url( (string) $object_url, \PHP_URL_HOST ) );
|
||||
$actor_host = \strtolower( (string) \wp_parse_url( (string) object_to_uri( $object['actor'] ?? '' ), \PHP_URL_HOST ) );
|
||||
|
||||
/*
|
||||
* Only an actor's own server may vouch for an activity attributed to it, so the
|
||||
* host it was fetched from must equal its actor's host — the same key-host ==
|
||||
* actor-host binding verify_key_id() enforces for signed requests, generalised
|
||||
* to every relayed activity type.
|
||||
*/
|
||||
if ( '' === $origin_host || '' === $actor_host || $origin_host !== $actor_host ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$type = \strtolower( $object['type'] );
|
||||
|
||||
/**
|
||||
@ -102,7 +131,8 @@ class Announce {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = Comment::object_id_to_comment( esc_url_raw( $url ) );
|
||||
// Match any status, so a repost that was marked as spam or trashed still counts as seen.
|
||||
$exists = Comment::object_id_to_comment( \esc_url_raw( $url ), array( 'status' => 'any' ) );
|
||||
if ( $exists ) {
|
||||
return;
|
||||
}
|
||||
@ -115,9 +145,9 @@ class Announce {
|
||||
$success = false;
|
||||
$result = Interactions::add_reaction( $activity );
|
||||
|
||||
if ( $result && ! is_wp_error( $result ) ) {
|
||||
if ( $result && ! \is_wp_error( $result ) ) {
|
||||
$success = true;
|
||||
$result = get_comment( $result );
|
||||
$result = \get_comment( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -66,7 +66,7 @@ class Collection_Sync {
|
||||
|
||||
// Check for followers collection.
|
||||
$collection_type = null;
|
||||
if ( preg_match( '#/followers(?:/sync)?(?:\?|$)#', $params['url'] ) ) {
|
||||
if ( \preg_match( '#/followers(?:/sync)?(?:\?|$)#', $params['url'] ) ) {
|
||||
$collection_type = 'followers';
|
||||
}
|
||||
|
||||
@ -89,10 +89,10 @@ class Collection_Sync {
|
||||
|
||||
// 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 );
|
||||
$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 );
|
||||
\set_transient( $cache_key, \time(), $frequency );
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@ -127,7 +127,7 @@ class Collection_Sync {
|
||||
return $args;
|
||||
}
|
||||
|
||||
if ( ! is_array( $args['body'] ) ) {
|
||||
if ( ! \is_array( $args['body'] ) ) {
|
||||
$body = \json_decode( $args['body'], true );
|
||||
if ( null === $body ) {
|
||||
return $args;
|
||||
@ -149,7 +149,7 @@ class Collection_Sync {
|
||||
}
|
||||
|
||||
// Check if we've already sent a sync header to this authority today.
|
||||
$transient_key = 'activitypub_collection_sync_sent_' . $user_id . '_' . md5( $inbox_authority );
|
||||
$transient_key = 'activitypub_collection_sync_sent_' . $user_id . '_' . \md5( $inbox_authority );
|
||||
if ( false !== \get_transient( $transient_key ) ) {
|
||||
return $args;
|
||||
}
|
||||
@ -159,7 +159,7 @@ class Collection_Sync {
|
||||
$args['headers']['Collection-Synchronization'] = $sync_header;
|
||||
|
||||
$frequency = self::get_frequency();
|
||||
\set_transient( $transient_key, time(), $frequency );
|
||||
\set_transient( $transient_key, \time(), $frequency );
|
||||
}
|
||||
|
||||
return $args;
|
||||
@ -196,7 +196,7 @@ class Collection_Sync {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( trailingslashit( $params['collectionId'] ) !== trailingslashit( $expected_collection ) ) {
|
||||
if ( \trailingslashit( $params['collectionId'] ) !== \trailingslashit( $expected_collection ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -164,7 +164,7 @@ class Create {
|
||||
* @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 ) ) {
|
||||
if ( ! \in_array( $activity->get_type(), array( 'Create', 'Update' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -156,7 +156,7 @@ class Delete {
|
||||
$follower = Remote_Actors::get_by_uri( $activity['actor'] );
|
||||
|
||||
// Verify that Actor is deleted.
|
||||
if ( ! is_wp_error( $follower ) && Tombstone::exists( $activity['actor'] ) ) {
|
||||
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 );
|
||||
@ -252,7 +252,7 @@ class Delete {
|
||||
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 );
|
||||
\wp_delete_comment( $comment->comment_ID, true );
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@ -0,0 +1,350 @@
|
||||
<?php
|
||||
/**
|
||||
* Handler for FeatureRequest activities (FEP-7aa9).
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Handler;
|
||||
|
||||
use Activitypub\Activity\Activity;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Followers;
|
||||
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 FeatureRequest activities.
|
||||
*
|
||||
* @see https://w3id.org/fep/7aa9
|
||||
*/
|
||||
class Feature_Request {
|
||||
|
||||
/**
|
||||
* Option-name prefix for the blog actor's feature stamps.
|
||||
*
|
||||
* The blog actor has no users-table row (its ID is 0), so its stamps cannot
|
||||
* live in user meta like the stamps of regular users do. Each stamp is stored
|
||||
* in its own option row, keyed `{prefix}_{id}`, so a stamp ID can be claimed
|
||||
* atomically with `add_option()` without a lock.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const BLOG_STAMPS_OPTION = 'activitypub_blog_featured_by';
|
||||
|
||||
/**
|
||||
* Initialize the class, registering WordPress hooks.
|
||||
*/
|
||||
public static function init() {
|
||||
\add_action( 'activitypub_inbox_feature_request', array( self::class, 'handle_feature_request' ), 10, 2 );
|
||||
\add_action( 'activitypub_rest_inbox_disallowed', array( self::class, 'handle_blocked_request' ), 10, 3 );
|
||||
|
||||
\add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle FeatureRequest activities.
|
||||
*
|
||||
* @param array $activity The activity object.
|
||||
* @param int|int[] $user_ids The user ID(s) targeted by the inbox dispatch.
|
||||
*/
|
||||
public static function handle_feature_request( $activity, $user_ids ) {
|
||||
$state = true;
|
||||
$object_uri = object_to_uri( $activity['object'] );
|
||||
$target = Actors::get_by_resource( $object_uri );
|
||||
|
||||
if ( \is_wp_error( $target ) ) {
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
self::queue_reject( $activity, $user_id );
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = $target->get__id();
|
||||
|
||||
$policy = \get_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ME );
|
||||
|
||||
switch ( $policy ) {
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_ANYONE:
|
||||
self::queue_accept( $activity, $user_id );
|
||||
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 );
|
||||
} else {
|
||||
self::queue_reject( $activity, $user_id );
|
||||
$state = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_ME:
|
||||
default:
|
||||
self::queue_reject( $activity, $user_id );
|
||||
$state = false;
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after an ActivityPub FeatureRequest activity has been handled.
|
||||
*
|
||||
* @param array $activity The ActivityPub activity data.
|
||||
* @param int[] $user_ids The local user IDs.
|
||||
* @param bool $state True on accept, false otherwise.
|
||||
* @param string $policy The active site policy.
|
||||
*/
|
||||
\do_action( 'activitypub_handled_feature_request', $activity, (array) $user_ids, $state, $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 activity type.
|
||||
*/
|
||||
public static function handle_blocked_request( $activity, $user_ids, $type ) {
|
||||
if ( ! \in_array( \strtolower( $type ), array( 'featurerequest', 'feature_request' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
self::queue_reject( $activity, $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an Accept activity in response to the FeatureRequest, issuing a stamp.
|
||||
*
|
||||
* Idempotent: a second call with the same instrument for the same actor reuses
|
||||
* the existing stamp instead of minting a duplicate, see {@see add_stamp()}.
|
||||
*
|
||||
* @param array $activity_object The activity object.
|
||||
* @param int $user_id The local user ID being featured (0 for the blog actor).
|
||||
*/
|
||||
public static function queue_accept( $activity_object, $user_id ) {
|
||||
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'] );
|
||||
|
||||
$stamp_id = self::add_stamp( $user_id, $activity_object['instrument'] );
|
||||
if ( ! $stamp_id ) {
|
||||
// Without a stamp there is nothing to consent with — don't send a dangling Accept.
|
||||
return;
|
||||
}
|
||||
|
||||
// Send minimal activity object back.
|
||||
$activity_object = \array_intersect_key(
|
||||
$activity_object,
|
||||
array(
|
||||
'id' => 1,
|
||||
'type' => 1,
|
||||
'actor' => 1,
|
||||
'object' => 1,
|
||||
'instrument' => 1,
|
||||
)
|
||||
);
|
||||
|
||||
$stamp_url = \add_query_arg(
|
||||
array(
|
||||
'actor' => $user_id,
|
||||
'stamp' => $stamp_id,
|
||||
),
|
||||
\home_url( '/' )
|
||||
);
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->set_type( 'Accept' );
|
||||
$activity->set_actor( $actor->get_id() );
|
||||
$activity->set_object( $activity_object );
|
||||
$activity->set_result( $stamp_url );
|
||||
$activity->add_to( object_to_uri( $activity_object['actor'] ) );
|
||||
|
||||
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or reuse) a feature stamp for an actor.
|
||||
*
|
||||
* Stamps for users live in user meta, with the umeta_id doubling as the
|
||||
* stamp ID. The blog actor has no users-table row, so each of its stamps is
|
||||
* stored in its own option row instead.
|
||||
*
|
||||
* Idempotent: an existing stamp for the same instrument is reused.
|
||||
*
|
||||
* @since 9.0.1
|
||||
*
|
||||
* @param int $user_id The local actor ID (0 for the blog actor).
|
||||
* @param string $instrument The instrument URI being stamped.
|
||||
* @return int|false The stamp ID, or false on failure.
|
||||
*/
|
||||
public static function add_stamp( $user_id, $instrument ) {
|
||||
if ( Actors::BLOG_USER_ID === $user_id ) {
|
||||
return self::add_blog_stamp( $instrument );
|
||||
}
|
||||
|
||||
$existing = \get_user_meta( $user_id, '_activitypub_featured_by', false );
|
||||
if ( \in_array( $instrument, (array) $existing, true ) ) {
|
||||
global $wpdb;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s AND meta_value = %s LIMIT 1",
|
||||
$user_id,
|
||||
'_activitypub_featured_by',
|
||||
$instrument
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return \add_user_meta( $user_id, '_activitypub_featured_by', $instrument );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or reuse) a feature stamp for the blog actor.
|
||||
*
|
||||
* Each blog stamp lives in its own option row (`{prefix}_{id}`), so a slot is
|
||||
* claimed atomically with `add_option()` — which only inserts when the row is
|
||||
* absent (see Scheduler\Statistics::send_annual_email()). Concurrent inbox
|
||||
* deliveries therefore can't mint the same ID or clobber each other's writes,
|
||||
* and no lock is needed. Stamps are never deleted, so the slots stay gap-free
|
||||
* and the walk stops at the first empty slot.
|
||||
*
|
||||
* @param string $instrument The instrument URI being stamped.
|
||||
* @return int|false The stamp ID, or false on failure.
|
||||
*/
|
||||
private static function add_blog_stamp( $instrument ) {
|
||||
$stamp_id = 1;
|
||||
|
||||
/*
|
||||
* Bounded far above any realistic blog stamp count, to guard against a
|
||||
* pathological object-cache state where a just-claimed slot never becomes
|
||||
* visible and the re-read would otherwise spin forever.
|
||||
*/
|
||||
for ( $attempt = 0; $attempt < 10000; $attempt++ ) {
|
||||
$key = self::BLOG_STAMPS_OPTION . '_' . $stamp_id;
|
||||
$current = \get_option( $key );
|
||||
|
||||
// Idempotent: an existing stamp for the same instrument is reused.
|
||||
if ( $current === $instrument ) {
|
||||
return $stamp_id;
|
||||
}
|
||||
|
||||
if ( false === $current ) {
|
||||
if ( \add_option( $key, $instrument, '', false ) ) {
|
||||
return $stamp_id;
|
||||
}
|
||||
|
||||
// A concurrent accept claimed this slot first; re-read it (no
|
||||
// increment) to see whether it took our instrument or another.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Slot holds a different instrument; try the next one.
|
||||
++$stamp_id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stamp ID for an actor to the stamped instrument URI.
|
||||
*
|
||||
* @since 9.0.1
|
||||
*
|
||||
* @param int $user_id The local actor ID (0 for the blog actor).
|
||||
* @param int $stamp_id The stamp ID.
|
||||
* @return string|null The instrument URI, or null if the stamp does not exist for this actor.
|
||||
*/
|
||||
public static function get_stamp( $user_id, $stamp_id ) {
|
||||
if ( Actors::BLOG_USER_ID === $user_id ) {
|
||||
$instrument = \get_option( self::BLOG_STAMPS_OPTION . '_' . (int) $stamp_id );
|
||||
|
||||
return false === $instrument ? null : $instrument;
|
||||
}
|
||||
|
||||
$meta = \get_metadata_by_mid( 'user', $stamp_id );
|
||||
if ( ! $meta || '_activitypub_featured_by' !== $meta->meta_key || (int) $meta->user_id !== $user_id ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $meta->meta_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Reject activity in response to the FeatureRequest.
|
||||
*
|
||||
* @param array $activity_object The activity object.
|
||||
* @param int $user_id The user ID.
|
||||
*/
|
||||
public static function queue_reject( $activity_object, $user_id ) {
|
||||
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;
|
||||
}
|
||||
|
||||
if ( isset( $activity_object['instrument'] ) ) {
|
||||
$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 on incoming FeatureRequest activities.
|
||||
*
|
||||
* @param bool $valid The current validation state.
|
||||
* @param string $param The object parameter name.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function validate_object( $valid, $param, $request ) {
|
||||
$activity = $request->get_json_params();
|
||||
|
||||
if ( empty( $activity['type'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( 'FeatureRequest' !== $activity['type'] ) {
|
||||
return $valid;
|
||||
}
|
||||
|
||||
if ( ! isset( $activity['actor'], $activity['object'], $activity['instrument'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $valid;
|
||||
}
|
||||
}
|
||||
@ -8,11 +8,14 @@
|
||||
namespace Activitypub\Handler;
|
||||
|
||||
use Activitypub\Activity\Activity;
|
||||
use Activitypub\Application;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
use Activitypub\Http;
|
||||
|
||||
use function Activitypub\add_to_outbox;
|
||||
use function Activitypub\object_to_uri;
|
||||
|
||||
/**
|
||||
* Handle Follow requests.
|
||||
@ -24,6 +27,9 @@ class Follow {
|
||||
public static function init() {
|
||||
\add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow' ), 10, 2 );
|
||||
\add_action( 'activitypub_handled_follow', array( self::class, 'queue_accept' ), 10, 4 );
|
||||
|
||||
// The Application actor cannot be followed; explicitly reject such Follows so they don't sit "pending" on the remote instance forever.
|
||||
\add_action( 'activitypub_inbox_shared_follow', array( self::class, 'reject_application_follow' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -36,11 +42,6 @@ class Follow {
|
||||
// Extract the user ID (follow requests are always for a single user).
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
|
||||
if ( Actors::APPLICATION_USER_ID === $user_id ) {
|
||||
self::queue_reject( $activity, $user_id );
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the actor already follows the user.
|
||||
$already_following = false;
|
||||
$remote_actor = Remote_Actors::get_by_uri( $activity['actor'] );
|
||||
@ -103,7 +104,7 @@ class Follow {
|
||||
$actor = $activity_object['actor'];
|
||||
|
||||
// Only send minimal data.
|
||||
$activity_object = array_intersect_key(
|
||||
$activity_object = \array_intersect_key(
|
||||
$activity_object,
|
||||
array(
|
||||
'id' => 1,
|
||||
@ -123,14 +124,43 @@ class Follow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Reject response.
|
||||
* Reject Follow requests aimed at the Application actor.
|
||||
*
|
||||
* @param array $activity The Activity array.
|
||||
* @param int $user_id The ID of the WordPress User.
|
||||
* The Application advertises `manuallyApprovesFollowers` but is not followable,
|
||||
* so an explicit Reject is the only way a remote follow request gets resolved.
|
||||
* The Reject is sent directly instead of through the Outbox, which only
|
||||
* dispatches for real actors, and is signed with the Application key.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @param array $activity The Follow activity data.
|
||||
* @param int[] $user_ids The local recipient IDs the inbox resolved.
|
||||
*/
|
||||
public static function queue_reject( $activity, $user_id ) {
|
||||
public static function reject_application_follow( $activity, $user_ids ) {
|
||||
// A resolved recipient means the Follow targets a real actor, not the Application.
|
||||
if ( ! empty( $user_ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( empty( $activity['object'] ) || ! Application::is_application_resource( object_to_uri( $activity['object'] ) ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = object_to_uri( $activity['actor'] );
|
||||
$remote_actor = Remote_Actors::fetch_by_uri( $actor );
|
||||
|
||||
if ( \is_wp_error( $remote_actor ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inbox = \get_post_meta( $remote_actor->ID, '_activitypub_inbox', true );
|
||||
|
||||
if ( ! $inbox ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only send minimal data.
|
||||
$origin_activity = array_intersect_key(
|
||||
$origin_activity = \array_intersect_key(
|
||||
$activity,
|
||||
array(
|
||||
'id' => 1,
|
||||
@ -140,12 +170,13 @@ class Follow {
|
||||
)
|
||||
);
|
||||
|
||||
$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'] ) );
|
||||
$reject = new Activity();
|
||||
$reject->set_type( 'Reject' );
|
||||
$reject->set_id( Application::get_id() . '#reject-' . \md5( \wp_json_encode( $origin_activity ) ) );
|
||||
$reject->set_actor( Application::get_id() );
|
||||
$reject->set_object( $origin_activity );
|
||||
$reject->set_to( array( $actor ) );
|
||||
|
||||
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
|
||||
Http::post( $inbox, $reject->to_json(), null );
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,7 +41,13 @@ class Like {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = Comment::object_id_to_comment( esc_url_raw( $url ) );
|
||||
/*
|
||||
* Dedupe on the Like activity ID (its source_id), not the liked object, so repeat
|
||||
* deliveries stay idempotent even when a mangled author name defeats WordPress's own
|
||||
* duplicate check. Match any status, so a like that was marked as spam or trashed
|
||||
* still counts as seen. See https://github.com/Automattic/wordpress-activitypub/issues/3215.
|
||||
*/
|
||||
$exists = Comment::object_id_to_comment( \esc_url_raw( object_to_uri( $like ) ), array( 'status' => 'any' ) );
|
||||
if ( $exists ) {
|
||||
return;
|
||||
}
|
||||
@ -49,9 +55,9 @@ class Like {
|
||||
$success = false;
|
||||
$result = Interactions::add_reaction( $like );
|
||||
|
||||
if ( $result && ! is_wp_error( $result ) ) {
|
||||
if ( $result && ! \is_wp_error( $result ) ) {
|
||||
$success = true;
|
||||
$result = get_comment( $result );
|
||||
$result = \get_comment( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -62,15 +62,17 @@ class Move {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->update(
|
||||
$wpdb->posts,
|
||||
array( 'guid' => sanitize_url( $target_uri ) ),
|
||||
array( 'ID' => sanitize_key( $origin_object->ID ) )
|
||||
array( 'guid' => \sanitize_url( $target_uri ) ),
|
||||
array( 'ID' => \sanitize_key( $origin_object->ID ) )
|
||||
);
|
||||
|
||||
// Clear the cache.
|
||||
\wp_cache_delete( $origin_object->ID, 'posts' );
|
||||
|
||||
$success = true;
|
||||
$result = Remote_Actors::upsert( $target_json );
|
||||
|
||||
// get_remote_object() already self-confirmed the target, so it is safe to cache.
|
||||
$result = Remote_Actors::upsert( $target_json );
|
||||
}
|
||||
|
||||
// If both the target and origin are followed, merge them.
|
||||
@ -177,7 +179,7 @@ class Move {
|
||||
}
|
||||
|
||||
// Collect all possible origin identifiers (id, url, webfinger).
|
||||
$origin_ids = array_filter(
|
||||
$origin_ids = \array_filter(
|
||||
array(
|
||||
$origin_object['id'] ?? null,
|
||||
$origin_object['url'] ?? null,
|
||||
@ -186,7 +188,7 @@ class Move {
|
||||
);
|
||||
|
||||
// Check if any origin identifier is in the alsoKnownAs property of the target.
|
||||
if ( ! array_intersect( $origin_ids, $also_known_as ) ) {
|
||||
if ( ! \array_intersect( $origin_ids, $also_known_as ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -99,7 +99,7 @@ class Quote_Request {
|
||||
* @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 ) ) {
|
||||
if ( ! \in_array( \strtolower( $type ), array( 'quoterequest', 'quote_request' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -218,7 +218,7 @@ class Quote_Request {
|
||||
$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 ) ) {
|
||||
if ( \in_array( $activity_object['instrument'], $post_meta, true ) ) {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
@ -235,7 +235,7 @@ class Quote_Request {
|
||||
}
|
||||
|
||||
// Only send minimal data.
|
||||
$activity_object = array_intersect_key(
|
||||
$activity_object = \array_intersect_key(
|
||||
$activity_object,
|
||||
array(
|
||||
'id' => 1,
|
||||
@ -287,7 +287,7 @@ class Quote_Request {
|
||||
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
|
||||
|
||||
// Only send minimal data.
|
||||
$activity_object = array_intersect_key(
|
||||
$activity_object = \array_intersect_key(
|
||||
$activity_object,
|
||||
array(
|
||||
'id' => 1,
|
||||
|
||||
@ -56,14 +56,24 @@ class Reject {
|
||||
* @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 ) );
|
||||
/*
|
||||
* For a Follow Reject, the sender must be the actor that was followed.
|
||||
* Without this, a signed Reject from one actor could cancel a Follow that
|
||||
* targeted another actor by referencing that pending Follow's outbox GUID.
|
||||
*/
|
||||
$reject_actor = object_to_uri( $reject['actor'] ?? '' );
|
||||
$followed_actor = object_to_uri( $reject['object']['object'] ?? '' );
|
||||
if ( ! $reject_actor || ! $followed_actor || $reject_actor !== $followed_actor ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor_post = Remote_Actors::get_by_uri( $followed_actor );
|
||||
|
||||
if ( \is_wp_error( $actor_post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = is_array( $user_ids ) ? reset( $user_ids ) : $user_ids;
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
$result = Following::reject( $actor_post, $user_id );
|
||||
$success = ! \is_wp_error( $result );
|
||||
|
||||
@ -102,6 +112,14 @@ class Reject {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,7 +31,24 @@ class Undo {
|
||||
*/
|
||||
public static function handle_undo( $activity, $user_ids ) {
|
||||
$success = false;
|
||||
$result = Inbox_Collection::undo( object_to_uri( $activity['object'] ) );
|
||||
|
||||
/*
|
||||
* Resolve the sender so Inbox::undo() can verify ownership. A genuinely absent actor
|
||||
* maps to null (no ownership check, for programmatic callers), but an actor that is
|
||||
* present yet unparseable must be rejected rather than skipping the check — passing
|
||||
* null there would re-open the undo-by-id attack.
|
||||
*/
|
||||
$actor = isset( $activity['actor'] ) ? object_to_uri( $activity['actor'] ) : null;
|
||||
|
||||
if ( isset( $activity['actor'] ) && empty( $actor ) ) {
|
||||
$result = new \WP_Error(
|
||||
'activitypub_undo_invalid_actor',
|
||||
\__( 'The Undo activity has an invalid actor.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
} else {
|
||||
$result = Inbox_Collection::undo( object_to_uri( $activity['object'] ), $actor );
|
||||
}
|
||||
|
||||
if ( $result && ! \is_wp_error( $result ) ) {
|
||||
$success = true;
|
||||
|
||||
@ -13,6 +13,7 @@ use Activitypub\Collection\Remote_Posts;
|
||||
use Activitypub\Http;
|
||||
|
||||
use function Activitypub\is_activity_reply;
|
||||
use function Activitypub\object_to_uri;
|
||||
|
||||
/**
|
||||
* Handle Update requests.
|
||||
@ -29,7 +30,7 @@ class Update {
|
||||
* Handle "Update" requests.
|
||||
*
|
||||
* @param array $activity The Activity object.
|
||||
* @param int[] $user_ids The user IDs. Always null for Update activities.
|
||||
* @param int[] $user_ids Local recipient user IDs (followers and addressed local actors); may be empty.
|
||||
* @param \Activitypub\Activity\Activity $activity_object The activity object. Default null.
|
||||
*/
|
||||
public static function handle_update( $activity, $user_ids, $activity_object ) {
|
||||
@ -78,7 +79,7 @@ class Update {
|
||||
* Update an Object.
|
||||
*
|
||||
* @param array $activity The Activity object.
|
||||
* @param int[]|null $user_ids The user IDs. Always null for Update activities.
|
||||
* @param int[]|null $user_ids Local recipient user IDs (followers and addressed local actors); may be empty.
|
||||
* @param \Activitypub\Activity\Activity $activity_object The activity object. Default null.
|
||||
*/
|
||||
public static function update_object( $activity, $user_ids, $activity_object ) {
|
||||
@ -91,6 +92,10 @@ class Update {
|
||||
|
||||
if ( false === $comment_data ) {
|
||||
$updated = false;
|
||||
} elseif ( \is_wp_error( $comment_data ) ) {
|
||||
// Handled but rejected (e.g. a foreign actor): keep the failure so the
|
||||
// success flag stays false and the Create fallback is not triggered.
|
||||
$result = $comment_data;
|
||||
} elseif ( ! empty( $comment_data['comment_ID'] ) ) {
|
||||
$result = \get_comment( $comment_data['comment_ID'] );
|
||||
}
|
||||
@ -124,7 +129,7 @@ class Update {
|
||||
* Update an Actor.
|
||||
*
|
||||
* @param array $activity The Activity object.
|
||||
* @param int[]|null $user_ids The user IDs. Always null for Update activities.
|
||||
* @param int[]|null $user_ids Local recipient user IDs (followers and addressed local actors); may be empty.
|
||||
*/
|
||||
public static function update_actor( $activity, $user_ids ) {
|
||||
/*
|
||||
@ -148,10 +153,16 @@ class Update {
|
||||
}
|
||||
}
|
||||
|
||||
if ( \is_array( $actor ) && isset( $actor['id'] ) ) {
|
||||
/*
|
||||
* An actor may only update itself. Bind the updated object to the activity
|
||||
* actor (the same constraint the Delete handler enforces) so a remote server
|
||||
* cannot overwrite another host's cached actor by sending an Update whose
|
||||
* object.id points at a victim actor.
|
||||
*/
|
||||
if ( \is_array( $actor ) && isset( $actor['id'] ) && object_to_uri( $actor ) === object_to_uri( $activity['actor'] ) ) {
|
||||
$state = Remote_Actors::upsert( $actor );
|
||||
} else {
|
||||
$state = new \WP_Error( 'activitypub_update_failed', 'Update failed: missing or invalid actor object in Update activity' );
|
||||
$state = new \WP_Error( 'activitypub_update_failed', \__( 'Update failed: missing, invalid, or unauthorized actor object in Update activity.', 'activitypub' ) );
|
||||
$actor = array();
|
||||
}
|
||||
|
||||
|
||||
@ -93,7 +93,7 @@ class Arrive {
|
||||
$location_name = self::get_location_name( $location );
|
||||
|
||||
$title = $location_name
|
||||
? sprintf(
|
||||
? \sprintf(
|
||||
/* translators: %s: location name */
|
||||
\__( 'Checked in at %s', 'activitypub' ),
|
||||
$location_name
|
||||
|
||||
Reference in New Issue
Block a user