updated plugin ActivityPub version 9.1.0
This commit is contained in:
@ -38,8 +38,10 @@ class Activity extends Base_Object {
|
||||
const JSON_LD_CONTEXT = array(
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
array(
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
'QuoteRequest' => 'toot:QuoteRequest',
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
'QuoteRequest' => 'toot:QuoteRequest',
|
||||
'blurhash' => 'toot:blurhash',
|
||||
'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest',
|
||||
),
|
||||
);
|
||||
|
||||
@ -70,6 +72,7 @@ class Activity extends Base_Object {
|
||||
'Move',
|
||||
'Offer',
|
||||
'QuoteRequest', // @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
|
||||
'FeatureRequest', // @see https://w3id.org/fep/7aa9 (FEP-7aa9 draft)
|
||||
'Read',
|
||||
'Reject',
|
||||
'Remove',
|
||||
@ -188,9 +191,9 @@ class Activity extends Base_Object {
|
||||
$object = $data;
|
||||
|
||||
// Convert array to appropriate object type.
|
||||
if ( is_array( $data ) ) {
|
||||
if ( array_is_list( $data ) ) {
|
||||
$object = array_map( array( $this, 'maybe_convert_to_object' ), $data );
|
||||
if ( \is_array( $data ) ) {
|
||||
if ( \array_is_list( $data ) ) {
|
||||
$object = \array_map( array( $this, 'maybe_convert_to_object' ), $data );
|
||||
} else {
|
||||
$object = $this->maybe_convert_to_object( $data );
|
||||
}
|
||||
@ -207,14 +210,14 @@ class Activity extends Base_Object {
|
||||
$object = $this->get_object();
|
||||
|
||||
// Check if `$object` is a URL and use it to generate an ID then.
|
||||
if ( is_string( $object ) && filter_var( $object, FILTER_VALIDATE_URL ) && ! $this->get_id() ) {
|
||||
$this->set( 'id', $object . '#activity-' . strtolower( $this->get_type() ) . '-' . time() );
|
||||
if ( \is_string( $object ) && \filter_var( $object, FILTER_VALIDATE_URL ) && ! $this->get_id() ) {
|
||||
$this->set( 'id', $object . '#activity-' . \strtolower( $this->get_type() ) . '-' . \time() );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if `$object` is an object and copy some properties otherwise do nothing.
|
||||
if ( ! is_object( $object ) ) {
|
||||
if ( ! \is_object( $object ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -246,15 +249,15 @@ class Activity extends Base_Object {
|
||||
}
|
||||
|
||||
if ( $object->get_id() && ! $this->get_id() ) {
|
||||
$id = strtok( $object->get_id(), '#' );
|
||||
$id = \strtok( $object->get_id(), '#' );
|
||||
if ( $object->get_updated() ) {
|
||||
$updated = $object->get_updated();
|
||||
} elseif ( $object->get_published() ) {
|
||||
$updated = $object->get_published();
|
||||
} else {
|
||||
$updated = time();
|
||||
$updated = \time();
|
||||
}
|
||||
$this->set( 'id', $id . '#activity-' . strtolower( $this->get_type() ) . '-' . $updated );
|
||||
$this->set( 'id', $id . '#activity-' . \strtolower( $this->get_type() ) . '-' . $updated );
|
||||
}
|
||||
}
|
||||
|
||||
@ -265,7 +268,7 @@ class Activity extends Base_Object {
|
||||
*/
|
||||
public function get_json_ld_context() {
|
||||
if ( \is_object( $this->object ) ) {
|
||||
$class = get_class( $this->object );
|
||||
$class = \get_class( $this->object );
|
||||
if ( $class && $class::JSON_LD_CONTEXT ) {
|
||||
// Without php 5.6 support this could be just: 'return $this->object::JSON_LD_CONTEXT;'.
|
||||
return $class::JSON_LD_CONTEXT;
|
||||
@ -283,17 +286,17 @@ class Activity extends Base_Object {
|
||||
* @return Activity|Actor|Base_Object|Generic_Object|string|\WP_Error|null The converted object or original data.
|
||||
*/
|
||||
private function maybe_convert_to_object( $data ) {
|
||||
if ( ! is_array( $data ) ) {
|
||||
if ( ! \is_array( $data ) ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$type = $data['type'] ?? null;
|
||||
|
||||
if ( in_array( $type, self::TYPES, true ) ) {
|
||||
if ( \in_array( $type, self::TYPES, true ) ) {
|
||||
$object = self::init_from_array( $data );
|
||||
} elseif ( in_array( $type, Actor::TYPES, true ) ) {
|
||||
} elseif ( \in_array( $type, Actor::TYPES, true ) ) {
|
||||
$object = Actor::init_from_array( $data );
|
||||
} elseif ( in_array( $type, Base_Object::TYPES, true ) ) {
|
||||
} elseif ( \in_array( $type, Base_Object::TYPES, true ) ) {
|
||||
switch ( $type ) {
|
||||
case 'Event':
|
||||
$object = Event::init_from_array( $data );
|
||||
|
||||
@ -74,6 +74,7 @@ class Actor extends Base_Object {
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
'lemmy' => 'https://join-lemmy.org/ns#',
|
||||
'litepub' => 'http://litepub.social/ns#',
|
||||
'gts' => 'https://gotosocial.org/ns#',
|
||||
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
|
||||
'PropertyValue' => 'schema:PropertyValue',
|
||||
'value' => 'schema:value',
|
||||
@ -107,6 +108,18 @@ class Actor extends Base_Object {
|
||||
'@type' => '@id',
|
||||
'@container' => '@list',
|
||||
),
|
||||
'interactionPolicy' => array(
|
||||
'@id' => 'gts:interactionPolicy',
|
||||
'@type' => '@id',
|
||||
),
|
||||
'canFeature' => array(
|
||||
'@id' => 'https://w3id.org/fep/7aa9#canFeature',
|
||||
'@type' => '@id',
|
||||
),
|
||||
'automaticApproval' => array(
|
||||
'@id' => 'gts:automaticApproval',
|
||||
'@type' => '@id',
|
||||
),
|
||||
'postingRestrictedToMods' => 'lemmy:postingRestrictedToMods',
|
||||
'discoverable' => 'toot:discoverable',
|
||||
'indexable' => 'toot:indexable',
|
||||
|
||||
@ -162,6 +162,8 @@ class Base_Object extends Generic_Object {
|
||||
'@id' => 'gts:always',
|
||||
'@type' => '@id',
|
||||
),
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
'blurhash' => 'toot:blurhash',
|
||||
),
|
||||
);
|
||||
|
||||
@ -634,7 +636,7 @@ class Base_Object extends Generic_Object {
|
||||
*/
|
||||
public function get( $key ) {
|
||||
if ( ! $this->has( $key ) ) {
|
||||
return new \WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
|
||||
return new \WP_Error( 'invalid_key', \__( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
|
||||
}
|
||||
|
||||
return parent::get( $key );
|
||||
@ -650,7 +652,7 @@ class Base_Object extends Generic_Object {
|
||||
*/
|
||||
public function set( $key, $value ) {
|
||||
if ( ! $this->has( $key ) ) {
|
||||
return new \WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
|
||||
return new \WP_Error( 'invalid_key', \__( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
|
||||
}
|
||||
|
||||
return parent::set( $key, $value );
|
||||
@ -666,7 +668,7 @@ class Base_Object extends Generic_Object {
|
||||
*/
|
||||
public function add( $key, $value ) {
|
||||
if ( ! $this->has( $key ) ) {
|
||||
return new \WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
|
||||
return new \WP_Error( 'invalid_key', \__( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
|
||||
}
|
||||
|
||||
return parent::add( $key, $value );
|
||||
|
||||
@ -99,7 +99,7 @@ class Generic_Object {
|
||||
* @return mixed The value.
|
||||
*/
|
||||
public function get( $key ) {
|
||||
return call_user_func( array( $this, 'get_' . $key ) );
|
||||
return \call_user_func( array( $this, 'get_' . $key ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -133,19 +133,19 @@ class Generic_Object {
|
||||
$this->$key = array();
|
||||
}
|
||||
|
||||
if ( is_string( $this->$key ) ) {
|
||||
if ( \is_string( $this->$key ) ) {
|
||||
$this->$key = array( $this->$key );
|
||||
}
|
||||
|
||||
$attributes = $this->$key;
|
||||
|
||||
if ( is_array( $value ) ) {
|
||||
$attributes = array_merge( $attributes, $value );
|
||||
if ( \is_array( $value ) ) {
|
||||
$attributes = \array_merge( $attributes, $value );
|
||||
} else {
|
||||
$attributes[] = $value;
|
||||
}
|
||||
|
||||
$this->$key = array_unique( $attributes );
|
||||
$this->$key = \array_unique( $attributes );
|
||||
|
||||
return $this->$key;
|
||||
}
|
||||
@ -158,7 +158,7 @@ class Generic_Object {
|
||||
* @return boolean True if the object has the key.
|
||||
*/
|
||||
public function has( $key ) {
|
||||
return property_exists( $this, $key );
|
||||
return \property_exists( $this, $key );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -171,8 +171,8 @@ class Generic_Object {
|
||||
public static function init_from_json( $json ) {
|
||||
$array = \json_decode( $json, true );
|
||||
|
||||
if ( ! is_array( $array ) ) {
|
||||
return new \WP_Error( 'invalid_json', __( 'Invalid JSON', 'activitypub' ), array( 'status' => 400 ) );
|
||||
if ( ! \is_array( $array ) ) {
|
||||
return new \WP_Error( 'invalid_json', \__( 'Invalid JSON', 'activitypub' ), array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
return self::init_from_array( $array );
|
||||
@ -186,8 +186,8 @@ class Generic_Object {
|
||||
* @return static|\WP_Error An Object built from the input array or WP_Error when it's not an array.
|
||||
*/
|
||||
public static function init_from_array( $data ) {
|
||||
if ( ! is_array( $data ) ) {
|
||||
return new \WP_Error( 'invalid_array', __( 'Invalid array', 'activitypub' ), array( 'status' => 400 ) );
|
||||
if ( ! \is_array( $data ) ) {
|
||||
return new \WP_Error( 'invalid_array', \__( 'Invalid array', 'activitypub' ), array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$object = new static();
|
||||
@ -209,7 +209,7 @@ class Generic_Object {
|
||||
$key = camel_to_snake_case( $key );
|
||||
}
|
||||
|
||||
call_user_func( array( $this, 'set_' . $key ), $value );
|
||||
\call_user_func( array( $this, 'set_' . $key ), $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -244,7 +244,7 @@ class Generic_Object {
|
||||
*/
|
||||
public function to_array( $include_json_ld_context = true, $include_blind_audience = false ) {
|
||||
$array = array();
|
||||
$vars = get_object_vars( $this );
|
||||
$vars = \get_object_vars( $this );
|
||||
|
||||
foreach ( $vars as $key => $value ) {
|
||||
if ( \is_wp_error( $value ) ) {
|
||||
@ -252,20 +252,20 @@ class Generic_Object {
|
||||
}
|
||||
|
||||
// Ignore all _prefixed keys.
|
||||
if ( '_' === substr( $key, 0, 1 ) ) {
|
||||
if ( '_' === \substr( $key, 0, 1 ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If value is empty, try to get it from a getter.
|
||||
if ( ! $value ) {
|
||||
$value = call_user_func( array( $this, 'get_' . $key ) );
|
||||
$value = \call_user_func( array( $this, 'get_' . $key ) );
|
||||
}
|
||||
|
||||
if ( is_object( $value ) ) {
|
||||
if ( \is_object( $value ) ) {
|
||||
$value = $value->to_array( false, $include_blind_audience );
|
||||
}
|
||||
|
||||
if ( is_array( $value ) && $this->is_namespaced( $key ) ) {
|
||||
if ( \is_array( $value ) && $this->is_namespaced( $key ) ) {
|
||||
foreach ( $value as $sub_key => $sub_value ) {
|
||||
$array[ snake_to_camel_case( $key ) . ':' . snake_to_camel_case( $sub_key ) ] = $sub_value;
|
||||
}
|
||||
@ -276,11 +276,11 @@ class Generic_Object {
|
||||
|
||||
if ( $include_json_ld_context ) {
|
||||
// Get JsonLD context and move it to '@context' at the top.
|
||||
$array = array_merge( array( '@context' => $this->get_json_ld_context() ), $array );
|
||||
$array = \array_merge( array( '@context' => $this->get_json_ld_context() ), $array );
|
||||
}
|
||||
|
||||
$class = new \ReflectionClass( $this );
|
||||
$class = strtolower( $class->getShortName() );
|
||||
$class = \strtolower( $class->getShortName() );
|
||||
|
||||
/**
|
||||
* Filter the array of the ActivityPub object.
|
||||
@ -371,7 +371,7 @@ class Generic_Object {
|
||||
$namespaces = array();
|
||||
|
||||
foreach ( static::JSON_LD_CONTEXT as $context ) {
|
||||
if ( is_array( $context ) ) {
|
||||
if ( \is_array( $context ) ) {
|
||||
$namespaces = \array_merge( $namespaces, $context );
|
||||
}
|
||||
}
|
||||
|
||||
@ -301,10 +301,10 @@ class Event extends Base_Object {
|
||||
* @return Event
|
||||
*/
|
||||
public function set_timezone( $timezone ) {
|
||||
if ( in_array( $timezone, timezone_identifiers_list(), true ) ) {
|
||||
if ( \in_array( $timezone, \timezone_identifiers_list(), true ) ) {
|
||||
$this->timezone = $timezone;
|
||||
} else {
|
||||
$this->timezone = wp_timezone_string();
|
||||
$this->timezone = \wp_timezone_string();
|
||||
}
|
||||
|
||||
return $this;
|
||||
@ -318,11 +318,11 @@ class Event extends Base_Object {
|
||||
* @return Event
|
||||
*/
|
||||
public function set_replies_moderation_option( $type ) {
|
||||
if ( in_array( $type, self::REPLIES_MODERATION_OPTION_TYPES, true ) ) {
|
||||
if ( \in_array( $type, self::REPLIES_MODERATION_OPTION_TYPES, true ) ) {
|
||||
$this->replies_moderation_option = $type;
|
||||
$this->comments_enabled = ( 'allow_all' === $type ) ? true : false;
|
||||
} else {
|
||||
_doing_it_wrong(
|
||||
\_doing_it_wrong(
|
||||
__METHOD__,
|
||||
'The replies moderation option must be either allow_all or closed.',
|
||||
'<version_placeholder>'
|
||||
@ -340,11 +340,11 @@ class Event extends Base_Object {
|
||||
* @return Event
|
||||
*/
|
||||
public function set_comments_enabled( $comments_enabled ) {
|
||||
if ( is_bool( $comments_enabled ) ) {
|
||||
if ( \is_bool( $comments_enabled ) ) {
|
||||
$this->comments_enabled = $comments_enabled;
|
||||
$this->replies_moderation_option = $comments_enabled ? 'allow_all' : 'closed';
|
||||
} else {
|
||||
_doing_it_wrong(
|
||||
\_doing_it_wrong(
|
||||
__METHOD__,
|
||||
'The commentsEnabled must be boolean.',
|
||||
'<version_placeholder>'
|
||||
@ -362,10 +362,10 @@ class Event extends Base_Object {
|
||||
* @return Event
|
||||
*/
|
||||
public function set_status( $status ) {
|
||||
if ( in_array( $status, self::ICAL_EVENT_STATUS_TYPES, true ) ) {
|
||||
if ( \in_array( $status, self::ICAL_EVENT_STATUS_TYPES, true ) ) {
|
||||
$this->status = $status;
|
||||
} else {
|
||||
_doing_it_wrong(
|
||||
\_doing_it_wrong(
|
||||
__METHOD__,
|
||||
'The status of the event must be a VEVENT iCal status.',
|
||||
'<version_placeholder>'
|
||||
@ -387,7 +387,7 @@ class Event extends Base_Object {
|
||||
*/
|
||||
public function set_category( $category, $mobilizon_compatibility = true ) {
|
||||
if ( $mobilizon_compatibility ) {
|
||||
$this->category = in_array( $category, self::DEFAULT_EVENT_CATEGORIES, true ) ? $category : 'MEETING';
|
||||
$this->category = \in_array( $category, self::DEFAULT_EVENT_CATEGORIES, true ) ? $category : 'MEETING';
|
||||
} else {
|
||||
$this->category = $category;
|
||||
}
|
||||
@ -405,7 +405,7 @@ class Event extends Base_Object {
|
||||
* @return Event
|
||||
*/
|
||||
public function set_external_participation_url( $url ) {
|
||||
if ( preg_match( '/^https?:\/\/.*/i', $url ) ) {
|
||||
if ( \preg_match( '/^https?:\/\/.*/i', $url ) ) {
|
||||
$this->external_participation_url = $url;
|
||||
$this->join_mode = 'external';
|
||||
}
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
/**
|
||||
* Feature_Authorization is an implementation of the FeatureAuthorization activity type,
|
||||
* as defined in FEP-7aa9 (https://w3id.org/fep/7aa9).
|
||||
*
|
||||
* This class represents a FeatureAuthorization activity for ActivityPub implementations.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Activity\Extended_Object;
|
||||
|
||||
use Activitypub\Activity\Base_Object;
|
||||
|
||||
/**
|
||||
* Class representing a FeatureAuthorization activity.
|
||||
*
|
||||
* @see https://w3id.org/fep/7aa9
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @method Base_Object|string|array|null get_interacting_object() Gets the interacting object property of the object.
|
||||
* @method Base_Object|string|array|null get_interaction_target() Gets the interaction target property of the object.
|
||||
*
|
||||
* @method Feature_Authorization set_interacting_object( string|array|Base_Object|null $data ) Sets the interacting object property of the object.
|
||||
* @method Feature_Authorization set_interaction_target( string|array|Base_Object|null $data ) Sets the interaction target property of the object.
|
||||
*/
|
||||
class Feature_Authorization extends Base_Object {
|
||||
/**
|
||||
* The JSON-LD context for the object.
|
||||
*
|
||||
* Intentionally minimal: stamps are always served standalone at their own
|
||||
* URL, so we ship only the vocabulary the stamp document itself uses.
|
||||
* Mirrors the Quote_Authorization (FEP-044f) approach.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
const JSON_LD_CONTEXT = array(
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
array(
|
||||
'FeatureAuthorization' => 'https://w3id.org/fep/7aa9#FeatureAuthorization',
|
||||
'gts' => 'https://gotosocial.org/ns#',
|
||||
'interactingObject' => array(
|
||||
'@id' => 'gts:interactingObject',
|
||||
'@type' => '@id',
|
||||
),
|
||||
'interactionTarget' => array(
|
||||
'@id' => 'gts:interactionTarget',
|
||||
'@type' => '@id',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The type of the object.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $type = 'FeatureAuthorization';
|
||||
|
||||
/**
|
||||
* The object that is being interacted with.
|
||||
*
|
||||
* @var Base_Object|string|array|null
|
||||
*/
|
||||
protected $interacting_object;
|
||||
|
||||
/**
|
||||
* The target of the interaction.
|
||||
*
|
||||
* @var Base_Object|string|array|null
|
||||
*/
|
||||
protected $interaction_target;
|
||||
}
|
||||
@ -106,10 +106,10 @@ class Place extends Base_Object {
|
||||
* @param array|string $address The address of the place.
|
||||
*/
|
||||
public function set_address( $address ) {
|
||||
if ( is_string( $address ) || is_array( $address ) ) {
|
||||
if ( \is_string( $address ) || \is_array( $address ) ) {
|
||||
$this->address = $address;
|
||||
} else {
|
||||
_doing_it_wrong(
|
||||
\_doing_it_wrong(
|
||||
__METHOD__,
|
||||
'The address must be either a string or an array like schema.org/PostalAddress.',
|
||||
'<version_placeholder>'
|
||||
|
||||
@ -568,7 +568,7 @@ abstract class File {
|
||||
// Method 4: Ensure file extension matches MIME type.
|
||||
$ext = \pathinfo( $file_path, PATHINFO_EXTENSION );
|
||||
|
||||
if ( strtolower( $ext ) !== $expected_ext ) {
|
||||
if ( \strtolower( $ext ) !== $expected_ext ) {
|
||||
$new_path = \preg_replace( '/\.[^.]+$/', '.' . $expected_ext, $file_path );
|
||||
if ( empty( $new_path ) || $new_path === $file_path ) {
|
||||
$new_path = $file_path . '.' . $expected_ext;
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
namespace Activitypub\Cache;
|
||||
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Model\Application;
|
||||
use Activitypub\Model\Blog;
|
||||
use Activitypub\Statistics;
|
||||
|
||||
@ -208,16 +207,16 @@ class Stats_Image extends File {
|
||||
|
||||
$actor = Actors::get_by_id( $user_id );
|
||||
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
if ( Actors::BLOG_USER_ID === $user_id ) {
|
||||
$actor = new Blog();
|
||||
} elseif ( Actors::APPLICATION_USER_ID === $user_id ) {
|
||||
$actor = new Application();
|
||||
}
|
||||
if ( \is_wp_error( $actor ) && Actors::BLOG_USER_ID === $user_id ) {
|
||||
$actor = new Blog();
|
||||
}
|
||||
|
||||
$actor_webfinger = ! \is_wp_error( $actor ) ? $actor->get_webfinger() : '';
|
||||
$site_name = \get_bloginfo( 'name' );
|
||||
if ( ! \is_wp_error( $actor ) ) {
|
||||
$actor_webfinger = $actor->get_webfinger();
|
||||
} else {
|
||||
$actor_webfinger = '';
|
||||
}
|
||||
$site_name = \get_bloginfo( 'name' );
|
||||
|
||||
if ( ! \function_exists( 'wp_tempnam' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
|
||||
@ -131,7 +131,7 @@ class Activitypub {
|
||||
* @deprecated 7.5.0 Use {@see Router::add_rewrite_rules()}.
|
||||
*/
|
||||
public static function add_rewrite_rules() {
|
||||
_deprecated_function( __FUNCTION__, '7.5.0', '\Activitypub\Router::add_rewrite_rules()' );
|
||||
\_deprecated_function( __FUNCTION__, '7.5.0', '\Activitypub\Router::add_rewrite_rules()' );
|
||||
|
||||
Router::add_rewrite_rules();
|
||||
}
|
||||
@ -142,9 +142,9 @@ class Activitypub {
|
||||
public static function theme_compat() {
|
||||
// We assume that you want to use Post-Formats when enabling the setting.
|
||||
if ( 'wordpress-post-format' === \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ) ) {
|
||||
if ( ! get_theme_support( 'post-formats' ) ) {
|
||||
if ( ! \get_theme_support( 'post-formats' ) ) {
|
||||
// Add support for the Aside, Gallery Post Formats...
|
||||
add_theme_support(
|
||||
\add_theme_support(
|
||||
'post-formats',
|
||||
array(
|
||||
'gallery',
|
||||
@ -230,7 +230,7 @@ class Activitypub {
|
||||
'single' => true,
|
||||
'default' => '',
|
||||
'sanitize_callback' => static function ( $value ) {
|
||||
return wp_kses( $value, 'user_description' );
|
||||
return \wp_kses( $value, 'user_description' );
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
318
wp-content/plugins/activitypub/includes/class-application.php
Normal file
318
wp-content/plugins/activitypub/includes/class-application.php
Normal file
@ -0,0 +1,318 @@
|
||||
<?php
|
||||
/**
|
||||
* Application class file.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub;
|
||||
|
||||
/**
|
||||
* ActivityPub Application Class.
|
||||
*
|
||||
* The Application is not a real actor in the plugin's internal sense —
|
||||
* it cannot be followed, addressed, or interacted with. It exists only as:
|
||||
* 1. A JSON-LD document at /wp-json/activitypub/1.0/application
|
||||
* 2. A signing identity for outbound HTTP GET requests
|
||||
*
|
||||
* This class provides static utility methods for the Application actor,
|
||||
* primarily key management for HTTP Signatures.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*/
|
||||
class Application {
|
||||
/**
|
||||
* The option key for the Application key pair.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const KEYPAIR_OPTION_KEY = 'activitypub_application_keypair';
|
||||
|
||||
/**
|
||||
* The preferred username for the Application actor.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const USERNAME = 'application';
|
||||
|
||||
/**
|
||||
* Initialize the class, registering WordPress hooks.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*/
|
||||
public static function init() {
|
||||
/*
|
||||
* Priority 2: must run after Integration\Webfinger::add_pseudo_user_discovery (priority 1),
|
||||
* which returns WP_Error for 'application' since it is not in the Actors collection.
|
||||
*/
|
||||
\add_filter( 'webfinger_data', array( self::class, 'add_webfinger_discovery' ), 2, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* WebFinger discovery filter callback.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @param array $jrd The jrd array.
|
||||
* @param string $uri The WebFinger resource.
|
||||
*
|
||||
* @return array The jrd array or Application WebFinger data.
|
||||
*/
|
||||
public static function add_webfinger_discovery( $jrd, $uri ) {
|
||||
/*
|
||||
* Respect a profile already resolved at an earlier priority — for example
|
||||
* on sites whose blog identifier was set to "application" before the name
|
||||
* was reserved for the Application actor.
|
||||
*/
|
||||
if ( $jrd && ! \is_wp_error( $jrd ) ) {
|
||||
return $jrd;
|
||||
}
|
||||
|
||||
$data = self::get_webfinger_data( $uri );
|
||||
|
||||
if ( $data ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
return $jrd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Application actor ID (URL).
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return string The Application ID.
|
||||
*/
|
||||
public static function get_id() {
|
||||
return get_rest_url_by_path( 'application' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pretty URL for the Application actor.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return string The Application URL.
|
||||
*/
|
||||
public static function get_url() {
|
||||
return self::get_id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WebFinger identifier for the Application.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return string The WebFinger identifier (e.g. application@example.com).
|
||||
*/
|
||||
public static function get_webfinger() {
|
||||
return self::USERNAME . '@' . home_host();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the icon for the Application.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return string[] The icon array with 'type' and 'url'.
|
||||
*/
|
||||
public static function get_icon() {
|
||||
return site_icon();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the published date of the Application.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return string The published date in RFC3339 format.
|
||||
*/
|
||||
public static function get_published() {
|
||||
$first_post = new \WP_Query(
|
||||
array(
|
||||
'orderby' => 'date',
|
||||
'order' => 'ASC',
|
||||
'posts_per_page' => 1,
|
||||
'no_found_rows' => true,
|
||||
'ignore_sticky_posts' => true,
|
||||
'update_post_meta_cache' => false,
|
||||
'update_post_term_cache' => false,
|
||||
)
|
||||
);
|
||||
|
||||
$time = false;
|
||||
|
||||
if ( ! empty( $first_post->posts[0] ) ) {
|
||||
$time = \strtotime( $first_post->posts[0]->post_date_gmt );
|
||||
}
|
||||
|
||||
if ( false === $time ) {
|
||||
$time = \time();
|
||||
}
|
||||
|
||||
return \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, $time );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the key ID for HTTP signatures.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return string The key ID.
|
||||
*/
|
||||
public static function get_key_id() {
|
||||
return self::get_id() . '#main-key';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public key PEM for the Application.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return string|null The public key PEM.
|
||||
*/
|
||||
public static function get_public_key() {
|
||||
$key_pair = self::get_keypair();
|
||||
return $key_pair['public_key'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the private key for the Application.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return string|null The private key.
|
||||
*/
|
||||
public static function get_private_key() {
|
||||
$key_pair = self::get_keypair();
|
||||
return $key_pair['private_key'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the key pair for the Application.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return array The key pair with 'public_key' and 'private_key'.
|
||||
*/
|
||||
public static function get_keypair() {
|
||||
return Signature::get_key_pair(
|
||||
self::KEYPAIR_OPTION_KEY,
|
||||
function () {
|
||||
return self::check_legacy_key_pair();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for legacy key pair options.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return array|false The key pair or false.
|
||||
*/
|
||||
private static function check_legacy_key_pair() {
|
||||
/*
|
||||
* Generic actor key pair option (array form) used for the former application
|
||||
* user (ID -1). Checked here so the key survives even if get_keypair() runs
|
||||
* before migrate_application_keypair_option() has had a chance to rename it.
|
||||
*/
|
||||
$key_pair = \get_option( 'activitypub_keypair_for_-1' );
|
||||
|
||||
if ( \is_array( $key_pair ) && ! empty( $key_pair['public_key'] ) && ! empty( $key_pair['private_key'] ) ) {
|
||||
return array(
|
||||
'private_key' => $key_pair['private_key'],
|
||||
'public_key' => $key_pair['public_key'],
|
||||
);
|
||||
}
|
||||
|
||||
// Even older separate key options.
|
||||
$public_key = \get_option( 'activitypub_application_user_public_key' );
|
||||
$private_key = \get_option( 'activitypub_application_user_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,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the URI matches the Application actor and return WebFinger data.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* Handles the following URI formats:
|
||||
* - acct:application@example.com / application@example.com
|
||||
* - http(s)://example.com/@application
|
||||
* - http(s)://example.com/wp-json/activitypub/1.0/application
|
||||
*
|
||||
* @param string $uri The WebFinger resource URI.
|
||||
*
|
||||
* @return array|false The WebFinger profile data or false if not the Application.
|
||||
*/
|
||||
public static function get_webfinger_data( $uri ) {
|
||||
if ( ! self::is_application_resource( $uri ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$application_id = self::get_id();
|
||||
|
||||
return array(
|
||||
'subject' => sprintf( 'acct:%s', self::get_webfinger() ),
|
||||
'aliases' => array( $application_id ),
|
||||
'links' => array(
|
||||
array(
|
||||
'rel' => 'self',
|
||||
'type' => 'application/activity+json',
|
||||
'href' => $application_id,
|
||||
'properties' => array(
|
||||
'https://www.w3.org/ns/activitystreams#type' => 'Application',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URI refers to the Application actor.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @param string $uri The URI to check.
|
||||
*
|
||||
* @return bool True if the URI refers to the Application.
|
||||
*/
|
||||
public static function is_application_resource( $uri ) {
|
||||
$identifier_and_host = Webfinger::get_identifier_and_host( $uri );
|
||||
|
||||
if ( \is_wp_error( $identifier_and_host ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list( $identifier, $host ) = $identifier_and_host;
|
||||
|
||||
// The resource must point at this site, or at its pre-migration host.
|
||||
$host = normalize_host( $host );
|
||||
if ( normalize_host( home_host() ) !== $host && normalize_host( \get_option( 'activitypub_old_host' ) ) !== $host ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// URL forms: the REST actor ID or the pretty /@application profile path.
|
||||
if ( false !== \strpos( $identifier, '://' ) ) {
|
||||
$identifier = normalize_url( $identifier );
|
||||
|
||||
return normalize_url( self::get_id() ) === $identifier
|
||||
|| normalize_url( \home_url( '/@' . self::USERNAME ) ) === $identifier;
|
||||
}
|
||||
|
||||
// acct form: application@host.
|
||||
$username = \strstr( \str_replace( 'acct:', '', $identifier ), '@', true );
|
||||
|
||||
return self::USERNAME === $username;
|
||||
}
|
||||
}
|
||||
@ -49,7 +49,7 @@ class Attachments {
|
||||
// First, import inline images from the post content.
|
||||
$inline_mappings = self::import_inline_images( $post_id, $author_id );
|
||||
|
||||
if ( empty( $attachments ) || ! is_array( $attachments ) ) {
|
||||
if ( empty( $attachments ) || ! \is_array( $attachments ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ class Attachments {
|
||||
}
|
||||
|
||||
// Find all img tags in the content.
|
||||
preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches );
|
||||
\preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches );
|
||||
|
||||
if ( empty( $matches[1] ) ) {
|
||||
return array();
|
||||
@ -172,7 +172,7 @@ class Attachments {
|
||||
$attachment = \get_object_vars( $attachment );
|
||||
}
|
||||
|
||||
if ( ! is_array( $attachment ) || empty( $attachment['url'] ) ) {
|
||||
if ( ! \is_array( $attachment ) || empty( $attachment['url'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -207,7 +207,7 @@ class Attachments {
|
||||
|
||||
$filesystem = new \WP_Filesystem_Direct( null );
|
||||
|
||||
$is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] );
|
||||
$is_local = ! \preg_match( '#^https?://#i', $attachment_data['url'] );
|
||||
|
||||
if ( $is_local ) {
|
||||
// Validate local path is within allowed directories to prevent file disclosure.
|
||||
@ -219,7 +219,7 @@ class Attachments {
|
||||
// Read local file from disk.
|
||||
if ( ! $filesystem->exists( $attachment_data['url'] ) ) {
|
||||
/* translators: %s: file path */
|
||||
return new \WP_Error( 'file_not_found', sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) );
|
||||
return new \WP_Error( 'file_not_found', \sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) );
|
||||
}
|
||||
|
||||
// Copy to temp file so media_handle_sideload doesn't move the original.
|
||||
@ -279,7 +279,7 @@ class Attachments {
|
||||
// Add alt text for images.
|
||||
if ( ! empty( $attachment_data['name'] ) ) {
|
||||
$original_mime = $attachment_data['mediaType'] ?? '';
|
||||
if ( 'image' === strtok( $original_mime, '/' ) ) {
|
||||
if ( 'image' === \strtok( $original_mime, '/' ) ) {
|
||||
$post_data['meta_input']['_wp_attachment_image_alt'] = $attachment_data['name'];
|
||||
}
|
||||
}
|
||||
@ -459,7 +459,7 @@ class Attachments {
|
||||
}
|
||||
|
||||
$media = self::generate_media_markup( $attachment_ids );
|
||||
$separator = empty( trim( $post->post_content ) ) ? '' : "\n\n";
|
||||
$separator = empty( \trim( $post->post_content ) ) ? '' : "\n\n";
|
||||
|
||||
\wp_update_post(
|
||||
array(
|
||||
@ -498,11 +498,11 @@ class Attachments {
|
||||
}
|
||||
|
||||
// Default to block markup.
|
||||
$type = strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );
|
||||
$type = \strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );
|
||||
|
||||
// Single video or audio file.
|
||||
if ( 1 === \count( $attachment_ids ) && ( 'video' === $type || 'audio' === $type ) ) {
|
||||
return sprintf(
|
||||
return \sprintf(
|
||||
'<!-- wp:%1$s {"id":"%2$s"} --><figure class="wp-block-%1$s"><%1$s controls src="%3$s"></%1$s></figure><!-- /wp:%1$s -->',
|
||||
\esc_attr( $type ),
|
||||
\esc_attr( $attachment_ids[0] ),
|
||||
@ -640,7 +640,7 @@ class Attachments {
|
||||
}
|
||||
|
||||
$media = self::generate_files_markup( $files );
|
||||
$separator = empty( trim( $content ) ) ? '' : "\n\n";
|
||||
$separator = empty( \trim( $content ) ) ? '' : "\n\n";
|
||||
|
||||
self::update_object_content( $object_id, $object_type, $content . $separator . $media );
|
||||
}
|
||||
@ -683,11 +683,11 @@ class Attachments {
|
||||
}
|
||||
|
||||
// Default to block markup.
|
||||
$type = strtok( $files[0]['mime_type'], '/' );
|
||||
$type = \strtok( $files[0]['mime_type'], '/' );
|
||||
|
||||
// Single video or audio file.
|
||||
if ( 1 === \count( $files ) && ( 'video' === $type || 'audio' === $type ) ) {
|
||||
return sprintf(
|
||||
return \sprintf(
|
||||
'<!-- wp:%1$s --><figure class="wp-block-%1$s"><%1$s controls src="%2$s"></%1$s></figure><!-- /wp:%1$s -->',
|
||||
\esc_attr( $type ),
|
||||
\esc_url( $files[0]['url'] )
|
||||
|
||||
@ -9,6 +9,9 @@ namespace Activitypub;
|
||||
|
||||
use Activitypub\Cache\Stats_Image;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Following;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
/**
|
||||
* Block class.
|
||||
@ -105,22 +108,22 @@ class Blocks {
|
||||
'noteLength' => ACTIVITYPUB_NOTE_LENGTH,
|
||||
'statsImageUrlEndpoint' => Stats_Image::is_available() ? \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image-url/{user_id}/{year}' ) : '',
|
||||
);
|
||||
wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
|
||||
\wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
|
||||
|
||||
// Check for our supported post types.
|
||||
$current_screen = \get_current_screen();
|
||||
$ap_post_types = \get_post_types_by_support( 'activitypub' );
|
||||
if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) {
|
||||
if ( ! $current_screen || ! \in_array( $current_screen->post_type, $ap_post_types, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php';
|
||||
$plugin_url = plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
|
||||
wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
|
||||
$plugin_url = \plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
|
||||
\wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
|
||||
|
||||
$asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/pre-publish-panel/plugin.asset.php';
|
||||
$plugin_url = plugins_url( 'build/pre-publish-panel/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
|
||||
wp_enqueue_script( 'activitypub-pre-publish-panel', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
|
||||
$plugin_url = \plugins_url( 'build/pre-publish-panel/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
|
||||
\wp_enqueue_script( 'activitypub-pre-publish-panel', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -134,8 +137,8 @@ class Blocks {
|
||||
}
|
||||
|
||||
$asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/reply-intent/plugin.asset.php';
|
||||
$plugin_url = plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
|
||||
wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
|
||||
$plugin_url = \plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
|
||||
\wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -289,7 +292,7 @@ class Blocks {
|
||||
*/
|
||||
public static function register_rest_fields() {
|
||||
// Register the post_count field for Follow Me block.
|
||||
register_rest_field(
|
||||
\register_rest_field(
|
||||
'user',
|
||||
'post_count',
|
||||
array(
|
||||
@ -302,7 +305,7 @@ class Blocks {
|
||||
* @return int The number of published posts.
|
||||
*/
|
||||
'get_callback' => static function ( $response, $field_name, $request ) {
|
||||
return (int) count_user_posts( $request->get_param( 'id' ), 'post', true );
|
||||
return (int) \count_user_posts( $request->get_param( 'id' ), 'post', true );
|
||||
},
|
||||
'schema' => array(
|
||||
'description' => 'Number of published posts',
|
||||
@ -320,8 +323,8 @@ class Blocks {
|
||||
* @return int|null The user ID, or null if the 'inherit' string is not supported in this context.
|
||||
*/
|
||||
public static function get_user_id( $user_string ) {
|
||||
if ( is_numeric( $user_string ) ) {
|
||||
return absint( $user_string );
|
||||
if ( \is_numeric( $user_string ) ) {
|
||||
return \absint( $user_string );
|
||||
}
|
||||
|
||||
// If the user string is 'blog', return the Blog User ID.
|
||||
@ -335,30 +338,30 @@ class Blocks {
|
||||
}
|
||||
|
||||
// For a homepage/front page, if the Blog User is active, use it.
|
||||
if ( ( is_front_page() || is_home() ) && ! is_user_type_disabled( 'blog' ) ) {
|
||||
if ( ( \is_front_page() || \is_home() ) && ! is_user_type_disabled( 'blog' ) ) {
|
||||
return Actors::BLOG_USER_ID;
|
||||
}
|
||||
|
||||
// If we're in a loop, use the post author.
|
||||
$author_id = get_the_author_meta( 'ID' );
|
||||
$author_id = \get_the_author_meta( 'ID' );
|
||||
if ( $author_id ) {
|
||||
return $author_id;
|
||||
}
|
||||
|
||||
// For other pages, the queried object will clue us in.
|
||||
$queried_object = get_queried_object();
|
||||
$queried_object = \get_queried_object();
|
||||
if ( ! $queried_object ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we're on a user archive page, use that user's ID.
|
||||
if ( is_a( $queried_object, 'WP_User' ) ) {
|
||||
if ( \is_a( $queried_object, 'WP_User' ) ) {
|
||||
return $queried_object->ID;
|
||||
}
|
||||
|
||||
// For a single post, use the post author's ID.
|
||||
if ( is_a( $queried_object, 'WP_Post' ) ) {
|
||||
return get_the_author_meta( 'ID' );
|
||||
if ( \is_a( $queried_object, 'WP_Post' ) ) {
|
||||
return \get_the_author_meta( 'ID' );
|
||||
}
|
||||
|
||||
// We won't properly account for some conditions, like tag archives.
|
||||
@ -381,12 +384,12 @@ class Blocks {
|
||||
}
|
||||
|
||||
$attributes = \wp_parse_args( $attributes );
|
||||
$block_name = 'followers' === $endpoint ? __( 'Followers', 'activitypub' ) : __( 'Following', 'activitypub' );
|
||||
$block_name = 'followers' === $endpoint ? \__( 'Followers', 'activitypub' ) : \__( 'Following', 'activitypub' );
|
||||
|
||||
if ( empty( $content ) ) {
|
||||
// Fallback for v1.0.0 blocks.
|
||||
/* translators: %s: Block type (Followers or Following) */
|
||||
$_title = $attributes['title'] ?? \sprintf( __( 'Fediverse %s', 'activitypub' ), $block_name );
|
||||
$_title = $attributes['title'] ?? \sprintf( \__( 'Fediverse %s', 'activitypub' ), $block_name );
|
||||
$content = '<h3 class="wp-block-heading">' . \esc_html( $_title ) . '</h3>';
|
||||
unset( $attributes['title'], $attributes['className'] );
|
||||
} else {
|
||||
@ -415,17 +418,17 @@ class Blocks {
|
||||
|
||||
// Query the appropriate collection.
|
||||
if ( 'followers' === $endpoint ) {
|
||||
$data = \Activitypub\Collection\Followers::query( $user_id, $_per_page );
|
||||
$data = Followers::query( $user_id, $_per_page );
|
||||
$items = $data['followers'];
|
||||
} else {
|
||||
$data = \Activitypub\Collection\Following::query( $user_id, $_per_page );
|
||||
$data = Following::query( $user_id, $_per_page );
|
||||
$items = $data['following'];
|
||||
}
|
||||
|
||||
// Prepare items data for the Interactivity API context.
|
||||
$prepared_items = \array_map(
|
||||
static function ( $item ) {
|
||||
$actor = \Activitypub\Collection\Remote_Actors::get_actor( $item );
|
||||
$actor = Remote_Actors::get_actor( $item );
|
||||
|
||||
// Restrict URLs to http/https schemes to prevent XSS via javascript: URIs.
|
||||
$url = object_to_uri( $actor->get_url() ) ?: $actor->get_id();
|
||||
@ -474,7 +477,7 @@ class Blocks {
|
||||
);
|
||||
|
||||
/* translators: %s: Block type (Followers or Following) */
|
||||
$nav_label = \sprintf( __( '%s navigation', 'activitypub' ), $block_name );
|
||||
$nav_label = \sprintf( \__( '%s navigation', 'activitypub' ), $block_name );
|
||||
|
||||
\ob_start();
|
||||
?>
|
||||
@ -512,8 +515,8 @@ class Blocks {
|
||||
}
|
||||
|
||||
$url = $attrs['url'];
|
||||
$shortcode = trim( $content );
|
||||
$name = trim( $shortcode, ':' );
|
||||
$shortcode = \trim( $content );
|
||||
$name = \trim( $shortcode, ':' );
|
||||
|
||||
/**
|
||||
* Filters a remote media URL for caching.
|
||||
@ -674,11 +677,26 @@ class Blocks {
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* In feed contexts (RSS, Atom, and anything else WordPress treats as a feed) the styled
|
||||
* embed card depends on plugin CSS that isn't loaded, so it degrades to an unreadable
|
||||
* wall of text. Substitute the same simplified mention link the federation path uses,
|
||||
* and if the remote lookup fails fall through to the plain `<a class="u-in-reply-to">`
|
||||
* link below so the feed item still surfaces *some* indication that it's a reply.
|
||||
*/
|
||||
if ( \is_feed() ) {
|
||||
$mention = self::generate_reply_link( '', array( 'attrs' => $attrs ) );
|
||||
if ( ! empty( $mention ) ) {
|
||||
return $mention;
|
||||
}
|
||||
$attrs['embedPost'] = false;
|
||||
}
|
||||
|
||||
$show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
|
||||
|
||||
$wrapper_attrs = get_block_wrapper_attributes(
|
||||
$wrapper_attrs = \get_block_wrapper_attributes(
|
||||
array(
|
||||
'aria-label' => __( 'Reply', 'activitypub' ),
|
||||
'aria-label' => \__( 'Reply', 'activitypub' ),
|
||||
'class' => 'activitypub-reply-block',
|
||||
'data-in-reply-to' => $attrs['url'],
|
||||
)
|
||||
@ -691,7 +709,7 @@ class Blocks {
|
||||
if ( $show_embed ) {
|
||||
// Use the theme's content width or a reasonable default to avoid narrow embeds.
|
||||
$embed_width = ! empty( $GLOBALS['content_width'] ) ? $GLOBALS['content_width'] : 600;
|
||||
$embed = wp_oembed_get( $attrs['url'], array( 'width' => $embed_width ) );
|
||||
$embed = \wp_oembed_get( $attrs['url'], array( 'width' => $embed_width ) );
|
||||
if ( $embed ) {
|
||||
$html .= $embed;
|
||||
\wp_enqueue_script( 'wp-embed' );
|
||||
@ -700,12 +718,12 @@ class Blocks {
|
||||
|
||||
// Show the link if embed is not requested or if embed failed.
|
||||
if ( ! $show_embed || ! $embed ) {
|
||||
$html .= sprintf(
|
||||
$html .= \sprintf(
|
||||
'<p><a title="%2$s" aria-label="%2$s" href="%1$s" class="u-in-reply-to" target="_blank">%3$s</a></p>',
|
||||
esc_url( $attrs['url'] ),
|
||||
esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
|
||||
\esc_url( $attrs['url'] ),
|
||||
\esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
|
||||
// translators: %s is the URL of the post being replied to.
|
||||
sprintf( __( '↬%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', esc_url( $attrs['url'] ) ) )
|
||||
\sprintf( \__( '↬%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', \esc_url( $attrs['url'] ) ) )
|
||||
);
|
||||
}
|
||||
|
||||
@ -817,7 +835,7 @@ class Blocks {
|
||||
'show_pagination' => true,
|
||||
'total' => 0,
|
||||
'per_page' => 10,
|
||||
'nav_label' => __( 'Actor navigation', 'activitypub' ),
|
||||
'nav_label' => \__( 'Actor navigation', 'activitypub' ),
|
||||
);
|
||||
|
||||
$args = \wp_parse_args( $args, $defaults );
|
||||
@ -1040,7 +1058,7 @@ class Blocks {
|
||||
'<p class="ap-reply-mention"><a rel="mention ugc" href="%1$s" title="%2$s">%3$s</a></p>',
|
||||
\esc_url( $url ),
|
||||
\esc_attr( $webfinger ),
|
||||
\esc_html( '@' . strtok( $webfinger, '@' ) )
|
||||
\esc_html( '@' . \strtok( $webfinger, '@' ) )
|
||||
);
|
||||
}
|
||||
|
||||
@ -1146,7 +1164,7 @@ class Blocks {
|
||||
if ( ! isset( $block['attrs']['url'] ) ) {
|
||||
return $block_content;
|
||||
}
|
||||
return '<p><a href="' . esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
|
||||
return '<p><a href="' . \esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1289,7 +1307,7 @@ class Blocks {
|
||||
$query_post_type = $query->get( 'post_type' );
|
||||
if ( ! empty( $query_post_type ) && 'any' !== $query_post_type ) {
|
||||
$query_post_types = (array) $query_post_type;
|
||||
if ( ! array_intersect( $query_post_types, \get_post_types_by_support( 'activitypub' ) ) ) {
|
||||
if ( ! \array_intersect( $query_post_types, \get_post_types_by_support( 'activitypub' ) ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,187 @@
|
||||
<?php
|
||||
/**
|
||||
* Blurhash encoder.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub;
|
||||
|
||||
/**
|
||||
* Encodes pixel data into a Blurhash placeholder string.
|
||||
*
|
||||
* A first-party port of the public Blurhash encode algorithm
|
||||
* (https://github.com/woltapp/blurhash, MIT). Adapted from
|
||||
* Automattic/FOSSE (https://github.com/Automattic/fosse), which used the
|
||||
* kornrunner/blurhash library; this replaces that runtime dependency so
|
||||
* the plugin ships Blurhash support out of the box.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*/
|
||||
class Blurhash_Encoder {
|
||||
|
||||
/**
|
||||
* Base83 alphabet defined by the Blurhash spec.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~';
|
||||
|
||||
/**
|
||||
* Encode a pixel array into a Blurhash string.
|
||||
*
|
||||
* @param array $pixels Row-major `[r,g,b][][]` array (0-255 per channel), indexed `[$y][$x]`.
|
||||
* @param int $components_x Horizontal component count (1-9).
|
||||
* @param int $components_y Vertical component count (1-9).
|
||||
* @return string Blurhash string, or '' on invalid input.
|
||||
*/
|
||||
public static function encode( $pixels, $components_x, $components_y ) {
|
||||
$components_x = (int) $components_x;
|
||||
$components_y = (int) $components_y;
|
||||
|
||||
if ( $components_x < 1 || $components_x > 9 || $components_y < 1 || $components_y > 9 ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$height = \count( $pixels );
|
||||
if ( $height < 1 || ! isset( $pixels[0] ) || ! \is_array( $pixels[0] ) ) {
|
||||
return '';
|
||||
}
|
||||
$width = \count( $pixels[0] );
|
||||
if ( $width < 1 ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$factors = array();
|
||||
for ( $y = 0; $y < $components_y; $y++ ) {
|
||||
for ( $x = 0; $x < $components_x; $x++ ) {
|
||||
$normalisation = ( 0 === $x && 0 === $y ) ? 1.0 : 2.0;
|
||||
$r = 0.0;
|
||||
$g = 0.0;
|
||||
$b = 0.0;
|
||||
for ( $i = 0; $i < $width; $i++ ) {
|
||||
for ( $j = 0; $j < $height; $j++ ) {
|
||||
$basis = $normalisation
|
||||
* \cos( \pi() * $x * $i / $width )
|
||||
* \cos( \pi() * $y * $j / $height );
|
||||
$pixel = $pixels[ $j ][ $i ];
|
||||
$r += $basis * self::srgb_to_linear( (int) $pixel[0] );
|
||||
$g += $basis * self::srgb_to_linear( (int) $pixel[1] );
|
||||
$b += $basis * self::srgb_to_linear( (int) $pixel[2] );
|
||||
}
|
||||
}
|
||||
$scale = 1.0 / ( $width * $height );
|
||||
$factors[] = array( $r * $scale, $g * $scale, $b * $scale );
|
||||
}
|
||||
}
|
||||
|
||||
$dc = $factors[0];
|
||||
$ac = \array_slice( $factors, 1 );
|
||||
|
||||
$hash = self::encode83( ( $components_x - 1 ) + ( $components_y - 1 ) * 9, 1 );
|
||||
$max_value = 1.0;
|
||||
|
||||
if ( \count( $ac ) > 0 ) {
|
||||
$actual_max = 0.0;
|
||||
foreach ( $ac as $factor ) {
|
||||
$actual_max = \max( $actual_max, \abs( $factor[0] ), \abs( $factor[1] ), \abs( $factor[2] ) );
|
||||
}
|
||||
$quantised_max = (int) \max( 0, \min( 82, \floor( $actual_max * 166 - 0.5 ) ) );
|
||||
$max_value = ( $quantised_max + 1 ) / 166;
|
||||
$hash .= self::encode83( $quantised_max, 1 );
|
||||
} else {
|
||||
$hash .= self::encode83( 0, 1 );
|
||||
}
|
||||
|
||||
$hash .= self::encode83( self::encode_dc( $dc ), 4 );
|
||||
foreach ( $ac as $factor ) {
|
||||
$hash .= self::encode83( self::encode_ac( $factor, $max_value ), 2 );
|
||||
}
|
||||
|
||||
return $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an integer to a fixed-length base83 string.
|
||||
*
|
||||
* @param int $value Value.
|
||||
* @param int $length Output length.
|
||||
* @return string
|
||||
*/
|
||||
private static function encode83( $value, $length ) {
|
||||
$value = (int) $value;
|
||||
$result = '';
|
||||
for ( $i = 1; $i <= $length; $i++ ) {
|
||||
$digit = (int) ( $value / ( 83 ** ( $length - $i ) ) ) % 83;
|
||||
$result .= self::ALPHABET[ $digit ];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an sRGB 0-255 channel to linear 0-1.
|
||||
*
|
||||
* @param int $value Channel value.
|
||||
* @return float
|
||||
*/
|
||||
private static function srgb_to_linear( $value ) {
|
||||
$v = $value / 255.0;
|
||||
if ( $v <= 0.04045 ) {
|
||||
return $v / 12.92;
|
||||
}
|
||||
return \pow( ( $v + 0.055 ) / 1.055, 2.4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a linear 0-1 channel to sRGB 0-255.
|
||||
*
|
||||
* @param float $value Linear value.
|
||||
* @return int
|
||||
*/
|
||||
private static function linear_to_srgb( $value ) {
|
||||
$v = \max( 0.0, \min( 1.0, $value ) );
|
||||
if ( $v <= 0.0031308 ) {
|
||||
return (int) ( $v * 12.92 * 255 + 0.5 );
|
||||
}
|
||||
return (int) ( ( 1.055 * \pow( $v, 1 / 2.4 ) - 0.055 ) * 255 + 0.5 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the DC (average color) factor.
|
||||
*
|
||||
* @param array $factor `[r,g,b]` linear floats.
|
||||
* @return int
|
||||
*/
|
||||
private static function encode_dc( $factor ) {
|
||||
$r = self::linear_to_srgb( $factor[0] );
|
||||
$g = self::linear_to_srgb( $factor[1] );
|
||||
$b = self::linear_to_srgb( $factor[2] );
|
||||
return ( $r << 16 ) + ( $g << 8 ) + $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an AC factor against the maximum value.
|
||||
*
|
||||
* @param array $factor `[r,g,b]` linear floats.
|
||||
* @param float $max_value Quantisation maximum.
|
||||
* @return int
|
||||
*/
|
||||
private static function encode_ac( $factor, $max_value ) {
|
||||
$quant_r = (int) \max( 0, \min( 18, \floor( self::sign_pow( $factor[0] / $max_value, 0.5 ) * 9 + 9.5 ) ) );
|
||||
$quant_g = (int) \max( 0, \min( 18, \floor( self::sign_pow( $factor[1] / $max_value, 0.5 ) * 9 + 9.5 ) ) );
|
||||
$quant_b = (int) \max( 0, \min( 18, \floor( self::sign_pow( $factor[2] / $max_value, 0.5 ) * 9 + 9.5 ) ) );
|
||||
return $quant_r * 19 * 19 + $quant_g * 19 + $quant_b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-preserving power.
|
||||
*
|
||||
* @param float $value Base.
|
||||
* @param float $exp Exponent.
|
||||
* @return float
|
||||
*/
|
||||
private static function sign_pow( $value, $exp ) {
|
||||
$result = \pow( \abs( $value ), $exp );
|
||||
return $value < 0 ? -$result : $result;
|
||||
}
|
||||
}
|
||||
745
wp-content/plugins/activitypub/includes/class-blurhash.php
Normal file
745
wp-content/plugins/activitypub/includes/class-blurhash.php
Normal file
@ -0,0 +1,745 @@
|
||||
<?php
|
||||
/**
|
||||
* Blurhash encoder + storage for image attachments.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub;
|
||||
|
||||
/**
|
||||
* Compute, store, and inject Blurhash placeholder strings for image
|
||||
* attachments so federated ActivityPub posts emit
|
||||
* `attachment[].blurhash` — the colored-blur preview that Pixelfed,
|
||||
* Mastodon, and other fediverse clients paint while the full image
|
||||
* is still loading. Without it, federated WP photos sit on a grey
|
||||
* placeholder where native uploads paint instantly; with it, the
|
||||
* loading state matches native.
|
||||
*
|
||||
* Encoding runs at upload time via `wp_generate_attachment_metadata`,
|
||||
* deferred to cron so the upload UI returns immediately. The hash
|
||||
* is persisted as attachment postmeta ({@see self::META_KEY}) and
|
||||
* read back by the `activitypub_attachment` projector — zero
|
||||
* per-publish CPU cost, federation hot path stays cheap.
|
||||
*
|
||||
* Pure no-op when GD isn't available: the encoder needs an
|
||||
* `[r,g,b][][]` pixel array, and GD's `imagecreatefrom*` family is
|
||||
* the only host-portable way to build one. Sites running Imagick-only
|
||||
* just don't get blurhash placeholders — attachments still federate,
|
||||
* minus the field. Same posture for any other failure (unreadable
|
||||
* file, encoder exception, deleted attachment): never blocks the
|
||||
* upload, never blocks federation.
|
||||
*
|
||||
* Called from `activitypub.php` on `init`.
|
||||
* Adapted from Automattic/FOSSE (https://github.com/Automattic/fosse).
|
||||
*
|
||||
* @since 9.0.0
|
||||
*/
|
||||
class Blurhash {
|
||||
|
||||
/**
|
||||
* Postmeta key holding the encoded blurhash for an attachment.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const META_KEY = '_activitypub_blurhash';
|
||||
|
||||
/**
|
||||
* Cron hook fired for each attachment that needs a hash computed.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CRON_HOOK = 'activitypub_blurhash_compute';
|
||||
|
||||
/**
|
||||
* DCT component count passed to the encoder as BOTH the X and
|
||||
* Y dimensions — kept as a single number because the two
|
||||
* dimensions are always equal in this encoder and splitting
|
||||
* them implies a tuning surface that doesn't exist. Wolt's
|
||||
* reference recommends 4–5 for landscape and lower for
|
||||
* portrait; 4 is the middle ground Mastodon also defaults to.
|
||||
* Hash length grows with the product, so 4×4 keeps the encoded
|
||||
* string short.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const COMPONENTS = 4;
|
||||
|
||||
/**
|
||||
* Upper bound on stored blurhash string length, in characters.
|
||||
* A 4×4 component grid produces a 30-character hash; the
|
||||
* theoretical max for the 9×9 component grid the spec supports
|
||||
* is 99 characters. We cap a little above that as a defense
|
||||
* against postmeta poisoning — anyone with `edit_post_meta` on
|
||||
* an attachment could otherwise write arbitrary bytes that we
|
||||
* would then federate straight into the AP envelope.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const MAX_HASH_LENGTH = 128;
|
||||
|
||||
/**
|
||||
* Image size used as the encoder's source. Blurhash encoding is
|
||||
* O(N) over pixel count and the output is a few low-frequency DCT
|
||||
* coefficients — feeding it a 12-megapixel original would burn
|
||||
* CPU producing a hash that's perceptually identical to the one
|
||||
* computed off the ~150px thumbnail. WP's `thumbnail` size is the
|
||||
* smallest variant guaranteed to exist for every uploaded image.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const ENCODE_SIZE = 'thumbnail';
|
||||
|
||||
/**
|
||||
* Hard upper bound on the longest edge fed to the encoder. Even
|
||||
* when {@see self::resolve_encode_path()} returns the original
|
||||
* (no intermediate, fallback path, misconfigured thumbnail size),
|
||||
* the GD image is downscaled to this max edge before the per-pixel
|
||||
* array is built. Keeps the PHP array allocation bounded — without
|
||||
* this cap, a 4000×3000 original would build a 12M-cell nested
|
||||
* array (~960 MB) and OOM the cron worker.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const MAX_ENCODE_EDGE = 64;
|
||||
|
||||
/**
|
||||
* Hard upper bound on the byte size of the source file fed into
|
||||
* GD. Defends against pathological cases where {@see self::resolve_encode_path()}
|
||||
* resolves to a huge original (or, via filterable
|
||||
* `image_get_intermediate_size`, a path that's not actually an
|
||||
* image at all). 8 MiB comfortably accommodates any realistic
|
||||
* web-photo upload at full quality.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const MAX_ENCODE_BYTES = 8388608;
|
||||
|
||||
/**
|
||||
* Hard upper bound on the DECODED pixel count (width × height) of
|
||||
* the source image. `imagecreatefromstring()` fully decodes the
|
||||
* compressed bytes into an uncompressed GD bitmap BEFORE
|
||||
* {@see self::encode_from_attachment()} downscales to
|
||||
* {@see self::MAX_ENCODE_EDGE}, so a small, highly compressible
|
||||
* source (e.g. a flat-color PNG declaring 30000×30000) slips past
|
||||
* the {@see self::MAX_ENCODE_BYTES} byte cap yet forces a
|
||||
* multi-gigabyte allocation — an uncatchable OOM that kills the
|
||||
* cron worker or aborts a CLI backfill mid-run. We read the
|
||||
* declared dimensions with `getimagesizefromstring()` first and
|
||||
* skip (`encode_from_attachment()` returns `false`, which callers
|
||||
* treat as "we don't encode this", not a failure) when the
|
||||
* product exceeds this cap. 50 megapixels comfortably covers any
|
||||
* realistic camera/phone upload while rejecting decompression bombs.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const MAX_ENCODE_PIXELS = 50000000;
|
||||
|
||||
/**
|
||||
* Raster MIME types GD can decode via `imagecreatefromstring`.
|
||||
* Used as the early gate that splits "we don't encode this"
|
||||
* (skip silently) from "encoder failed" (emit warning, count
|
||||
* against exit status). SVG/XML, ICO, and other formats either
|
||||
* GD can't read or aren't raster get filtered out before the
|
||||
* encoder runs.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ENCODABLE_MIME_TYPES = array(
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/bmp',
|
||||
);
|
||||
|
||||
/**
|
||||
* Register all hooks. Called from `activitypub.php` on `init`.
|
||||
*/
|
||||
public static function init(): void {
|
||||
\add_filter( 'wp_generate_attachment_metadata', array( self::class, 'schedule_encode' ), 10, 2 );
|
||||
\add_action( self::CRON_HOOK, array( self::class, 'run_encode' ), 10, 1 );
|
||||
\add_filter( 'activitypub_attachment', array( self::class, 'inject_blurhash' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the stored blurhash for an attachment, or null when no
|
||||
* usable value is stored. Empty/whitespace/non-string values are
|
||||
* treated as absent, AND values that fail
|
||||
* {@see self::is_well_formed_hash()} are too — so postmeta
|
||||
* poisoning (or an old encoder bug that wrote junk) doesn't
|
||||
* leak into the federation envelope AND doesn't permanently
|
||||
* stick the cron `run_encode` short-circuit (which keys off
|
||||
* this returning non-null). Net effect: a malformed row
|
||||
* self-heals on the next `wp_generate_attachment_metadata`
|
||||
* cycle because `get()` reports absent, `run_encode` proceeds,
|
||||
* and `set()` overwrites the malformed value.
|
||||
*
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
* @return string|null
|
||||
*/
|
||||
public static function get( int $attachment_id ): ?string {
|
||||
$value = \get_post_meta( $attachment_id, self::META_KEY, true );
|
||||
if ( ! \is_string( $value ) ) {
|
||||
return null;
|
||||
}
|
||||
$value = \trim( $value );
|
||||
if ( '' === $value ) {
|
||||
return null;
|
||||
}
|
||||
return self::is_well_formed_hash( $value ) ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a computed blurhash on the attachment.
|
||||
*
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
* @param string $hash Encoded blurhash string.
|
||||
*/
|
||||
public static function set( int $attachment_id, string $hash ): void {
|
||||
\update_post_meta( $attachment_id, self::META_KEY, $hash );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete any stored blurhash for an attachment. Used by the
|
||||
* upload/regen invalidation path ({@see self::schedule_encode()}) so a
|
||||
* replaced image re-encodes against its latest bytes.
|
||||
*
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
*/
|
||||
public static function delete( int $attachment_id ): void {
|
||||
\delete_post_meta( $attachment_id, self::META_KEY );
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute (synchronously) the blurhash for an attachment by
|
||||
* loading the configured size's file through GD and feeding the
|
||||
* pixel array to the encoder. Never throws, never warns. Used by
|
||||
* both the cron handler and the WP-CLI backfill.
|
||||
*
|
||||
* Three-state return so callers can route outcomes to the right
|
||||
* bucket: a string is success; `false` means the source is
|
||||
* deliberately outside encode policy and must be skipped silently —
|
||||
* a non-encodable attachment (non-raster mime, a deleted or
|
||||
* nonexistent attachment ID, or a format the host GD build can't
|
||||
* decode), an unavailable encoder, or a declared pixel count over
|
||||
* {@see self::MAX_ENCODE_PIXELS}; `null` is an unexpected failure
|
||||
* (the file behind an otherwise-encodable attachment is missing,
|
||||
* unreadable, or corrupt, or GD/the encoder errored) that callers
|
||||
* surface for monitoring. No native return type because union types
|
||||
* require PHP 8.0 and the plugin supports 7.4.
|
||||
*
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
* @return string|false|null Hash on success, false on policy skip, null on failure.
|
||||
*/
|
||||
public static function encode_from_attachment( int $attachment_id ) {
|
||||
/*
|
||||
* Predictable unencodability (non-raster mime, missing/deleted
|
||||
* attachment, host GD that can't decode the format, or no GD at
|
||||
* all) is a policy skip, not a failure: `false` routes direct
|
||||
* callers to the silent bucket instead of the diagnostic one.
|
||||
*/
|
||||
if ( ! self::is_encodable_attachment( $attachment_id ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! self::is_encoder_runnable() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = self::resolve_encode_path( $attachment_id );
|
||||
if ( null === $path ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fast-fail before reading bytes. A pathological source
|
||||
// (huge original, non-image file slipped through a filterable
|
||||
// metadata path) gets rejected without allocating PHP memory
|
||||
// for the read.
|
||||
$size = @\filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- stat failure returns false and we handle.
|
||||
if ( false === $size || $size < 1 || $size > self::MAX_ENCODE_BYTES ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- local absolute path read; corrupt/missing file returns false and we handle.
|
||||
$bytes = @\file_get_contents( $path );
|
||||
if ( false === $bytes || '' === $bytes ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Decode-bomb guard. `imagecreatefromstring()` fully decodes
|
||||
* the compressed bytes into an uncompressed bitmap before we
|
||||
* get a chance to downscale, so a small but highly
|
||||
* compressible source declaring huge dimensions (e.g. a
|
||||
* flat-color 30000×30000 PNG) would force a multi-gigabyte
|
||||
* allocation and OOM the worker — uncatchable, so we can't
|
||||
* recover with the try/catch below. Read the declared
|
||||
* dimensions cheaply first and skip (silent, not an error)
|
||||
* anything past the megapixel cap.
|
||||
*/
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed header returns false and we handle.
|
||||
$dimensions = @\getimagesizefromstring( $bytes );
|
||||
if ( false === $dimensions || ! isset( $dimensions[0] ) || ! isset( $dimensions[1] ) ) {
|
||||
return null;
|
||||
}
|
||||
$declared_width = (int) $dimensions[0];
|
||||
$declared_height = (int) $dimensions[1];
|
||||
if ( $declared_width < 1 || $declared_height < 1 ) {
|
||||
return null;
|
||||
}
|
||||
if ( $declared_width * $declared_height > self::MAX_ENCODE_PIXELS ) {
|
||||
/*
|
||||
* Policy skip, not a failure: `false` routes the caller
|
||||
* to the same silent bucket as a non-raster mime, so a
|
||||
* permanently over-cap source doesn't error_log on every
|
||||
* cron run or hold the CLI backfill exit code nonzero
|
||||
* forever.
|
||||
*/
|
||||
return false;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- corrupt image returns false and we handle.
|
||||
$original = @\imagecreatefromstring( $bytes );
|
||||
if ( false === $original ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* $scaled holds a second GD resource created by imagescale when
|
||||
* the image exceeds MAX_ENCODE_EDGE, $canvas a third one
|
||||
* created for the transparency flattening composite. Both are
|
||||
* kept separate from $original so all of them can be destroyed
|
||||
* in finally regardless of which code path ran.
|
||||
*/
|
||||
$scaled = null;
|
||||
$canvas = null;
|
||||
|
||||
try {
|
||||
$width = \imagesx( $original );
|
||||
$height = \imagesy( $original );
|
||||
if ( $width < 1 || $height < 1 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The image we will actually read pixels from — either the
|
||||
// original or a downscaled copy.
|
||||
$src_image = $original;
|
||||
|
||||
// Defensive downscale before the per-pixel loop. The
|
||||
// nested array grows quadratically with edge length, so
|
||||
// any source larger than MAX_ENCODE_EDGE gets sampled
|
||||
// down — perceptually identical output, bounded memory.
|
||||
//
|
||||
// Scale by the LONGER edge so portrait images
|
||||
// (`height > width`) don't get upscaled by a
|
||||
// fixed-width call to `imagescale`. Target dimensions
|
||||
// are computed explicitly so both edges land at or
|
||||
// below the cap. If `imagescale` fails we bail rather
|
||||
// than fall through to the per-pixel loop with the
|
||||
// original (oversized) GD image.
|
||||
if ( $width > self::MAX_ENCODE_EDGE || $height > self::MAX_ENCODE_EDGE ) {
|
||||
if ( $width >= $height ) {
|
||||
$target_width = self::MAX_ENCODE_EDGE;
|
||||
$target_height = (int) \max( 1, \round( $height * ( self::MAX_ENCODE_EDGE / $width ) ) );
|
||||
} else {
|
||||
$target_height = self::MAX_ENCODE_EDGE;
|
||||
$target_width = (int) \max( 1, \round( $width * ( self::MAX_ENCODE_EDGE / $height ) ) );
|
||||
}
|
||||
$scaled = \imagescale( $original, $target_width, $target_height );
|
||||
if ( false === $scaled ) {
|
||||
return null;
|
||||
}
|
||||
$src_image = $scaled;
|
||||
$width = $target_width;
|
||||
$height = $target_height;
|
||||
}
|
||||
|
||||
/*
|
||||
* Flatten transparency against a white background before
|
||||
* sampling. `imagecolorsforindex()` reports the raw RGB of
|
||||
* a transparent pixel (usually 0,0,0 → black) while
|
||||
* discarding alpha, so a transparent PNG/GIF/WebP logo or
|
||||
* sticker would otherwise encode to a near-black blurhash.
|
||||
* Compositing onto an opaque white canvas yields the color
|
||||
* a viewer actually sees over a typical light surface.
|
||||
* Best-effort: if any GD call fails we keep sampling the
|
||||
* un-flattened image rather than bail.
|
||||
*/
|
||||
$canvas = \imagecreatetruecolor( $width, $height );
|
||||
if ( false !== $canvas ) {
|
||||
$white = \imagecolorallocate( $canvas, 255, 255, 255 );
|
||||
if ( false !== $white ) {
|
||||
\imagefilledrectangle( $canvas, 0, 0, $width - 1, $height - 1, $white );
|
||||
\imagealphablending( $canvas, true );
|
||||
if ( \imagecopy( $canvas, $src_image, 0, 0, 0, 0, $width, $height ) ) {
|
||||
$src_image = $canvas;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pixels = array();
|
||||
for ( $y = 0; $y < $height; $y++ ) {
|
||||
$row = array();
|
||||
for ( $x = 0; $x < $width; $x++ ) {
|
||||
$index = \imagecolorat( $src_image, $x, $y );
|
||||
$colors = \imagecolorsforindex( $src_image, $index );
|
||||
$row[] = array( $colors['red'], $colors['green'], $colors['blue'] );
|
||||
}
|
||||
$pixels[] = $row;
|
||||
}
|
||||
|
||||
$hash = Blurhash_Encoder::encode( $pixels, self::COMPONENTS, self::COMPONENTS );
|
||||
return \is_string( $hash ) && '' !== $hash ? $hash : null;
|
||||
} catch ( \Throwable $e ) {
|
||||
return null;
|
||||
} finally {
|
||||
// Free GD resources. On PHP 7.4 GD images are plain
|
||||
// resources that persist until script end; the CLI
|
||||
// backfill processes many images in one process and
|
||||
// would leak all of them without explicit cleanup.
|
||||
\imagedestroy( $original );
|
||||
if ( null !== $scaled && false !== $scaled ) {
|
||||
\imagedestroy( $scaled );
|
||||
}
|
||||
if ( null !== $canvas && false !== $canvas ) {
|
||||
\imagedestroy( $canvas );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute filesystem path of the size we use as
|
||||
* encoder input, falling back to the original when the
|
||||
* intermediate doesn't exist (small uploads that core didn't
|
||||
* generate downscales for).
|
||||
*
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
* @return string|null Absolute file path, or null when nothing readable resolved.
|
||||
*/
|
||||
private static function resolve_encode_path( int $attachment_id ): ?string {
|
||||
$upload = \wp_upload_dir();
|
||||
$basedir_real = ( \is_array( $upload ) && ! empty( $upload['basedir'] ) )
|
||||
? \realpath( $upload['basedir'] )
|
||||
: false;
|
||||
|
||||
$sized = \image_get_intermediate_size( $attachment_id, self::ENCODE_SIZE );
|
||||
if ( \is_array( $sized ) && ! empty( $sized['path'] ) && false !== $basedir_real ) {
|
||||
$candidate = \trailingslashit( $upload['basedir'] ) . $sized['path'];
|
||||
$resolved = self::contain_under_basedir( $candidate, $basedir_real );
|
||||
if ( null !== $resolved ) {
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
$attached = \get_attached_file( $attachment_id );
|
||||
if ( \is_string( $attached ) && '' !== $attached ) {
|
||||
// The fallback can return paths outside the uploads dir
|
||||
// (e.g. shared media), so containment is best-effort: we
|
||||
// accept readable real paths but skip the basedir prefix
|
||||
// check, while still rejecting unreadable / non-existent
|
||||
// targets.
|
||||
$resolved = \realpath( $attached );
|
||||
if ( false !== $resolved && \is_readable( $resolved ) ) {
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Realpath-resolve `$candidate` and verify it sits under
|
||||
* `$basedir_real`. Returns the resolved path on success, null on
|
||||
* any failure (unresolvable, traversal outside the basedir,
|
||||
* unreadable). Defends the encoder against filterable
|
||||
* `image_get_intermediate_size` returning a `path` that contains
|
||||
* `..` segments or an absolute symlink target outside uploads.
|
||||
*
|
||||
* @param string $candidate Path to resolve.
|
||||
* @param string $basedir_real Already-resolved (realpath) basedir.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function contain_under_basedir( string $candidate, string $basedir_real ): ?string {
|
||||
$resolved = \realpath( $candidate );
|
||||
if ( false === $resolved ) {
|
||||
return null;
|
||||
}
|
||||
$prefix = \rtrim( $basedir_real, \DIRECTORY_SEPARATOR ) . \DIRECTORY_SEPARATOR;
|
||||
if ( 0 !== \strpos( $resolved, $prefix ) ) {
|
||||
return null;
|
||||
}
|
||||
return \is_readable( $resolved ) ? $resolved : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `wp_generate_attachment_metadata` filter callback. Schedules
|
||||
* a single-event cron run to compute the blurhash for image
|
||||
* attachments. The filter return value is the metadata unchanged
|
||||
* — we use the hook purely as an "image is ready" notifier.
|
||||
*
|
||||
* @param array $metadata Attachment metadata as built by WP.
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
* @return array
|
||||
*/
|
||||
public static function schedule_encode( $metadata, $attachment_id ) {
|
||||
$attachment_id = (int) $attachment_id;
|
||||
if ( $attachment_id < 1 || ! self::is_encodable_attachment( $attachment_id ) ) {
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
// Skip both the invalidation AND the cron enqueue when the
|
||||
// encoder can't actually run on this host. Without the gate,
|
||||
// a metadata regen on a GD-less site would wipe a previously
|
||||
// stored hash (computed on a different host, restored from
|
||||
// backup, etc.) and queue a cron event guaranteed to fail.
|
||||
if ( ! self::is_encoder_runnable() ) {
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
// Invalidate any prior hash so cron will re-encode against
|
||||
// the latest bytes. `wp_generate_attachment_metadata` fires
|
||||
// on initial upload AND on media-replace / crop / regen, so
|
||||
// without this delete a replaced image would keep federating
|
||||
// the placeholder for its prior bytes.
|
||||
self::delete( $attachment_id );
|
||||
|
||||
self::schedule( $attachment_id );
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an attachment is something the encoder will attempt on this host.
|
||||
* True for `wp_attachment_is_image` attachments whose mime is in
|
||||
* {@see self::ENCODABLE_MIME_TYPES} AND that this GD build can actually
|
||||
* decode; false for SVG, non-image media, deleted/nonexistent IDs, and
|
||||
* formats this GD build lacks support for (e.g. WebP/AVIF/BMP on a
|
||||
* stripped-down GD). Shared gate used by the upload-scheduling path, the
|
||||
* CLI backfill (to count "we don't encode this" as a skip rather than a
|
||||
* failure), and the encoder itself.
|
||||
*
|
||||
* The GD-capability check matters because `schedule_encode()` invalidates
|
||||
* any prior hash before queueing the cron encode: without it, a metadata
|
||||
* regen on a host that can't decode the format would wipe a previously
|
||||
* good hash (e.g. migrated from a host with broader GD support) and queue
|
||||
* a cron event guaranteed to fail.
|
||||
*
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_encodable_attachment( int $attachment_id ): bool {
|
||||
if ( $attachment_id < 1 || ! \wp_attachment_is_image( $attachment_id ) ) {
|
||||
return false;
|
||||
}
|
||||
$mime = \get_post_mime_type( $attachment_id );
|
||||
return \is_string( $mime )
|
||||
&& \in_array( $mime, self::ENCODABLE_MIME_TYPES, true )
|
||||
&& self::host_can_decode( $mime );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this GD build can decode the given image mime type.
|
||||
*
|
||||
* GD support for WebP, AVIF, and BMP is build-dependent, so a mime being
|
||||
* in {@see self::ENCODABLE_MIME_TYPES} is necessary but not sufficient.
|
||||
* `imagetypes()` reports what the running GD can actually handle. The
|
||||
* `IMG_*` flags for WebP/AVIF/BMP are not defined on every PHP version
|
||||
* (`IMG_AVIF` is PHP 8.1+), so guard each with `defined()`.
|
||||
*
|
||||
* @param string $mime The attachment mime type.
|
||||
* @return bool
|
||||
*/
|
||||
private static function host_can_decode( $mime ) {
|
||||
if ( ! \function_exists( 'imagetypes' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$supported = \imagetypes();
|
||||
|
||||
switch ( $mime ) {
|
||||
case 'image/jpeg':
|
||||
return (bool) ( $supported & IMG_JPG );
|
||||
case 'image/png':
|
||||
return (bool) ( $supported & IMG_PNG );
|
||||
case 'image/gif':
|
||||
return (bool) ( $supported & IMG_GIF );
|
||||
case 'image/webp':
|
||||
return \defined( 'IMG_WEBP' ) && (bool) ( $supported & IMG_WEBP );
|
||||
case 'image/avif':
|
||||
return \defined( 'IMG_AVIF' ) && (bool) ( $supported & IMG_AVIF );
|
||||
case 'image/bmp':
|
||||
return \defined( 'IMG_BMP' ) && (bool) ( $supported & IMG_BMP );
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the host has the GD primitives the encoder needs to
|
||||
* actually run. Public so the WP-CLI backfill can fail-fast with
|
||||
* one clear error message rather than emit a warning per
|
||||
* attachment, and so the upload/cron paths can short-circuit
|
||||
* without leaving cron noise behind on a GD-less host.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_encoder_runnable(): bool {
|
||||
return \function_exists( 'imagecreatefromstring' )
|
||||
&& \function_exists( 'imagecreatetruecolor' )
|
||||
&& \function_exists( 'imagescale' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue (or skip, if already queued) a cron event to compute
|
||||
* the blurhash for an attachment. Internal helper for
|
||||
* {@see self::schedule_encode()} — callers should go through
|
||||
* that filter callback so the metadata-regen invalidation pass
|
||||
* stays load-bearing.
|
||||
*
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
*/
|
||||
private static function schedule( int $attachment_id ): void {
|
||||
if ( $attachment_id < 1 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Rely on WP's own duplicate-event guard inside
|
||||
* `wp_schedule_single_event` (rejects matching args within
|
||||
* the 10-minute window) rather than running an explicit
|
||||
* `wp_next_scheduled` check first. The explicit check did
|
||||
* nothing the underlying scheduler doesn't already do and
|
||||
* added a needless read against the autoloaded cron option
|
||||
* on every attachment metadata regen.
|
||||
*
|
||||
* Defer one minute rather than firing at `time()`. This
|
||||
* callback runs inside the `wp_generate_attachment_metadata`
|
||||
* filter, BEFORE `wp_update_attachment_metadata()` commits the
|
||||
* sizes array. A concurrent wp-cron tick could otherwise run
|
||||
* `run_encode()` before that commit lands, find no thumbnail
|
||||
* intermediate yet, and fall back to encoding the full-size
|
||||
* original. The one-minute delay lets the metadata write
|
||||
* settle first; single-event dedup semantics are unchanged.
|
||||
*/
|
||||
\wp_schedule_single_event( \time() + MINUTE_IN_SECONDS, self::CRON_HOOK, array( $attachment_id ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron callback: compute and store the blurhash. No-op when a
|
||||
* hash is already stored; callers wanting a re-encode delete
|
||||
* the postmeta first ({@see self::delete()}) — that's what
|
||||
* {@see self::schedule_encode()} does before scheduling, so the
|
||||
* media-replace path always recomputes.
|
||||
*
|
||||
* Emits `activitypub_blurhash_encode_failed` (action) and an
|
||||
* `error_log` line when the encoder returns null without a
|
||||
* pre-existing stored hash, so transient failures (NFS hiccup,
|
||||
* S3 lag, GD blip) leave a signal for monitoring instead of
|
||||
* silently never producing a placeholder.
|
||||
*
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
*/
|
||||
public static function run_encode( int $attachment_id ): void {
|
||||
$attachment_id = (int) $attachment_id;
|
||||
if ( $attachment_id < 1 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Predictable unencodability (system-wide GD missing,
|
||||
// non-raster mime, deleted attachment) is silent — logging
|
||||
// per-event would spam diagnostics on every scheduled run
|
||||
// even though the reason is global. Only unexpected failures
|
||||
// (encoder exception, missing file, decode failure) below
|
||||
// fire the diagnostic action.
|
||||
if ( ! self::is_encoder_runnable() || ! self::is_encodable_attachment( $attachment_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( null !== self::get( $attachment_id ) ) {
|
||||
return;
|
||||
}
|
||||
$hash = self::encode_from_attachment( $attachment_id );
|
||||
if ( \is_string( $hash ) ) {
|
||||
self::set( $attachment_id, $hash );
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* `false` is a policy skip (source over the decode-bomb
|
||||
* dimension cap): deliberate, deterministic, and global to
|
||||
* the source bytes — logging it would re-introduce the
|
||||
* per-run noise this bucket exists to avoid. Only `null`
|
||||
* (unexpected failure) falls through to diagnostics.
|
||||
*/
|
||||
if ( false === $hash ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Silent-failure guard — surface the gap so an operator can
|
||||
// investigate (or wire monitoring against the action).
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- intentional plugin diagnostics; cron path only.
|
||||
\error_log( "[activitypub:blurhash] encode failed for attachment {$attachment_id}; placeholder will be absent until backfill." );
|
||||
|
||||
/**
|
||||
* Fires when the cron-deferred encoder fails to produce a
|
||||
* hash for an attachment. Monitoring integrations can hook
|
||||
* this to count blurhash-encode failures over time.
|
||||
*
|
||||
* @param int $attachment_id The attachment ID that failed to encode.
|
||||
*/
|
||||
\do_action( 'activitypub_blurhash_encode_failed', $attachment_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* `activitypub_attachment` filter callback. Injects `blurhash`
|
||||
* into image attachment arrays when a usable postmeta value is
|
||||
* stored. No-op on anything else (non-image attachments, missing
|
||||
* meta, malformed meta, malformed arrays) so non-photo federation
|
||||
* paths are untouched. Sanitization is enforced inside
|
||||
* {@see self::get()} — anyone with `edit_post_meta` on an
|
||||
* attachment could otherwise rewrite `_activitypub_blurhash` to bytes
|
||||
* that break `wp_json_encode` and drop the entire AP envelope.
|
||||
*
|
||||
* @param mixed $attachment The attachment array as built by bundled AP.
|
||||
* @param mixed $attachment_id The attachment post ID (mixed because the upstream filter is loosely typed).
|
||||
* @return mixed
|
||||
*/
|
||||
public static function inject_blurhash( $attachment, $attachment_id ) {
|
||||
if ( ! \is_array( $attachment ) ) {
|
||||
return $attachment;
|
||||
}
|
||||
if ( 'Image' !== ( $attachment['type'] ?? '' ) ) {
|
||||
return $attachment;
|
||||
}
|
||||
$hash = self::get( (int) $attachment_id );
|
||||
if ( null === $hash ) {
|
||||
return $attachment;
|
||||
}
|
||||
$attachment['blurhash'] = $hash;
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a stored hash against the blurhash spec's character
|
||||
* set and our length bound. Treat any out-of-bounds value as
|
||||
* absent rather than coerce — preserves the "no blurhash is
|
||||
* better than a wrong blurhash" posture the rest of the class
|
||||
* follows. The base83 alphabet is defined by the spec at
|
||||
* {@link https://github.com/woltapp/blurhash/blob/master/Algorithm.md#base-83}.
|
||||
*
|
||||
* @param string $hash Candidate hash string.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_well_formed_hash( string $hash ): bool {
|
||||
$length = \strlen( $hash );
|
||||
if ( $length < 6 || $length > self::MAX_HASH_LENGTH ) {
|
||||
return false;
|
||||
}
|
||||
// Base83 alphabet — same character set the encoder library
|
||||
// emits, conservative enough to reject any byte sequence
|
||||
// that would surprise a downstream JSON encoder or client
|
||||
// decoder.
|
||||
return 1 === \preg_match( '/\A[0-9A-Za-z#$%*+,\-.:;=?@\[\]\^_{|}~]+\z/', $hash );
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,19 @@
|
||||
|
||||
namespace Activitypub;
|
||||
|
||||
use Activitypub\Cli\Actor_Command;
|
||||
use Activitypub\Cli\Blurhash_Command;
|
||||
use Activitypub\Cli\Cache_Command;
|
||||
use Activitypub\Cli\Command;
|
||||
use Activitypub\Cli\Comment_Command;
|
||||
use Activitypub\Cli\Fetch_Command;
|
||||
use Activitypub\Cli\Follow_Command;
|
||||
use Activitypub\Cli\Move_Command;
|
||||
use Activitypub\Cli\Outbox_Command;
|
||||
use Activitypub\Cli\Post_Command;
|
||||
use Activitypub\Cli\Self_Destruct_Command;
|
||||
use Activitypub\Cli\Stats_Command;
|
||||
|
||||
/**
|
||||
* ActivityPub CLI command registry.
|
||||
*
|
||||
@ -33,12 +46,13 @@ class Cli {
|
||||
* - wp activitypub follow <remote_user>
|
||||
* - wp activitypub stats <collect|compile|send>
|
||||
* - wp activitypub fetch <url>
|
||||
* - wp activitypub blurhash backfill [--dry-run] [--limit=<n>] [--force]
|
||||
*/
|
||||
public static function register() {
|
||||
// Register parent command with version subcommand.
|
||||
\WP_CLI::add_command(
|
||||
'activitypub',
|
||||
'\Activitypub\Cli\Command',
|
||||
Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Manage ActivityPub plugin functionality and federation.',
|
||||
)
|
||||
@ -46,7 +60,7 @@ class Cli {
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub post',
|
||||
'\Activitypub\Cli\Post_Command',
|
||||
Post_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Manage ActivityPub posts (delete or update).',
|
||||
)
|
||||
@ -54,7 +68,7 @@ class Cli {
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub comment',
|
||||
'\Activitypub\Cli\Comment_Command',
|
||||
Comment_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Manage ActivityPub comments (delete or update).',
|
||||
)
|
||||
@ -62,7 +76,7 @@ class Cli {
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub actor',
|
||||
'\Activitypub\Cli\Actor_Command',
|
||||
Actor_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Manage ActivityPub actors (delete or update).',
|
||||
)
|
||||
@ -70,7 +84,7 @@ class Cli {
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub outbox',
|
||||
'\Activitypub\Cli\Outbox_Command',
|
||||
Outbox_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Manage ActivityPub outbox items (undo or reschedule).',
|
||||
)
|
||||
@ -78,7 +92,7 @@ class Cli {
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub self-destruct',
|
||||
'\Activitypub\Cli\Self_Destruct_Command',
|
||||
Self_Destruct_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Remove the entire blog from the Fediverse.',
|
||||
)
|
||||
@ -86,7 +100,7 @@ class Cli {
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub move',
|
||||
'\Activitypub\Cli\Move_Command',
|
||||
Move_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Move the blog to a new URL.',
|
||||
)
|
||||
@ -94,7 +108,7 @@ class Cli {
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub follow',
|
||||
'\Activitypub\Cli\Follow_Command',
|
||||
Follow_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Follow a remote ActivityPub user.',
|
||||
)
|
||||
@ -102,7 +116,7 @@ class Cli {
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub cache',
|
||||
'\Activitypub\Cli\Cache_Command',
|
||||
Cache_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Manage remote media cache (clear or show status).',
|
||||
)
|
||||
@ -110,7 +124,7 @@ class Cli {
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub fetch',
|
||||
'\Activitypub\Cli\Fetch_Command',
|
||||
Fetch_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Fetch a remote URL with a signed ActivityPub request.',
|
||||
)
|
||||
@ -118,10 +132,18 @@ class Cli {
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub stats',
|
||||
'\Activitypub\Cli\Stats_Command',
|
||||
Stats_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Manage ActivityPub statistics (collect, compile or send).',
|
||||
)
|
||||
);
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'activitypub blurhash',
|
||||
Blurhash_Command::class,
|
||||
array(
|
||||
'shortdesc' => 'Backfill Blurhash placeholders for image attachments.',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -113,7 +113,7 @@ class Comment {
|
||||
if ( \is_user_logged_in() ) {
|
||||
$author = \esc_html( $comment->comment_author );
|
||||
|
||||
$message = sprintf(
|
||||
$message = \sprintf(
|
||||
/* translators: %s: comment author name */
|
||||
\__( '%s is on the Fediverse. To reply to them, ask your administrator to enable ActivityPub for your account.', 'activitypub' ),
|
||||
$author
|
||||
@ -121,7 +121,7 @@ class Comment {
|
||||
|
||||
// Add link to users page if current user can edit users.
|
||||
if ( \current_user_can( 'edit_users' ) ) {
|
||||
$message = sprintf(
|
||||
$message = \sprintf(
|
||||
/* translators: 1: comment author name, 2: URL to the users management page */
|
||||
\__( '%1$s is on the Fediverse. To reply to them, <a href="%2$s">enable ActivityPub for your account</a>.', 'activitypub' ),
|
||||
$author,
|
||||
@ -129,7 +129,7 @@ class Comment {
|
||||
);
|
||||
}
|
||||
|
||||
$warning = sprintf(
|
||||
$warning = \sprintf(
|
||||
'<p class="activitypub-reply-warning"><em>%s</em></p>',
|
||||
\wp_kses( $message, array( 'a' => array( 'href' => array() ) ) )
|
||||
);
|
||||
@ -178,7 +178,7 @@ class Comment {
|
||||
return true;
|
||||
}
|
||||
|
||||
$current_user = get_current_user_id();
|
||||
$current_user = \get_current_user_id();
|
||||
|
||||
if ( ! $current_user ) {
|
||||
return false;
|
||||
@ -299,6 +299,17 @@ class Comment {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Do not federate brand-new comments on a post that is not federated itself
|
||||
* (e.g. a private post, a post switched to local visibility, or a non-ActivityPub
|
||||
* post type). This prevents leaking replies on content the post type's read rules
|
||||
* would otherwise protect. Comments that were already sent are allowed through so
|
||||
* their Update and Delete activities can still federate (and tear down remote copies).
|
||||
*/
|
||||
if ( ! self::was_sent( $comment ) && ! is_post_federated( $comment->comment_post_ID ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// It is a comment to the post and can be federated.
|
||||
if ( empty( $comment->comment_parent ) ) {
|
||||
return true;
|
||||
@ -313,20 +324,31 @@ class Comment {
|
||||
/**
|
||||
* Examine a comment ID and look up an existing comment it represents.
|
||||
*
|
||||
* @param string $id ActivityPub object ID (usually a URL) to check.
|
||||
* @since 9.1.0 Added the `$args` parameter.
|
||||
*
|
||||
* @param string $id ActivityPub object ID (usually a URL) to check.
|
||||
* @param array $args Optional. Additional WP_Comment_Query arguments. Pass `array( 'status' => 'any' )`
|
||||
* to also match comments in spam or trash, which the default status excludes.
|
||||
*
|
||||
* @return \WP_Comment|false Comment object, or false on failure.
|
||||
*/
|
||||
public static function object_id_to_comment( $id ) {
|
||||
$comment_query = new \WP_Comment_Query(
|
||||
public static function object_id_to_comment( $id, $args = array() ) {
|
||||
$args = \wp_parse_args(
|
||||
$args,
|
||||
array(
|
||||
'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
|
||||
'orderby' => 'comment_date',
|
||||
'order' => 'DESC',
|
||||
'number' => 1,
|
||||
'orderby' => 'comment_date',
|
||||
'order' => 'DESC',
|
||||
)
|
||||
);
|
||||
|
||||
// Force the lookup key and full comment objects, so callers cannot break the return contract.
|
||||
$args['fields'] = 'all';
|
||||
$args['meta_key'] = 'source_id'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
$args['meta_value'] = $id; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
|
||||
|
||||
$comment_query = new \WP_Comment_Query( $args );
|
||||
|
||||
if ( ! $comment_query->comments ) {
|
||||
return false;
|
||||
}
|
||||
@ -382,7 +404,7 @@ class Comment {
|
||||
$query = new \WP_Comment_Query();
|
||||
$comments = $query->query( $args );
|
||||
|
||||
if ( $comments && is_array( $comments ) ) {
|
||||
if ( $comments && \is_array( $comments ) ) {
|
||||
return $comments[0]->comment_ID;
|
||||
}
|
||||
|
||||
@ -400,7 +422,7 @@ class Comment {
|
||||
*/
|
||||
public static function comment_class( $classes, $css_class, $comment_id ) {
|
||||
// Check if ActivityPub comment.
|
||||
if ( 'activitypub' === get_comment_meta( $comment_id, 'protocol', true ) ) {
|
||||
if ( 'activitypub' === \get_comment_meta( $comment_id, 'protocol', true ) ) {
|
||||
$classes[] = 'activitypub-comment';
|
||||
}
|
||||
|
||||
@ -431,9 +453,9 @@ class Comment {
|
||||
$where .= $wpdb->prepare( ' AND comment_type = %s', $comment_type );
|
||||
} else {
|
||||
$comment_types = \array_map( 'esc_sql', $comment_types );
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $comment_types ), '%s' ) );
|
||||
$placeholders = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) );
|
||||
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.NotPrepared
|
||||
$where .= $wpdb->prepare( sprintf( ' AND comment_type NOT IN (%s)', $placeholders ), ...$comment_types );
|
||||
$where .= $wpdb->prepare( \sprintf( ' AND comment_type NOT IN (%s)', $placeholders ), ...$comment_types );
|
||||
}
|
||||
|
||||
return $where;
|
||||
@ -565,7 +587,7 @@ class Comment {
|
||||
$comment_types = self::get_comment_types();
|
||||
|
||||
foreach ( $comment_types as $comment_type ) {
|
||||
if ( in_array( $activity_type, $comment_type['activity_types'], true ) ) {
|
||||
if ( \in_array( $activity_type, $comment_type['activity_types'], true ) ) {
|
||||
return $comment_type;
|
||||
}
|
||||
}
|
||||
@ -606,13 +628,13 @@ class Comment {
|
||||
* @return array The registered custom comment type slugs.
|
||||
*/
|
||||
public static function get_comment_type_slugs() {
|
||||
if ( ! did_action( 'init' ) ) {
|
||||
_doing_it_wrong( __METHOD__, 'This function should not be called before the init action has run. Comment types are only available after init.', '7.5.0' );
|
||||
if ( ! \did_action( 'init' ) ) {
|
||||
\_doing_it_wrong( __METHOD__, 'This function should not be called before the init action has run. Comment types are only available after init.', '7.5.0' );
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
return array_keys( self::get_comment_types() );
|
||||
return \array_keys( self::get_comment_types() );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -628,14 +650,14 @@ class Comment {
|
||||
* @return array The comment type.
|
||||
*/
|
||||
public static function get_comment_type( $type ) {
|
||||
$type = strtolower( $type );
|
||||
$type = sanitize_key( $type );
|
||||
$type = \strtolower( $type );
|
||||
$type = \sanitize_key( $type );
|
||||
|
||||
$comment_types = self::get_comment_types();
|
||||
$type_array = array();
|
||||
|
||||
// Check array keys.
|
||||
if ( in_array( $type, array_keys( $comment_types ), true ) ) {
|
||||
if ( \in_array( $type, \array_keys( $comment_types ), true ) ) {
|
||||
$type_array = $comment_types[ $type ];
|
||||
}
|
||||
|
||||
@ -644,7 +666,7 @@ class Comment {
|
||||
*
|
||||
* @param array $type_array The comment type.
|
||||
*/
|
||||
return apply_filters( "activitypub_comment_type_{$type}", $type_array );
|
||||
return \apply_filters( "activitypub_comment_type_{$type}", $type_array );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -670,7 +692,7 @@ class Comment {
|
||||
* @param mixed $value The value of the attribute.
|
||||
* @param string $type The comment type.
|
||||
*/
|
||||
return apply_filters( "activitypub_comment_type_{$attr}", $value, $type );
|
||||
return \apply_filters( "activitypub_comment_type_{$attr}", $value, $type );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -680,57 +702,57 @@ class Comment {
|
||||
register_comment_type(
|
||||
'repost',
|
||||
array(
|
||||
'label' => __( 'Reposts', 'activitypub' ),
|
||||
'singular' => __( 'Repost', 'activitypub' ),
|
||||
'label' => \__( 'Reposts', 'activitypub' ),
|
||||
'singular' => \__( 'Repost', 'activitypub' ),
|
||||
'description' => 'A repost (or Announce) is when a post appears in the timeline because someone else shared it, while still showing the original author as the source.',
|
||||
'icon' => '♻️',
|
||||
'class' => 'p-repost',
|
||||
'type' => 'repost',
|
||||
'collection' => 'reposts',
|
||||
'activity_types' => array( 'announce' ),
|
||||
'excerpt' => html_entity_decode( \__( '… reposted this!', 'activitypub' ) ),
|
||||
'excerpt' => \html_entity_decode( \__( '… reposted this!', 'activitypub' ) ),
|
||||
/* translators: %d: Number of reposts */
|
||||
'count_single' => _x( '%d repost', 'number of reposts', 'activitypub' ),
|
||||
'count_single' => \_x( '%d repost', 'number of reposts', 'activitypub' ),
|
||||
/* translators: %d: Number of reposts */
|
||||
'count_plural' => _x( '%d reposts', 'number of reposts', 'activitypub' ),
|
||||
'count_plural' => \_x( '%d reposts', 'number of reposts', 'activitypub' ),
|
||||
)
|
||||
);
|
||||
|
||||
register_comment_type(
|
||||
'like',
|
||||
array(
|
||||
'label' => __( 'Likes', 'activitypub' ),
|
||||
'singular' => __( 'Like', 'activitypub' ),
|
||||
'label' => \__( 'Likes', 'activitypub' ),
|
||||
'singular' => \__( 'Like', 'activitypub' ),
|
||||
'description' => 'A like is a small positive reaction that shows appreciation for a post without sharing it further.',
|
||||
'icon' => '👍',
|
||||
'class' => 'p-like',
|
||||
'type' => 'like',
|
||||
'collection' => 'likes',
|
||||
'activity_types' => array( 'like' ),
|
||||
'excerpt' => html_entity_decode( \__( '… liked this!', 'activitypub' ) ),
|
||||
'excerpt' => \html_entity_decode( \__( '… liked this!', 'activitypub' ) ),
|
||||
/* translators: %d: Number of likes */
|
||||
'count_single' => _x( '%d like', 'number of likes', 'activitypub' ),
|
||||
'count_single' => \_x( '%d like', 'number of likes', 'activitypub' ),
|
||||
/* translators: %d: Number of likes */
|
||||
'count_plural' => _x( '%d likes', 'number of likes', 'activitypub' ),
|
||||
'count_plural' => \_x( '%d likes', 'number of likes', 'activitypub' ),
|
||||
)
|
||||
);
|
||||
|
||||
register_comment_type(
|
||||
'quote',
|
||||
array(
|
||||
'label' => __( 'Quotes', 'activitypub' ),
|
||||
'singular' => __( 'Quote', 'activitypub' ),
|
||||
'label' => \__( 'Quotes', 'activitypub' ),
|
||||
'singular' => \__( 'Quote', 'activitypub' ),
|
||||
'description' => 'A quote is when a post is shared along with an added comment, so the original post appears together with the sharer’s own words.',
|
||||
'icon' => '❞',
|
||||
'class' => 'p-quote',
|
||||
'type' => 'quote',
|
||||
'collection' => 'quotes',
|
||||
'activity_types' => array( 'quote' ),
|
||||
'excerpt' => html_entity_decode( \__( '… quoted this!', 'activitypub' ) ),
|
||||
'excerpt' => \html_entity_decode( \__( '… quoted this!', 'activitypub' ) ),
|
||||
/* translators: %d: Number of quotes */
|
||||
'count_single' => _x( '%d quote', 'number of quotes', 'activitypub' ),
|
||||
'count_single' => \_x( '%d quote', 'number of quotes', 'activitypub' ),
|
||||
/* translators: %d: Number of quotes */
|
||||
'count_plural' => _x( '%d quotes', 'number of quotes', 'activitypub' ),
|
||||
'count_plural' => \_x( '%d quotes', 'number of quotes', 'activitypub' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -744,9 +766,9 @@ class Comment {
|
||||
*/
|
||||
public static function get_avatar_comment_types( $types ) {
|
||||
$comment_types = self::get_comment_type_slugs();
|
||||
$types = array_merge( $types, $comment_types );
|
||||
$types = \array_merge( $types, $comment_types );
|
||||
|
||||
return array_unique( $types );
|
||||
return \array_unique( $types );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -764,7 +786,7 @@ class Comment {
|
||||
}
|
||||
|
||||
// Do not exclude likes and reposts on ActivityPub requests.
|
||||
if ( defined( 'ACTIVITYPUB_REQUEST' ) && ACTIVITYPUB_REQUEST ) {
|
||||
if ( \defined( 'ACTIVITYPUB_REQUEST' ) && ACTIVITYPUB_REQUEST ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -922,7 +944,7 @@ class Comment {
|
||||
*/
|
||||
public static function pre_wp_update_comment_count_now( $new_count, $old_count, $post_id ) {
|
||||
if ( null === $new_count ) {
|
||||
$excluded_types = array_filter( self::get_comment_type_slugs(), array( self::class, 'is_comment_type_enabled' ) );
|
||||
$excluded_types = \array_filter( self::get_comment_type_slugs(), array( self::class, 'is_comment_type_enabled' ) );
|
||||
|
||||
if ( ! empty( $excluded_types ) ) {
|
||||
/*
|
||||
@ -946,12 +968,12 @@ class Comment {
|
||||
* @param int $post_id The post ID.
|
||||
*/
|
||||
$excluded_types = \apply_filters( 'activitypub_excluded_comment_types', $excluded_types, $post_id );
|
||||
$excluded_types = array_unique( array_filter( $excluded_types ) );
|
||||
$excluded_types = \array_unique( \array_filter( $excluded_types ) );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB
|
||||
$new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type NOT IN ('" . implode( "','", $excluded_types ) . "')", $post_id ) );
|
||||
$new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type NOT IN ('" . \implode( "','", $excluded_types ) . "')", $post_id ) );
|
||||
}
|
||||
}
|
||||
|
||||
@ -965,7 +987,7 @@ class Comment {
|
||||
* @return bool True if the comment type is enabled.
|
||||
*/
|
||||
public static function is_comment_type_enabled( $comment_type ) {
|
||||
return '1' === get_option( "activitypub_allow_{$comment_type}s", '1' );
|
||||
return '1' === \get_option( "activitypub_allow_{$comment_type}s", '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -57,7 +57,7 @@ class Dispatcher {
|
||||
*
|
||||
* @param int $batch_size The batch size. Default ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE.
|
||||
*/
|
||||
return apply_filters( 'activitypub_dispatcher_batch_size', ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE );
|
||||
return \apply_filters( 'activitypub_dispatcher_batch_size', ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -71,7 +71,7 @@ class Dispatcher {
|
||||
*
|
||||
* @param int $retry_max_attempts The maximum number of retry attempts. Default ACTIVITYPUB_OUTBOX_RETRY_MAX_ATTEMPTS.
|
||||
*/
|
||||
return apply_filters( 'activitypub_dispatcher_retry_max_attempts', 3 );
|
||||
return \apply_filters( 'activitypub_dispatcher_retry_max_attempts', 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -89,7 +89,7 @@ class Dispatcher {
|
||||
*
|
||||
* @param int $retry_delay_unit The retry delay unit in seconds. Default ACTIVITYPUB_OUTBOX_RETRY_DELAY_UNIT.
|
||||
*/
|
||||
return apply_filters( 'activitypub_dispatcher_retry_delay', HOUR_IN_SECONDS );
|
||||
return \apply_filters( 'activitypub_dispatcher_retry_delay', HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -105,7 +105,7 @@ class Dispatcher {
|
||||
*
|
||||
* @param int[] $retry_error_codes The error codes. Default array( 408, 429, 500, 502, 503, 504 ).
|
||||
*/
|
||||
return apply_filters( 'activitypub_dispatcher_retry_error_codes', ACTIVITYPUB_RETRY_ERROR_CODES );
|
||||
return \apply_filters( 'activitypub_dispatcher_retry_error_codes', ACTIVITYPUB_RETRY_ERROR_CODES );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -183,7 +183,7 @@ class Dispatcher {
|
||||
self::schedule_retry( $retries, $outbox_item_id );
|
||||
}
|
||||
|
||||
if ( is_countable( $inboxes ) && count( $inboxes ) < $batch_size ) {
|
||||
if ( \is_countable( $inboxes ) && \count( $inboxes ) < $batch_size ) {
|
||||
\delete_post_meta( $outbox_item_id, '_activitypub_outbox_offset' );
|
||||
|
||||
/**
|
||||
@ -280,7 +280,7 @@ class Dispatcher {
|
||||
$result = safe_remote_post( $inbox, $json, $outbox_item->post_author );
|
||||
}
|
||||
|
||||
if ( \is_wp_error( $result ) && in_array( $result->get_error_code(), self::get_retry_error_codes(), true ) ) {
|
||||
if ( \is_wp_error( $result ) && \in_array( $result->get_error_code(), self::get_retry_error_codes(), true ) ) {
|
||||
$retries[] = $inbox;
|
||||
}
|
||||
|
||||
@ -309,7 +309,7 @@ class Dispatcher {
|
||||
private static function send_to_local_inbox( $inbox_url, $json ) {
|
||||
// Parse the inbox URL to extract the REST route.
|
||||
$path = \wp_parse_url( $inbox_url, PHP_URL_PATH ) ?? '';
|
||||
$rest_route = \preg_replace( '#^/' . preg_quote( \rest_get_url_prefix(), '#' ) . '#', '', $path );
|
||||
$rest_route = \preg_replace( '#^/' . \preg_quote( \rest_get_url_prefix(), '#' ) . '#', '', $path );
|
||||
|
||||
// Create a REST request.
|
||||
$request = new \WP_REST_Request( 'POST', $rest_route );
|
||||
@ -369,8 +369,8 @@ class Dispatcher {
|
||||
* @param int $actor_id The actor ID.
|
||||
* @param Activity $activity The ActivityPub Activity.
|
||||
*/
|
||||
$inboxes = apply_filters( 'activitypub_additional_inboxes', array(), $actor_id, $activity );
|
||||
$inboxes = array_unique( $inboxes );
|
||||
$inboxes = \apply_filters( 'activitypub_additional_inboxes', array(), $actor_id, $activity );
|
||||
$inboxes = \array_unique( $inboxes );
|
||||
|
||||
$retries = self::send_to_inboxes( $inboxes, $outbox_item->ID );
|
||||
|
||||
@ -393,15 +393,15 @@ class Dispatcher {
|
||||
$cc = $activity->get_cc() ?? array();
|
||||
$to = $activity->get_to() ?? array();
|
||||
|
||||
$audience = array_merge( $cc, $to );
|
||||
$audience = \array_merge( $cc, $to );
|
||||
|
||||
// Remove "public placeholder" from the audience.
|
||||
$audience = array_diff( $audience, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS );
|
||||
$audience = \array_diff( $audience, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS );
|
||||
|
||||
if ( $audience ) {
|
||||
$mentioned_inboxes = Mention::get_inboxes( $audience );
|
||||
|
||||
return array_merge( $inboxes, $mentioned_inboxes );
|
||||
return \array_merge( $inboxes, $mentioned_inboxes );
|
||||
}
|
||||
|
||||
return $inboxes;
|
||||
@ -423,7 +423,7 @@ class Dispatcher {
|
||||
return $inboxes;
|
||||
}
|
||||
|
||||
if ( ! is_array( $in_reply_to ) ) {
|
||||
if ( ! \is_array( $in_reply_to ) ) {
|
||||
$in_reply_to = array( $in_reply_to );
|
||||
}
|
||||
|
||||
@ -475,20 +475,20 @@ class Dispatcher {
|
||||
$bcc = (array) ( $activity->get_bcc() ?? array() );
|
||||
$bto = (array) ( $activity->get_bto() ?? array() );
|
||||
|
||||
$audience = array_merge( $cc, $to, $bcc, $bto );
|
||||
$audience = \array_merge( $cc, $to, $bcc, $bto );
|
||||
|
||||
$send = (
|
||||
// Check if activity is public.
|
||||
is_activity_public( $activity ) ||
|
||||
// ...or check if follower endpoint is set.
|
||||
in_array( $actor->get_followers(), $audience, true )
|
||||
\in_array( $actor->get_followers(), $audience, true )
|
||||
);
|
||||
|
||||
if ( $send ) {
|
||||
$followers = Followers::get_inboxes_for_activity( $activity->to_json(), $outbox_item->post_author );
|
||||
|
||||
// Only send if there are followers to send to.
|
||||
$send = ! is_countable( $followers ) || 0 < count( $followers );
|
||||
$send = ! \is_countable( $followers ) || 0 < \count( $followers );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -499,7 +499,7 @@ class Dispatcher {
|
||||
* @param int $actor_id The actor ID.
|
||||
* @param \WP_Post $outbox_item The WordPress object.
|
||||
*/
|
||||
return apply_filters( 'activitypub_send_activity_to_followers', $send, $activity, $outbox_item->post_author, $outbox_item );
|
||||
return \apply_filters( 'activitypub_send_activity_to_followers', $send, $activity, $outbox_item->post_author, $outbox_item );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -523,7 +523,7 @@ class Dispatcher {
|
||||
return $inboxes;
|
||||
}
|
||||
|
||||
return array_merge( $inboxes, $relays );
|
||||
return \array_merge( $inboxes, $relays );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -56,7 +56,7 @@ class Embed {
|
||||
// If we don't have an avatar URL, but we have an author URL, try to fetch it.
|
||||
if ( ! $avatar_url && $author_url ) {
|
||||
$author = Http::get_remote_object( $author_url );
|
||||
if ( is_wp_error( $author ) ) {
|
||||
if ( \is_wp_error( $author ) ) {
|
||||
$author = array();
|
||||
} else {
|
||||
$avatar_url = $author['icon']['url'] ?? '';
|
||||
@ -78,7 +78,7 @@ class Embed {
|
||||
|
||||
$title = $activity_object['name'] ?? '';
|
||||
$content = $activity_object['content'] ?? '';
|
||||
$published = isset( $activity_object['published'] ) ? gmdate( get_option( 'date_format' ) . ', ' . get_option( 'time_format' ), strtotime( $activity_object['published'] ) ) : '';
|
||||
$published = isset( $activity_object['published'] ) ? \gmdate( \get_option( 'date_format' ) . ', ' . \get_option( 'time_format' ), \strtotime( $activity_object['published'] ) ) : '';
|
||||
$boosts = isset( $activity_object['shares']['totalItems'] ) ? (int) $activity_object['shares']['totalItems'] : null;
|
||||
$favorites = isset( $activity_object['likes']['totalItems'] ) ? (int) $activity_object['likes']['totalItems'] : null;
|
||||
|
||||
@ -95,7 +95,7 @@ class Embed {
|
||||
);
|
||||
} elseif ( isset( $activity_object['attachment'] ) ) {
|
||||
foreach ( $activity_object['attachment'] as $attachment ) {
|
||||
$type = isset( $attachment['mediaType'] ) ? strtok( $attachment['mediaType'], '/' ) : strtolower( $attachment['type'] );
|
||||
$type = isset( $attachment['mediaType'] ) ? \strtok( $attachment['mediaType'], '/' ) : \strtolower( $attachment['type'] );
|
||||
|
||||
switch ( $type ) {
|
||||
case 'image':
|
||||
@ -112,8 +112,8 @@ class Embed {
|
||||
$images = \array_slice( $images, 0, 4 );
|
||||
}
|
||||
|
||||
ob_start();
|
||||
load_template(
|
||||
\ob_start();
|
||||
\load_template(
|
||||
ACTIVITYPUB_PLUGIN_DIR . 'templates/embed.php',
|
||||
false,
|
||||
array(
|
||||
@ -137,11 +137,11 @@ class Embed {
|
||||
// Grab the CSS.
|
||||
$css = \file_get_contents( ACTIVITYPUB_PLUGIN_DIR . 'assets/css/activitypub-embed.css' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||
// We embed CSS directly because this may be in an iframe.
|
||||
printf( '<style>%s</style>', $css ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
\printf( '<style>%s</style>', $css ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
// A little light whitespace cleanup.
|
||||
return preg_replace( '/\s+/', ' ', ob_get_clean() );
|
||||
return \preg_replace( '/\s+/', ' ', \ob_get_clean() );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -261,7 +261,7 @@ class Embed {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ( ( is_wp_error( $response ) && 'oembed_invalid_url' === $response->get_error_code() ) || empty( $response->html ) ) {
|
||||
if ( ( \is_wp_error( $response ) && 'oembed_invalid_url' === $response->get_error_code() ) || empty( $response->html ) ) {
|
||||
$url = $request->get_param( 'url' );
|
||||
$html = self::get_html( $url );
|
||||
|
||||
@ -274,12 +274,12 @@ class Embed {
|
||||
);
|
||||
|
||||
/** This filter is documented in wp-includes/class-wp-oembed.php */
|
||||
$data->html = apply_filters( 'oembed_result', $data->html, $url, $args );
|
||||
$data->html = \apply_filters( 'oembed_result', $data->html, $url, $args );
|
||||
|
||||
/** This filter is documented in wp-includes/class-wp-oembed-controller.php */
|
||||
$ttl = apply_filters( 'rest_oembed_ttl', DAY_IN_SECONDS, $url, $args );
|
||||
$ttl = \apply_filters( 'rest_oembed_ttl', DAY_IN_SECONDS, $url, $args );
|
||||
|
||||
set_transient( 'oembed_' . md5( serialize( $args ) ), $data, $ttl ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
|
||||
\set_transient( 'oembed_' . \md5( \serialize( $args ) ), $data, $ttl ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
|
||||
|
||||
$response = new \WP_REST_Response( $data );
|
||||
}
|
||||
|
||||
@ -38,8 +38,8 @@ class Event_Stream {
|
||||
* @param int $user_id The user ID.
|
||||
*/
|
||||
public static function signal_outbox( $outbox_activity_id, $activity, $user_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$signal_key = sprintf( 'activitypub_sse_signal_%s_outbox', $user_id );
|
||||
\set_transient( $signal_key, time(), HOUR_IN_SECONDS );
|
||||
$signal_key = \sprintf( 'activitypub_sse_signal_%s_outbox', $user_id );
|
||||
\set_transient( $signal_key, \time(), HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -54,8 +54,8 @@ class Event_Stream {
|
||||
}
|
||||
|
||||
foreach ( $user_ids as $user_id ) {
|
||||
$signal_key = sprintf( 'activitypub_sse_signal_%s_inbox', $user_id );
|
||||
\set_transient( $signal_key, time(), HOUR_IN_SECONDS );
|
||||
$signal_key = \sprintf( 'activitypub_sse_signal_%s_inbox', $user_id );
|
||||
\set_transient( $signal_key, \time(), HOUR_IN_SECONDS );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ class Handler {
|
||||
Handler\Collection_Sync::init();
|
||||
Handler\Create::init();
|
||||
Handler\Delete::init();
|
||||
Handler\Feature_Request::init();
|
||||
Handler\Follow::init();
|
||||
Handler\Like::init();
|
||||
Handler\Move::init();
|
||||
@ -41,7 +42,7 @@ class Handler {
|
||||
*
|
||||
* @since 1.3.0
|
||||
*/
|
||||
do_action( 'activitypub_register_handlers' );
|
||||
\do_action( 'activitypub_register_handlers' );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -65,6 +66,6 @@ class Handler {
|
||||
*
|
||||
* @since 8.1.0
|
||||
*/
|
||||
do_action( 'activitypub_register_outbox_handlers' );
|
||||
\do_action( 'activitypub_register_outbox_handlers' );
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ class Hashtag {
|
||||
|
||||
// Check if the (custom) post supports tags.
|
||||
$taxonomies = \get_object_taxonomies( $post );
|
||||
if ( ! in_array( 'post_tag', $taxonomies, true ) ) {
|
||||
if ( ! \in_array( 'post_tag', $taxonomies, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -150,7 +150,7 @@ class Hashtag {
|
||||
|
||||
if ( $tag_object ) {
|
||||
$link = \get_term_link( $tag_object, 'post_tag' );
|
||||
return \sprintf( '<a rel="tag" class="hashtag u-tag u-category" href="%s">#%s</a>', esc_url( $link ), $tag );
|
||||
return \sprintf( '<a rel="tag" class="hashtag u-tag u-category" href="%s">#%s</a>', \esc_url( $link ), $tag );
|
||||
}
|
||||
|
||||
return '#' . $tag;
|
||||
|
||||
@ -18,9 +18,9 @@ class Http {
|
||||
/**
|
||||
* Send a POST Request with the needed HTTP Headers
|
||||
*
|
||||
* @param string $url The URL endpoint.
|
||||
* @param string $body The Post Body.
|
||||
* @param int $user_id The WordPress User-ID.
|
||||
* @param string $url The URL endpoint.
|
||||
* @param string $body The Post Body.
|
||||
* @param int|null $user_id The WordPress User-ID, or null to sign with the Application key.
|
||||
*
|
||||
* @return array|\WP_Error The POST Response or an WP_Error.
|
||||
*/
|
||||
@ -28,9 +28,9 @@ class Http {
|
||||
/**
|
||||
* Fires before an HTTP POST request is made.
|
||||
*
|
||||
* @param string $url The URL endpoint.
|
||||
* @param string $body The POST body.
|
||||
* @param int $user_id The WordPress User ID.
|
||||
* @param string $url The URL endpoint.
|
||||
* @param string $body The POST body.
|
||||
* @param int|null $user_id The WordPress User ID, or null when signing with the Application key.
|
||||
*/
|
||||
\do_action( 'activitypub_pre_http_post', $url, $body, $user_id );
|
||||
|
||||
@ -49,6 +49,12 @@ class Http {
|
||||
*/
|
||||
$timeout = \apply_filters( 'activitypub_remote_post_timeout', 10 );
|
||||
|
||||
/*
|
||||
* Get the private key for signing the request. If a user ID is provided,
|
||||
* get the user's private key; otherwise, use the application's private key.
|
||||
*/
|
||||
$private_key = null === $user_id ? Application::get_private_key() : Actors::get_private_key( $user_id );
|
||||
|
||||
$args = array(
|
||||
'timeout' => $timeout,
|
||||
'limit_response_size' => 1048576,
|
||||
@ -61,7 +67,7 @@ class Http {
|
||||
),
|
||||
'body' => $body,
|
||||
'key_id' => \json_decode( $body )->actor . '#main-key',
|
||||
'private_key' => Actors::get_private_key( $user_id ),
|
||||
'private_key' => $private_key,
|
||||
'user_id' => $user_id,
|
||||
);
|
||||
|
||||
@ -71,7 +77,7 @@ class Http {
|
||||
if ( $code >= 400 ) {
|
||||
$response = new \WP_Error(
|
||||
$code,
|
||||
__( 'Failed HTTP Request', 'activitypub' ),
|
||||
\__( 'Failed HTTP Request', 'activitypub' ),
|
||||
array(
|
||||
'status' => $code,
|
||||
'response' => $response,
|
||||
@ -85,7 +91,7 @@ class Http {
|
||||
* @param array|\WP_Error $response The response of the remote POST request.
|
||||
* @param string $url The URL endpoint.
|
||||
* @param string $body The Post Body.
|
||||
* @param int $user_id The WordPress User-ID.
|
||||
* @param int|null $user_id The WordPress User-ID, or null when signing with the Application key.
|
||||
*/
|
||||
\do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id );
|
||||
|
||||
@ -98,7 +104,8 @@ class Http {
|
||||
* @param string $url The URL endpoint.
|
||||
* @param array $args Optional. Additional arguments to customize the request.
|
||||
* - 'headers': Array of headers to override defaults.
|
||||
* @param bool|int $cached Optional. Whether to return cached results, or cache duration. Default false.
|
||||
* @param bool|int $cached Optional. Whether to cache the response, or the cache duration in seconds for
|
||||
* successful responses. Failed responses use a fixed short backoff duration. Default false.
|
||||
*
|
||||
* @return array|\WP_Error The GET Response or a WP_Error.
|
||||
*/
|
||||
@ -165,8 +172,8 @@ class Http {
|
||||
'Content-Type' => 'application/activity+json',
|
||||
'Date' => \gmdate( 'D, d M Y H:i:s T' ),
|
||||
),
|
||||
'key_id' => Actors::get_by_id( Actors::APPLICATION_USER_ID )->get_id() . '#main-key',
|
||||
'private_key' => Actors::get_private_key( Actors::APPLICATION_USER_ID ),
|
||||
'key_id' => Application::get_key_id(),
|
||||
'private_key' => Application::get_private_key(),
|
||||
);
|
||||
|
||||
$args = \wp_parse_args( $args, $defaults );
|
||||
@ -176,23 +183,33 @@ class Http {
|
||||
$code = \wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( \is_wp_error( $response ) || $code >= 400 ) {
|
||||
// Capture the served-from URL before $response is replaced by the error below.
|
||||
$effective_url = \is_wp_error( $response ) ? '' : self::effective_url( $response );
|
||||
|
||||
if ( ! $code ) {
|
||||
$code = 0;
|
||||
}
|
||||
$response = new \WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
|
||||
$response = new \WP_Error( $code, \__( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
|
||||
|
||||
/*
|
||||
* Always cache errors to prevent repeated timeout waits.
|
||||
* Cache errors to prevent repeated timeout waits, but never one reached via a
|
||||
* cross-host redirect: cached under the requested URL's key, a one-off open
|
||||
* redirect on the requested host would let a transient 4xx be replayed as a
|
||||
* repeatable federation outage (the same caching concern as the success path
|
||||
* below, just with a WP_Error instead of a document).
|
||||
*
|
||||
* - Retriable errors (timeouts, 5xx): 1 minute (server may recover quickly).
|
||||
* - Other errors (4xx): 15 minutes (client errors are more permanent).
|
||||
*/
|
||||
if ( \in_array( $code, ACTIVITYPUB_RETRY_ERROR_CODES, true ) || 0 === $code ) {
|
||||
$cache_duration = MINUTE_IN_SECONDS;
|
||||
} else {
|
||||
$cache_duration = 15 * MINUTE_IN_SECONDS;
|
||||
}
|
||||
if ( $cached && ( ! $effective_url || is_same_host( $url, $effective_url ) ) ) {
|
||||
if ( \in_array( $code, ACTIVITYPUB_RETRY_ERROR_CODES, true ) || 0 === $code ) {
|
||||
$cache_duration = MINUTE_IN_SECONDS;
|
||||
} else {
|
||||
$cache_duration = 15 * MINUTE_IN_SECONDS;
|
||||
}
|
||||
|
||||
\set_transient( $transient_key, $response, $cache_duration );
|
||||
\set_transient( $transient_key, $response, $cache_duration );
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
@ -205,12 +222,26 @@ class Http {
|
||||
*/
|
||||
\do_action( 'activitypub_safe_remote_get_response', $response, $url );
|
||||
|
||||
// Always cache successful responses.
|
||||
$cache_duration = $cached;
|
||||
if ( ! is_int( $cache_duration ) ) {
|
||||
$cache_duration = HOUR_IN_SECONDS;
|
||||
/*
|
||||
* Never persist a response that was redirected to a different host. The cache
|
||||
* is keyed on the requested URL, so caching cross-origin content under that key
|
||||
* would let a one-off open redirect on the requested host durably associate the
|
||||
* other host's document with that URL — a later lookup (e.g. a public-key fetch)
|
||||
* would then return it. Same-host redirects (e.g. http→https) stay cacheable.
|
||||
*/
|
||||
$effective_url = self::effective_url( $response );
|
||||
if ( $effective_url && ! is_same_host( $url, $effective_url ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Cache successful responses when caching is requested.
|
||||
if ( $cached ) {
|
||||
$cache_duration = $cached;
|
||||
if ( ! \is_int( $cache_duration ) ) {
|
||||
$cache_duration = HOUR_IN_SECONDS;
|
||||
}
|
||||
\set_transient( $transient_key, $response, $cache_duration );
|
||||
}
|
||||
\set_transient( $transient_key, $response, $cache_duration );
|
||||
|
||||
return $response;
|
||||
}
|
||||
@ -223,7 +254,7 @@ class Http {
|
||||
* @return bool True if the URL is a tombstone.
|
||||
*/
|
||||
public static function is_tombstone( $url ) {
|
||||
_deprecated_function( __METHOD__, '7.3.0', 'Activitypub\Tombstone::exists_remote' );
|
||||
\_deprecated_function( __METHOD__, '7.3.0', 'Activitypub\Tombstone::exists_remote' );
|
||||
|
||||
return Tombstone::exists_remote( $url );
|
||||
}
|
||||
@ -242,6 +273,15 @@ class Http {
|
||||
/**
|
||||
* Requests the Data from the Object-URL or Object-Array.
|
||||
*
|
||||
* Fetched objects are self-confirmed before they are returned, the same way
|
||||
* Mastodon's `JsonLdHelper#fetch_resource` works: an object is trusted only when
|
||||
* its own `id` is the URL it was actually served from (after any redirects). If
|
||||
* the document served at the requested URL declares a different `id`, that id is
|
||||
* dereferenced from its own host and accepted only when it self-confirms. This
|
||||
* makes every caller safe to cache the result under its `id` — one host can never
|
||||
* serve an object (and its public key) under another host's id — without each
|
||||
* caller having to re-check the origin itself.
|
||||
*
|
||||
* @param array|string $url_or_object The Object or the Object URL.
|
||||
* @param bool $cached Optional. Whether the result should be cached. Default true.
|
||||
*
|
||||
@ -251,10 +291,13 @@ class Http {
|
||||
/**
|
||||
* Filters the preemptive return value of a remote object request.
|
||||
*
|
||||
* This is an explicit in-process override (used for caching and tests), not
|
||||
* untrusted network data, so it is returned as-is without self-confirmation.
|
||||
*
|
||||
* @param array|string|null $response The response.
|
||||
* @param array|string|null $url_or_object The Object or the Object URL.
|
||||
*/
|
||||
$response = apply_filters( 'activitypub_pre_http_get_remote_object', null, $url_or_object );
|
||||
$response = \apply_filters( 'activitypub_pre_http_get_remote_object', null, $url_or_object );
|
||||
if ( null !== $response ) {
|
||||
return $response;
|
||||
}
|
||||
@ -280,6 +323,59 @@ class Http {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$final_url = '';
|
||||
$object = self::fetch_object( $url, $cached, $final_url );
|
||||
|
||||
if ( \is_wp_error( $object ) ) {
|
||||
return $object;
|
||||
}
|
||||
|
||||
// Trust the document when it is served under its own id (after redirects).
|
||||
if ( id_matches_url( $object, $final_url ) ) {
|
||||
return $object;
|
||||
}
|
||||
|
||||
$declared_id = isset( $object['id'] ) && \is_string( $object['id'] ) ? $object['id'] : '';
|
||||
|
||||
/*
|
||||
* An id-less object cannot be cached under an id, so it cannot be written
|
||||
* under another id in an id-keyed cache. Return the document as served.
|
||||
*/
|
||||
if ( '' === $declared_id ) {
|
||||
return $object;
|
||||
}
|
||||
|
||||
// Re-fetch the declared id from its own host and require it to self-confirm. One hop only.
|
||||
$object = self::fetch_object( $declared_id, $cached, $final_url );
|
||||
|
||||
if ( \is_wp_error( $object ) ) {
|
||||
return $object;
|
||||
}
|
||||
|
||||
if ( ! id_matches_url( $object, $final_url ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_object_id_mismatch',
|
||||
\__( 'The object id does not match the URL it was served from', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and JSON-decode a single remote document.
|
||||
*
|
||||
* @param string $url The URL to fetch. Must already be resolved (not a WebFinger acct).
|
||||
* @param bool $cached Whether the result may be served from and written to cache.
|
||||
* @param string $final_url Filled by reference with the URL the document was served from,
|
||||
* after following any redirects.
|
||||
*
|
||||
* @return array|\WP_Error The decoded document, or WP_Error on failure.
|
||||
*/
|
||||
private static function fetch_object( $url, $cached, &$final_url ) {
|
||||
$final_url = $url;
|
||||
|
||||
if ( ! \wp_http_validate_url( $url ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_no_valid_object_url',
|
||||
@ -297,8 +393,12 @@ class Http {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$data = \wp_remote_retrieve_body( $response );
|
||||
$data = \json_decode( $data, true );
|
||||
$effective_url = self::effective_url( $response );
|
||||
if ( $effective_url ) {
|
||||
$final_url = $effective_url;
|
||||
}
|
||||
|
||||
$data = \json_decode( \wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( ! $data ) {
|
||||
return new \WP_Error(
|
||||
@ -313,4 +413,36 @@ class Http {
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the effective URL a response was served from, after redirects.
|
||||
*
|
||||
* WordPress follows redirects transparently and exposes the final URL only on
|
||||
* the underlying Requests response object. Returns an empty string when the URL
|
||||
* cannot be determined, so callers fall back to the URL they requested.
|
||||
*
|
||||
* SECURITY: the redirect protections that build on this (the self-confirmation in
|
||||
* get_remote_object() and the cross-host cache skip in get()) fail OPEN when this
|
||||
* returns an empty string — self-confirmation then compares against the requested
|
||||
* URL, which a redirect could have bounced away from. This relies on the internal
|
||||
* `http_response` → Requests response `url` shape; if a future WordPress release
|
||||
* changes it, re-verify that this still returns the post-redirect URL.
|
||||
*
|
||||
* @param array $response A `wp_remote_get()` response array.
|
||||
*
|
||||
* @return string The final URL, or an empty string when unavailable.
|
||||
*/
|
||||
private static function effective_url( $response ) {
|
||||
if ( empty( $response['http_response'] ) || ! \is_object( $response['http_response'] ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$requests_response = $response['http_response']->get_response_object();
|
||||
|
||||
if ( ! \is_object( $requests_response ) || empty( $requests_response->url ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) $requests_response->url;
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,10 +60,10 @@ class Link {
|
||||
* @return string The final string.
|
||||
*/
|
||||
public static function replace_with_links( $result ) {
|
||||
if ( 'www.' === substr( $result[0], 0, 4 ) ) {
|
||||
if ( 'www.' === \substr( $result[0], 0, 4 ) ) {
|
||||
$result[0] = 'https://' . $result[0];
|
||||
}
|
||||
$parsed_url = \wp_parse_url( html_entity_decode( $result[0] ) );
|
||||
$parsed_url = \wp_parse_url( \html_entity_decode( $result[0] ) );
|
||||
if ( ! $parsed_url || empty( $parsed_url['host'] ) ) {
|
||||
return $result[0];
|
||||
}
|
||||
@ -84,8 +84,8 @@ class Link {
|
||||
}
|
||||
|
||||
$text_url = $parsed_url['host'];
|
||||
if ( 'www.' === substr( $text_url, 0, 4 ) ) {
|
||||
$text_url = substr( $text_url, 4 );
|
||||
if ( 'www.' === \substr( $text_url, 0, 4 ) ) {
|
||||
$text_url = \substr( $text_url, 4 );
|
||||
$invisible_prefix .= 'www.';
|
||||
}
|
||||
if ( ! empty( $parsed_url['port'] ) ) {
|
||||
@ -114,16 +114,16 @@ class Link {
|
||||
*
|
||||
* @param string $rel The rel attribute string. Default 'nofollow noopener noreferrer'.
|
||||
*/
|
||||
$rel = apply_filters( 'activitypub_link_rel', 'nofollow noopener noreferrer' );
|
||||
$rel = \apply_filters( 'activitypub_link_rel', 'nofollow noopener noreferrer' );
|
||||
|
||||
return \sprintf(
|
||||
'<a href="%s" target="_blank" rel="%s" translate="no"><span class="invisible">%s</span><span class="%s">%s</span><span class="invisible">%s</span></a>',
|
||||
esc_url( $result[0] ),
|
||||
\esc_url( $result[0] ),
|
||||
$rel,
|
||||
esc_html( $invisible_prefix ),
|
||||
\esc_html( $invisible_prefix ),
|
||||
$display_class,
|
||||
esc_html( $display ),
|
||||
esc_html( $invisible_suffix )
|
||||
\esc_html( $display ),
|
||||
\esc_html( $invisible_suffix )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@ class Mailer {
|
||||
$post = \get_post( $comment->comment_post_ID );
|
||||
|
||||
/* translators: 1: Blog name, 2: Like or Repost, 3: Post title */
|
||||
return \sprintf( \esc_html__( '[%1$s] %2$s: %3$s', 'activitypub' ), \esc_html( get_option( 'blogname' ) ), \esc_html( $singular ), \esc_html( $post->post_title ) );
|
||||
return \sprintf( \esc_html__( '[%1$s] %2$s: %3$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $singular ), \esc_html( $post->post_title ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,7 +101,7 @@ class Mailer {
|
||||
if ( 0 === (int) $comment->comment_parent ) {
|
||||
$notify_message = \sprintf(
|
||||
/* translators: 1: Comment type, 2: Post title */
|
||||
\html_entity_decode( esc_html__( 'New %1$s on your post “%2$s”.', 'activitypub' ) ),
|
||||
\html_entity_decode( \esc_html__( 'New %1$s on your post “%2$s”.', 'activitypub' ) ),
|
||||
\esc_html( $comment_type['singular'] ),
|
||||
\esc_html( $post->post_title )
|
||||
) . PHP_EOL . PHP_EOL;
|
||||
@ -110,7 +110,7 @@ class Mailer {
|
||||
$parent_comment = \get_comment( $comment->comment_parent );
|
||||
$notify_message = \sprintf(
|
||||
/* translators: 1: Comment type, 2: Post title, 3: Parent comment author */
|
||||
\html_entity_decode( esc_html__( 'New %1$s on your post “%2$s” in reply to %3$s’s comment.', 'activitypub' ) ),
|
||||
\html_entity_decode( \esc_html__( 'New %1$s on your post “%2$s” in reply to %3$s’s comment.', 'activitypub' ) ),
|
||||
\esc_html( $comment_type['singular'] ),
|
||||
\esc_html( $post->post_title ),
|
||||
\esc_html( $parent_comment->comment_author )
|
||||
@ -120,7 +120,19 @@ class Mailer {
|
||||
/* translators: 1: Website name, 2: Website IP address, 3: Website hostname. */
|
||||
$notify_message .= \sprintf( \esc_html__( 'From: %1$s (IP address: %2$s, %3$s)', 'activitypub' ), \esc_html( $comment->comment_author ), \esc_html( $comment->comment_author_IP ), \esc_html( $comment_author_domain ) ) . "\r\n";
|
||||
/* translators: Reaction author URL. */
|
||||
$notify_message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $comment->comment_author_url ) ) . "\r\n\r\n";
|
||||
$notify_message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $comment->comment_author_url ) ) . "\r\n";
|
||||
|
||||
// For quotes, link to the quoting post itself so the author can review and respond.
|
||||
if ( 'quote' === $comment->comment_type ) {
|
||||
$quote_url = Comment::get_source_url( $comment->comment_ID );
|
||||
|
||||
if ( $quote_url ) {
|
||||
/* translators: Quoting post URL. */
|
||||
$notify_message .= \sprintf( \esc_html__( 'Quoting post: %s', 'activitypub' ), \esc_url( $quote_url ) ) . "\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
$notify_message .= "\r\n";
|
||||
/* translators: Comment type label */
|
||||
$notify_message .= \sprintf( \esc_html__( 'You can see all %s on this post here:', 'activitypub' ), \esc_html( $comment_type['label'] ) ) . "\r\n";
|
||||
$notify_message .= \get_permalink( $comment->comment_post_ID ) . '#' . \esc_attr( $comment_type['type'] ) . "\r\n\r\n";
|
||||
@ -144,11 +156,6 @@ class Mailer {
|
||||
// Extract the user ID (follows are always for a single user).
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
|
||||
// Do not send notifications to the Application user.
|
||||
if ( Actors::APPLICATION_USER_ID === $user_id ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $user_id > Actors::BLOG_USER_ID ) {
|
||||
if ( ! \get_user_option( 'activitypub_mailer_new_follower', $user_id ) ) {
|
||||
return;
|
||||
@ -180,7 +187,7 @@ class Mailer {
|
||||
$actor['summary'] = Emoji::replace_for_actor( $actor['summary'], $actor['url'] );
|
||||
}
|
||||
|
||||
$template_args = array_merge(
|
||||
$template_args = \array_merge(
|
||||
$actor,
|
||||
array(
|
||||
'admin_url' => $admin_url,
|
||||
@ -305,7 +312,7 @@ class Mailer {
|
||||
$alt_function = static function ( $mailer ) use ( $actor, $activity ) {
|
||||
$content = \html_entity_decode(
|
||||
\wp_strip_all_tags(
|
||||
str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
|
||||
\str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
|
||||
),
|
||||
ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
|
||||
);
|
||||
@ -345,8 +352,8 @@ class Mailer {
|
||||
}
|
||||
|
||||
$recipients = array();
|
||||
$mentions = wp_list_filter( (array) $activity['object']['tag'], array( 'type' => 'Mention' ) );
|
||||
$mentions = array_map( '\Activitypub\object_to_uri', $mentions );
|
||||
$mentions = \wp_list_filter( (array) $activity['object']['tag'], array( 'type' => 'Mention' ) );
|
||||
$mentions = \array_map( '\Activitypub\object_to_uri', $mentions );
|
||||
foreach ( (array) $user_ids as $user_id ) {
|
||||
$actor = Actors::get_by_id( $user_id );
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
@ -405,7 +412,7 @@ class Mailer {
|
||||
$alt_function = static function ( $mailer ) use ( $actor, $activity ) {
|
||||
$content = \html_entity_decode(
|
||||
\wp_strip_all_tags(
|
||||
str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
|
||||
\str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
|
||||
),
|
||||
ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
|
||||
);
|
||||
|
||||
@ -80,7 +80,7 @@ class Mention {
|
||||
$username = $actor->get_preferred_username() ?: $actor->get_name() ?: Sanitize::webfinger( $result[0] );
|
||||
$url = object_to_uri( $actor->get_url() ?: $actor->get_id() );
|
||||
|
||||
return \sprintf( '<a rel="mention" class="u-url mention" href="%1$s">@%2$s</a>', esc_url( $url ), esc_html( $username ) );
|
||||
return \sprintf( '<a rel="mention" class="u-url mention" href="%1$s">@%2$s</a>', \esc_url( $url ), \esc_html( $username ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -96,7 +96,7 @@ class Mention {
|
||||
foreach ( $mentioned as $actor ) {
|
||||
$inbox = self::get_inbox_by_mentioned_actor( $actor );
|
||||
|
||||
if ( ! is_wp_error( $inbox ) && $inbox ) {
|
||||
if ( ! \is_wp_error( $inbox ) && $inbox ) {
|
||||
$inboxes[] = $inbox;
|
||||
}
|
||||
}
|
||||
@ -141,7 +141,7 @@ class Mention {
|
||||
\preg_match_all( '/@' . ACTIVITYPUB_USERNAME_REGEXP . '/i', $post_content, $matches );
|
||||
foreach ( $matches[0] as $match ) {
|
||||
$link = Webfinger::resolve( $match );
|
||||
if ( ! is_wp_error( $link ) ) {
|
||||
if ( ! \is_wp_error( $link ) ) {
|
||||
$mentions[ $match ] = $link;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Extra_Fields;
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Following;
|
||||
use Activitypub\Collection\Inbox;
|
||||
use Activitypub\Collection\Outbox;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
use Activitypub\Transformer\Factory;
|
||||
@ -43,7 +44,7 @@ class Migration {
|
||||
* @return string The current version.
|
||||
*/
|
||||
public static function get_version() {
|
||||
return get_option( 'activitypub_db_version', 0 );
|
||||
return \get_option( 'activitypub_db_version', 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -185,15 +186,15 @@ class Migration {
|
||||
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_storage' ) );
|
||||
}
|
||||
if ( \version_compare( $version_from_db, '7.0.0', '<' ) ) {
|
||||
wp_unschedule_hook( 'activitypub_update_followers' );
|
||||
wp_unschedule_hook( 'activitypub_cleanup_followers' );
|
||||
\wp_unschedule_hook( 'activitypub_update_followers' );
|
||||
\wp_unschedule_hook( 'activitypub_cleanup_followers' );
|
||||
|
||||
if ( ! \wp_next_scheduled( 'activitypub_update_remote_actors' ) ) {
|
||||
\wp_schedule_event( time(), 'hourly', 'activitypub_update_remote_actors' );
|
||||
\wp_schedule_event( \time(), 'hourly', 'activitypub_update_remote_actors' );
|
||||
}
|
||||
|
||||
if ( ! \wp_next_scheduled( 'activitypub_cleanup_remote_actors' ) ) {
|
||||
\wp_schedule_event( time(), 'daily', 'activitypub_cleanup_remote_actors' );
|
||||
\wp_schedule_event( \time(), 'daily', 'activitypub_cleanup_remote_actors' );
|
||||
}
|
||||
}
|
||||
if ( \version_compare( $version_from_db, '7.3.0', '<' ) ) {
|
||||
@ -213,11 +214,16 @@ class Migration {
|
||||
// Backfill historical statistics data (delay + jitter to avoid load spikes on hosts running many sites).
|
||||
\wp_schedule_single_event( \time() + HOUR_IN_SECONDS + \wp_rand( 0, 6 * HOUR_IN_SECONDS ), 'activitypub_backfill_statistics' );
|
||||
}
|
||||
|
||||
if ( \version_compare( $version_from_db, '8.3.0', '<' ) ) {
|
||||
if ( ! \wp_next_scheduled( 'activitypub_tombstone_migrate' ) ) {
|
||||
\wp_schedule_single_event( \time() + MINUTE_IN_SECONDS, 'activitypub_tombstone_migrate' );
|
||||
}
|
||||
}
|
||||
if ( \version_compare( $version_from_db, '9.1.0', '<' ) ) {
|
||||
self::migrate_application_keypair_option();
|
||||
self::delete_application_outbox_items();
|
||||
}
|
||||
|
||||
/*
|
||||
* Defer the flush to late in the `init` cycle (priority 20). Migration::init
|
||||
@ -297,8 +303,8 @@ class Migration {
|
||||
*/
|
||||
public static function migrate_from_0_17() {
|
||||
// Migrate followers.
|
||||
foreach ( get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
|
||||
$followers = get_user_meta( $user_id, 'activitypub_followers', true );
|
||||
foreach ( \get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
|
||||
$followers = \get_user_meta( $user_id, 'activitypub_followers', true );
|
||||
|
||||
if ( $followers ) {
|
||||
foreach ( $followers as $actor ) {
|
||||
@ -320,7 +326,7 @@ class Migration {
|
||||
);
|
||||
|
||||
foreach ( $user_ids as $user_id ) {
|
||||
wp_cache_delete( sprintf( Followers::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
|
||||
\wp_cache_delete( \sprintf( Followers::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
|
||||
}
|
||||
}
|
||||
|
||||
@ -328,13 +334,13 @@ class Migration {
|
||||
* Unschedule Hooks after updating to 2.0.0.
|
||||
*/
|
||||
private static function migrate_from_2_0_0() {
|
||||
wp_clear_scheduled_hook( 'activitypub_send_post_activity' );
|
||||
wp_clear_scheduled_hook( 'activitypub_send_update_activity' );
|
||||
wp_clear_scheduled_hook( 'activitypub_send_delete_activity' );
|
||||
\wp_clear_scheduled_hook( 'activitypub_send_post_activity' );
|
||||
\wp_clear_scheduled_hook( 'activitypub_send_update_activity' );
|
||||
\wp_clear_scheduled_hook( 'activitypub_send_delete_activity' );
|
||||
|
||||
wp_unschedule_hook( 'activitypub_send_post_activity' );
|
||||
wp_unschedule_hook( 'activitypub_send_update_activity' );
|
||||
wp_unschedule_hook( 'activitypub_send_delete_activity' );
|
||||
\wp_unschedule_hook( 'activitypub_send_post_activity' );
|
||||
\wp_unschedule_hook( 'activitypub_send_update_activity' );
|
||||
\wp_unschedule_hook( 'activitypub_send_delete_activity' );
|
||||
|
||||
$object_type = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE );
|
||||
if ( 'article' === $object_type ) {
|
||||
@ -355,7 +361,7 @@ class Migration {
|
||||
* Rename DB fields.
|
||||
*/
|
||||
private static function migrate_from_2_6_0() {
|
||||
wp_cache_flush();
|
||||
\wp_cache_flush();
|
||||
|
||||
self::update_usermeta_key( 'activitypub_user_description', 'activitypub_description' );
|
||||
|
||||
@ -371,7 +377,7 @@ class Migration {
|
||||
$latest_post_id = 0;
|
||||
|
||||
// Get the ID of the latest blog post and save it to the options table.
|
||||
$latest_post = get_posts(
|
||||
$latest_post = \get_posts(
|
||||
array(
|
||||
'numberposts' => 1,
|
||||
'orderby' => 'ID',
|
||||
@ -484,7 +490,7 @@ class Migration {
|
||||
$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", Remote_Actors::POST_TYPE )
|
||||
);
|
||||
foreach ( $followers as $id ) {
|
||||
clean_post_cache( $id );
|
||||
\clean_post_cache( $id );
|
||||
}
|
||||
}
|
||||
|
||||
@ -502,7 +508,7 @@ class Migration {
|
||||
|
||||
Comment::register_comment_types();
|
||||
$comment_types = Comment::get_comment_type_slugs();
|
||||
$type_inclusion = "AND comment_type IN ('" . implode( "','", $comment_types ) . "')";
|
||||
$type_inclusion = "AND comment_type IN ('" . \implode( "','", $comment_types ) . "')";
|
||||
|
||||
// Get and process this batch.
|
||||
$post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB
|
||||
@ -518,7 +524,7 @@ class Migration {
|
||||
\wp_update_comment_count_now( $post_id );
|
||||
}
|
||||
|
||||
if ( count( $post_ids ) === $batch_size ) {
|
||||
if ( \count( $post_ids ) === $batch_size ) {
|
||||
// Schedule next batch.
|
||||
return array( $batch_size, $offset + $batch_size );
|
||||
}
|
||||
@ -562,7 +568,7 @@ class Migration {
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $posts ) === $batch_size ) {
|
||||
if ( \count( $posts ) === $batch_size ) {
|
||||
return array(
|
||||
'batch_size' => $batch_size,
|
||||
'offset' => $offset + $batch_size,
|
||||
@ -599,7 +605,7 @@ class Migration {
|
||||
self::add_to_outbox( $comment, 'Create', $comment->user_id );
|
||||
}
|
||||
|
||||
if ( count( $comments ) === $batch_size ) {
|
||||
if ( \count( $comments ) === $batch_size ) {
|
||||
return array(
|
||||
'batch_size' => $batch_size,
|
||||
'offset' => $offset + $batch_size,
|
||||
@ -695,7 +701,7 @@ class Migration {
|
||||
);
|
||||
}
|
||||
|
||||
if ( count( $comments ) === $batch_size ) {
|
||||
if ( \count( $comments ) === $batch_size ) {
|
||||
return array(
|
||||
'batch_size' => $batch_size,
|
||||
'offset' => $offset + $batch_size,
|
||||
@ -1029,7 +1035,7 @@ class Migration {
|
||||
$wpdb->postmeta,
|
||||
array(
|
||||
'meta_key' => '_activitypub_following', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
'meta_value' => Actors::APPLICATION_USER_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
|
||||
'meta_value' => -1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -1083,7 +1089,7 @@ class Migration {
|
||||
$inbox_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s",
|
||||
\Activitypub\Collection\Inbox::POST_TYPE
|
||||
Inbox::POST_TYPE
|
||||
)
|
||||
);
|
||||
|
||||
@ -1271,7 +1277,7 @@ class Migration {
|
||||
}
|
||||
|
||||
// Return batch info if there are more comments to process.
|
||||
if ( count( $comments ) === $batch_size ) {
|
||||
if ( \count( $comments ) === $batch_size ) {
|
||||
return array(
|
||||
'batch_size' => $batch_size,
|
||||
);
|
||||
@ -1321,7 +1327,7 @@ class Migration {
|
||||
}
|
||||
|
||||
// Return batch info if there are more actors to process.
|
||||
if ( count( $actors ) === $batch_size ) {
|
||||
if ( \count( $actors ) === $batch_size ) {
|
||||
return array(
|
||||
'batch_size' => $batch_size,
|
||||
'offset' => $offset + $batch_size,
|
||||
@ -1330,4 +1336,62 @@ class Migration {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the Application key pair option from the old name to the new name.
|
||||
*
|
||||
* Renames `activitypub_keypair_for_-1` to `activitypub_application_keypair`.
|
||||
* Older separate key options (activitypub_application_user_public_key /
|
||||
* activitypub_application_user_private_key) are migrated lazily on first read.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*/
|
||||
public static function migrate_application_keypair_option() {
|
||||
self::update_options_key( 'activitypub_keypair_for_-1', Application::KEYPAIR_OPTION_KEY );
|
||||
|
||||
// The raw rename bypasses the options API, so drop only the two stale option caches (plus the autoload bucket) instead of flushing everything.
|
||||
\wp_cache_delete( 'activitypub_keypair_for_-1', 'options' );
|
||||
\wp_cache_delete( Application::KEYPAIR_OPTION_KEY, 'options' );
|
||||
\wp_cache_delete( 'alloptions', 'options' );
|
||||
|
||||
/*
|
||||
* If an early Application::get_keypair() read already created the destination
|
||||
* option, the rename above is a no-op blocked by the unique `option_name`,
|
||||
* leaving the legacy row behind. Drop it once the destination is in place.
|
||||
*/
|
||||
if ( false !== \get_option( Application::KEYPAIR_OPTION_KEY, false ) ) {
|
||||
\delete_option( 'activitypub_keypair_for_-1' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete outbox items that belonged to the Application actor.
|
||||
*
|
||||
* The Application used to queue Reject activities through the Outbox as user
|
||||
* ID -1. It no longer dispatches activities, so any pending items are
|
||||
* undeliverable and are removed.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*/
|
||||
public static function delete_application_outbox_items() {
|
||||
$items = \get_posts(
|
||||
array(
|
||||
'post_type' => Outbox::POST_TYPE,
|
||||
'post_status' => 'any',
|
||||
'nopaging' => true,
|
||||
'fields' => 'ids',
|
||||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => '_activitypub_activity_actor',
|
||||
'value' => 'application',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $items as $item_id ) {
|
||||
\wp_delete_post( $item_id, true );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -233,7 +233,7 @@ class Moderation {
|
||||
* @param array $values Array of values to block.
|
||||
*/
|
||||
public static function add_site_blocks( $type, $values ) {
|
||||
if ( ! in_array( $type, array( self::TYPE_DOMAIN, self::TYPE_KEYWORD ), true ) ) {
|
||||
if ( ! \in_array( $type, array( self::TYPE_DOMAIN, self::TYPE_KEYWORD ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -252,7 +252,7 @@ class Moderation {
|
||||
}
|
||||
|
||||
$existing = \get_option( self::OPTION_KEYS[ $type ], array() );
|
||||
\update_option( self::OPTION_KEYS[ $type ], array_unique( array_merge( $existing, $values ) ) );
|
||||
\update_option( self::OPTION_KEYS[ $type ], \array_unique( \array_merge( $existing, $values ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -29,13 +29,13 @@ class Move {
|
||||
*
|
||||
* @param bool $domain_moves_enabled Whether domain moves are enabled.
|
||||
*/
|
||||
$domain_moves_enabled = apply_filters( 'activitypub_enable_primary_domain_moves', false );
|
||||
$domain_moves_enabled = \apply_filters( 'activitypub_enable_primary_domain_moves', false );
|
||||
|
||||
if ( $domain_moves_enabled ) {
|
||||
// Add the filter to change the domain.
|
||||
\add_filter( 'update_option_home', array( self::class, 'change_domain' ), 10, 2 );
|
||||
|
||||
if ( get_option( 'activitypub_old_host' ) ) {
|
||||
if ( \get_option( 'activitypub_old_host' ) ) {
|
||||
\add_action( 'activitypub_construct_model_actor', array( self::class, 'maybe_initiate_old_user' ) );
|
||||
\add_action( 'activitypub_pre_send_to_inboxes', array( self::class, 'pre_send_to_inboxes' ) );
|
||||
|
||||
@ -99,8 +99,8 @@ class Move {
|
||||
|
||||
// Check if the `Move` Activity is valid.
|
||||
$also_known_as = $target_actor->get_also_known_as() ?? array();
|
||||
if ( ! in_array( $from, $also_known_as, true ) ) {
|
||||
return new \WP_Error( 'invalid_target', __( 'Invalid target', 'activitypub' ) );
|
||||
if ( ! \in_array( $from, $also_known_as, true ) ) {
|
||||
return new \WP_Error( 'invalid_target', \__( 'Invalid target', 'activitypub' ) );
|
||||
}
|
||||
|
||||
$activity = new Activity();
|
||||
@ -218,7 +218,7 @@ class Move {
|
||||
$actor_id = $actor->get_id();
|
||||
|
||||
// Replace the new host with the old host in the actor ID.
|
||||
$old_actor_id = str_replace( $to_host, $from_host, $actor_id );
|
||||
$old_actor_id = \str_replace( $to_host, $from_host, $actor_id );
|
||||
|
||||
// Call Move::internally for this actor.
|
||||
$result = self::internally( $old_actor_id, $actor_id );
|
||||
@ -236,7 +236,7 @@ class Move {
|
||||
continue;
|
||||
}
|
||||
|
||||
$json = str_replace( $to_host, $from_host, $actor->to_json() );
|
||||
$json = \str_replace( $to_host, $from_host, $actor->to_json() );
|
||||
|
||||
// Save the current actor data after migration.
|
||||
if ( $actor instanceof Blog ) {
|
||||
@ -284,7 +284,7 @@ class Move {
|
||||
* @param string $json The ActivityPub Activity JSON.
|
||||
*/
|
||||
public static function pre_send_to_inboxes( $json ) {
|
||||
$json = json_decode( $json, true );
|
||||
$json = \json_decode( $json, true );
|
||||
|
||||
if ( 'Move' !== $json['type'] ) {
|
||||
return;
|
||||
|
||||
@ -27,6 +27,10 @@ class Options {
|
||||
\add_filter( 'pre_option_activitypub_following_ui', array( self::class, 'pre_option_activitypub_following_ui' ) );
|
||||
\add_filter( 'pre_option_activitypub_create_posts', array( self::class, 'pre_option_activitypub_create_posts' ) );
|
||||
|
||||
\add_filter( 'pre_option_activitypub_distribution_mode', array( self::class, 'pre_option_activitypub_distribution_mode' ) );
|
||||
\add_filter( 'activitypub_dispatcher_batch_size', array( self::class, 'filter_dispatcher_batch_size' ) );
|
||||
\add_filter( 'activitypub_scheduler_async_batch_pause', array( self::class, 'filter_scheduler_batch_pause' ), 10, 2 );
|
||||
|
||||
\add_filter( 'pre_option_activitypub_allow_likes', array( self::class, 'maybe_disable_interactions' ) );
|
||||
\add_filter( 'pre_option_activitypub_allow_replies', array( self::class, 'maybe_disable_interactions' ) );
|
||||
|
||||
@ -201,6 +205,24 @@ class Options {
|
||||
)
|
||||
);
|
||||
|
||||
\register_setting(
|
||||
'activitypub',
|
||||
'activitypub_default_feature_policy',
|
||||
array(
|
||||
'type' => 'string',
|
||||
'description' => 'Default policy for who can include this site\'s actors in featured collections (FEP-7aa9).',
|
||||
'default' => ACTIVITYPUB_INTERACTION_POLICY_ME,
|
||||
'sanitize_callback' => static function ( $value ) {
|
||||
$allowed = array(
|
||||
ACTIVITYPUB_INTERACTION_POLICY_ANYONE,
|
||||
ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS,
|
||||
ACTIVITYPUB_INTERACTION_POLICY_ME,
|
||||
);
|
||||
return \in_array( $value, $allowed, true ) ? $value : ACTIVITYPUB_INTERACTION_POLICY_ME;
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
\register_setting(
|
||||
'activitypub',
|
||||
'activitypub_relays',
|
||||
@ -371,6 +393,45 @@ class Options {
|
||||
)
|
||||
);
|
||||
|
||||
$default_distribution = self::get_distribution_preset_values()['default'];
|
||||
|
||||
\register_setting(
|
||||
'activitypub_advanced',
|
||||
'activitypub_distribution_mode',
|
||||
array(
|
||||
'type' => 'string',
|
||||
'description' => \__( 'Distribution mode for federation delivery.', 'activitypub' ),
|
||||
'default' => 'default',
|
||||
'sanitize_callback' => array( self::class, 'sanitize_distribution_mode' ),
|
||||
)
|
||||
);
|
||||
|
||||
\register_setting(
|
||||
'activitypub_advanced',
|
||||
'activitypub_custom_batch_size',
|
||||
array(
|
||||
'type' => 'integer',
|
||||
'description' => \__( 'Custom batch size for federation delivery.', 'activitypub' ),
|
||||
'default' => $default_distribution['batch_size'],
|
||||
'sanitize_callback' => static function ( $value ) {
|
||||
return \min( 500, \max( 1, \absint( $value ) ) );
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
\register_setting(
|
||||
'activitypub_advanced',
|
||||
'activitypub_custom_batch_pause',
|
||||
array(
|
||||
'type' => 'integer',
|
||||
'description' => \__( 'Custom pause in seconds between batches.', 'activitypub' ),
|
||||
'default' => $default_distribution['pause'],
|
||||
'sanitize_callback' => static function ( $value ) {
|
||||
return \min( 3600, \absint( $value ) );
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
/*
|
||||
* Options Group: activitypub_blog
|
||||
*/
|
||||
@ -673,6 +734,249 @@ class Options {
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-get option filter for the Distribution Mode.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @param string|false $pre The pre-get option value.
|
||||
*
|
||||
* @return string|false The distribution mode or false if it should not be filtered.
|
||||
*/
|
||||
public static function pre_option_activitypub_distribution_mode( $pre ) {
|
||||
return self::resolve_distribution_mode( $pre, ACTIVITYPUB_DISTRIBUTION_MODE );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the distribution mode is locked to a valid preset by the
|
||||
* `ACTIVITYPUB_DISTRIBUTION_MODE` constant.
|
||||
*
|
||||
* Returns true only when the constant is set to a key recognized by
|
||||
* `get_distribution_preset_values()`. Invalid constant values fall back
|
||||
* to `'default'` at runtime (see `resolve_distribution_mode()`) but the
|
||||
* UI stays visible so admins can spot the misconfiguration.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @return bool True when the constant pins the mode to a valid preset.
|
||||
*/
|
||||
public static function is_distribution_mode_locked() {
|
||||
if ( false === ACTIVITYPUB_DISTRIBUTION_MODE ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \in_array( ACTIVITYPUB_DISTRIBUTION_MODE, \array_keys( self::get_distribution_preset_values() ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the distribution mode against the wp-config constant.
|
||||
*
|
||||
* Extracted from `pre_option_activitypub_distribution_mode()` so the
|
||||
* constant-lock path can be exercised from tests without redefining
|
||||
* the real constant.
|
||||
*
|
||||
* Only preset modes are honored via the constant. The 'custom' mode
|
||||
* is excluded because its batch size and pause values are still read
|
||||
* from the database, which would defeat the purpose of locking the
|
||||
* mode via wp-config.php.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @param string|false $pre The pre-get option value.
|
||||
* @param mixed $constant_value The value of `ACTIVITYPUB_DISTRIBUTION_MODE`.
|
||||
*
|
||||
* @return string|false Mode if locked, `$pre` otherwise.
|
||||
*/
|
||||
public static function resolve_distribution_mode( $pre, $constant_value ) {
|
||||
if ( false === $constant_value ) {
|
||||
return $pre;
|
||||
}
|
||||
|
||||
$allowed = \array_keys( self::get_distribution_preset_values() );
|
||||
|
||||
if ( \in_array( $constant_value, $allowed, true ) ) {
|
||||
return $constant_value;
|
||||
}
|
||||
|
||||
\_doing_it_wrong(
|
||||
__METHOD__,
|
||||
\sprintf(
|
||||
/* translators: %s: invalid constant value */
|
||||
\esc_html__( 'ACTIVITYPUB_DISTRIBUTION_MODE value %s is not a valid preset; falling back to default.', 'activitypub' ),
|
||||
\esc_html( (string) $constant_value )
|
||||
),
|
||||
'9.0.0'
|
||||
);
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw batch_size/pause values for each distribution preset.
|
||||
*
|
||||
* Single source of truth for the preset values, used in the hot path
|
||||
* (get_distribution_params, sanitize_distribution_mode, resolve_distribution_mode)
|
||||
* to avoid running translation calls just to check keys or numbers.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @return array Associative array of mode => { batch_size, pause }.
|
||||
*/
|
||||
private static function get_distribution_preset_values() {
|
||||
return array(
|
||||
'default' => array(
|
||||
'batch_size' => 100,
|
||||
'pause' => 15,
|
||||
),
|
||||
'balanced' => array(
|
||||
'batch_size' => 50,
|
||||
'pause' => 30,
|
||||
),
|
||||
'eco' => array(
|
||||
'batch_size' => 20,
|
||||
'pause' => 30,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available distribution mode presets with UI labels.
|
||||
*
|
||||
* Decorates `get_distribution_preset_values()` with translated labels
|
||||
* and descriptions for use in the admin settings page.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @return array Associative array of mode => { batch_size, pause, label, description }.
|
||||
*/
|
||||
public static function get_distribution_modes() {
|
||||
$modes = self::get_distribution_preset_values();
|
||||
|
||||
$modes['default']['label'] = \__( 'Default', 'activitypub' );
|
||||
$modes['default']['description'] = \sprintf(
|
||||
/* translators: 1: batch size, 2: pause in seconds */
|
||||
\__( 'Deliver activities as fast as possible (<code>%1$d</code> per batch, <code>%2$ds</code> pause).', 'activitypub' ),
|
||||
$modes['default']['batch_size'],
|
||||
$modes['default']['pause']
|
||||
);
|
||||
$modes['balanced']['label'] = \__( 'Balanced', 'activitypub' );
|
||||
$modes['balanced']['description'] = \sprintf(
|
||||
/* translators: 1: batch size, 2: pause in seconds */
|
||||
\__( 'Moderate pace with reasonable pauses between batches (<code>%1$d</code> per batch, <code>%2$ds</code> pause).', 'activitypub' ),
|
||||
$modes['balanced']['batch_size'],
|
||||
$modes['balanced']['pause']
|
||||
);
|
||||
$modes['eco']['label'] = \__( 'Eco Mode', 'activitypub' );
|
||||
$modes['eco']['description'] = \sprintf(
|
||||
/* translators: 1: batch size, 2: pause in seconds */
|
||||
\__( 'Gentle on server resources, ideal for shared hosting (<code>%1$d</code> per batch, <code>%2$ds</code> pause).', 'activitypub' ),
|
||||
$modes['eco']['batch_size'],
|
||||
$modes['eco']['pause']
|
||||
);
|
||||
|
||||
return $modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the distribution mode option.
|
||||
*
|
||||
* Restricts the stored value to a known preset (from
|
||||
* `get_distribution_modes()`) or `'custom'`. Anything else
|
||||
* falls back to `'default'`.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @param string $value The submitted option value.
|
||||
*
|
||||
* @return string A valid distribution mode key.
|
||||
*/
|
||||
public static function sanitize_distribution_mode( $value ) {
|
||||
$allowed = \array_merge( \array_keys( self::get_distribution_preset_values() ), array( 'custom' ) );
|
||||
|
||||
return \in_array( $value, $allowed, true ) ? $value : 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distribution parameters for the current mode.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @return array { mode: string, batch_size: int, pause: int }
|
||||
*/
|
||||
public static function get_distribution_params() {
|
||||
$mode = \get_option( 'activitypub_distribution_mode', 'default' );
|
||||
$modes = self::get_distribution_preset_values();
|
||||
|
||||
if ( isset( $modes[ $mode ] ) ) {
|
||||
return array(
|
||||
'mode' => $mode,
|
||||
'batch_size' => $modes[ $mode ]['batch_size'],
|
||||
'pause' => $modes[ $mode ]['pause'],
|
||||
);
|
||||
}
|
||||
|
||||
// Custom mode reads its values from dedicated options; any other
|
||||
// unrecognized mode falls back to the default preset so callers
|
||||
// always receive a valid configuration.
|
||||
if ( 'custom' !== $mode ) {
|
||||
return array(
|
||||
'mode' => 'default',
|
||||
'batch_size' => $modes['default']['batch_size'],
|
||||
'pause' => $modes['default']['pause'],
|
||||
);
|
||||
}
|
||||
|
||||
$default_params = $modes['default'];
|
||||
|
||||
return array(
|
||||
'mode' => 'custom',
|
||||
'batch_size' => \max( 1, \absint( \get_option( 'activitypub_custom_batch_size', $default_params['batch_size'] ) ) ),
|
||||
'pause' => \absint( \get_option( 'activitypub_custom_batch_pause', $default_params['pause'] ) ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the dispatcher batch size based on distribution mode.
|
||||
*
|
||||
* In `'default'` mode the upstream value is passed through so the
|
||||
* `ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE` constant and other filters
|
||||
* still win; any explicit mode imposes its own batch size.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @param int $batch_size The default batch size.
|
||||
*
|
||||
* @return int The batch size for the current distribution mode.
|
||||
*/
|
||||
public static function filter_dispatcher_batch_size( $batch_size ) {
|
||||
$params = self::get_distribution_params();
|
||||
|
||||
return 'default' === $params['mode'] ? $batch_size : $params['batch_size'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the scheduler batch pause based on distribution mode.
|
||||
*
|
||||
* Only delivery batches (`activitypub_send_activity`) are affected. Every
|
||||
* mode imposes its own delivery pause: `'default'` is the fast preset, which
|
||||
* is intentionally shorter than the generic async-batch baseline, so it does
|
||||
* not pass the upstream value through.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @param int $pause The default pause in seconds.
|
||||
* @param string|false|null $hook The async batch hook being scheduled.
|
||||
*
|
||||
* @return int The pause for the current distribution mode.
|
||||
*/
|
||||
public static function filter_scheduler_batch_pause( $pause, $hook = null ) {
|
||||
if ( 'activitypub_send_activity' !== $hook ) {
|
||||
return $pause;
|
||||
}
|
||||
|
||||
return self::get_distribution_params()['pause'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize purge day values.
|
||||
*
|
||||
|
||||
@ -519,10 +519,10 @@ class Post_Types {
|
||||
'single' => true,
|
||||
'description' => 'Allowed redirect URIs for this client.',
|
||||
'sanitize_callback' => static function ( $value ) {
|
||||
if ( ! is_array( $value ) ) {
|
||||
if ( ! \is_array( $value ) ) {
|
||||
return array();
|
||||
}
|
||||
return array_map( array( Sanitize::class, 'redirect_uri' ), $value );
|
||||
return \array_map( array( Sanitize::class, 'redirect_uri' ), $value );
|
||||
},
|
||||
)
|
||||
);
|
||||
@ -574,6 +574,10 @@ class Post_Types {
|
||||
\register_post_type(
|
||||
Tombstone::POST_TYPE,
|
||||
array(
|
||||
'labels' => array(
|
||||
'name' => \_x( 'Tombstones', 'post_type plural name', 'activitypub' ),
|
||||
'singular_name' => \_x( 'Tombstone', 'post_type single name', 'activitypub' ),
|
||||
),
|
||||
'public' => false,
|
||||
'publicly_queryable' => false,
|
||||
'show_ui' => false,
|
||||
@ -741,8 +745,8 @@ class Post_Types {
|
||||
return array(
|
||||
'username' => $actor->get_preferred_username(),
|
||||
'name' => $actor->get_name() ?? $actor->get_preferred_username(),
|
||||
'icon' => object_to_uri( $actor->get_icon() ),
|
||||
'url' => object_to_uri( $actor->get_url() ?? $actor->get_id() ),
|
||||
'icon' => \sanitize_url( object_to_uri( $actor->get_icon() ) ?? '' ),
|
||||
'url' => \sanitize_url( object_to_uri( $actor->get_url() ?? $actor->get_id() ) ?? '' ),
|
||||
'webfinger' => Remote_Actors::get_acct( $response['id'] ),
|
||||
'identifier' => $actor->get_id(),
|
||||
);
|
||||
@ -829,8 +833,8 @@ class Post_Types {
|
||||
return array(
|
||||
'username' => $actor->get_preferred_username(),
|
||||
'name' => $actor->get_name() ?? $actor->get_preferred_username(),
|
||||
'icon' => object_to_uri( $actor->get_icon() ),
|
||||
'url' => object_to_uri( $actor->get_url() ?? $actor->get_id() ),
|
||||
'icon' => \sanitize_url( object_to_uri( $actor->get_icon() ) ?? '' ),
|
||||
'url' => \sanitize_url( object_to_uri( $actor->get_url() ?? $actor->get_id() ) ?? '' ),
|
||||
'webfinger' => Remote_Actors::get_acct( $id ),
|
||||
'identifier' => $actor->get_id(),
|
||||
);
|
||||
@ -852,7 +856,7 @@ class Post_Types {
|
||||
'rest_' . Remote_Posts::POST_TYPE . '_collection_params',
|
||||
function ( $params ) {
|
||||
$params['user_id'] = array(
|
||||
'description' => __( 'Filter posts by user ID (0 for site/blog actor).', 'activitypub' ),
|
||||
'description' => \__( 'Filter posts by user ID (0 for site/blog actor).', 'activitypub' ),
|
||||
'type' => 'integer',
|
||||
'sanitize_callback' => 'absint',
|
||||
);
|
||||
@ -942,7 +946,7 @@ class Post_Types {
|
||||
*/
|
||||
public static function register_object_type_user_param( $params ) {
|
||||
$params['user_id'] = array(
|
||||
'description' => __( 'Filter terms to those with posts from this user ID.', 'activitypub' ),
|
||||
'description' => \__( 'Filter terms to those with posts from this user ID.', 'activitypub' ),
|
||||
'type' => 'integer',
|
||||
'sanitize_callback' => 'absint',
|
||||
);
|
||||
@ -1010,7 +1014,7 @@ class Post_Types {
|
||||
);
|
||||
|
||||
if ( isset( $post_metas[ $meta_key ] ) && $post_metas[ $meta_key ] === (string) $meta_value ) {
|
||||
if ( 'update_post_metadata' === current_action() ) {
|
||||
if ( 'update_post_metadata' === \current_action() ) {
|
||||
\delete_post_meta( $object_id, $meta_key );
|
||||
}
|
||||
|
||||
|
||||
@ -7,9 +7,11 @@
|
||||
|
||||
namespace Activitypub;
|
||||
|
||||
use Activitypub\Activity\Extended_Object\Feature_Authorization;
|
||||
use Activitypub\Activity\Extended_Object\Quote_Authorization;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Outbox;
|
||||
use Activitypub\Handler\Feature_Request;
|
||||
use Activitypub\Transformer\Factory;
|
||||
|
||||
/**
|
||||
@ -139,8 +141,15 @@ class Query {
|
||||
private function prepare_activitypub_data() {
|
||||
$queried_object = $this->get_queried_object();
|
||||
|
||||
if ( $queried_object instanceof \WP_Post && \get_query_var( 'stamp' ) ) {
|
||||
return $this->maybe_get_stamp();
|
||||
if ( \get_query_var( 'stamp' ) ) {
|
||||
if ( $queried_object instanceof \WP_Post ) {
|
||||
return $this->maybe_get_stamp();
|
||||
}
|
||||
|
||||
// Note: the blog actor's `actor` query var is '0', which is falsy but valid.
|
||||
if ( $queried_object instanceof \WP_User || '' !== \get_query_var( 'actor' ) ) {
|
||||
return $this->maybe_get_actor_stamp();
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Outbox Activity.
|
||||
@ -220,17 +229,16 @@ class Query {
|
||||
*
|
||||
* @param \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null $queried_object The queried object.
|
||||
*/
|
||||
return apply_filters( 'activitypub_queried_object', $queried_object );
|
||||
return \apply_filters( 'activitypub_queried_object', $queried_object );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the virtual object.
|
||||
*
|
||||
* Virtual objects are objects that are not stored in the database, but are created on the fly.
|
||||
* The plugins currently supports two virtual objects: The Blog-Actor and the Application-Actor.
|
||||
* The plugin currently supports one virtual object: The Blog-Actor.
|
||||
*
|
||||
* @see \Activitypub\Model\Blog
|
||||
* @see \Activitypub\Model\Application
|
||||
*
|
||||
* @return object|null The virtual object.
|
||||
*/
|
||||
@ -243,7 +251,7 @@ class Query {
|
||||
|
||||
$author_id = url_to_authorid( $url );
|
||||
|
||||
if ( ! is_numeric( $author_id ) ) {
|
||||
if ( ! \is_numeric( $author_id ) ) {
|
||||
$author_id = $url;
|
||||
}
|
||||
|
||||
@ -403,8 +411,13 @@ class Query {
|
||||
|
||||
$post = $this->get_queried_object();
|
||||
|
||||
// Ensure the meta belongs to the queried post to prevent arbitrary meta disclosure.
|
||||
if ( (int) $meta->post_id !== $post->ID ) {
|
||||
/*
|
||||
* Only quote-authorization meta may be reflected as a stamp, and only for the queried
|
||||
* post. Checking the post id alone would still let an unauthenticated request read any
|
||||
* of that post's meta rows (e.g. _edit_lock or private custom fields) by guessing a
|
||||
* meta_id, so the meta key is verified too.
|
||||
*/
|
||||
if ( '_activitypub_quoted_by' !== $meta->meta_key || (int) $meta->post_id !== $post->ID ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -433,4 +446,69 @@ class Query {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe get a FeatureAuthorization object from an actor-scoped stamp.
|
||||
*
|
||||
* Resolves URLs of the form `?actor=USER_ID&stamp=STAMP_ID` against the
|
||||
* actor's stamp store, see {@see Feature_Request::get_stamp()}. Ownership
|
||||
* is enforced by resolving the stamp scoped to the queried actor, which
|
||||
* includes the blog actor (`actor=0`).
|
||||
*
|
||||
* @return bool True if a FeatureAuthorization was prepared, false otherwise.
|
||||
*/
|
||||
private function maybe_get_actor_stamp() {
|
||||
$stamp_id = (int) \get_query_var( 'stamp' );
|
||||
$actor_var = \get_query_var( 'actor' );
|
||||
|
||||
if ( ! $stamp_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( '' === $actor_var ) {
|
||||
$queried = $this->get_queried_object();
|
||||
if ( ! $queried instanceof \WP_User ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actor_id = (int) $queried->ID;
|
||||
} else {
|
||||
// Values like '0e1' or '1.5' pass is_numeric() but cast to 0/1 and alias
|
||||
// an actor, so require a plain decimal integer before casting.
|
||||
if ( ! \ctype_digit( (string) $actor_var ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actor_id = (int) $actor_var;
|
||||
}
|
||||
|
||||
$instrument = Feature_Request::get_stamp( $actor_id, $stamp_id );
|
||||
if ( null === $instrument ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actor = Actors::get_by_id( $actor_id );
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stamp_url = \add_query_arg(
|
||||
array(
|
||||
'actor' => $actor_id,
|
||||
'stamp' => $stamp_id,
|
||||
),
|
||||
\home_url( '/' )
|
||||
);
|
||||
|
||||
$authorization = new Feature_Authorization();
|
||||
$authorization->set_id( $stamp_url );
|
||||
$authorization->set_attributed_to( $actor->get_id() );
|
||||
$authorization->set_interacting_object( $instrument );
|
||||
$authorization->set_interaction_target( $actor->get_id() );
|
||||
|
||||
$this->activitypub_object = $authorization;
|
||||
$this->activitypub_object_id = $authorization->get_id();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ class Relay {
|
||||
// Only relay if: successfully handled, Blog actor is recipient, activity is public, and in single-user mode.
|
||||
if (
|
||||
! $success ||
|
||||
! in_array( Actors::BLOG_USER_ID, (array) $user_ids, true ) ||
|
||||
! \in_array( Actors::BLOG_USER_ID, (array) $user_ids, true ) ||
|
||||
! is_activity_public( $activity ) ||
|
||||
! is_single_user()
|
||||
) {
|
||||
@ -53,7 +53,7 @@ class Relay {
|
||||
$announce->set_type( 'Announce' );
|
||||
$announce->set_actor( Actors::BLOG_USER_ID );
|
||||
$announce->set_object( $activity );
|
||||
$announce->set_published( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339 ) );
|
||||
$announce->set_published( \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339 ) );
|
||||
|
||||
// Add to outbox for distribution. The outbox will generate the ID.
|
||||
Outbox::add( $announce, Actors::BLOG_USER_ID );
|
||||
|
||||
@ -71,6 +71,8 @@ class Router {
|
||||
'top'
|
||||
);
|
||||
|
||||
// Must precede the generic @username rule, which would otherwise match "application" as an actor username.
|
||||
\add_rewrite_rule( '^@application\/?$', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/application', 'top' );
|
||||
\add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' );
|
||||
\add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES );
|
||||
}
|
||||
@ -107,8 +109,31 @@ class Router {
|
||||
}
|
||||
|
||||
$activitypub_object = Query::get_instance()->get_activitypub_object();
|
||||
$queried_object = Query::get_instance()->get_queried_object();
|
||||
|
||||
if ( Tombstone::exists_local( Query::get_instance()->get_request_url() ) ) {
|
||||
/*
|
||||
* Serve the Tombstone for a deleted object — but not while an authorized
|
||||
* user is previewing it. During an editor `?preview=true` request,
|
||||
* `is_post_publicly_queryable()` treats a draft or pending post as
|
||||
* queryable only for a user who can edit it, so the author can still use
|
||||
* the Fediverse Preview on a post they just soft-deleted (which is
|
||||
* otherwise already in the tombstone registry).
|
||||
*
|
||||
* The bypass is scoped to that preview request on purpose. A normal
|
||||
* ActivityPub fetch (no `preview`) of a tombstoned URL always gets the
|
||||
* Tombstone — even if the URL now resolves to a fresh public post because
|
||||
* its slug was reused — since remote servers were told that id is gone.
|
||||
* The legitimate restore path clears the registry entry itself, via
|
||||
* `Create::maybe_unbury()` when the re-publish Create is queued.
|
||||
*/
|
||||
$is_authorized_preview = \get_query_var( 'preview' )
|
||||
&& $queried_object instanceof \WP_Post
|
||||
&& is_post_publicly_queryable( $queried_object );
|
||||
|
||||
if (
|
||||
Tombstone::exists_local( Query::get_instance()->get_request_url() )
|
||||
&& ! $is_authorized_preview
|
||||
) {
|
||||
// Set 410 Gone for permanently deleted posts, 200 OK for soft-deleted.
|
||||
if ( ! $activitypub_object ) {
|
||||
\status_header( 410 );
|
||||
@ -117,18 +142,36 @@ class Router {
|
||||
return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php';
|
||||
}
|
||||
|
||||
/*
|
||||
* Refuse to expose the content-negotiated representation of a post
|
||||
* that is no longer publicly queryable (non-public status, AP
|
||||
* visibility flipped, post-type support removed, etc.). The
|
||||
* lifecycle gate in `is_post_disabled()` intentionally lets such
|
||||
* posts through the federation pipeline so a Delete can fire, but
|
||||
* that escape hatch must not leak into front-end rendering during
|
||||
* the window between status change and Delete delivery.
|
||||
*/
|
||||
if (
|
||||
$activitypub_object &&
|
||||
$queried_object instanceof \WP_Post &&
|
||||
'ap_outbox' !== $queried_object->post_type &&
|
||||
! is_post_publicly_queryable( $queried_object )
|
||||
) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
$activitypub_template = false;
|
||||
|
||||
if ( $activitypub_object ) {
|
||||
if ( \get_query_var( 'preview' ) ) {
|
||||
\define( 'ACTIVITYPUB_PREVIEW', true );
|
||||
\defined( 'ACTIVITYPUB_PREVIEW' ) || \define( 'ACTIVITYPUB_PREVIEW', true );
|
||||
|
||||
/**
|
||||
* Filter the template used for the ActivityPub preview.
|
||||
*
|
||||
* @param string $activitypub_template Absolute path to the template file.
|
||||
*/
|
||||
$activitypub_template = apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
|
||||
$activitypub_template = \apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
|
||||
} else {
|
||||
$activitypub_template = ACTIVITYPUB_PLUGIN_DIR . 'templates/activitypub-json.php';
|
||||
}
|
||||
@ -191,7 +234,7 @@ class Router {
|
||||
}
|
||||
|
||||
if ( ! \headers_sent() ) {
|
||||
\header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
|
||||
\header( 'Link: <' . \esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
|
||||
|
||||
if ( \get_option( 'activitypub_vary_header', '1' ) ) {
|
||||
// Send Vary header for Accept header.
|
||||
@ -202,7 +245,7 @@ class Router {
|
||||
\add_action(
|
||||
'wp_head',
|
||||
static function () use ( $id ) {
|
||||
echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . esc_url( $id ) . '" />' . PHP_EOL;
|
||||
echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . \esc_url( $id ) . '" />' . PHP_EOL;
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -216,7 +259,7 @@ class Router {
|
||||
* @return string $redirect_url The possibly-unslashed redirect URL.
|
||||
*/
|
||||
public static function no_trailing_redirect( $redirect_url, $requested_url ) {
|
||||
if ( get_query_var( 'actor' ) ) {
|
||||
if ( \get_query_var( 'actor' ) ) {
|
||||
return $requested_url;
|
||||
}
|
||||
|
||||
@ -246,7 +289,7 @@ class Router {
|
||||
unset( $query_params['activitypub'] );
|
||||
unset( $query_params['stamp'] );
|
||||
|
||||
if ( 1 !== count( $query_params ) ) {
|
||||
if ( 1 !== \count( $query_params ) ) {
|
||||
return $redirect_url;
|
||||
}
|
||||
|
||||
@ -286,12 +329,20 @@ class Router {
|
||||
return;
|
||||
}
|
||||
|
||||
\wp_safe_redirect( get_comment_link( $comment ) );
|
||||
\wp_safe_redirect( \get_comment_link( $comment ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
$actor = \get_query_var( 'actor', null );
|
||||
if ( $actor ) {
|
||||
/*
|
||||
* Skip the actor branch when this looks like an actor-scoped FEP-7aa9
|
||||
* stamp URL: numeric `actor` paired with a `stamp`. Those resolve to a
|
||||
* FeatureAuthorization via Activitypub\Query, not via the username
|
||||
* lookup which would 404 the numeric ID. Non-numeric actors fall
|
||||
* through to the regular Mastodon-style profile lookup.
|
||||
*/
|
||||
$actor = \get_query_var( 'actor', null );
|
||||
$is_stamp_url = $actor && \get_query_var( 'stamp' ) && \ctype_digit( (string) $actor );
|
||||
if ( $actor && ! $is_stamp_url ) {
|
||||
$actor = Actors::get_by_username( $actor );
|
||||
if ( ! $actor || \is_wp_error( $actor ) ) {
|
||||
$wp_query->set_404();
|
||||
@ -325,7 +376,7 @@ class Router {
|
||||
*/
|
||||
$supported_taxonomies = \apply_filters( 'activitypub_supported_taxonomies', array( 'category', 'post_tag' ) );
|
||||
|
||||
if ( ! in_array( $term->taxonomy, $supported_taxonomies, true ) ) {
|
||||
if ( ! \in_array( $term->taxonomy, $supported_taxonomies, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -165,6 +165,17 @@ class Sanitize {
|
||||
return Blog::get_default_username();
|
||||
}
|
||||
|
||||
// The 'application' identifier is reserved for the Application actor.
|
||||
if ( Application::USERNAME === $sanitized ) {
|
||||
\add_settings_error(
|
||||
'activitypub_blog_identifier',
|
||||
'activitypub_blog_identifier',
|
||||
\esc_html__( 'This name is reserved and cannot be used for the blog profile ID.', 'activitypub' )
|
||||
);
|
||||
|
||||
return Blog::get_default_username();
|
||||
}
|
||||
|
||||
// Check for login or nicename.
|
||||
$user = new \WP_User_Query(
|
||||
array(
|
||||
@ -197,17 +208,17 @@ class Sanitize {
|
||||
* @return string The sanitized value.
|
||||
*/
|
||||
public static function constant_value( $value ) {
|
||||
if ( is_bool( $value ) ) {
|
||||
if ( \is_bool( $value ) ) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if ( is_string( $value ) ) {
|
||||
return esc_attr( $value );
|
||||
if ( \is_string( $value ) ) {
|
||||
return \esc_attr( $value );
|
||||
}
|
||||
|
||||
if ( is_array( $value ) ) {
|
||||
if ( \is_array( $value ) ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
|
||||
return print_r( $value, true );
|
||||
return \print_r( $value, true );
|
||||
}
|
||||
|
||||
return $value;
|
||||
@ -277,19 +288,19 @@ class Sanitize {
|
||||
* Extract scheme manually because wp_parse_url() returns false
|
||||
* for URIs like "myapp://" (scheme + empty authority, no path).
|
||||
*/
|
||||
if ( ! preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
|
||||
if ( ! \preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$scheme = \strtolower( $matches[1] );
|
||||
|
||||
// For standard schemes, use default sanitization.
|
||||
if ( in_array( $scheme, array( 'http', 'https' ), true ) ) {
|
||||
if ( \in_array( $scheme, array( 'http', 'https' ), true ) ) {
|
||||
return \sanitize_url( $uri );
|
||||
}
|
||||
|
||||
// For custom schemes, include the scheme in allowed protocols.
|
||||
return \sanitize_url( $uri, array_merge( \wp_allowed_protocols(), array( $scheme ) ) );
|
||||
return \sanitize_url( $uri, \array_merge( \wp_allowed_protocols(), array( $scheme ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -53,15 +53,22 @@ class Scheduler {
|
||||
/**
|
||||
* Get the pause between async batches (in seconds).
|
||||
*
|
||||
* @param string|false|null $hook Optional. The async batch hook being scheduled. Default current action.
|
||||
*
|
||||
* @return int The pause in seconds.
|
||||
*/
|
||||
public static function get_retry_delay() {
|
||||
public static function get_retry_delay( $hook = null ) {
|
||||
if ( null === $hook ) {
|
||||
$hook = \current_action();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the pause between async batches (in seconds).
|
||||
*
|
||||
* @param int $async_batch_pause The pause in seconds. Default 30.
|
||||
* @param int $async_batch_pause The pause in seconds. Default 30.
|
||||
* @param string|false|null $hook The async batch hook being scheduled.
|
||||
*/
|
||||
return apply_filters( 'activitypub_scheduler_async_batch_pause', 30 );
|
||||
return \apply_filters( 'activitypub_scheduler_async_batch_pause', 30, $hook );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -162,7 +169,7 @@ class Scheduler {
|
||||
public static function register_schedules() {
|
||||
foreach ( self::SCHEDULES as $hook => $recurrence ) {
|
||||
if ( ! \wp_next_scheduled( $hook ) ) {
|
||||
\wp_schedule_event( time(), $recurrence, $hook );
|
||||
\wp_schedule_event( \time(), $recurrence, $hook );
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,7 +193,7 @@ class Scheduler {
|
||||
* @return void
|
||||
*/
|
||||
public static function deregister_schedules() {
|
||||
foreach ( array_keys( self::SCHEDULES ) as $hook ) {
|
||||
foreach ( \array_keys( self::SCHEDULES ) as $hook ) {
|
||||
\wp_unschedule_hook( $hook );
|
||||
}
|
||||
|
||||
@ -217,11 +224,11 @@ class Scheduler {
|
||||
$year = (int) \gmdate( 'Y', $now );
|
||||
|
||||
// Get December 1st 3:00 AM for this year.
|
||||
$this_year_dec_first = \strtotime( sprintf( '%d-12-01 03:00:00', $year ) );
|
||||
$this_year_dec_first = \strtotime( \sprintf( '%d-12-01 03:00:00', $year ) );
|
||||
|
||||
// If we're already past this year's December 1st, schedule for next year.
|
||||
if ( $now >= $this_year_dec_first ) {
|
||||
return \strtotime( sprintf( '%d-12-01 03:00:00', $year + 1 ) );
|
||||
return \strtotime( \sprintf( '%d-12-01 03:00:00', $year + 1 ) );
|
||||
}
|
||||
|
||||
return $this_year_dec_first;
|
||||
@ -233,22 +240,15 @@ class Scheduler {
|
||||
* @param int $outbox_item_id The outbox item ID.
|
||||
*/
|
||||
public static function unschedule_events_for_item( $outbox_item_id ) {
|
||||
$event_args = array(
|
||||
$outbox_item_id,
|
||||
Dispatcher::get_batch_size(),
|
||||
\get_post_meta( $outbox_item_id, '_activitypub_outbox_offset', true ) ?: 0, // phpcs:ignore
|
||||
);
|
||||
|
||||
\delete_post_meta( $outbox_item_id, '_activitypub_outbox_offset' );
|
||||
|
||||
$timestamp = \wp_next_scheduled( 'activitypub_process_outbox', array( $outbox_item_id ) );
|
||||
\wp_unschedule_event( $timestamp, 'activitypub_process_outbox', array( $outbox_item_id ) );
|
||||
|
||||
$timestamp = \wp_next_scheduled( 'activitypub_send_activity', $event_args );
|
||||
\wp_unschedule_event( $timestamp, 'activitypub_send_activity', $event_args );
|
||||
self::unschedule_outbox_delivery_batches( $outbox_item_id );
|
||||
|
||||
// Invalidate any retries for this outbox item.
|
||||
foreach ( _get_cron_array() as $timestamp => $cron ) {
|
||||
foreach ( \_get_cron_array() as $timestamp => $cron ) {
|
||||
if ( ! isset( $cron['activitypub_retry_activity'] ) ) {
|
||||
continue;
|
||||
}
|
||||
@ -267,7 +267,7 @@ class Scheduler {
|
||||
public static function update_remote_actors() {
|
||||
$number = 5;
|
||||
|
||||
if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
|
||||
if ( \defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
|
||||
$number = 50;
|
||||
}
|
||||
|
||||
@ -276,16 +276,54 @@ class Scheduler {
|
||||
*
|
||||
* @param int $number The number of remote Actors to update.
|
||||
*/
|
||||
$number = apply_filters( 'activitypub_update_remote_actors_number', $number );
|
||||
$number = \apply_filters( 'activitypub_update_remote_actors_number', $number );
|
||||
$actors = Remote_Actors::get_outdated( $number );
|
||||
|
||||
foreach ( $actors as $actor ) {
|
||||
$meta = get_remote_metadata_by_actor( $actor->guid, false );
|
||||
/*
|
||||
* Use Http::get_remote_object() directly here.
|
||||
* get_remote_metadata_by_actor() short-circuits to the locally
|
||||
* cached ap_actor CPT via Remote_Actors::fetch_by_uri() and never
|
||||
* makes an HTTP request when the actor is already cached, so the
|
||||
* upsert would just rewrite the same stale data and this refresh
|
||||
* would be a no-op. The Update handler documents the same trap.
|
||||
*/
|
||||
$meta = Http::get_remote_object( $actor->guid, false );
|
||||
|
||||
if ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) {
|
||||
if ( empty( $meta ) || ! \is_array( $meta ) || \is_wp_error( $meta ) ) {
|
||||
Remote_Actors::add_error( $actor->ID, 'Failed to fetch or parse metadata' );
|
||||
} else {
|
||||
$id = Remote_Actors::upsert( $meta );
|
||||
/*
|
||||
* Only refresh when the remote still reports the same identity. A
|
||||
* different (or missing) id means a Move or a malformed response;
|
||||
* applying it would rewrite the cached guid in place and could
|
||||
* collide with another cached actor, so leave the record alone.
|
||||
* Updating by the known post ID otherwise refreshes it without the
|
||||
* redundant get_by_uri() lookup upsert() would do.
|
||||
*/
|
||||
$fetched_id = isset( $meta['id'] ) && \is_string( $meta['id'] ) ? \esc_url_raw( $meta['id'] ) : '';
|
||||
if ( $fetched_id !== $actor->guid ) {
|
||||
/*
|
||||
* Bump only the modified date, directly, so the skipped actor
|
||||
* drops out of the outdated queue and is not re-fetched every
|
||||
* run. A direct write avoids the save_post hooks wp_update_post()
|
||||
* fires (which would needlessly clear the cached avatar); the
|
||||
* record is intentionally left unchanged otherwise.
|
||||
*/
|
||||
global $wpdb;
|
||||
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->posts,
|
||||
array(
|
||||
'post_modified' => \current_time( 'mysql' ),
|
||||
'post_modified_gmt' => \current_time( 'mysql', true ),
|
||||
),
|
||||
array( 'ID' => $actor->ID )
|
||||
);
|
||||
\clean_post_cache( $actor->ID );
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = Remote_Actors::update( $actor->ID, $meta );
|
||||
if ( \is_wp_error( $id ) ) {
|
||||
continue;
|
||||
}
|
||||
@ -300,7 +338,7 @@ class Scheduler {
|
||||
public static function cleanup_remote_actors() {
|
||||
$number = 5;
|
||||
|
||||
if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
|
||||
if ( \defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
|
||||
$number = 50;
|
||||
}
|
||||
|
||||
@ -309,7 +347,7 @@ class Scheduler {
|
||||
*
|
||||
* @param int $number The number of remote Actors to clean up.
|
||||
*/
|
||||
$number = apply_filters( 'activitypub_cleanup_remote_actors_number', $number );
|
||||
$number = \apply_filters( 'activitypub_cleanup_remote_actors_number', $number );
|
||||
$actors = Remote_Actors::get_faulty( $number );
|
||||
|
||||
foreach ( $actors as $actor ) {
|
||||
@ -317,7 +355,7 @@ class Scheduler {
|
||||
|
||||
if ( Tombstone::exists( $meta ) ) {
|
||||
\wp_delete_post( $actor->ID );
|
||||
} elseif ( empty( $meta ) || ! is_array( $meta ) || \is_wp_error( $meta ) ) {
|
||||
} elseif ( empty( $meta ) || ! \is_array( $meta ) || \is_wp_error( $meta ) ) {
|
||||
if ( Remote_Actors::count_errors( $actor->ID ) >= 5 ) {
|
||||
\wp_schedule_single_event( \time(), 'activitypub_delete_remote_actor_interactions', array( $actor->guid ) );
|
||||
\wp_schedule_single_event( \time(), 'activitypub_delete_remote_actor_posts', array( $actor->guid ) );
|
||||
@ -346,7 +384,7 @@ class Scheduler {
|
||||
$hook = 'activitypub_process_outbox';
|
||||
$args = array( $id );
|
||||
|
||||
if ( false === wp_next_scheduled( $hook, $args ) ) {
|
||||
if ( false === \wp_next_scheduled( $hook, $args ) ) {
|
||||
\wp_schedule_single_event(
|
||||
\time() + $offset,
|
||||
$hook,
|
||||
@ -371,7 +409,7 @@ class Scheduler {
|
||||
foreach ( $ids as $id ) {
|
||||
// Bail if there is a pending batch.
|
||||
$offset = \get_post_meta( $id, '_activitypub_outbox_offset', true ) ?: 0; // phpcs:ignore
|
||||
if ( \wp_next_scheduled( 'activitypub_send_activity', array( $id, Dispatcher::get_batch_size(), $offset ) ) ) {
|
||||
if ( self::has_scheduled_outbox_delivery_batch( $id, $offset ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -415,7 +453,7 @@ class Scheduler {
|
||||
* @since 8.3.0
|
||||
*/
|
||||
public static function purge_tombstones() {
|
||||
\Activitypub\Tombstone::purge();
|
||||
Tombstone::purge();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -435,7 +473,7 @@ class Scheduler {
|
||||
$data = \json_decode( $inbox_item->post_content, true );
|
||||
// Reconstruct activity from inbox post.
|
||||
$activity = Activity::init_from_array( $data );
|
||||
$type = \Activitypub\camel_to_snake_case( $activity->get_type() );
|
||||
$type = camel_to_snake_case( $activity->get_type() );
|
||||
$context = Inbox::CONTEXT_INBOX;
|
||||
$user_ids = Inbox::get_recipients( $inbox_item->ID );
|
||||
|
||||
@ -542,10 +580,73 @@ class Scheduler {
|
||||
|
||||
if ( ! empty( $next ) ) {
|
||||
// Schedule the next run, adding the result to the arguments.
|
||||
\wp_schedule_single_event( \time() + self::get_retry_delay(), \current_action(), \array_values( $next ) );
|
||||
\wp_schedule_single_event( \time() + self::get_retry_delay( \current_action() ), \current_action(), \array_values( $next ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an outbox item already has a scheduled delivery batch at an offset.
|
||||
*
|
||||
* @param int $outbox_item_id The outbox item ID.
|
||||
* @param int $offset The delivery offset.
|
||||
*
|
||||
* @return bool True when a matching delivery batch is scheduled.
|
||||
*/
|
||||
private static function has_scheduled_outbox_delivery_batch( $outbox_item_id, $offset ) {
|
||||
return ! empty( self::get_scheduled_outbox_delivery_batches( $outbox_item_id, $offset ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Unschedule all pending delivery batches for an outbox item.
|
||||
*
|
||||
* @param int $outbox_item_id The outbox item ID.
|
||||
*/
|
||||
private static function unschedule_outbox_delivery_batches( $outbox_item_id ) {
|
||||
foreach ( self::get_scheduled_outbox_delivery_batches( $outbox_item_id ) as $event ) {
|
||||
\wp_unschedule_event( $event['timestamp'], 'activitypub_send_activity', $event['args'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled delivery batches for an outbox item.
|
||||
*
|
||||
* The batch size is deliberately ignored because scheduled events may
|
||||
* retain an older value after the admin changes the distribution mode.
|
||||
*
|
||||
* @param int $outbox_item_id The outbox item ID.
|
||||
* @param int|null $offset Optional. Restrict results to this delivery offset.
|
||||
*
|
||||
* @return array Scheduled events with timestamp and args.
|
||||
*/
|
||||
private static function get_scheduled_outbox_delivery_batches( $outbox_item_id, $offset = null ) {
|
||||
$events = array();
|
||||
|
||||
foreach ( \_get_cron_array() as $timestamp => $cron ) {
|
||||
if ( empty( $cron['activitypub_send_activity'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ( $cron['activitypub_send_activity'] as $event ) {
|
||||
$args = $event['args'] ?? array();
|
||||
|
||||
if ( ! isset( $args[0] ) || (int) $outbox_item_id !== (int) $args[0] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( null !== $offset && (int) ( $args[2] ?? 0 ) !== (int) $offset ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$events[] = array(
|
||||
'timestamp' => $timestamp,
|
||||
'args' => $args,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks the async batch process for individual callbacks to prevent simultaneous processing.
|
||||
*
|
||||
@ -626,12 +727,12 @@ class Scheduler {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! is_object( $activity->get_object() ) ) {
|
||||
if ( ! \is_object( $activity->get_object() ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the object is an article, image, audio, video, event, or document and ignore profile updates and other activities.
|
||||
if ( ! in_array( $activity->get_object()->get_type(), Base_Object::TYPES, true ) ) {
|
||||
if ( ! \in_array( $activity->get_object()->get_type(), Base_Object::TYPES, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ class Search {
|
||||
*/
|
||||
public static function enhance_public_search( $query ) {
|
||||
// Check user capabilities.
|
||||
if ( ! current_user_can( 'activitypub' ) ) {
|
||||
if ( ! \current_user_can( 'activitypub' ) ) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
@ -76,7 +76,7 @@ class Search {
|
||||
*/
|
||||
public static function enhance_admin_comment_search() {
|
||||
// Check user capabilities.
|
||||
if ( ! current_user_can( 'activitypub' ) ) {
|
||||
if ( ! \current_user_can( 'activitypub' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -15,9 +15,9 @@ class Shortcodes {
|
||||
* Register the shortcodes.
|
||||
*/
|
||||
public static function register() {
|
||||
foreach ( get_class_methods( self::class ) as $shortcode ) {
|
||||
foreach ( \get_class_methods( self::class ) as $shortcode ) {
|
||||
if ( 'init' !== $shortcode ) {
|
||||
add_shortcode( 'ap_' . $shortcode, array( self::class, $shortcode ) );
|
||||
\add_shortcode( 'ap_' . $shortcode, array( self::class, $shortcode ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -26,9 +26,9 @@ class Shortcodes {
|
||||
* Unregister the shortcodes.
|
||||
*/
|
||||
public static function unregister() {
|
||||
foreach ( get_class_methods( self::class ) as $shortcode ) {
|
||||
foreach ( \get_class_methods( self::class ) as $shortcode ) {
|
||||
if ( 'init' !== $shortcode ) {
|
||||
remove_shortcode( 'ap_' . $shortcode );
|
||||
\remove_shortcode( 'ap_' . $shortcode );
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -91,7 +91,7 @@ class Shortcodes {
|
||||
return '';
|
||||
}
|
||||
|
||||
$attributes = shortcode_atts(
|
||||
$attributes = \shortcode_atts(
|
||||
array( 'type' => 'plain' ),
|
||||
$attributes,
|
||||
$tag
|
||||
@ -101,7 +101,7 @@ class Shortcodes {
|
||||
return $title;
|
||||
}
|
||||
|
||||
return sprintf( '<h2>%s</h2>', $title );
|
||||
return \sprintf( '<h2>%s</h2>', $title );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -120,13 +120,13 @@ class Shortcodes {
|
||||
return '';
|
||||
}
|
||||
|
||||
$attributes = shortcode_atts(
|
||||
$attributes = \shortcode_atts(
|
||||
array( 'length' => ACTIVITYPUB_EXCERPT_LENGTH ),
|
||||
$attributes,
|
||||
$tag
|
||||
);
|
||||
|
||||
$excerpt_length = intval( $attributes['length'] );
|
||||
$excerpt_length = \intval( $attributes['length'] );
|
||||
|
||||
if ( 0 === $excerpt_length ) {
|
||||
$excerpt_length = ACTIVITYPUB_EXCERPT_LENGTH;
|
||||
@ -155,9 +155,9 @@ class Shortcodes {
|
||||
}
|
||||
|
||||
// Prevent inception.
|
||||
remove_shortcode( 'ap_content' );
|
||||
\remove_shortcode( 'ap_content' );
|
||||
|
||||
$attributes = shortcode_atts(
|
||||
$attributes = \shortcode_atts(
|
||||
array( 'apply_filters' => 'yes' ),
|
||||
$attributes,
|
||||
$tag
|
||||
@ -167,9 +167,9 @@ class Shortcodes {
|
||||
|
||||
if ( 'attachment' === $item->post_type ) {
|
||||
// Get title of attachment with fallback to alt text.
|
||||
$content = wp_get_attachment_caption( $item->ID );
|
||||
$content = \wp_get_attachment_caption( $item->ID );
|
||||
if ( empty( $content ) ) {
|
||||
$content = get_post_meta( $item->ID, '_wp_attachment_image_alt', true );
|
||||
$content = \get_post_meta( $item->ID, '_wp_attachment_image_alt', true );
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,7 +192,7 @@ class Shortcodes {
|
||||
$content = Sanitize::clean_html( $content );
|
||||
$content = Sanitize::strip_whitespace( $content );
|
||||
|
||||
add_shortcode( 'ap_content', array( 'Activitypub\Shortcodes', 'content' ) );
|
||||
\add_shortcode( 'ap_content', array( 'Activitypub\Shortcodes', 'content' ) );
|
||||
|
||||
return $content;
|
||||
}
|
||||
@ -213,7 +213,7 @@ class Shortcodes {
|
||||
return '';
|
||||
}
|
||||
|
||||
$attributes = shortcode_atts(
|
||||
$attributes = \shortcode_atts(
|
||||
array(
|
||||
'type' => 'url',
|
||||
),
|
||||
@ -247,7 +247,7 @@ class Shortcodes {
|
||||
return '';
|
||||
}
|
||||
|
||||
$attributes = shortcode_atts(
|
||||
$attributes = \shortcode_atts(
|
||||
array(
|
||||
'type' => 'url',
|
||||
),
|
||||
@ -281,7 +281,7 @@ class Shortcodes {
|
||||
return '';
|
||||
}
|
||||
|
||||
$attributes = shortcode_atts(
|
||||
$attributes = \shortcode_atts(
|
||||
array(
|
||||
'type' => 'full',
|
||||
),
|
||||
@ -291,7 +291,7 @@ class Shortcodes {
|
||||
|
||||
$size = 'full';
|
||||
|
||||
if ( in_array(
|
||||
if ( \in_array(
|
||||
$attributes['type'],
|
||||
array( 'thumbnail', 'medium', 'large', 'full' ),
|
||||
true
|
||||
@ -338,7 +338,7 @@ class Shortcodes {
|
||||
return '';
|
||||
}
|
||||
|
||||
return wp_strip_all_tags( $name );
|
||||
return \wp_strip_all_tags( $name );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -7,8 +7,6 @@
|
||||
|
||||
namespace Activitypub;
|
||||
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
use Activitypub\Signature\Http_Message_Signature;
|
||||
use Activitypub\Signature\Http_Signature_Draft;
|
||||
|
||||
@ -28,6 +26,81 @@ class Signature {
|
||||
\add_filter( 'http_response', array( self::class, 'maybe_double_knock' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new RSA key pair for signing HTTP requests.
|
||||
*
|
||||
* Does not persist anything — callers are responsible for storing the keys.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return array The key pair with 'private_key' and 'public_key', both null on failure.
|
||||
*/
|
||||
public static function generate_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,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'private_key' => $private_key,
|
||||
'public_key' => $detail['key'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key pair stored in an option, migrating a legacy pair or generating a new one on first use.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @param string $option_key The option name the key pair is stored in.
|
||||
* @param callable|null $legacy_callback Optional. Callback that returns a legacy key pair to migrate, or false. Default null.
|
||||
*
|
||||
* @return array The key pair with 'private_key' and 'public_key'.
|
||||
*/
|
||||
public static function get_key_pair( $option_key, $legacy_callback = null ) {
|
||||
$key_pair = \get_option( $option_key );
|
||||
|
||||
if ( $key_pair ) {
|
||||
return $key_pair;
|
||||
}
|
||||
|
||||
$key_pair = $legacy_callback ? $legacy_callback() : false;
|
||||
|
||||
if ( ! $key_pair ) {
|
||||
$key_pair = self::generate_key_pair();
|
||||
|
||||
// Only persist valid keys.
|
||||
if ( empty( $key_pair['private_key'] ) ) {
|
||||
return $key_pair;
|
||||
}
|
||||
}
|
||||
|
||||
// `update_option()` also overwrites a corrupted-but-present row, which `add_option()` would silently skip.
|
||||
\update_option( $option_key, $key_pair );
|
||||
|
||||
return $key_pair;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an HTTP Request.
|
||||
*
|
||||
@ -54,18 +127,24 @@ class Signature {
|
||||
/**
|
||||
* Verifies the http signatures
|
||||
*
|
||||
* On success the verified keyId is returned (a truthy string), so callers can bind it to
|
||||
* the activity actor without re-parsing headers, which cannot tell which signature label
|
||||
* actually validated. Pass/fail callers should branch on {@see is_wp_error()} as before.
|
||||
*
|
||||
* @since 9.0.0 Returns the verified keyId on success instead of `true`.
|
||||
*
|
||||
* @param \WP_REST_Request|array $request The request object or $_SERVER array.
|
||||
*
|
||||
* @return bool|\WP_Error A boolean or WP_Error.
|
||||
* @return string|\WP_Error The verified keyId on success, WP_Error on failure.
|
||||
*/
|
||||
public static function verify_http_signature( $request ) {
|
||||
if ( is_object( $request ) ) { // REST Request object.
|
||||
if ( \is_object( $request ) ) { // REST Request object.
|
||||
$body = $request->get_body();
|
||||
$headers = $request->get_headers();
|
||||
$headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' ' . self::get_route( $request );
|
||||
$headers['(request-target)'][0] = \strtolower( $request->get_method() ) . ' ' . self::get_route( $request );
|
||||
} else {
|
||||
$headers = self::format_server_request( $request );
|
||||
$headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
|
||||
$headers['(request-target)'][0] = \strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
|
||||
}
|
||||
|
||||
$signature = isset( $headers['signature_input'] ) ? new Http_Message_Signature() : new Http_Signature_Draft();
|
||||
@ -73,6 +152,54 @@ class Signature {
|
||||
return $signature->verify( $headers, $body ?? null );
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the signing keyId that {@see Signature::verify_http_signature()} would verify against.
|
||||
*
|
||||
* The returned keyId is only trustworthy if it identifies the key the signature is
|
||||
* actually checked with, so this mirrors the verifier's header choice rather than
|
||||
* scanning headers in an arbitrary order:
|
||||
*
|
||||
* - When a `Signature-Input` header is present the RFC 9421 verifier is used, so the
|
||||
* keyId is taken from there and a draft `Signature` header (which the verifier ignores)
|
||||
* is not consulted. The RFC 9421 verifier accepts whichever of several signature labels
|
||||
* validates, so a `Signature-Input` carrying more than one keyId is ambiguous: we cannot
|
||||
* know in advance which key will verify and must not guess, so `null` is returned.
|
||||
* - Otherwise the draft HTTP Signatures form is used, taking the first `keyId` from the
|
||||
* `Signature` header or, failing that, the `Authorization` header — matching the draft
|
||||
* verifier, which reads `signature ?? authorization`.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return string|null The keyId, or null when none is present or the choice is ambiguous.
|
||||
*/
|
||||
public static function get_key_id( $request ) {
|
||||
$signature_input = $request->get_header( 'signature-input' );
|
||||
if ( $signature_input ) {
|
||||
/*
|
||||
* keyid is a `;`-delimited parameter whose value may be quoted or unquoted.
|
||||
* Anchoring on `;` (or string start) avoids matching a `keyid=` substring inside
|
||||
* another parameter's value. Count every label's keyId: more than one is ambiguous.
|
||||
*/
|
||||
$count = \preg_match_all( '/(?:^|;)\s*keyid="?([^";,\s]+)/i', $signature_input, $matches );
|
||||
|
||||
return 1 === $count ? $matches[1][0] : null;
|
||||
}
|
||||
|
||||
// A draft signature may arrive in the Signature header or, less commonly, Authorization.
|
||||
$signature = $request->get_header( 'signature' );
|
||||
if ( ! $signature ) {
|
||||
$signature = $request->get_header( 'authorization' );
|
||||
}
|
||||
|
||||
if ( $signature && \preg_match( '/keyId="([^"]+)"/i', $signature, $matches ) ) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* If a request with RFC-9421 signature fails, we try again with the Draft Cavage signature.
|
||||
*
|
||||
@ -131,23 +258,37 @@ class Signature {
|
||||
*/
|
||||
private static function get_route( $request ) {
|
||||
// Check if the route starts with "index.php".
|
||||
if ( str_starts_with( $request->get_route(), '/index.php' ) || ! rest_get_url_prefix() ) {
|
||||
if ( \str_starts_with( $request->get_route(), '/index.php' ) || ! \rest_get_url_prefix() ) {
|
||||
$route = $request->get_route();
|
||||
} else {
|
||||
$route = '/' . rest_get_url_prefix() . '/' . ltrim( $request->get_route(), '/' );
|
||||
$route = '/' . \rest_get_url_prefix() . '/' . \ltrim( $request->get_route(), '/' );
|
||||
}
|
||||
|
||||
// Fix route for subdirectory installations.
|
||||
$path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
|
||||
|
||||
if ( \is_string( $path ) ) {
|
||||
$path = trim( $path, '/' );
|
||||
$path = \trim( $path, '/' );
|
||||
}
|
||||
|
||||
if ( $path ) {
|
||||
$route = '/' . $path . $route;
|
||||
}
|
||||
|
||||
/*
|
||||
* Append the query string. Peers sign the full request-target including
|
||||
* the query (see Http_Signature_Draft::sign()), so the reconstructed
|
||||
* value has to match byte-for-byte. Use the raw REQUEST_URI instead of
|
||||
* re-encoding the parsed query params, re-encoding could change the
|
||||
* percent-encoding or parameter order and break the signature.
|
||||
*/
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
$query = (string) \wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', \PHP_URL_QUERY );
|
||||
|
||||
if ( '' !== $query ) {
|
||||
$route .= '?' . $query;
|
||||
}
|
||||
|
||||
return $route;
|
||||
}
|
||||
|
||||
@ -187,291 +328,6 @@ class Signature {
|
||||
\update_option( 'activitypub_rfc9421_unsupported', $list, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the public key for a given user.
|
||||
*
|
||||
* @deprecated 7.0.0 Use {@see Actors::get_public_key()}.
|
||||
*
|
||||
* @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_for( $user_id, $force = false ) {
|
||||
\_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_public_key' );
|
||||
|
||||
return Actors::get_public_key( $user_id, $force );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the private key for a given user.
|
||||
*
|
||||
* @deprecated 7.0.0 Use {@see Actors::get_private_key()}.
|
||||
*
|
||||
* @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_for( $user_id, $force = false ) {
|
||||
\_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_private_key' );
|
||||
|
||||
return Actors::get_private_key( $user_id, $force );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key pair for a given user.
|
||||
*
|
||||
* @deprecated 7.0.0 Use {@see Actors::get_keypair()}.
|
||||
*
|
||||
* @param int $user_id The WordPress User ID.
|
||||
*
|
||||
* @return array The key pair.
|
||||
*/
|
||||
public static function get_keypair_for( $user_id ) {
|
||||
\_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_keypair' );
|
||||
|
||||
return Actors::get_keypair( $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the Signature for an HTTP Request.
|
||||
*
|
||||
* @deprecated 7.0.0 Use {@see Signature::sign_request()}.
|
||||
*
|
||||
* @param int $user_id The WordPress User ID.
|
||||
* @param string $http_method The HTTP method.
|
||||
* @param string $url The URL to send the request to.
|
||||
* @param string $date The date the request is sent.
|
||||
* @param string $digest Optional. The digest of the request body. Default null.
|
||||
*
|
||||
* @return string The signature.
|
||||
*/
|
||||
public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) {
|
||||
\_deprecated_function( __METHOD__, '7.0.0', self::class . '::sign_request()' );
|
||||
|
||||
$user = Actors::get_by_id( $user_id );
|
||||
$key = Actors::get_private_key( $user_id );
|
||||
|
||||
$url_parts = \wp_parse_url( $url );
|
||||
|
||||
$host = $url_parts['host'];
|
||||
$path = '/';
|
||||
|
||||
// Add path.
|
||||
if ( ! empty( $url_parts['path'] ) ) {
|
||||
$path = $url_parts['path'];
|
||||
}
|
||||
|
||||
// Add query.
|
||||
if ( ! empty( $url_parts['query'] ) ) {
|
||||
$path .= '?' . $url_parts['query'];
|
||||
}
|
||||
|
||||
$http_method = \strtolower( $http_method );
|
||||
|
||||
if ( ! empty( $digest ) ) {
|
||||
$signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date\ndigest: $digest";
|
||||
} else {
|
||||
$signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date";
|
||||
}
|
||||
|
||||
$signature = null;
|
||||
\openssl_sign( $signed_string, $signature, $key, \OPENSSL_ALGO_SHA256 );
|
||||
$signature = \base64_encode( $signature ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||
|
||||
$key_id = $user->get_id() . '#main-key';
|
||||
|
||||
if ( ! empty( $digest ) ) {
|
||||
return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature );
|
||||
} else {
|
||||
return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date",signature="%s"', $key_id, $signature );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the signature algorithm from the signature header.
|
||||
*
|
||||
* @deprecated 7.0.0 Use {@see Signature::verify()}.
|
||||
*
|
||||
* @param array $signature_block The signature block.
|
||||
*
|
||||
* @return string|bool The signature algorithm or false if not found.
|
||||
*/
|
||||
public static function get_signature_algorithm( $signature_block ) { // phpcs:ignore
|
||||
\_deprecated_function( __METHOD__, '7.0.0', self::class . '::verify' );
|
||||
|
||||
if ( ! empty( $signature_block['algorithm'] ) ) {
|
||||
switch ( $signature_block['algorithm'] ) {
|
||||
case 'rsa-sha-512':
|
||||
return 'sha512'; // hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12.
|
||||
default:
|
||||
return 'sha256';
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Signature header.
|
||||
*
|
||||
* @deprecated 7.0.0 Use {@see Signature::verify()}.
|
||||
*
|
||||
* @param string $signature The signature header.
|
||||
*
|
||||
* @return array Signature parts.
|
||||
*/
|
||||
public static function parse_signature_header( $signature ) { // phpcs:ignore
|
||||
\_deprecated_function( __METHOD__, '7.0.0', self::class . '::verify' );
|
||||
|
||||
$parsed_header = array();
|
||||
$matches = array();
|
||||
|
||||
if ( \preg_match( '/keyId="(.*?)"/ism', $signature, $matches ) ) {
|
||||
$parsed_header['keyId'] = trim( $matches[1] );
|
||||
}
|
||||
if ( \preg_match( '/created=["|\']*([0-9]*)["|\']*/ism', $signature, $matches ) ) {
|
||||
$parsed_header['(created)'] = trim( $matches[1] );
|
||||
}
|
||||
if ( \preg_match( '/expires=["|\']*([0-9]*)["|\']*/ism', $signature, $matches ) ) {
|
||||
$parsed_header['(expires)'] = trim( $matches[1] );
|
||||
}
|
||||
if ( \preg_match( '/algorithm="(.*?)"/ism', $signature, $matches ) ) {
|
||||
$parsed_header['algorithm'] = trim( $matches[1] );
|
||||
}
|
||||
if ( \preg_match( '/headers="(.*?)"/ism', $signature, $matches ) ) {
|
||||
$parsed_header['headers'] = \explode( ' ', trim( $matches[1] ) );
|
||||
}
|
||||
if ( \preg_match( '/signature="(.*?)"/ism', $signature, $matches ) ) {
|
||||
$parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', trim( $matches[1] ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
|
||||
}
|
||||
|
||||
if ( empty( $parsed_header['headers'] ) ) {
|
||||
$parsed_header['headers'] = array( 'date' );
|
||||
}
|
||||
|
||||
return $parsed_header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the header data from the included pseudo headers.
|
||||
*
|
||||
* @deprecated 7.0.0 Use {@see Signature::verify()}.
|
||||
*
|
||||
* @param array $signed_headers The signed headers.
|
||||
* @param array $signature_block The signature block.
|
||||
* @param array $headers The HTTP headers.
|
||||
*
|
||||
* @return string signed headers for comparison
|
||||
*/
|
||||
public static function get_signed_data( $signed_headers, $signature_block, $headers ) { // phpcs:ignore
|
||||
\_deprecated_function( __METHOD__, '7.0.0', self::class . '::verify' );
|
||||
|
||||
$signed_data = '';
|
||||
|
||||
// This also verifies time-based values by returning false if any of these are out of range.
|
||||
foreach ( $signed_headers as $header ) {
|
||||
if ( 'host' === $header ) {
|
||||
if ( isset( $headers['x_original_host'] ) ) {
|
||||
$signed_data .= $header . ': ' . $headers['x_original_host'][0] . "\n";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ( '(request-target)' === $header ) {
|
||||
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
|
||||
continue;
|
||||
}
|
||||
if ( str_contains( $header, '-' ) ) {
|
||||
$signed_data .= $header . ': ' . $headers[ str_replace( '-', '_', $header ) ][0] . "\n";
|
||||
continue;
|
||||
}
|
||||
if ( '(created)' === $header ) {
|
||||
if ( ! empty( $signature_block['(created)'] ) && \intval( $signature_block['(created)'] ) > \time() ) {
|
||||
// Created in the future.
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! array_key_exists( '(created)', $headers ) ) {
|
||||
$signed_data .= $header . ': ' . $signature_block['(created)'] . "\n";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ( '(expires)' === $header ) {
|
||||
if ( ! empty( $signature_block['(expires)'] ) && \intval( $signature_block['(expires)'] ) < \time() ) {
|
||||
// Expired in the past.
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! array_key_exists( '(expires)', $headers ) ) {
|
||||
$signed_data .= $header . ': ' . $signature_block['(expires)'] . "\n";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ( 'date' === $header ) {
|
||||
// A signed `date` header with no value must fail closed, otherwise the time-window check is skipped.
|
||||
if ( empty( $headers[ $header ][0] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// date_create() returns false on malformed input; new DateTime() would instead throw.
|
||||
$d = \date_create( $headers[ $header ][0], new \DateTimeZone( 'UTC' ) );
|
||||
if ( false === $d ) {
|
||||
return false;
|
||||
}
|
||||
$d->setTimeZone( new \DateTimeZone( 'UTC' ) );
|
||||
$c = (int) $d->format( 'U' );
|
||||
|
||||
// Match the past-skew of the maintained Http_Signature_Draft verifier (1 hour); use its 5-minute future allowance.
|
||||
$now = \time();
|
||||
$d_plus = $now + ( 5 * MINUTE_IN_SECONDS );
|
||||
$d_minus = $now - HOUR_IN_SECONDS;
|
||||
|
||||
if ( $c > $d_plus || $c < $d_minus ) {
|
||||
// Time out of range.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $headers[ $header ][0] ) ) {
|
||||
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return \rtrim( $signed_data, "\n" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the digest for an HTTP Request.
|
||||
*
|
||||
* @deprecated 7.0.0 Use {@see Signature::sign_request()}.
|
||||
*
|
||||
* @param string $body The body of the request.
|
||||
*
|
||||
* @return string The digest.
|
||||
*/
|
||||
public static function generate_digest( $body ) {
|
||||
\_deprecated_function( __METHOD__, '7.0.0', self::class . '::sign_request' );
|
||||
|
||||
$digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||
return "SHA-256=$digest";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the collection digest for a specific instance.
|
||||
*
|
||||
@ -486,16 +342,16 @@ class Signature {
|
||||
* @return string|false The hex-encoded digest, or false if no followers.
|
||||
*/
|
||||
public static function get_collection_digest( $collection ) {
|
||||
if ( empty( $collection ) || ! is_array( $collection ) ) {
|
||||
if ( empty( $collection ) || ! \is_array( $collection ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize with zeros (64 hex chars = 32 bytes = 256 bits).
|
||||
$digest = str_repeat( '0', 64 );
|
||||
$digest = \str_repeat( '0', 64 );
|
||||
|
||||
foreach ( $collection as $item ) {
|
||||
// Compute SHA256 hash of the follower ID.
|
||||
$hash = hash( 'sha256', $item );
|
||||
$hash = \hash( 'sha256', $item );
|
||||
|
||||
// XOR the hash with the running digest.
|
||||
$digest = self::xor_hex_strings( $digest, $hash );
|
||||
|
||||
@ -55,7 +55,7 @@ class Statistics {
|
||||
* @return string The option name.
|
||||
*/
|
||||
public static function get_monthly_option_name( $user_id, $year, $month ) {
|
||||
return sprintf( '%s%d_%d_%02d', self::OPTION_PREFIX, $user_id, $year, $month );
|
||||
return \sprintf( '%s%d_%d_%02d', self::OPTION_PREFIX, $user_id, $year, $month );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -67,7 +67,7 @@ class Statistics {
|
||||
* @return string The option name.
|
||||
*/
|
||||
public static function get_annual_option_name( $user_id, $year ) {
|
||||
return sprintf( '%s%d_%d_annual', self::OPTION_PREFIX, $user_id, $year );
|
||||
return \sprintf( '%s%d_%d_annual', self::OPTION_PREFIX, $user_id, $year );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -104,7 +104,7 @@ class Tombstone {
|
||||
return self::check_array( $data );
|
||||
}
|
||||
|
||||
if ( in_array( (int) $response->get_error_code(), self::$codes, true ) ) {
|
||||
if ( \in_array( (int) $response->get_error_code(), self::$codes, true ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -162,7 +162,7 @@ class Tombstone {
|
||||
}
|
||||
|
||||
$data = $wp_error->get_error_data();
|
||||
if ( isset( $data['status'] ) && in_array( (int) $data['status'], self::$codes, true ) ) {
|
||||
if ( isset( $data['status'] ) && \in_array( (int) $data['status'], self::$codes, true ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -52,7 +52,7 @@ class Webfinger {
|
||||
*/
|
||||
public static function get_user_resource( $user_id ) {
|
||||
$user = Actors::get_by_id( $user_id );
|
||||
if ( ! $user || is_wp_error( $user ) ) {
|
||||
if ( ! $user || \is_wp_error( $user ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -73,10 +73,10 @@ class Webfinger {
|
||||
return $data;
|
||||
}
|
||||
|
||||
if ( ! is_array( $data ) || empty( $data['links'] ) ) {
|
||||
if ( ! \is_array( $data ) || empty( $data['links'] ) ) {
|
||||
return new \WP_Error(
|
||||
'webfinger_missing_links',
|
||||
__( 'No valid Link elements found.', 'activitypub' ),
|
||||
\__( 'No valid Link elements found.', 'activitypub' ),
|
||||
array(
|
||||
'status' => 400,
|
||||
'data' => $data,
|
||||
@ -99,7 +99,7 @@ class Webfinger {
|
||||
|
||||
return new \WP_Error(
|
||||
'webfinger_url_no_activitypub',
|
||||
__( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ),
|
||||
\__( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ),
|
||||
array(
|
||||
'status' => 400,
|
||||
'data' => $data,
|
||||
@ -119,7 +119,7 @@ class Webfinger {
|
||||
public static function uri_to_acct( $uri ) {
|
||||
$data = self::get_data( $uri );
|
||||
|
||||
if ( is_wp_error( $data ) ) {
|
||||
if ( \is_wp_error( $data ) ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
@ -142,7 +142,7 @@ class Webfinger {
|
||||
|
||||
return new \WP_Error(
|
||||
'webfinger_url_no_acct',
|
||||
__( 'No acct URI found.', 'activitypub' ),
|
||||
\__( 'No acct URI found.', 'activitypub' ),
|
||||
array(
|
||||
'status' => 400,
|
||||
'data' => $data,
|
||||
@ -162,7 +162,7 @@ class Webfinger {
|
||||
if ( ! $url ) {
|
||||
return new \WP_Error(
|
||||
'webfinger_invalid_identifier',
|
||||
__( 'Invalid Identifier', 'activitypub' ),
|
||||
\__( 'Invalid Identifier', 'activitypub' ),
|
||||
array(
|
||||
'status' => 400,
|
||||
'data' => $url,
|
||||
@ -171,9 +171,9 @@ class Webfinger {
|
||||
}
|
||||
|
||||
// Remove leading @.
|
||||
$url = ltrim( $url, '@' );
|
||||
$url = \ltrim( $url, '@' );
|
||||
|
||||
if ( ! preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
|
||||
if ( ! \preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
|
||||
$identifier = 'acct:' . $url;
|
||||
$scheme = 'acct';
|
||||
} else {
|
||||
@ -187,19 +187,19 @@ class Webfinger {
|
||||
case 'acct':
|
||||
case 'mailto':
|
||||
case 'xmpp':
|
||||
if ( strpos( $identifier, '@' ) !== false ) {
|
||||
$host = substr( $identifier, strpos( $identifier, '@' ) + 1 );
|
||||
if ( \strpos( $identifier, '@' ) !== false ) {
|
||||
$host = \substr( $identifier, \strpos( $identifier, '@' ) + 1 );
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$host = wp_parse_url( $identifier, PHP_URL_HOST );
|
||||
$host = \wp_parse_url( $identifier, PHP_URL_HOST );
|
||||
break;
|
||||
}
|
||||
|
||||
if ( empty( $host ) ) {
|
||||
return new \WP_Error(
|
||||
'webfinger_invalid_identifier',
|
||||
__( 'Invalid Identifier', 'activitypub' ),
|
||||
\__( 'Invalid Identifier', 'activitypub' ),
|
||||
array(
|
||||
'status' => 400,
|
||||
'data' => $url,
|
||||
@ -220,13 +220,13 @@ class Webfinger {
|
||||
public static function get_data( $uri ) {
|
||||
$identifier_and_host = self::get_identifier_and_host( $uri );
|
||||
|
||||
if ( is_wp_error( $identifier_and_host ) ) {
|
||||
if ( \is_wp_error( $identifier_and_host ) ) {
|
||||
return $identifier_and_host;
|
||||
}
|
||||
|
||||
list( $identifier, $host ) = $identifier_and_host;
|
||||
|
||||
$webfinger_url = sprintf(
|
||||
$webfinger_url = \sprintf(
|
||||
'https://%s/.well-known/webfinger?resource=%s',
|
||||
$host,
|
||||
\rawurlencode( $identifier )
|
||||
@ -267,13 +267,13 @@ class Webfinger {
|
||||
* @return string The cache key.
|
||||
*/
|
||||
public static function generate_cache_key( $uri ) {
|
||||
$uri = ltrim( $uri, '@' );
|
||||
$uri = \ltrim( $uri, '@' );
|
||||
|
||||
if ( filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) {
|
||||
if ( \filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) {
|
||||
$uri = 'acct:' . $uri;
|
||||
}
|
||||
|
||||
return 'webfinger_' . md5( $uri );
|
||||
return 'webfinger_' . \md5( $uri );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -39,8 +39,9 @@ class Actor_Command extends \WP_CLI_Command {
|
||||
* @param array $assoc_args The associative arguments (unused).
|
||||
*/
|
||||
public function delete( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
if ( Actors::APPLICATION_USER_ID === (int) $args[0] ) {
|
||||
\WP_CLI::error( 'You cannot delete the application actor.' );
|
||||
// Only the blog actor (0) and real users (positive IDs) can be deleted; negative IDs (e.g. the former -1 application ID) are never valid actors.
|
||||
if ( (int) $args[0] < Actors::BLOG_USER_ID ) {
|
||||
\WP_CLI::error( \sprintf( 'No deletable actor found for ID "%s".', $args[0] ) );
|
||||
}
|
||||
|
||||
\add_filter( 'activitypub_user_can_activitypub', '__return_true' );
|
||||
|
||||
@ -0,0 +1,272 @@
|
||||
<?php
|
||||
/**
|
||||
* WP-CLI commands for the Blurhash encoder.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Cli;
|
||||
|
||||
use Activitypub\Blurhash;
|
||||
|
||||
/**
|
||||
* `wp activitypub blurhash …` command surface for backfilling and managing
|
||||
* stored blurhash placeholders.
|
||||
*
|
||||
* The runtime path ({@see Blurhash}) covers new uploads via a cron
|
||||
* event; this command exists for two cases the runtime can't handle:
|
||||
*
|
||||
* 1. **First install on a site with existing media** — every
|
||||
* already-uploaded image attachment is missing the postmeta and
|
||||
* will never get it without a re-upload or a forced re-encode.
|
||||
* `wp activitypub blurhash backfill` walks the library and fills the
|
||||
* gap.
|
||||
* 2. **Re-encode after an algorithm change** — if the encoder's
|
||||
* components or source-size change, the previously-computed
|
||||
* hashes are stale (still decodable, but produced from a
|
||||
* different recipe). `--force` recomputes against the latest
|
||||
* bytes.
|
||||
*
|
||||
* Registration is gated on `WP_CLI` so the class is only loaded by
|
||||
* the CLI process — no overhead on web requests.
|
||||
*/
|
||||
class Blurhash_Command extends \WP_CLI_Command {
|
||||
|
||||
/**
|
||||
* WP_Query page size for the backfill walk. 100 is the same batch
|
||||
* size WP's own bulk operations default to and keeps each query's
|
||||
* working set bounded.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const PAGE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Compute and persist Blurhash placeholders for image
|
||||
* attachments missing one. Use after first install on a site
|
||||
* with existing media, or after an encoder change with --force.
|
||||
*
|
||||
* Default mode walks only attachments that have no stored
|
||||
* `_activitypub_blurhash` — already-encoded media is filtered out
|
||||
* server-side via a `NOT EXISTS` meta query rather than fetched
|
||||
* and skipped in PHP. Combined with `--limit`, this means a
|
||||
* limit of N processes exactly N candidates (the prior behavior
|
||||
* counted already-hashed attachments against the limit and could
|
||||
* never advance past a fully-encoded first page).
|
||||
*
|
||||
* `--force` drops the meta-query filter and walks every image
|
||||
* attachment in ID order. The existing hash is overwritten only
|
||||
* after the new encode succeeds, so a failed re-encode leaves
|
||||
* the prior good hash in place.
|
||||
*
|
||||
* Non-raster `image/*` mime types (SVG, etc.) are recognized by
|
||||
* `wp_attachment_is_image()` returning false and are counted as
|
||||
* skipped, not failed — they're outside the encoder's GD-based
|
||||
* scope by design.
|
||||
*
|
||||
* Exits nonzero when any encode failed, so automation can detect
|
||||
* partial-success runs without parsing the summary text.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--dry-run]
|
||||
* : Walk and report what would be encoded, but don't write postmeta.
|
||||
*
|
||||
* [--limit=<n>]
|
||||
* : Process at most <n> candidate attachments. Default: 0 (no limit).
|
||||
*
|
||||
* [--force]
|
||||
* : Re-encode attachments that already have a stored hash.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp activitypub blurhash backfill --dry-run
|
||||
* wp activitypub blurhash backfill --limit=100
|
||||
* wp activitypub blurhash backfill --force
|
||||
*
|
||||
* @param array<int, string> $args Positional CLI args (unused).
|
||||
* @param array<string, mixed> $assoc_args Associative CLI flags.
|
||||
*
|
||||
* @when after_wp_load
|
||||
*/
|
||||
public function backfill( $args, $assoc_args ) {
|
||||
unset( $args );
|
||||
|
||||
$dry_run = ! empty( $assoc_args['dry-run'] );
|
||||
$force = ! empty( $assoc_args['force'] );
|
||||
$limit = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 0;
|
||||
|
||||
// Fail fast when the encoder can't run on this host.
|
||||
// Without this gate, the loop would emit a `\WP_CLI::warning`
|
||||
// per attachment and exit non-zero — noisy and unactionable.
|
||||
// `--dry-run` still works (no encoding attempted), so
|
||||
// operators can enumerate candidates from a GD-less host
|
||||
// to decide whether to migrate the media.
|
||||
if ( ! $dry_run && ! Blurhash::is_encoder_runnable() ) {
|
||||
\WP_CLI::error( 'Blurhash encoder requires GD (imagecreatefromstring/truecolor/scale). Install or enable GD and re-run.' );
|
||||
return;
|
||||
}
|
||||
|
||||
$encoded = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$last_id = 0;
|
||||
|
||||
while ( true ) {
|
||||
$query_args = array(
|
||||
'post_type' => 'attachment',
|
||||
'post_status' => 'inherit',
|
||||
'post_mime_type' => 'image',
|
||||
'posts_per_page' => self::PAGE_SIZE,
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
'update_post_meta_cache' => false,
|
||||
'update_post_term_cache' => false,
|
||||
'orderby' => 'ID',
|
||||
'order' => 'ASC',
|
||||
);
|
||||
|
||||
// Default mode filters to candidates server-side, so we
|
||||
// never call `Blurhash::get()` per row in the loop (the
|
||||
// N+1 the prior pagination shape had). `--force` skips
|
||||
// the filter and walks everything in ID order.
|
||||
//
|
||||
// Candidate set is "key missing OR value empty". A
|
||||
// non-empty-but-malformed row (postmeta poisoning, a
|
||||
// truncated import) self-heals through the runtime
|
||||
// cron path because {@see Blurhash::get()} reports
|
||||
// malformed values as absent, so `run_encode()` will
|
||||
// re-compute on the next `wp_generate_attachment_metadata`
|
||||
// regen. Operators who need a one-shot rescue without
|
||||
// waiting for a regen run the command with `--force`.
|
||||
if ( ! $force ) {
|
||||
$query_args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one-shot CLI backfill; not a web-path query.
|
||||
'relation' => 'OR',
|
||||
array(
|
||||
'key' => Blurhash::META_KEY,
|
||||
'compare' => 'NOT EXISTS',
|
||||
),
|
||||
array(
|
||||
'key' => Blurhash::META_KEY,
|
||||
'value' => '',
|
||||
'compare' => '=',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Keyset pagination — `ID > $last_id` is stable under
|
||||
// concurrent inserts and deletes, so a long backfill can't
|
||||
// silently skip attachments the way offset pagination
|
||||
// (paged=N) does when rows shift mid-run.
|
||||
$where_filter = null;
|
||||
if ( $last_id > 0 ) {
|
||||
$where_filter = self::keyset_where( $last_id );
|
||||
\add_filter( 'posts_where', $where_filter, 10, 1 );
|
||||
}
|
||||
|
||||
$query = new \WP_Query( $query_args );
|
||||
|
||||
if ( null !== $where_filter ) {
|
||||
\remove_filter( 'posts_where', $where_filter, 10 );
|
||||
}
|
||||
|
||||
if ( empty( $query->posts ) ) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ( $query->posts as $attachment_id ) {
|
||||
$attachment_id = (int) $attachment_id;
|
||||
$last_id = $attachment_id;
|
||||
|
||||
// Non-encodable mime types (SVG, ICO, anything
|
||||
// outside the GD raster set) get filtered out
|
||||
// up-front and counted as skipped, not failed. The
|
||||
// encoder would return null on them too, but a
|
||||
// per-attachment WARNING for every SVG on every
|
||||
// backfill run is noise; the user already knows we
|
||||
// don't encode vector formats.
|
||||
if ( ! Blurhash::is_encodable_attachment( $attachment_id ) ) {
|
||||
++$skipped;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $limit > 0 && ( $encoded + $failed ) >= $limit ) {
|
||||
break 2;
|
||||
}
|
||||
|
||||
if ( $dry_run ) {
|
||||
++$encoded;
|
||||
\WP_CLI::log( "would encode: attachment {$attachment_id}" );
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = Blurhash::encode_from_attachment( $attachment_id );
|
||||
|
||||
/*
|
||||
* `false` is a policy skip (e.g. declared dimensions
|
||||
* over the decode-bomb cap) — same bucket as a
|
||||
* non-raster mime, not a failure. Warning on it every
|
||||
* run (and exiting nonzero) would make a permanently
|
||||
* over-cap attachment poison automation forever.
|
||||
*/
|
||||
if ( false === $hash ) {
|
||||
++$skipped;
|
||||
\WP_CLI::log( "skipped (out of encode policy): attachment {$attachment_id}" );
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( null === $hash ) {
|
||||
++$failed;
|
||||
\WP_CLI::warning( "encode failed: attachment {$attachment_id}" );
|
||||
continue;
|
||||
}
|
||||
|
||||
// Encode-then-set — never delete-first. A failed
|
||||
// re-encode on `--force` leaves the prior good hash
|
||||
// in place rather than wiping it.
|
||||
Blurhash::set( $attachment_id, $hash );
|
||||
++$encoded;
|
||||
\WP_CLI::log( "encoded {$attachment_id}: {$hash}" );
|
||||
}
|
||||
}
|
||||
|
||||
$encoded_label = $dry_run ? 'would encode' : 'encoded';
|
||||
$summary = \sprintf(
|
||||
'%s %d, skipped %d (non-raster, unsupported, or out of encode policy), failed %d.',
|
||||
$encoded_label,
|
||||
$encoded,
|
||||
$skipped,
|
||||
$failed
|
||||
);
|
||||
|
||||
if ( $dry_run ) {
|
||||
\WP_CLI::success( '[dry-run] ' . $summary );
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-zero exit on any failure so automation can detect
|
||||
// partial-success runs without parsing the summary text.
|
||||
if ( $failed > 0 ) {
|
||||
\WP_CLI::error( $summary );
|
||||
return;
|
||||
}
|
||||
|
||||
\WP_CLI::success( $summary );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `posts_where` filter closure that constrains the
|
||||
* query to `ID > $last_id`. Used to implement keyset pagination
|
||||
* without polluting WP_Query's stable args. The closure clears
|
||||
* itself after each query in {@see self::backfill()}.
|
||||
*
|
||||
* @param int $last_id Last processed attachment ID.
|
||||
* @return \Closure
|
||||
*/
|
||||
private static function keyset_where( int $last_id ): \Closure {
|
||||
return function ( $where ) use ( $last_id ) {
|
||||
global $wpdb;
|
||||
return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $last_id );
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
namespace Activitypub\Cli;
|
||||
|
||||
use Activitypub\Cache;
|
||||
use Activitypub\Cache\Avatar;
|
||||
use Activitypub\Cache\Emoji;
|
||||
use Activitypub\Cache\File;
|
||||
@ -264,7 +265,7 @@ class Cache_Command extends \WP_CLI_Command {
|
||||
*/
|
||||
private function is_cache_enabled( $type ) {
|
||||
// Check global cache enablement (includes constant and activitypub_remote_cache_enabled filter).
|
||||
if ( ! \Activitypub\Cache::is_enabled() ) {
|
||||
if ( ! Cache::is_enabled() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -89,7 +89,12 @@ class Comment_Command extends \WP_CLI_Command {
|
||||
\WP_CLI::error( 'This comment was received via ActivityPub and cannot be deleted or updated.' );
|
||||
}
|
||||
|
||||
add_to_outbox( $comment, 'Update', $comment->user_id );
|
||||
$result = add_to_outbox( $comment, 'Update', $comment->user_id );
|
||||
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
\WP_CLI::error( $result->get_error_message() );
|
||||
}
|
||||
|
||||
\WP_CLI::success( '"Update" activity is queued.' );
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,7 @@ class Outbox_Command extends \WP_CLI_Command {
|
||||
*/
|
||||
public function undo( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$outbox_item_id = $args[0];
|
||||
if ( ! is_numeric( $outbox_item_id ) ) {
|
||||
if ( ! \is_numeric( $outbox_item_id ) ) {
|
||||
$outbox_item_id = \url_to_postid( $outbox_item_id );
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ class Outbox_Command extends \WP_CLI_Command {
|
||||
*/
|
||||
public function reschedule( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$outbox_item_id = $args[0];
|
||||
if ( ! is_numeric( $outbox_item_id ) ) {
|
||||
if ( ! \is_numeric( $outbox_item_id ) ) {
|
||||
$outbox_item_id = \url_to_postid( $outbox_item_id );
|
||||
}
|
||||
|
||||
|
||||
@ -80,7 +80,12 @@ class Post_Command extends \WP_CLI_Command {
|
||||
\WP_CLI::error( 'Post not found.' );
|
||||
}
|
||||
|
||||
add_to_outbox( $post, 'Update', $post->post_author );
|
||||
$result = add_to_outbox( $post, 'Update', $post->post_author );
|
||||
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
\WP_CLI::error( $result->get_error_message() );
|
||||
}
|
||||
|
||||
\WP_CLI::success( '"Update" activity is queued.' );
|
||||
}
|
||||
}
|
||||
|
||||
@ -275,7 +275,7 @@ class Self_Destruct_Command extends \WP_CLI_Command {
|
||||
);
|
||||
|
||||
// Get count of pending Delete activities.
|
||||
$pending_count = count( $pending_deletes );
|
||||
$pending_count = \count( $pending_deletes );
|
||||
|
||||
// If no more pending Delete activities, self-destruct is complete.
|
||||
if ( 0 === $pending_count ) {
|
||||
|
||||
@ -147,7 +147,7 @@ class Stats_Command extends \WP_CLI_Command {
|
||||
Statistics::compile_annual_summary( $uid, $year );
|
||||
}
|
||||
|
||||
$count = count( $user_ids );
|
||||
$count = \count( $user_ids );
|
||||
\WP_CLI::success( "Annual stats compiled for {$count} user(s) ({$year})." );
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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'] ) ) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ defined( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS' ) || define( 'ACTIVITYPUB_D
|
||||
defined( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS' ) || define( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS', false );
|
||||
defined( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE' ) || define( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE', 'wordpress-post-format' );
|
||||
defined( 'ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE' ) || define( 'ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE', 100 );
|
||||
defined( 'ACTIVITYPUB_DISTRIBUTION_MODE' ) || define( 'ACTIVITYPUB_DISTRIBUTION_MODE', false );
|
||||
// Backwards compatibility: map old ACTIVITYPUB_DISABLE_SIDELOADING to ACTIVITYPUB_DISABLE_REMOTE_CACHE.
|
||||
if ( ! defined( 'ACTIVITYPUB_DISABLE_REMOTE_CACHE' ) && defined( 'ACTIVITYPUB_DISABLE_SIDELOADING' ) ) {
|
||||
define( 'ACTIVITYPUB_DISABLE_REMOTE_CACHE', ACTIVITYPUB_DISABLE_SIDELOADING );
|
||||
|
||||
@ -51,7 +51,17 @@ function extract_recipients_from_activity( $data ) {
|
||||
$recipient_items = \array_merge( $recipient_items, extract_recipients_from_activity_property( $i, $data ) );
|
||||
}
|
||||
|
||||
return \array_unique( $recipient_items );
|
||||
// An Accept/Reject that wraps a Follow is addressed only through the embedded Follow's actor.
|
||||
if (
|
||||
\in_array( $data['type'], array( 'Accept', 'Reject' ), true ) &&
|
||||
! empty( $data['object'] ) &&
|
||||
\is_array( $data['object'] ) &&
|
||||
! empty( $data['object']['actor'] )
|
||||
) {
|
||||
$recipient_items[] = object_to_uri( $data['object']['actor'] );
|
||||
}
|
||||
|
||||
return \array_unique( \array_filter( $recipient_items ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -93,19 +103,19 @@ function extract_recipients_from_activity_property( $property, $data ) {
|
||||
*/
|
||||
function get_activity_visibility( $activity ) {
|
||||
// Set default visibility for specific activity types.
|
||||
if ( ! empty( $activity['type'] ) && in_array( $activity['type'], array( 'Accept', 'Delete', 'Follow', 'Reject', 'Undo' ), true ) ) {
|
||||
if ( ! empty( $activity['type'] ) && \in_array( $activity['type'], array( 'Accept', 'Delete', 'Follow', 'Reject', 'Undo' ), true ) ) {
|
||||
return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
|
||||
}
|
||||
|
||||
// Check 'to' field for public visibility.
|
||||
$to = extract_recipients_from_activity_property( 'to', $activity );
|
||||
if ( ! empty( array_intersect( $to, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) {
|
||||
if ( ! empty( \array_intersect( $to, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) {
|
||||
return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
|
||||
}
|
||||
|
||||
// Check 'cc' field for quiet public visibility.
|
||||
$cc = extract_recipients_from_activity_property( 'cc', $activity );
|
||||
if ( ! empty( array_intersect( $cc, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) {
|
||||
if ( ! empty( \array_intersect( $cc, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) {
|
||||
return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC;
|
||||
}
|
||||
|
||||
@ -133,7 +143,7 @@ function is_activity_public( $data ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! empty( array_intersect( $recipients, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) );
|
||||
return ! empty( \array_intersect( $recipients, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -172,11 +182,11 @@ function is_quote_activity( $data ) {
|
||||
*/
|
||||
function object_to_uri( $data ) {
|
||||
// Check whether it is already simple.
|
||||
if ( ! $data || is_string( $data ) ) {
|
||||
if ( ! $data || \is_string( $data ) ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
if ( is_object( $data ) ) {
|
||||
if ( \is_object( $data ) ) {
|
||||
$data = $data->to_array();
|
||||
}
|
||||
|
||||
@ -184,12 +194,12 @@ function object_to_uri( $data ) {
|
||||
* Check if it is a list, then take first item.
|
||||
* This plugin does not support collections.
|
||||
*/
|
||||
if ( array_is_list( $data ) ) {
|
||||
if ( \array_is_list( $data ) ) {
|
||||
$data = $data[0];
|
||||
}
|
||||
|
||||
// Check if it is simplified now.
|
||||
if ( is_string( $data ) ) {
|
||||
if ( \is_string( $data ) ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
@ -232,6 +242,81 @@ function object_to_uri( $data ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether two references point at the same actor.
|
||||
*
|
||||
* Both values are resolved to their canonical URI via object_to_uri() before
|
||||
* comparison. Empty references never match, so a missing actor can never be
|
||||
* mistaken for a match.
|
||||
*
|
||||
* @param array|object|string $a The first actor reference.
|
||||
* @param array|object|string $b The second actor reference.
|
||||
*
|
||||
* @return bool True when both resolve to the same non-empty URI.
|
||||
*/
|
||||
function is_same_actor( $a, $b ) {
|
||||
$a = object_to_uri( $a );
|
||||
$b = object_to_uri( $b );
|
||||
|
||||
return ! empty( $a ) && ! empty( $b ) && $a === $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether two references live on the same host.
|
||||
*
|
||||
* Both values are resolved to their canonical URI via object_to_uri(), then
|
||||
* their hosts are compared case-insensitively. Empty references, or references
|
||||
* without a host, never match.
|
||||
*
|
||||
* @param array|object|string $a The first reference.
|
||||
* @param array|object|string $b The second reference.
|
||||
*
|
||||
* @return bool True when both resolve to a URI on the same host.
|
||||
*/
|
||||
function is_same_host( $a, $b ) {
|
||||
$host_a = \wp_parse_url( (string) object_to_uri( $a ), PHP_URL_HOST );
|
||||
$host_b = \wp_parse_url( (string) object_to_uri( $b ), PHP_URL_HOST );
|
||||
|
||||
return ! empty( $host_a ) && ! empty( $host_b ) && \strtolower( $host_a ) === \strtolower( $host_b );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an object is served under its own canonical id.
|
||||
*
|
||||
* An object is only trustworthy to cache when its own `id` is the URL it was
|
||||
* actually served from: otherwise one host could serve a document — and its
|
||||
* public key — under another host's id. Reads the raw `id` attribute only
|
||||
* (never the `url`/`href` fallback that object_to_uri() applies), because the
|
||||
* cache is keyed on `id`, so "is this canonical?" must ask the same field the
|
||||
* write uses. The comparison ignores the URL fragment and a trailing slash;
|
||||
* everything else (scheme, host, port, path, query) must match exactly.
|
||||
* Host-level equality is deliberately NOT enough — any different id on the same
|
||||
* host is still a distinct cache entry that a document served elsewhere must not write.
|
||||
*
|
||||
* @param array|string $item The fetched object, or its id.
|
||||
* @param string $url The URL the object was served from.
|
||||
*
|
||||
* @return bool True when the object's id is the canonical URL it was served from.
|
||||
*/
|
||||
function id_matches_url( $item, $url ) {
|
||||
if ( \is_array( $item ) ) {
|
||||
$id = isset( $item['id'] ) && \is_string( $item['id'] ) ? $item['id'] : '';
|
||||
} elseif ( \is_string( $item ) ) {
|
||||
$id = $item;
|
||||
} else {
|
||||
$id = '';
|
||||
}
|
||||
|
||||
$id = \strip_fragment_from_url( $id );
|
||||
$url = \strip_fragment_from_url( (string) $url );
|
||||
|
||||
if ( '' === $id || '' === $url ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \untrailingslashit( $id ) === \untrailingslashit( $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an `$data` is an Activity.
|
||||
*
|
||||
@ -247,7 +332,7 @@ function is_activity( $data ) {
|
||||
*
|
||||
* @param array $types The activity types.
|
||||
*/
|
||||
$types = apply_filters( 'activitypub_activity_types', Activity::TYPES );
|
||||
$types = \apply_filters( 'activitypub_activity_types', Activity::TYPES );
|
||||
|
||||
return _is_type_of( $data, $types );
|
||||
}
|
||||
@ -287,7 +372,7 @@ function is_actor( $data ) {
|
||||
*
|
||||
* @param array $types The actor types.
|
||||
*/
|
||||
$types = apply_filters( 'activitypub_actor_types', Actor::TYPES );
|
||||
$types = \apply_filters( 'activitypub_actor_types', Actor::TYPES );
|
||||
|
||||
return _is_type_of( $data, $types );
|
||||
}
|
||||
@ -307,7 +392,7 @@ function is_collection( $data ) {
|
||||
*
|
||||
* @param array $types The collection types.
|
||||
*/
|
||||
$types = apply_filters( 'activitypub_collection_types', array( 'Collection', 'OrderedCollection', 'CollectionPage', 'OrderedCollectionPage' ) );
|
||||
$types = \apply_filters( 'activitypub_collection_types', array( 'Collection', 'OrderedCollection', 'CollectionPage', 'OrderedCollectionPage' ) );
|
||||
|
||||
return _is_type_of( $data, $types );
|
||||
}
|
||||
@ -321,16 +406,16 @@ function is_collection( $data ) {
|
||||
* @return boolean True if $data is of one of the types, false otherwise.
|
||||
*/
|
||||
function _is_type_of( $data, $types ) {
|
||||
if ( is_string( $data ) ) {
|
||||
return in_array( $data, $types, true );
|
||||
if ( \is_string( $data ) ) {
|
||||
return \in_array( $data, $types, true );
|
||||
}
|
||||
|
||||
if ( is_array( $data ) && isset( $data['type'] ) ) {
|
||||
return in_array( $data['type'], $types, true );
|
||||
if ( \is_array( $data ) && isset( $data['type'] ) ) {
|
||||
return \in_array( $data['type'], $types, true );
|
||||
}
|
||||
|
||||
if ( $data instanceof Base_Object ) {
|
||||
return in_array( $data->get_type(), $types, true );
|
||||
return \in_array( $data->get_type(), $types, true );
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@ -9,29 +9,6 @@
|
||||
|
||||
namespace Activitypub;
|
||||
|
||||
/**
|
||||
* Detect a comment request.
|
||||
*
|
||||
* @deprecated 7.1.0
|
||||
*
|
||||
* @return int|bool Comment ID or false if not found.
|
||||
*/
|
||||
function is_comment() {
|
||||
\_deprecated_function( __FUNCTION__, '7.1.0' );
|
||||
|
||||
$comment_id = get_query_var( 'c', null );
|
||||
|
||||
if ( ! is_null( $comment_id ) ) {
|
||||
$comment = \get_comment( $comment_id );
|
||||
|
||||
if ( $comment ) {
|
||||
return $comment_id;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ActivityPub ID of a Comment by the WordPress Comment ID.
|
||||
*
|
||||
@ -46,12 +23,15 @@ function get_comment_id( $id ) {
|
||||
/**
|
||||
* Get the comment from an ActivityPub Object ID.
|
||||
*
|
||||
* @param string $id ActivityPub object ID (usually a URL) to check.
|
||||
* @since 9.1.0 Added the `$args` parameter.
|
||||
*
|
||||
* @param string $id ActivityPub object ID (usually a URL) to check.
|
||||
* @param array $args Optional. Additional WP_Comment_Query arguments.
|
||||
*
|
||||
* @return \WP_Comment|boolean Comment, or false on failure.
|
||||
*/
|
||||
function object_id_to_comment( $id ) {
|
||||
return Comment::object_id_to_comment( $id );
|
||||
function object_id_to_comment( $id, $args = array() ) {
|
||||
return Comment::object_id_to_comment( $id, $args );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -155,7 +135,7 @@ function get_comment_ancestors( $comment ) {
|
||||
$parent_id = (int) $ancestor->comment_parent;
|
||||
|
||||
// Loop detection: If the ancestor has been seen before, break.
|
||||
if ( empty( $parent_id ) || ( $parent_id === (int) $comment->comment_ID ) || in_array( $parent_id, $ancestors, true ) ) {
|
||||
if ( empty( $parent_id ) || ( $parent_id === (int) $comment->comment_ID ) || \in_array( $parent_id, $ancestors, true ) ) {
|
||||
break;
|
||||
}
|
||||
|
||||
@ -177,12 +157,12 @@ function get_comment_ancestors( $comment ) {
|
||||
function register_comment_type( $comment_type, $args = array() ) {
|
||||
global $activitypub_comment_types;
|
||||
|
||||
if ( ! is_array( $activitypub_comment_types ) ) {
|
||||
if ( ! \is_array( $activitypub_comment_types ) ) {
|
||||
$activitypub_comment_types = array();
|
||||
}
|
||||
|
||||
// Sanitize comment type name.
|
||||
$comment_type = sanitize_key( $comment_type );
|
||||
$comment_type = \sanitize_key( $comment_type );
|
||||
|
||||
$activitypub_comment_types[ $comment_type ] = $args;
|
||||
|
||||
@ -192,7 +172,7 @@ function register_comment_type( $comment_type, $args = array() ) {
|
||||
* @param string $comment_type Comment type.
|
||||
* @param array $args Arguments used to register the comment type.
|
||||
*/
|
||||
do_action( 'activitypub_registered_comment_type', $comment_type, $args );
|
||||
\do_action( 'activitypub_registered_comment_type', $comment_type, $args );
|
||||
|
||||
return $args;
|
||||
}
|
||||
@ -203,7 +183,7 @@ function register_comment_type( $comment_type, $args = array() ) {
|
||||
* @return string The reply intent URI.
|
||||
*/
|
||||
function get_reply_intent_js() {
|
||||
return sprintf(
|
||||
return \sprintf(
|
||||
'javascript:(()=>{window.open(\'%s\'+encodeURIComponent(window.location.href));})();',
|
||||
get_reply_intent_url()
|
||||
);
|
||||
@ -234,5 +214,5 @@ function get_reply_intent_url() {
|
||||
*/
|
||||
$url = \apply_filters( 'activitypub_reply_intent_url', $url );
|
||||
|
||||
return esc_url_raw( $url );
|
||||
return \esc_url_raw( $url );
|
||||
}
|
||||
|
||||
@ -94,7 +94,7 @@ function is_self_ping( $id ) {
|
||||
|
||||
if (
|
||||
is_same_domain( $id ) &&
|
||||
in_array( 'c', array_keys( $query ), true )
|
||||
\in_array( 'c', \array_keys( $query ), true )
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@ -122,9 +122,26 @@ function add_to_outbox( $data, $activity_type = null, $user_id = 0, $content_vis
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Refuse to Update an object that is already deleted from the Fediverse. An
|
||||
* explicit Update (e.g. `wp activitypub post update` or a third-party caller)
|
||||
* on a soft-deleted post would only federate a Tombstone, and — worse — would
|
||||
* reset the object's state to "federated" below, so a later re-publish would
|
||||
* skip the Create that cancels the still-pending Delete and the post could be
|
||||
* torn down after it is public again. Re-publishing is the supported way to
|
||||
* bring a deleted object back, and that emits a Create, not an Update.
|
||||
*/
|
||||
if ( 'Update' === $activity_type && ACTIVITYPUB_OBJECT_STATE_DELETED === get_wp_object_state( $data ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_object_deleted',
|
||||
\__( 'Cannot send an Update for an object that has been deleted from the Fediverse. Re-publish it instead.', 'activitypub' ),
|
||||
array( 'status' => 409 )
|
||||
);
|
||||
}
|
||||
|
||||
$transformer = Transformer_Factory::get_transformer( $data );
|
||||
|
||||
if ( ! $transformer || is_wp_error( $transformer ) ) {
|
||||
if ( ! $transformer || \is_wp_error( $transformer ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -130,7 +130,7 @@ function is_post_publicly_queryable( $post ) {
|
||||
}
|
||||
|
||||
$visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
|
||||
$is_local_or_private = in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ), true );
|
||||
$is_local_or_private = \in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ), true );
|
||||
|
||||
/*
|
||||
* An attachment (`inherit` status) inherits its parent's visibility.
|
||||
@ -142,8 +142,8 @@ function is_post_publicly_queryable( $post ) {
|
||||
'attachment' === $post->post_type &&
|
||||
( ! $post->post_parent || is_post_publicly_queryable( $post->post_parent ) );
|
||||
|
||||
// Drafts and pending posts are allowed during preview requests so the Fediverse Preview works.
|
||||
$is_preview = in_array( $post->post_status, array( 'draft', 'pending' ), true ) &&
|
||||
// Draft, pending, and scheduled posts are allowed during preview requests so the Fediverse Preview works.
|
||||
$is_preview = \in_array( $post->post_status, array( 'draft', 'pending', 'future' ), true ) &&
|
||||
\get_query_var( 'preview' ) &&
|
||||
\current_user_can( 'edit_post', $post->ID );
|
||||
|
||||
@ -165,6 +165,42 @@ function is_post_publicly_queryable( $post ) {
|
||||
return \apply_filters( 'activitypub_is_post_publicly_queryable', $queryable, $post );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a post is federated.
|
||||
*
|
||||
* A post is federated when it has been sent to the Fediverse (its federation
|
||||
* state is "federated") AND it is still publicly queryable, i.e. its post type
|
||||
* is enabled for ActivityPub, it has a public status, it is not password-
|
||||
* protected, and its content visibility allows it (see `is_post_publicly_queryable()`).
|
||||
*
|
||||
* Re-checking the live queryability alongside the stored state guards against a
|
||||
* stale "federated" status left behind when a post is moved to a private status,
|
||||
* switched to local visibility, or its post type loses ActivityPub support.
|
||||
*
|
||||
* The federation-state check also keeps `is_post_publicly_queryable()`'s
|
||||
* preview allowance inert here: a draft/pending/scheduled post is never in the
|
||||
* federated state, so the preview branch can never make this return true.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @param mixed $post The post ID or object.
|
||||
*
|
||||
* @return boolean True if the post is federated, false otherwise.
|
||||
*/
|
||||
function is_post_federated( $post ) {
|
||||
if ( empty( $post ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$post = \get_post( $post );
|
||||
|
||||
if ( ! $post ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ACTIVITYPUB_OBJECT_STATE_FEDERATED === get_wp_object_state( $post ) && is_post_publicly_queryable( $post );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a post is an ActivityPub post.
|
||||
*
|
||||
@ -199,7 +235,7 @@ function get_post_type_description( $post_type ) {
|
||||
$description = '';
|
||||
break;
|
||||
case 'attachment':
|
||||
$description = ' - ' . __( 'Files uploaded to the media library (such as images, videos, documents, or other attachments). Note: This federates every file upload, not just published content.', 'activitypub' );
|
||||
$description = ' - ' . \__( 'Files uploaded to the media library (such as images, videos, documents, or other attachments). Note: This federates every file upload, not just published content.', 'activitypub' );
|
||||
break;
|
||||
default:
|
||||
$description = '';
|
||||
@ -215,7 +251,7 @@ function get_post_type_description( $post_type ) {
|
||||
* @param string $post_type_name The post type name.
|
||||
* @param \WP_Post_Type $post_type The post type object.
|
||||
*/
|
||||
return apply_filters( 'activitypub_post_type_description', $description, $post_type->name, $post_type );
|
||||
return \apply_filters( 'activitypub_post_type_description', $description, $post_type->name, $post_type );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -226,20 +262,20 @@ function get_post_type_description( $post_type ) {
|
||||
* @return array The enclosures.
|
||||
*/
|
||||
function get_enclosures( $post_id ) {
|
||||
$enclosures = get_post_meta( $post_id, 'enclosure', false );
|
||||
$enclosures = \get_post_meta( $post_id, 'enclosure', false );
|
||||
|
||||
if ( ! $enclosures ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$enclosures = array_map(
|
||||
$enclosures = \array_map(
|
||||
static function ( $enclosure ) {
|
||||
// Check if the enclosure is a string.
|
||||
if ( ! $enclosure || ! is_string( $enclosure ) ) {
|
||||
if ( ! $enclosure || ! \is_string( $enclosure ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$attributes = explode( "\n", $enclosure );
|
||||
$attributes = \explode( "\n", $enclosure );
|
||||
|
||||
if ( ! isset( $attributes[0] ) || ! \wp_http_validate_url( $attributes[0] ) ) {
|
||||
return false;
|
||||
@ -254,7 +290,7 @@ function get_enclosures( $post_id ) {
|
||||
$enclosures
|
||||
);
|
||||
|
||||
return array_filter( $enclosures );
|
||||
return \array_filter( $enclosures );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -270,7 +306,7 @@ function get_enclosures( $post_id ) {
|
||||
* @return string The generated post summary.
|
||||
*/
|
||||
function generate_post_summary( $post, $length = 500 ) {
|
||||
$post = get_post( $post );
|
||||
$post = \get_post( $post );
|
||||
|
||||
if ( ! $post ) {
|
||||
return '';
|
||||
@ -332,12 +368,12 @@ function generate_post_summary( $post, $length = 500 ) {
|
||||
* @return string|false The content warning or false if not found.
|
||||
*/
|
||||
function get_content_warning( $post_id ) {
|
||||
$post = get_post( $post_id );
|
||||
$post = \get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$warning = get_post_meta( $post->ID, 'activitypub_content_warning', true );
|
||||
$warning = \get_post_meta( $post->ID, 'activitypub_content_warning', true );
|
||||
if ( empty( $warning ) ) {
|
||||
return false;
|
||||
}
|
||||
@ -372,7 +408,7 @@ function get_post_id( $id ) {
|
||||
* @return string|false The visibility of the post or false if not found.
|
||||
*/
|
||||
function get_content_visibility( $post_id ) {
|
||||
$post = get_post( $post_id );
|
||||
$post = \get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
return false;
|
||||
}
|
||||
@ -385,7 +421,7 @@ function get_content_visibility( $post_id ) {
|
||||
ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL,
|
||||
);
|
||||
|
||||
if ( in_array( $visibility, $options, true ) ) {
|
||||
if ( \in_array( $visibility, $options, true ) ) {
|
||||
$_visibility = $visibility;
|
||||
}
|
||||
|
||||
|
||||
@ -50,23 +50,7 @@ function use_authorized_fetch() {
|
||||
*
|
||||
* @param boolean $use_authorized_fetch True if Authorized-Fetch is enabled, false otherwise.
|
||||
*/
|
||||
return apply_filters( 'activitypub_use_authorized_fetch', $use );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for Tombstone Objects.
|
||||
*
|
||||
* @deprecated 7.3.0 Use {@see Tombstone::exists_in_error()}.
|
||||
* @see https://www.w3.org/TR/activitypub/#delete-activity-outbox
|
||||
*
|
||||
* @param \WP_Error $wp_error A WP_Error-Response of an HTTP-Request.
|
||||
*
|
||||
* @return boolean True if HTTP-Code is 410 or 404.
|
||||
*/
|
||||
function is_tombstone( $wp_error ) {
|
||||
\_deprecated_function( __FUNCTION__, '7.3.0', 'Activitypub\Tombstone::exists_in_error' );
|
||||
|
||||
return Tombstone::exists_in_error( $wp_error );
|
||||
return \apply_filters( 'activitypub_use_authorized_fetch', $use );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -106,18 +90,18 @@ function get_remote_metadata_by_actor( $actor, $cached = true ) { // phpcs:ignor
|
||||
* Default false to continue with the remote request.
|
||||
* @param string $actor The actor URL.
|
||||
*/
|
||||
$pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
|
||||
$pre = \apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
|
||||
if ( $pre ) {
|
||||
return $pre;
|
||||
}
|
||||
|
||||
$remote_actor = Remote_Actors::fetch_by_various( $actor );
|
||||
|
||||
if ( is_wp_error( $remote_actor ) ) {
|
||||
if ( \is_wp_error( $remote_actor ) ) {
|
||||
return $remote_actor;
|
||||
}
|
||||
|
||||
return json_decode( $remote_actor->post_content, true );
|
||||
return \json_decode( $remote_actor->post_content, true );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -144,15 +128,36 @@ function get_remote_metadata_by_actor( $actor, $cached = true ) { // phpcs:ignor
|
||||
* @return string|false A safe public IP, or false when no safe address is available.
|
||||
*/
|
||||
function resolve_public_host( $host ) {
|
||||
if ( ! is_string( $host ) || '' === $host ) {
|
||||
if ( ! \is_string( $host ) || '' === $host ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalise bracketed IPv6 literals (parse_url returns "[::1]").
|
||||
$host = \trim( $host, '[]' );
|
||||
|
||||
/**
|
||||
* Filters whether a non-public host may be used.
|
||||
*
|
||||
* Returning true skips this function's private/reserved-range validation and returns the resolved
|
||||
* address as is, for sites that federate over a private network or intranet. A host that does not
|
||||
* resolve at all is still rejected.
|
||||
*
|
||||
* Note: Callers that fetch through WordPress' safe HTTP APIs (wp_safe_remote_get()/post())
|
||||
* are still subject to core's own loopback/RFC1918 rejection outside its same-host exception.
|
||||
* Re-enabling those ranges additionally requires filtering WordPress core (e.g.
|
||||
* http_request_reject_unsafe_urls).
|
||||
*
|
||||
* @param bool $allow Whether to allow the non-public host. Default false.
|
||||
* @param string $host The host being resolved.
|
||||
*/
|
||||
$allow_non_public = \apply_filters( 'activitypub_allow_non_public_host', false, $host );
|
||||
|
||||
// Already an IP literal — validate directly. Accepts IPv4 and IPv6.
|
||||
if ( \filter_var( $host, FILTER_VALIDATE_IP ) ) {
|
||||
if ( $allow_non_public ) {
|
||||
return $host;
|
||||
}
|
||||
|
||||
if ( is_unsafe_ipv6_literal( $host ) ) {
|
||||
return false;
|
||||
}
|
||||
@ -198,6 +203,11 @@ function resolve_public_host( $host ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A host that resolves may be used as is when non-public hosts are explicitly allowed.
|
||||
if ( $allow_non_public ) {
|
||||
return $ipv4[0] ?? $ipv6[0];
|
||||
}
|
||||
|
||||
foreach ( $ipv4 as $ip ) {
|
||||
if ( ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
|
||||
return false;
|
||||
@ -229,7 +239,7 @@ function resolve_public_host( $host ) {
|
||||
*/
|
||||
function is_ipv4_mapped_ipv6( $ip ) {
|
||||
// Short-circuit before inet_pton() so it doesn't emit a warning for non-IP input.
|
||||
if ( ! is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
|
||||
if ( ! \is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -267,7 +277,7 @@ function is_ipv4_mapped_ipv6( $ip ) {
|
||||
* @return bool True if the value is an unsafe IPv6 literal.
|
||||
*/
|
||||
function is_unsafe_ipv6_literal( $ip ) {
|
||||
if ( ! is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
|
||||
if ( ! \is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -18,8 +18,8 @@ namespace Activitypub;
|
||||
*/
|
||||
function get_rest_url_by_path( $path = '' ) {
|
||||
// We'll handle the leading slash.
|
||||
$path = ltrim( $path, '/' );
|
||||
$namespaced_path = sprintf( '/%s/%s', ACTIVITYPUB_REST_NAMESPACE, $path );
|
||||
$path = \ltrim( $path, '/' );
|
||||
$namespaced_path = \sprintf( '/%s/%s', ACTIVITYPUB_REST_NAMESPACE, $path );
|
||||
return \get_rest_url( null, $namespaced_path );
|
||||
}
|
||||
|
||||
@ -90,7 +90,7 @@ function get_upload_baseurl() {
|
||||
*
|
||||
* @param string|false $maybe_upload_dir The upload base URL or false if not set.
|
||||
*/
|
||||
$maybe_upload_dir = apply_filters( 'pre_activitypub_get_upload_baseurl', false );
|
||||
$maybe_upload_dir = \apply_filters( 'pre_activitypub_get_upload_baseurl', false );
|
||||
if ( false !== $maybe_upload_dir ) {
|
||||
return $maybe_upload_dir;
|
||||
}
|
||||
@ -102,7 +102,7 @@ function get_upload_baseurl() {
|
||||
*
|
||||
* @param string $upload_dir The upload base URL. Default \wp_get_upload_dir()['baseurl']
|
||||
*/
|
||||
return apply_filters( 'activitypub_get_upload_baseurl', $upload_dir['baseurl'] );
|
||||
return \apply_filters( 'activitypub_get_upload_baseurl', $upload_dir['baseurl'] );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -113,7 +113,7 @@ function get_upload_baseurl() {
|
||||
* @return string|false The authority, or false on failure.
|
||||
*/
|
||||
function get_url_authority( $url ) {
|
||||
$parsed = wp_parse_url( $url );
|
||||
$parsed = \wp_parse_url( $url );
|
||||
|
||||
if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
|
||||
return false;
|
||||
@ -156,7 +156,7 @@ function extract_name_from_uri( $uri ) {
|
||||
) {
|
||||
// Expected: user@example.com or acct:user@example (WebFinger).
|
||||
$name = \ltrim( $name, '@' );
|
||||
if ( str_starts_with( $name, 'acct:' ) ) {
|
||||
if ( \str_starts_with( $name, 'acct:' ) ) {
|
||||
$name = \substr( $name, 5 );
|
||||
}
|
||||
$parts = \explode( '@', $name );
|
||||
|
||||
@ -12,21 +12,6 @@ namespace Activitypub;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Followers;
|
||||
|
||||
/**
|
||||
* Returns a users WebFinger "resource".
|
||||
*
|
||||
* @deprecated 7.1.0 Use {@see \Activitypub\Webfinger::get_user_resource} instead.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
*
|
||||
* @return string The User resource.
|
||||
*/
|
||||
function get_webfinger_resource( $user_id ) {
|
||||
\_deprecated_function( __FUNCTION__, '7.1.0', 'Activitypub\Webfinger::get_user_resource' );
|
||||
|
||||
return Webfinger::get_user_resource( $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the followers of a given user.
|
||||
*
|
||||
@ -63,7 +48,7 @@ function url_to_authorid( $url ) {
|
||||
|
||||
// Check if url has the same host.
|
||||
$request_host = \wp_parse_url( $url, \PHP_URL_HOST );
|
||||
if ( \wp_parse_url( \home_url(), \PHP_URL_HOST ) !== $request_host && get_option( 'activitypub_old_host' ) !== $request_host ) {
|
||||
if ( \wp_parse_url( \home_url(), \PHP_URL_HOST ) !== $request_host && \get_option( 'activitypub_old_host' ) !== $request_host ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -103,15 +88,11 @@ function url_to_authorid( $url ) {
|
||||
* @return boolean True if the user is enabled, false otherwise.
|
||||
*/
|
||||
function user_can_activitypub( $user_id ) {
|
||||
if ( ! is_numeric( $user_id ) ) {
|
||||
if ( ! \is_numeric( $user_id ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch ( $user_id ) {
|
||||
case Actors::APPLICATION_USER_ID:
|
||||
$enabled = true; // Application user is always enabled.
|
||||
break;
|
||||
|
||||
case Actors::BLOG_USER_ID:
|
||||
$enabled = ! is_user_type_disabled( 'blog' );
|
||||
break;
|
||||
@ -136,7 +117,7 @@ function user_can_activitypub( $user_id ) {
|
||||
* @param boolean $enabled True if the user is enabled, false otherwise.
|
||||
* @param int $user_id The user ID.
|
||||
*/
|
||||
return apply_filters( 'activitypub_user_can_activitypub', $enabled, $user_id );
|
||||
return \apply_filters( 'activitypub_user_can_activitypub', $enabled, $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -234,7 +215,7 @@ function is_user_type_disabled( $type ) {
|
||||
* @param boolean $disabled True if the user type is disabled, false otherwise.
|
||||
* @param string $type The User-Type.
|
||||
*/
|
||||
return apply_filters( 'activitypub_is_user_type_disabled', $disabled, $type );
|
||||
return \apply_filters( 'activitypub_is_user_type_disabled', $disabled, $type );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -38,7 +38,7 @@ function get_object_id( $wp_object ) {
|
||||
* @return string The converted string.
|
||||
*/
|
||||
function camel_to_snake_case( $input ) {
|
||||
return strtolower( preg_replace( '/(?<!^)[A-Z]/', '_$0', $input ) );
|
||||
return \strtolower( \preg_replace( '/(?<!^)[A-Z]/', '_$0', $input ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -49,7 +49,7 @@ function camel_to_snake_case( $input ) {
|
||||
* @return string The converted string.
|
||||
*/
|
||||
function snake_to_camel_case( $input ) {
|
||||
return lcfirst( str_replace( '_', '', ucwords( $input, '_' ) ) );
|
||||
return \lcfirst( \str_replace( '_', '', \ucwords( $input, '_' ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -66,8 +66,8 @@ function seconds_to_iso8601( $seconds ) {
|
||||
return 'PT0S';
|
||||
}
|
||||
|
||||
$hours = floor( $seconds / 3600 );
|
||||
$minutes = floor( ( $seconds % 3600 ) / 60 );
|
||||
$hours = \floor( $seconds / 3600 );
|
||||
$minutes = \floor( ( $seconds % 3600 ) / 60 );
|
||||
$secs = $seconds % 60;
|
||||
|
||||
$duration = 'PT';
|
||||
@ -99,22 +99,46 @@ function site_supports_blocks() {
|
||||
*
|
||||
* @param boolean $supports_blocks True if the site supports the block editor, false otherwise.
|
||||
*/
|
||||
return apply_filters( 'activitypub_site_supports_blocks', true );
|
||||
return \apply_filters( 'activitypub_site_supports_blocks', true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data is valid JSON.
|
||||
* Get the icon Image object for site-wide ActivityPub actors.
|
||||
*
|
||||
* @deprecated 7.1.0 Use {@see \json_decode}.
|
||||
* Tries the site icon first, then the custom logo, and falls back to the
|
||||
* bundled WordPress logo.
|
||||
*
|
||||
* @param string $data The data to check.
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return boolean True if the data is JSON, false otherwise.
|
||||
* @return array The icon array with 'type' and 'url'.
|
||||
*/
|
||||
function is_json( $data ) {
|
||||
\_deprecated_function( __FUNCTION__, '7.1.0', 'json_decode' );
|
||||
function site_icon() {
|
||||
// Try site icon first.
|
||||
$icon_id = \get_option( 'site_icon' );
|
||||
|
||||
return \is_array( \json_decode( $data, true ) );
|
||||
// Try custom logo second.
|
||||
if ( ! $icon_id ) {
|
||||
$icon_id = \get_theme_mod( 'custom_logo' );
|
||||
}
|
||||
|
||||
$icon_url = false;
|
||||
|
||||
if ( $icon_id ) {
|
||||
$icon = \wp_get_attachment_image_src( $icon_id, 'full' );
|
||||
if ( $icon ) {
|
||||
$icon_url = $icon[0];
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $icon_url ) {
|
||||
// Fallback to default icon.
|
||||
$icon_url = \plugins_url( '/assets/img/wp-logo.png', ACTIVITYPUB_PLUGIN_FILE );
|
||||
}
|
||||
|
||||
return array(
|
||||
'type' => 'Image',
|
||||
'url' => \esc_url_raw( $icon_url ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -128,7 +152,7 @@ function is_blog_public() {
|
||||
*
|
||||
* @param bool $public Whether the blog is public.
|
||||
*/
|
||||
return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) );
|
||||
return (bool) \apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -138,13 +162,13 @@ function is_blog_public() {
|
||||
*/
|
||||
function get_masked_wp_version() {
|
||||
// Only show the major and minor version.
|
||||
$version = get_bloginfo( 'version' );
|
||||
$version = \get_bloginfo( 'version' );
|
||||
// Strip the RC or beta part.
|
||||
$version = preg_replace( '/-.*$/', '', $version );
|
||||
$version = explode( '.', $version );
|
||||
$version = array_slice( $version, 0, 2 );
|
||||
$version = \preg_replace( '/-.*$/', '', $version );
|
||||
$version = \explode( '.', $version );
|
||||
$version = \array_slice( $version, 0, 2 );
|
||||
|
||||
return implode( '.', $version );
|
||||
return \implode( '.', $version );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -178,7 +202,7 @@ function get_attribution_domains() {
|
||||
}
|
||||
|
||||
$domains = \get_option( 'activitypub_attribution_domains', home_host() );
|
||||
$domains = explode( PHP_EOL, $domains );
|
||||
$domains = \explode( PHP_EOL, $domains );
|
||||
|
||||
if ( ! $domains ) {
|
||||
$domains = null;
|
||||
@ -236,17 +260,17 @@ function esc_hashtag( $input ) {
|
||||
$hashtag = \preg_replace( '/[^\p{L}\p{Nd}-]+/u', '-', $hashtag );
|
||||
|
||||
// Capitalize every letter that is preceded by a hyphen.
|
||||
$hashtag = preg_replace_callback(
|
||||
$hashtag = \preg_replace_callback(
|
||||
'/-+(.)/',
|
||||
static function ( $matches ) {
|
||||
return strtoupper( $matches[1] );
|
||||
return \strtoupper( $matches[1] );
|
||||
},
|
||||
$hashtag
|
||||
);
|
||||
|
||||
// Add a hashtag to the beginning of the string.
|
||||
$hashtag = ltrim( $hashtag, '#' );
|
||||
$hashtag = trim( $hashtag, '-' );
|
||||
$hashtag = \ltrim( $hashtag, '#' );
|
||||
$hashtag = \trim( $hashtag, '-' );
|
||||
$hashtag = '#' . $hashtag;
|
||||
|
||||
/**
|
||||
@ -255,9 +279,9 @@ function esc_hashtag( $input ) {
|
||||
* @param string $hashtag The hashtag to be returned.
|
||||
* @param string $input The original string.
|
||||
*/
|
||||
$hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $input );
|
||||
$hashtag = \apply_filters( 'activitypub_esc_hashtag', $hashtag, $input );
|
||||
|
||||
return esc_html( $hashtag );
|
||||
return \esc_html( $hashtag );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -271,7 +295,7 @@ function esc_hashtag( $input ) {
|
||||
*/
|
||||
function enrich_content_data( $content, $regex, $regex_callback ) {
|
||||
// Small protection against execution timeouts: limit to 1 MB.
|
||||
if ( mb_strlen( $content ) > MB_IN_BYTES ) {
|
||||
if ( \mb_strlen( $content ) > MB_IN_BYTES ) {
|
||||
return $content;
|
||||
}
|
||||
$tag_stack = array();
|
||||
@ -284,20 +308,20 @@ function enrich_content_data( $content, $regex, $regex_callback ) {
|
||||
);
|
||||
$content_with_links = '';
|
||||
$in_protected_tag = false;
|
||||
foreach ( wp_html_split( $content ) as $chunk ) {
|
||||
if ( preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
|
||||
foreach ( \wp_html_split( $content ) as $chunk ) {
|
||||
if ( \preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
|
||||
$content_with_links .= $chunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
|
||||
$tag = strtolower( $m[2] );
|
||||
if ( \preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
|
||||
$tag = \strtolower( $m[2] );
|
||||
if ( '/' === $m[1] ) {
|
||||
// Closing tag.
|
||||
$i = array_search( $tag, $tag_stack, true );
|
||||
$i = \array_search( $tag, $tag_stack, true );
|
||||
// We can only remove the tag from the stack if it is in the stack.
|
||||
if ( false !== $i ) {
|
||||
$tag_stack = array_slice( $tag_stack, 0, $i );
|
||||
$tag_stack = \array_slice( $tag_stack, 0, $i );
|
||||
}
|
||||
} else {
|
||||
// Opening tag, add it to the stack.
|
||||
@ -306,7 +330,7 @@ function enrich_content_data( $content, $regex, $regex_callback ) {
|
||||
|
||||
// If we're in a protected tag, the tag_stack contains at least one protected tag string.
|
||||
// The protected tag state can only change when we encounter a start or end tag.
|
||||
$in_protected_tag = array_intersect( $tag_stack, $protected_tags );
|
||||
$in_protected_tag = \array_intersect( $tag_stack, $protected_tags );
|
||||
|
||||
// Never inspect tags.
|
||||
$content_with_links .= $chunk;
|
||||
|
||||
@ -11,6 +11,7 @@ use Activitypub\Collection\Following;
|
||||
use Activitypub\Collection\Outbox;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
use function Activitypub\is_same_actor;
|
||||
use function Activitypub\object_to_uri;
|
||||
|
||||
/**
|
||||
@ -42,13 +43,22 @@ class Accept {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor_post = Remote_Actors::get_by_uri( object_to_uri( $accept['object']['object'] ) );
|
||||
/*
|
||||
* For a Follow Accept, the sender must be the actor that was followed.
|
||||
* Without this, a signed Accept from one actor could confirm a Follow that
|
||||
* targeted another actor by referencing that pending Follow's outbox GUID.
|
||||
*/
|
||||
if ( ! is_same_actor( $accept['actor'] ?? '', $accept['object']['object'] ?? '' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor_post = Remote_Actors::get_by_uri( object_to_uri( $accept['object']['object'] ?? '' ) );
|
||||
|
||||
if ( \is_wp_error( $actor_post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = is_array( $user_ids ) ? reset( $user_ids ) : $user_ids;
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
$result = Following::accept( $actor_post, $user_id );
|
||||
$success = ! \is_wp_error( $result );
|
||||
|
||||
|
||||
@ -53,13 +53,29 @@ class Announce {
|
||||
|
||||
self::maybe_save_announce( $announcement, $user_ids );
|
||||
|
||||
if ( is_string( $announcement['object'] ) ) {
|
||||
$object = Http::get_remote_object( $announcement['object'] );
|
||||
} else {
|
||||
$object = $announcement['object'];
|
||||
}
|
||||
$object_url = object_to_uri( $announcement['object'] );
|
||||
|
||||
if ( ! $object || is_wp_error( $object ) ) {
|
||||
// Force no redirects for this object's request only, so the requested host stays the authoritative origin.
|
||||
$no_redirects = static function ( $args, $url ) use ( $object_url ) {
|
||||
if ( $url === $object_url ) {
|
||||
$args['redirection'] = 0;
|
||||
}
|
||||
return $args;
|
||||
};
|
||||
|
||||
/*
|
||||
* Fetch the activity from its own id rather than the inline copy the Announce
|
||||
* carries: that copy is the announcer's, who is not necessarily the activity's
|
||||
* author. Redirects are forbidden (above) and the cache is bypassed so the
|
||||
* requested host is the authoritative origin — otherwise a redirect, or a
|
||||
* response cached from an earlier redirect-following fetch, could resolve to
|
||||
* attacker content while the host check below still saw the trusted host.
|
||||
*/
|
||||
\add_filter( 'http_request_args', $no_redirects, 10, 2 );
|
||||
$object = Http::get_remote_object( $object_url, false );
|
||||
\remove_filter( 'http_request_args', $no_redirects, 10 );
|
||||
|
||||
if ( ! $object || \is_wp_error( $object ) || ! \is_array( $object ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -67,6 +83,19 @@ class Announce {
|
||||
return;
|
||||
}
|
||||
|
||||
$origin_host = \strtolower( (string) \wp_parse_url( (string) $object_url, \PHP_URL_HOST ) );
|
||||
$actor_host = \strtolower( (string) \wp_parse_url( (string) object_to_uri( $object['actor'] ?? '' ), \PHP_URL_HOST ) );
|
||||
|
||||
/*
|
||||
* Only an actor's own server may vouch for an activity attributed to it, so the
|
||||
* host it was fetched from must equal its actor's host — the same key-host ==
|
||||
* actor-host binding verify_key_id() enforces for signed requests, generalised
|
||||
* to every relayed activity type.
|
||||
*/
|
||||
if ( '' === $origin_host || '' === $actor_host || $origin_host !== $actor_host ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$type = \strtolower( $object['type'] );
|
||||
|
||||
/**
|
||||
@ -102,7 +131,8 @@ class Announce {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = Comment::object_id_to_comment( esc_url_raw( $url ) );
|
||||
// Match any status, so a repost that was marked as spam or trashed still counts as seen.
|
||||
$exists = Comment::object_id_to_comment( \esc_url_raw( $url ), array( 'status' => 'any' ) );
|
||||
if ( $exists ) {
|
||||
return;
|
||||
}
|
||||
@ -115,9 +145,9 @@ class Announce {
|
||||
$success = false;
|
||||
$result = Interactions::add_reaction( $activity );
|
||||
|
||||
if ( $result && ! is_wp_error( $result ) ) {
|
||||
if ( $result && ! \is_wp_error( $result ) ) {
|
||||
$success = true;
|
||||
$result = get_comment( $result );
|
||||
$result = \get_comment( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -66,7 +66,7 @@ class Collection_Sync {
|
||||
|
||||
// Check for followers collection.
|
||||
$collection_type = null;
|
||||
if ( preg_match( '#/followers(?:/sync)?(?:\?|$)#', $params['url'] ) ) {
|
||||
if ( \preg_match( '#/followers(?:/sync)?(?:\?|$)#', $params['url'] ) ) {
|
||||
$collection_type = 'followers';
|
||||
}
|
||||
|
||||
@ -89,10 +89,10 @@ class Collection_Sync {
|
||||
|
||||
// Extract the user ID for cache key (collection sync is always for a single user).
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
$cache_key = 'activitypub_collection_sync_received_' . $user_id . '_' . md5( $actor_url );
|
||||
$cache_key = 'activitypub_collection_sync_received_' . $user_id . '_' . \md5( $actor_url );
|
||||
if ( false === \get_transient( $cache_key ) ) {
|
||||
$frequency = self::get_frequency();
|
||||
\set_transient( $cache_key, time(), $frequency );
|
||||
\set_transient( $cache_key, \time(), $frequency );
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@ -127,7 +127,7 @@ class Collection_Sync {
|
||||
return $args;
|
||||
}
|
||||
|
||||
if ( ! is_array( $args['body'] ) ) {
|
||||
if ( ! \is_array( $args['body'] ) ) {
|
||||
$body = \json_decode( $args['body'], true );
|
||||
if ( null === $body ) {
|
||||
return $args;
|
||||
@ -149,7 +149,7 @@ class Collection_Sync {
|
||||
}
|
||||
|
||||
// Check if we've already sent a sync header to this authority today.
|
||||
$transient_key = 'activitypub_collection_sync_sent_' . $user_id . '_' . md5( $inbox_authority );
|
||||
$transient_key = 'activitypub_collection_sync_sent_' . $user_id . '_' . \md5( $inbox_authority );
|
||||
if ( false !== \get_transient( $transient_key ) ) {
|
||||
return $args;
|
||||
}
|
||||
@ -159,7 +159,7 @@ class Collection_Sync {
|
||||
$args['headers']['Collection-Synchronization'] = $sync_header;
|
||||
|
||||
$frequency = self::get_frequency();
|
||||
\set_transient( $transient_key, time(), $frequency );
|
||||
\set_transient( $transient_key, \time(), $frequency );
|
||||
}
|
||||
|
||||
return $args;
|
||||
@ -196,7 +196,7 @@ class Collection_Sync {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( trailingslashit( $params['collectionId'] ) !== trailingslashit( $expected_collection ) ) {
|
||||
if ( \trailingslashit( $params['collectionId'] ) !== \trailingslashit( $expected_collection ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -164,7 +164,7 @@ class Create {
|
||||
* @param \Activitypub\Activity\Activity $activity The Activity object.
|
||||
*/
|
||||
public static function maybe_unbury( $outbox_id, $activity ) {
|
||||
if ( ! in_array( $activity->get_type(), array( 'Create', 'Update' ), true ) ) {
|
||||
if ( ! \in_array( $activity->get_type(), array( 'Create', 'Update' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -156,7 +156,7 @@ class Delete {
|
||||
$follower = Remote_Actors::get_by_uri( $activity['actor'] );
|
||||
|
||||
// Verify that Actor is deleted.
|
||||
if ( ! is_wp_error( $follower ) && Tombstone::exists( $activity['actor'] ) ) {
|
||||
if ( ! \is_wp_error( $follower ) && Tombstone::exists( $activity['actor'] ) ) {
|
||||
self::maybe_delete_interactions( $follower->ID );
|
||||
self::maybe_delete_posts( $follower->ID );
|
||||
$state = Remote_Actors::delete( $follower->ID );
|
||||
@ -252,7 +252,7 @@ class Delete {
|
||||
if ( $comments && Tombstone::exists( $id ) ) {
|
||||
foreach ( $comments as $comment ) {
|
||||
// WordPress will automatically delete all comment meta including _activitypub_remote_actor_id.
|
||||
wp_delete_comment( $comment->comment_ID, true );
|
||||
\wp_delete_comment( $comment->comment_ID, true );
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@ -0,0 +1,350 @@
|
||||
<?php
|
||||
/**
|
||||
* Handler for FeatureRequest activities (FEP-7aa9).
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Handler;
|
||||
|
||||
use Activitypub\Activity\Activity;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
use function Activitypub\add_to_outbox;
|
||||
use function Activitypub\object_to_uri;
|
||||
use function Activitypub\user_can_activitypub;
|
||||
|
||||
/**
|
||||
* Handler for FeatureRequest activities.
|
||||
*
|
||||
* @see https://w3id.org/fep/7aa9
|
||||
*/
|
||||
class Feature_Request {
|
||||
|
||||
/**
|
||||
* Option-name prefix for the blog actor's feature stamps.
|
||||
*
|
||||
* The blog actor has no users-table row (its ID is 0), so its stamps cannot
|
||||
* live in user meta like the stamps of regular users do. Each stamp is stored
|
||||
* in its own option row, keyed `{prefix}_{id}`, so a stamp ID can be claimed
|
||||
* atomically with `add_option()` without a lock.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const BLOG_STAMPS_OPTION = 'activitypub_blog_featured_by';
|
||||
|
||||
/**
|
||||
* Initialize the class, registering WordPress hooks.
|
||||
*/
|
||||
public static function init() {
|
||||
\add_action( 'activitypub_inbox_feature_request', array( self::class, 'handle_feature_request' ), 10, 2 );
|
||||
\add_action( 'activitypub_rest_inbox_disallowed', array( self::class, 'handle_blocked_request' ), 10, 3 );
|
||||
|
||||
\add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle FeatureRequest activities.
|
||||
*
|
||||
* @param array $activity The activity object.
|
||||
* @param int|int[] $user_ids The user ID(s) targeted by the inbox dispatch.
|
||||
*/
|
||||
public static function handle_feature_request( $activity, $user_ids ) {
|
||||
$state = true;
|
||||
$object_uri = object_to_uri( $activity['object'] );
|
||||
$target = Actors::get_by_resource( $object_uri );
|
||||
|
||||
if ( \is_wp_error( $target ) ) {
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
self::queue_reject( $activity, $user_id );
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = $target->get__id();
|
||||
|
||||
$policy = \get_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ME );
|
||||
|
||||
switch ( $policy ) {
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_ANYONE:
|
||||
self::queue_accept( $activity, $user_id );
|
||||
break;
|
||||
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS:
|
||||
$follower = Remote_Actors::get_by_uri( object_to_uri( $activity['actor'] ) );
|
||||
if ( ! \is_wp_error( $follower ) && Followers::follows( $follower->ID, $user_id ) ) {
|
||||
self::queue_accept( $activity, $user_id );
|
||||
} else {
|
||||
self::queue_reject( $activity, $user_id );
|
||||
$state = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_ME:
|
||||
default:
|
||||
self::queue_reject( $activity, $user_id );
|
||||
$state = false;
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after an ActivityPub FeatureRequest activity has been handled.
|
||||
*
|
||||
* @param array $activity The ActivityPub activity data.
|
||||
* @param int[] $user_ids The local user IDs.
|
||||
* @param bool $state True on accept, false otherwise.
|
||||
* @param string $policy The active site policy.
|
||||
*/
|
||||
\do_action( 'activitypub_handled_feature_request', $activity, (array) $user_ids, $state, $policy );
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivityPub inbox disallowed activity.
|
||||
*
|
||||
* @param array $activity The activity array.
|
||||
* @param int|int[]|null $user_ids The user ID(s).
|
||||
* @param string $type The activity type.
|
||||
*/
|
||||
public static function handle_blocked_request( $activity, $user_ids, $type ) {
|
||||
if ( ! \in_array( \strtolower( $type ), array( 'featurerequest', 'feature_request' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
self::queue_reject( $activity, $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an Accept activity in response to the FeatureRequest, issuing a stamp.
|
||||
*
|
||||
* Idempotent: a second call with the same instrument for the same actor reuses
|
||||
* the existing stamp instead of minting a duplicate, see {@see add_stamp()}.
|
||||
*
|
||||
* @param array $activity_object The activity object.
|
||||
* @param int $user_id The local user ID being featured (0 for the blog actor).
|
||||
*/
|
||||
public static function queue_accept( $activity_object, $user_id ) {
|
||||
if ( ! user_can_activitypub( $user_id ) ) {
|
||||
$user_id = Actors::BLOG_USER_ID;
|
||||
}
|
||||
|
||||
$actor = Actors::get_by_id( $user_id );
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
|
||||
|
||||
$stamp_id = self::add_stamp( $user_id, $activity_object['instrument'] );
|
||||
if ( ! $stamp_id ) {
|
||||
// Without a stamp there is nothing to consent with — don't send a dangling Accept.
|
||||
return;
|
||||
}
|
||||
|
||||
// Send minimal activity object back.
|
||||
$activity_object = \array_intersect_key(
|
||||
$activity_object,
|
||||
array(
|
||||
'id' => 1,
|
||||
'type' => 1,
|
||||
'actor' => 1,
|
||||
'object' => 1,
|
||||
'instrument' => 1,
|
||||
)
|
||||
);
|
||||
|
||||
$stamp_url = \add_query_arg(
|
||||
array(
|
||||
'actor' => $user_id,
|
||||
'stamp' => $stamp_id,
|
||||
),
|
||||
\home_url( '/' )
|
||||
);
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->set_type( 'Accept' );
|
||||
$activity->set_actor( $actor->get_id() );
|
||||
$activity->set_object( $activity_object );
|
||||
$activity->set_result( $stamp_url );
|
||||
$activity->add_to( object_to_uri( $activity_object['actor'] ) );
|
||||
|
||||
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or reuse) a feature stamp for an actor.
|
||||
*
|
||||
* Stamps for users live in user meta, with the umeta_id doubling as the
|
||||
* stamp ID. The blog actor has no users-table row, so each of its stamps is
|
||||
* stored in its own option row instead.
|
||||
*
|
||||
* Idempotent: an existing stamp for the same instrument is reused.
|
||||
*
|
||||
* @since 9.0.1
|
||||
*
|
||||
* @param int $user_id The local actor ID (0 for the blog actor).
|
||||
* @param string $instrument The instrument URI being stamped.
|
||||
* @return int|false The stamp ID, or false on failure.
|
||||
*/
|
||||
public static function add_stamp( $user_id, $instrument ) {
|
||||
if ( Actors::BLOG_USER_ID === $user_id ) {
|
||||
return self::add_blog_stamp( $instrument );
|
||||
}
|
||||
|
||||
$existing = \get_user_meta( $user_id, '_activitypub_featured_by', false );
|
||||
if ( \in_array( $instrument, (array) $existing, true ) ) {
|
||||
global $wpdb;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s AND meta_value = %s LIMIT 1",
|
||||
$user_id,
|
||||
'_activitypub_featured_by',
|
||||
$instrument
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return \add_user_meta( $user_id, '_activitypub_featured_by', $instrument );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or reuse) a feature stamp for the blog actor.
|
||||
*
|
||||
* Each blog stamp lives in its own option row (`{prefix}_{id}`), so a slot is
|
||||
* claimed atomically with `add_option()` — which only inserts when the row is
|
||||
* absent (see Scheduler\Statistics::send_annual_email()). Concurrent inbox
|
||||
* deliveries therefore can't mint the same ID or clobber each other's writes,
|
||||
* and no lock is needed. Stamps are never deleted, so the slots stay gap-free
|
||||
* and the walk stops at the first empty slot.
|
||||
*
|
||||
* @param string $instrument The instrument URI being stamped.
|
||||
* @return int|false The stamp ID, or false on failure.
|
||||
*/
|
||||
private static function add_blog_stamp( $instrument ) {
|
||||
$stamp_id = 1;
|
||||
|
||||
/*
|
||||
* Bounded far above any realistic blog stamp count, to guard against a
|
||||
* pathological object-cache state where a just-claimed slot never becomes
|
||||
* visible and the re-read would otherwise spin forever.
|
||||
*/
|
||||
for ( $attempt = 0; $attempt < 10000; $attempt++ ) {
|
||||
$key = self::BLOG_STAMPS_OPTION . '_' . $stamp_id;
|
||||
$current = \get_option( $key );
|
||||
|
||||
// Idempotent: an existing stamp for the same instrument is reused.
|
||||
if ( $current === $instrument ) {
|
||||
return $stamp_id;
|
||||
}
|
||||
|
||||
if ( false === $current ) {
|
||||
if ( \add_option( $key, $instrument, '', false ) ) {
|
||||
return $stamp_id;
|
||||
}
|
||||
|
||||
// A concurrent accept claimed this slot first; re-read it (no
|
||||
// increment) to see whether it took our instrument or another.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Slot holds a different instrument; try the next one.
|
||||
++$stamp_id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stamp ID for an actor to the stamped instrument URI.
|
||||
*
|
||||
* @since 9.0.1
|
||||
*
|
||||
* @param int $user_id The local actor ID (0 for the blog actor).
|
||||
* @param int $stamp_id The stamp ID.
|
||||
* @return string|null The instrument URI, or null if the stamp does not exist for this actor.
|
||||
*/
|
||||
public static function get_stamp( $user_id, $stamp_id ) {
|
||||
if ( Actors::BLOG_USER_ID === $user_id ) {
|
||||
$instrument = \get_option( self::BLOG_STAMPS_OPTION . '_' . (int) $stamp_id );
|
||||
|
||||
return false === $instrument ? null : $instrument;
|
||||
}
|
||||
|
||||
$meta = \get_metadata_by_mid( 'user', $stamp_id );
|
||||
if ( ! $meta || '_activitypub_featured_by' !== $meta->meta_key || (int) $meta->user_id !== $user_id ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $meta->meta_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Reject activity in response to the FeatureRequest.
|
||||
*
|
||||
* @param array $activity_object The activity object.
|
||||
* @param int $user_id The user ID.
|
||||
*/
|
||||
public static function queue_reject( $activity_object, $user_id ) {
|
||||
if ( ! user_can_activitypub( $user_id ) ) {
|
||||
$user_id = Actors::BLOG_USER_ID;
|
||||
}
|
||||
|
||||
$actor = Actors::get_by_id( $user_id );
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( isset( $activity_object['instrument'] ) ) {
|
||||
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
|
||||
}
|
||||
|
||||
// Only send minimal data.
|
||||
$activity_object = \array_intersect_key(
|
||||
$activity_object,
|
||||
array(
|
||||
'id' => 1,
|
||||
'type' => 1,
|
||||
'actor' => 1,
|
||||
'object' => 1,
|
||||
'instrument' => 1,
|
||||
)
|
||||
);
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->set_type( 'Reject' );
|
||||
$activity->set_actor( $actor->get_id() );
|
||||
$activity->set_object( $activity_object );
|
||||
$activity->add_to( object_to_uri( $activity_object['actor'] ) );
|
||||
|
||||
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the object on incoming FeatureRequest activities.
|
||||
*
|
||||
* @param bool $valid The current validation state.
|
||||
* @param string $param The object parameter name.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function validate_object( $valid, $param, $request ) {
|
||||
$activity = $request->get_json_params();
|
||||
|
||||
if ( empty( $activity['type'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( 'FeatureRequest' !== $activity['type'] ) {
|
||||
return $valid;
|
||||
}
|
||||
|
||||
if ( ! isset( $activity['actor'], $activity['object'], $activity['instrument'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $valid;
|
||||
}
|
||||
}
|
||||
@ -8,11 +8,14 @@
|
||||
namespace Activitypub\Handler;
|
||||
|
||||
use Activitypub\Activity\Activity;
|
||||
use Activitypub\Application;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
use Activitypub\Http;
|
||||
|
||||
use function Activitypub\add_to_outbox;
|
||||
use function Activitypub\object_to_uri;
|
||||
|
||||
/**
|
||||
* Handle Follow requests.
|
||||
@ -24,6 +27,9 @@ class Follow {
|
||||
public static function init() {
|
||||
\add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow' ), 10, 2 );
|
||||
\add_action( 'activitypub_handled_follow', array( self::class, 'queue_accept' ), 10, 4 );
|
||||
|
||||
// The Application actor cannot be followed; explicitly reject such Follows so they don't sit "pending" on the remote instance forever.
|
||||
\add_action( 'activitypub_inbox_shared_follow', array( self::class, 'reject_application_follow' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -36,11 +42,6 @@ class Follow {
|
||||
// Extract the user ID (follow requests are always for a single user).
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
|
||||
if ( Actors::APPLICATION_USER_ID === $user_id ) {
|
||||
self::queue_reject( $activity, $user_id );
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the actor already follows the user.
|
||||
$already_following = false;
|
||||
$remote_actor = Remote_Actors::get_by_uri( $activity['actor'] );
|
||||
@ -103,7 +104,7 @@ class Follow {
|
||||
$actor = $activity_object['actor'];
|
||||
|
||||
// Only send minimal data.
|
||||
$activity_object = array_intersect_key(
|
||||
$activity_object = \array_intersect_key(
|
||||
$activity_object,
|
||||
array(
|
||||
'id' => 1,
|
||||
@ -123,14 +124,43 @@ class Follow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Reject response.
|
||||
* Reject Follow requests aimed at the Application actor.
|
||||
*
|
||||
* @param array $activity The Activity array.
|
||||
* @param int $user_id The ID of the WordPress User.
|
||||
* The Application advertises `manuallyApprovesFollowers` but is not followable,
|
||||
* so an explicit Reject is the only way a remote follow request gets resolved.
|
||||
* The Reject is sent directly instead of through the Outbox, which only
|
||||
* dispatches for real actors, and is signed with the Application key.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @param array $activity The Follow activity data.
|
||||
* @param int[] $user_ids The local recipient IDs the inbox resolved.
|
||||
*/
|
||||
public static function queue_reject( $activity, $user_id ) {
|
||||
public static function reject_application_follow( $activity, $user_ids ) {
|
||||
// A resolved recipient means the Follow targets a real actor, not the Application.
|
||||
if ( ! empty( $user_ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( empty( $activity['object'] ) || ! Application::is_application_resource( object_to_uri( $activity['object'] ) ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = object_to_uri( $activity['actor'] );
|
||||
$remote_actor = Remote_Actors::fetch_by_uri( $actor );
|
||||
|
||||
if ( \is_wp_error( $remote_actor ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inbox = \get_post_meta( $remote_actor->ID, '_activitypub_inbox', true );
|
||||
|
||||
if ( ! $inbox ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only send minimal data.
|
||||
$origin_activity = array_intersect_key(
|
||||
$origin_activity = \array_intersect_key(
|
||||
$activity,
|
||||
array(
|
||||
'id' => 1,
|
||||
@ -140,12 +170,13 @@ class Follow {
|
||||
)
|
||||
);
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->set_type( 'Reject' );
|
||||
$activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
|
||||
$activity->set_object( $origin_activity );
|
||||
$activity->set_to( array( $origin_activity['actor'] ) );
|
||||
$reject = new Activity();
|
||||
$reject->set_type( 'Reject' );
|
||||
$reject->set_id( Application::get_id() . '#reject-' . \md5( \wp_json_encode( $origin_activity ) ) );
|
||||
$reject->set_actor( Application::get_id() );
|
||||
$reject->set_object( $origin_activity );
|
||||
$reject->set_to( array( $actor ) );
|
||||
|
||||
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
|
||||
Http::post( $inbox, $reject->to_json(), null );
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,7 +41,13 @@ class Like {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = Comment::object_id_to_comment( esc_url_raw( $url ) );
|
||||
/*
|
||||
* Dedupe on the Like activity ID (its source_id), not the liked object, so repeat
|
||||
* deliveries stay idempotent even when a mangled author name defeats WordPress's own
|
||||
* duplicate check. Match any status, so a like that was marked as spam or trashed
|
||||
* still counts as seen. See https://github.com/Automattic/wordpress-activitypub/issues/3215.
|
||||
*/
|
||||
$exists = Comment::object_id_to_comment( \esc_url_raw( object_to_uri( $like ) ), array( 'status' => 'any' ) );
|
||||
if ( $exists ) {
|
||||
return;
|
||||
}
|
||||
@ -49,9 +55,9 @@ class Like {
|
||||
$success = false;
|
||||
$result = Interactions::add_reaction( $like );
|
||||
|
||||
if ( $result && ! is_wp_error( $result ) ) {
|
||||
if ( $result && ! \is_wp_error( $result ) ) {
|
||||
$success = true;
|
||||
$result = get_comment( $result );
|
||||
$result = \get_comment( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -62,15 +62,17 @@ class Move {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->update(
|
||||
$wpdb->posts,
|
||||
array( 'guid' => sanitize_url( $target_uri ) ),
|
||||
array( 'ID' => sanitize_key( $origin_object->ID ) )
|
||||
array( 'guid' => \sanitize_url( $target_uri ) ),
|
||||
array( 'ID' => \sanitize_key( $origin_object->ID ) )
|
||||
);
|
||||
|
||||
// Clear the cache.
|
||||
\wp_cache_delete( $origin_object->ID, 'posts' );
|
||||
|
||||
$success = true;
|
||||
$result = Remote_Actors::upsert( $target_json );
|
||||
|
||||
// get_remote_object() already self-confirmed the target, so it is safe to cache.
|
||||
$result = Remote_Actors::upsert( $target_json );
|
||||
}
|
||||
|
||||
// If both the target and origin are followed, merge them.
|
||||
@ -177,7 +179,7 @@ class Move {
|
||||
}
|
||||
|
||||
// Collect all possible origin identifiers (id, url, webfinger).
|
||||
$origin_ids = array_filter(
|
||||
$origin_ids = \array_filter(
|
||||
array(
|
||||
$origin_object['id'] ?? null,
|
||||
$origin_object['url'] ?? null,
|
||||
@ -186,7 +188,7 @@ class Move {
|
||||
);
|
||||
|
||||
// Check if any origin identifier is in the alsoKnownAs property of the target.
|
||||
if ( ! array_intersect( $origin_ids, $also_known_as ) ) {
|
||||
if ( ! \array_intersect( $origin_ids, $also_known_as ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -99,7 +99,7 @@ class Quote_Request {
|
||||
* @param string $type The type of the activity.
|
||||
*/
|
||||
public static function handle_blocked_request( $activity, $user_ids, $type ) {
|
||||
if ( ! in_array( strtolower( $type ), array( 'quoterequest', 'quote_request' ), true ) ) {
|
||||
if ( ! \in_array( \strtolower( $type ), array( 'quoterequest', 'quote_request' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -218,7 +218,7 @@ class Quote_Request {
|
||||
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
|
||||
|
||||
$post_meta = \get_post_meta( $post_id, '_activitypub_quoted_by', false );
|
||||
if ( in_array( $activity_object['instrument'], $post_meta, true ) ) {
|
||||
if ( \in_array( $activity_object['instrument'], $post_meta, true ) ) {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
@ -235,7 +235,7 @@ class Quote_Request {
|
||||
}
|
||||
|
||||
// Only send minimal data.
|
||||
$activity_object = array_intersect_key(
|
||||
$activity_object = \array_intersect_key(
|
||||
$activity_object,
|
||||
array(
|
||||
'id' => 1,
|
||||
@ -287,7 +287,7 @@ class Quote_Request {
|
||||
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
|
||||
|
||||
// Only send minimal data.
|
||||
$activity_object = array_intersect_key(
|
||||
$activity_object = \array_intersect_key(
|
||||
$activity_object,
|
||||
array(
|
||||
'id' => 1,
|
||||
|
||||
@ -56,14 +56,24 @@ class Reject {
|
||||
* @param int|int[] $user_ids The user ID(s).
|
||||
*/
|
||||
private static function reject_follow( $reject, $user_ids ) {
|
||||
$actor_uri = $reject['object']['actor'] ?? '';
|
||||
$actor_post = Remote_Actors::get_by_uri( object_to_uri( $actor_uri ) );
|
||||
/*
|
||||
* For a Follow Reject, the sender must be the actor that was followed.
|
||||
* Without this, a signed Reject from one actor could cancel a Follow that
|
||||
* targeted another actor by referencing that pending Follow's outbox GUID.
|
||||
*/
|
||||
$reject_actor = object_to_uri( $reject['actor'] ?? '' );
|
||||
$followed_actor = object_to_uri( $reject['object']['object'] ?? '' );
|
||||
if ( ! $reject_actor || ! $followed_actor || $reject_actor !== $followed_actor ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor_post = Remote_Actors::get_by_uri( $followed_actor );
|
||||
|
||||
if ( \is_wp_error( $actor_post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = is_array( $user_ids ) ? reset( $user_ids ) : $user_ids;
|
||||
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
|
||||
$result = Following::reject( $actor_post, $user_id );
|
||||
$success = ! \is_wp_error( $result );
|
||||
|
||||
@ -102,6 +112,14 @@ class Reject {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! \is_array( $activity['object'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! isset( $activity['object']['id'], $activity['object']['type'], $activity['object']['actor'], $activity['object']['object'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $valid;
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,7 +31,24 @@ class Undo {
|
||||
*/
|
||||
public static function handle_undo( $activity, $user_ids ) {
|
||||
$success = false;
|
||||
$result = Inbox_Collection::undo( object_to_uri( $activity['object'] ) );
|
||||
|
||||
/*
|
||||
* Resolve the sender so Inbox::undo() can verify ownership. A genuinely absent actor
|
||||
* maps to null (no ownership check, for programmatic callers), but an actor that is
|
||||
* present yet unparseable must be rejected rather than skipping the check — passing
|
||||
* null there would re-open the undo-by-id attack.
|
||||
*/
|
||||
$actor = isset( $activity['actor'] ) ? object_to_uri( $activity['actor'] ) : null;
|
||||
|
||||
if ( isset( $activity['actor'] ) && empty( $actor ) ) {
|
||||
$result = new \WP_Error(
|
||||
'activitypub_undo_invalid_actor',
|
||||
\__( 'The Undo activity has an invalid actor.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
} else {
|
||||
$result = Inbox_Collection::undo( object_to_uri( $activity['object'] ), $actor );
|
||||
}
|
||||
|
||||
if ( $result && ! \is_wp_error( $result ) ) {
|
||||
$success = true;
|
||||
|
||||
@ -13,6 +13,7 @@ use Activitypub\Collection\Remote_Posts;
|
||||
use Activitypub\Http;
|
||||
|
||||
use function Activitypub\is_activity_reply;
|
||||
use function Activitypub\object_to_uri;
|
||||
|
||||
/**
|
||||
* Handle Update requests.
|
||||
@ -29,7 +30,7 @@ class Update {
|
||||
* Handle "Update" requests.
|
||||
*
|
||||
* @param array $activity The Activity object.
|
||||
* @param int[] $user_ids The user IDs. Always null for Update activities.
|
||||
* @param int[] $user_ids Local recipient user IDs (followers and addressed local actors); may be empty.
|
||||
* @param \Activitypub\Activity\Activity $activity_object The activity object. Default null.
|
||||
*/
|
||||
public static function handle_update( $activity, $user_ids, $activity_object ) {
|
||||
@ -78,7 +79,7 @@ class Update {
|
||||
* Update an Object.
|
||||
*
|
||||
* @param array $activity The Activity object.
|
||||
* @param int[]|null $user_ids The user IDs. Always null for Update activities.
|
||||
* @param int[]|null $user_ids Local recipient user IDs (followers and addressed local actors); may be empty.
|
||||
* @param \Activitypub\Activity\Activity $activity_object The activity object. Default null.
|
||||
*/
|
||||
public static function update_object( $activity, $user_ids, $activity_object ) {
|
||||
@ -91,6 +92,10 @@ class Update {
|
||||
|
||||
if ( false === $comment_data ) {
|
||||
$updated = false;
|
||||
} elseif ( \is_wp_error( $comment_data ) ) {
|
||||
// Handled but rejected (e.g. a foreign actor): keep the failure so the
|
||||
// success flag stays false and the Create fallback is not triggered.
|
||||
$result = $comment_data;
|
||||
} elseif ( ! empty( $comment_data['comment_ID'] ) ) {
|
||||
$result = \get_comment( $comment_data['comment_ID'] );
|
||||
}
|
||||
@ -124,7 +129,7 @@ class Update {
|
||||
* Update an Actor.
|
||||
*
|
||||
* @param array $activity The Activity object.
|
||||
* @param int[]|null $user_ids The user IDs. Always null for Update activities.
|
||||
* @param int[]|null $user_ids Local recipient user IDs (followers and addressed local actors); may be empty.
|
||||
*/
|
||||
public static function update_actor( $activity, $user_ids ) {
|
||||
/*
|
||||
@ -148,10 +153,16 @@ class Update {
|
||||
}
|
||||
}
|
||||
|
||||
if ( \is_array( $actor ) && isset( $actor['id'] ) ) {
|
||||
/*
|
||||
* An actor may only update itself. Bind the updated object to the activity
|
||||
* actor (the same constraint the Delete handler enforces) so a remote server
|
||||
* cannot overwrite another host's cached actor by sending an Update whose
|
||||
* object.id points at a victim actor.
|
||||
*/
|
||||
if ( \is_array( $actor ) && isset( $actor['id'] ) && object_to_uri( $actor ) === object_to_uri( $activity['actor'] ) ) {
|
||||
$state = Remote_Actors::upsert( $actor );
|
||||
} else {
|
||||
$state = new \WP_Error( 'activitypub_update_failed', 'Update failed: missing or invalid actor object in Update activity' );
|
||||
$state = new \WP_Error( 'activitypub_update_failed', \__( 'Update failed: missing, invalid, or unauthorized actor object in Update activity.', 'activitypub' ) );
|
||||
$actor = array();
|
||||
}
|
||||
|
||||
|
||||
@ -93,7 +93,7 @@ class Arrive {
|
||||
$location_name = self::get_location_name( $location );
|
||||
|
||||
$title = $location_name
|
||||
? sprintf(
|
||||
? \sprintf(
|
||||
/* translators: %s: location name */
|
||||
\__( 'Checked in at %s', 'activitypub' ),
|
||||
$location_name
|
||||
|
||||
@ -2,13 +2,16 @@
|
||||
/**
|
||||
* Application model file.
|
||||
*
|
||||
* @deprecated Use {@see \Activitypub\Application} for key management
|
||||
* and {@see \Activitypub\Rest\Application_Controller} for the REST endpoint.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Model;
|
||||
|
||||
use Activitypub\Activity\Actor;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Application as Application_Utility;
|
||||
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
use function Activitypub\home_host;
|
||||
@ -16,7 +19,7 @@ use function Activitypub\home_host;
|
||||
/**
|
||||
* Application class.
|
||||
*
|
||||
* @method int get__id() Gets the internal user ID for the application (always returns APPLICATION_USER_ID).
|
||||
* @deprecated Use {@see \Activitypub\Application} instead.
|
||||
*/
|
||||
class Application extends Actor {
|
||||
/**
|
||||
@ -24,7 +27,7 @@ class Application extends Actor {
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $_id = Actors::APPLICATION_USER_ID; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
|
||||
protected $_id = -1; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
|
||||
|
||||
/**
|
||||
* Whether the Application is discoverable.
|
||||
@ -101,13 +104,20 @@ class Application extends Actor {
|
||||
*/
|
||||
protected $preferred_username = 'application';
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
\_deprecated_class( __CLASS__, '9.1.0', 'Activitypub\Application' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ID of the Application.
|
||||
*
|
||||
* @return string The ID of the Application.
|
||||
*/
|
||||
public function get_id() {
|
||||
return get_rest_url_by_path( 'application' );
|
||||
return Application_Utility::get_id();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -134,32 +144,7 @@ class Application extends Actor {
|
||||
* @return string[] The User-Icon.
|
||||
*/
|
||||
public function get_icon() {
|
||||
// Try site icon first.
|
||||
$icon_id = get_option( 'site_icon' );
|
||||
|
||||
// Try custom logo second.
|
||||
if ( ! $icon_id ) {
|
||||
$icon_id = get_theme_mod( 'custom_logo' );
|
||||
}
|
||||
|
||||
$icon_url = false;
|
||||
|
||||
if ( $icon_id ) {
|
||||
$icon = wp_get_attachment_image_src( $icon_id, 'full' );
|
||||
if ( $icon ) {
|
||||
$icon_url = $icon[0];
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $icon_url ) {
|
||||
// Fallback to default icon.
|
||||
$icon_url = plugins_url( '/assets/img/wp-logo.png', ACTIVITYPUB_PLUGIN_FILE );
|
||||
}
|
||||
|
||||
return array(
|
||||
'type' => 'Image',
|
||||
'url' => esc_url( $icon_url ),
|
||||
);
|
||||
return Application_Utility::get_icon();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -171,7 +156,7 @@ class Application extends Actor {
|
||||
if ( \has_header_image() ) {
|
||||
return array(
|
||||
'type' => 'Image',
|
||||
'url' => esc_url( \get_header_image() ),
|
||||
'url' => \esc_url_raw( \get_header_image() ),
|
||||
);
|
||||
}
|
||||
|
||||
@ -184,21 +169,7 @@ class Application extends Actor {
|
||||
* @return string The published date.
|
||||
*/
|
||||
public function get_published() {
|
||||
$first_post = new \WP_Query(
|
||||
array(
|
||||
'orderby' => 'date',
|
||||
'order' => 'ASC',
|
||||
'number' => 1,
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! empty( $first_post->posts[0] ) ) {
|
||||
$time = \strtotime( $first_post->posts[0]->post_date_gmt );
|
||||
} else {
|
||||
$time = \time();
|
||||
}
|
||||
|
||||
return \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, $time );
|
||||
return Application_Utility::get_published();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -207,7 +178,7 @@ class Application extends Actor {
|
||||
* @return string The Inbox-Endpoint.
|
||||
*/
|
||||
public function get_inbox() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/inbox', $this->get__id() ) );
|
||||
return get_rest_url_by_path( 'inbox' );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -216,7 +187,7 @@ class Application extends Actor {
|
||||
* @return string The Outbox-Endpoint.
|
||||
*/
|
||||
public function get_outbox() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/outbox', $this->get__id() ) );
|
||||
return get_rest_url_by_path( 'application/outbox' );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -235,9 +206,9 @@ class Application extends Actor {
|
||||
*/
|
||||
public function get_public_key() {
|
||||
return array(
|
||||
'id' => $this->get_id() . '#main-key',
|
||||
'id' => Application_Utility::get_key_id(),
|
||||
'owner' => $this->get_id(),
|
||||
'publicKeyPem' => Actors::get_public_key( Actors::APPLICATION_USER_ID ),
|
||||
'publicKeyPem' => Application_Utility::get_public_key(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -247,9 +218,9 @@ class Application extends Actor {
|
||||
* @return string The User description.
|
||||
*/
|
||||
public function get_summary() {
|
||||
return sprintf(
|
||||
return \sprintf(
|
||||
/* translators: %s: Domain of the site */
|
||||
__( 'This is the Application Actor for %s.', 'activitypub' ),
|
||||
\__( 'This is the Application Actor for %s.', 'activitypub' ),
|
||||
home_host()
|
||||
);
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ use function Activitypub\get_attribution_domains;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
use function Activitypub\is_blog_public;
|
||||
use function Activitypub\is_single_user;
|
||||
use function Activitypub\site_icon;
|
||||
|
||||
/**
|
||||
* Blog class.
|
||||
@ -93,7 +94,7 @@ class Blog extends Actor {
|
||||
$permalink = \get_option( 'activitypub_use_permalink_as_id_for_blog', false );
|
||||
|
||||
if ( $permalink ) {
|
||||
return \esc_url( \home_url( '/@' . $this->get_preferred_username() ) );
|
||||
return \esc_url_raw( \home_url( '/@' . $this->get_preferred_username() ) );
|
||||
}
|
||||
|
||||
return \add_query_arg( 'author', $this->_id, \home_url( '/' ) );
|
||||
@ -169,7 +170,7 @@ class Blog extends Actor {
|
||||
* @return string The User-Url.
|
||||
*/
|
||||
public function get_alternate_url() {
|
||||
return \esc_url( \trailingslashit( get_home_url() ) );
|
||||
return \esc_url_raw( \trailingslashit( \get_home_url() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -191,7 +192,7 @@ class Blog extends Actor {
|
||||
*
|
||||
* @param string $host The default username (site's host name).
|
||||
*/
|
||||
return apply_filters( 'activitypub_default_blog_username', $host );
|
||||
return \apply_filters( 'activitypub_default_blog_username', $host );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -215,32 +216,7 @@ class Blog extends Actor {
|
||||
* @return string[] The User icon.
|
||||
*/
|
||||
public function get_icon() {
|
||||
// Try site_logo, falling back to site_icon, first.
|
||||
$icon_id = get_option( 'site_icon' );
|
||||
|
||||
// Try custom logo second.
|
||||
if ( ! $icon_id ) {
|
||||
$icon_id = get_theme_mod( 'custom_logo' );
|
||||
}
|
||||
|
||||
$icon_url = false;
|
||||
|
||||
if ( $icon_id ) {
|
||||
$icon = wp_get_attachment_image_src( $icon_id, 'full' );
|
||||
if ( $icon ) {
|
||||
$icon_url = $icon[0];
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $icon_url ) {
|
||||
// Fallback to default icon.
|
||||
$icon_url = plugins_url( '/assets/img/wp-logo.png', ACTIVITYPUB_PLUGIN_FILE );
|
||||
}
|
||||
|
||||
return array(
|
||||
'type' => 'Image',
|
||||
'url' => esc_url( $icon_url ),
|
||||
);
|
||||
return site_icon();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -249,7 +225,7 @@ class Blog extends Actor {
|
||||
* @return string[]|null The User-Header-Image.
|
||||
*/
|
||||
public function get_image() {
|
||||
$header_image = get_option( 'activitypub_header_image' );
|
||||
$header_image = \get_option( 'activitypub_header_image' );
|
||||
$image_url = null;
|
||||
|
||||
if ( $header_image ) {
|
||||
@ -263,7 +239,7 @@ class Blog extends Actor {
|
||||
if ( $image_url ) {
|
||||
return array(
|
||||
'type' => 'Image',
|
||||
'url' => esc_url( $image_url ),
|
||||
'url' => \esc_url_raw( $image_url ),
|
||||
);
|
||||
}
|
||||
|
||||
@ -383,7 +359,7 @@ class Blog extends Actor {
|
||||
* @return string The Inbox-Endpoint.
|
||||
*/
|
||||
public function get_inbox() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/inbox', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/inbox', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -392,7 +368,7 @@ class Blog extends Actor {
|
||||
* @return string The Outbox-Endpoint.
|
||||
*/
|
||||
public function get_outbox() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/outbox', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/outbox', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -401,7 +377,7 @@ class Blog extends Actor {
|
||||
* @return string The Followers-Endpoint.
|
||||
*/
|
||||
public function get_followers() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/followers', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/followers', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -410,7 +386,7 @@ class Blog extends Actor {
|
||||
* @return string The Following-Endpoint.
|
||||
*/
|
||||
public function get_following() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/following', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/following', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -419,7 +395,7 @@ class Blog extends Actor {
|
||||
* @return string[]|null The endpoints.
|
||||
*/
|
||||
public function get_endpoints() {
|
||||
return array(
|
||||
$endpoints = array(
|
||||
'sharedInbox' => get_rest_url_by_path( 'inbox' ),
|
||||
'oauthAuthorizationEndpoint' => get_rest_url_by_path( 'oauth/authorize' ),
|
||||
'oauthTokenEndpoint' => get_rest_url_by_path( 'oauth/token' ),
|
||||
@ -427,6 +403,16 @@ class Blog extends Actor {
|
||||
'proxyUrl' => get_rest_url_by_path( 'proxy' ),
|
||||
'proxyEventStream' => get_rest_url_by_path( 'proxy/stream' ),
|
||||
);
|
||||
|
||||
if ( \get_option( 'activitypub_api', false ) ) {
|
||||
/*
|
||||
* RFC 6570 template. add_query_arg() picks the ?/& separator (plain permalinks already
|
||||
* carry a query string) and does not encode values, so the {q} placeholder stays intact.
|
||||
*/
|
||||
$endpoints['actorAutocomplete'] = \add_query_arg( 'q', '{q}', get_rest_url_by_path( 'actors/autocomplete' ) );
|
||||
}
|
||||
|
||||
return $endpoints;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -446,7 +432,7 @@ class Blog extends Actor {
|
||||
* @return string The Liked endpoint.
|
||||
*/
|
||||
public function get_liked() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/liked', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/liked', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -455,7 +441,7 @@ class Blog extends Actor {
|
||||
* @return string The Featured-Endpoint.
|
||||
*/
|
||||
public function get_featured() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/collections/featured', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -464,7 +450,7 @@ class Blog extends Actor {
|
||||
* @return string The Featured-Tags-Endpoint.
|
||||
*/
|
||||
public function get_featured_tags() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/collections/tags', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/collections/tags', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -507,7 +493,7 @@ class Blog extends Actor {
|
||||
* @return bool True if the attribute was updated, false otherwise.
|
||||
*/
|
||||
public function update_icon( $value ) {
|
||||
if ( ! wp_attachment_is_image( $value ) ) {
|
||||
if ( ! \wp_attachment_is_image( $value ) ) {
|
||||
return false;
|
||||
}
|
||||
return \update_option( 'site_icon', $value );
|
||||
@ -520,7 +506,7 @@ class Blog extends Actor {
|
||||
* @return bool True if the attribute was updated, false otherwise.
|
||||
*/
|
||||
public function update_header( $value ) {
|
||||
if ( ! wp_attachment_is_image( $value ) ) {
|
||||
if ( ! \wp_attachment_is_image( $value ) ) {
|
||||
return false;
|
||||
}
|
||||
return \update_option( 'activitypub_header_image', $value );
|
||||
@ -542,7 +528,7 @@ class Blog extends Actor {
|
||||
'number' => 10,
|
||||
);
|
||||
|
||||
$tags = get_tags( $args );
|
||||
$tags = \get_tags( $args );
|
||||
|
||||
foreach ( $tags as $tag ) {
|
||||
$hashtags[] = array(
|
||||
@ -586,9 +572,9 @@ class Blog extends Actor {
|
||||
$this->get_alternate_url(),
|
||||
);
|
||||
|
||||
$also_known_as = array_merge( $also_known_as, \get_option( 'activitypub_blog_user_also_known_as', array() ) );
|
||||
$also_known_as = \array_merge( $also_known_as, \get_option( 'activitypub_blog_user_also_known_as', array() ) );
|
||||
|
||||
return array_unique( $also_known_as );
|
||||
return \array_unique( $also_known_as );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -601,4 +587,50 @@ class Blog extends Actor {
|
||||
|
||||
return $moved_to && $moved_to !== $this->get_id() ? $moved_to : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actor-level interaction policy.
|
||||
*
|
||||
* Overrides the magic property accessor on Base_Object so that we always
|
||||
* compute the policy from the current site setting rather than returning a
|
||||
* cached property value. Currently only emits `canFeature` (FEP-7aa9).
|
||||
* Driven by the site option `activitypub_default_feature_policy` and
|
||||
* defaults to denying all featured-collection requests, in line with
|
||||
* FEP-7aa9's "absence of policy = no consent" rule.
|
||||
*
|
||||
* @see https://w3id.org/fep/7aa9
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_interaction_policy() {
|
||||
$policy = array( 'canFeature' => $this->build_can_feature_policy() );
|
||||
|
||||
// Merge with an explicitly set interaction policy, if any.
|
||||
if ( $this->interaction_policy ) {
|
||||
$policy = \array_merge( (array) $this->interaction_policy, $policy );
|
||||
}
|
||||
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `canFeature` policy array from the site option.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function build_can_feature_policy() {
|
||||
$policy = \get_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ME );
|
||||
|
||||
switch ( $policy ) {
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_ANYONE:
|
||||
return array( 'automaticApproval' => array( 'https://www.w3.org/ns/activitystreams#Public' ) );
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS:
|
||||
return array( 'automaticApproval' => array( $this->get_followers() ) );
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_ME:
|
||||
default:
|
||||
return array( 'automaticApproval' => array( $this->get_id() ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,359 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Follower class file.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Model;
|
||||
|
||||
use Activitypub\Activity\Actor;
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
use function Activitypub\extract_name_from_uri;
|
||||
|
||||
/**
|
||||
* ActivityPub Follower Class.
|
||||
*
|
||||
* This Object represents a single Follower.
|
||||
* There is no direct reference to a WordPress User here.
|
||||
*
|
||||
* @author Matt Wiebe
|
||||
* @author Matthias Pfefferle
|
||||
*
|
||||
* @deprecated 7.0.0
|
||||
* @see https://www.w3.org/TR/activitypub/#follow-activity-inbox
|
||||
*
|
||||
* @method int get__id() Gets the post ID of the follower record.
|
||||
* @method string[]|null get_image() Gets the follower's profile image data.
|
||||
* @method string|null get_inbox() Gets the follower's ActivityPub inbox URL.
|
||||
* @method string[]|null get_endpoints() Gets the follower's ActivityPub endpoints.
|
||||
*
|
||||
* @method Follower set__id( int $id ) Sets the post ID of the follower record.
|
||||
* @method Follower set_id( string $guid ) Sets the follower's GUID.
|
||||
* @method Follower set_name( string $name ) Sets the follower's display name.
|
||||
* @method Follower set_summary( string $summary ) Sets the follower's bio/summary.
|
||||
* @method Follower set_published( string $datetime ) Sets the follower's published datetime in ISO 8601 format.
|
||||
* @method Follower set_updated( string $datetime ) Sets the follower's last updated datetime in ISO 8601 format.
|
||||
*/
|
||||
class Follower extends Actor {
|
||||
/**
|
||||
* The complete Remote-Profile of the Follower.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $_id; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @deprecated Use Actor instead.
|
||||
*/
|
||||
public function __construct() {
|
||||
\_deprecated_class( __CLASS__, '7.0.0', Actor::class );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the errors.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function get_errors() {
|
||||
return Remote_Actors::get_errors( $this->_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the errors for the current Follower.
|
||||
*
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public function clear_errors() {
|
||||
return Remote_Actors::clear_errors( $this->_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Summary.
|
||||
*
|
||||
* @return string The Summary.
|
||||
*/
|
||||
public function get_summary() {
|
||||
if ( isset( $this->summary ) ) {
|
||||
return $this->summary;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter for URL attribute.
|
||||
*
|
||||
* Falls back to ID, if no URL is set. This is relevant for
|
||||
* Platforms like Lemmy, where the ID is the URL.
|
||||
*
|
||||
* @return string The URL.
|
||||
*/
|
||||
public function get_url() {
|
||||
if ( $this->url ) {
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset (delete) all errors.
|
||||
*
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public function reset_errors() {
|
||||
return Remote_Actors::clear_errors( $this->_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the errors.
|
||||
*
|
||||
* @return int The number of errors.
|
||||
*/
|
||||
public function count_errors() {
|
||||
return Remote_Actors::count_errors( $this->_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the latest error message.
|
||||
*
|
||||
* @return string The error message.
|
||||
*/
|
||||
public function get_latest_error_message() {
|
||||
$errors = $this->get_errors();
|
||||
|
||||
if ( \is_array( $errors ) && ! empty( $errors ) ) {
|
||||
return \reset( $errors );
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current Follower object.
|
||||
*/
|
||||
public function update() {
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the current Follower object.
|
||||
*
|
||||
* @return boolean True if the verification was successful.
|
||||
*/
|
||||
public function is_valid() {
|
||||
// The minimum required attributes.
|
||||
$required_attributes = array(
|
||||
'id',
|
||||
'preferredUsername',
|
||||
'inbox',
|
||||
'publicKey',
|
||||
'publicKeyPem',
|
||||
);
|
||||
|
||||
foreach ( $required_attributes as $attribute ) {
|
||||
if ( ! $this->get( $attribute ) ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current Follower object.
|
||||
*
|
||||
* @return int|\WP_Error The post ID or an WP_Error.
|
||||
*/
|
||||
public function save() {
|
||||
if ( ! $this->is_valid() ) {
|
||||
return new \WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$id = Remote_Actors::upsert( $this );
|
||||
if ( \is_wp_error( $id ) ) {
|
||||
return $id;
|
||||
}
|
||||
|
||||
$this->set__id( $id );
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the current Follower object.
|
||||
*
|
||||
* @return int|\WP_Error The post ID or an WP_Error.
|
||||
*/
|
||||
public function upsert() {
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current Follower object.
|
||||
*
|
||||
* Beware that this os deleting a Follower for ALL users!!!
|
||||
*
|
||||
* To delete only the User connection (unfollow)
|
||||
*
|
||||
* @see \Activitypub\Rest\Followers::remove_follower()
|
||||
*/
|
||||
public function delete() {
|
||||
Followers::remove_follower( $this->_id, $this->get_id() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon.
|
||||
*
|
||||
* Sets a fallback to better handle API and HTML outputs.
|
||||
*
|
||||
* @return string[] The icon.
|
||||
*/
|
||||
public function get_icon() {
|
||||
if ( isset( $this->icon['url'] ) ) {
|
||||
return $this->icon;
|
||||
}
|
||||
|
||||
return array(
|
||||
'type' => 'Image',
|
||||
'mediaType' => 'image/jpeg',
|
||||
'url' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Name.
|
||||
*
|
||||
* Tries to extract a name from the URL or ID if not set.
|
||||
*
|
||||
* @return string The name.
|
||||
*/
|
||||
public function get_name() {
|
||||
if ( $this->name ) {
|
||||
return $this->name;
|
||||
} elseif ( $this->preferred_username ) {
|
||||
return $this->preferred_username;
|
||||
}
|
||||
|
||||
return $this->extract_name_from_uri();
|
||||
}
|
||||
|
||||
/**
|
||||
* The preferred Username.
|
||||
*
|
||||
* Tries to extract a name from the URL or ID if not set.
|
||||
*
|
||||
* @return string The preferred Username.
|
||||
*/
|
||||
public function get_preferred_username() {
|
||||
if ( $this->preferred_username ) {
|
||||
return $this->preferred_username;
|
||||
}
|
||||
|
||||
return $this->extract_name_from_uri();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Icon URL (Avatar).
|
||||
*
|
||||
* @return string The URL to the Avatar.
|
||||
*/
|
||||
public function get_icon_url() {
|
||||
$icon = $this->get_icon();
|
||||
|
||||
if ( ! $icon ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( \is_array( $icon ) ) {
|
||||
return $icon['url'];
|
||||
}
|
||||
|
||||
return $icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Icon URL (Avatar).
|
||||
*
|
||||
* @return string The URL to the Avatar.
|
||||
*/
|
||||
public function get_image_url() {
|
||||
$image = $this->get_image();
|
||||
|
||||
if ( ! $image ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( \is_array( $image ) ) {
|
||||
return $image['url'];
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the shared inbox, with a fallback to the inbox.
|
||||
*
|
||||
* @return string|null The URL to the shared inbox, the inbox or null.
|
||||
*/
|
||||
public function get_shared_inbox() {
|
||||
if ( ! empty( $this->get_endpoints()['sharedInbox'] ) ) {
|
||||
return $this->get_endpoints()['sharedInbox'];
|
||||
} elseif ( ! empty( $this->get_inbox() ) ) {
|
||||
return $this->get_inbox();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Custom-Post-Type input to an Activitypub\Model\Follower.
|
||||
*
|
||||
* @param \WP_Post $post The post object.
|
||||
* @return Follower|false The Follower object or false on failure.
|
||||
*/
|
||||
public static function init_from_cpt( $post ) {
|
||||
if ( empty( $post->post_content ) ) {
|
||||
$json = \get_post_meta( $post->ID, '_activitypub_actor_json', true );
|
||||
} else {
|
||||
$json = $post->post_content;
|
||||
}
|
||||
|
||||
/* @var Follower $object Follower object. */
|
||||
$object = self::init_from_json( $json );
|
||||
|
||||
if ( \is_wp_error( $object ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$object->set__id( $post->ID );
|
||||
$object->set_id( $post->guid );
|
||||
$object->set_name( $post->post_title );
|
||||
$object->set_summary( $post->post_excerpt );
|
||||
$object->set_published( gmdate( 'Y-m-d H:i:s', strtotime( $post->post_date ) ) );
|
||||
$object->set_updated( gmdate( 'Y-m-d H:i:s', strtotime( $post->post_modified ) ) );
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer a shortname from the Actor ID or URL. Used only for fallbacks,
|
||||
* we will try to use what's supplied.
|
||||
*
|
||||
* @return string Hopefully the name of the Follower.
|
||||
*/
|
||||
protected function extract_name_from_uri() {
|
||||
// prefer the URL, but fall back to the ID.
|
||||
if ( $this->url ) {
|
||||
$uri = $this->url;
|
||||
} else {
|
||||
$uri = $this->id;
|
||||
}
|
||||
|
||||
return extract_name_from_uri( $uri );
|
||||
}
|
||||
}
|
||||
@ -140,9 +140,9 @@ class User extends Actor {
|
||||
* @return string The User description.
|
||||
*/
|
||||
public function get_summary() {
|
||||
$description = get_user_option( 'activitypub_description', $this->_id );
|
||||
$description = \get_user_option( 'activitypub_description', $this->_id );
|
||||
if ( empty( $description ) ) {
|
||||
$description = get_user_meta( $this->_id, 'description', true );
|
||||
$description = \get_user_meta( $this->_id, 'description', true );
|
||||
}
|
||||
return \wpautop( \wp_kses( $description, 'default' ) );
|
||||
}
|
||||
@ -153,7 +153,7 @@ class User extends Actor {
|
||||
* @return string The User url.
|
||||
*/
|
||||
public function get_url() {
|
||||
return \esc_url( \get_author_posts_url( $this->_id ) );
|
||||
return \esc_url_raw( \get_author_posts_url( $this->_id ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -162,7 +162,7 @@ class User extends Actor {
|
||||
* @return string The User URL with @-Prefix for the username.
|
||||
*/
|
||||
public function get_alternate_url() {
|
||||
return \esc_url( \trailingslashit( get_home_url() ) . '@' . $this->get_preferred_username() );
|
||||
return \esc_url_raw( \trailingslashit( \get_home_url() ) . '@' . $this->get_preferred_username() );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -188,14 +188,14 @@ class User extends Actor {
|
||||
*/
|
||||
public function get_icon() {
|
||||
$icon = \get_user_option( 'activitypub_icon', $this->_id );
|
||||
if ( false !== $icon && wp_attachment_is_image( $icon ) ) {
|
||||
if ( false !== $icon && \wp_attachment_is_image( $icon ) ) {
|
||||
return array(
|
||||
'type' => 'Image',
|
||||
'url' => esc_url( wp_get_attachment_url( $icon ) ),
|
||||
'url' => \esc_url_raw( \wp_get_attachment_url( $icon ) ),
|
||||
);
|
||||
}
|
||||
|
||||
$icon = \esc_url(
|
||||
$icon = \esc_url_raw(
|
||||
\get_avatar_url(
|
||||
$this->_id,
|
||||
array( 'size' => 120 )
|
||||
@ -214,7 +214,7 @@ class User extends Actor {
|
||||
* @return string[]|null The header image.
|
||||
*/
|
||||
public function get_image() {
|
||||
$header_image = get_user_option( 'activitypub_header_image', $this->_id );
|
||||
$header_image = \get_user_option( 'activitypub_header_image', $this->_id );
|
||||
$image_url = null;
|
||||
|
||||
if ( ! $header_image && \has_header_image() ) {
|
||||
@ -228,7 +228,7 @@ class User extends Actor {
|
||||
if ( $image_url ) {
|
||||
return array(
|
||||
'type' => 'Image',
|
||||
'url' => esc_url( $image_url ),
|
||||
'url' => \esc_url_raw( $image_url ),
|
||||
);
|
||||
}
|
||||
|
||||
@ -263,7 +263,7 @@ class User extends Actor {
|
||||
* @return string The Inbox-Endpoint.
|
||||
*/
|
||||
public function get_inbox() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/inbox', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/inbox', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -272,7 +272,7 @@ class User extends Actor {
|
||||
* @return string The Outbox-Endpoint.
|
||||
*/
|
||||
public function get_outbox() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/outbox', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/outbox', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -281,7 +281,7 @@ class User extends Actor {
|
||||
* @return string The Followers-Endpoint.
|
||||
*/
|
||||
public function get_followers() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/followers', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/followers', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -290,7 +290,7 @@ class User extends Actor {
|
||||
* @return string The Following-Endpoint.
|
||||
*/
|
||||
public function get_following() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/following', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/following', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -301,7 +301,7 @@ class User extends Actor {
|
||||
* @return string The Liked endpoint.
|
||||
*/
|
||||
public function get_liked() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/liked', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/liked', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -310,7 +310,7 @@ class User extends Actor {
|
||||
* @return string The Featured-Endpoint.
|
||||
*/
|
||||
public function get_featured() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/collections/featured', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -319,7 +319,7 @@ class User extends Actor {
|
||||
* @return string The Featured-Tags-Endpoint.
|
||||
*/
|
||||
public function get_featured_tags() {
|
||||
return get_rest_url_by_path( sprintf( 'actors/%d/collections/tags', $this->get__id() ) );
|
||||
return get_rest_url_by_path( \sprintf( 'actors/%d/collections/tags', $this->get__id() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -328,7 +328,7 @@ class User extends Actor {
|
||||
* @return string[]|null The endpoints.
|
||||
*/
|
||||
public function get_endpoints() {
|
||||
return array(
|
||||
$endpoints = array(
|
||||
'sharedInbox' => get_rest_url_by_path( 'inbox' ),
|
||||
'oauthAuthorizationEndpoint' => get_rest_url_by_path( 'oauth/authorize' ),
|
||||
'oauthTokenEndpoint' => get_rest_url_by_path( 'oauth/token' ),
|
||||
@ -336,6 +336,16 @@ class User extends Actor {
|
||||
'proxyUrl' => get_rest_url_by_path( 'proxy' ),
|
||||
'proxyEventStream' => get_rest_url_by_path( 'proxy/stream' ),
|
||||
);
|
||||
|
||||
if ( \get_option( 'activitypub_api', false ) ) {
|
||||
/*
|
||||
* RFC 6570 template. add_query_arg() picks the ?/& separator (plain permalinks already
|
||||
* carry a query string) and does not encode values, so the {q} placeholder stays intact.
|
||||
*/
|
||||
$endpoints['actorAutocomplete'] = \add_query_arg( 'q', '{q}', get_rest_url_by_path( 'actors/autocomplete' ) );
|
||||
}
|
||||
|
||||
return $endpoints;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -428,10 +438,10 @@ class User extends Actor {
|
||||
* @return bool True if the attribute was updated, false otherwise.
|
||||
*/
|
||||
public function update_icon( $value ) {
|
||||
if ( ! wp_attachment_is_image( $value ) ) {
|
||||
if ( ! \wp_attachment_is_image( $value ) ) {
|
||||
return false;
|
||||
}
|
||||
return update_user_option( $this->_id, 'activitypub_icon', $value );
|
||||
return \update_user_option( $this->_id, 'activitypub_icon', $value );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -441,7 +451,7 @@ class User extends Actor {
|
||||
* @return bool True if the attribute was updated, false otherwise.
|
||||
*/
|
||||
public function update_header( $value ) {
|
||||
if ( ! wp_attachment_is_image( $value ) ) {
|
||||
if ( ! \wp_attachment_is_image( $value ) ) {
|
||||
return false;
|
||||
}
|
||||
return \update_user_option( $this->_id, 'activitypub_header_image', $value );
|
||||
@ -468,9 +478,9 @@ class User extends Actor {
|
||||
$this->get_alternate_url(),
|
||||
);
|
||||
|
||||
$also_known_as = array_merge( $also_known_as, \get_user_option( 'activitypub_also_known_as', $this->_id ) ?: array() );
|
||||
$also_known_as = \array_merge( $also_known_as, \get_user_option( 'activitypub_also_known_as', $this->_id ) ?: array() );
|
||||
|
||||
return array_unique( $also_known_as );
|
||||
return \array_unique( $also_known_as );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -483,4 +493,50 @@ class User extends Actor {
|
||||
|
||||
return $moved_to && $moved_to !== $this->get_id() ? $moved_to : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actor-level interaction policy.
|
||||
*
|
||||
* Overrides the magic property accessor on Base_Object so that we always
|
||||
* compute the policy from the current site setting rather than returning a
|
||||
* cached property value. Currently only emits `canFeature` (FEP-7aa9).
|
||||
* Driven by the site option `activitypub_default_feature_policy` and
|
||||
* defaults to denying all featured-collection requests, in line with
|
||||
* FEP-7aa9's "absence of policy = no consent" rule.
|
||||
*
|
||||
* @see https://w3id.org/fep/7aa9
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_interaction_policy() {
|
||||
$policy = array( 'canFeature' => $this->build_can_feature_policy() );
|
||||
|
||||
// Merge with an explicitly set interaction policy, if any.
|
||||
if ( $this->interaction_policy ) {
|
||||
$policy = \array_merge( (array) $this->interaction_policy, $policy );
|
||||
}
|
||||
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `canFeature` policy array from the site option.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function build_can_feature_policy() {
|
||||
$policy = \get_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ME );
|
||||
|
||||
switch ( $policy ) {
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_ANYONE:
|
||||
return array( 'automaticApproval' => array( 'https://www.w3.org/ns/activitystreams#Public' ) );
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS:
|
||||
return array( 'automaticApproval' => array( $this->get_followers() ) );
|
||||
case ACTIVITYPUB_INTERACTION_POLICY_ME:
|
||||
default:
|
||||
return array( 'automaticApproval' => array( $this->get_id() ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,7 +97,7 @@ class Authorization_Code {
|
||||
// Generate the code.
|
||||
$code = self::generate_code();
|
||||
$code_hash = self::hash_code( $code );
|
||||
$expires_at = time() + self::EXPIRATION;
|
||||
$expires_at = \time() + self::EXPIRATION;
|
||||
|
||||
// Store code data in transient.
|
||||
$code_data = array(
|
||||
@ -108,7 +108,7 @@ class Authorization_Code {
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => $code_challenge_method,
|
||||
'expires_at' => $expires_at,
|
||||
'created_at' => time(),
|
||||
'created_at' => \time(),
|
||||
);
|
||||
|
||||
$stored = \set_transient(
|
||||
@ -155,7 +155,7 @@ class Authorization_Code {
|
||||
\delete_transient( $transient );
|
||||
|
||||
// Check expiration (belt and suspenders - transient should auto-expire).
|
||||
if ( isset( $code_data['expires_at'] ) && $code_data['expires_at'] < time() ) {
|
||||
if ( isset( $code_data['expires_at'] ) && $code_data['expires_at'] < \time() ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_code_expired',
|
||||
\__( 'Authorization code has expired.', 'activitypub' ),
|
||||
@ -228,7 +228,7 @@ class Authorization_Code {
|
||||
// S256: BASE64URL(SHA256(code_verifier)) == code_challenge.
|
||||
$computed = self::compute_code_challenge( $code_verifier );
|
||||
|
||||
return hash_equals( $code_challenge, $computed );
|
||||
return \hash_equals( $code_challenge, $computed );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -238,9 +238,9 @@ class Authorization_Code {
|
||||
* @return string The code challenge (BASE64URL encoded SHA256 hash).
|
||||
*/
|
||||
public static function compute_code_challenge( $code_verifier ) {
|
||||
$hash = hash( 'sha256', $code_verifier, true );
|
||||
$hash = \hash( 'sha256', $code_verifier, true );
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PKCE BASE64URL encoding per RFC 7636.
|
||||
return rtrim( strtr( base64_encode( $hash ), '+/', '-_' ), '=' );
|
||||
return \rtrim( \strtr( \base64_encode( $hash ), '+/', '-_' ), '=' );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -249,7 +249,7 @@ class Authorization_Code {
|
||||
* @return string The authorization code.
|
||||
*/
|
||||
public static function generate_code() {
|
||||
return bin2hex( random_bytes( 32 ) );
|
||||
return \bin2hex( \random_bytes( 32 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -259,7 +259,7 @@ class Authorization_Code {
|
||||
* @return string The SHA-256 hash.
|
||||
*/
|
||||
public static function hash_code( $code ) {
|
||||
return hash( 'sha256', $code );
|
||||
return \hash( 'sha256', $code );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -286,7 +286,7 @@ class Authorization_Code {
|
||||
}
|
||||
|
||||
$timeout_prefix = '_transient_timeout_' . self::TRANSIENT_PREFIX;
|
||||
$now = time();
|
||||
$now = \time();
|
||||
|
||||
// Find expired timeout rows for this prefix.
|
||||
$timeout_option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
@ -307,10 +307,10 @@ class Authorization_Code {
|
||||
$option_names_to_delete = array();
|
||||
foreach ( $timeout_option_names as $timeout_name ) {
|
||||
$option_names_to_delete[] = $timeout_name;
|
||||
$option_names_to_delete[] = str_replace( '_transient_timeout_', '_transient_', $timeout_name );
|
||||
$option_names_to_delete[] = \str_replace( '_transient_timeout_', '_transient_', $timeout_name );
|
||||
}
|
||||
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $option_names_to_delete ), '%s' ) );
|
||||
$placeholders = \implode( ', ', \array_fill( 0, \count( $option_names_to_delete ), '%s' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery
|
||||
$count = $wpdb->query(
|
||||
|
||||
@ -107,7 +107,7 @@ class Client {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_redirect_uri',
|
||||
/* translators: %s: The invalid redirect URI */
|
||||
sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ),
|
||||
\sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
@ -131,7 +131,7 @@ class Client {
|
||||
'meta_input' => array(
|
||||
'_activitypub_client_id' => $client_id,
|
||||
'_activitypub_client_secret_hash' => $client_secret ? \wp_hash_password( $client_secret ) : '',
|
||||
'_activitypub_redirect_uris' => array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
|
||||
'_activitypub_redirect_uris' => \array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
|
||||
'_activitypub_allowed_scopes' => Scope::validate( $scopes ),
|
||||
'_activitypub_is_public' => (bool) $is_public,
|
||||
),
|
||||
@ -281,7 +281,7 @@ class Client {
|
||||
|
||||
// Get redirect URIs from metadata or derive from client_id origin.
|
||||
$redirect_uris = array();
|
||||
if ( ! empty( $metadata['redirect_uris'] ) && is_array( $metadata['redirect_uris'] ) ) {
|
||||
if ( ! empty( $metadata['redirect_uris'] ) && \is_array( $metadata['redirect_uris'] ) ) {
|
||||
foreach ( $metadata['redirect_uris'] as $uri ) {
|
||||
if ( ! self::validate_uri_format( $uri ) ) {
|
||||
return new \WP_Error(
|
||||
@ -307,7 +307,7 @@ class Client {
|
||||
'meta_input' => array(
|
||||
'_activitypub_client_id' => $client_id,
|
||||
'_activitypub_client_secret_hash' => '', // Public client.
|
||||
'_activitypub_redirect_uris' => array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
|
||||
'_activitypub_redirect_uris' => \array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
|
||||
'_activitypub_allowed_scopes' => Scope::ALL,
|
||||
'_activitypub_is_public' => true,
|
||||
'_activitypub_discovered' => true,
|
||||
@ -396,7 +396,7 @@ class Client {
|
||||
$body = \wp_remote_retrieve_body( $response );
|
||||
$data = \json_decode( $body, true );
|
||||
|
||||
if ( ! is_array( $data ) ) {
|
||||
if ( ! \is_array( $data ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_client_invalid_metadata',
|
||||
\__( 'Invalid client metadata format.', 'activitypub' ),
|
||||
@ -465,19 +465,19 @@ class Client {
|
||||
$metadata['redirect_uris'] = (array) $data['redirectURI'];
|
||||
}
|
||||
if ( empty( $metadata['logo_uri'] ) && ! empty( $data['icon'] ) ) {
|
||||
if ( is_string( $data['icon'] ) ) {
|
||||
if ( \is_string( $data['icon'] ) ) {
|
||||
$metadata['logo_uri'] = $data['icon'];
|
||||
} elseif ( is_array( $data['icon'] ) && ! empty( $data['icon']['url'] ) ) {
|
||||
} elseif ( \is_array( $data['icon'] ) && ! empty( $data['icon']['url'] ) ) {
|
||||
$metadata['logo_uri'] = $data['icon']['url'];
|
||||
}
|
||||
}
|
||||
if ( empty( $metadata['client_uri'] ) && ! empty( $data['url'] ) ) {
|
||||
$metadata['client_uri'] = is_array( $data['url'] ) ? $data['url'][0] : $data['url'];
|
||||
$metadata['client_uri'] = \is_array( $data['url'] ) ? $data['url'][0] : $data['url'];
|
||||
}
|
||||
|
||||
// Mark ActivityPub actor-typed clients for lenient redirect validation.
|
||||
$actor_types = array( 'Application', 'Person', 'Service', 'Group', 'Organization' );
|
||||
if ( ! empty( $data['type'] ) && in_array( $data['type'], $actor_types, true ) ) {
|
||||
if ( ! empty( $data['type'] ) && \in_array( $data['type'], $actor_types, true ) ) {
|
||||
$metadata['is_actor'] = true;
|
||||
}
|
||||
|
||||
@ -534,7 +534,7 @@ class Client {
|
||||
}
|
||||
|
||||
// Exact match first.
|
||||
if ( in_array( $redirect_uri, $allowed_uris, true ) ) {
|
||||
if ( \in_array( $redirect_uri, $allowed_uris, true ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -668,7 +668,7 @@ class Client {
|
||||
);
|
||||
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
|
||||
return array_map(
|
||||
return \array_map(
|
||||
function ( $post ) {
|
||||
return new self( $post->ID );
|
||||
},
|
||||
@ -734,7 +734,7 @@ class Client {
|
||||
*/
|
||||
public function get_redirect_uris() {
|
||||
$uris = \get_post_meta( $this->post_id, '_activitypub_redirect_uris', true );
|
||||
return is_array( $uris ) ? $uris : array();
|
||||
return \is_array( $uris ) ? $uris : array();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -744,7 +744,7 @@ class Client {
|
||||
*/
|
||||
public function get_allowed_scopes() {
|
||||
$scopes = \get_post_meta( $this->post_id, '_activitypub_allowed_scopes', true );
|
||||
return is_array( $scopes ) ? $scopes : Scope::DEFAULT_SCOPES;
|
||||
return \is_array( $scopes ) ? $scopes : Scope::DEFAULT_SCOPES;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -790,7 +790,7 @@ class Client {
|
||||
$host = \wp_parse_url( $redirect_uris[0], PHP_URL_HOST );
|
||||
|
||||
if ( $scheme && $host ) {
|
||||
return \trailingslashit( sprintf( '%s://%s', $scheme, $host ) );
|
||||
return \trailingslashit( \sprintf( '%s://%s', $scheme, $host ) );
|
||||
}
|
||||
}
|
||||
|
||||
@ -823,7 +823,7 @@ class Client {
|
||||
*/
|
||||
public function filter_scopes( $requested_scopes ) {
|
||||
$allowed = $this->get_allowed_scopes();
|
||||
return array_values( array_intersect( $requested_scopes, $allowed ) );
|
||||
return \array_values( \array_intersect( $requested_scopes, $allowed ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -833,11 +833,11 @@ class Client {
|
||||
*/
|
||||
public static function generate_client_id() {
|
||||
// Generate UUID v4.
|
||||
$data = random_bytes( 16 );
|
||||
$data[6] = chr( ord( $data[6] ) & 0x0f | 0x40 ); // Version 4.
|
||||
$data[8] = chr( ord( $data[8] ) & 0x3f | 0x80 ); // Variant.
|
||||
$data = \random_bytes( 16 );
|
||||
$data[6] = \chr( \ord( $data[6] ) & 0x0f | 0x40 ); // Version 4.
|
||||
$data[8] = \chr( \ord( $data[8] ) & 0x3f | 0x80 ); // Variant.
|
||||
|
||||
return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $data ), 4 ) );
|
||||
return \vsprintf( '%s%s-%s-%s-%s-%s%s%s', \str_split( \bin2hex( $data ), 4 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -869,7 +869,7 @@ class Client {
|
||||
* but double-slash forms ("myapp://host") are common in practice, so both
|
||||
* are accepted.
|
||||
*/
|
||||
if ( ! preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
|
||||
if ( ! \preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -883,7 +883,7 @@ class Client {
|
||||
|
||||
// Block dangerous schemes (see OWASP XSS prevention).
|
||||
$blocked_schemes = array( 'javascript', 'data', 'vbscript', 'blob', 'file', 'mhtml', 'cid', 'jar', 'view-source' );
|
||||
if ( in_array( $scheme, $blocked_schemes, true ) ) {
|
||||
if ( \in_array( $scheme, $blocked_schemes, true ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -922,7 +922,7 @@ class Client {
|
||||
* Custom schemes must be at least 2 characters to avoid matching
|
||||
* Windows drive letters (e.g., "C:").
|
||||
*/
|
||||
return strlen( $scheme ) >= 2;
|
||||
return \strlen( $scheme ) >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -949,7 +949,7 @@ class Client {
|
||||
// Also revoke all tokens stored in user meta.
|
||||
Token::revoke_all();
|
||||
|
||||
return count( $post_ids );
|
||||
return \count( $post_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -51,6 +51,23 @@ class Scope {
|
||||
self::PROFILE,
|
||||
);
|
||||
|
||||
/**
|
||||
* SWICG ActivityPub API Basic Profile canonical scope aliases.
|
||||
*
|
||||
* Advertised in OAuth metadata so Basic Profile clients can discover them,
|
||||
* and accepted in scope requests (any `activitypub:read:*` collapses to
|
||||
* `read`, any `activitypub:write:*` collapses to `write`). Enforcement
|
||||
* stays coarse: there is no per-activity-type access control yet.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
const CANONICAL_ALIASES = array(
|
||||
'activitypub:read:all',
|
||||
'activitypub:write:all',
|
||||
);
|
||||
|
||||
/**
|
||||
* Human-readable descriptions for each scope.
|
||||
*
|
||||
@ -79,25 +96,83 @@ class Scope {
|
||||
/**
|
||||
* Validate and filter requested scopes.
|
||||
*
|
||||
* Canonical SWICG ActivityPub API Basic Profile scope names of the form
|
||||
* `activitypub:read:*` and `activitypub:write:*` are normalized to the
|
||||
* plugin's internal `read` and `write` scopes before validation.
|
||||
*
|
||||
* @param string|array $scopes The requested scopes (space-separated string or array).
|
||||
* @return array Valid scopes.
|
||||
*/
|
||||
public static function validate( $scopes ) {
|
||||
if ( is_string( $scopes ) ) {
|
||||
if ( \is_string( $scopes ) ) {
|
||||
$scopes = self::parse( $scopes );
|
||||
}
|
||||
|
||||
if ( ! is_array( $scopes ) ) {
|
||||
if ( ! \is_array( $scopes ) ) {
|
||||
return self::DEFAULT_SCOPES;
|
||||
}
|
||||
|
||||
$valid_scopes = array_intersect( $scopes, self::ALL );
|
||||
$scopes = self::normalize( $scopes );
|
||||
$valid_scopes = \array_intersect( $scopes, self::ALL );
|
||||
|
||||
if ( empty( $valid_scopes ) ) {
|
||||
return self::DEFAULT_SCOPES;
|
||||
}
|
||||
|
||||
return array_values( $valid_scopes );
|
||||
return \array_values( \array_unique( $valid_scopes ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize canonical Basic Profile scope names to internal scopes.
|
||||
*
|
||||
* Maps any `activitypub:read:*` to {@see self::READ} and any
|
||||
* `activitypub:write:*` to {@see self::WRITE}. Unknown values pass through
|
||||
* unchanged so they can be filtered out by the caller.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @param array $scopes Requested scope strings.
|
||||
* @return array Normalized scope strings.
|
||||
*/
|
||||
public static function normalize( $scopes ) {
|
||||
if ( ! \is_array( $scopes ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$normalized = array();
|
||||
foreach ( $scopes as $scope ) {
|
||||
if ( ! \is_string( $scope ) || '' === $scope ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( 0 === \strpos( $scope, 'activitypub:read:' ) ) {
|
||||
$normalized[] = self::READ;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( 0 === \strpos( $scope, 'activitypub:write:' ) ) {
|
||||
$normalized[] = self::WRITE;
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $scope;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the scope identifiers advertised in OAuth authorization-server metadata.
|
||||
*
|
||||
* Includes the plugin's internal scopes plus the SWICG Basic Profile
|
||||
* canonical aliases so spec-aware clients can discover them.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @return array Scope identifiers.
|
||||
*/
|
||||
public static function supported() {
|
||||
return \array_merge( self::ALL, self::CANONICAL_ALIASES );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -107,13 +182,13 @@ class Scope {
|
||||
* @return array Scope array.
|
||||
*/
|
||||
public static function parse( $scope_string ) {
|
||||
if ( empty( $scope_string ) || ! is_string( $scope_string ) ) {
|
||||
if ( empty( $scope_string ) || ! \is_string( $scope_string ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$scopes = preg_split( '/\s+/', trim( $scope_string ) );
|
||||
$scopes = \preg_split( '/\s+/', \trim( $scope_string ) );
|
||||
|
||||
return array_filter( array_map( 'trim', $scopes ) );
|
||||
return \array_filter( \array_map( 'trim', $scopes ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -123,11 +198,11 @@ class Scope {
|
||||
* @return string Space-separated scope string.
|
||||
*/
|
||||
public static function to_string( $scopes ) {
|
||||
if ( ! is_array( $scopes ) ) {
|
||||
if ( ! \is_array( $scopes ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode( ' ', $scopes );
|
||||
return \implode( ' ', $scopes );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -137,7 +212,7 @@ class Scope {
|
||||
* @return bool True if valid, false otherwise.
|
||||
*/
|
||||
public static function is_valid( $scope ) {
|
||||
return in_array( $scope, self::ALL, true );
|
||||
return \in_array( $scope, self::ALL, true );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -167,7 +242,7 @@ class Scope {
|
||||
* @return bool True if the scope is present.
|
||||
*/
|
||||
public static function contains( $scopes, $scope ) {
|
||||
return is_array( $scopes ) && in_array( $scope, $scopes, true );
|
||||
return \is_array( $scopes ) && \in_array( $scope, $scopes, true );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -177,11 +252,11 @@ class Scope {
|
||||
* @return array Sanitized scopes array.
|
||||
*/
|
||||
public static function sanitize( $value ) {
|
||||
if ( is_string( $value ) ) {
|
||||
if ( \is_string( $value ) ) {
|
||||
$value = self::parse( $value );
|
||||
}
|
||||
|
||||
if ( ! is_array( $value ) ) {
|
||||
if ( ! \is_array( $value ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ class Server {
|
||||
|
||||
// Schedule cleanup cron.
|
||||
if ( ! \wp_next_scheduled( 'activitypub_oauth_cleanup' ) ) {
|
||||
\wp_schedule_event( time(), 'daily', 'activitypub_oauth_cleanup' );
|
||||
\wp_schedule_event( \time(), 'daily', 'activitypub_oauth_cleanup' );
|
||||
}
|
||||
\add_action( 'activitypub_oauth_cleanup', array( self::class, 'cleanup' ) );
|
||||
}
|
||||
@ -56,6 +56,22 @@ class Server {
|
||||
return $result;
|
||||
}
|
||||
|
||||
/*
|
||||
* Only honor OAuth bearer tokens on the plugin's own REST API.
|
||||
*
|
||||
* These are scoped ActivityPub C2S grants. Honoring them on core routes
|
||||
* (e.g. POST /wp/v2/posts) would establish a full-capability session that
|
||||
* ignores the token's granted scope, since scope is only enforced where
|
||||
* the plugin opts in via check_oauth_permission() (CWE-863). Every C2S
|
||||
* endpoint lives under ACTIVITYPUB_REST_NAMESPACE, so restricting here
|
||||
* does not affect legitimate clients. Direct callers (outbox permalinks,
|
||||
* SSE query-param auth) invoke this method outside REST dispatch and are
|
||||
* trusted to have scoped the context themselves.
|
||||
*/
|
||||
if ( ! self::is_authenticatable_request() ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$validated = Token::validate( $token );
|
||||
|
||||
if ( \is_wp_error( $validated ) ) {
|
||||
@ -68,6 +84,63 @@ class Server {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current request may be authenticated with an OAuth bearer token.
|
||||
*
|
||||
* Bearer tokens are scoped ActivityPub C2S grants and must only authenticate
|
||||
* the plugin's own REST API. During REST dispatch we therefore require the
|
||||
* requested route to live under {@see ACTIVITYPUB_REST_NAMESPACE}; a token
|
||||
* presented to a core route such as `/wp/v2/posts` is ignored so its granted
|
||||
* scope cannot be bypassed. Outside REST dispatch (e.g. outbox permalinks
|
||||
* that authenticate explicitly) there is no route to scope against, so the
|
||||
* request is allowed through to the caller's own checks.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return bool True if the request may be OAuth-authenticated.
|
||||
*/
|
||||
private static function is_authenticatable_request() {
|
||||
global $wp;
|
||||
|
||||
$route = isset( $wp->query_vars['rest_route'] ) ? (string) $wp->query_vars['rest_route'] : '';
|
||||
|
||||
if ( '' === $route ) {
|
||||
// No REST route in context: only trust non-REST (direct/permalink) callers.
|
||||
return ! \wp_is_serving_rest_request();
|
||||
}
|
||||
|
||||
$route = '/' . \ltrim( $route, '/' );
|
||||
$namespace = '/' . \trim( ACTIVITYPUB_REST_NAMESPACE, '/' );
|
||||
|
||||
return $route === $namespace || 0 === \strpos( $route, $namespace . '/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject requests that authenticated with an OAuth bearer token.
|
||||
*
|
||||
* OAuth bearer tokens are scoped ActivityPub C2S grants and must not reach
|
||||
* the plugin's admin and management endpoints, which authorize by WordPress
|
||||
* capability rather than by OAuth scope. Without this guard a token consented
|
||||
* to for a narrow scope (e.g. read) could drive privileged, capability-gated
|
||||
* actions such as moderation. Cookie-authenticated admin-UI requests do not
|
||||
* establish an OAuth session and are unaffected.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return \WP_Error|null WP_Error when the request is OAuth-authenticated, null otherwise.
|
||||
*/
|
||||
public static function deny_if_oauth() {
|
||||
if ( ! self::is_oauth_request() ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new \WP_Error(
|
||||
'activitypub_oauth_not_allowed',
|
||||
\__( 'OAuth authentication is not allowed for this endpoint.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current OAuth token from the request.
|
||||
*
|
||||
@ -113,11 +186,11 @@ class Server {
|
||||
}
|
||||
|
||||
// Check for Bearer token.
|
||||
if ( 0 !== strpos( $auth_header, 'Bearer ' ) ) {
|
||||
if ( 0 !== \strpos( $auth_header, 'Bearer ' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr( $auth_header, 7 );
|
||||
return \substr( $auth_header, 7 );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -142,14 +215,14 @@ class Server {
|
||||
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
|
||||
// Fallback: read from Apache's own header API (case-insensitive).
|
||||
if ( ! function_exists( 'apache_request_headers' ) ) {
|
||||
if ( ! \function_exists( 'apache_request_headers' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$headers = apache_request_headers();
|
||||
$headers = \apache_request_headers();
|
||||
|
||||
foreach ( $headers as $key => $value ) {
|
||||
if ( 'authorization' === strtolower( $key ) ) {
|
||||
if ( 'authorization' === \strtolower( $key ) ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -214,7 +287,7 @@ class Server {
|
||||
return new \WP_Error(
|
||||
'activitypub_insufficient_scope',
|
||||
/* translators: %s: The required scope */
|
||||
sprintf( \__( 'This action requires the "%s" scope.', 'activitypub' ), $scope ),
|
||||
\sprintf( \__( 'This action requires the "%s" scope.', 'activitypub' ), $scope ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
@ -248,7 +321,7 @@ class Server {
|
||||
'revocation_endpoint' => $base_url . 'oauth/revoke',
|
||||
'introspection_endpoint' => $base_url . 'oauth/introspect',
|
||||
'registration_endpoint' => $base_url . 'oauth/clients',
|
||||
'scopes_supported' => Scope::ALL,
|
||||
'scopes_supported' => Scope::supported(),
|
||||
'response_types_supported' => array( 'code' ),
|
||||
'response_modes_supported' => array( 'query' ),
|
||||
'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
|
||||
@ -337,7 +410,7 @@ class Server {
|
||||
// Build form action URL.
|
||||
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$form_url = \add_query_arg(
|
||||
array_merge( array( 'action' => 'activitypub_authorize' ), $authorize_params ),
|
||||
\array_merge( array( 'action' => 'activitypub_authorize' ), $authorize_params ),
|
||||
\wp_login_url()
|
||||
);
|
||||
|
||||
@ -448,7 +521,7 @@ class Server {
|
||||
$url = Sanitize::redirect_uri( \add_query_arg( $params, $redirect_uri ) );
|
||||
|
||||
\nocache_headers();
|
||||
header( 'Location: ' . $url, true, 303 );
|
||||
\header( 'Location: ' . $url, true, 303 );
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,8 +102,8 @@ class Token {
|
||||
$refresh_token = self::generate_token();
|
||||
|
||||
// Calculate expirations.
|
||||
$access_expires_at = time() + $expires;
|
||||
$refresh_expires_at = time() + self::REFRESH_EXPIRATION;
|
||||
$access_expires_at = \time() + $expires;
|
||||
$refresh_expires_at = \time() + self::REFRESH_EXPIRATION;
|
||||
|
||||
// Create token data.
|
||||
$token_data = array(
|
||||
@ -113,7 +113,7 @@ class Token {
|
||||
'scopes' => Scope::validate( $scopes ),
|
||||
'expires_at' => $access_expires_at,
|
||||
'refresh_expires_at' => $refresh_expires_at,
|
||||
'created_at' => time(),
|
||||
'created_at' => \time(),
|
||||
'last_used_at' => null,
|
||||
);
|
||||
|
||||
@ -141,7 +141,8 @@ class Token {
|
||||
self::enforce_token_limit( $user_id );
|
||||
|
||||
/*
|
||||
* Get the actor URI for the 'me' parameter (IndieAuth convention).
|
||||
* Get the actor URI for the 'me' parameter (IndieAuth convention) and
|
||||
* `activitypub_actor_id` (SWICG ActivityPub API Basic Profile).
|
||||
* Fall back to blog actor when user actors are disabled.
|
||||
*/
|
||||
$actor = Actors::get_by_id( $user_id );
|
||||
@ -151,12 +152,13 @@ class Token {
|
||||
$me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;
|
||||
|
||||
return array(
|
||||
'access_token' => $access_token,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => $expires,
|
||||
'refresh_token' => $refresh_token,
|
||||
'scope' => Scope::to_string( $token_data['scopes'] ),
|
||||
'me' => $me,
|
||||
'access_token' => $access_token,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => $expires,
|
||||
'refresh_token' => $refresh_token,
|
||||
'scope' => Scope::to_string( $token_data['scopes'] ),
|
||||
'me' => $me,
|
||||
'activitypub_actor_id' => $me,
|
||||
);
|
||||
}
|
||||
|
||||
@ -190,7 +192,7 @@ class Token {
|
||||
|
||||
$token_data = \get_user_meta( (int) $user_id, $meta_key, true );
|
||||
|
||||
if ( empty( $token_data ) || ! is_array( $token_data ) ) {
|
||||
if ( empty( $token_data ) || ! \is_array( $token_data ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_token',
|
||||
\__( 'Invalid access token.', 'activitypub' ),
|
||||
@ -200,7 +202,7 @@ class Token {
|
||||
|
||||
// Verify hash matches.
|
||||
if ( ! isset( $token_data['access_token_hash'] ) ||
|
||||
! hash_equals( $token_data['access_token_hash'], $token_hash ) ) {
|
||||
! \hash_equals( $token_data['access_token_hash'], $token_hash ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_token',
|
||||
\__( 'Invalid access token.', 'activitypub' ),
|
||||
@ -209,7 +211,7 @@ class Token {
|
||||
}
|
||||
|
||||
// Check expiration.
|
||||
if ( isset( $token_data['expires_at'] ) && $token_data['expires_at'] < time() ) {
|
||||
if ( isset( $token_data['expires_at'] ) && $token_data['expires_at'] < \time() ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_token_expired',
|
||||
\__( 'Access token has expired.', 'activitypub' ),
|
||||
@ -219,8 +221,8 @@ class Token {
|
||||
|
||||
// Throttle last_used_at writes to avoid a DB write on every request.
|
||||
$last_used = $token_data['last_used_at'] ?? 0;
|
||||
if ( empty( $last_used ) || ( time() - $last_used ) > 5 * MINUTE_IN_SECONDS ) {
|
||||
$token_data['last_used_at'] = time();
|
||||
if ( empty( $last_used ) || ( \time() - $last_used ) > 5 * MINUTE_IN_SECONDS ) {
|
||||
$token_data['last_used_at'] = \time();
|
||||
\update_user_meta( (int) $user_id, $meta_key, $token_data );
|
||||
}
|
||||
|
||||
@ -272,7 +274,7 @@ class Token {
|
||||
$meta_key = self::META_PREFIX . $access_hash;
|
||||
$token_data = \get_user_meta( $user_id, $meta_key, true );
|
||||
|
||||
if ( empty( $token_data ) || ! is_array( $token_data ) ) {
|
||||
if ( empty( $token_data ) || ! \is_array( $token_data ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_refresh_token',
|
||||
\__( 'Invalid refresh token.', 'activitypub' ),
|
||||
@ -282,7 +284,7 @@ class Token {
|
||||
|
||||
// Verify refresh token hash matches.
|
||||
if ( ! isset( $token_data['refresh_token_hash'] ) ||
|
||||
! hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) {
|
||||
! \hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_refresh_token',
|
||||
\__( 'Invalid refresh token.', 'activitypub' ),
|
||||
@ -301,7 +303,7 @@ class Token {
|
||||
|
||||
// Check refresh token expiration.
|
||||
if ( isset( $token_data['refresh_expires_at'] ) &&
|
||||
$token_data['refresh_expires_at'] < time() ) {
|
||||
$token_data['refresh_expires_at'] < \time() ) {
|
||||
// Delete the expired token and index.
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
\delete_user_meta( $user_id, $refresh_index_key );
|
||||
@ -353,7 +355,7 @@ class Token {
|
||||
if ( $user_id ) {
|
||||
$user_id = (int) $user_id;
|
||||
$token_data = \get_user_meta( $user_id, $access_meta_key, true );
|
||||
$client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
|
||||
$client_id = \is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
|
||||
|
||||
if ( ! self::caller_owns_token( $user_id, $client_id, $caller_user_id, $caller_client_id ) ) {
|
||||
return true;
|
||||
@ -363,7 +365,7 @@ class Token {
|
||||
\delete_user_meta( $user_id, $access_meta_key );
|
||||
|
||||
// Also delete the refresh token index if it exists.
|
||||
if ( is_array( $token_data ) && isset( $token_data['refresh_token_hash'] ) ) {
|
||||
if ( \is_array( $token_data ) && isset( $token_data['refresh_token_hash'] ) ) {
|
||||
$refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'];
|
||||
\delete_user_meta( $user_id, $refresh_index_key );
|
||||
}
|
||||
@ -388,7 +390,7 @@ class Token {
|
||||
|
||||
if ( $access_hash ) {
|
||||
$token_data = \get_user_meta( $user_id, self::META_PREFIX . $access_hash, true );
|
||||
$client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
|
||||
$client_id = \is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
|
||||
}
|
||||
|
||||
if ( ! self::caller_owns_token( $user_id, $client_id, $caller_user_id, $caller_client_id ) ) {
|
||||
@ -482,22 +484,22 @@ class Token {
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
// Delete token entries and collect client IDs.
|
||||
if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
if ( 0 === \strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
if ( is_array( $token_data ) && ! empty( $token_data['client_id'] ) ) {
|
||||
if ( \is_array( $token_data ) && ! empty( $token_data['client_id'] ) ) {
|
||||
$client_ids[] = $token_data['client_id'];
|
||||
}
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
++$count;
|
||||
}
|
||||
// Delete refresh token indices.
|
||||
if ( 0 === strpos( $meta_key, self::REFRESH_INDEX_PREFIX ) ) {
|
||||
if ( 0 === \strpos( $meta_key, self::REFRESH_INDEX_PREFIX ) ) {
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
}
|
||||
}
|
||||
|
||||
// Remove user from all client tracking.
|
||||
foreach ( array_unique( $client_ids ) as $client_id ) {
|
||||
foreach ( \array_unique( $client_ids ) as $client_id ) {
|
||||
self::untrack_user( $user_id, $client_id );
|
||||
}
|
||||
|
||||
@ -536,13 +538,13 @@ class Token {
|
||||
$all_meta = \get_user_meta( $user_id );
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
if ( 0 !== \strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( ! is_array( $token_data ) ) {
|
||||
if ( ! \is_array( $token_data ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -575,13 +577,13 @@ class Token {
|
||||
$tokens = array();
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
if ( 0 !== \strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( is_array( $token_data ) ) {
|
||||
if ( \is_array( $token_data ) ) {
|
||||
// Don't expose hashes.
|
||||
unset( $token_data['access_token_hash'], $token_data['refresh_token_hash'] );
|
||||
$token_data['meta_key'] = $meta_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a DB query, just array key.
|
||||
@ -645,7 +647,7 @@ class Token {
|
||||
* @return bool True if expired.
|
||||
*/
|
||||
public function is_expired() {
|
||||
return $this->get_expires_at() < time();
|
||||
return $this->get_expires_at() < \time();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -673,7 +675,7 @@ class Token {
|
||||
* @return string The random token as a hex string.
|
||||
*/
|
||||
public static function generate_token( $length = 32 ) {
|
||||
return bin2hex( random_bytes( $length ) );
|
||||
return \bin2hex( \random_bytes( $length ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -683,7 +685,7 @@ class Token {
|
||||
* @return string The SHA-256 hash.
|
||||
*/
|
||||
public static function hash_token( $token ) {
|
||||
return hash( 'sha256', $token );
|
||||
return \hash( 'sha256', $token );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -705,7 +707,7 @@ class Token {
|
||||
$post_id = $client->get_post_id();
|
||||
$existing = \get_post_meta( $post_id, self::USER_META_KEY, false );
|
||||
|
||||
if ( ! in_array( $user_id, array_map( 'intval', $existing ), true ) ) {
|
||||
if ( ! \in_array( $user_id, \array_map( 'intval', $existing ), true ) ) {
|
||||
\add_post_meta( $post_id, self::USER_META_KEY, $user_id );
|
||||
}
|
||||
}
|
||||
@ -722,30 +724,30 @@ class Token {
|
||||
$tokens = array();
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
if ( 0 !== \strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( is_array( $token_data ) ) {
|
||||
if ( \is_array( $token_data ) ) {
|
||||
$tokens[ $meta_key ] = $token_data;
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $tokens ) <= self::MAX_TOKENS_PER_USER ) {
|
||||
if ( \count( $tokens ) <= self::MAX_TOKENS_PER_USER ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by created_at ascending (oldest first).
|
||||
uasort(
|
||||
\uasort(
|
||||
$tokens,
|
||||
function ( $a, $b ) {
|
||||
return ( $a['created_at'] ?? 0 ) - ( $b['created_at'] ?? 0 );
|
||||
}
|
||||
);
|
||||
|
||||
$to_remove = count( $tokens ) - self::MAX_TOKENS_PER_USER;
|
||||
$to_remove = \count( $tokens ) - self::MAX_TOKENS_PER_USER;
|
||||
|
||||
foreach ( $tokens as $meta_key => $token_data ) {
|
||||
if ( $to_remove <= 0 ) {
|
||||
@ -809,7 +811,7 @@ class Token {
|
||||
|
||||
$user_ids = \get_post_meta( $client->get_post_id(), self::USER_META_KEY, false );
|
||||
|
||||
return array_map( 'intval', $user_ids );
|
||||
return \array_map( 'intval', $user_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -831,7 +833,7 @@ class Token {
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( 'intval', $user_ids );
|
||||
return \array_map( 'intval', $user_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -850,13 +852,13 @@ class Token {
|
||||
$client_ids = array();
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
if ( 0 !== \strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( ! is_array( $token_data ) ) {
|
||||
if ( ! \is_array( $token_data ) ) {
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
++$count;
|
||||
continue;
|
||||
@ -864,9 +866,9 @@ class Token {
|
||||
|
||||
// Check if both access and refresh tokens are expired.
|
||||
$access_expired = isset( $token_data['expires_at'] ) &&
|
||||
$token_data['expires_at'] < time() - DAY_IN_SECONDS;
|
||||
$token_data['expires_at'] < \time() - DAY_IN_SECONDS;
|
||||
$refresh_expired = isset( $token_data['refresh_expires_at'] ) &&
|
||||
$token_data['refresh_expires_at'] < time();
|
||||
$token_data['refresh_expires_at'] < \time();
|
||||
|
||||
if ( $access_expired && $refresh_expired ) {
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
@ -883,7 +885,7 @@ class Token {
|
||||
}
|
||||
|
||||
// Untrack user from clients where all tokens were removed.
|
||||
foreach ( array_unique( $client_ids ) as $client_id ) {
|
||||
foreach ( \array_unique( $client_ids ) as $client_id ) {
|
||||
self::maybe_untrack_user( $user_id, $client_id );
|
||||
}
|
||||
}
|
||||
@ -919,15 +921,16 @@ class Token {
|
||||
$me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;
|
||||
|
||||
return array(
|
||||
'active' => true,
|
||||
'scope' => Scope::to_string( $validated->get_scopes() ),
|
||||
'client_id' => $validated->get_client_id(),
|
||||
'username' => $user ? $user->user_login : null,
|
||||
'token_type' => 'Bearer',
|
||||
'exp' => $validated->get_expires_at(),
|
||||
'iat' => $validated->get_created_at(),
|
||||
'sub' => (string) $user_id,
|
||||
'me' => $me,
|
||||
'active' => true,
|
||||
'scope' => Scope::to_string( $validated->get_scopes() ),
|
||||
'client_id' => $validated->get_client_id(),
|
||||
'username' => $user ? $user->user_login : null,
|
||||
'token_type' => 'Bearer',
|
||||
'exp' => $validated->get_expires_at(),
|
||||
'iat' => $validated->get_created_at(),
|
||||
'sub' => (string) $user_id,
|
||||
'me' => $me,
|
||||
'activitypub_actor_id' => $me,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Following;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
use Activitypub\Moderation;
|
||||
use Activitypub\OAuth\Server as OAuth_Server;
|
||||
|
||||
use function Activitypub\user_can_activitypub;
|
||||
|
||||
@ -118,6 +119,12 @@ class Actions_Controller extends \WP_REST_Controller {
|
||||
* @return bool|\WP_Error True if the request has permission, WP_Error object otherwise.
|
||||
*/
|
||||
public function check_permission() {
|
||||
// This is an admin endpoint; scoped OAuth C2S tokens must not drive it.
|
||||
$denied = OAuth_Server::deny_if_oauth();
|
||||
if ( null !== $denied ) {
|
||||
return $denied;
|
||||
}
|
||||
|
||||
if ( ! user_can_activitypub( \get_current_user_id() ) ) {
|
||||
return new \WP_Error(
|
||||
'rest_forbidden',
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
namespace Activitypub\Rest\Admin;
|
||||
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\OAuth\Server as OAuth_Server;
|
||||
use Activitypub\Statistics;
|
||||
|
||||
use function Activitypub\user_can_act_as_blog;
|
||||
@ -65,6 +66,12 @@ class Statistics_Controller extends \WP_REST_Controller {
|
||||
* @return true|\WP_Error True if the request has access, WP_Error otherwise.
|
||||
*/
|
||||
public function get_item_permissions_check( $request ) {
|
||||
// This is an admin endpoint; scoped OAuth C2S tokens must not drive it.
|
||||
$denied = OAuth_Server::deny_if_oauth();
|
||||
if ( null !== $denied ) {
|
||||
return $denied;
|
||||
}
|
||||
|
||||
$user_id = (int) $request->get_param( 'user_id' );
|
||||
|
||||
// Check if user can access stats for this actor.
|
||||
|
||||
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
/**
|
||||
* Actor_Autocomplete_Controller file.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
use function Activitypub\get_masked_wp_version;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
|
||||
/**
|
||||
* ActivityPub Actor Autocomplete Controller.
|
||||
*
|
||||
* Implements the SWICG ActivityPub API actor autocomplete extension: a typeahead search over the
|
||||
* actors this server knows (its own local actors and the remote actors it has cached), returning an
|
||||
* ActivityStreams Collection of actor objects.
|
||||
*
|
||||
* @see https://swicg.github.io/activitypub-api/autocomplete
|
||||
*/
|
||||
class Actor_Autocomplete_Controller extends \WP_REST_Controller {
|
||||
use Verification;
|
||||
|
||||
/**
|
||||
* The JSON-LD context for the actor autocomplete extension.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CONTEXT = 'https://swicg.github.io/activitypub-api/autocomplete';
|
||||
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
/**
|
||||
* The base of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'actors/autocomplete';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base,
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_items' ),
|
||||
'permission_callback' => array( $this, 'verify_authentication' ),
|
||||
'args' => array(
|
||||
'q' => array(
|
||||
'description' => 'The text typed so far.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search actors matching the query.
|
||||
*
|
||||
* @param \WP_REST_Request $request Full details about the request.
|
||||
* @return \WP_REST_Response|\WP_Error Collection of actor objects, or WP_Error on failure.
|
||||
*/
|
||||
public function get_items( $request ) {
|
||||
$query = \trim( (string) $request->get_param( 'q' ) );
|
||||
|
||||
/**
|
||||
* Filters the minimum number of characters required before actors are searched.
|
||||
*
|
||||
* @param int $min_length The minimum query length. Default 2.
|
||||
*/
|
||||
$min_length = (int) \apply_filters( 'activitypub_actor_autocomplete_min_length', 2 );
|
||||
|
||||
if ( \mb_strlen( $query ) < $min_length ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_query_too_short',
|
||||
\sprintf(
|
||||
/* translators: %d: minimum number of characters */
|
||||
\__( 'The search query must be at least %d characters long.', 'activitypub' ),
|
||||
$min_length
|
||||
),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the maximum number of actors returned by the autocomplete endpoint.
|
||||
*
|
||||
* @param int $number The maximum number of actors. Default 10.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*/
|
||||
$number = \max( 1, (int) \apply_filters( 'activitypub_actor_autocomplete_number', 10, $request ) );
|
||||
|
||||
$items = array();
|
||||
$seen = array();
|
||||
|
||||
foreach ( Actors::search( $query, $number ) as $actor ) {
|
||||
$items[] = $actor->to_array( false );
|
||||
$seen[ $actor->get_id() ] = true;
|
||||
}
|
||||
|
||||
// Only look up as many remote actors as are still needed to fill the result set.
|
||||
$remaining = $number - \count( $items );
|
||||
|
||||
if ( $remaining > 0 ) {
|
||||
foreach ( Remote_Actors::search( $query, $remaining ) as $post ) {
|
||||
$actor = Remote_Actors::get_actor( $post );
|
||||
if ( \is_wp_error( $actor ) || isset( $seen[ $actor->get_id() ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$items[] = $actor->to_array( false );
|
||||
$seen[ $actor->get_id() ] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$response = array(
|
||||
// JSON_LD_CONTEXT is already a context array, so merge the extension IRI in rather than nesting it.
|
||||
'@context' => \array_merge( (array) Base_Object::JSON_LD_CONTEXT, array( self::CONTEXT ) ),
|
||||
'id' => \add_query_arg( 'q', $query, get_rest_url_by_path( $this->rest_base ) ),
|
||||
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
|
||||
'type' => 'Collection',
|
||||
'totalItems' => \count( $items ),
|
||||
'items' => $items,
|
||||
);
|
||||
|
||||
$response = \rest_ensure_response( $response );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,8 @@ namespace Activitypub\Rest;
|
||||
use Activitypub\Collection\Actors as Actor_Collection;
|
||||
use Activitypub\Webfinger;
|
||||
|
||||
use function Activitypub\get_client_ip;
|
||||
|
||||
/**
|
||||
* ActivityPub Actors REST-Class.
|
||||
*
|
||||
@ -118,6 +120,23 @@ class Actors_Controller extends \WP_REST_Controller {
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public function get_remote_follow_item( $request ) {
|
||||
/*
|
||||
* This endpoint is unauthenticated and triggers an outbound WebFinger request to a
|
||||
* user-supplied host, so throttle it per IP (max 10 per minute) to limit its use as
|
||||
* a blind SSRF / request-amplification vector. Fail closed when no IP is available.
|
||||
*/
|
||||
$ip = get_client_ip();
|
||||
if ( '' === $ip ) {
|
||||
return self::rate_limit_response();
|
||||
}
|
||||
|
||||
$transient_key = 'ap_remote_follow_' . \md5( $ip );
|
||||
$count = (int) \get_transient( $transient_key );
|
||||
if ( $count >= 10 ) {
|
||||
return self::rate_limit_response();
|
||||
}
|
||||
\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
|
||||
|
||||
$resource = $request->get_param( 'resource' );
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$user = Actor_Collection::get_by_id( $user_id );
|
||||
@ -139,6 +158,24 @@ class Actors_Controller extends \WP_REST_Controller {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 429 rate-limit response for the remote-follow endpoint.
|
||||
*
|
||||
* @return \WP_REST_Response The rate-limit response.
|
||||
*/
|
||||
private static function rate_limit_response() {
|
||||
return new \WP_REST_Response(
|
||||
array(
|
||||
'code' => 'activitypub_rate_limited',
|
||||
'message' => \__( 'Too many requests. Please try again later.', 'activitypub' ),
|
||||
'data' => array( 'status' => 429 ),
|
||||
),
|
||||
429,
|
||||
// RFC 6585 §4: send Retry-After so clients can back off.
|
||||
array( 'Retry-After' => (string) MINUTE_IN_SECONDS )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the actor schema, conforming to JSON Schema.
|
||||
*
|
||||
|
||||
@ -72,19 +72,25 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
'callback' => array( $this, 'create_item' ),
|
||||
'permission_callback' => array( $this, 'verify_signature' ),
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_inbox_user_id' ),
|
||||
),
|
||||
'id' => array(
|
||||
'description' => 'The unique identifier for the activity.',
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
'required' => true,
|
||||
),
|
||||
'actor' => array(
|
||||
'actor' => array(
|
||||
'description' => 'The actor performing the activity.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => '\Activitypub\object_to_uri',
|
||||
),
|
||||
'type' => array(
|
||||
'type' => array(
|
||||
'description' => 'The type of the activity.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
@ -94,7 +100,7 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
return '' !== \sanitize_html_class( (string) $param );
|
||||
},
|
||||
),
|
||||
'object' => array(
|
||||
'object' => array(
|
||||
'description' => 'The object of the activity.',
|
||||
'required' => true,
|
||||
'sanitize_callback' => array( $this, 'localize_language_maps' ),
|
||||
@ -141,6 +147,28 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
\add_action( 'activitypub_inbox_create_item', array( self::class, 'process_create_item' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user ID for inbox deliveries.
|
||||
*
|
||||
* Also accepts the retired Application ID so remote servers that cached the
|
||||
* old Application actor document, which advertised this route as its inbox,
|
||||
* can still deliver to it. Those requests are handed to the shared inbox in
|
||||
* `create_item()`.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
*
|
||||
* @return true|\WP_Error True if the user ID is valid, WP_Error otherwise.
|
||||
*/
|
||||
public function validate_inbox_user_id( $user_id ) {
|
||||
if ( Actors::APPLICATION_USER_ID === (int) $user_id ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->validate_user_id( $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a collection of inbox items.
|
||||
*
|
||||
@ -192,7 +220,7 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
|
||||
$response = array(
|
||||
'@context' => Base_Object::JSON_LD_CONTEXT,
|
||||
'id' => get_rest_url_by_path( sprintf( 'actors/%d/inbox', $user_id ) ),
|
||||
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/inbox', $user_id ) ),
|
||||
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
|
||||
'actor' => $user->get_id(),
|
||||
'type' => 'OrderedCollection',
|
||||
@ -266,8 +294,21 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
*/
|
||||
public function create_item( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$data = $request->get_json_params();
|
||||
$type = camel_to_snake_case( $request->get_param( 'type' ) );
|
||||
|
||||
/*
|
||||
* Deliveries to the retired Application actor's inbox come from remote
|
||||
* servers that cached its actor document from before the Application was
|
||||
* extracted from the actor system. Hand them to the shared inbox, which
|
||||
* also rejects Follows aimed at the Application.
|
||||
*/
|
||||
if ( Actors::APPLICATION_USER_ID === (int) $user_id ) {
|
||||
$shared_inbox = new Inbox_Controller();
|
||||
|
||||
return $shared_inbox->create_item( $request );
|
||||
}
|
||||
|
||||
$data = $request->get_json_params();
|
||||
$type = camel_to_snake_case( $request->get_param( 'type' ) );
|
||||
|
||||
/* @var Activity $activity Activity object.*/
|
||||
$activity = Activity::init_from_array( $data );
|
||||
@ -282,7 +323,7 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
* @param string $type The type of the activity.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
*/
|
||||
do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity );
|
||||
\do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity );
|
||||
} else {
|
||||
/**
|
||||
* ActivityPub inbox action.
|
||||
@ -324,7 +365,7 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
Inbox::add( $activity, (array) $user_id );
|
||||
|
||||
\wp_clear_scheduled_hook( 'activitypub_inbox_create_item', array( $activity_id ) );
|
||||
\wp_schedule_single_event( time() + 15, 'activitypub_inbox_create_item', array( $activity_id ) );
|
||||
\wp_schedule_single_event( \time() + 15, 'activitypub_inbox_create_item', array( $activity_id ) );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,12 +2,23 @@
|
||||
/**
|
||||
* Application Controller file.
|
||||
*
|
||||
* Self-contained controller for the ActivityPub Application actor.
|
||||
* The Application is not a real actor in the plugin's internal sense —
|
||||
* it cannot be followed, addressed, or interacted with. It exists only as:
|
||||
* 1. A JSON-LD document at /wp-json/activitypub/1.0/application
|
||||
* 2. A signing identity for outbound HTTP GET requests
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Model\Application;
|
||||
use Activitypub\Activity\Actor;
|
||||
use Activitypub\Activity\Generic_Object;
|
||||
use Activitypub\Application;
|
||||
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
use function Activitypub\home_host;
|
||||
|
||||
/**
|
||||
* ActivityPub Application Controller.
|
||||
@ -43,16 +54,105 @@ class Application_Controller extends \WP_REST_Controller {
|
||||
'schema' => array( $this, 'get_item_schema' ),
|
||||
)
|
||||
);
|
||||
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/outbox',
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_outbox' ),
|
||||
'permission_callback' => '__return_true',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the application actor profile.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return \WP_REST_Response Response object.
|
||||
*/
|
||||
public function get_item( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$json = ( new Application() )->to_array();
|
||||
$id = Application::get_id();
|
||||
|
||||
$json = array(
|
||||
'@context' => Actor::JSON_LD_CONTEXT,
|
||||
'id' => $id,
|
||||
'type' => 'Application',
|
||||
'name' => Application::USERNAME,
|
||||
'preferredUsername' => Application::USERNAME,
|
||||
'summary' => sprintf(
|
||||
/* translators: %s: Domain of the site */
|
||||
\__( 'This is the Application Actor for %s.', 'activitypub' ),
|
||||
home_host()
|
||||
),
|
||||
'url' => Application::get_url(),
|
||||
'icon' => Application::get_icon(),
|
||||
'published' => Application::get_published(),
|
||||
'inbox' => get_rest_url_by_path( 'inbox' ),
|
||||
'outbox' => get_rest_url_by_path( 'application/outbox' ),
|
||||
'manuallyApprovesFollowers' => true,
|
||||
'discoverable' => false,
|
||||
'indexable' => false,
|
||||
'invisible' => true,
|
||||
'webfinger' => Application::get_webfinger(),
|
||||
'publicKey' => array(
|
||||
'id' => Application::get_key_id(),
|
||||
'owner' => $id,
|
||||
'publicKeyPem' => Application::get_public_key(),
|
||||
),
|
||||
'implements' => array(
|
||||
array(
|
||||
'href' => 'https://datatracker.ietf.org/doc/html/rfc9421',
|
||||
'name' => 'RFC-9421: HTTP Message Signatures',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/*
|
||||
* Run the same serialization filters the object-based path used, so
|
||||
* integrations that add actor fields via these hooks still apply to the
|
||||
* Application actor. The filters document a Generic_Object argument, so
|
||||
* hydrate one from the array to keep that contract.
|
||||
*/
|
||||
$class = 'application';
|
||||
$object = Generic_Object::init_from_array( $json );
|
||||
|
||||
/** This filter is documented in includes/activity/class-generic-object.php */
|
||||
$json = \apply_filters( 'activitypub_activity_object_array', $json, $class, $id, $object );
|
||||
|
||||
/** This filter is documented in includes/activity/class-generic-object.php */
|
||||
$json = \apply_filters( "activitypub_activity_{$class}_object_array", $json, $id, $object );
|
||||
|
||||
$rest_response = new \WP_REST_Response( $json, 200 );
|
||||
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
return $rest_response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an empty outbox collection for the Application actor.
|
||||
*
|
||||
* The Application is a signing-only identity and does not publish
|
||||
* activities, so its outbox is always an empty OrderedCollection.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return \WP_REST_Response Response object.
|
||||
*/
|
||||
public function get_outbox( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$json = array(
|
||||
'@context' => Actor::JSON_LD_CONTEXT,
|
||||
'id' => get_rest_url_by_path( 'application/outbox' ),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => 0,
|
||||
'orderedItems' => array(),
|
||||
);
|
||||
|
||||
$rest_response = new \WP_REST_Response( $json, 200 );
|
||||
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
@ -157,6 +257,9 @@ class Application_Controller extends \WP_REST_Controller {
|
||||
'indexable' => array(
|
||||
'type' => 'boolean',
|
||||
),
|
||||
'invisible' => array(
|
||||
'type' => 'boolean',
|
||||
),
|
||||
'implements' => array(
|
||||
'type' => 'array',
|
||||
'items' => array(
|
||||
|
||||
@ -130,7 +130,7 @@ class Collections_Controller extends Actors_Controller {
|
||||
|
||||
$response = array(
|
||||
'@context' => Base_Object::JSON_LD_CONTEXT,
|
||||
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/tags', $user_id ) ),
|
||||
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/collections/tags', $user_id ) ),
|
||||
'type' => 'Collection',
|
||||
'totalItems' => \is_countable( $tags ) ? \count( $tags ) : 0,
|
||||
'items' => array(),
|
||||
@ -139,7 +139,7 @@ class Collections_Controller extends Actors_Controller {
|
||||
foreach ( $tags as $tag ) {
|
||||
$response['items'][] = array(
|
||||
'type' => 'Hashtag',
|
||||
'href' => \esc_url( \get_tag_link( $tag ) ),
|
||||
'href' => \esc_url_raw( \get_tag_link( $tag ) ),
|
||||
'name' => esc_hashtag( $tag->name ),
|
||||
);
|
||||
}
|
||||
@ -161,7 +161,7 @@ class Collections_Controller extends Actors_Controller {
|
||||
if ( is_single_user() || Actors::BLOG_USER_ID !== $user_id ) {
|
||||
$sticky_posts = \get_option( 'sticky_posts' );
|
||||
|
||||
if ( $sticky_posts && is_array( $sticky_posts ) ) {
|
||||
if ( $sticky_posts && \is_array( $sticky_posts ) ) {
|
||||
// Only show public posts.
|
||||
$args = array(
|
||||
'post__in' => $sticky_posts,
|
||||
@ -187,7 +187,7 @@ class Collections_Controller extends Actors_Controller {
|
||||
|
||||
$response = array(
|
||||
'@context' => Base_Object::JSON_LD_CONTEXT,
|
||||
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $user_id ) ),
|
||||
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/collections/featured', $user_id ) ),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => \is_countable( $posts ) ? \count( $posts ) : 0,
|
||||
'orderedItems' => array(),
|
||||
|
||||
@ -10,6 +10,7 @@ namespace Activitypub\Rest;
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
use Activitypub\Signature;
|
||||
|
||||
use function Activitypub\get_masked_wp_version;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
@ -259,8 +260,17 @@ class Followers_Controller extends Actors_Controller {
|
||||
* FEP-8fcf: the responding server MUST ensure the requested authority
|
||||
* matches the signing peer, so that instances cannot "get tricked
|
||||
* into requesting the followers list of a third-party individual".
|
||||
*
|
||||
* Derive the signer host from the keyId that the verifier actually
|
||||
* checked (Signature::get_key_id() mirrors the verifier's header
|
||||
* choice and returns null when several labels make the choice
|
||||
* ambiguous). Re-parsing the raw headers here would diverge from the
|
||||
* verified key: a request can carry an RFC 9421 Signature-Input plus a
|
||||
* Signature header padded with an unrelated keyId, letting an attacker
|
||||
* sign with their own key yet steer a naive parser at a third-party host.
|
||||
*/
|
||||
$signer_host = self::normalize_host( self::get_signer_host( $request ) );
|
||||
$key_id = Signature::get_key_id( $request );
|
||||
$signer_host = $key_id ? self::normalize_host( (string) \wp_parse_url( $key_id, \PHP_URL_HOST ) ) : '';
|
||||
$asked_host = self::normalize_host( (string) \wp_parse_url( $authority, \PHP_URL_HOST ) );
|
||||
|
||||
if ( ! $signer_host || ! $asked_host || $signer_host !== $asked_host ) {
|
||||
@ -279,11 +289,11 @@ class Followers_Controller extends Actors_Controller {
|
||||
\sprintf(
|
||||
'actors/%d/followers/sync?authority=%s',
|
||||
$user_id,
|
||||
rawurlencode( $authority )
|
||||
\rawurlencode( $authority )
|
||||
)
|
||||
),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => count( $followers ),
|
||||
'totalItems' => \count( $followers ),
|
||||
'orderedItems' => $followers,
|
||||
);
|
||||
|
||||
@ -316,38 +326,6 @@ class Followers_Controller extends Actors_Controller {
|
||||
return \rtrim( $host, '.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the signing peer's host from the request's HTTP Signature header.
|
||||
*
|
||||
* Supports both Cavage-style `Signature: keyId="…"` and RFC 9421's
|
||||
* `Signature-Input: …keyid="…"`. Returns the host component of the key
|
||||
* ID URI, lowercased, or an empty string when none is present.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return string The signer's host, or an empty string.
|
||||
*/
|
||||
private static function get_signer_host( $request ) {
|
||||
$signature = $request->get_header( 'signature' );
|
||||
$key_id = null;
|
||||
|
||||
if ( $signature && \preg_match( '/keyId="([^"]+)"/i', $signature, $matches ) ) {
|
||||
$key_id = $matches[1];
|
||||
} else {
|
||||
$signature_input = $request->get_header( 'signature-input' );
|
||||
if ( $signature_input && \preg_match( '/keyid="([^"]+)"/i', $signature_input, $matches ) ) {
|
||||
$key_id = $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $key_id ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return \strtolower( (string) \wp_parse_url( $key_id, \PHP_URL_HOST ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the followers schema, conforming to JSON Schema.
|
||||
*
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user