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

@ -7,21 +7,22 @@
namespace Activitypub\Collection;
use WP_Error;
use WP_User_Query;
use Activitypub\Model\User;
use Activitypub\Model\Blog;
use Activitypub\Activity\Actor;
use Activitypub\Model\Application;
use Activitypub\Model\Blog;
use Activitypub\Model\User;
use function Activitypub\object_to_uri;
use function Activitypub\normalize_url;
use function Activitypub\normalize_host;
use function Activitypub\url_to_authorid;
use function Activitypub\is_user_type_disabled;
use function Activitypub\normalize_host;
use function Activitypub\normalize_url;
use function Activitypub\object_to_uri;
use function Activitypub\url_to_authorid;
use function Activitypub\user_can_activitypub;
/**
* Actors collection.
*
* Provides methods to retrieve, create, update, and manage ActivityPub actors (users, blogs, applications, and remote actors).
*/
class Actors {
/**
@ -41,17 +42,34 @@ class Actors {
/**
* Get the Actor by ID.
*
* @param int $user_id The User-ID.
* @param int $user_id The user ID.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
* @return Actor|User|Blog|Application|\WP_Error Actor object or WP_Error if not found or not permitted.
*/
public static function get_by_id( $user_id ) {
if ( is_numeric( $user_id ) ) {
$user_id = (int) $user_id;
}
/**
* Filter the actor before resolving by ID.
*
* Allows third-party plugins to register custom virtual actors
* resolved by ID, mirroring the `activitypub_pre_get_by_username`
* filter for username lookups.
*
* @since 8.1.0
*
* @param null $pre The pre-existing value.
* @param int $user_id The user ID.
*/
$pre = \apply_filters( 'activitypub_pre_get_by_id', null, $user_id );
if ( null !== $pre ) {
return $pre;
}
if ( ! user_can_activitypub( $user_id ) ) {
return new WP_Error(
return new \WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
@ -71,9 +89,9 @@ class Actors {
/**
* Get the Actor by username.
*
* @param string $username Name of the Actor.
* @param string $username Name of the actor.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
* @return User|Blog|Application|\WP_Error Actor object or WP_Error if not found.
*/
public static function get_by_username( $username ) {
/**
@ -87,22 +105,45 @@ class Actors {
return $pre;
}
// Check for blog user.
if ( Blog::get_default_username() === $username ) {
return new Blog();
$id = self::get_id_by_username( $username );
if ( \is_wp_error( $id ) ) {
return $id;
}
if ( get_option( 'activitypub_blog_identifier' ) === $username ) {
return new Blog();
return self::get_by_id( $id );
}
/**
* Get the Actor by username.
*
* @param string $username Name of the actor.
*
* @return int|\WP_Error Actor id or WP_Error if not found.
*/
public static function get_id_by_username( $username ) {
// Check for blog user.
if (
Blog::get_default_username() === $username ||
\get_option( 'activitypub_blog_identifier' ) === $username
) {
if ( is_user_type_disabled( 'blog' ) ) {
return new \WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
return self::BLOG_USER_ID;
}
// Check for application user.
if ( 'application' === $username ) {
return new Application();
return self::APPLICATION_USER_ID;
}
// Check for 'activitypub_username' meta.
$user = new WP_User_Query(
$user = new \WP_User_Query(
array(
'count_total' => false,
'number' => 1,
@ -120,17 +161,14 @@ class Actors {
)
);
if ( $user->results ) {
$actor = self::get_by_id( $user->results[0] );
if ( ! \is_wp_error( $actor ) ) {
return $actor;
}
if ( $user->get_results() ) {
return \current( $user->get_results() );
}
$username = str_replace( array( '*', '%' ), '', $username );
// Check for login or nicename.
$user = new WP_User_Query(
$user = new \WP_User_Query(
array(
'count_total' => false,
'search' => $username,
@ -141,14 +179,11 @@ class Actors {
)
);
if ( $user->results ) {
$actor = self::get_by_id( $user->results[0] );
if ( ! \is_wp_error( $actor ) ) {
return $actor;
}
if ( $user->get_results() ) {
return \current( $user->get_results() );
}
return new WP_Error(
return new \WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
@ -156,17 +191,33 @@ class Actors {
}
/**
* Get the Actor by resource.
* Get the Actor by resource URI (acct, http(s), etc).
*
* @param string $uri The Actor resource.
* @param string $uri The actor resource URI.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
* @return User|Blog|Application|\WP_Error Actor object or WP_Error if not found.
*/
public static function get_by_resource( $uri ) {
$id = self::get_id_by_resource( $uri );
if ( \is_wp_error( $id ) ) {
return $id;
}
return self::get_by_id( $id );
}
/**
* Get the Actor by resource URI (acct, http(s), etc).
*
* @param string $uri The actor resource URI.
*
* @return int|\WP_Error Actor id or WP_Error if not found.
*/
public static function get_id_by_resource( $uri ) {
$uri = object_to_uri( $uri );
if ( ! $uri ) {
return new WP_Error(
return new \WP_Error(
'activitypub_no_uri',
\__( 'No URI provided', 'activitypub' ),
array( 'status' => 404 )
@ -188,6 +239,7 @@ class Actors {
// Check for http(s) URIs.
case 'http':
case 'https':
// Check for http(s)://blog.example.com/@username.
$resource_path = \wp_parse_url( $uri, PHP_URL_PATH );
if ( $resource_path ) {
@ -199,12 +251,11 @@ class Actors {
$resource_path = \trim( $resource_path, '/' );
// Check for http(s)://blog.example.com/@username.
if ( str_starts_with( $resource_path, '@' ) ) {
$identifier = \str_replace( '@', '', $resource_path );
$identifier = \trim( $identifier, '/' );
return self::get_by_username( $identifier );
return self::get_id_by_username( $identifier );
}
}
@ -212,7 +263,7 @@ class Actors {
$user_id = url_to_authorid( $uri );
if ( \is_int( $user_id ) ) {
return self::get_by_id( $user_id );
return $user_id;
}
// Check for http(s)://blog.example.com/.
@ -222,10 +273,10 @@ class Actors {
normalize_url( site_url() ) === $normalized_uri ||
normalize_url( home_url() ) === $normalized_uri
) {
return self::get_by_id( self::BLOG_USER_ID );
return self::BLOG_USER_ID;
}
return new WP_Error(
return new \WP_Error(
'activitypub_no_user_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
@ -238,7 +289,7 @@ class Actors {
$blog_host = normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) );
if ( $blog_host !== $host && get_option( 'activitypub_old_host' ) !== $host ) {
return new WP_Error(
return new \WP_Error(
'activitypub_wrong_host',
\__( 'Resource host does not match blog host', 'activitypub' ),
array( 'status' => 404 )
@ -247,12 +298,12 @@ class Actors {
// Prepare wildcards https://github.com/mastodon/mastodon/issues/22213.
if ( in_array( $identifier, array( '_', '*', '' ), true ) ) {
return self::get_by_id( self::BLOG_USER_ID );
return self::BLOG_USER_ID;
}
return self::get_by_username( $identifier );
return self::get_id_by_username( $identifier );
default:
return new WP_Error(
return new \WP_Error(
'activitypub_wrong_scheme',
\__( 'Wrong scheme', 'activitypub' ),
array( 'status' => 404 )
@ -261,17 +312,31 @@ class Actors {
}
/**
* Get the Actor by resource.
* Get the Actor by various identifier types (ID, URI, username, or email).
*
* @param string $id The Actor resource.
* @param string|int $id Actor identifier (user ID, URI, username, or email).
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
* @return User|Blog|Application|\WP_Error Actor object or WP_Error if not found.
*/
public static function get_by_various( $id ) {
$user = null;
$id = self::get_id_by_various( $id );
if ( \is_wp_error( $id ) ) {
return $id;
}
return self::get_by_id( $id );
}
/**
* Get the Actor by various identifier types (ID, URI, username, or email).
*
* @param string|int $id Actor identifier (user ID, URI, username, or email).
*
* @return int|\WP_Error Actor id or WP_Error if not found.
*/
public static function get_id_by_various( $id ) {
if ( is_numeric( $id ) ) {
$user = self::get_by_id( $id );
$id = (int) $id;
} elseif (
// Is URL.
filter_var( $id, FILTER_VALIDATE_URL ) ||
@ -280,18 +345,18 @@ class Actors {
// Is email.
filter_var( $id, FILTER_VALIDATE_EMAIL )
) {
$user = self::get_by_resource( $id );
$id = self::get_id_by_resource( $id );
} else {
$user = self::get_by_username( $id );
$id = self::get_id_by_username( $id );
}
return $user;
return $id;
}
/**
* Get the Actor collection.
* Get the collection of all local user actors.
*
* @return array The Actor collection.
* @return Actor[] Array of User actor objects.
*/
public static function get_collection() {
if ( is_user_type_disabled( 'user' ) ) {
@ -320,47 +385,55 @@ class Actors {
}
/**
* Get all active Actors including the Blog Actor.
* Get all active actors, including the Blog actor if enabled.
*
* @return array The actor collection.
* @return int[] Array of User and Blog actor IDs.
*/
public static function get_all() {
$return = array();
public static function get_all_ids() {
$user_ids = array();
if ( ! is_user_type_disabled( 'user' ) ) {
$users = \get_users(
$user_ids = \get_users(
array(
'fields' => 'ID',
'capability__in' => array( 'activitypub' ),
)
);
foreach ( $users as $user ) {
$actor = User::from_wp_user( $user->ID );
if ( \is_wp_error( $actor ) ) {
continue;
}
$return[] = $actor;
}
}
// Also include the blog actor if active.
if ( ! is_user_type_disabled( 'blog' ) ) {
$blog_actor = self::get_by_id( self::BLOG_USER_ID );
if ( ! \is_wp_error( $blog_actor ) ) {
$return[] = $blog_actor;
}
$user_ids[] = self::BLOG_USER_ID;
}
return $return;
return array_map( 'intval', $user_ids );
}
/**
* Get all active actors, including the Blog actor if enabled.
*
* @return Actor[] Array of User and Blog actor objects.
*/
public static function get_all() {
$user_ids = self::get_all_ids();
$actors = array_map( array( self::class, 'get_by_id' ), $user_ids );
// Filter out any WP_Error instances.
return array_filter(
$actors,
static function ( $actor ) {
return ! \is_wp_error( $actor );
}
);
}
/**
* Returns the actor type based on the user ID.
*
* @param int $user_id The user ID to check.
* @return string The user type.
*
* @return string Actor type: 'user', 'blog', or 'application'.
*/
public static function get_type_by_id( $user_id ) {
$user_id = (int) $user_id;
@ -375,4 +448,405 @@ class Actors {
return 'user';
}
/**
* Return the public key for a given actor.
*
* @param int $user_id The WordPress User ID.
* @param bool $force Optional. Force the generation of a new key pair. Default false.
*
* @return string The public key.
*/
public static function get_public_key( $user_id, $force = false ) {
if ( $force ) {
self::generate_key_pair( $user_id );
}
$key_pair = self::get_keypair( $user_id );
return $key_pair['public_key'];
}
/**
* Return the private key for a given actor.
*
* @param int $user_id The WordPress User ID.
* @param bool $force Optional. Force the generation of a new key pair. Default false.
*
* @return string The private key.
*/
public static function get_private_key( $user_id, $force = false ) {
if ( $force ) {
self::generate_key_pair( $user_id );
}
$key_pair = self::get_keypair( $user_id );
return $key_pair['private_key'];
}
/**
* Return the key pair for a given actor.
*
* @param int $user_id The WordPress User ID.
*
* @return array The key pair.
*/
public static function get_keypair( $user_id ) {
$option_key = self::get_signature_options_key( $user_id );
$key_pair = \get_option( $option_key );
if ( ! $key_pair ) {
$key_pair = self::generate_key_pair( $user_id );
}
return $key_pair;
}
/**
* Generates the pair of keys.
*
* @param int $user_id The WordPress User ID.
*
* @return array The key pair.
*/
protected static function generate_key_pair( $user_id ) {
$option_key = self::get_signature_options_key( $user_id );
$key_pair = self::check_legacy_key_pair( $user_id );
if ( $key_pair ) {
\add_option( $option_key, $key_pair );
return $key_pair;
}
$config = array(
'digest_alg' => 'sha512',
'private_key_bits' => 2048,
'private_key_type' => \OPENSSL_KEYTYPE_RSA,
);
$key = \openssl_pkey_new( $config );
$private_key = null;
$detail = array();
if ( $key ) {
\openssl_pkey_export( $key, $private_key );
$detail = \openssl_pkey_get_details( $key );
}
// Check if keys are valid.
if (
empty( $private_key ) || ! is_string( $private_key ) ||
! isset( $detail['key'] ) || ! is_string( $detail['key'] )
) {
return array(
'private_key' => null,
'public_key' => null,
);
}
$key_pair = array(
'private_key' => $private_key,
'public_key' => $detail['key'],
);
// Persist keys.
\add_option( $option_key, $key_pair );
return $key_pair;
}
/**
* Return the option key for a given user.
*
* @param int $user_id The WordPress User ID.
*
* @return string The option key.
*/
protected static function get_signature_options_key( $user_id ) {
if ( $user_id > 0 ) {
$user = \get_userdata( $user_id );
// Sanitize username because it could include spaces and special chars.
$user_id = \sanitize_title( $user->user_login );
}
return 'activitypub_keypair_for_' . $user_id;
}
/**
* Check if there is a legacy key pair
*
* @param int $user_id The WordPress User ID.
*
* @return array|bool The key pair or false.
*/
protected static function check_legacy_key_pair( $user_id ) {
switch ( $user_id ) {
case 0:
$public_key = \get_option( 'activitypub_blog_user_public_key' );
$private_key = \get_option( 'activitypub_blog_user_private_key' );
break;
case -1:
$public_key = \get_option( 'activitypub_application_user_public_key' );
$private_key = \get_option( 'activitypub_application_user_private_key' );
break;
default:
$public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true );
$private_key = \get_user_meta( $user_id, 'magic_sig_private_key', true );
break;
}
if ( ! empty( $public_key ) && is_string( $public_key ) && ! empty( $private_key ) && is_string( $private_key ) ) {
return array(
'private_key' => $private_key,
'public_key' => $public_key,
);
}
return false;
}
/**
* Returns all Inboxes for all known remote Actors.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::get_inboxes()}
*
* @return array The list of Inboxes.
*/
public static function get_inboxes() {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::get_inboxes' );
return Remote_Actors::get_inboxes();
}
/**
* Upsert (insert or update) a remote actor as a custom post type.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::upsert()}
*
* @param array|Actor $actor ActivityPub actor object (array or actor, must include 'id').
*
* @return int|\WP_Error Post ID on success, WP_Error on failure.
*/
public static function upsert( $actor ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::upsert' );
return Remote_Actors::upsert( $actor );
}
/**
* Create a remote actor as a custom post type.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::create()}
*
* @param array|Actor $actor ActivityPub actor object (array or Actor, must include 'id').
*
* @return int|\WP_Error Post ID on success, WP_Error on failure.
*/
public static function create( $actor ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::create' );
return Remote_Actors::create( $actor );
}
/**
* Update a remote Actor object by actor URL (guid).
*
* @deprecated 7.4.0 Use {@see Remote_Actors::update()}
*
* @param int|\WP_Post $post The post ID or object.
* @param array|Actor $actor The ActivityPub actor object as associative array (must include 'id').
*
* @return int|\WP_Error The post ID or WP_Error.
*/
public static function update( $post, $actor ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::update' );
return Remote_Actors::update( $post, $actor );
}
/**
* Delete a remote actor object by actor URL (guid).
*
* @deprecated 7.4.0 Use {@see Remote_Actors::delete()}
*
* @param int $post_id The post ID.
*
* @return bool True on success, false on failure.
*/
public static function delete( $post_id ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::delete' );
return Remote_Actors::delete( $post_id );
}
/**
* Get a remote actor post by actor URI (guid).
*
* @deprecated 7.4.0 Use {@see Remote_Actors::get_by_uri()}
*
* @param string $actor_uri The actor URI.
*
* @return \WP_Post|\WP_Error Post object or WP_Error if not found.
*/
public static function get_remote_by_uri( $actor_uri ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::get_by_uri' );
return Remote_Actors::get_by_uri( $actor_uri );
}
/**
* Lookup a remote actor post by actor URI (guid), fetching from remote if not found locally.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::fetch_by_uri()}
*
* @param string $actor_uri The actor URI.
*
* @return \WP_Post|\WP_Error Post object or WP_Error if not found.
*/
public static function fetch_remote_by_uri( $actor_uri ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::fetch_by_uri' );
return Remote_Actors::fetch_by_uri( $actor_uri );
}
/**
* Store an error that occurred when sending an ActivityPub message to a follower.
*
* The error will be stored in post meta.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::add_error()}
*
* @param int $post_id The ID of the WordPress Custom-Post-Type.
* @param string|\WP_Error $error The error message.
*
* @return int|false The meta ID on success, false on failure.
*/
public static function add_error( $post_id, $error ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::add_error' );
return Remote_Actors::add_error( $post_id, $error );
}
/**
* Count the errors for an actor.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::count_errors()}
*
* @param int $post_id The ID of the WordPress Custom-Post-Type.
*
* @return int The number of errors.
*/
public static function count_errors( $post_id ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::count_errors' );
return Remote_Actors::count_errors( $post_id );
}
/**
* Get all error messages for an actor.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::get_errors()}
*
* @param int $post_id The post ID.
*
* @return string[] Array of error messages.
*/
public static function get_errors( $post_id ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::get_errors' );
return Remote_Actors::get_errors( $post_id );
}
/**
* Clear all errors for an actor.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::clear_errors()}
*
* @param int $post_id The ID of the WordPress Custom-Post-Type.
*
* @return bool True on success, false on failure.
*/
public static function clear_errors( $post_id ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::clear_errors' );
return Remote_Actors::clear_errors( $post_id );
}
/**
* Get all remote actors (Custom Post Type) that had errors.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::get_faulty()}
*
* @param int $number Optional. Number of actors to return. Default 20.
*
* @return \WP_Post[] Array of faulty actor posts.
*/
public static function get_faulty( $number = 20 ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::get_faulty' );
return Remote_Actors::get_faulty( $number );
}
/**
* Get all remote actor posts not updated for a given time.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::get_outdated()}
*
* @param int $number Optional. Limits the result. Default 50.
* @param int $older_than Optional. The time in seconds. Default DAY_IN_SECONDS.
*
* @return \WP_Post[] The list of actors.
*/
public static function get_outdated( $number = 50, $older_than = DAY_IN_SECONDS ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::get_outdated' );
return Remote_Actors::get_outdated( $number, $older_than );
}
/**
* Convert a custom post type input to an Activitypub\Activity\Actor.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::get_actor()}
*
* @param int|\WP_Post $post The post ID or object.
*
* @return Actor|\WP_Error The actor object or WP_Error on failure.
*/
public static function get_actor( $post ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::get_actor' );
return Remote_Actors::get_actor( $post );
}
/**
* Get public key from key_id.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::get_public_key()}
*
* @param string $key_id The URL to the public key.
*
* @return resource|\WP_Error The public key resource or WP_Error.
*/
public static function get_remote_key( $key_id ) {
_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::get_public_key' );
return Remote_Actors::get_public_key( $key_id );
}
/**
* Normalize actor identifier to a URI.
*
* Handles webfinger addresses, URLs without schemes, objects, and arrays.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::normalize_identifier()}
*
* @param string|object|array $actor Actor URI, webfinger address, actor object, or array.
* @return string|null Normalized actor URI or null if unable to resolve.
*/
public static function normalize_identifier( $actor ) {
_deprecated_function( __METHOD__, '7.4.0', 'Remote_Actors::normalize_identifier' );
return Remote_Actors::normalize_identifier( $actor );
}
/**
* Determine if social graph (followers and following) should be shown for a given user.
*
* @param int $user_id The user ID.
*
* @return bool True if social graph should be shown, false otherwise.
*/
public static function show_social_graph( $user_id ) {
if ( self::BLOG_USER_ID === (int) $user_id ) {
return ! (bool) \get_option( 'activitypub_hide_social_graph' );
} else {
return ! (bool) \get_user_option( 'activitypub_hide_social_graph', $user_id );
}
}
}

View File

@ -0,0 +1,209 @@
<?php
/**
* Blocked Actors collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Moderation;
/**
* ActivityPub Blocked Actors Collection.
*/
class Blocked_Actors {
/**
* Add an actor block for a user.
*
* @param int $user_id The user ID.
* @param string $value The actor URI to block.
* @return bool True on success, false on failure.
*/
public static function add( $user_id, $value ) {
// Find or create actor post.
$actor_post = Remote_Actors::fetch_by_uri( $value );
if ( \is_wp_error( $actor_post ) ) {
return false;
}
$blocked = \get_post_meta( $actor_post->ID, Moderation::BLOCKED_ACTORS_META_KEY, false );
if ( ! \in_array( (string) $user_id, $blocked, true ) ) {
/**
* Fired when an actor is blocked.
*
* @param string $value The blocked actor URI.
* @param string $type The block type (actor, domain, keyword).
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_add_user_block', $value, Moderation::TYPE_ACTOR, $user_id );
$result = (bool) \add_post_meta( $actor_post->ID, Moderation::BLOCKED_ACTORS_META_KEY, (string) $user_id );
\clean_post_cache( $actor_post->ID );
return $result;
}
return true; // Already blocked.
}
/**
* Remove an actor block for a user.
*
* @param int $user_id The user ID.
* @param string|int $value The actor URI or post ID to unblock.
* @return bool True on success, false on failure.
*/
public static function remove( $user_id, $value ) {
// Handle both post ID and URI formats.
if ( \is_numeric( $value ) ) {
$post_id = (int) $value;
} else {
// Otherwise, find the actor post by actor ID.
$actor_post = Remote_Actors::fetch_by_uri( $value );
if ( \is_wp_error( $actor_post ) ) {
return false;
}
$post_id = $actor_post->ID;
}
/**
* Fired when an actor is unblocked.
*
* @param string $value The unblocked actor URI.
* @param string $type The block type (actor, domain, keyword).
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_remove_user_block', $value, Moderation::TYPE_ACTOR, $user_id );
$result = \delete_post_meta( $post_id, Moderation::BLOCKED_ACTORS_META_KEY, $user_id );
\clean_post_cache( $post_id );
return $result;
}
/**
* Query blocked actors of a given user, with pagination info.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about the blocked actors.
*
* @type \WP_Post[] $blocked_actors List of blocked Actor WP_Post objects.
* @type int $total Total number of blocked actors.
* }
*/
public static function query( $user_id, $number = -1, $page = null, $args = array() ) {
$defaults = array(
'post_type' => Remote_Actors::POST_TYPE,
'posts_per_page' => $number,
'paged' => $page,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => Moderation::BLOCKED_ACTORS_META_KEY,
'value' => $user_id,
),
),
);
$args = \wp_parse_args( $args, $defaults );
$query = new \WP_Query( $args );
$total = $query->found_posts;
$blocked_actors = \array_filter( $query->posts );
return \compact( 'blocked_actors', 'total' );
}
/**
* Get many blocked actors.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return \WP_Post[] List of blocked Actors.
*/
public static function get_many( $user_id, $number = -1, $page = null, $args = array() ) {
return self::query( $user_id, $number, $page, $args )['blocked_actors'];
}
/**
* Add an actor block for a user.
*
* @deprecated 7.6.0 Use {@see Blocked_Actors::add()}.
*
* @param int $user_id The user ID.
* @param string $value The actor URI to block.
* @return bool True on success, false on failure.
*/
public static function add_block( $user_id, $value ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Blocked_Actors::add' );
return self::add( $user_id, $value );
}
/**
* Remove an actor block for a user.
*
* @deprecated 7.6.0 Use {@see Blocked_Actors::remove()}.
*
* @param int $user_id The user ID.
* @param string|int $value The actor URI or post ID to unblock.
* @return bool True on success, false on failure.
*/
public static function remove_block( $user_id, $value ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Blocked_Actors::remove' );
return self::remove( $user_id, $value );
}
/**
* Get the blocked actors of a given user, along with a total count for pagination purposes.
*
* @deprecated 7.6.0 Use {@see Blocked_Actors::query()}.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about the blocked actors.
*
* @type \WP_Post[] $blocked_actors List of blocked Actor WP_Post objects.
* @type int $total Total number of blocked actors.
* }
*/
public static function get_blocked_actors_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Blocked_Actors::query' );
return self::query( $user_id, $number, $page, $args );
}
/**
* Get the blocked actors of a given user.
*
* @deprecated 7.6.0 Use {@see Blocked_Actors::get_many()}.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return \WP_Post[] List of blocked Actors.
*/
public static function get_blocked_actors( $user_id, $number = -1, $page = null, $args = array() ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Blocked_Actors::get_many' );
return self::get_many( $user_id, $number, $page, $args );
}
}

View File

@ -8,6 +8,7 @@
namespace Activitypub\Collection;
use Activitypub\Link;
use Activitypub\Sanitize;
use function Activitypub\site_supports_blocks;
@ -39,6 +40,17 @@ class Extra_Fields {
$args['author'] = $user_id;
}
// Limit to 20 fields to prevent response size issues.
if ( ! is_admin() ) {
/**
* Filters the number of extra fields to retrieve for an ActivityPub actor.
*
* @param int $limit The number of extra fields to retrieve. Default 20.
*/
$args['posts_per_page'] = apply_filters( 'activitypub_actor_extra_fields_limit', 20 );
$args['nopaging'] = false;
}
$query = new \WP_Query( $args );
$fields = $query->posts ?? array();
@ -70,10 +82,9 @@ class Extra_Fields {
$content = \wptexturize( $content );
$content = \wp_filter_content_tags( $content );
// Replace script and style elements.
$content = \preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $content );
$content = \strip_shortcodes( $content );
$content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) );
$content = Sanitize::clean_html( $content );
$content = Sanitize::strip_whitespace( $content );
/**
* Filters the content of an extra field.
@ -96,15 +107,12 @@ class Extra_Fields {
\add_filter( 'activitypub_link_rel', array( self::class, 'add_rel_me' ) );
foreach ( $fields as $post ) {
$title = \html_entity_decode( \get_the_title( $post ), \ENT_QUOTES, 'UTF-8' );
$content = self::get_formatted_content( $post );
$attachments[] = array(
'type' => 'PropertyValue',
'name' => \get_the_title( $post ),
'value' => \html_entity_decode(
$content,
\ENT_QUOTES,
'UTF-8'
),
'name' => $title,
'value' => \html_entity_decode( $content, \ENT_QUOTES, 'UTF-8' ),
);
$attachment = false;
@ -123,7 +131,7 @@ class Extra_Fields {
if ( 'A' === $tags->get_tag() ) {
$attachment = array(
'type' => 'Link',
'name' => \get_the_title( $post ),
'name' => $title,
'href' => \esc_url( $tags->get_attribute( 'href' ) ),
);
@ -138,12 +146,8 @@ class Extra_Fields {
if ( ! $attachment ) {
$attachment = array(
'type' => 'Note',
'name' => \get_the_title( $post ),
'content' => \html_entity_decode(
$content,
\ENT_QUOTES,
'UTF-8'
),
'name' => $title,
'content' => \html_entity_decode( $content, \ENT_QUOTES, 'UTF-8' ),
);
}
@ -213,7 +217,7 @@ class Extra_Fields {
\add_filter(
'activitypub_link_rel',
function ( $rel ) {
static function ( $rel ) {
$rel .= ' me';
return $rel;

View File

@ -7,12 +7,11 @@
namespace Activitypub\Collection;
use Activitypub\Model\Follower;
use WP_Error;
use WP_Query;
use Activitypub\Signature;
use Activitypub\Tombstone;
use function Activitypub\is_tombstone;
use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub Followers Collection.
@ -21,172 +20,273 @@ use function Activitypub\get_remote_metadata_by_actor;
* @author Matthias Pfefferle
*/
class Followers {
const POST_TYPE = 'ap_follower';
/**
* Cache key for the followers inbox.
*
* @var string
*/
const CACHE_KEY_INBOXES = 'follower_inboxes_%s';
/**
* Meta key for the followers user ID.
*
* @var string
*/
const FOLLOWER_META_KEY = '_activitypub_following';
/**
* Add new Follower.
*
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return Follower|WP_Error The Follower (WP_Post array) or an WP_Error.
* @return int|\WP_Error The Follower ID or an WP_Error.
*/
public static function add_follower( $user_id, $actor ) {
public static function add( $user_id, $actor ) {
$meta = get_remote_metadata_by_actor( $actor );
if ( is_tombstone( $meta ) ) {
if ( Tombstone::exists( $meta ) ) {
return $meta;
}
if ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) {
return new WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) );
if ( empty( $meta ) || ! \is_array( $meta ) || \is_wp_error( $meta ) ) {
return new \WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) );
}
$follower = new Follower();
$follower->from_array( $meta );
$id = $follower->upsert();
if ( is_wp_error( $id ) ) {
return $id;
$post_id = Remote_Actors::upsert( $meta );
if ( \is_wp_error( $post_id ) ) {
return $post_id;
}
$post_meta = get_post_meta( $id, '_activitypub_user_id', false );
// phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
if ( is_array( $post_meta ) && ! in_array( $user_id, $post_meta ) ) {
add_post_meta( $id, '_activitypub_user_id', $user_id );
wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
$post_meta = \get_post_meta( $post_id, self::FOLLOWER_META_KEY, false );
if ( \is_array( $post_meta ) && ! \in_array( (string) $user_id, $post_meta, true ) ) {
\add_post_meta( $post_id, self::FOLLOWER_META_KEY, $user_id );
\wp_cache_delete( \sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
\wp_cache_delete( Remote_Actors::CACHE_KEY_INBOXES, 'activitypub' );
}
return $follower;
return $post_id;
}
/**
* Add new Follower.
*
* @deprecated 7.6.0 Use {@see Followers::add()}.
*
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return int|\WP_Error The Follower ID or an WP_Error.
*/
public static function add_follower( $user_id, $actor ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Followers::add' );
return self::add( $user_id, $actor );
}
/**
* Remove a Follower.
*
* @param \WP_Post|int $post_or_id The ID of the remote Actor.
* @param int $user_id The ID of the WordPress User.
*
* @return bool True on success, false on failure.
*/
public static function remove( $post_or_id, $user_id ) {
$post = \get_post( $post_or_id );
if ( ! $post ) {
return false;
}
\wp_cache_delete( \sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
\wp_cache_delete( Remote_Actors::CACHE_KEY_INBOXES, 'activitypub' );
/**
* Fires before a Follower is removed.
*
* @param \WP_Post $post The remote Actor object.
* @param int $user_id The ID of the WordPress User.
* @param \Activitypub\Activity\Actor $actor The remote Actor object.
*/
\do_action( 'activitypub_followers_pre_remove_follower', $post, $user_id, Remote_Actors::get_actor( $post ) );
return \delete_post_meta( $post->ID, self::FOLLOWER_META_KEY, $user_id );
}
/**
* Remove a Follower.
*
* @deprecated 7.1.0 Use {@see Followers::remove()}.
*
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return bool True on success, false on failure.
*/
public static function remove_follower( $user_id, $actor ) {
wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
\_deprecated_function( __METHOD__, '7.1.0', 'Activitypub\Collection\Followers::remove' );
$follower = self::get_follower( $user_id, $actor );
$remote_actor = self::get_by_uri( $user_id, $actor );
if ( ! $follower ) {
if ( \is_wp_error( $remote_actor ) ) {
return false;
}
/**
* Fires before a Follower is removed.
*
* @param Follower $follower The Follower object.
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*/
do_action( 'activitypub_followers_pre_remove_follower', $follower, $user_id, $actor );
return self::remove( $remote_actor->ID, $user_id );
}
return delete_post_meta( $follower->get__id(), '_activitypub_user_id', $user_id );
/**
* Get a Follower by URI.
*
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return \WP_Post|\WP_Error The Follower object or WP_Error on failure.
*/
public static function get_by_uri( $user_id, $actor ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$id = $wpdb->get_var(
$wpdb->prepare(
"SELECT DISTINCT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key = %s AND pm.meta_value = %d AND p.guid = %s",
array(
\esc_sql( Remote_Actors::POST_TYPE ),
\esc_sql( self::FOLLOWER_META_KEY ),
\esc_sql( $user_id ),
\esc_sql( $actor ),
)
)
);
if ( ! $id ) {
return new \WP_Error(
'activitypub_follower_not_found',
\__( 'Follower not found', 'activitypub' ),
array( 'status' => 404 )
);
}
return \get_post( $id );
}
/**
* Get a Follower.
*
* @deprecated 7.6.0 Use {@see Followers::get_by_uri()}
*
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return Follower|false|null The Follower object or null
* @return \WP_Post|\WP_Error The Follower object or WP_Error on failure.
*/
public static function get_follower( $user_id, $actor ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT DISTINCT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key = '_activitypub_user_id' AND pm.meta_value = %d AND p.guid = %s",
array(
esc_sql( self::POST_TYPE ),
esc_sql( $user_id ),
esc_sql( $actor ),
)
)
);
if ( $post_id ) {
$post = get_post( $post_id );
return Follower::init_from_cpt( $post );
}
return null;
_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Followers::get_by_uri' );
return self::get_by_uri( $user_id, $actor );
}
/**
* Get a Follower by Actor independent of the User.
*
* @deprecated 7.4.0 Use {@see Remote_Actors::get_by_uri()}.
*
* @param string $actor The Actor URL.
*
* @return Follower|false|null The Follower object or false on failure.
* @return \WP_Post|\WP_Error The Follower object or WP_Error on failure.
*/
public static function get_follower_by_actor( $actor ) {
global $wpdb;
\_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::get_by_uri' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE guid=%s",
esc_sql( $actor )
)
);
return Remote_Actors::get_by_uri( $actor );
}
if ( $post_id ) {
$post = get_post( $post_id );
return Follower::init_from_cpt( $post );
}
/**
* Get many followers.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return \WP_Post[] List of `Follower` objects.
*/
public static function get_many( $user_id, $number = -1, $page = null, $args = array() ) {
$data = self::query( $user_id, $number, $page, $args );
return null;
return $data['followers'];
}
/**
* Get the Followers of a given user.
*
* @param int $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
* @return Follower[] List of `Follower` objects.
* @deprecated 7.6.0 Use {@see Followers::get_many()}
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return \WP_Post[] List of `Follower` objects.
*/
public static function get_followers( $user_id, $number = -1, $page = null, $args = array() ) {
$data = self::get_followers_with_count( $user_id, $number, $page, $args );
return $data['followers'];
_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Followers::get_many' );
return self::get_many( $user_id, $number, $page, $args );
}
/**
* Get the Followers of a given user, along with a total count for pagination purposes.
*
* @param int $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
* @deprecated 7.6.0 Use {@see Followers::query()}.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about the followers.
*
* @type Follower[] $followers List of `Follower` objects.
* @type \WP_Post[] $followers List of `Follower` objects.
* @type int $total Total number of followers.
* }
*/
public static function get_followers_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Followers::query' );
return self::query( $user_id, $number, $page, $args );
}
/**
* Query followers with pagination info.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about the followers.
*
* @type \WP_Post[] $followers List of `Follower` objects.
* @type int $total Total number of followers.
* }
*/
public static function query( $user_id, $number = -1, $page = null, $args = array() ) {
$defaults = array(
'post_type' => self::POST_TYPE,
'post_type' => Remote_Actors::POST_TYPE,
'posts_per_page' => $number,
'paged' => $page,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'OR',
array(
'key' => self::FOLLOWER_META_KEY,
'value' => $user_id,
),
// for backwards compatibility.
array(
'key' => '_activitypub_user_id',
'value' => $user_id,
@ -194,71 +294,66 @@ class Followers {
),
);
$args = wp_parse_args( $args, $defaults );
$query = new WP_Query( $args );
$args = \wp_parse_args( $args, $defaults );
$query = new \WP_Query( $args );
$total = $query->found_posts;
$followers = array_map( array( Follower::class, 'init_from_cpt' ), $query->get_posts() );
$followers = array_filter( $followers );
$followers = \array_filter( $query->posts );
return compact( 'followers', 'total' );
return \compact( 'followers', 'total' );
}
/**
* Get all Followers.
* Count the total number of followers.
*
* @return Follower[] The Term list of Followers.
* @param int $user_id The ID of the WordPress User.
*
* @return int The number of Followers
*/
public static function get_all_followers() {
$args = array(
'nopaging' => true,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_activitypub_inbox',
'compare' => 'EXISTS',
),
array(
'key' => '_activitypub_actor_json',
'compare' => 'EXISTS',
),
),
);
return self::get_followers( null, null, null, $args );
public static function count( $user_id ) {
return self::query( $user_id, 1 )['total'];
}
/**
* Count the total number of followers
* Count followers gained in a date range.
*
* @param int $user_id The ID of the WordPress User.
* @param string $start Start date (Y-m-d H:i:s).
* @param string $end End date (Y-m-d H:i:s).
*
* @return int The number of new followers in the date range.
*/
public static function count_in_range( $user_id, $start, $end ) {
$result = self::query(
$user_id,
1, // We only need the count.
null,
array(
'date_query' => array(
array(
'after' => $start,
'before' => $end,
'inclusive' => true,
),
),
)
);
return $result['total'];
}
/**
* Count the total number of followers.
*
* @deprecated 7.6.0 Use {@see Followers::count()}.
*
* @param int $user_id The ID of the WordPress User.
*
* @return int The number of Followers
*/
public static function count_followers( $user_id ) {
$query = new WP_Query(
array(
'post_type' => self::POST_TYPE,
'fields' => 'ids',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_activitypub_user_id',
'value' => $user_id,
),
array(
'key' => '_activitypub_inbox',
'compare' => 'EXISTS',
),
array(
'key' => '_activitypub_actor_json',
'compare' => 'EXISTS',
),
),
)
);
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Followers::count' );
return $query->found_posts;
return self::count( $user_id );
}
/**
@ -269,18 +364,18 @@ class Followers {
* @return array The list of Inboxes.
*/
public static function get_inboxes( $user_id ) {
$cache_key = sprintf( self::CACHE_KEY_INBOXES, $user_id );
$inboxes = wp_cache_get( $cache_key, 'activitypub' );
$cache_key = \sprintf( self::CACHE_KEY_INBOXES, $user_id );
$inboxes = \wp_cache_get( $cache_key, 'activitypub' );
if ( $inboxes ) {
return $inboxes;
}
// Get all Followers of an ID of the WordPress User.
$posts = new WP_Query(
$posts = new \WP_Query(
array(
'nopaging' => true,
'post_type' => self::POST_TYPE,
'post_type' => Remote_Actors::POST_TYPE,
'fields' => 'ids',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
@ -290,7 +385,7 @@ class Followers {
'compare' => 'EXISTS',
),
array(
'key' => '_activitypub_user_id',
'key' => self::FOLLOWER_META_KEY,
'value' => $user_id,
),
array(
@ -302,9 +397,7 @@ class Followers {
)
);
$posts = $posts->get_posts();
if ( ! $posts ) {
if ( ! $posts->posts ) {
return array();
}
@ -313,15 +406,15 @@ class Followers {
$results = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT meta_value FROM {$wpdb->postmeta}
WHERE post_id IN (" . implode( ', ', array_fill( 0, count( $posts ), '%d' ) ) . ")
WHERE post_id IN (" . \implode( ', ', \array_fill( 0, \absint( $posts->post_count ), '%d' ) ) . ")
AND meta_key = '_activitypub_inbox'
AND meta_value IS NOT NULL",
$posts
$posts->posts
)
);
$inboxes = array_filter( $results );
wp_cache_set( $cache_key, $inboxes, 'activitypub' );
$inboxes = \array_filter( $results );
\wp_cache_set( $cache_key, $inboxes, 'activitypub' );
return $inboxes;
}
@ -337,27 +430,30 @@ class Followers {
* @return array The list of Inboxes.
*/
public static function get_inboxes_for_activity( $json, $actor_id, $batch_size = 50, $offset = 0 ) {
$inboxes = self::get_inboxes( $actor_id );
if ( self::maybe_add_inboxes_of_blog_user( $json, $actor_id ) ) {
$inboxes = array_fill_keys( $inboxes, 1 );
foreach ( self::get_inboxes( Actors::BLOG_USER_ID ) as $inbox ) {
$inboxes[ $inbox ] = 1;
}
$inboxes = array_keys( $inboxes );
$activity = \json_decode( $json, true );
// Only if this is a Delete. Create handles its own "Announce" in dual user mode.
if ( 'Delete' === ( $activity['type'] ?? null ) ) {
$inboxes = Remote_Actors::get_inboxes();
} else {
$inboxes = self::get_inboxes( $actor_id );
}
return array_slice( $inboxes, $offset, $batch_size );
return \array_slice( $inboxes, $offset, $batch_size );
}
/**
* Maybe add Inboxes of the Blog User.
*
* @deprecated 7.3.0
*
* @param string $json The ActivityPub Activity JSON.
* @param int $actor_id The WordPress Actor ID.
*
* @return bool True if the Inboxes of the Blog User should be added, false otherwise.
*/
public static function maybe_add_inboxes_of_blog_user( $json, $actor_id ) {
\_deprecated_function( __METHOD__, '7.3.0' );
// Only if we're in both Blog and User modes.
if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE !== \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
return false;
@ -367,87 +463,68 @@ class Followers {
return false;
}
$activity = json_decode( $json, true );
$activity = \json_decode( $json, true );
// Only if this is an Update or Delete. Create handles its own "Announce" in dual user mode.
if ( ! in_array( $activity['type'] ?? null, array( 'Update', 'Delete' ), true ) ) {
if ( ! \in_array( $activity['type'] ?? null, array( 'Update', 'Delete' ), true ) ) {
return false;
}
return true;
}
/**
* Get all Followers.
*
* @deprecated 7.1.0 Use {@see Actors::get_all()}.
*
* @return \WP_Post[] The list of Followers.
*/
public static function get_all_followers() {
_deprecated_function( __METHOD__, '7.1.0', 'Activitypub\Collection\Actors::get_all' );
$args = array(
'nopaging' => true,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_activitypub_inbox',
'compare' => 'EXISTS',
),
),
);
return self::get_many( null, null, null, $args );
}
/**
* Get all Followers that have not been updated for a given time.
*
* @deprecated 7.0.0 Use {@see Remote_Actors::get_outdated()}.
*
* @param int $number Optional. Limits the result. Default 50.
* @param int $older_than Optional. The time in seconds. Default 86400 (1 day).
*
* @return Follower[] The Term list of Followers.
* @return \WP_Post[] The list of Actors.
*/
public static function get_outdated_followers( $number = 50, $older_than = 86400 ) {
$args = array(
'post_type' => self::POST_TYPE,
'posts_per_page' => $number,
'orderby' => 'modified',
'order' => 'ASC',
'post_status' => 'any', // 'any' includes 'trash'.
'date_query' => array(
array(
'column' => 'post_modified_gmt',
'before' => gmdate( 'Y-m-d', \time() - $older_than ),
),
),
);
_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Remote_Actors::get_outdated' );
$posts = new WP_Query( $args );
$items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() );
return array_filter( $items );
return Remote_Actors::get_outdated( $number, $older_than );
}
/**
* Get all Followers that had errors.
*
* @deprecated 7.0.0 Use {@see Remote_Actors::get_faulty()}.
*
* @param int $number Optional. The number of Followers to return. Default 20.
*
* @return Follower[] The Term list of Followers.
* @return \WP_Post[] The list of Actors.
*/
public static function get_faulty_followers( $number = 20 ) {
$args = array(
'post_type' => self::POST_TYPE,
'posts_per_page' => $number,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'OR',
array(
'key' => '_activitypub_errors',
'compare' => 'EXISTS',
),
array(
'key' => '_activitypub_inbox',
'compare' => 'NOT EXISTS',
),
array(
'key' => '_activitypub_actor_json',
'compare' => 'NOT EXISTS',
),
array(
'key' => '_activitypub_inbox',
'value' => '',
'compare' => '=',
),
array(
'key' => '_activitypub_actor_json',
'value' => '',
'compare' => '=',
),
),
);
_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Remote_Actors::get_faulty' );
$posts = new WP_Query( $args );
$items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() );
return array_filter( $items );
return Remote_Actors::get_faulty( $number );
}
/**
@ -456,27 +533,172 @@ class Followers {
*
* The error will be stored in post meta.
*
* @deprecated 7.0.0 Use {@see Remote_Actors::add_error()}.
*
* @param int $post_id The ID of the WordPress Custom-Post-Type.
* @param mixed $error The error message. Can be a string or a WP_Error.
*
* @return int|false The meta ID on success, false on failure.
*/
public static function add_error( $post_id, $error ) {
if ( is_string( $error ) ) {
$error_message = $error;
} elseif ( is_wp_error( $error ) ) {
$error_message = $error->get_error_message();
} else {
$error_message = __(
'Unknown Error or misconfigured Error-Message',
'activitypub'
);
\_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Remote_Actors::add_error' );
return Remote_Actors::add_error( $post_id, $error );
}
/**
* Clear the errors for a Follower.
*
* @deprecated 7.0.0 Use {@see Remote_Actors::clear_errors()}.
*
* @param int $post_id The ID of the WordPress Custom-Post-Type.
*
* @return bool True on success, false on failure.
*/
public static function clear_errors( $post_id ) {
\_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Remote_Actors::clear_errors' );
return Remote_Actors::clear_errors( $post_id );
}
/**
* Check the status of a given following.
*
* @param int $post_id The ID of the Post.
* @param int $user_id The ID of the WordPress User.
*
* @return bool The status of the following.
*/
public static function follows( $post_id, $user_id ) {
$all_meta = \get_post_meta( $post_id );
$following = $all_meta[ self::FOLLOWER_META_KEY ] ?? array();
return \in_array( (string) $user_id, $following, true );
}
/**
* Remove blocked actors from follower lists.
*
* Called via activitypub_add_user_block hook.
*
* @param string $value The blocked actor URI.
* @param string $type The block type (actor, domain, keyword).
* @param int $user_id The user ID.
*/
public static function remove_blocked_actors( $value, $type, $user_id ) {
if ( 'actor' !== $type ) {
return;
}
return add_post_meta(
$post_id,
'_activitypub_errors',
$error_message
$actor_id = Actors::get_id_by_various( $value );
if ( \is_wp_error( $actor_id ) ) {
return;
}
self::remove( $actor_id, $user_id );
}
/**
* Compute the partial follower collection digest for a specific instance.
*
* Implements FEP-8fcf: Followers collection synchronization.
* This is a convenience wrapper that filters followers by authority and then
* computes the digest using the standard FEP-8fcf algorithm.
*
* The digest is created by XORing together the individual SHA256 digests
* of each follower's ID.
*
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
* @see Signature::get_collection_digest() for the core digest algorithm
*
* @param int $user_id The user ID whose followers to compute.
* @param string $authority The URI authority (scheme + host) to filter by.
*
* @return string|false The hex-encoded digest, or false if no followers.
*/
public static function compute_partial_digest( $user_id, $authority ) {
// Get followers filtered by authority.
$followers = self::get_by_authority( $user_id, $authority );
$follower_ids = \wp_list_pluck( $followers, 'guid' );
// Delegate to the core digest computation algorithm.
return Signature::get_collection_digest( $follower_ids );
}
/**
* Get partial followers collection for a specific instance.
*
* Returns only followers whose ID shares the specified URI authority.
* Used for FEP-8fcf synchronization.
*
* @param int $user_id The user ID whose followers to get.
* @param string $authority The URI authority (scheme + host) to filter by.
*
* @return \WP_Post[] Array of WP_Post objects.
*/
public static function get_by_authority( $user_id, $authority ) {
$posts = new \WP_Query(
array(
'post_type' => Remote_Actors::POST_TYPE,
'posts_per_page' => -1,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'AND',
array(
'key' => self::FOLLOWER_META_KEY,
'value' => $user_id,
),
array(
'key' => '_activitypub_inbox',
'compare' => 'LIKE',
'value' => $authority,
),
),
)
);
return $posts->posts ?? array();
}
/**
* Generate the Collection-Synchronization header value for FEP-8fcf.
*
* @param int $user_id The user ID whose followers collection to sync.
* @param string $authority The authority of the receiving instance.
*
* @return string|false The header value, or false if cannot generate.
*/
public static function generate_sync_header( $user_id, $authority ) {
$followers = self::get_by_authority( $user_id, $authority );
$followers = \wp_list_pluck( $followers, 'guid' );
// Compute the digest for this specific authority.
$digest = Signature::get_collection_digest( $followers );
if ( ! $digest ) {
return false;
}
// Build the collection ID (followers collection URL).
$collection_id = get_rest_url_by_path( sprintf( 'actors/%d/followers', $user_id ) );
// Build the partial followers URL.
$url = get_rest_url_by_path(
sprintf(
'actors/%d/followers/sync?authority=%s',
$user_id,
rawurlencode( $authority )
)
);
// Format as per FEP-8fcf (similar to HTTP Signatures format).
return sprintf(
'collectionId="%s", url="%s", digest="%s"',
$collection_id,
$url,
$digest
);
}
}

View File

@ -0,0 +1,639 @@
<?php
/**
* Following collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Activity\Activity;
use function Activitypub\add_to_outbox;
/**
* ActivityPub Following Collection.
*/
class Following {
/**
* Meta key for the following user ID.
*
* @var string
*/
const FOLLOWING_META_KEY = '_activitypub_followed_by';
/**
* Meta key for pending following user ID.
*
* @var string
*/
const PENDING_META_KEY = '_activitypub_followed_by_pending';
/**
* Pending Status.
*
* @var string
*/
const PENDING = 'pending';
/**
* Accepted Status.
*
* @var string
*/
const ACCEPTED = 'accepted';
/**
* All Status.
*
* @var string
*/
const ALL = 'all';
/**
* Follow a user.
*
* Please do not use this method directly, use `\Activitypub\follow` instead.
*
* @see \Activitypub\follow
*
* @param \WP_Post|int $post The ID of the remote Actor.
* @param int $user_id The ID of the WordPress User.
*
* @return int|\WP_Error The Outbox ID on success or a WP_Error on failure.
*/
public static function follow( $post, $user_id ) {
$post = \get_post( $post );
if ( ! $post ) {
return new \WP_Error( 'activitypub_remote_actor_not_found', 'Remote actor not found' );
}
$all_meta = get_post_meta( $post->ID );
$following = $all_meta[ self::FOLLOWING_META_KEY ] ?? array();
$pending = $all_meta[ self::PENDING_META_KEY ] ?? array();
if ( \in_array( (string) $user_id, $following, true ) || \in_array( (string) $user_id, $pending, true ) ) {
$post_id_query = new \WP_Query(
array(
'post_type' => Outbox::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => 1,
'no_found_rows' => true,
'author' => \max( $user_id, 0 ),
'fields' => 'ids',
'order' => 'DESC',
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => '_activitypub_object_id',
'value' => $post->guid,
),
array(
'key' => '_activitypub_activity_type',
'value' => 'Follow',
),
),
)
);
if ( $post_id_query->posts ) {
return $post_id_query->posts[0];
}
return new \WP_Error( 'activitypub_already_following', 'User is already following this actor but outbox activity not found.' );
}
$actor = Actors::get_by_id( $user_id );
if ( \is_wp_error( $actor ) ) {
return $actor;
}
\add_post_meta( $post->ID, self::PENDING_META_KEY, (string) $user_id );
$follow = new Activity();
$follow->set_type( 'Follow' );
$follow->set_actor( $actor->get_id() );
$follow->set_object( $post->guid );
$follow->set_to( array( $post->guid ) );
$result = add_to_outbox( $follow, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
if ( ! $result ) {
return new \WP_Error( 'activitypub_follow_failed', 'Failed to add follow activity to outbox.' );
}
return $result;
}
/**
* Accept a follow request.
*
* @param \WP_Post|int $post The ID of the remote Actor.
* @param int $user_id The ID of the WordPress User.
*
* @return \WP_Post|\WP_Error The ID of the Actor or a WP_Error.
*/
public static function accept( $post, $user_id ) {
$post = \get_post( $post );
if ( ! $post ) {
return new \WP_Error( 'activitypub_remote_actor_not_found', 'Remote actor not found' );
}
$following = \get_post_meta( $post->ID, self::PENDING_META_KEY, false );
if ( ! \is_array( $following ) || ! \in_array( (string) $user_id, $following, true ) ) {
return new \WP_Error( 'activitypub_following_not_found', 'Follow request not found' );
}
\add_post_meta( $post->ID, self::FOLLOWING_META_KEY, $user_id );
\delete_post_meta( $post->ID, self::PENDING_META_KEY, $user_id );
return $post;
}
/**
* Reject a follow request.
*
* @param \WP_Post|int $post The ID of the remote Actor.
* @param int $user_id The ID of the WordPress User.
*
* @return \WP_Post|\WP_Error The ID of the Actor or a WP_Error.
*/
public static function reject( $post, $user_id ) {
$post = \get_post( $post );
if ( ! $post ) {
return new \WP_Error( 'activitypub_remote_actor_not_found', 'Remote actor not found' );
}
\delete_post_meta( $post->ID, self::PENDING_META_KEY, $user_id );
\delete_post_meta( $post->ID, self::FOLLOWING_META_KEY, $user_id );
return $post;
}
/**
* Remove a follow request.
*
* Please do not use this method directly, use `\Activitypub\unfollow` instead.
*
* @see \Activitypub\unfollow
*
* @param \WP_Post|int $post The ID of the remote Actor.
* @param int $user_id The ID of the WordPress User.
*
* @return int|\WP_Error The ID of the Undo outbox item, 0 if no matching Follow outbox was found, or WP_Error on failure.
*/
public static function unfollow( $post, $user_id ) {
$post = \get_post( $post );
if ( ! $post ) {
return new \WP_Error( 'activitypub_remote_actor_not_found', __( 'Remote actor not found', 'activitypub' ) );
}
$actor_type = Actors::get_type_by_id( $user_id );
\delete_post_meta( $post->ID, self::FOLLOWING_META_KEY, $user_id );
\delete_post_meta( $post->ID, self::PENDING_META_KEY, $user_id );
/*
* Get Post-ID of the Follow Outbox Activity. Include `pending` so an
* Undo posted before the remote Accept arrives can still find the Follow.
*/
$post_id_query = new \WP_Query(
array(
'post_type' => Outbox::POST_TYPE,
'post_status' => array( 'publish', 'pending' ),
'nopaging' => true,
'posts_per_page' => 1,
'author' => \max( $user_id, 0 ),
'fields' => 'ids',
'number' => 1,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_activitypub_object_id',
'value' => $post->guid,
),
array(
'key' => '_activitypub_activity_type',
'value' => 'Follow',
),
array(
'key' => '_activitypub_activity_actor',
'value' => $actor_type,
),
),
)
);
if ( ! $post_id_query->posts ) {
return 0;
}
$undo_id = Outbox::undo( $post_id_query->posts[0] );
if ( \is_wp_error( $undo_id ) ) {
return $undo_id;
}
if ( ! $undo_id ) {
return new \WP_Error(
'activitypub_outbox_undo_failed',
\__( 'Failed to create Undo activity.', 'activitypub' ),
array( 'status' => 500 )
);
}
return (int) $undo_id;
}
/**
* Query followings of a given user, with pagination info.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about the followings.
*
* @type \WP_Post[] $following List of `Following` objects.
* @type int $total Total number of followings.
* }
*/
public static function query( $user_id, $number = -1, $page = null, $args = array() ) {
$defaults = array(
'post_type' => Remote_Actors::POST_TYPE,
'posts_per_page' => $number,
'paged' => $page,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => self::FOLLOWING_META_KEY,
'value' => $user_id,
),
),
);
$args = \wp_parse_args( $args, $defaults );
$query = new \WP_Query( $args );
$total = $query->found_posts;
$following = \array_filter( $query->posts );
return \compact( 'following', 'total' );
}
/**
* Get many followings of a given user.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return \WP_Post[] List of `Following` objects.
*/
public static function get_many( $user_id, $number = -1, $page = null, $args = array() ) {
$data = self::query( $user_id, $number, $page, $args );
return $data['following'];
}
/**
* Query pending followings of a given user, with pagination info.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about the pending followings.
*
* @type \WP_Post[] $following List of `Following` objects.
* @type int $total Total number of pending followings.
* }
*/
public static function query_pending( $user_id, $number = -1, $page = null, $args = array() ) {
$defaults = array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => self::PENDING_META_KEY,
'value' => $user_id,
),
),
);
$args = \wp_parse_args( $args, $defaults );
return self::query( $user_id, $number, $page, $args );
}
/**
* Get the pending followings of a given user.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return \WP_Post[] List of `Following` objects.
*/
public static function get_pending( $user_id, $number = -1, $page = null, $args = array() ) {
return self::query_pending( $user_id, $number, $page, $args )['following'];
}
/**
* Get the total number of pending followings of a given user.
*
* @param int|null $user_id The ID of the WordPress User.
*
* @return int The total number of pending followings.
*/
public static function count_pending( $user_id ) {
return self::query_pending( $user_id, 1 )['total'];
}
/**
* Query all followings of a given user (both accepted and pending), with pagination info.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about all followings.
*
* @type \WP_Post[] $following List of `Following` objects.
* @type int $total Total number of all followings.
* }
*/
public static function query_all( $user_id, $number = -1, $page = null, $args = array() ) {
$defaults = array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'OR',
array(
'key' => self::FOLLOWING_META_KEY,
'value' => $user_id,
),
array(
'key' => self::PENDING_META_KEY,
'value' => $user_id,
),
),
);
$args = \wp_parse_args( $args, $defaults );
return self::query( $user_id, $number, $page, $args );
}
/**
* Get partial followers collection for a specific instance.
*
* Returns only followers whose ID shares the specified URI authority.
* Used for FEP-8fcf synchronization.
*
* @param int $user_id The user ID whose followers to get.
* @param string $authority The URI authority (scheme + host) to filter by.
* @param string $state The following state to filter by (accepted or pending). Default is accepted.
*
* @return array Array of follower URLs.
*/
public static function get_by_authority( $user_id, $authority, $state = self::FOLLOWING_META_KEY ) {
$posts = new \WP_Query(
array(
'post_type' => Remote_Actors::POST_TYPE,
'posts_per_page' => -1,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'AND',
array(
'key' => $state,
'value' => $user_id,
),
array(
'key' => '_activitypub_inbox',
'compare' => 'LIKE',
'value' => $authority,
),
),
)
);
return $posts->posts ?? array();
}
/**
* Get all followings of a given user.
*
* @param int|null $user_id The ID of the WordPress User.
*
* @return \WP_Post[] List of `Following` objects.
*/
public static function get_all( $user_id ) {
return self::query_all( $user_id, -1 )['following'];
}
/**
* Get the total number of all followings of a given user.
*
* @param int|null $user_id The ID of the WordPress User.
*
* @return int The total number of all followings.
*/
public static function count_all( $user_id ) {
return self::query_all( $user_id, 1 )['total'];
}
/**
* Count the total number of followings.
*
* @param int $user_id The ID of the WordPress User.
*
* @return int The number of Followings
*/
public static function count( $user_id ) {
return self::query( $user_id, 1 )['total'];
}
/**
* Get the total number of followings of a given user by status.
*
* @param int|null $user_id The ID of the WordPress User.
*
* @return array Total number of followings and pending followings.
*/
public static function count_by_status( $user_id ) {
return array(
self::ALL => self::count_all( $user_id ),
self::ACCEPTED => self::count( $user_id ),
self::PENDING => self::count_pending( $user_id ),
);
}
/**
* Check the status of a given following.
*
* @param int $user_id The ID of the WordPress User.
* @param int $post_id The ID of the Post.
*
* @return string|false The status of the following.
*/
public static function check_status( $user_id, $post_id ) {
$all_meta = get_post_meta( $post_id );
$following = $all_meta[ self::FOLLOWING_META_KEY ] ?? array();
$pending = $all_meta[ self::PENDING_META_KEY ] ?? array();
if ( \in_array( (string) $user_id, $following, true ) ) {
return self::ACCEPTED;
}
if ( \in_array( (string) $user_id, $pending, true ) ) {
return self::PENDING;
}
return false;
}
/**
* Get local user IDs following a given remote actor.
*
* @param string $actor_url The actor URL.
*
* @return int[] List of local user IDs following the actor.
*/
public static function get_follower_ids( $actor_url ) {
$actor = Remote_Actors::get_by_uri( $actor_url );
if ( \is_wp_error( $actor ) ) {
return array();
}
$user_ids = \get_post_meta( $actor->ID, self::FOLLOWING_META_KEY, false );
if ( ! is_array( $user_ids ) || empty( $user_ids ) ) {
return array();
}
return array_map( 'intval', $user_ids );
}
/**
* Remove blocked actors from following list.
*
* @see \Activitypub\Activitypub::init()
*
* @param string $value The blocked actor URI or domain/keyword.
* @param string $type The block type (actor, domain, keyword).
* @param int $user_id The user ID.
*/
public static function remove_blocked_actors( $value, $type, $user_id ) {
if ( 'actor' !== $type ) {
return;
}
$actor_id = Actors::get_id_by_various( $value );
if ( \is_wp_error( $actor_id ) ) {
return;
}
self::unfollow( $actor_id, $user_id );
}
/**
* Get the Followings of a given user, along with a total count for pagination purposes.
*
* @deprecated 7.6.0 Use {@see Following::query()}.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about the followings.
*
* @type \WP_Post[] $following List of `Following` objects.
* @type int $total Total number of followings.
* }
*/
public static function get_following_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Following::query' );
return self::query( $user_id, $number, $page, $args );
}
/**
* Get pending followings of a given user, along with a total count for pagination purposes.
*
* @deprecated 7.6.0 Use {@see Following::query_pending()}.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about the pending followings.
*
* @type \WP_Post[] $following List of `Following` objects.
* @type int $total Total number of pending followings.
* }
*/
public static function get_pending_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Following::query_pending' );
return self::query_pending( $user_id, $number, $page, $args );
}
/**
* Get all followings of a given user (both accepted and pending), along with a total count for pagination purposes.
*
* @deprecated 7.6.0 Use {@see Following::query_all()}.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about all followings.
*
* @type \WP_Post[] $following List of `Following` objects.
* @type int $total Total number of all followings.
* }
*/
public static function get_all_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Following::query_all' );
return self::query_all( $user_id, $number, $page, $args );
}
/**
* Get the Followings of a given user.
*
* @deprecated 7.6.0 Use {@see Following::get_many()}.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return \WP_Post[] List of `Following` objects.
*/
public static function get_following( $user_id, $number = -1, $page = null, $args = array() ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Following::get_many' );
return self::get_many( $user_id, $number, $page, $args );
}
}

View File

@ -0,0 +1,556 @@
<?php
/**
* Inbox collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use Activitypub\Comment;
use function Activitypub\is_activity_public;
use function Activitypub\object_to_uri;
/**
* ActivityPub Inbox Collection
*
* @link https://www.w3.org/TR/activitypub/#inbox
*/
class Inbox {
/**
* The post type for the objects.
*
* @var string
*/
const POST_TYPE = 'ap_inbox';
/**
* Maximum number of inbox items to keep.
*
* @var int
*/
const MAX_ITEMS = 5000;
/**
* Number of items to process per batch during purge.
*
* @var int
*/
const PURGE_BATCH_SIZE = 100;
/**
* Maximum seconds a purge run may take before yielding.
*
* @var int
*/
const PURGE_TIMEOUT = 30;
/**
* Context for user inbox requests.
*
* @var string
*/
const CONTEXT_INBOX = 'inbox';
/**
* Context for shared inbox requests.
*
* @var string
*/
const CONTEXT_SHARED_INBOX = 'shared_inbox';
/**
* Add an activity to the inbox.
*
* @param Activity|\WP_Error $activity The Activity object.
* @param int|array $recipients The id(s) of the local blog-user(s).
*
* @return false|int|\WP_Error The added item or an error.
*/
public static function add( $activity, $recipients ) {
if ( \is_wp_error( $activity ) ) {
return $activity;
}
// Sanitize recipients.
$recipients = \array_map( 'absint', (array) $recipients );
$recipients = \array_unique( $recipients );
$recipients = \array_values( $recipients );
if ( empty( $recipients ) ) {
return new \WP_Error(
'activitypub_inbox_no_recipients',
'No valid recipients provided',
array( 'status' => 400 )
);
}
// Check if activity already exists (by GUID).
$existing = self::get_by_guid( $activity->get_id() );
// If activity exists, add new recipients to it.
if ( $existing instanceof \WP_Post ) {
foreach ( $recipients as $user_id ) {
self::add_recipient( $existing->ID, $user_id );
}
return $existing->ID;
}
// Activity doesn't exist, create new post.
$title = self::get_object_title( $activity->get_object() );
$visibility = is_activity_public( $activity ) ? ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC : ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
/*
* For QuoteRequest activities, we store the instrument URL as the object_id.
* This allows efficient querying by instrument (the quote post URL).
* For all other activities, we store the object URL as before.
*/
if ( 'QuoteRequest' === $activity->get_type() && $activity->get_instrument() ) {
$object_id = object_to_uri( $activity->get_instrument() ?? '' );
} else {
$object_id = object_to_uri( $activity->get_object() ?? '' );
}
$inbox_item = array(
'post_type' => self::POST_TYPE,
'post_title' => sprintf(
/* translators: 1. Activity type, 2. Object Title or Excerpt */
\__( '[%1$s] %2$s', 'activitypub' ),
$activity->get_type(),
\wp_trim_words( $title, 5 )
),
// Persist the blind audience so we keep the full addressing the sender used.
'post_content' => wp_slash( $activity->to_json( true, true ) ),
'post_author' => 0, // No specific author, recipients stored in meta.
'post_status' => 'publish',
'guid' => $activity->get_id(),
'meta_input' => array(
'_activitypub_object_id' => $object_id,
'_activitypub_activity_type' => $activity->get_type(),
'_activitypub_activity_remote_actor' => object_to_uri( $activity->get_actor() ),
'activitypub_content_visibility' => $visibility,
),
);
$has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
if ( $has_kses ) {
// Prevent KSES from corrupting JSON in post_content.
\kses_remove_filters();
}
$id = \wp_insert_post( $inbox_item, true );
if ( $has_kses ) {
\kses_init_filters();
}
// Add recipients as separate meta entries after post is created.
if ( ! \is_wp_error( $id ) ) {
foreach ( $recipients as $user_id ) {
self::add_recipient( $id, $user_id );
}
}
return $id;
}
/**
* Get the title of an activity recursively.
*
* @param Activity|Base_Object|array $activity_object The activity object.
*
* @return string The title.
*/
private static function get_object_title( $activity_object ) {
if ( ! $activity_object || is_array( $activity_object ) ) {
return '';
}
if ( \is_string( $activity_object ) ) {
$post_id = \url_to_postid( $activity_object );
return $post_id ? \get_the_title( $post_id ) : '';
}
$title = $activity_object->get_name() ?: $activity_object->get_content();
if ( ! $title && $activity_object->get_object() instanceof Base_Object ) {
$title = $activity_object->get_object()->get_name() ?: $activity_object->get_object()->get_content();
}
return $title;
}
/**
* Get the inbox item by id.
*
* @param int $id The inbox item id.
*
* @return \WP_Post|null The inbox item or null.
*/
public static function get( $id ) {
return \get_post( $id );
}
/**
* Get an inbox item by its GUID.
*
* @param string $guid The GUID of the inbox item.
*
* @return \WP_Post|\WP_Error The inbox item or WP_Error.
*/
public static function get_by_guid( $guid ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
\esc_url( $guid ),
self::POST_TYPE
)
);
if ( ! $post_id ) {
return new \WP_Error(
'activitypub_inbox_item_not_found',
\__( 'Inbox item not found', 'activitypub' ),
array( 'status' => 404 )
);
}
return \get_post( $post_id );
}
/**
* Undo a received activity.
*
* @param string $id The ID of the inbox item to be removed.
*
* @return bool|\WP_Error True on success, WP_Error on failure.
*/
public static function undo( $id ) {
$inbox_item = self::get_by_guid( $id );
if ( \is_wp_error( $inbox_item ) ) {
// If inbox entry not found, return the error.
return $inbox_item;
}
$type = \get_post_meta( $inbox_item->ID, '_activitypub_activity_type', true );
switch ( $type ) {
case 'Follow':
$actor = \get_post_meta( $inbox_item->ID, '_activitypub_activity_remote_actor', true );
$remote_actor = Remote_Actors::get_by_uri( $actor );
if ( \is_wp_error( $remote_actor ) ) {
return $remote_actor;
}
// A follow is only possible for a specific user.
$user_id = \get_post_meta( $inbox_item->ID, '_activitypub_user_id', true );
return Followers::remove( $remote_actor, $user_id );
case 'Like':
case 'Create':
case 'Announce':
if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
return new \WP_Error(
'activitypub_inbox_undo_interactions_disabled',
\__( 'Undo is not possible because incoming interactions are disabled.', 'activitypub' ),
array( 'status' => 403 )
);
}
$result = Comment::object_id_to_comment( esc_url_raw( $inbox_item->guid ) );
if ( empty( $result ) ) {
return new \WP_Error(
'activitypub_inbox_undo_comment_not_found',
\__( 'Undo is not possible because the comment was not found.', 'activitypub' ),
array( 'status' => 404 )
);
}
return \wp_delete_comment( $result, true );
default:
return new \WP_Error(
'activitypub_inbox_undo_unsupported',
// Translators: %s is the activity type.
\sprintf( \__( 'Undo is not supported for %s activities.', 'activitypub' ), $type ),
array( 'status' => 400 )
);
}
}
/**
* Get all recipients for an inbox activity.
*
* @param int $post_id The inbox post ID.
*
* @return array Array of user IDs who are recipients.
*/
public static function get_recipients( $post_id ) {
// Get all meta values with key '_activitypub_user_id' (single => false).
$recipients = \get_post_meta( $post_id, '_activitypub_user_id', false );
$recipients = \array_map( 'intval', $recipients );
return $recipients;
}
/**
* Check if a user is a recipient of an inbox activity.
*
* @param int $post_id The inbox post ID.
* @param int $user_id The user ID to check.
*
* @return bool True if user is a recipient, false otherwise.
*/
public static function has_recipient( $post_id, $user_id ) {
$recipients = self::get_recipients( $post_id );
return \in_array( (int) $user_id, $recipients, true );
}
/**
* Add a recipient to an existing inbox activity.
*
* @param int $post_id The inbox post ID.
* @param int $user_id The user ID to add.
*
* @return bool True on success, false on failure.
*/
public static function add_recipient( $post_id, $user_id ) {
$user_id = (int) $user_id;
// Allow 0 for blog user, but reject negative values.
if ( $user_id < 0 ) {
return false;
}
// Check if already a recipient.
if ( self::has_recipient( $post_id, $user_id ) ) {
return true;
}
// Add new recipient as separate meta entry.
return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
}
/**
* Remove a recipient from an inbox activity.
*
* @param int $post_id The inbox post ID.
* @param int $user_id The user ID to remove.
*
* @return bool True on success, false on failure.
*/
public static function remove_recipient( $post_id, $user_id ) {
$user_id = (int) $user_id;
// Allow 0 for blog user, but reject negative values.
if ( $user_id < 0 ) {
return false;
}
// Delete the specific meta entry with this value.
return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id );
}
/**
* Add multiple recipients to an existing inbox activity.
*
* @param int $post_id The inbox post ID.
* @param int[] $user_ids The user ID or array of user IDs to add.
*/
public static function add_recipients( $post_id, $user_ids ) {
foreach ( $user_ids as $user_id ) {
self::add_recipient( $post_id, $user_id );
}
}
/**
* Get an inbox item by GUID for a specific recipient.
*
* This checks both that the activity exists and that the user is a valid recipient.
*
* @param string $guid The activity GUID.
* @param int $user_id The user ID.
*
* @return \WP_Post|\WP_Error The inbox item or WP_Error.
*/
public static function get_by_guid_and_recipient( $guid, $user_id ) {
$post = self::get_by_guid( $guid );
if ( \is_wp_error( $post ) ) {
return $post;
}
// Check if user is a recipient.
if ( ! self::has_recipient( $post->ID, $user_id ) ) {
return new \WP_Error(
'activitypub_inbox_not_recipient',
'User is not a recipient of this activity',
array( 'status' => 404 )
);
}
return $post;
}
/**
* Get an inbox item by activity type and object ID.
*
* This is useful for finding specific activity types (like QuoteRequest)
* by their object identifier. For QuoteRequest activities, the object_id
* is the instrument URL (the quote post).
*
* @param string $activity_type The activity type (e.g., 'QuoteRequest').
* @param string $object_id The object identifier to search for.
*
* @return \WP_Post|\WP_Error The inbox item or WP_Error if not found.
*/
public static function get_by_type_and_object( $activity_type, $object_id ) {
$posts = \get_posts(
array(
'post_type' => self::POST_TYPE,
'posts_per_page' => 1,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Necessary for querying by activity type and object ID.
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_activitypub_activity_type',
'value' => $activity_type,
),
array(
'key' => '_activitypub_object_id',
'value' => $object_id,
),
),
)
);
if ( empty( $posts ) ) {
return new \WP_Error(
'activitypub_inbox_item_not_found',
\__( 'Inbox item not found', 'activitypub' ),
array( 'status' => 404 )
);
}
return $posts[0];
}
/**
* Deduplicate inbox items with the same GUID.
*
* If multiple inbox items exist with the same GUID (due to race conditions),
* this merges all recipients into the first post and deletes duplicates.
*
* @param string $guid The activity GUID.
*
* @return \WP_Post|false The primary inbox post, or false if no posts found.
*/
public static function deduplicate( $guid ) {
global $wpdb;
// Query for all posts with this GUID directly (get_posts doesn't supports guid parameter).
$post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE guid=%s AND post_type=%s ORDER BY ID ASC",
\esc_url( $guid ),
self::POST_TYPE
)
);
if ( empty( $post_ids ) ) {
return false;
}
// Keep the first (oldest) post as primary.
$primary_id = array_shift( $post_ids );
$primary = \get_post( $primary_id );
// Merge recipients from duplicates into primary and delete duplicates.
foreach ( $post_ids as $duplicate_id ) {
$recipients = \get_post_meta( $duplicate_id, '_activitypub_user_id', false );
self::add_recipients( $primary_id, $recipients );
\wp_delete_post( $duplicate_id, true );
}
return $primary;
}
/**
* Purge old inbox items.
*
* Deletes inbox items older than the specified number of days.
*
* @param int $days Number of days to keep items. Items older than this will be deleted.
*
* @return int The number of items deleted.
*/
public static function purge( $days ) {
if ( $days <= 0 ) {
return 0;
}
$counts = \wp_count_posts( self::POST_TYPE );
$total = 0;
foreach ( $counts as $count ) {
$total += (int) $count;
}
if ( $total <= 200 ) {
return 0;
}
$deleted = 0;
$cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) );
$start_time = \time();
// If total exceeds the hard cap, drop the date filter to purge oldest items first.
$overflow = $total > self::MAX_ITEMS;
$date_query = array(
array(
'before' => $cutoff,
),
);
$query_args = array(
'post_type' => self::POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'numberposts' => self::PURGE_BATCH_SIZE,
'orderby' => 'date',
'order' => 'ASC',
);
if ( ! $overflow ) {
$query_args['date_query'] = $date_query;
}
do {
$post_ids = \get_posts( $query_args );
foreach ( $post_ids as $post_id ) {
\wp_delete_post( $post_id, true );
++$deleted;
}
// Once we're back under the cap, re-apply the date filter.
if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
$overflow = false;
$query_args['date_query'] = $date_query;
}
} while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
return $deleted;
}
}

View File

@ -7,15 +7,17 @@
namespace Activitypub\Collection;
use Activitypub\Comment;
use Activitypub\Emoji;
use Activitypub\Webfinger;
use WP_Comment_Query;
use Activitypub\Comment;
use function Activitypub\object_to_uri;
use function Activitypub\is_post_disabled;
use function Activitypub\url_to_commentid;
use function Activitypub\object_id_to_comment;
use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\is_ap_post;
use function Activitypub\is_post_disabled;
use function Activitypub\object_id_to_comment;
use function Activitypub\object_to_uri;
use function Activitypub\url_to_commentid;
/**
* ActivityPub Interactions Collection.
@ -27,36 +29,73 @@ class Interactions {
/**
* Add a comment to a post.
*
* @param array $activity The activity-object.
* When $user_id is provided, comment author data is built from the
* local WordPress user instead of fetching remote actor metadata.
*
* @param array $activity The activity-object.
* @param int|null $user_id Optional. Local user ID for outbox replies.
*
* @return int|false|\WP_Error The comment ID or false or WP_Error on failure.
*/
public static function add_comment( $activity ) {
$commentdata = self::activity_to_comment( $activity );
public static function add_comment( $activity, $user_id = null ) {
$comment_data = self::activity_to_comment( $activity, $user_id );
if ( ! $commentdata || ! isset( $activity['object']['inReplyTo'] ) ) {
if ( ! $comment_data ) {
return false;
}
$in_reply_to = object_to_uri( $activity['object']['inReplyTo'] );
$in_reply_to = \esc_url_raw( $in_reply_to );
$comment_post_id = \url_to_postid( $in_reply_to );
$parent_comment_id = url_to_commentid( $in_reply_to );
// Determine target URL from reply or quote.
$parent_comment_id = 0;
// Save only replies and reactions.
if ( ! empty( $activity['object']['inReplyTo'] ) ) {
// Regular reply.
$target_url = object_to_uri( $activity['object']['inReplyTo'] );
$parent_comment_id = url_to_commentid( $target_url );
} else {
// Check for quote.
$target_url = self::get_quote_url( $activity );
if ( ! $target_url ) {
return false;
}
// Mark as quote and clean content.
$comment_data['comment_type'] = 'quote';
if ( ! empty( $activity['object']['content'] ) ) {
$pattern = '/<p[^>]*class=["\']quote-inline["\'][^>]*>.*?<\/p>/is';
$cleaned_content = \preg_replace( $pattern, '', $activity['object']['content'], 1 );
$comment_data['comment_content'] = \wp_kses_post( $cleaned_content );
}
}
// Get post ID from target URL.
$target_url = \esc_url_raw( $target_url );
$comment_post_id = \url_to_postid( $target_url );
if ( ! $comment_post_id ) {
// Check for `ap_post`.
$comment_post = Remote_Posts::get_by_guid( $target_url );
if ( $comment_post instanceof \WP_Post ) {
$comment_post_id = $comment_post->ID;
}
}
// Handle nested replies (replies to comments).
if ( ! $comment_post_id && $parent_comment_id ) {
$parent_comment = get_comment( $parent_comment_id );
$parent_comment = \get_comment( $parent_comment_id );
$comment_post_id = $parent_comment->comment_post_ID;
}
if ( is_post_disabled( $comment_post_id ) ) {
if ( ! $comment_post_id ) {
// Not a reply to a post or comment.
return false;
}
$commentdata['comment_post_ID'] = $comment_post_id;
$commentdata['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0;
$comment_data['comment_post_ID'] = $comment_post_id;
$comment_data['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0;
return self::persist( $commentdata, self::INSERT );
return self::persist( $comment_data );
}
/**
@ -69,19 +108,29 @@ class Interactions {
public static function update_comment( $activity ) {
$meta = get_remote_metadata_by_actor( $activity['actor'] );
// Determine comment_ID.
$comment = object_id_to_comment( \esc_url_raw( $activity['object']['id'] ) );
$commentdata = \get_comment( $comment, ARRAY_A );
if ( \is_wp_error( $meta ) || ! \is_array( $meta ) ) {
return $meta;
}
if ( ! $commentdata ) {
// Determine comment_ID.
$comment = object_id_to_comment( \esc_url_raw( $activity['object']['id'] ) );
$comment_data = \get_comment( $comment, ARRAY_A );
if ( ! $comment_data ) {
return false;
}
// Found a local comment id.
$commentdata['comment_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] );
$commentdata['comment_content'] = \addslashes( $activity['object']['content'] );
$comment_data['comment_author'] = \sanitize_text_field( empty( $meta['name'] ) ? $meta['preferredUsername'] : $meta['name'] );
return self::persist( $commentdata, self::UPDATE );
/*
* Wrap emoji in content with blocks for runtime replacement.
* Note: Remote images in comments are stripped for security (only emoji allowed).
*/
$content = Emoji::wrap_in_content( $activity['object']['content'], $activity['object'] );
$comment_data['comment_content'] = \addslashes( $content );
return self::persist( $comment_data, self::UPDATE );
}
/**
@ -89,54 +138,59 @@ class Interactions {
*
* @param array $activity Activity array.
*
* @return array|false Comment data or `false` on failure.
* @return array|string|int|\WP_Error|false Comment data or `false` on failure.
*/
public static function add_reaction( $activity ) {
$commentdata = self::activity_to_comment( $activity );
if ( ! $commentdata ) {
return false;
}
$url = object_to_uri( $activity['object'] );
$comment_post_id = \url_to_postid( $url );
$parent_comment_id = url_to_commentid( $url );
if ( ! $comment_post_id ) {
// Check for `ap_post`.
$comment_post = Remote_Posts::get_by_guid( $url );
if ( $comment_post instanceof \WP_Post ) {
$comment_post_id = $comment_post->ID;
}
}
if ( ! $comment_post_id && $parent_comment_id ) {
$parent_comment = \get_comment( $parent_comment_id );
$comment_post_id = $parent_comment->comment_post_ID;
}
if ( ! $comment_post_id || is_post_disabled( $comment_post_id ) ) {
if ( ! $comment_post_id ) {
// Not a reply to a post or comment.
return false;
}
$comment_type = Comment::get_comment_type_by_activity_type( $activity['type'] );
if ( ! $comment_type ) {
// Not a valid comment type.
return false;
}
$comment_content = $comment_type['excerpt'];
$comment_data = self::activity_to_comment( $activity );
if ( ! $comment_data ) {
return false;
}
$commentdata['comment_post_ID'] = $comment_post_id;
$commentdata['comment_content'] = \esc_html( $comment_content );
$commentdata['comment_type'] = \esc_attr( $comment_type['type'] );
$commentdata['comment_meta']['source_id'] = \esc_url_raw( $activity['id'] );
$comment_data['comment_post_ID'] = $comment_post_id;
$comment_data['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0;
$comment_data['comment_content'] = \esc_html( $comment_type['excerpt'] );
$comment_data['comment_type'] = \esc_attr( $comment_type['type'] );
$comment_data['comment_meta']['source_id'] = \esc_url_raw( $activity['id'] );
return self::persist( $commentdata, self::INSERT );
return self::persist( $comment_data );
}
/**
* Get interaction(s) for a given URL/ID.
* Get interaction(s) by ID.
*
* @param string $url The URL/ID to get interactions for.
*
* @return array The interactions as WP_Comment objects.
*/
public static function get_interaction_by_id( $url ) {
public static function get_by_id( $url ) {
$args = array(
'nopaging' => true,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
@ -165,13 +219,28 @@ class Interactions {
}
/**
* Get interaction(s) for a given actor.
* Get interaction(s) for a given URL/ID.
*
* @deprecated 7.6.0 Use {@see Interactions::get_by_id()}.
*
* @param string $url The URL/ID to get interactions for.
*
* @return array The interactions as WP_Comment objects.
*/
public static function get_interaction_by_id( $url ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Interactions::get_by_id' );
return self::get_by_id( $url );
}
/**
* Get interaction(s) by actor.
*
* @param string $actor The Actor-URL.
*
* @return array The interactions as WP_Comment objects.
*/
public static function get_interactions_by_actor( $actor ) {
public static function get_by_actor( $actor ) {
$meta = get_remote_metadata_by_actor( $actor );
// Get URL, because $actor seems to be the ID.
@ -191,7 +260,52 @@ class Interactions {
),
);
return get_comments( $args );
return \get_comments( $args );
}
/**
* Get interaction(s) by remote actor ID.
*
* This is an optimized query that uses the remote actor post ID directly
* instead of querying by author_url.
*
* @param int $remote_actor_id The remote actor post ID.
*
* @return array The interactions as WP_Comment objects.
*/
public static function get_by_remote_actor_id( $remote_actor_id ) {
$args = array(
'nopaging' => true,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'protocol',
'value' => 'activitypub',
),
array(
'key' => '_activitypub_remote_actor_id',
'value' => $remote_actor_id,
),
),
);
return \get_comments( $args );
}
/**
* Get interaction(s) for a given actor.
*
* @deprecated 7.6.0 Use {@see Interactions::get_by_actor()}.
*
* @param string $actor The Actor-URL.
*
* @return array The interactions as WP_Comment objects.
*/
public static function get_interactions_by_actor( $actor ) {
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Interactions::get_by_actor' );
return self::get_by_actor( $actor );
}
/**
@ -217,84 +331,134 @@ class Interactions {
$allowed_tags['p'] = array();
}
// Add `img` for custom emoji support with strict validation.
$emoji_html = Emoji::get_kses_allowed_html();
if ( ! array_key_exists( 'img', $allowed_tags ) ) {
$allowed_tags['img'] = $emoji_html['img'];
}
return $allowed_tags;
}
/**
* Convert an Activity to a WP_Comment
* Convert an Activity to a WP_Comment.
*
* @param array $activity The Activity array.
* When $user_id is provided, comment author data is built from the
* local WordPress user instead of fetching remote actor metadata.
*
* @param array $activity The Activity array.
* @param int|null $user_id Optional. Local user ID for outbox comments.
*
* @return array|false The comment data or false on failure.
*/
public static function activity_to_comment( $activity ) {
public static function activity_to_comment( $activity, $user_id = null ) {
$comment_content = null;
$actor = object_to_uri( $activity['actor'] ?? null );
$actor = get_remote_metadata_by_actor( $actor );
// Check Actor-Meta.
if ( ! $actor || is_wp_error( $actor ) ) {
return false;
}
if ( $user_id ) {
// Outbox: resolve author from the local WordPress user.
$user = \get_userdata( $user_id );
// Check Actor-Name.
if ( isset( $actor['name'] ) ) {
$comment_author = $actor['name'];
} elseif ( isset( $actor['preferredUsername'] ) ) {
$comment_author = $actor['preferredUsername'];
if ( ! $user ) {
return false;
}
$comment_author = $user->display_name;
$comment_author_url = $user->user_url;
$comment_author_email = $user->user_email;
$comment_content = \wp_kses_post( $activity['object']['content'] ?? '' );
} else {
return false;
// S2S: resolve author from remote actor metadata.
$actor = object_to_uri( $activity['actor'] ?? null );
$actor = get_remote_metadata_by_actor( $actor );
if ( ! $actor || is_wp_error( $actor ) ) {
return false;
}
$comment_author = null;
if ( ! empty( $actor['name'] ) ) {
$comment_author = $actor['name'];
} elseif ( ! empty( $actor['preferredUsername'] ) ) {
$comment_author = $actor['preferredUsername'];
}
if ( empty( $comment_author ) && \get_option( 'require_name_email' ) ) {
return false;
}
$comment_author = $comment_author ?? __( 'Anonymous', 'activitypub' );
$comment_author_url = \esc_url_raw( object_to_uri( $actor['url'] ?? $actor['id'] ) );
$webfinger = Webfinger::uri_to_acct( $comment_author_url );
if ( is_wp_error( $webfinger ) ) {
$comment_author_email = '';
} else {
$comment_author_email = str_replace( 'acct:', '', $webfinger );
}
if ( isset( $activity['object']['content'] ) ) {
/*
* Wrap emoji in content with blocks for runtime replacement.
* Note: Remote images in comments are stripped for security (only emoji allowed).
*/
$content = Emoji::wrap_in_content( $activity['object']['content'], $activity['object'] );
$comment_content = \addslashes( $content );
}
}
$url = object_to_uri( $actor['url'] ?? $actor['id'] );
$published = $activity['object']['published'] ?? $activity['published'] ?? 'now';
$gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $published ) );
if ( ! $url ) {
$url = object_to_uri( $actor['id'] );
}
if ( isset( $activity['object']['content'] ) ) {
$comment_content = \addslashes( $activity['object']['content'] );
}
$webfinger = Webfinger::uri_to_acct( $url );
if ( is_wp_error( $webfinger ) ) {
$webfinger = '';
} else {
$webfinger = str_replace( 'acct:', '', $webfinger );
}
$commentdata = array(
'comment_author' => \esc_attr( $comment_author ),
'comment_author_url' => \esc_url_raw( $url ),
$comment_data = array(
'comment_author' => $comment_author,
'comment_author_url' => $comment_author_url,
'comment_content' => $comment_content,
'comment_type' => 'comment',
'comment_author_email' => $webfinger,
'comment_meta' => array(
'source_id' => \esc_url_raw( object_to_uri( $activity['object'] ) ),
'protocol' => 'activitypub',
),
'comment_author_email' => $comment_author_email,
'comment_date' => \get_date_from_gmt( $gm_date ),
'comment_date_gmt' => $gm_date,
'comment_meta' => array(),
);
if ( isset( $actor['icon']['url'] ) ) {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $actor['icon']['url'] );
if ( $user_id ) {
$comment_data['user_id'] = $user_id;
} else {
$comment_data['comment_meta']['protocol'] = 'activitypub';
$comment_data['comment_meta']['source_id'] = \esc_url_raw( object_to_uri( $activity['object'] ) );
// Store reference to remote actor post.
$actor_uri = object_to_uri( $activity['actor'] ?? null );
if ( $actor_uri ) {
$remote_actor = Remote_Actors::get_by_uri( $actor_uri );
if ( ! \is_wp_error( $remote_actor ) ) {
$comment_data['comment_meta']['_activitypub_remote_actor_id'] = $remote_actor->ID;
}
}
if ( isset( $activity['object']['url'] ) ) {
$comment_data['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) );
}
}
if ( isset( $activity['object']['url'] ) ) {
$commentdata['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) );
}
return $commentdata;
return $comment_data;
}
/**
* Persist a comment.
*
* @param array $commentdata The commentdata array.
* @param string $action Optional. Either 'insert' or 'update'. Default 'insert'.
* @param array $comment_data The comment data array.
* @param string $action Optional. Either 'insert' or 'update'. Default 'insert'.
*
* @return array|string|int|\WP_Error|false The comment data or false on failure
*/
public static function persist( $commentdata, $action = self::INSERT ) {
public static function persist( $comment_data, $action = self::INSERT ) {
if (
is_post_disabled( $comment_data['comment_post_ID'] ) &&
! is_ap_post( $comment_data['comment_post_ID'] )
) {
return false;
}
// Disable flood control.
\remove_action( 'check_comment_flood', 'check_comment_flood_db' );
// Do not require email for AP entries.
@ -302,16 +466,16 @@ class Interactions {
// No nonce possible for this submission route.
\add_filter(
'akismet_comment_nonce',
function () {
static function () {
return 'inactive';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
if ( self::INSERT === $action ) {
$state = \wp_new_comment( $commentdata, true );
$state = \wp_new_comment( $comment_data, true );
} else {
$state = \wp_update_comment( $commentdata, true );
$state = \wp_update_comment( $comment_data, true );
}
\remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ) );
@ -320,7 +484,7 @@ class Interactions {
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
if ( 1 === $state ) {
return $commentdata;
return $comment_data;
} else {
return $state; // Either WP_Comment, false, a WP_Error, 0, or 1!
}
@ -346,4 +510,33 @@ class Interactions {
)
);
}
/**
* Get the quote URL from an activity.
*
* Checks for quote properties in priority order: quote -> quoteUrl -> quoteUri -> _misskey_quote.
*
* @param array $activity The activity array.
*
* @return string|false The quote URL or false if not found.
*/
public static function get_quote_url( $activity ) {
if ( ! empty( $activity['object']['quote'] ) ) {
return object_to_uri( $activity['object']['quote'] );
}
if ( ! empty( $activity['object']['quoteUrl'] ) ) {
return object_to_uri( $activity['object']['quoteUrl'] );
}
if ( ! empty( $activity['object']['quoteUri'] ) ) {
return object_to_uri( $activity['object']['quoteUri'] );
}
if ( ! empty( $activity['object']['_misskey_quote'] ) ) {
return object_to_uri( $activity['object']['_misskey_quote'] );
}
return false;
}
}

View File

@ -7,12 +7,14 @@
namespace Activitypub\Collection;
use Activitypub\Dispatcher;
use Activitypub\Scheduler;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use Activitypub\Scheduler;
use Activitypub\Webfinger;
use function Activitypub\add_to_outbox;
use function Activitypub\object_to_uri;
use function Activitypub\user_can_act_as_blog;
/**
* ActivityPub Outbox Collection
@ -20,8 +22,45 @@ use function Activitypub\add_to_outbox;
* @link https://www.w3.org/TR/activitypub/#outbox
*/
class Outbox {
/**
* The post type for the objects.
*
* @var string
*/
const POST_TYPE = 'ap_outbox';
/**
* Maximum number of outbox items to keep.
*
* When the total count exceeds this, the oldest items are purged
* regardless of their age. Acts as a safety net for runaway growth.
*
* @var int
*/
const MAX_ITEMS = 5000;
/**
* Activity types included in the outbox collection listing.
*
* @var string[]
*/
const ACTIVITY_TYPES = array( 'Announce', 'Arrive', 'Create', 'Like', 'Update' );
/**
* Number of items to process per batch during purge.
*
* @var int
*/
const PURGE_BATCH_SIZE = 100;
/**
* Maximum seconds a purge run may take before yielding.
*
* @var int
*/
const PURGE_TIMEOUT = 30;
/**
* Add an Item to the outbox.
*
@ -33,13 +72,33 @@ class Outbox {
*/
public static function add( Activity $activity, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) {
$actor_type = Actors::get_type_by_id( $user_id );
$object_id = self::get_object_id( $activity );
$title = self::get_object_title( $activity->get_object() );
if ( ! $activity->get_actor() ) {
$activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
}
$object_id = object_to_uri( self::get_object_id( $activity ) );
$title = self::get_object_title( $activity->get_object() );
if ( ! $object_id || ! \is_string( $object_id ) ) {
return new \WP_Error(
'activitypub_outbox_invalid_object_id',
\__( 'Unable to determine an object ID for this activity.', 'activitypub' ),
array( 'status' => 400 )
);
}
if ( ! \filter_var( $object_id, FILTER_VALIDATE_URL ) ) {
$object_id = Webfinger::resolve( $object_id );
}
if ( \is_wp_error( $object_id ) ) {
return $object_id;
}
// Save activity in the context of an activitypub request.
\add_filter( 'activitypub_is_activitypub_request', '__return_true' );
$outbox_item = array(
'post_type' => self::POST_TYPE,
'post_title' => sprintf(
@ -48,7 +107,8 @@ class Outbox {
$activity->get_type(),
\wp_trim_words( $title, 5 )
),
'post_content' => wp_slash( $activity->to_json() ),
// Persist the blind audience so later dispatch can compute recipients from `bto`/`bcc`.
'post_content' => wp_slash( $activity->to_json( true, true ) ),
// ensure that user ID is not below 0.
'post_author' => \max( $user_id, 0 ),
'post_status' => 'pending',
@ -60,6 +120,8 @@ class Outbox {
),
);
\remove_filter( 'activitypub_is_activitypub_request', '__return_true' );
$has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
if ( $has_kses ) {
// Prevent KSES from corrupting JSON in post_content.
@ -75,7 +137,7 @@ class Outbox {
\wp_update_post(
array(
'ID' => $id,
'post_content' => \wp_slash( $activity->to_json() ),
'post_content' => \wp_slash( $activity->to_json( true, true ) ),
)
);
}
@ -92,24 +154,38 @@ class Outbox {
return false;
}
self::invalidate_existing_items( $object_id, $activity->get_type(), $id );
self::delete_superseded_items( $object_id, $activity->get_type(), $id );
return $id;
}
/**
* Invalidate existing outbox items with the same activity type and object ID
* by setting their status to 'publish'.
* Delete pending outbox items that have been superseded by a newer item.
*
* @param string $object_id The ID of the activity object.
* @param string $activity_type The type of the activity.
* @param int $current_id The ID of the current outbox item to exclude.
* For most activity types, only items with the same type and object ID are
* deleted. Delete activities are a special case: they supersede all pending
* items for the same object regardless of type.
*
* Unschedules all federation events before deleting each item.
* Skips Follow, Announce, Accept, and Reject activities, as those are
* independent per-request responses that must not cancel each other.
*
* @param string $object_id The ActivityPub object ID (URL).
* @param string $activity_type The activity type (e.g. 'Create', 'Update', 'Delete').
* @param int $exclude_id The ID of the newly added outbox item to keep.
*
* @return void
*/
private static function invalidate_existing_items( $object_id, $activity_type, $current_id ) {
// Do not invalidate items for Announce activities.
if ( 'Announce' === $activity_type ) {
private static function delete_superseded_items( $object_id, $activity_type, $exclude_id ) {
/*
* Do not delete items for Follow, Announce, Accept, or Reject activities.
* Follow activities from different users share the same object ID but are
* independent and must survive until their Accept is received.
* Accept/Reject are per-request responses (e.g. to individual incoming
* QuoteRequests) and must not cancel each other even when they share
* the same object ID.
*/
if ( in_array( $activity_type, array( 'Follow', 'Announce', 'Accept', 'Reject' ), true ) ) {
return;
}
@ -120,7 +196,8 @@ class Outbox {
),
);
// For non-Delete activities, only invalidate items of the same type.
// For non-Delete activities, only delete items of the same type.
// Delete activities supersede all pending items for the same object.
if ( 'Delete' !== $activity_type ) {
$meta_query[] = array(
'key' => '_activitypub_activity_type',
@ -132,7 +209,7 @@ class Outbox {
array(
'post_type' => self::POST_TYPE,
'post_status' => 'pending',
'exclude' => array( $current_id ),
'exclude' => array( $exclude_id ),
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => $meta_query,
'fields' => 'ids',
@ -140,21 +217,8 @@ class Outbox {
);
foreach ( $existing_items as $existing_item_id ) {
$event_args = array(
Dispatcher::$callback,
$existing_item_id,
Dispatcher::$batch_size,
\get_post_meta( $existing_item_id, '_activitypub_outbox_offset', true ) ?: 0, // phpcs:ignore
);
$timestamp = \wp_next_scheduled( 'activitypub_async_batch', $event_args );
\wp_unschedule_event( $timestamp, 'activitypub_async_batch', $event_args );
$timestamp = \wp_next_scheduled( 'activitypub_process_outbox', array( $existing_item_id ) );
\wp_unschedule_event( $timestamp, 'activitypub_process_outbox', array( $existing_item_id ) );
\wp_publish_post( $existing_item_id );
\delete_post_meta( $existing_item_id, '_activitypub_outbox_offset' );
Scheduler::unschedule_events_for_item( $existing_item_id );
\wp_delete_post( $existing_item_id, true );
}
}
@ -163,12 +227,16 @@ class Outbox {
*
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
*
* @return int|bool The ID of the outbox item or false on failure.
* @return int|bool|\WP_Error The ID of the outbox item or false on failure.
*/
public static function undo( $outbox_item ) {
$outbox_item = get_post( $outbox_item );
$outbox_item = \get_post( $outbox_item );
$activity = self::get_activity( $outbox_item );
if ( \is_wp_error( $activity ) ) {
return $activity;
}
$type = 'Undo';
if ( 'Create' === $activity->get_type() ) {
$type = 'Delete';
@ -176,7 +244,69 @@ class Outbox {
$type = 'Remove';
}
return add_to_outbox( $activity, $type, $outbox_item->post_author );
$visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
return add_to_outbox( $activity, $type, $outbox_item->post_author, $visibility );
}
/**
* Get an outbox item by object ID and activity type.
*
* @param string $object_id The ActivityPub object ID.
* @param string $activity_type The activity type (Create, Update, etc.).
*
* @return \WP_Post|null The outbox item or null if not found.
*/
public static function get_by_object_id( $object_id, $activity_type ) {
$outbox_items = \get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => 1,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_activitypub_object_id',
'value' => $object_id,
),
array(
'key' => '_activitypub_activity_type',
'value' => $activity_type,
),
),
)
);
return ! empty( $outbox_items ) ? $outbox_items[0] : null;
}
/**
* Get an outbox item by its GUID.
*
* @param string $guid The GUID of the outbox item.
*
* @return \WP_Post|\WP_Error The outbox item or WP_Error.
*/
public static function get_by_guid( $guid ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
\esc_url( $guid ),
self::POST_TYPE
)
);
if ( ! $post_id ) {
return new \WP_Error(
'activitypub_outbox_item_not_found',
\__( 'Outbox item not found', 'activitypub' ),
array( 'status' => 404 )
);
}
return \get_post( $post_id );
}
/**
@ -206,10 +336,14 @@ class Outbox {
* @return Activity|\WP_Error The Activity object or WP_Error.
*/
public static function get_activity( $outbox_item ) {
$outbox_item = get_post( $outbox_item );
$actor = self::get_actor( $outbox_item );
if ( is_wp_error( $actor ) ) {
return $actor;
$outbox_item = \get_post( $outbox_item );
if ( ! $outbox_item ) {
return new \WP_Error(
'activitypub_outbox_item_not_found',
\__( 'Outbox item not found.', 'activitypub' ),
array( 'status' => 404 )
);
}
$activity_object = \json_decode( $outbox_item->post_content, true );
@ -218,9 +352,18 @@ class Outbox {
if ( $activity_object['type'] === $type ) {
$activity = Activity::init_from_array( $activity_object );
if ( ! $activity->get_actor() ) {
$actor = self::get_actor( $outbox_item );
if ( \is_wp_error( $actor ) ) {
return $actor;
}
$activity->set_actor( $actor->get_id() );
}
} else {
$actor = self::get_actor( $outbox_item );
if ( \is_wp_error( $actor ) ) {
return $actor;
}
$activity = new Activity();
$activity->set_type( $type );
$activity->set_id( $outbox_item->guid );
@ -284,6 +427,32 @@ class Outbox {
return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
}
// Authenticate via Bearer token for non-REST requests (e.g. permalink access).
if ( \get_option( 'activitypub_api', false ) && ! \is_user_logged_in() && ! \wp_is_serving_rest_request() ) {
\Activitypub\OAuth\Server::authenticate_oauth( null );
}
/*
* Allow the author to view their own outbox items regardless of visibility.
* The `is_user_logged_in()` guard prevents anonymous visitors from matching
* the blog actor's items (where both `get_current_user_id()` and `post_author`
* are `0`), which would otherwise expose private activities at their permalink.
*
* Users authorized to act as the blog actor are treated as the author of
* blog-actor items so they can read the same private outbox they can post to.
*/
if ( \is_user_logged_in() ) {
$author = (int) $outbox_item->post_author;
if ( \get_current_user_id() === $author ) {
return self::get_activity( $outbox_item );
}
if ( Actors::BLOG_USER_ID === $author && user_can_act_as_blog() ) {
return self::get_activity( $outbox_item );
}
}
// Check if Outbox Activity is public.
$visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
@ -291,7 +460,7 @@ class Outbox {
return new \WP_Error( 'private_outbox_item', 'Not a public Outbox item.' );
}
$activity_types = \apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) );
$activity_types = \apply_filters( 'rest_activitypub_outbox_activity_types', self::ACTIVITY_TYPES );
$activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
if ( ! in_array( $activity_type, $activity_types, true ) ) {
@ -306,7 +475,7 @@ class Outbox {
*
* @param Activity|Base_Object|string $data The activity object.
*
* @return string The object ID.
* @return string|null The object ID.
*/
private static function get_object_id( $data ) {
$object = $data->get_object();
@ -319,13 +488,17 @@ class Outbox {
return $object;
}
return $data->get_id() ?? $data->get_actor();
if ( $data->get_id() ) {
return $data->get_id();
}
return object_to_uri( $data->get_actor() );
}
/**
* Get the title of an activity recursively.
*
* @param Base_Object $activity_object The activity object.
* @param Activity|Base_Object $activity_object The activity object.
*
* @return string The title.
*/
@ -340,12 +513,89 @@ class Outbox {
return $post_id ? get_the_title( $post_id ) : '';
}
$title = $activity_object->get_name() ?? $activity_object->get_content();
$title = $activity_object->get_name() ?: $activity_object->get_content();
if ( ! $title && $activity_object->get_object() instanceof Base_Object ) {
$title = $activity_object->get_object()->get_name() ?? $activity_object->get_object()->get_content();
$title = $activity_object->get_object()->get_name() ?: $activity_object->get_object()->get_content();
}
return $title;
}
/**
* Purge old outbox items.
*
* Deletes outbox items older than the specified number of days,
* except for Follow activities which are always preserved.
* Also enforces a hard cap on total items via MAX_ITEMS.
*
* @param int $days Number of days to keep items. Items older than this will be deleted.
*
* @return int The number of items deleted.
*/
public static function purge( $days ) {
if ( $days <= 0 ) {
return 0;
}
$counts = \wp_count_posts( self::POST_TYPE );
$total = 0;
foreach ( $counts as $count ) {
$total += (int) $count;
}
if ( $total <= 20 ) {
return 0;
}
$deleted = 0;
$cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) );
$start_time = \time();
// If total exceeds the hard cap, drop the date filter to purge oldest items first.
$overflow = $total > self::MAX_ITEMS;
$date_query = array(
array(
'before' => $cutoff,
),
);
$query_args = array(
'post_type' => self::POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'numberposts' => self::PURGE_BATCH_SIZE,
'orderby' => 'date',
'order' => 'ASC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_activitypub_activity_type',
'value' => 'Follow',
'compare' => '!=',
),
),
);
if ( ! $overflow ) {
$query_args['date_query'] = $date_query;
}
do {
$post_ids = \get_posts( $query_args );
foreach ( $post_ids as $post_id ) {
\wp_delete_post( $post_id, true );
++$deleted;
}
// Once we're back under the cap, re-apply the date filter.
if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
$overflow = false;
$query_args['date_query'] = $date_query;
}
} while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
return $deleted;
}
}

View File

@ -0,0 +1,221 @@
<?php
/**
* Posts collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Blocks;
use Activitypub\Hashtag;
use Activitypub\Link;
use function Activitypub\get_content_visibility;
use function Activitypub\user_can_act_as_blog;
/**
* Posts collection.
*
* Provides CRUD methods for local WordPress posts created
* via ActivityPub Client-to-Server (C2S) outbox.
*
* @see Remote_Posts for federated posts received via Server-to-Server (S2S).
*/
class Posts {
/**
* Create a WordPress post from an ActivityPub activity.
*
* @since 8.1.0
*
* @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.
*/
public static function create( $activity, $user_id, $visibility = null ) {
// Resolve the post author. Blog actor falls back to the current user for a real byline.
$post_author = $user_id > 0 ? $user_id : \get_current_user_id();
/*
* Authorize the request:
* - Per-user path: require `publish_posts` on the URL-specified user.
* - Blog actor path (post_author falls back to current user): require
* the act-as-blog grant. `publish_posts` is implicit because the
* helper defaults to `manage_options` (administrators).
* - Cron/CLI path keeps `post_author = 0` and bypasses both checks.
*/
if ( $post_author > 0 ) {
$authorized = $post_author === (int) $user_id
? \user_can( $user_id, 'publish_posts' )
: user_can_act_as_blog();
if ( ! $authorized ) {
return new \WP_Error(
'activitypub_forbidden',
\__( 'You do not have permission to create posts.', 'activitypub' ),
array( 'status' => 403 )
);
}
}
$object = $activity['object'] ?? array();
$object_type = $object['type'] ?? '';
$content = \wp_kses_post( $object['content'] ?? '' );
$name = \sanitize_text_field( $object['name'] ?? '' );
$summary = \wp_kses_post( $object['summary'] ?? '' );
$plain_summary = \sanitize_text_field( $summary );
// A summary marked sensitive is a content warning (plain text); otherwise it's a regular excerpt.
// Route on the sanitized summary so whitespace-only values don't pollute either field.
$content_warning = ! empty( $object['sensitive'] ) && '' !== $plain_summary ? $plain_summary : '';
$post_excerpt = '' === $content_warning && '' !== $plain_summary ? $summary : '';
// Process content: autop, autolink, hashtags, and convert to blocks.
$content = self::prepare_content( $content );
// Use name as title for Articles, or generate from content for Notes.
$title = $name;
if ( empty( $title ) && ! empty( $content ) ) {
$title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' );
}
// Determine visibility if not provided.
if ( null === $visibility ) {
$visibility = get_content_visibility( $activity );
}
$post_data = array(
'post_author' => $post_author,
'post_title' => $title,
'post_content' => $content,
'post_excerpt' => $post_excerpt,
'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish',
'post_type' => 'post',
'meta_input' => array(
'activitypub_content_visibility' => $visibility,
'activitypub_content_warning' => $content_warning,
),
);
$post_id = \wp_insert_post( $post_data, true );
if ( \is_wp_error( $post_id ) ) {
return $post_id;
}
// Set post format to 'status' for Notes so the transformer maps it back correctly.
if ( 'Note' === $object_type ) {
\set_post_format( $post_id, 'status' );
}
return \get_post( $post_id );
}
/**
* Update a WordPress post from an ActivityPub activity.
*
* @since 8.1.0
*
* @param \WP_Post $post The post to update.
* @param array $activity The activity data.
* @param string|null $visibility Content visibility.
*
* @return \WP_Post|\WP_Error The updated post on success, WP_Error on failure.
*/
public static function update( $post, $activity, $visibility = null ) {
$object = $activity['object'] ?? array();
$content = \wp_kses_post( $object['content'] ?? '' );
$name = \sanitize_text_field( $object['name'] ?? '' );
$summary = \wp_kses_post( $object['summary'] ?? '' );
$plain_summary = \sanitize_text_field( $summary );
// A summary marked sensitive is a content warning (plain text); otherwise it's a regular excerpt.
// Route on the sanitized summary so whitespace-only values don't pollute either field.
$content_warning = ! empty( $object['sensitive'] ) && '' !== $plain_summary ? $plain_summary : '';
$post_excerpt = '' === $content_warning && '' !== $plain_summary ? $summary : '';
// Process content: autop, autolink, hashtags, and convert to blocks.
$content = self::prepare_content( $content );
// Use name as title for Articles, or generate from content for Notes.
$title = $name;
if ( empty( $title ) && ! empty( $content ) ) {
$title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' );
}
// Determine visibility if not provided.
if ( null === $visibility ) {
$visibility = get_content_visibility( $activity );
}
$post_data = array(
'ID' => $post->ID,
'post_title' => $title,
'post_content' => $content,
'post_excerpt' => $post_excerpt,
'meta_input' => array(
'activitypub_content_visibility' => $visibility,
'activitypub_content_warning' => $content_warning,
),
);
$post_id = \wp_update_post( $post_data, true );
if ( \is_wp_error( $post_id ) ) {
return $post_id;
}
return \get_post( $post_id );
}
/**
* Delete (trash) a WordPress post.
*
* @since 8.1.0
*
* @param int $post_id The post ID.
*
* @return \WP_Post|false|null Post data on success, false or null on failure.
*/
public static function delete( $post_id ) {
return \wp_trash_post( $post_id );
}
/**
* Prepare content for storage as a WordPress post.
*
* Applies wpautop (for plain text), autolinks bare URLs,
* converts hashtags to links, and wraps in block markup.
*
* @since 8.1.0
*
* @param string $content The HTML or plain-text content.
*
* @return string The processed content with block markup.
*/
public static function prepare_content( $content ) {
if ( empty( $content ) ) {
return '';
}
// Wrap plain text in paragraphs if it has no block-level HTML.
if ( ! \preg_match( '/<(p|h[1-6]|ul|ol|blockquote|figure|hr|img|div|pre|table)\b/i', $content ) ) {
$content = \wpautop( $content );
}
// Convert bare URLs to links.
$content = Link::the_content( $content );
// Convert #hashtags to links.
$content = Hashtag::the_content( $content );
// Convert HTML to block markup.
$content = Blocks::convert_from_html( $content );
return $content;
}
}

View File

@ -0,0 +1,861 @@
<?php
/**
* Remote Actors collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Activity\Actor;
use Activitypub\Emoji;
use Activitypub\Http;
use Activitypub\Sanitize;
use Activitypub\Webfinger;
use function Activitypub\is_actor;
use function Activitypub\object_to_uri;
/**
* Remote Actors collection class.
*/
class Remote_Actors {
/**
* Post type for storing remote actors.
*
* @var string
*/
const POST_TYPE = 'ap_actor';
/**
* Cache key for the followers inbox.
*
* @var string
*/
const CACHE_KEY_INBOXES = 'actor_inboxes';
/**
* Returns all Inboxes for all known remote Actors.
*
* @return array The list of Inboxes.
*/
public static function get_inboxes() {
$inboxes = \wp_cache_get( self::CACHE_KEY_INBOXES, 'activitypub' );
if ( $inboxes ) {
return $inboxes;
}
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_col(
"SELECT DISTINCT meta_value FROM {$wpdb->postmeta}
WHERE meta_key = '_activitypub_inbox'
AND meta_value IS NOT NULL"
);
$inboxes = \array_filter( $results );
\wp_cache_set( self::CACHE_KEY_INBOXES, $inboxes, 'activitypub' );
return $inboxes;
}
/**
* Get an Remote Actor from the collection.
*
* @param int $id The object ID.
*
* @return \WP_Post|null The post object or null on failure.
*/
public static function get( $id ) {
$post = \get_post( $id );
if ( $post && self::POST_TYPE === $post->post_type ) {
return $post;
}
return null;
}
/**
* Upsert (insert or update) a remote actor as a custom post type.
*
* @param array|Actor $actor ActivityPub actor object (array or actor, must include 'id').
*
* @return int|\WP_Error Post ID on success, WP_Error on failure.
*/
public static function upsert( $actor ) {
if ( \is_array( $actor ) ) {
$actor = Actor::init_from_array( $actor );
}
$post = self::get_by_uri( $actor->get_id() );
if ( ! \is_wp_error( $post ) ) {
return self::update( $post, $actor );
}
return self::create( $actor );
}
/**
* Create a remote actor as a custom post type.
*
* @param array|Actor $actor ActivityPub actor object (array or Actor, must include 'id').
*
* @return int|\WP_Error Post ID on success, WP_Error on failure.
*/
public static function create( $actor ) {
if ( \is_array( $actor ) ) {
$actor = Actor::init_from_array( $actor );
}
$args = self::prepare_custom_post_type( $actor );
if ( \is_wp_error( $args ) ) {
return $args;
}
$has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
if ( $has_kses ) {
// Prevent KSES from corrupting JSON in post_content.
\kses_remove_filters();
}
$post_id = \wp_insert_post( $args );
if ( $has_kses ) {
// Restore KSES filters.
\kses_init_filters();
}
return $post_id;
}
/**
* Update a remote Actor object by actor URL (guid).
*
* @param int|\WP_Post $post The post ID or object.
* @param array|Actor $actor The ActivityPub actor object as associative array (must include 'id').
*
* @return int|\WP_Error The post ID or WP_Error.
*/
public static function update( $post, $actor ) {
if ( \is_array( $actor ) ) {
$actor = Actor::init_from_array( $actor );
}
$post = \get_post( $post, ARRAY_A );
if ( ! $post ) {
return new \WP_Error(
'activitypub_actor_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
$args = self::prepare_custom_post_type( $actor );
if ( \is_wp_error( $args ) ) {
return $args;
}
$args = \wp_parse_args( $args, $post );
$has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
if ( $has_kses ) {
// Prevent KSES from corrupting JSON in post_content.
\kses_remove_filters();
}
$post_id = \wp_update_post( $args );
if ( $has_kses ) {
// Restore KSES filters.
\kses_init_filters();
}
return $post_id;
}
/**
* Delete a remote actor object by actor URL (guid).
*
* @param int $post_id The post ID.
*
* @return bool True on success, false on failure.
*/
public static function delete( $post_id ) {
return \wp_delete_post( $post_id );
}
/**
* Get a remote actor post by actor URI (guid).
*
* @param string $actor_uri The actor URI.
*
* @return \WP_Post|\WP_Error Post object or WP_Error if not found.
*/
public static function get_by_uri( $actor_uri ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
esc_sql( $actor_uri ),
esc_sql( self::POST_TYPE )
)
);
if ( ! $post_id ) {
return new \WP_Error(
'activitypub_actor_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
$post = \get_post( $post_id );
if ( ! $post instanceof \WP_Post ) {
return new \WP_Error(
'activitypub_actor_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
return $post;
}
/**
* Look up which of the given URIs already exist as cached remote actors.
*
* Single batched query (chunked at 200 placeholders to stay well within
* common DB limits). Use this instead of looping over `get_by_uri()` when
* a caller only needs to know which URIs are known — e.g. the inbox
* recipient resolver, where a flood of unknown recipients would otherwise
* trigger one DB query per recipient.
*
* @since 8.2.1
*
* @param string[] $uris Candidate actor URIs.
*
* @return array<string, true> Map of URIs that exist, keyed for O(1) lookup.
*/
public static function get_existing_uris( $uris ) {
if ( empty( $uris ) ) {
return array();
}
global $wpdb;
$existing = array();
foreach ( \array_chunk( \array_values( \array_unique( $uris ) ), 200 ) as $chunk ) {
$placeholders = \implode( ', ', \array_fill( 0, \count( $chunk ), '%s' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$found = $wpdb->get_col(
$wpdb->prepare(
"SELECT guid FROM $wpdb->posts WHERE post_type = %s AND guid IN ( $placeholders )",
\array_merge( array( self::POST_TYPE ), $chunk )
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
foreach ( $found as $uri ) {
$existing[ $uri ] = true;
}
}
return $existing;
}
/**
* Fetch a remote actor post by either actor URI or acct, fetching from remote if not found locally.
*
* @param string $uri_or_acct The actor URI or acct identifier.
*
* @return \WP_Post|\WP_Error Post object or WP_Error if not found.
*/
public static function fetch_by_various( $uri_or_acct ) {
if ( \filter_var( $uri_or_acct, FILTER_VALIDATE_URL ) ) {
return self::fetch_by_uri( $uri_or_acct );
}
if ( Webfinger::is_acct( $uri_or_acct ) ) {
return self::fetch_by_acct( $uri_or_acct );
}
return new \WP_Error(
'activitypub_invalid_actor_identifier',
'The actor identifier is not supported',
array( 'status' => 400 )
);
}
/**
* Lookup a remote actor post by actor URI (guid), fetching from remote if not found locally.
*
* @param string $actor_uri The actor URI.
*
* @return \WP_Post|\WP_Error Post object or WP_Error if not found.
*/
public static function fetch_by_uri( $actor_uri ) {
$post = self::get_by_uri( $actor_uri );
if ( ! \is_wp_error( $post ) ) {
return $post;
}
$object = Http::get_remote_object( $actor_uri, false );
if ( \is_wp_error( $object ) ) {
return $object;
}
if ( ! is_actor( $object ) ) {
return new \WP_Error(
'activitypub_no_actor',
\__( 'Object is not an Actor', 'activitypub' ),
array( 'status' => 400 )
);
}
$post_id = self::upsert( $object );
if ( \is_wp_error( $post_id ) ) {
return $post_id;
}
$post = \get_post( $post_id );
if ( ! $post instanceof \WP_Post ) {
return new \WP_Error(
'activitypub_actor_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
return $post;
}
/**
* Fetch a remote actor post by acct, fetching from remote if not found locally.
*
* @param string $acct The acct identifier.
*
* @return \WP_Post|\WP_Error Post object or WP_Error if not found.
*/
public static function fetch_by_acct( $acct ) {
$acct = Sanitize::webfinger( $acct );
// Check local DB for acct post meta.
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT post_id FROM $wpdb->postmeta WHERE meta_key='_activitypub_acct' AND meta_value=%s",
$acct
)
);
if ( $post_id ) {
$post = \get_post( $post_id );
if ( ! $post instanceof \WP_Post ) {
return new \WP_Error(
'activitypub_actor_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
return $post;
}
$profile_uri = Webfinger::resolve( $acct );
if ( \is_wp_error( $profile_uri ) ) {
return $profile_uri;
}
$post = self::fetch_by_uri( $profile_uri );
if ( ! \is_wp_error( $post ) ) {
\update_post_meta( $post->ID, '_activitypub_acct', $acct );
}
return $post;
}
/**
* Store an error that occurred when sending an ActivityPub message to a follower.
*
* The error will be stored in post meta.
*
* @param int $post_id The ID of the WordPress Custom-Post-Type.
* @param string|\WP_Error $error The error message.
*
* @return int|false The meta ID on success, false on failure.
*/
public static function add_error( $post_id, $error ) {
if ( \is_string( $error ) ) {
$error_message = $error;
} elseif ( \is_wp_error( $error ) ) {
$error_message = $error->get_error_message();
} else {
$error_message = \__(
'Unknown Error or misconfigured Error-Message',
'activitypub'
);
}
return \add_post_meta(
$post_id,
'_activitypub_errors',
$error_message
);
}
/**
* Count the errors for an actor.
*
* @param int $post_id The ID of the WordPress Custom-Post-Type.
*
* @return int The number of errors.
*/
public static function count_errors( $post_id ) {
return \count( \get_post_meta( $post_id, '_activitypub_errors', false ) );
}
/**
* Get all error messages for an actor.
*
* @param int $post_id The post ID.
*
* @return string[] Array of error messages.
*/
public static function get_errors( $post_id ) {
return \get_post_meta( $post_id, '_activitypub_errors', false );
}
/**
* Clear all errors for an actor.
*
* @param int $post_id The ID of the WordPress Custom-Post-Type.
*
* @return bool True on success, false on failure.
*/
public static function clear_errors( $post_id ) {
return \delete_post_meta( $post_id, '_activitypub_errors' );
}
/**
* Get all remote actors (Custom Post Type) that had errors.
*
* @param int $number Optional. Number of actors to return. Default 20.
*
* @return \WP_Post[] Array of faulty actor posts.
*/
public static function get_faulty( $number = 20 ) {
$args = array(
'post_type' => self::POST_TYPE,
'posts_per_page' => $number,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'OR',
array(
'key' => '_activitypub_errors',
'compare' => 'EXISTS',
),
array(
'key' => '_activitypub_inbox',
'compare' => 'NOT EXISTS',
),
array(
'key' => '_activitypub_inbox',
'value' => '',
'compare' => '=',
),
),
);
return ( new \WP_Query() )->query( $args );
}
/**
* Get all remote actor posts not updated for a given time.
*
* @param int $number Optional. Limits the result. Default 50.
* @param int $older_than Optional. The time in seconds. Default DAY_IN_SECONDS.
*
* @return \WP_Post[] The list of actors.
*/
public static function get_outdated( $number = 50, $older_than = DAY_IN_SECONDS ) {
$args = array(
'post_type' => self::POST_TYPE,
'posts_per_page' => $number,
'orderby' => 'modified',
'order' => 'ASC',
'post_status' => 'any', // 'any' includes 'trash'.
'date_query' => array(
array(
'column' => 'post_modified_gmt',
'before' => \gmdate( 'Y-m-d', \time() - $older_than ),
),
),
);
return ( new \WP_Query() )->query( $args );
}
/**
* Convert a custom post type input to an Activitypub\Activity\Actor.
*
* @param int|\WP_Post $post The post ID or object.
*
* @return Actor|\WP_Error The actor object or WP_Error on failure.
*/
public static function get_actor( $post ) {
$post = \get_post( $post );
if ( ! $post ) {
return new \WP_Error(
'activitypub_actor_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
$json = $post->post_content;
if ( empty( $json ) ) {
$json = \get_post_meta( $post->ID, '_activitypub_actor_json', true );
}
$actor = Actor::init_from_json( $json );
if ( \is_wp_error( $actor ) ) {
self::add_error( $post->ID, $actor );
return $actor;
}
if ( ! $actor->get_webfinger() ) {
$actor->set_webfinger( self::get_acct( $post->ID ) );
}
return $actor;
}
/**
* Prepare actor object for insert or update as a custom post type.
*
* @param Actor $actor The actor data.
*
* @return array|\WP_Error Array of post arguments or WP_Error on failure.
*/
private static function prepare_custom_post_type( $actor ) {
if ( ! $actor instanceof Actor ) {
return new \WP_Error(
'activitypub_invalid_actor_data',
\__( 'Invalid actor data', 'activitypub' ),
array( 'status' => 400 )
);
}
if ( ! empty( $actor->get_endpoints()['sharedInbox'] ) ) {
$inbox = $actor->get_endpoints()['sharedInbox'];
} elseif ( ! empty( $actor->get_inbox() ) ) {
$inbox = $actor->get_inbox();
} else {
return new \WP_Error(
'activitypub_invalid_actor_data',
\__( 'Invalid actor data', 'activitypub' ),
array( 'status' => 400 )
);
}
if ( $actor->get_webfinger() ) {
$webfinger = Sanitize::webfinger( $actor->get_webfinger() );
} else {
$webfinger = Webfinger::uri_to_acct( $actor->get_id() );
$webfinger = \is_wp_error( $webfinger ) ? Webfinger::guess( $actor ) : Sanitize::webfinger( $webfinger );
}
/*
* Temporarily remove mention/hashtag/link filters to prevent infinite recursion when
* storing remote actors with mentions/hashtags in their bios.
*
* PROBLEM: These filters are globally registered on 'init' for all to_json() calls,
* but they're designed for OUTGOING content (federation). When processing mentions in
* an actor's bio during storage, the Mention filter fetches the mentioned actor, which
* then processes mentions in THEIR bio, creating infinite recursion.
*
* SHORTCOMINGS:
* - Fragile: Easy to forget when adding new storage locations (e.g., Inbox storage).
* - Scattered: Same pattern would need to be repeated anywhere we store remote content.
* - Race conditions: If filters are re-added/removed elsewhere, this could break.
* - Not semantic: We're working around a design issue rather than fixing it.
*
* BETTER LONG-TERM SOLUTION:
* Distinguish between "incoming" (storage) and "outgoing" (federation) contexts:
* - INCOMING: Store received ActivityPub data as-is, don't process mentions/hashtags.
* (Remote_Actors::prepare_custom_post_type, Inbox storage)
* - OUTGOING: Process mentions/hashtags when serving our content to other servers.
* (Dispatcher, REST API controllers, Transformers)
*/
\remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Mention', 'filter_activity_object' ), 99 );
\remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Hashtag', 'filter_activity_object' ), 99 );
\remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Link', 'filter_activity_object' ), 99 );
$actor_json = $actor->to_json();
$actor_array = $actor->to_array();
// Re-add the filters.
\add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Mention', 'filter_activity_object' ), 99 );
\add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Hashtag', 'filter_activity_object' ), 99 );
\add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Link', 'filter_activity_object' ), 99 );
$meta_input = array(
'_activitypub_inbox' => $inbox,
'_activitypub_acct' => $webfinger,
);
// Add emoji meta if actor has emoji in tags.
$emoji_meta = Emoji::prepare_actor_meta( $actor_array );
$meta_input = array_merge( $meta_input, $emoji_meta );
return array(
'guid' => \esc_url_raw( $actor->get_id() ),
'post_title' => \wp_strip_all_tags( \wp_slash( $actor->get_name() ?: $actor->get_preferred_username() ) ),
'post_author' => 0,
'post_type' => self::POST_TYPE,
'post_content' => \wp_slash( $actor_json ),
'post_excerpt' => \wp_kses( \wp_slash( (string) $actor->get_summary() ), 'user_description' ),
'post_status' => 'publish',
'meta_input' => $meta_input,
);
}
/**
* Normalize actor identifier to a URI.
*
* Handles webfinger addresses, URLs without schemes, objects, and arrays.
*
* @param string|object|array $actor Actor URI, webfinger address, actor object, or array.
* @return string|null Normalized actor URI or null if unable to resolve.
*/
public static function normalize_identifier( $actor ) {
$actor = object_to_uri( $actor );
if ( ! is_string( $actor ) ) {
return null;
}
$actor = \trim( $actor, '@' );
// If it's an email-like webfinger address, resolve it.
if ( \filter_var( $actor, FILTER_VALIDATE_EMAIL ) ) {
$resolved = Webfinger::resolve( $actor );
return \is_wp_error( $resolved ) ? null : object_to_uri( $resolved );
}
// If it's a URL without scheme, add https://.
if ( empty( \wp_parse_url( $actor, PHP_URL_SCHEME ) ) ) {
$actor = \esc_url_raw( 'https://' . \ltrim( $actor, '/' ) );
}
return $actor;
}
/**
* Get public key from key_id.
*
* @param string $key_id The URL to the public key.
*
* @return resource|\WP_Error The public key resource or WP_Error.
*/
public static function get_public_key( $key_id ) {
$no_profile_error = new \WP_Error( 'activitypub_no_remote_profile_found', 'No Profile found or Profile not accessible', array( 'status' => 401 ) );
$no_key_error = new \WP_Error( 'activitypub_no_remote_key_found', 'No Public-Key found', array( 'status' => 401 ) );
$actor = self::get_by_uri( \strip_fragment_from_url( $key_id ) );
if ( ! \is_wp_error( $actor ) ) {
$actor = \json_decode( $actor->post_content, true );
} else {
$data = Http::get_remote_object( $key_id );
if ( \is_wp_error( $data ) ) {
return $no_profile_error;
}
// If we fetched a standalone key object, follow the owner to get the actor.
if ( isset( $data['owner'] ) && ! isset( $data['publicKey'] ) ) {
// Verify the owner is on the same host as the key to prevent cross-origin spoofing.
$key_host = \wp_parse_url( $key_id, \PHP_URL_HOST );
$owner_host = \wp_parse_url( $data['owner'], \PHP_URL_HOST );
if ( ! $key_host || ! $owner_host || $key_host !== $owner_host ) {
return $no_key_error;
}
$data = Http::get_remote_object( $data['owner'] );
}
$actor = $data;
}
if ( \is_wp_error( $actor ) ) {
return $no_profile_error;
}
$public_key_pem = self::extract_public_key_pem( $actor );
if ( ! $public_key_pem ) {
return $no_key_error;
}
$key_resource = \openssl_pkey_get_public( \rtrim( $public_key_pem ) );
if ( ! $key_resource ) {
return $no_key_error;
}
return $key_resource;
}
/**
* Extract public key PEM from a fetched object.
*
* Supports two formats:
* 1. Actor objects with a nested `publicKey` property (e.g. Mastodon-style `#main-key` fragments).
* 2. Actor objects with a `publicKey` URL reference (e.g. `tags.pub`).
* The URL is dereferenced and the key's owner is verified against the actor.
*
* @since 8.0.0
*
* @param array $data The fetched actor JSON data.
*
* @return string|false The public key PEM string, or false if not found.
*/
private static function extract_public_key_pem( $data ) {
// Standard actor with nested publicKey.
if ( isset( $data['publicKey']['publicKeyPem'] ) ) {
return $data['publicKey']['publicKeyPem'];
}
// Actor with publicKey as a URL reference (e.g. tags.pub).
if ( ! isset( $data['publicKey'] ) || ! \is_string( $data['publicKey'] ) ) {
return false;
}
$actor_host = isset( $data['id'] ) ? \wp_parse_url( $data['id'], \PHP_URL_HOST ) : null;
$key_url_host = \wp_parse_url( $data['publicKey'], \PHP_URL_HOST );
// Verify the key URL is on the same host as the actor.
if ( ! $actor_host || ! $key_url_host || $actor_host !== $key_url_host ) {
return false;
}
$key_data = Http::get_remote_object( $data['publicKey'] );
if ( \is_wp_error( $key_data ) || ! isset( $key_data['publicKeyPem'] ) ) {
return false;
}
// Verify the key's owner matches the actor.
if ( ! isset( $key_data['owner'] ) || $key_data['owner'] !== $data['id'] ) {
return false;
}
return $key_data['publicKeyPem'];
}
/**
* Get the acct of a remote actor.
*
* @uses Webfinger::uri_to_acct to resolve the acct by the actor URI.
* @uses Webfinger::guess to guess a acct if the actors acct is not resolvable.
*
* @param int $id The ID of the remote actor.
*
* @return string The acct of the remote actor or empty string on failure.
*/
public static function get_acct( $id ) {
$acct = \get_post_meta( $id, '_activitypub_acct', true );
if ( $acct ) {
return $acct;
}
$post = \get_post( $id );
if ( ! $post ) {
return '';
}
$acct = Webfinger::uri_to_acct( $post->guid );
if ( \is_wp_error( $acct ) ) {
$actor = Actor::init_from_json( $post->post_content );
if ( \is_wp_error( $actor ) ) {
return '';
}
$acct = Webfinger::guess( $actor );
}
$acct = Sanitize::webfinger( $acct );
\update_post_meta( $id, '_activitypub_acct', $acct );
return $acct;
}
/**
* Get the avatar URL for a remote actor.
*
* Uses lazy caching - the avatar is only downloaded when first accessed.
* Passes the URL through the activitypub_remote_media_url filter which
* triggers caching if enabled.
*
* @param int $id The ID of the remote actor post.
*
* @return string The avatar URL or a default one if not found.
*/
public static function get_avatar_url( $id ) {
$default_avatar_url = ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg';
// Extract remote avatar URL from actor data.
$post = \get_post( $id );
if ( ! $post || empty( $post->post_content ) ) {
return $default_avatar_url;
}
$actor_data = \json_decode( $post->post_content, true );
if ( empty( $actor_data['icon'] ) ) {
return $default_avatar_url;
}
$remote_avatar_url = object_to_uri( $actor_data['icon'] );
if ( empty( $remote_avatar_url ) ) {
return $default_avatar_url;
}
/**
* Filters a remote media URL before use.
*
* Cache handlers hook into this filter to provide lazy caching.
* Returns cached local URL if available, otherwise original URL.
*
* @since 5.6.0
*
* @param string $url The remote avatar URL.
* @param string $context The context ('avatar', 'media', 'emoji').
* @param int|null $entity_id The entity ID (actor post ID, post ID, or null for emoji).
* @param array $options Optional. Additional options like 'updated' timestamp.
*/
return \apply_filters( 'activitypub_remote_media_url', $remote_avatar_url, 'avatar', $id, array() );
}
}

View File

@ -0,0 +1,623 @@
<?php
/**
* Remote Posts collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Emoji;
use Activitypub\Sanitize;
use function Activitypub\generate_post_summary;
use function Activitypub\object_to_uri;
use function Activitypub\process_remote_media;
/**
* Remote Posts collection.
*
* Provides methods to retrieve, create, update, and manage remote
* ActivityPub posts (articles, notes, media, etc.) received via
* Server-to-Server (S2S) federation.
*
* @see Posts for local posts created via Client-to-Server (C2S) outbox.
*/
class Remote_Posts {
/**
* The post type for the posts.
*
* @var string
*/
const POST_TYPE = 'ap_post';
/**
* Maximum number of remote post items to keep.
*
* @var int
*/
const MAX_ITEMS = 5000;
/**
* Number of items to process per batch during purge.
*
* @var int
*/
const PURGE_BATCH_SIZE = 100;
/**
* Maximum seconds a purge run may take before yielding.
*
* @var int
*/
const PURGE_TIMEOUT = 30;
/**
* Add an object to the collection.
*
* @param array $activity The activity object data.
* @param int|int[] $recipients The id(s) of the local blog-user(s).
*
* @return \WP_Post|\WP_Error The object post or WP_Error on failure.
*/
public static function add( $activity, $recipients ) {
$recipients = (array) $recipients;
$activity_object = $activity['object'];
$existing = self::get_by_guid( $activity_object['id'] );
// If post exists, call update instead.
if ( ! \is_wp_error( $existing ) ) {
return self::update( $activity, $recipients );
}
// Post doesn't exist, create new post.
$actor = Remote_Actors::fetch_by_uri( object_to_uri( $activity_object['attributedTo'] ) );
if ( \is_wp_error( $actor ) ) {
return $actor;
}
$post_array = self::activity_to_post( $activity_object );
$post_id = \wp_insert_post( $post_array, true );
if ( \is_wp_error( $post_id ) ) {
return $post_id;
}
\add_post_meta( $post_id, '_activitypub_remote_actor_id', $actor->ID );
// Add recipients as separate meta entries after post is created.
foreach ( $recipients as $user_id ) {
self::add_recipient( $post_id, $user_id );
}
self::add_taxonomies( $post_id, $activity_object );
return \get_post( $post_id );
}
/**
* Get an object from the collection.
*
* @param int $id The object ID.
*
* @return \WP_Post|null The post object or null on failure.
*/
public static function get( $id ) {
return \get_post( $id );
}
/**
* Get an object by its GUID.
*
* @param string $guid The object GUID.
*
* @return \WP_Post|\WP_Error The object post or WP_Error on failure.
*/
public static function get_by_guid( $guid ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
\esc_url( $guid ),
self::POST_TYPE
)
);
if ( ! $post_id ) {
return new \WP_Error(
'activitypub_post_not_found',
\__( 'Post not found', 'activitypub' ),
array( 'status' => 404 )
);
}
return \get_post( $post_id );
}
/**
* Update an object in the collection.
*
* @param array $activity The activity object data.
* @param int|int[] $recipients The id(s) of the local blog-user(s).
*
* @return \WP_Post|\WP_Error The updated object post or WP_Error on failure.
*/
public static function update( $activity, $recipients ) {
$recipients = (array) $recipients;
$post = self::get_by_guid( $activity['object']['id'] );
if ( \is_wp_error( $post ) ) {
return $post;
}
$post_array = self::activity_to_post( $activity['object'] );
$post_array['ID'] = $post->ID;
$post_id = \wp_update_post( $post_array, true );
if ( \is_wp_error( $post_id ) ) {
return $post_id;
}
// Add new recipients using add_recipient (handles deduplication).
foreach ( $recipients as $user_id ) {
self::add_recipient( $post_id, $user_id );
}
self::add_taxonomies( $post_id, $activity['object'] );
return \get_post( $post_id );
}
/**
* Delete an object from the collection.
*
* @param int $id The object ID.
*
* @return \WP_Post|false|null Post data on success, false or null on failure.
*/
public static function delete( $id ) {
return \wp_delete_post( $id, true );
}
/**
* Delete an object from the collection by its GUID.
*
* @param string $guid The object GUID.
*
* @return \WP_Post|\WP_Error|false|null Post data on success, false or null on failure, or WP_Error if no post to delete.
*/
public static function delete_by_guid( $guid ) {
$post = self::get_by_guid( $guid );
if ( \is_wp_error( $post ) ) {
return $post;
}
return self::delete( $post->ID );
}
/**
* Extract hashtag names from ActivityPub tag array.
*
* @param array $tags Array of ActivityPub tags.
*
* @return array Array of normalized hashtag names (without # prefix, trimmed, sanitized).
*/
public static function extract_hashtags( $tags ) {
$hashtags = array();
if ( empty( $tags ) || ! \is_array( $tags ) ) {
return $hashtags;
}
foreach ( $tags as $tag ) {
if ( isset( $tag['type'] ) && 'Hashtag' === $tag['type'] && isset( $tag['name'] ) ) {
// Strip # prefix, trim whitespace, and sanitize.
$normalized = \trim( \ltrim( $tag['name'], '#' ) );
$normalized = \wp_strip_all_tags( $normalized );
if ( ! empty( $normalized ) ) {
$hashtags[] = $normalized;
}
}
}
return $hashtags;
}
/**
* Remove hashtags from content.
*
* Removes hashtags that appear at the end of the content.
* Handles both plain text and HTML content, including hashtags within anchor tags.
*
* @param string $content The content to process.
* @param array $tags Array of tag objects from activity (with 'type' and 'name' keys).
*
* @return string The content with trailing hashtags removed.
*/
public static function remove_hashtags( $content, $tags ) {
if ( empty( $content ) || empty( $tags ) || ! \is_array( $tags ) ) {
return $content;
}
// Extract and normalize hashtags from tag objects.
$normalized_tags = self::extract_hashtags( $tags );
if ( empty( $normalized_tags ) ) {
return $content;
}
// Build pattern to match trailing hashtags (at end of content or before closing tags).
$tag_patterns = array();
foreach ( $normalized_tags as $tag ) {
$escaped_tag = \preg_quote( $tag, '/' );
$tag_patterns[] = '(?:<a[^>]*>\s*)?#' . $escaped_tag . '(?=\s|<|$)(?:\s*<\/a>)?';
}
/*
* Pattern explanation:
* Match one or more hashtags (plain or in anchor tags) at the end of content.
* The pattern matches trailing hashtags before closing HTML tags or at end of string.
*/
$pattern = '/(?:\s+(?:' . \implode( '|', $tag_patterns ) . '))+(?=\s*(?:<\/[^>]+>)*\s*$)/i';
$content = \preg_replace( $pattern, '', $content );
// Clean up any extra whitespace at end of paragraphs.
$content = \preg_replace( '/<p>\s*<\/p>/', '', $content );
$content = \preg_replace( '/\s+<\/p>/', '</p>', $content );
$content = \preg_replace( '/\s+<\/strong>/', '</strong>', $content );
return \trim( $content );
}
/**
* Convert an activity to a post array.
*
* @param array $activity The activity array.
*
* @return array|\WP_Error The post array or WP_Error on failure.
*/
private static function activity_to_post( $activity ) {
if ( ! \is_array( $activity ) ) {
return new \WP_Error( 'invalid_activity', \__( 'Invalid activity format', 'activitypub' ) );
}
$gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $activity['published'] ?? 'now' ) );
// Sanitize content and remove hashtags.
$content = isset( $activity['content'] ) ? Sanitize::content( $activity['content'] ) : '';
$content = self::remove_hashtags( $content, $activity['tag'] ?? array() );
$content = Emoji::wrap_in_content( $content, $activity );
// Process remote media: wrap inline images and append attachments.
$attachments = self::extract_attachments( $activity );
$content = process_remote_media( $content, $attachments );
return array(
'post_title' => isset( $activity['name'] ) ? \wp_strip_all_tags( $activity['name'] ) : '',
'post_content' => $content,
'post_excerpt' => isset( $activity['summary'] ) ? \wp_strip_all_tags( $activity['summary'] ) : generate_post_summary( $activity['content'] ?? '' ),
'post_status' => 'publish',
'post_type' => self::POST_TYPE,
'post_date_gmt' => $gm_date,
'post_date' => \get_date_from_gmt( $gm_date ),
'guid' => isset( $activity['id'] ) ? \esc_url_raw( $activity['id'] ) : '',
);
}
/**
* Add taxonomies to the object post.
*
* @param int $post_id The post ID.
* @param array $activity_object The activity object data.
*/
private static function add_taxonomies( $post_id, $activity_object ) {
// Save Object Type as Taxonomy item.
\wp_set_post_terms( $post_id, array( $activity_object['type'] ), 'ap_object_type' );
// Save the Hashtags as Taxonomy items.
$tags = self::extract_hashtags( $activity_object['tag'] ?? array() );
\wp_set_post_terms( $post_id, $tags, 'ap_tag' );
}
/**
* Extract media attachments from an activity object.
*
* Extracts attachments with URL, alt text, and media type for appending to content.
*
* @param array $activity_object The activity object data.
*
* @return array Array of attachments with 'url', 'alt', and 'type' keys.
*/
private static function extract_attachments( $activity_object ) {
if ( empty( $activity_object['attachment'] ) || ! \is_array( $activity_object['attachment'] ) ) {
return array();
}
$attachments = array();
foreach ( $activity_object['attachment'] as $attachment ) {
if ( \is_object( $attachment ) ) {
$attachment = \get_object_vars( $attachment );
}
if ( empty( $attachment['url'] ) ) {
continue;
}
$mime_type = $attachment['mediaType'] ?? '';
if ( \str_starts_with( $mime_type, 'video/' ) ) {
$type = 'video';
} elseif ( \str_starts_with( $mime_type, 'audio/' ) ) {
$type = 'audio';
} else {
$type = 'image';
}
$attachments[] = array(
'url' => $attachment['url'],
'alt' => $attachment['name'] ?? '',
'type' => $type,
);
}
return $attachments;
}
/**
* Get posts by remote actor.
*
* @param string $actor The remote actor URI.
*
* @return array Array of WP_Post objects.
*/
public static function get_by_remote_actor( $actor ) {
$remote_actor = Remote_Actors::fetch_by_uri( $actor );
if ( \is_wp_error( $remote_actor ) ) {
return array();
}
return self::get_by_remote_actor_id( $remote_actor->ID );
}
/**
* Get posts by remote actor ID.
*
* @param int $actor_id The remote actor post ID.
*
* @return array Array of WP_Post objects.
*/
public static function get_by_remote_actor_id( $actor_id ) {
$query = new \WP_Query(
array(
'post_type' => self::POST_TYPE,
'posts_per_page' => -1,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_key' => '_activitypub_remote_actor_id',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'meta_value' => $actor_id,
)
);
return $query->posts;
}
/**
* Get all recipients for a post.
*
* @param int $post_id The post ID.
*
* @return int[] Array of user IDs who are recipients.
*/
public static function get_recipients( $post_id ) {
// Get all meta values with key '_activitypub_user_id' (single => false).
$recipients = \get_post_meta( $post_id, '_activitypub_user_id', false );
$recipients = \array_map( 'intval', $recipients );
return $recipients;
}
/**
* Check if a user is a recipient of a post.
*
* @param int $post_id The post ID.
* @param int $user_id The user ID to check.
*
* @return bool True if user is a recipient, false otherwise.
*/
public static function has_recipient( $post_id, $user_id ) {
$recipients = self::get_recipients( $post_id );
return \in_array( (int) $user_id, $recipients, true );
}
/**
* Add a recipient to an existing post.
*
* @param int $post_id The post ID.
* @param int $user_id The user ID to add.
*
* @return bool True on success, false on failure.
*/
public static function add_recipient( $post_id, $user_id ) {
$user_id = (int) $user_id;
// Allow 0 for blog user, but reject negative values.
if ( $user_id < 0 ) {
return false;
}
// Check if already a recipient.
if ( self::has_recipient( $post_id, $user_id ) ) {
return true;
}
// Add new recipient as separate meta entry.
return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
}
/**
* Add multiple recipients to an existing post.
*
* @param int $post_id The post ID.
* @param int[] $user_ids The user ID or array of user IDs to add.
*/
public static function add_recipients( $post_id, $user_ids ) {
foreach ( $user_ids as $user_id ) {
self::add_recipient( $post_id, $user_id );
}
}
/**
* Remove a recipient from a post.
*
* @param int $post_id The post ID.
* @param int $user_id The user ID to remove.
*
* @return bool True on success, false on failure.
*/
public static function remove_recipient( $post_id, $user_id ) {
$user_id = (int) $user_id;
// Allow 0 for blog user, but reject negative values.
if ( $user_id < 0 ) {
return false;
}
// Delete the specific meta entry with this value.
return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id );
}
/**
* Delete all posts.
*
* Used during plugin uninstall to clean up all remote posts.
*
* @return int The number of posts deleted.
*/
public static function delete_all() {
$post_ids = \get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => array( 'any', 'trash', 'auto-draft' ),
'fields' => 'ids',
'numberposts' => -1,
)
);
foreach ( $post_ids as $post_id ) {
\wp_delete_post( $post_id, true );
}
return count( $post_ids );
}
/**
* Purge old remote posts.
*
* Deletes remote posts older than the specified number of days,
* but preserves posts that have comments from local users
* as these indicate meaningful local interactions.
*
* @param int $days Number of days to keep items. Items older than this will be deleted.
*
* @return int The number of items deleted.
*/
public static function purge( $days ) {
if ( $days <= 0 ) {
return 0;
}
$counts = \wp_count_posts( self::POST_TYPE );
$total = 0;
foreach ( $counts as $count ) {
$total += (int) $count;
}
if ( $total <= 200 ) {
return 0;
}
global $wpdb;
$deleted = 0;
$cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) );
$start_time = \time();
$exclude = array();
// If total exceeds the hard cap, drop the date filter to purge oldest items first.
$overflow = $total > self::MAX_ITEMS;
$date_query = array(
array(
'before' => $cutoff,
),
);
$query_args = array(
'post_type' => self::POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'numberposts' => self::PURGE_BATCH_SIZE,
'orderby' => 'date',
'order' => 'ASC',
);
if ( ! $overflow ) {
$query_args['date_query'] = $date_query;
}
do {
$query_args['exclude'] = $exclude;
$post_ids = \get_posts( $query_args );
if ( empty( $post_ids ) ) {
break;
}
// Batch-fetch post IDs that have local user comments (single query per batch).
$placeholders = \implode( ',', \array_fill( 0, \count( $post_ids ), '%d' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$commented_post_ids = $wpdb->get_col(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders
$wpdb->prepare( "SELECT DISTINCT comment_post_ID FROM $wpdb->comments WHERE comment_post_ID IN ($placeholders) AND user_id > 0", $post_ids )
);
$commented_post_ids = \array_flip( $commented_post_ids );
foreach ( $post_ids as $post_id ) {
/**
* Filter whether to preserve a specific ap_post from being purged.
*
* @param bool $preserve Whether to preserve this post. Default false.
* @param int $post_id The ap_post ID being considered for deletion.
*
* @return bool Whether to preserve this post from deletion.
*/
if ( \apply_filters( 'activitypub_preserve_ap_post', false, $post_id ) ) {
$exclude[] = $post_id;
continue;
}
// Preserve posts with comments from local users.
if ( isset( $commented_post_ids[ $post_id ] ) ) {
$exclude[] = $post_id;
continue;
}
\wp_delete_post( $post_id, true );
++$deleted;
}
// Once we're back under the cap, re-apply the date filter.
if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
$overflow = false;
$query_args['date_query'] = $date_query;
}
} while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
return $deleted;
}
}

View File

@ -7,18 +7,14 @@
namespace Activitypub\Collection;
use WP_Post;
use WP_Comment;
use WP_Error;
use Activitypub\Comment;
use Activitypub\Model\Blog;
use Activitypub\Transformer\Post as PostTransformer;
use Activitypub\Transformer\Comment as CommentTransformer;
use Activitypub\Transformer\Comment as Comment_Transformer;
use Activitypub\Transformer\Post as Post_Transformer;
use function Activitypub\is_post_disabled;
use function Activitypub\is_local_comment;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\is_local_comment;
use function Activitypub\is_post_publicly_queryable;
use function Activitypub\is_user_type_disabled;
/**
@ -28,7 +24,7 @@ class Replies {
/**
* Build base arguments for fetching the comments of either a WordPress post or comment.
*
* @param WP_Post|WP_Comment|WP_Error $wp_object The post or comment to fetch replies for on success.
* @param \WP_Post|\WP_Comment|\WP_Error $wp_object The post or comment to fetch replies for on success.
*/
private static function build_args( $wp_object ) {
$args = array(
@ -38,13 +34,13 @@ class Replies {
'type' => 'comment',
);
if ( $wp_object instanceof WP_Post ) {
if ( $wp_object instanceof \WP_Post ) {
$args['parent'] = 0; // TODO: maybe this is unnecessary.
$args['post_id'] = $wp_object->ID;
} elseif ( $wp_object instanceof WP_Comment ) {
} elseif ( $wp_object instanceof \WP_Comment ) {
$args['parent'] = $wp_object->comment_ID;
} else {
return new WP_Error();
return new \WP_Error();
}
return $args;
@ -53,24 +49,24 @@ class Replies {
/**
* Get the replies collections ID.
*
* @param WP_Post|WP_Comment $wp_object The post or comment to fetch replies for.
* @param \WP_Post|\WP_Comment $wp_object The post or comment to fetch replies for.
*
* @return string|WP_Error The rest URL of the replies collection or WP_Error if the object is not a post or comment.
* @return string|\WP_Error The rest URL of the replies collection or WP_Error if the object is not a post or comment.
*/
private static function get_id( $wp_object ) {
if ( $wp_object instanceof WP_Post ) {
if ( $wp_object instanceof \WP_Post ) {
return get_rest_url_by_path( sprintf( 'posts/%d/replies', $wp_object->ID ) );
} elseif ( $wp_object instanceof WP_Comment ) {
} elseif ( $wp_object instanceof \WP_Comment ) {
return get_rest_url_by_path( sprintf( 'comments/%d/replies', $wp_object->comment_ID ) );
} else {
return new WP_Error( 'unsupported_object', 'The object is not a post or comment.' );
return new \WP_Error( 'unsupported_object', 'The object is not a post or comment.' );
}
}
/**
* Get the Replies collection.
*
* @param WP_Post|WP_Comment $wp_object The post or comment to fetch replies for.
* @param \WP_Post|\WP_Comment $wp_object The post or comment to fetch replies for.
*
* @return array|\WP_Error|null An associative array containing the replies collection without JSON-LD context on success.
*/
@ -96,11 +92,11 @@ class Replies {
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage
*
* @param WP_Post|WP_Comment $wp_object The post of comment the replies are for.
* @param int $page The current pagination page.
* @param string $part_of Optional. The collection id/url the returned CollectionPage belongs to. Default null.
* @param \WP_Post|\WP_Comment $wp_object The post of comment the replies are for.
* @param int $page The current pagination page.
* @param string $part_of Optional. The collection id/url the returned CollectionPage belongs to. Default null.
*
* @return array|WP_Error|null A CollectionPage as an associative array on success, WP_Error or null on failure.
* @return array|\WP_Error|null A CollectionPage as an associative array on success, WP_Error or null on failure.
*/
public static function get_collection_page( $wp_object, $page, $part_of = null ) {
// Build initial arguments for fetching approved comments.
@ -156,7 +152,7 @@ class Replies {
public static function get_context_collection( $post_id ) {
$post = \get_post( $post_id );
if ( ! $post || is_post_disabled( $post_id ) ) {
if ( ! is_post_publicly_queryable( $post_id ) ) {
return false;
}
@ -170,7 +166,7 @@ class Replies {
)
);
$ids = self::get_reply_ids( $comments, true );
$post_uri = ( new PostTransformer( $post ) )->to_id();
$post_uri = ( new Post_Transformer( $post ) )->to_id();
\array_unshift( $ids, $post_uri );
$author = Actors::get_by_id( $post->post_author );
@ -197,8 +193,8 @@ class Replies {
* It takes only federated/non-local comments into account, others also do not have an
* ActivityPub ID available.
*
* @param WP_Comment[] $comments The comments to retrieve the ActivityPub ids from.
* @param boolean $include_blog_comments Optional. Include blog comments in the returned array. Default false.
* @param \WP_Comment[] $comments The comments to retrieve the ActivityPub ids from.
* @param boolean $include_blog_comments Optional. Include blog comments in the returned array. Default false.
*
* @return string[] A list of the ActivityPub ID's.
*/
@ -217,7 +213,7 @@ class Replies {
}
if ( $include_blog_comments ) {
$comment_ids[] = ( new CommentTransformer( $comment ) )->to_id();
$comment_ids[] = ( new Comment_Transformer( $comment ) )->to_id();
}
}

View File

@ -1,78 +0,0 @@
<?php
/**
* Users collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
/**
* Users collection.
*
* @deprecated version 4.2.0
*/
class Users extends Actors {
/**
* Get the User by ID.
*
* @param int $user_id The User-ID.
*
* @return User|Blog|Application|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_id( $user_id ) {
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_id' );
return parent::get_by_id( $user_id );
}
/**
* Get the User by username.
*
* @param string $username The User-Name.
*
* @return User|Blog|Application|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_username( $username ) {
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_username' );
return parent::get_by_username( $username );
}
/**
* Get the User by resource.
*
* @param string $uri The User-Resource.
*
* @return User|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_resource( $uri ) {
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_resource' );
return parent::get_by_resource( $uri );
}
/**
* Get the User by resource.
*
* @param string $id The User-Resource.
*
* @return User|Blog|Application|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_various( $id ) {
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_various' );
return parent::get_by_various( $id );
}
/**
* Get the User collection.
*
* @return array The User collection.
*/
public static function get_collection() {
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_collection' );
return parent::get_collection();
}
}