updated plugin ActivityPub version 8.3.0
This commit is contained in:
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 );
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 );
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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() );
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user