updated plugin ActivityPub version 9.1.0

This commit is contained in:
2026-07-28 15:03:10 +00:00
committed by Gitium
parent bf428f0e45
commit ff806e1811
217 changed files with 6098 additions and 3025 deletions

View File

@ -8,9 +8,10 @@
namespace Activitypub\Collection;
use Activitypub\Activity\Actor;
use Activitypub\Model\Application;
use Activitypub\Application;
use Activitypub\Model\Blog;
use Activitypub\Model\User;
use Activitypub\Signature;
use function Activitypub\is_user_type_disabled;
use function Activitypub\normalize_host;
@ -33,7 +34,9 @@ class Actors {
const BLOG_USER_ID = 0;
/**
* The ID of the Application User.
* The ID of the former Application user.
*
* @deprecated 9.1.0 The Application is no longer a user-like actor, see {@see \Activitypub\Application}. Retained for backward compatibility and legacy data handling.
*
* @var int
*/
@ -47,7 +50,7 @@ class Actors {
* @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 ) ) {
if ( \is_numeric( $user_id ) ) {
$user_id = (int) $user_id;
}
@ -79,8 +82,6 @@ class Actors {
switch ( $user_id ) {
case self::BLOG_USER_ID:
return new Blog();
case self::APPLICATION_USER_ID:
return new Application();
default:
return User::from_wp_user( $user_id );
}
@ -100,7 +101,7 @@ class Actors {
* @param null $pre The pre-existing value.
* @param string $username The username.
*/
$pre = apply_filters( 'activitypub_pre_get_by_username', null, $username );
$pre = \apply_filters( 'activitypub_pre_get_by_username', null, $username );
if ( null !== $pre ) {
return $pre;
}
@ -137,9 +138,19 @@ class Actors {
return self::BLOG_USER_ID;
}
// Check for application user.
if ( 'application' === $username ) {
return self::APPLICATION_USER_ID;
/*
* The 'application' identifier is reserved for the signing-only Application
* actor, which is served only through the dedicated /application endpoint.
* Never resolve it to a regular user, even on sites that happen to have a
* user named "application". Compare lowercased: the user lookups below are
* case-insensitive, so the reservation has to be too.
*/
if ( Application::USERNAME === \strtolower( $username ) ) {
return new \WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
// Check for 'activitypub_username' meta.
@ -165,7 +176,7 @@ class Actors {
return \current( $user->get_results() );
}
$username = str_replace( array( '*', '%' ), '', $username );
$username = \str_replace( array( '*', '%' ), '', $username );
// Check for login or nicename.
$user = new \WP_User_Query(
@ -227,7 +238,7 @@ class Actors {
$scheme = 'acct';
$match = array();
// Try to extract the scheme and the host.
if ( preg_match( '/^([a-zA-Z^:]+):(.*)$/i', $uri, $match ) ) {
if ( \preg_match( '/^([a-zA-Z^:]+):(.*)$/i', $uri, $match ) ) {
// Extract the scheme.
$scheme = \esc_attr( $match[1] );
}
@ -251,7 +262,7 @@ class Actors {
$resource_path = \trim( $resource_path, '/' );
if ( str_starts_with( $resource_path, '@' ) ) {
if ( \str_starts_with( $resource_path, '@' ) ) {
$identifier = \str_replace( '@', '', $resource_path );
$identifier = \trim( $identifier, '/' );
@ -270,8 +281,8 @@ class Actors {
$normalized_uri = normalize_url( $uri );
if (
normalize_url( site_url() ) === $normalized_uri ||
normalize_url( home_url() ) === $normalized_uri
normalize_url( \site_url() ) === $normalized_uri ||
normalize_url( \home_url() ) === $normalized_uri
) {
return self::BLOG_USER_ID;
}
@ -288,7 +299,7 @@ class Actors {
$host = normalize_host( \substr( \strrchr( $uri, '@' ), 1 ) );
$blog_host = normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) );
if ( $blog_host !== $host && get_option( 'activitypub_old_host' ) !== $host ) {
if ( $blog_host !== $host && normalize_host( \get_option( 'activitypub_old_host' ) ) !== $host ) {
return new \WP_Error(
'activitypub_wrong_host',
\__( 'Resource host does not match blog host', 'activitypub' ),
@ -297,7 +308,7 @@ class Actors {
}
// Prepare wildcards https://github.com/mastodon/mastodon/issues/22213.
if ( in_array( $identifier, array( '_', '*', '' ), true ) ) {
if ( \in_array( $identifier, array( '_', '*', '' ), true ) ) {
return self::BLOG_USER_ID;
}
@ -335,15 +346,15 @@ class Actors {
* @return int|\WP_Error Actor id or WP_Error if not found.
*/
public static function get_id_by_various( $id ) {
if ( is_numeric( $id ) ) {
if ( \is_numeric( $id ) ) {
$id = (int) $id;
} elseif (
// Is URL.
filter_var( $id, FILTER_VALIDATE_URL ) ||
\filter_var( $id, FILTER_VALIDATE_URL ) ||
// Is acct.
str_starts_with( $id, 'acct:' ) ||
\str_starts_with( $id, 'acct:' ) ||
// Is email.
filter_var( $id, FILTER_VALIDATE_EMAIL )
\filter_var( $id, FILTER_VALIDATE_EMAIL )
) {
$id = self::get_id_by_resource( $id );
} else {
@ -384,6 +395,54 @@ class Actors {
return $return;
}
/**
* Search local actors by name or username.
*
* Backs the actor autocomplete endpoint. Matches the search term against the WordPress user's
* login, nicename, and display name; the Blog actor is included when its name or identifier
* matches. Disabled actor types are excluded, mirroring get_collection()/get_all().
*
* @since 9.1.0
*
* @param string $query The search term.
* @param int $number Optional. Maximum number of actors to return. Default 10.
*
* @return Actor[] The matching local actor objects.
*/
public static function search( $query, $number = 10 ) {
$actors = array();
if ( ! is_user_type_disabled( 'blog' ) ) {
$blog = new Blog();
if ( false !== \stripos( $blog->get_name(), $query ) || false !== \stripos( (string) $blog->get_preferred_username(), $query ) ) {
$actors[] = $blog;
}
}
// Only query as many users as the Blog actor left room for, so a match is never fetched then trimmed.
$remaining = $number - \count( $actors );
if ( $remaining > 0 && ! is_user_type_disabled( 'user' ) ) {
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
'search' => '*' . $query . '*',
'search_columns' => array( 'user_login', 'user_nicename', 'display_name' ),
'number' => $remaining,
)
);
foreach ( $users as $user ) {
$actor = User::from_wp_user( $user->ID );
if ( ! \is_wp_error( $actor ) ) {
$actors[] = $actor;
}
}
}
return $actors;
}
/**
* Get all active actors, including the Blog actor if enabled.
*
@ -406,7 +465,7 @@ class Actors {
$user_ids[] = self::BLOG_USER_ID;
}
return array_map( 'intval', $user_ids );
return \array_map( 'intval', $user_ids );
}
/**
@ -417,10 +476,10 @@ class Actors {
public static function get_all() {
$user_ids = self::get_all_ids();
$actors = array_map( array( self::class, 'get_by_id' ), $user_ids );
$actors = \array_map( array( self::class, 'get_by_id' ), $user_ids );
// Filter out any WP_Error instances.
return array_filter(
return \array_filter(
$actors,
static function ( $actor ) {
return ! \is_wp_error( $actor );
@ -433,15 +492,11 @@ class Actors {
*
* @param int $user_id The user ID to check.
*
* @return string Actor type: 'user', 'blog', or 'application'.
* @return string Actor type: 'user' or 'blog'.
*/
public static function get_type_by_id( $user_id ) {
$user_id = (int) $user_id;
if ( self::APPLICATION_USER_ID === $user_id ) {
return 'application';
}
if ( self::BLOG_USER_ID === $user_id ) {
return 'blog';
}
@ -453,13 +508,13 @@ class Actors {
* 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.
* @param bool $force Deprecated. Keys are never rotated; new pairs are only generated when none is stored.
*
* @return string The public key.
*/
public static function get_public_key( $user_id, $force = false ) {
if ( $force ) {
self::generate_key_pair( $user_id );
\_deprecated_argument( __METHOD__, '9.1.0', \esc_html__( 'Keys are never rotated; new pairs are only generated when none is stored.', 'activitypub' ) );
}
$key_pair = self::get_keypair( $user_id );
@ -471,13 +526,13 @@ class Actors {
* 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.
* @param bool $force Deprecated. Keys are never rotated; new pairs are only generated when none is stored.
*
* @return string The private key.
*/
public static function get_private_key( $user_id, $force = false ) {
if ( $force ) {
self::generate_key_pair( $user_id );
\_deprecated_argument( __METHOD__, '9.1.0', \esc_html__( 'Keys are never rotated; new pairs are only generated when none is stored.', 'activitypub' ) );
}
$key_pair = self::get_keypair( $user_id );
@ -493,68 +548,12 @@ class Actors {
* @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,
return Signature::get_key_pair(
self::get_signature_options_key( $user_id ),
function () use ( $user_id ) {
return self::check_legacy_key_pair( $user_id );
}
);
$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;
}
/**
@ -587,17 +586,13 @@ class Actors {
$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 ) ) {
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,
@ -607,234 +602,6 @@ class Actors {
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.
*

View File

@ -41,13 +41,13 @@ class Extra_Fields {
}
// Limit to 20 fields to prevent response size issues.
if ( ! is_admin() ) {
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['posts_per_page'] = \apply_filters( 'activitypub_actor_extra_fields_limit', 20 );
$args['nopaging'] = false;
}
@ -63,7 +63,7 @@ class Extra_Fields {
* @param \WP_Post[] $fields Array of WP_Post objects representing the extra fields.
* @param int $user_id The ID of the user whose fields are being retrieved.
*/
return apply_filters( 'activitypub_get_actor_extra_fields', $fields, $user_id );
return \apply_filters( 'activitypub_get_actor_extra_fields', $fields, $user_id );
}
/**
@ -132,7 +132,7 @@ class Extra_Fields {
$attachment = array(
'type' => 'Link',
'name' => $title,
'href' => \esc_url( $tags->get_attribute( 'href' ) ),
'href' => \esc_url_raw( $tags->get_attribute( 'href' ) ),
);
$rel = $tags->get_attribute( 'rel' );
@ -257,8 +257,8 @@ class Extra_Fields {
);
$menu_order += 10;
$extra_field_id = wp_insert_post( $extra_field );
$extra_fields[] = get_post( $extra_field_id );
$extra_field_id = \wp_insert_post( $extra_field );
$extra_fields[] = \get_post( $extra_field_id );
}
$is_blog

View File

@ -12,6 +12,8 @@ use Activitypub\Tombstone;
use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\is_same_host;
use function Activitypub\object_to_uri;
/**
* ActivityPub Followers Collection.
@ -50,7 +52,20 @@ class Followers {
}
if ( empty( $meta ) || ! \is_array( $meta ) || \is_wp_error( $meta ) ) {
return new \WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) );
return new \WP_Error( 'activitypub_invalid_follower', \__( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) );
}
/*
* The signed sender ($actor) must be the actor we actually resolved. A document that
* declares an id on a different host than the one that sent the Follow would
* otherwise record a third party, who never sent the Follow, as a follower.
*/
if ( ! is_same_host( $actor, object_to_uri( $meta ) ) ) {
return new \WP_Error(
'activitypub_follower_host_mismatch',
\__( 'The follower does not match the actor that sent the request.', 'activitypub' ),
array( 'status' => 403 )
);
}
$post_id = Remote_Actors::upsert( $meta );
@ -114,28 +129,6 @@ class Followers {
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 ) {
\_deprecated_function( __METHOD__, '7.1.0', 'Activitypub\Collection\Followers::remove' );
$remote_actor = self::get_by_uri( $user_id, $actor );
if ( \is_wp_error( $remote_actor ) ) {
return false;
}
return self::remove( $remote_actor->ID, $user_id );
}
/**
* Get a Follower by URI.
*
@ -182,25 +175,10 @@ class Followers {
* @return \WP_Post|\WP_Error The Follower object or WP_Error on failure.
*/
public static function get_follower( $user_id, $actor ) {
_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Followers::get_by_uri' );
\_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 \WP_Post|\WP_Error The Follower object or WP_Error on failure.
*/
public static function get_follower_by_actor( $actor ) {
\_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::get_by_uri' );
return Remote_Actors::get_by_uri( $actor );
}
/**
* Get many followers.
*
@ -230,7 +208,7 @@ class Followers {
* @return \WP_Post[] List of `Follower` objects.
*/
public static function get_followers( $user_id, $number = -1, $page = null, $args = array() ) {
_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Followers::get_many' );
\_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Followers::get_many' );
return self::get_many( $user_id, $number, $page, $args );
}
@ -441,126 +419,6 @@ class Followers {
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;
}
// Only if this isn't the Blog Actor.
if ( Actors::BLOG_USER_ID === $actor_id ) {
return false;
}
$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 ) ) {
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 \WP_Post[] The list of Actors.
*/
public static function get_outdated_followers( $number = 50, $older_than = 86400 ) {
_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Remote_Actors::get_outdated' );
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 \WP_Post[] The list of Actors.
*/
public static function get_faulty_followers( $number = 20 ) {
_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Remote_Actors::get_faulty' );
return Remote_Actors::get_faulty( $number );
}
/**
* This function is used to store errors that occur when
* sending an ActivityPub message to a Follower.
*
* 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 ) {
\_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.
*
@ -682,19 +540,19 @@ class Followers {
}
// Build the collection ID (followers collection URL).
$collection_id = get_rest_url_by_path( sprintf( 'actors/%d/followers', $user_id ) );
$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(
\sprintf(
'actors/%d/followers/sync?authority=%s',
$user_id,
rawurlencode( $authority )
\rawurlencode( $authority )
)
);
// Format as per FEP-8fcf (similar to HTTP Signatures format).
return sprintf(
return \sprintf(
'collectionId="%s", url="%s", digest="%s"',
$collection_id,
$url,

View File

@ -69,7 +69,7 @@ class Following {
return new \WP_Error( 'activitypub_remote_actor_not_found', 'Remote actor not found' );
}
$all_meta = get_post_meta( $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();
@ -190,7 +190,7 @@ class Following {
$post = \get_post( $post );
if ( ! $post ) {
return new \WP_Error( 'activitypub_remote_actor_not_found', __( 'Remote actor not found', 'activitypub' ) );
return new \WP_Error( 'activitypub_remote_actor_not_found', \__( 'Remote actor not found', 'activitypub' ) );
}
$actor_type = Actors::get_type_by_id( $user_id );
@ -492,7 +492,7 @@ class Following {
* @return string|false The status of the following.
*/
public static function check_status( $user_id, $post_id ) {
$all_meta = get_post_meta( $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();
@ -521,11 +521,11 @@ class Following {
}
$user_ids = \get_post_meta( $actor->ID, self::FOLLOWING_META_KEY, false );
if ( ! is_array( $user_ids ) || empty( $user_ids ) ) {
if ( ! \is_array( $user_ids ) || empty( $user_ids ) ) {
return array();
}
return array_map( 'intval', $user_ids );
return \array_map( 'intval', $user_ids );
}
/**

View File

@ -117,14 +117,14 @@ class Inbox {
$inbox_item = array(
'post_type' => self::POST_TYPE,
'post_title' => sprintf(
'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_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(),
@ -166,7 +166,7 @@ class Inbox {
* @return string The title.
*/
private static function get_object_title( $activity_object ) {
if ( ! $activity_object || is_array( $activity_object ) ) {
if ( ! $activity_object || \is_array( $activity_object ) ) {
return '';
}
@ -228,11 +228,14 @@ class Inbox {
/**
* Undo a received activity.
*
* @param string $id The ID of the inbox item to be removed.
* @param string $id The ID of the inbox item to be removed.
* @param string|null $actor Optional. The actor URI of the Undo sender. When provided, the
* activity is only undone if this actor created the original
* activity. Default null.
*
* @return bool|\WP_Error True on success, WP_Error on failure.
*/
public static function undo( $id ) {
public static function undo( $id, $actor = null ) {
$inbox_item = self::get_by_guid( $id );
if ( \is_wp_error( $inbox_item ) ) {
@ -240,6 +243,23 @@ class Inbox {
return $inbox_item;
}
/*
* Only the actor that created the original activity may undo it. Without this
* binding a remote server could undo (and, for interactions, force-delete the
* comment behind) any activity whose public id it knows but does not own.
*
* Items stored before this meta existed have no actor recorded; those are let
* through for backward compatibility rather than becoming permanently un-undoable.
*/
$stored_actor = \get_post_meta( $inbox_item->ID, '_activitypub_activity_remote_actor', true );
if ( null !== $actor && $stored_actor && object_to_uri( $actor ) !== $stored_actor ) {
return new \WP_Error(
'activitypub_inbox_undo_forbidden',
\__( 'Undo is not possible because the actor does not own the activity.', 'activitypub' ),
array( 'status' => 403 )
);
}
$type = \get_post_meta( $inbox_item->ID, '_activitypub_activity_type', true );
switch ( $type ) {
@ -266,7 +286,7 @@ class Inbox {
);
}
$result = Comment::object_id_to_comment( esc_url_raw( $inbox_item->guid ) );
$result = Comment::object_id_to_comment( \esc_url_raw( $inbox_item->guid ) );
if ( empty( $result ) ) {
return new \WP_Error(
@ -474,7 +494,7 @@ class Inbox {
}
// Keep the first (oldest) post as primary.
$primary_id = array_shift( $post_ids );
$primary_id = \array_shift( $post_ids );
$primary = \get_post( $primary_id );
// Merge recipients from duplicates into primary and delete duplicates.

View File

@ -10,11 +10,11 @@ namespace Activitypub\Collection;
use Activitypub\Comment;
use Activitypub\Emoji;
use Activitypub\Webfinger;
use WP_Comment_Query;
use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\is_ap_post;
use function Activitypub\is_post_disabled;
use function Activitypub\is_same_host;
use function Activitypub\object_id_to_comment;
use function Activitypub\object_to_uri;
use function Activitypub\url_to_commentid;
@ -38,6 +38,17 @@ class Interactions {
* @return int|false|\WP_Error The comment ID or false or WP_Error on failure.
*/
public static function add_comment( $activity, $user_id = null ) {
/*
* A remote comment is stored under its object id (source_id); that id must be on
* the signature-verified actor's host. Otherwise a remote server could file a
* comment whose recorded id points at a different host, mis-recording its
* provenance and taking over that id (the update owner-check would then reject the
* genuine author). Local outbox replies ($user_id set) are trusted.
*/
if ( null === $user_id && ! is_same_host( $activity['actor'] ?? '', $activity['object'] ?? '' ) ) {
return false;
}
$comment_data = self::activity_to_comment( $activity, $user_id );
if ( ! $comment_data ) {
@ -120,6 +131,29 @@ class Interactions {
return false;
}
/*
* Only the comment's author may update it. The comment maps to the remote actor that
* created it via _activitypub_remote_actor_id; that actor post's guid is the
* (signature-bound) actor URI. The Update's actor must match it, otherwise a remote
* server could rewrite another actor's comment by sending an Update whose object.id
* points at it.
*
* Comments created before this mapping existed have no owner recorded; those are let
* through for backward compatibility (matching the Undo path) rather than becoming
* permanently un-editable. On mismatch, return a WP_Error rather than false: false would
* make the Update handler fall back to Create (which re-dispatches to Update for an
* existing comment and recurses), while the unchanged comment array would be read as a
* successful update and relayed onward. A WP_Error is handled but unsuccessful: no Create
* fallback, and the handled-update success flag stays false.
*/
$owner = \get_post( (int) \get_comment_meta( $comment_data['comment_ID'], '_activitypub_remote_actor_id', true ) );
if ( $owner instanceof \WP_Post && object_to_uri( $activity['actor'] ) !== $owner->guid ) {
return new \WP_Error(
'activitypub_update_forbidden',
\__( 'The Update actor does not own the target comment.', 'activitypub' )
);
}
// Found a local comment id.
$comment_data['comment_author'] = \sanitize_text_field( empty( $meta['name'] ) ? $meta['preferredUsername'] : $meta['name'] );
@ -141,6 +175,15 @@ class Interactions {
* @return array|string|int|\WP_Error|false Comment data or `false` on failure.
*/
public static function add_reaction( $activity ) {
/*
* The reaction is stored under its own id (source_id); that id must be on the
* signature-verified actor's host, so a remote server cannot file a reaction
* whose recorded id points at a different host and take over that id.
*/
if ( ! is_same_host( $activity['actor'] ?? '', $activity['id'] ?? '' ) ) {
return false;
}
$url = object_to_uri( $activity['object'] );
$comment_post_id = \url_to_postid( $url );
$parent_comment_id = url_to_commentid( $url );
@ -214,7 +257,7 @@ class Interactions {
),
);
$query = new WP_Comment_Query( $args );
$query = new \WP_Comment_Query( $args );
return $query->comments;
}
@ -244,7 +287,7 @@ class Interactions {
$meta = get_remote_metadata_by_actor( $actor );
// Get URL, because $actor seems to be the ID.
if ( $meta && ! is_wp_error( $meta ) && isset( $meta['url'] ) ) {
if ( $meta && ! \is_wp_error( $meta ) && isset( $meta['url'] ) ) {
$actor = object_to_uri( $meta['url'] );
}
@ -323,17 +366,17 @@ class Interactions {
}
// Add `p` and `br` to the list of allowed tags.
if ( ! array_key_exists( 'br', $allowed_tags ) ) {
if ( ! \array_key_exists( 'br', $allowed_tags ) ) {
$allowed_tags['br'] = array();
}
if ( ! array_key_exists( 'p', $allowed_tags ) ) {
if ( ! \array_key_exists( 'p', $allowed_tags ) ) {
$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 ) ) {
if ( ! \array_key_exists( 'img', $allowed_tags ) ) {
$allowed_tags['img'] = $emoji_html['img'];
}
@ -371,7 +414,7 @@ class Interactions {
$actor = object_to_uri( $activity['actor'] ?? null );
$actor = get_remote_metadata_by_actor( $actor );
if ( ! $actor || is_wp_error( $actor ) ) {
if ( ! $actor || \is_wp_error( $actor ) ) {
return false;
}
@ -386,14 +429,14 @@ class Interactions {
return false;
}
$comment_author = $comment_author ?? __( 'Anonymous', 'activitypub' );
$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 ) ) {
if ( \is_wp_error( $webfinger ) ) {
$comment_author_email = '';
} else {
$comment_author_email = str_replace( 'acct:', '', $webfinger );
$comment_author_email = \str_replace( 'acct:', '', $webfinger );
}
if ( isset( $activity['object']['content'] ) ) {

View File

@ -9,6 +9,7 @@ namespace Activitypub\Collection;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use Activitypub\OAuth\Server;
use Activitypub\Scheduler;
use Activitypub\Webfinger;
@ -101,14 +102,14 @@ class Outbox {
$outbox_item = array(
'post_type' => self::POST_TYPE,
'post_title' => sprintf(
'post_title' => \sprintf(
/* translators: 1. Activity type, 2. Object Title or Excerpt */
__( '[%1$s] %2$s', 'activitypub' ),
\__( '[%1$s] %2$s', 'activitypub' ),
$activity->get_type(),
\wp_trim_words( $title, 5 )
),
// Persist the blind audience so later dispatch can compute recipients from `bto`/`bcc`.
'post_content' => wp_slash( $activity->to_json( true, true ) ),
'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',
@ -185,7 +186,7 @@ class Outbox {
* 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 ) ) {
if ( \in_array( $activity_type, array( 'Follow', 'Announce', 'Accept', 'Reject' ), true ) ) {
return;
}
@ -196,20 +197,43 @@ class Outbox {
),
);
// For non-Delete activities, only delete items of the same type.
// Delete activities supersede all pending items for the same object.
/*
* Same-type pending items are always superseded. A confirmed
* re-publish (Create) additionally invalidates a pending Delete so
* we do not send both Delete and Create for the same object.
*
* Update is intentionally NOT in this list: it must not cancel a
* pending Delete, or an unrelated edit could flip a hidden object back
* to federated. An Update for an already-deleted object is rejected
* upstream in `add_to_outbox()`, and the scheduler re-publish path emits
* a Create (not an Update), so that legitimate path still cancels Delete.
*/
if ( 'Delete' !== $activity_type ) {
$types = 'Create' === $activity_type
? array( 'Create', 'Delete' )
: array( $activity_type );
$meta_query[] = array(
'key' => '_activitypub_activity_type',
'value' => $activity_type,
'key' => '_activitypub_activity_type',
'value' => $types,
'compare' => 'IN',
);
}
$existing_items = get_posts(
/*
* Delete wipes the entire outbox history for the object — any
* already-sent Create/Update/etc. is now stale and a redelivery
* retry would resurrect content we are tearing down. Other
* activity types only invalidate pending peers.
*/
$status_filter = 'Delete' === $activity_type ? 'any' : 'pending';
$existing_items = \get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'pending',
'post_status' => $status_filter,
'exclude' => array( $exclude_id ),
'numberposts' => -1,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => $meta_query,
'fields' => 'ids',
@ -317,12 +341,12 @@ class Outbox {
* @return bool True if the activity was rescheduled, false otherwise.
*/
public static function reschedule( $outbox_item ) {
$outbox_item = get_post( $outbox_item );
$outbox_item = \get_post( $outbox_item );
$outbox_item->post_status = 'pending';
$outbox_item->post_date = current_time( 'mysql' );
$outbox_item->post_date = \current_time( 'mysql' );
wp_update_post( $outbox_item );
\wp_update_post( $outbox_item );
Scheduler::schedule_outbox_activity_for_federation( $outbox_item->ID );
@ -373,7 +397,7 @@ class Outbox {
}
if ( 'Update' === $type ) {
$activity->set_updated( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_modified ) ) );
$activity->set_updated( \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, \strtotime( $outbox_item->post_modified ) ) );
}
/**
@ -382,7 +406,7 @@ class Outbox {
* @param Activity $activity The Activity object.
* @param \WP_Post $outbox_item The outbox item post object.
*/
return apply_filters( 'activitypub_get_outbox_activity', $activity, $outbox_item );
return \apply_filters( 'activitypub_get_outbox_activity', $activity, $outbox_item );
}
/**
@ -399,9 +423,6 @@ class Outbox {
case 'blog':
$actor_id = Actors::BLOG_USER_ID;
break;
case 'application':
$actor_id = Actors::APPLICATION_USER_ID;
break;
case 'user':
default:
$actor_id = $outbox_item->post_author;
@ -429,7 +450,7 @@ class Outbox {
// 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 );
Server::authenticate_oauth( null );
}
/*
@ -456,14 +477,14 @@ class Outbox {
// Check if Outbox Activity is public.
$visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
if ( ! in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ), true ) ) {
if ( ! \in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ), true ) ) {
return new \WP_Error( 'private_outbox_item', 'Not a public Outbox item.' );
}
$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 ) ) {
if ( ! \in_array( $activity_type, $activity_types, true ) ) {
return new \WP_Error( 'private_outbox_item', 'Not public Outbox item type.' );
}
@ -480,11 +501,11 @@ class Outbox {
private static function get_object_id( $data ) {
$object = $data->get_object();
if ( is_object( $object ) ) {
if ( \is_object( $object ) ) {
return self::get_object_id( $object );
}
if ( is_string( $object ) ) {
if ( \is_string( $object ) ) {
return $object;
}
@ -507,10 +528,10 @@ class Outbox {
return '';
}
if ( is_string( $activity_object ) ) {
$post_id = url_to_postid( $activity_object );
if ( \is_string( $activity_object ) ) {
$post_id = \url_to_postid( $activity_object );
return $post_id ? get_the_title( $post_id ) : '';
return $post_id ? \get_the_title( $post_id ) : '';
}
$title = $activity_object->get_name() ?: $activity_object->get_content();

View File

@ -14,6 +14,7 @@ use Activitypub\Sanitize;
use Activitypub\Webfinger;
use function Activitypub\is_actor;
use function Activitypub\is_same_host;
use function Activitypub\object_to_uri;
/**
@ -80,6 +81,11 @@ class Remote_Actors {
/**
* Upsert (insert or update) a remote actor as a custom post type.
*
* The actor is looked up and stored under its own `id`. Callers that obtain
* the actor from an untrusted fetch MUST fetch it via {@see Http::get_remote_object()},
* which self-confirms the document is served under its own id, so a document
* claiming another actor's id can never reach this method.
*
* @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.
@ -203,8 +209,8 @@ class Remote_Actors {
$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 )
\esc_sql( $actor_uri ),
\esc_sql( self::POST_TYPE )
)
);
@ -228,6 +234,57 @@ class Remote_Actors {
return $post;
}
/**
* Search cached remote actors by name, handle, or actor URI.
*
* Backs the actor autocomplete endpoint. Matches the search term against the stored post title
* (the actor's name or preferred username) and the webfinger handle (user@host), newest first.
* The actor URI (guid) is matched only when the query itself looks like a URL: every URI shares
* the same scheme and host shape, so matching it for a short term would return nearly every
* cached actor.
*
* @since 9.1.0
*
* @param string $query The search term.
* @param int $number Optional. Maximum number of actors to return. Default 10.
*
* @return \WP_Post[] The matching remote actor posts.
*/
public static function search( $query, $number = 10 ) {
global $wpdb;
$like = '%' . $wpdb->esc_like( $query ) . '%';
$conditions = 'p.post_title LIKE %s OR acct.meta_value LIKE %s';
$params = array( self::POST_TYPE, $like, $like );
// Match the actor URI only for URL-like queries, so short terms don't match every https:// guid.
if ( \str_contains( $query, '://' ) ) {
$conditions .= ' OR p.guid LIKE %s';
$params[] = $like;
}
$params[] = $number;
/*
* Select full rows so get_post() hydrates each in place instead of re-querying per ID. The
* interpolated $conditions is built from the literal fragments above, not from user input,
* and the placeholder count is dynamic because the guid clause is optional.
*/
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$posts = $wpdb->get_results(
$wpdb->prepare(
"SELECT DISTINCT p.* FROM $wpdb->posts p
LEFT JOIN $wpdb->postmeta acct ON acct.post_id = p.ID AND acct.meta_key = '_activitypub_acct'
WHERE p.post_type = %s AND p.post_status = 'publish' AND ( $conditions )
ORDER BY p.ID DESC LIMIT %d",
$params
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
return \array_map( '\get_post', $posts );
}
/**
* Look up which of the given URIs already exist as cached remote actors.
*
@ -308,6 +365,7 @@ class Remote_Actors {
return $post;
}
// get_remote_object() self-confirms the actor is served under its own id, so it is safe to cache.
$object = Http::get_remote_object( $actor_uri, false );
if ( \is_wp_error( $object ) ) {
@ -556,7 +614,12 @@ class Remote_Actors {
* @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 ) {
/*
* Reject non-actor objects here, the single chokepoint every
* create/update/upsert funnels through, so callers do not each have to
* guard against accidentally caching a Note or other non-actor object.
*/
if ( ! $actor instanceof Actor || ! is_actor( $actor ) ) {
return new \WP_Error(
'activitypub_invalid_actor_data',
\__( 'Invalid actor data', 'activitypub' ),
@ -624,7 +687,7 @@ class Remote_Actors {
// 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 );
$meta_input = \array_merge( $meta_input, $emoji_meta );
return array(
'guid' => \esc_url_raw( $actor->get_id() ),
@ -648,7 +711,7 @@ class Remote_Actors {
*/
public static function normalize_identifier( $actor ) {
$actor = object_to_uri( $actor );
if ( ! is_string( $actor ) ) {
if ( ! \is_string( $actor ) ) {
return null;
}
@ -692,11 +755,8 @@ class Remote_Actors {
// 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 ) {
// Verify the owner is on the same host as the key, so a key on one host cannot be claimed for an actor on another.
if ( ! is_same_host( $key_id, $data['owner'] ) ) {
return $no_key_error;
}
@ -750,11 +810,8 @@ class Remote_Actors {
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 ) {
if ( ! is_same_host( $data['id'] ?? '', $data['publicKey'] ) ) {
return false;
}

View File

@ -11,6 +11,8 @@ use Activitypub\Emoji;
use Activitypub\Sanitize;
use function Activitypub\generate_post_summary;
use function Activitypub\is_same_actor;
use function Activitypub\is_same_host;
use function Activitypub\object_to_uri;
use function Activitypub\process_remote_media;
@ -70,6 +72,30 @@ class Remote_Posts {
return self::update( $activity, $recipients );
}
// An actor may only create posts attributed to itself; only the actor is signature-bound, not attributedTo.
if ( ! is_same_actor( $activity['actor'] ?? '', $activity_object['attributedTo'] ?? '' ) ) {
return new \WP_Error(
'activitypub_create_unauthorized',
\__( 'The Create actor does not match the object attributedTo.', 'activitypub' ),
array( 'status' => 403 )
);
}
/*
* A post is cached under its own id (guid), so that id must live on the same host
* as its author. Otherwise a signed Create could cache a post under a different
* host's object id, mis-recording its origin and taking over that id, so the
* genuine post can no longer overwrite the cached copy (the update owner-check
* would then reject the real author).
*/
if ( ! is_same_host( $activity_object['id'] ?? '', $activity['actor'] ?? '' ) ) {
return new \WP_Error(
'activitypub_create_host_mismatch',
\__( 'The object id must be on the same host as the actor.', 'activitypub' ),
array( 'status' => 403 )
);
}
// Post doesn't exist, create new post.
$actor = Remote_Actors::fetch_by_uri( object_to_uri( $activity_object['attributedTo'] ) );
@ -152,6 +178,25 @@ class Remote_Posts {
return $post;
}
/*
* Only the post's author may update it. When the activity carries an actor (every
* signature-verified inbound activity does), compare it against the remote actor
* stored when the post was first cached (its guid is the actor URI), so a remote
* server cannot overwrite another host's cached post by sending an Update whose
* object.id points at a post it does not own. The actor must be used here, not the
* payload's attributedTo, because only the actor is bound to the HTTP signature.
*/
if ( isset( $activity['actor'] ) ) {
$owner = \get_post( (int) \get_post_meta( $post->ID, '_activitypub_remote_actor_id', true ) );
if ( ! $owner instanceof \WP_Post || object_to_uri( $activity['actor'] ) !== $owner->guid ) {
return new \WP_Error(
'activitypub_update_forbidden',
\__( 'Update failed: the actor does not own this post.', 'activitypub' ),
array( 'status' => 403 )
);
}
}
$post_array = self::activity_to_post( $activity['object'] );
$post_array['ID'] = $post->ID;
$post_id = \wp_update_post( $post_array, true );
@ -512,7 +557,7 @@ class Remote_Posts {
\wp_delete_post( $post_id, true );
}
return count( $post_ids );
return \count( $post_ids );
}
/**

View File

@ -55,9 +55,9 @@ class Replies {
*/
private static function get_id( $wp_object ) {
if ( $wp_object instanceof \WP_Post ) {
return get_rest_url_by_path( sprintf( 'posts/%d/replies', $wp_object->ID ) );
return get_rest_url_by_path( \sprintf( 'posts/%d/replies', $wp_object->ID ) );
} elseif ( $wp_object instanceof \WP_Comment ) {
return get_rest_url_by_path( sprintf( 'comments/%d/replies', $wp_object->comment_ID ) );
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.' );
}
@ -73,7 +73,7 @@ class Replies {
public static function get_collection( $wp_object ) {
$id = self::get_id( $wp_object );
if ( is_wp_error( $id ) ) {
if ( \is_wp_error( $id ) ) {
return \wp_is_serving_rest_request() ? $id : null;
}
@ -101,7 +101,7 @@ class Replies {
public static function get_collection_page( $wp_object, $page, $part_of = null ) {
// Build initial arguments for fetching approved comments.
$args = self::build_args( $wp_object );
if ( is_wp_error( $args ) ) {
if ( \is_wp_error( $args ) ) {
return \wp_is_serving_rest_request() ? $args : null;
}
@ -109,16 +109,16 @@ class Replies {
$part_of = $part_of ?? self::get_id( $wp_object );
// If the collection page does not exist.
if ( is_wp_error( $part_of ) ) {
if ( \is_wp_error( $part_of ) ) {
return \wp_is_serving_rest_request() ? $part_of : null;
}
// Get to total replies count.
$total_replies = \get_comments( array_merge( $args, array( 'count' => true ) ) );
$total_replies = \get_comments( \array_merge( $args, array( 'count' => true ) ) );
// If set to zero, we get errors below. You need at least one comment per page, here.
$args['number'] = max( (int) \get_option( 'comments_per_page' ), 1 );
$args['offset'] = intval( $page - 1 ) * $args['number'];
$args['number'] = \max( (int) \get_option( 'comments_per_page' ), 1 );
$args['offset'] = \intval( $page - 1 ) * $args['number'];
// Get the ActivityPub ID's of the comments, without local-only comments.
$comment_ids = self::get_reply_ids( \get_comments( $args ) );
@ -170,7 +170,7 @@ class Replies {
\array_unshift( $ids, $post_uri );
$author = Actors::get_by_id( $post->post_author );
if ( is_wp_error( $author ) ) {
if ( \is_wp_error( $author ) ) {
if ( is_user_type_disabled( 'blog' ) ) {
return false;
}
@ -182,7 +182,7 @@ class Replies {
'type' => 'OrderedCollection',
'url' => \get_permalink( $post_id ),
'attributedTo' => $author->get_id(),
'totalItems' => count( $ids ),
'totalItems' => \count( $ids ),
'items' => $ids,
);
}