From 5565c266323faba2ed1660ae825484af48a563d1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 Dec 2023 15:46:12 +0100 Subject: [PATCH 01/98] init --- includes/class-activitypub.php | 22 +++++++++- includes/class-handler.php | 55 ++++++++++++++++++++++- includes/class-scheduler.php | 52 ---------------------- includes/collection/class-outbox.php | 9 ++++ includes/transformer/class-base.php | 55 +++++++++++++++++++++++ includes/transformer/class-post.php | 65 ++++++++-------------------- 6 files changed, 156 insertions(+), 102 deletions(-) create mode 100644 includes/collection/class-outbox.php create mode 100644 includes/transformer/class-base.php diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 6f654c5c4..091e25433 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -4,6 +4,7 @@ use Exception; use Activitypub\Signature; use Activitypub\Collection\Users; +use Activitypub\Collection\Outbox; use Activitypub\Collection\Followers; use function Activitypub\sanitize_url; @@ -336,11 +337,12 @@ public static function plugin_update_message( $data ) { } /** - * Register the "Followers" Taxonomy + * Register Custom Post Types * * @return void */ private static function register_post_types() { + // register Followers Post-Type register_post_type( Followers::POST_TYPE, array( @@ -409,5 +411,23 @@ private static function register_post_types() { ); do_action( 'activitypub_after_register_post_type' ); + + // register Outbox Post-Type + register_post_type( + Outbox::POST_TYPE, + array( + 'labels' => array( + 'name' => _x( 'Outbox', 'post_type plural name', 'activitypub' ), + 'singular_name' => _x( 'Outbox Item', 'post_type single name', 'activitypub' ), + ), + 'public' => true, + 'hierarchical' => false, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => false, + 'can_export' => true, + 'supports' => array(), + ) + ); } } diff --git a/includes/class-handler.php b/includes/class-handler.php index fcabd63c7..130b82a01 100644 --- a/includes/class-handler.php +++ b/includes/class-handler.php @@ -15,13 +15,15 @@ class Handler { * Initialize the class, registering WordPress hooks */ public static function init() { - self::register_handlers(); + self::register_inbox_handlers(); + + \add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 ); } /** * Register handlers. */ - public static function register_handlers() { + public static function register_inbox_handlers() { Create::init(); Delete::init(); Follow::init(); @@ -30,4 +32,53 @@ public static function register_handlers() { do_action( 'activitypub_register_handlers' ); } + + /** + * Schedule Activities. + * + * @param string $new_status New post status. + * @param string $old_status Old post status. + * @param WP_Post $post Post object. + */ + public static function schedule_post_activity( $new_status, $old_status, $post ) { + // Do not send activities if post is password protected. + if ( \post_password_required( $post ) ) { + return; + } + + // Check if post-type supports ActivityPub. + $post_types = \get_post_types_by_support( 'activitypub' ); + if ( ! \in_array( $post->post_type, $post_types, true ) ) { + return; + } + + $type = false; + + if ( 'publish' === $new_status && 'publish' !== $old_status ) { + $type = 'Create'; + } elseif ( 'publish' === $new_status ) { + $type = 'Update'; + } elseif ( 'trash' === $new_status ) { + $type = 'Delete'; + } + + if ( ! $type ) { + return; + } + + \wp_schedule_single_event( + \time(), + 'activitypub_send_activity', + array( $post, $type ) + ); + + \wp_schedule_single_event( + \time(), + sprintf( + 'activitypub_send_%s_activity', + \strtolower( $type ) + ), + array( $post ) + ); + } } diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 11f40dafb..c88ce2e8c 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -16,8 +16,6 @@ class Scheduler { * Initialize the class, registering WordPress hooks */ public static function init() { - \add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 ); - \add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) ); \add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) ); @@ -49,56 +47,6 @@ public static function deregister_schedules() { wp_unschedule_hook( 'activitypub_cleanup_followers' ); } - - /** - * Schedule Activities. - * - * @param string $new_status New post status. - * @param string $old_status Old post status. - * @param WP_Post $post Post object. - */ - public static function schedule_post_activity( $new_status, $old_status, $post ) { - // Do not send activities if post is password protected. - if ( \post_password_required( $post ) ) { - return; - } - - // Check if post-type supports ActivityPub. - $post_types = \get_post_types_by_support( 'activitypub' ); - if ( ! \in_array( $post->post_type, $post_types, true ) ) { - return; - } - - $type = false; - - if ( 'publish' === $new_status && 'publish' !== $old_status ) { - $type = 'Create'; - } elseif ( 'publish' === $new_status ) { - $type = 'Update'; - } elseif ( 'trash' === $new_status ) { - $type = 'Delete'; - } - - if ( ! $type ) { - return; - } - - \wp_schedule_single_event( - \time(), - 'activitypub_send_activity', - array( $post, $type ) - ); - - \wp_schedule_single_event( - \time(), - sprintf( - 'activitypub_send_%s_activity', - \strtolower( $type ) - ), - array( $post ) - ); - } - /** * Update followers * diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php new file mode 100644 index 000000000..4ca9d1912 --- /dev/null +++ b/includes/collection/class-outbox.php @@ -0,0 +1,9 @@ +object = $object; + } + + /** + * Transform the WordPress Object into an ActivityPub Object. + * + * @return Activitypub\Activity\Base_Object + */ + abstract public function to_object(); + + /** + * Transform the WordPress Object into an ActivityPub Activity. + * + * @param string $type The type of Activity to transform to. + * + * @return Activitypub\Activity\Activity + */ + //abstract public function to_activity( $type ); +} diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 721bbec0f..80ed7b918 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -2,10 +2,11 @@ namespace Activitypub\Transformer; use WP_Post; -use Activitypub\Collection\Users; +use Activitypub\Shortcodes; use Activitypub\Model\Blog_User; +use Activitypub\Transformer\Base; +use Activitypub\Collection\Users; use Activitypub\Activity\Base_Object; -use Activitypub\Shortcodes; use function Activitypub\esc_hashtag; use function Activitypub\is_single_user; @@ -22,37 +23,7 @@ * * - Activitypub\Activity\Base_Object */ -class Post { - - /** - * The WP_Post object. - * - * @var WP_Post - */ - protected $wp_post; - - /** - * Static function to Transform a WP_Post Object. - * - * This helps to chain the output of the Transformer. - * - * @param WP_Post $wp_post The WP_Post object - * - * @return void - */ - public static function transform( WP_Post $wp_post ) { - return new static( $wp_post ); - } - - /** - * - * - * @param WP_Post $wp_post - */ - public function __construct( WP_Post $wp_post ) { - $this->wp_post = $wp_post; - } - +class Post extends Base { /** * Transforms the WP_Post object to an ActivityPub Object * @@ -61,7 +32,7 @@ public function __construct( WP_Post $wp_post ) { * @return \Activitypub\Activity\Base_Object The ActivityPub Object */ public function to_object() { - $wp_post = $this->wp_post; + $wp_post = $this->object; $object = new Base_Object(); $object->set_id( $this->get_id() ); @@ -115,7 +86,7 @@ public function get_id() { * @return string The Posts URL. */ public function get_url() { - $post = $this->wp_post; + $post = $this->object; if ( 'trash' === get_post_status( $post ) ) { $permalink = \get_post_meta( $post->ID, 'activitypub_canonical_url', true ); @@ -139,7 +110,7 @@ protected function get_attributed_to() { return $user->get_url(); } - return Users::get_by_id( $this->wp_post->post_author )->get_url(); + return Users::get_by_id( $this->object->post_author )->get_url(); } /** @@ -152,7 +123,7 @@ protected function get_attachments() { // We maintain the image-centric naming for backwards compatibility. $max_media = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) ); - if ( site_supports_blocks() && \has_blocks( $this->wp_post->post_content ) ) { + if ( site_supports_blocks() && \has_blocks( $this->object->post_content ) ) { return $this->get_block_attachments( $max_media ); } @@ -172,7 +143,7 @@ protected function get_block_attachments( $max_media ) { return array(); } - $id = $this->wp_post->ID; + $id = $this->object->ID; $media_ids = array(); @@ -182,7 +153,7 @@ protected function get_block_attachments( $max_media ) { } if ( $max_media > 0 ) { - $blocks = \parse_blocks( $this->wp_post->post_content ); + $blocks = \parse_blocks( $this->object->post_content ); $media_ids = self::get_media_ids_from_blocks( $blocks, $media_ids, $max_media ); } @@ -203,7 +174,7 @@ protected function get_classic_editor_images( $max_images ) { return array(); } - $id = $this->wp_post->ID; + $id = $this->object->ID; $image_ids = array(); @@ -402,10 +373,10 @@ protected function get_object_type() { // Default to Article. $object_type = 'Article'; - $post_type = \get_post_type( $this->wp_post ); + $post_type = \get_post_type( $this->object ); switch ( $post_type ) { case 'post': - $post_format = \get_post_format( $this->wp_post ); + $post_format = \get_post_format( $this->object ); switch ( $post_format ) { case 'aside': case 'status': @@ -484,7 +455,7 @@ protected function get_cc() { protected function get_tags() { $tags = array(); - $post_tags = \get_the_tags( $this->wp_post->ID ); + $post_tags = \get_the_tags( $this->object->ID ); if ( $post_tags ) { foreach ( $post_tags as $post_tag ) { $tag = array( @@ -531,7 +502,7 @@ protected function get_content() { do_action( 'activitypub_before_get_content', $post ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $post = $this->wp_post; + $post = $this->object; $content = $this->get_post_content_template(); // Register our shortcodes just in time. @@ -580,7 +551,7 @@ protected function get_post_content_template() { * @return array The list of @-Mentions. */ protected function get_mentions() { - return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_post->post_content, $this->wp_post ); + return apply_filters( 'activitypub_extract_mentions', array(), $this->object->post_content, $this->object ); } /** @@ -589,7 +560,7 @@ protected function get_mentions() { * @return string The locale of the post. */ public function get_locale() { - $post_id = $this->wp_post->ID; + $post_id = $this->object->ID; $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); /** @@ -601,6 +572,6 @@ public function get_locale() { * * @return string The filtered locale of the post. */ - return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_post ); + return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->object ); } } From 7448cd664f2e04ad0fac58e811f7f7a4a06bb779 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 Dec 2023 15:51:30 +0100 Subject: [PATCH 02/98] outbox should not be public! --- includes/class-activitypub.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 091e25433..a05c7a61a 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -420,7 +420,7 @@ private static function register_post_types() { 'name' => _x( 'Outbox', 'post_type plural name', 'activitypub' ), 'singular_name' => _x( 'Outbox Item', 'post_type single name', 'activitypub' ), ), - 'public' => true, + 'public' => false, 'hierarchical' => false, 'rewrite' => false, 'query_var' => false, From 6ac167f908f5f718bc02c13b71da8bcd67de3416 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 11 Dec 2023 10:41:02 +0100 Subject: [PATCH 03/98] add basic `to_activity` function --- includes/transformer/class-base.php | 4 ++-- includes/transformer/class-post.php | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index e26e9cf82..ca787f12d 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -45,11 +45,11 @@ public function __construct( $object ) { abstract public function to_object(); /** - * Transform the WordPress Object into an ActivityPub Activity. + * Transform the ActivityPub Object into an Activity. * * @param string $type The type of Activity to transform to. * * @return Activitypub\Activity\Activity */ - //abstract public function to_activity( $type ); + abstract public function to_activity( $type ); } diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 80ed7b918..dbd573be1 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -6,6 +6,7 @@ use Activitypub\Model\Blog_User; use Activitypub\Transformer\Base; use Activitypub\Collection\Users; +use Activitypub\Activity\Activity; use Activitypub\Activity\Base_Object; use function Activitypub\esc_hashtag; @@ -71,6 +72,23 @@ public function to_object() { return $object; } + /** + * Transforms the ActivityPub Object to an Activity + * + * @param string $type The Activity-Type. + * + * @return \Activitypub\Activity\Activity The Activity. + */ + public function to_activity( $type ) { + $object = $this->to_object(); + + $activity = new Activity(); + $activity->set_type( $type ); + $activity->set_object( $object ); + + return $activity; + } + /** * Returns the ID of the Post. * From 89ece407ae0c6d286490848f0d82b8782ed97224 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 11 Dec 2023 18:48:28 +0100 Subject: [PATCH 04/98] do not allow to instance post transformer --- includes/handler/class-update.php | 4 ++-- includes/transformer/class-base.php | 2 +- templates/post-json.php | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index 00e0430b6..6286bc652 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -20,8 +20,8 @@ public static function init() { /** * Handle "Update" requests * - * @param array $array The activity-object - * @param int $user_id The id of the local blog-user + * @param array $array The activity-object + * @param int $user_id The id of the local blog-user */ public static function handle_update( $array, $user_id ) { $object_type = isset( $array['object']['type'] ) ? $array['object']['type'] : ''; diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index ca787f12d..43c4b5379 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -33,7 +33,7 @@ public static function transform( $object ) { * * @param stdClass $object */ - public function __construct( $object ) { + private function __construct( $object ) { $this->object = $object; } diff --git a/templates/post-json.php b/templates/post-json.php index 89467c466..80a9af8be 100644 --- a/templates/post-json.php +++ b/templates/post-json.php @@ -2,8 +2,8 @@ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited $post = \get_post(); -$object = new \Activitypub\Transformer\Post( $post ); -$json = \array_merge( array( '@context' => \Activitypub\get_context() ), $object->to_object()->to_array() ); +$object = \Activitypub\Transformer\Post::transform( $post )->to_object(); +$json = \array_merge( array( '@context' => \Activitypub\get_context() ), $object->to_array() ); // filter output $json = \apply_filters( 'activitypub_json_post_array', $json ); From efda4a565493696c6775a7ab3814ddb2ada0d6b6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 12 Dec 2023 14:10:21 +0100 Subject: [PATCH 05/98] fix tests --- includes/transformer/class-post.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 578d1164d..388594eb4 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -565,7 +565,7 @@ protected function get_post_content_template() { break; } - return apply_filters( 'activitypub_object_content_template', $template, $this->wp_post ); + return apply_filters( 'activitypub_object_content_template', $template, $this->object ); } /** From 5d6649483dd7c3883e1c91ea8f75e3529f326008 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 27 Sep 2024 14:51:58 +0200 Subject: [PATCH 06/98] fix phpcs --- includes/transformer/class-post.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 3d9ba0517..8c747c847 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -114,9 +114,9 @@ public function to_activity( $type ) { $activity->set_object( $object ); return $activity; - } - - /** + } + + /** * Returns the User-Object of the Author of the Post. * * If `single_user` mode is enabled, the Blog-User is returned. From b1d260d40d573fca84a771e1b8cb87cf7be705f2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 27 Sep 2024 14:53:11 +0200 Subject: [PATCH 07/98] revert change --- includes/class-handler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-handler.php b/includes/class-handler.php index 03da7c44b..7ca397248 100644 --- a/includes/class-handler.php +++ b/includes/class-handler.php @@ -17,7 +17,7 @@ class Handler { * Initialize the class, registering WordPress hooks */ public static function init() { - self::register_inbox_handlers(); + self::register_handlers(); \add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 ); } From 81b0b88326a57a767d8dd1ac84f4b9a4a1488dc4 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 27 Sep 2024 14:54:39 +0200 Subject: [PATCH 08/98] remove unused `use` declarations --- includes/transformer/class-post.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 8c747c847..938db3d4f 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -5,9 +5,8 @@ use Activitypub\Shortcodes; use Activitypub\Model\Blog; use Activitypub\Collection\Users; -use Activitypub\Activity\Activity; -use Activitypub\Activity\Base_Object; use Activitypub\Transformer\Base; +use Activitypub\Activity\Activity; use function Activitypub\esc_hashtag; use function Activitypub\is_single_user; From 8c0c3fa886f28d04598563d8179a2633455105b4 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 27 Sep 2024 14:56:54 +0200 Subject: [PATCH 09/98] the handler should not handle outgoing stuff --- includes/class-handler.php | 51 -------------------------------------- 1 file changed, 51 deletions(-) diff --git a/includes/class-handler.php b/includes/class-handler.php index 7ca397248..ef5245007 100644 --- a/includes/class-handler.php +++ b/includes/class-handler.php @@ -18,8 +18,6 @@ class Handler { */ public static function init() { self::register_handlers(); - - \add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 ); } /** @@ -39,53 +37,4 @@ public static function register_handlers() { do_action( 'activitypub_register_handlers' ); } - - /** - * Schedule Activities. - * - * @param string $new_status New post status. - * @param string $old_status Old post status. - * @param WP_Post $post Post object. - */ - public static function schedule_post_activity( $new_status, $old_status, $post ) { - // Do not send activities if post is password protected. - if ( \post_password_required( $post ) ) { - return; - } - - // Check if post-type supports ActivityPub. - $post_types = \get_post_types_by_support( 'activitypub' ); - if ( ! \in_array( $post->post_type, $post_types, true ) ) { - return; - } - - $type = false; - - if ( 'publish' === $new_status && 'publish' !== $old_status ) { - $type = 'Create'; - } elseif ( 'publish' === $new_status ) { - $type = 'Update'; - } elseif ( 'trash' === $new_status ) { - $type = 'Delete'; - } - - if ( ! $type ) { - return; - } - - \wp_schedule_single_event( - \time(), - 'activitypub_send_activity', - array( $post, $type ) - ); - - \wp_schedule_single_event( - \time(), - sprintf( - 'activitypub_send_%s_activity', - \strtolower( $type ) - ), - array( $post ) - ); - } } From 43a3343d376b07cc0781f6605b4fe8d8e70341ab Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 22 Oct 2024 17:29:17 +0200 Subject: [PATCH 10/98] Update class-activitypub.php --- includes/class-activitypub.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 0ebbf6085..9c94c8a84 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -8,9 +8,6 @@ namespace Activitypub; use Exception; -use Activitypub\Signature; -use Activitypub\Collection\Users; -use Activitypub\Collection\Outbox; use Activitypub\Collection\Followers; use Activitypub\Collection\Extra_Fields; From 87464476454c6950804c1c4382e7645527b3b670 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 22 Oct 2024 17:31:04 +0200 Subject: [PATCH 11/98] fix PHPCS --- includes/class-activitypub.php | 2 +- includes/transformer/class-base.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 9c94c8a84..12c54f0df 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -516,7 +516,7 @@ private static function register_post_types() { ) ); - // register Outbox Post-Type + // Register Outbox Post-Type. register_post_type( Outbox::POST_TYPE, array( diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 6d4c202a6..0efb44dca 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -11,8 +11,8 @@ use WP_Comment; use Activitypub\Activity\Activity; -use Activitypub\Activity\Base_Object; use Activitypub\Collection\Replies; +use Activitypub\Activity\Base_Object; /** * WordPress Base Transformer. From 242dea40da4aa3c918c070ae749d6a10898a8f31 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 22 Oct 2024 17:34:40 +0200 Subject: [PATCH 12/98] more PHPCS fixes --- includes/collection/class-outbox.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 4ca9d1912..0234db76f 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -1,4 +1,10 @@ Date: Tue, 22 Oct 2024 17:37:27 +0200 Subject: [PATCH 13/98] fix namespace issue --- includes/class-activitypub.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 12c54f0df..2d420d64a 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -8,6 +8,7 @@ namespace Activitypub; use Exception; +use Activitypub\Collection\Outbox; use Activitypub\Collection\Followers; use Activitypub\Collection\Extra_Fields; From 5ca19a64eba7f83d47e68524ab3186ec45233af2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 22 Oct 2024 17:42:31 +0200 Subject: [PATCH 14/98] remove unneeded function --- includes/transformer/class-post.php | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 2b4170a29..a1cff1ea0 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -87,23 +87,6 @@ public function to_object() { return $object; } - /** - * Transforms the ActivityPub Object to an Activity - * - * @param string $type The Activity-Type. - * - * @return \Activitypub\Activity\Activity The Activity. - */ - public function to_activity( $type ) { - $object = $this->to_object(); - - $activity = new Activity(); - $activity->set_type( $type ); - $activity->set_object( $object ); - - return $activity; - } - /** * Returns the User-Object of the Author of the Post. * From 9f525b0f75d3d2563e94114bffae68762b7e1e07 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 22 Oct 2024 20:54:17 +0200 Subject: [PATCH 15/98] fix sticky post endpoint /cc @mattwiebe --- includes/rest/class-collection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-collection.php b/includes/rest/class-collection.php index 06f2203b8..a37d91df3 100644 --- a/includes/rest/class-collection.php +++ b/includes/rest/class-collection.php @@ -218,7 +218,7 @@ public static function featured_get( $request ) { if ( ! is_single_user() && User_Collection::BLOG_USER_ID === $user->get__id() ) { $posts = array(); - } elseif ( is_array( $sticky_posts ) ) { + } elseif ( $sticky_posts && is_array( $sticky_posts ) ) { $args = array( 'post__in' => $sticky_posts, 'ignore_sticky_posts' => 1, From e0aeefdb9b607acdd2456e6b1d6a54d313c3aa5b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 22 Oct 2024 21:03:20 +0200 Subject: [PATCH 16/98] no need to check for User-ID --- includes/rest/class-actors.php | 5 ----- includes/rest/class-collection.php | 5 ----- includes/rest/class-followers.php | 5 ----- includes/rest/class-following.php | 5 ----- includes/rest/class-inbox.php | 13 ------------- includes/rest/class-outbox.php | 5 ----- 6 files changed, 38 deletions(-) diff --git a/includes/rest/class-actors.php b/includes/rest/class-actors.php index 60f03d298..aa15c0c24 100644 --- a/includes/rest/class-actors.php +++ b/includes/rest/class-actors.php @@ -151,11 +151,6 @@ public static function request_parameters() { 'type' => 'string', ); - $params['user_id'] = array( - 'required' => true, - 'type' => 'string', - ); - return $params; } } diff --git a/includes/rest/class-collection.php b/includes/rest/class-collection.php index a37d91df3..0df17ba91 100644 --- a/includes/rest/class-collection.php +++ b/includes/rest/class-collection.php @@ -292,11 +292,6 @@ public static function moderators_get() { public static function request_parameters() { $params = array(); - $params['user_id'] = array( - 'required' => true, - 'type' => 'string', - ); - return $params; } diff --git a/includes/rest/class-followers.php b/includes/rest/class-followers.php index 1b38187f3..50bec2458 100644 --- a/includes/rest/class-followers.php +++ b/includes/rest/class-followers.php @@ -138,11 +138,6 @@ public static function request_parameters() { 'enum' => array( 'asc', 'desc' ), ); - $params['user_id'] = array( - 'required' => true, - 'type' => 'string', - ); - $params['context'] = array( 'type' => 'string', 'default' => 'simple', diff --git a/includes/rest/class-following.php b/includes/rest/class-following.php index cda058962..4b0f4674e 100644 --- a/includes/rest/class-following.php +++ b/includes/rest/class-following.php @@ -111,11 +111,6 @@ public static function request_parameters() { 'type' => 'integer', ); - $params['user_id'] = array( - 'required' => true, - 'type' => 'string', - ); - return $params; } diff --git a/includes/rest/class-inbox.php b/includes/rest/class-inbox.php index b5f5fa9b1..15837bc9d 100644 --- a/includes/rest/class-inbox.php +++ b/includes/rest/class-inbox.php @@ -215,11 +215,6 @@ public static function user_inbox_get_parameters() { 'type' => 'integer', ); - $params['user_id'] = array( - 'required' => true, - 'type' => 'string', - ); - return $params; } @@ -231,11 +226,6 @@ public static function user_inbox_get_parameters() { public static function user_inbox_post_parameters() { $params = array(); - $params['user_id'] = array( - 'required' => true, - 'type' => 'string', - ); - $params['id'] = array( 'required' => true, 'sanitize_callback' => 'esc_url_raw', @@ -276,9 +266,6 @@ public static function user_inbox_post_parameters() { public static function shared_inbox_post_parameters() { $params = self::user_inbox_post_parameters(); - // A shared Inbox does not need a User-ID. - unset( $params['user_id'] ); - $params['to'] = array( 'required' => false, 'sanitize_callback' => function ( $param ) { diff --git a/includes/rest/class-outbox.php b/includes/rest/class-outbox.php index 461d42861..1dff85ec6 100644 --- a/includes/rest/class-outbox.php +++ b/includes/rest/class-outbox.php @@ -163,11 +163,6 @@ public static function request_parameters() { 'default' => 1, ); - $params['user_id'] = array( - 'required' => true, - 'type' => 'string', - ); - return $params; } } From 934599164caff3fdfaf9e314916e70d8769fe290 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 5 Nov 2024 11:46:55 +0100 Subject: [PATCH 17/98] support JSON and Arrays beside WP_Comments and WP_Posts --- includes/activity/class-base-object.php | 10 +-- includes/collection/class-outbox.php | 42 ++++++++++ .../transformer/class-activity-object.php | 31 +++++++ includes/transformer/class-attachment.php | 6 +- includes/transformer/class-base.php | 24 ++++-- includes/transformer/class-comment.php | 26 +++--- includes/transformer/class-factory.php | 14 +++- includes/transformer/class-json.php | 41 ++++++++++ includes/transformer/class-post.php | 82 +++++++++---------- .../class-seriously-simple-podcasting.php | 4 +- 10 files changed, 203 insertions(+), 77 deletions(-) create mode 100644 includes/transformer/class-activity-object.php create mode 100644 includes/transformer/class-json.php diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index ab765174f..a75647ff2 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -585,7 +585,7 @@ public static function init_from_json( $json ) { $array = \json_decode( $json, true ); if ( ! is_array( $array ) ) { - $array = array(); + return new WP_Error( 'invalid_json', __( 'Invalid JSON', 'activitypub' ), array( 'status' => 400 ) ); } return self::init_from_array( $array ); @@ -600,15 +600,11 @@ public static function init_from_json( $json ) { */ public static function init_from_array( $data ) { if ( ! is_array( $data ) ) { - return new WP_Error( 'invalid_array', __( 'Invalid array', 'activitypub' ), array( 'status' => 404 ) ); + return new WP_Error( 'invalid_array', __( 'Invalid array', 'activitypub' ), array( 'status' => 400 ) ); } $object = new static(); - - foreach ( $data as $key => $value ) { - $key = camel_to_snake_case( $key ); - call_user_func( array( $object, 'set_' . $key ), $value ); - } + $object->from_array( $data ); return $object; } diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 0234db76f..acda0053b 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -7,9 +7,51 @@ namespace Activitypub\Collection; +use Activitypub\Transformer\Factory; + /** * ActivityPub Outbox Collection */ class Outbox { const POST_TYPE = 'ap_outbox'; + + /** + * Add an Item to the outbox. + * + * @param string|array|Base_Object|WP_Post|WP_Comment $item The item to add. + * @param int $user_id The user ID. + * @param string $activity_type The activity + * + * @return mixed The added item or an error. + */ + public static function add_item( $item, $user_id, $activity_type = 'Create' ) { + $transformer = Factory::get_transformer( $item ); + $object = $transformer->transform(); + + if ( ! $object || is_wp_error( $object ) ) { + return $object; + } + + $outbox_item = array( + 'post_type' => self::POST_TYPE, + 'post_title' => $object->get_id(), + 'post_content' => $object->to_json(), + 'post_author' => $user_id, + 'post_status' => 'draft', + ); + + $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' ); + if ( $has_kses ) { + // Prevent KSES from corrupting JSON in post_content. + \kses_remove_filters(); + } + + $result = \wp_insert_post( $outbox_item, true ); + + if ( $has_kses ) { + \kses_init_filters(); + } + + return $result; + } } diff --git a/includes/transformer/class-activity-object.php b/includes/transformer/class-activity-object.php new file mode 100644 index 000000000..bfe567813 --- /dev/null +++ b/includes/transformer/class-activity-object.php @@ -0,0 +1,31 @@ +item; + } + + /** + * Get the ID of the WordPress Object. + * + * @return string The ID of the WordPress Object. + */ + protected function get_id() { + return ''; + } +} diff --git a/includes/transformer/class-attachment.php b/includes/transformer/class-attachment.php index 98aaf8bf4..ef3e1d1fd 100644 --- a/includes/transformer/class-attachment.php +++ b/includes/transformer/class-attachment.php @@ -24,7 +24,7 @@ class Attachment extends Post { * @return array The Attachments. */ protected function get_attachment() { - $mime_type = get_post_mime_type( $this->wp_object->ID ); + $mime_type = get_post_mime_type( $this->item->ID ); $media_type = preg_replace( '/(\/[a-zA-Z]+)/i', '', $mime_type ); $type = ''; @@ -40,11 +40,11 @@ protected function get_attachment() { $attachment = array( 'type' => $type, - 'url' => wp_get_attachment_url( $this->wp_object->ID ), + 'url' => wp_get_attachment_url( $this->item->ID ), 'mediaType' => $mime_type, ); - $alt = \get_post_meta( $this->wp_object->ID, '_wp_attachment_image_alt', true ); + $alt = \get_post_meta( $this->item->ID, '_wp_attachment_image_alt', true ); if ( $alt ) { $attachment['name'] = $alt; } diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 0efb44dca..bca105f3a 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -26,6 +26,15 @@ abstract class Base { * * This is the source object of the transformer. * + * @var WP_Post|WP_Comment|Base_Object|string|array + */ + protected $item; + + /** + * The WP_Post or WP_Comment object. + * + * @deprecated version 5.0.0 + * * @var WP_Post|WP_Comment */ protected $wp_object; @@ -35,21 +44,22 @@ abstract class Base { * * This helps to chain the output of the Transformer. * - * @param WP_Post|WP_Comment $wp_object The WordPress object. + * @param WP_Post|WP_Comment|Base_Object|string|array $item The WordPress object. * * @return Base */ - public static function transform( $wp_object ) { - return new static( $wp_object ); + public static function transform( $item ) { + return new static( $item ); } /** * Base constructor. * - * @param WP_Post|WP_Comment $wp_object The WordPress object. + * @param WP_Post|WP_Comment|Base_Object|string|array $item The WordPress object. */ - public function __construct( $wp_object ) { - $this->wp_object = $wp_object; + public function __construct( $item ) { + $this->item = $item; + $this->wp_object = $item; } /** @@ -122,7 +132,7 @@ abstract protected function get_id(); * Get the replies Collection. */ public function get_replies() { - return Replies::get_collection( $this->wp_object ); + return Replies::get_collection( $this->item ); } /** diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index 71fbb2dd4..d9ee1e0e9 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -33,7 +33,7 @@ class Comment extends Base { * @return int The User-ID of the WordPress Comment */ public function get_wp_user_id() { - return $this->wp_object->user_id; + return $this->item->user_id; } /** @@ -42,7 +42,7 @@ public function get_wp_user_id() { * @param int $user_id The new user ID. */ public function change_wp_user_id( $user_id ) { - $this->wp_object->user_id = $user_id; + $this->item->user_id = $user_id; } /** @@ -53,7 +53,7 @@ public function change_wp_user_id( $user_id ) { * @return \Activitypub\Activity\Base_Object The ActivityPub Object. */ public function to_object() { - $comment = $this->wp_object; + $comment = $this->item; $object = parent::to_object(); $object->set_url( $this->get_id() ); @@ -97,7 +97,7 @@ protected function get_attributed_to() { return $user->get_id(); } - return Users::get_by_id( $this->wp_object->user_id )->get_id(); + return Users::get_by_id( $this->item->user_id )->get_id(); } /** @@ -108,7 +108,7 @@ protected function get_attributed_to() { * @return string The content. */ protected function get_content() { - $comment = $this->wp_object; + $comment = $this->item; $content = $comment->comment_content; /** @@ -141,7 +141,7 @@ protected function get_content() { * @return false|string|null The URL of the in-reply-to. */ protected function get_in_reply_to() { - $comment = $this->wp_object; + $comment = $this->item; $parent_comment = null; if ( $comment->comment_parent ) { @@ -169,7 +169,7 @@ protected function get_in_reply_to() { * @return string ActivityPub URI for comment */ protected function get_id() { - $comment = $this->wp_object; + $comment = $this->item; return Comment_Utils::generate_id( $comment ); } @@ -235,7 +235,7 @@ protected function get_mentions() { * * @return array The filtered list of mentions. */ - return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_object->comment_content, $this->wp_object ); + return apply_filters( 'activitypub_extract_mentions', array(), $this->item->comment_content, $this->item ); } /** @@ -244,7 +244,7 @@ protected function get_mentions() { * @return array The list of ancestors. */ protected function get_comment_ancestors() { - $ancestors = get_comment_ancestors( $this->wp_object ); + $ancestors = get_comment_ancestors( $this->item ); // Now that we have the full tree of ancestors, only return the ones received from the fediverse. return array_filter( @@ -264,8 +264,8 @@ function ( $comment_id ) { * @return array The list of all Repliers. */ public function extract_reply_context( $mentions ) { - // Check if `$this->wp_object` is a WP_Comment. - if ( 'WP_Comment' !== get_class( $this->wp_object ) ) { + // Check if `$this->item` is a WP_Comment. + if ( 'WP_Comment' !== get_class( $this->item ) ) { return $mentions; } @@ -294,7 +294,7 @@ public function extract_reply_context( $mentions ) { * @return string The locale of the post. */ public function get_locale() { - $comment_id = $this->wp_object->ID; + $comment_id = $this->item->ID; $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); /** @@ -306,6 +306,6 @@ public function get_locale() { * * @return string The filtered locale of the comment. */ - return apply_filters( 'activitypub_comment_locale', $lang, $comment_id, $this->wp_object ); + return apply_filters( 'activitypub_comment_locale', $lang, $comment_id, $this->item ); } } diff --git a/includes/transformer/class-factory.php b/includes/transformer/class-factory.php index a619423af..ae67a6f16 100644 --- a/includes/transformer/class-factory.php +++ b/includes/transformer/class-factory.php @@ -21,12 +21,14 @@ class Factory { * @return Base|WP_Error The transformer to use, or an error. */ public static function get_transformer( $data ) { - if ( ! \is_object( $data ) ) { + if ( \is_array( $data ) || \is_string( $data ) ) { + $class = 'json'; + } elseif ( \is_object( $data ) ) { + $class = \get_class( $data ); + } else { return new WP_Error( 'invalid_object', __( 'Invalid object', 'activitypub' ) ); } - $class = \get_class( $data ); - /** * Filter the transformer for a given object. * @@ -78,8 +80,12 @@ public static function get_transformer( $data ) { return new Post( $data ); case 'WP_Comment': return new Comment( $data ); + case 'Base_Object': + return new Activity_Object( $data ); + case 'json': + return new Json( $data ); default: - return null; + return new WP_Error( 'invalid_object', __( 'Invalid object', 'activitypub' ) ); } } } diff --git a/includes/transformer/class-json.php b/includes/transformer/class-json.php new file mode 100644 index 000000000..1eaeb5062 --- /dev/null +++ b/includes/transformer/class-json.php @@ -0,0 +1,41 @@ +item ) ) { + $activitypub_object = Base_Object::init_from_array( $this->item ); + } else { + $activitypub_object = Base_Object::init_from_json( $this->item ); + } + + return $activitypub_object; + } + + /** + * Get the ID of the WordPress Object. + * + * @return string The ID of the WordPress Object. + */ + protected function get_id() { + return ''; + } +} diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 23dd46e67..8644113e4 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -44,7 +44,7 @@ class Post extends Base { * @return int The ID of the WordPress Post */ public function get_wp_user_id() { - return $this->wp_object->post_author; + return $this->item->post_author; } /** @@ -55,7 +55,7 @@ public function get_wp_user_id() { * @return Post The Post Object. */ public function change_wp_user_id( $user_id ) { - $this->wp_object->post_author = $user_id; + $this->item->post_author = $user_id; return $this; } @@ -68,7 +68,7 @@ public function change_wp_user_id( $user_id ) { * @return \Activitypub\Activity\Base_Object The ActivityPub Object */ public function to_object() { - $post = $this->wp_object; + $post = $this->item; $object = parent::to_object(); $content_warning = get_content_warning( $post ); @@ -113,7 +113,7 @@ protected function get_actor_object() { return $blog_user; } - $user = Users::get_by_id( $this->wp_object->post_author ); + $user = Users::get_by_id( $this->item->post_author ); if ( $user && ! is_wp_error( $user ) ) { $this->actor_object = $user; @@ -130,7 +130,7 @@ protected function get_actor_object() { */ public function get_id() { $last_legacy_id = (int) \get_option( 'activitypub_last_post_with_permalink_as_id', 0 ); - $post_id = (int) $this->wp_object->ID; + $post_id = (int) $this->item->ID; if ( $post_id > $last_legacy_id ) { // Generate URI based on post ID. @@ -146,7 +146,7 @@ public function get_id() { * @return string The Posts URL. */ public function get_url() { - $post = $this->wp_object; + $post = $this->item; switch ( \get_post_status( $post ) ) { case 'trash': @@ -186,7 +186,7 @@ protected function get_attributed_to() { */ protected function get_attachment() { // Remove attachments from drafts. - if ( 'draft' === \get_post_status( $this->wp_object ) ) { + if ( 'draft' === \get_post_status( $this->item ) ) { return array(); } @@ -204,7 +204,7 @@ protected function get_attachment() { 'video' => array(), 'image' => array(), ); - $id = $this->wp_object->ID; + $id = $this->item->ID; // List post thumbnail first if this post has one. if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) { @@ -213,13 +213,13 @@ protected function get_attachment() { $media = $this->get_enclosures( $media ); - if ( site_supports_blocks() && \has_blocks( $this->wp_object->post_content ) ) { + if ( site_supports_blocks() && \has_blocks( $this->item->post_content ) ) { $media = $this->get_block_attachments( $media, $max_media ); } else { $media = $this->get_classic_editor_images( $media, $max_media ); } - $media = self::filter_media_by_object_type( $media, \get_post_format( $this->wp_object ), $this->wp_object ); + $media = self::filter_media_by_object_type( $media, \get_post_format( $this->item ), $this->item ); $unique_ids = \array_unique( \array_column( $media, 'id' ) ); $media = \array_intersect_key( $media, $unique_ids ); $media = \array_slice( $media, 0, $max_media ); @@ -228,11 +228,11 @@ protected function get_attachment() { * Filter the attachment IDs for a post. * * @param array $media The media array grouped by type. - * @param WP_Post $this->wp_object The post object. + * @param WP_Post $this->item The post object. * * @return array The filtered attachment IDs. */ - $media = \apply_filters( 'activitypub_attachment_ids', $media, $this->wp_object ); + $media = \apply_filters( 'activitypub_attachment_ids', $media, $this->item ); $attachments = \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $media ) ); @@ -240,11 +240,11 @@ protected function get_attachment() { * Filter the attachments for a post. * * @param array $attachments The attachments. - * @param WP_Post $this->wp_object The post object. + * @param WP_Post $this->item The post object. * * @return array The filtered attachments. */ - return \apply_filters( 'activitypub_attachments', $attachments, $this->wp_object ); + return \apply_filters( 'activitypub_attachments', $attachments, $this->item ); } /** @@ -255,7 +255,7 @@ protected function get_attachment() { * @return array The media array extended with enclosures. */ public function get_enclosures( $media ) { - $enclosures = get_enclosures( $this->wp_object->ID ); + $enclosures = get_enclosures( $this->item->ID ); if ( ! $enclosures ) { return $media; @@ -303,7 +303,7 @@ protected function get_block_attachments( $media, $max_media ) { return array(); } - $blocks = \parse_blocks( $this->wp_object->post_content ); + $blocks = \parse_blocks( $this->item->post_content ); return self::get_media_from_blocks( $blocks, $media ); } @@ -425,7 +425,7 @@ protected function get_classic_editor_image_embeds( $max_images ) { $images = array(); $base = \wp_get_upload_dir()['baseurl']; - $content = \get_post_field( 'post_content', $this->wp_object ); + $content = \get_post_field( 'post_content', $this->item ); $tags = new \WP_HTML_Tag_Processor( $content ); // This linter warning is a false positive - we have to re-count each time here as we modify $images. @@ -489,7 +489,7 @@ protected function get_classic_editor_image_attachments( $max_images ) { $images = array(); $query = new \WP_Query( array( - 'post_parent' => $this->wp_object->ID, + 'post_parent' => $this->item->ID, 'post_status' => 'inherit', 'post_type' => 'attachment', 'post_mime_type' => 'image', @@ -513,20 +513,20 @@ protected function get_classic_editor_image_attachments( $max_images ) { * * @param array $media The media array grouped by type. * @param string $type The object type. - * @param WP_Post $wp_object The post object. + * @param WP_Post $item The post object. * * @return array The filtered media IDs. */ - protected static function filter_media_by_object_type( $media, $type, $wp_object ) { + protected static function filter_media_by_object_type( $media, $type, $item ) { /** * Filter the object type for media attachments. * * @param string $type The object type. - * @param WP_Post $wp_object The post object. + * @param WP_Post $item The post object. * * @return string The filtered object type. */ - $type = \apply_filters( 'filter_media_by_object_type', \strtolower( $type ), $wp_object ); + $type = \apply_filters( 'filter_media_by_object_type', \strtolower( $type ), $item ); if ( ! empty( $media[ $type ] ) ) { return $media[ $type ]; @@ -664,13 +664,13 @@ protected function get_type() { return \ucfirst( $post_format_setting ); } - $has_title = \post_type_supports( $this->wp_object->post_type, 'title' ); - $content = \wp_strip_all_tags( $this->wp_object->post_content ); + $has_title = \post_type_supports( $this->item->post_type, 'title' ); + $content = \wp_strip_all_tags( $this->item->post_content ); // Check if the post has a title. if ( ! $has_title || - ! $this->wp_object->post_title || + ! $this->item->post_title || \strlen( $content ) <= ACTIVITYPUB_NOTE_LENGTH ) { return 'Note'; @@ -681,10 +681,10 @@ protected function get_type() { $post_format = 'standard'; if ( \get_theme_support( 'post-formats' ) ) { - $post_format = \get_post_format( $this->wp_object ); + $post_format = \get_post_format( $this->item ); } - $post_type = \get_post_type( $this->wp_object ); + $post_type = \get_post_type( $this->item ); switch ( $post_type ) { case 'post': switch ( $post_format ) { @@ -767,7 +767,7 @@ public function get_audience() { protected function get_tag() { $tags = array(); - $post_tags = \get_the_tags( $this->wp_object->ID ); + $post_tags = \get_the_tags( $this->item->ID ); if ( $post_tags ) { foreach ( $post_tags as $post_tag ) { $tag = array( @@ -812,7 +812,7 @@ protected function get_summary() { return \__( '(This post is being modified)', 'activitypub' ); } - return generate_post_summary( $this->wp_object ); + return generate_post_summary( $this->item ); } /** @@ -828,7 +828,7 @@ protected function get_name() { return null; } - $title = \get_the_title( $this->wp_object->ID ); + $title = \get_the_title( $this->item->ID ); if ( $title ) { return \wp_strip_all_tags( @@ -852,7 +852,7 @@ protected function get_content() { add_filter( 'activitypub_reply_block', '__return_empty_string' ); // Remove Content from drafts. - if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->wp_object ) ) { + if ( 'draft' === \get_post_status( $this->wp_object ) ) { return \__( '(This post is being modified)', 'activitypub' ); } @@ -870,7 +870,7 @@ protected function get_content() { add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $post = $this->wp_object; + $post = $this->item; $content = $this->get_post_content_template(); // It seems that shortcodes are only applied to published posts. @@ -918,7 +918,7 @@ protected function get_post_content_template() { $template .= '[ap_content]'; } - return apply_filters( 'activitypub_object_content_template', $template, $this->wp_object ); + return apply_filters( 'activitypub_object_content_template', $template, $this->item ); } /** @@ -939,8 +939,8 @@ protected function get_mentions() { return apply_filters( 'activitypub_extract_mentions', array(), - $this->wp_object->post_content . ' ' . $this->wp_object->post_excerpt, - $this->wp_object + $this->item->post_content . ' ' . $this->item->post_excerpt, + $this->item ); } @@ -950,7 +950,7 @@ protected function get_mentions() { * @return string The locale of the post. */ public function get_locale() { - $post_id = $this->wp_object->ID; + $post_id = $this->item->ID; $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); /** @@ -962,7 +962,7 @@ public function get_locale() { * * @return string The filtered locale of the post. */ - return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_object ); + return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->item ); } /** @@ -973,7 +973,7 @@ public function get_locale() { * @return string|null The in-reply-to URL of the post. */ public function get_in_reply_to() { - $blocks = \parse_blocks( $this->wp_object->post_content ); + $blocks = \parse_blocks( $this->item->post_content ); foreach ( $blocks as $block ) { if ( 'activitypub/reply' === $block['blockName'] ) { @@ -991,7 +991,7 @@ public function get_in_reply_to() { * @return string The published date of the post. */ public function get_published() { - $published = \strtotime( $this->wp_object->post_date_gmt ); + $published = \strtotime( $this->item->post_date_gmt ); return \gmdate( 'Y-m-d\TH:i:s\Z', $published ); } @@ -1002,8 +1002,8 @@ public function get_published() { * @return string|null The updated date of the post. */ public function get_updated() { - $published = \strtotime( $this->wp_object->post_date_gmt ); - $updated = \strtotime( $this->wp_object->post_modified_gmt ); + $published = \strtotime( $this->item->post_date_gmt ); + $updated = \strtotime( $this->item->post_modified_gmt ); if ( $updated > $published ) { return \gmdate( 'Y-m-d\TH:i:s\Z', $updated ); diff --git a/integration/class-seriously-simple-podcasting.php b/integration/class-seriously-simple-podcasting.php index 8ca343b45..38df757aa 100644 --- a/integration/class-seriously-simple-podcasting.php +++ b/integration/class-seriously-simple-podcasting.php @@ -28,7 +28,7 @@ class Seriously_Simple_Podcasting extends Post { * @return array The attachments array. */ public function get_attachment() { - $post = $this->wp_object; + $post = $this->item; $attachment = array( 'type' => \esc_attr( ucfirst( \get_post_meta( $post->ID, 'episode_type', true ) ?? 'Audio' ) ), 'url' => \esc_url( \get_post_meta( $post->ID, 'audio_file', true ) ), @@ -62,6 +62,6 @@ public function get_type() { * @return string The content. */ public function get_content() { - return generate_post_summary( $this->wp_object ); + return generate_post_summary( $this->item ); } } From d1e98393e5da09e072cbd786d964de0d5bcafa87 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 5 Nov 2024 11:48:54 +0100 Subject: [PATCH 18/98] fix auto-complete issue --- includes/collection/class-outbox.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index acda0053b..36d95d836 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -20,7 +20,7 @@ class Outbox { * * @param string|array|Base_Object|WP_Post|WP_Comment $item The item to add. * @param int $user_id The user ID. - * @param string $activity_type The activity + * @param string $activity_type The activity type. * * @return mixed The added item or an error. */ From 59ab8b9607760d9a0a99fd7fe3807a74958c994e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 5 Nov 2024 13:43:16 +0100 Subject: [PATCH 19/98] simplify code. --- includes/collection/class-replies.php | 4 ++-- includes/transformer/class-base.php | 19 +++++++++++++++++++ includes/transformer/class-comment.php | 21 --------------------- includes/transformer/class-post.php | 21 --------------------- 4 files changed, 21 insertions(+), 44 deletions(-) diff --git a/includes/collection/class-replies.php b/includes/collection/class-replies.php index 2f10c004b..3981e2636 100644 --- a/includes/collection/class-replies.php +++ b/includes/collection/class-replies.php @@ -74,7 +74,7 @@ private static function get_id( $wp_object ) { } elseif ( $wp_object instanceof WP_Comment ) { return get_rest_url_by_path( sprintf( 'comments/%d/replies', $wp_object->comment_ID ) ); } else { - return new WP_Error(); + return new WP_Error( 'unsupported_object', 'The object is not a post or comment.' ); } } @@ -88,7 +88,7 @@ private static function get_id( $wp_object ) { public static function get_collection( $wp_object ) { $id = self::get_id( $wp_object ); - if ( ! $id ) { + if ( ! $id || is_wp_error( $id ) ) { return null; } diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index bca105f3a..80deb2644 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -146,4 +146,23 @@ abstract public function get_wp_user_id(); * @param int $user_id The new user ID. */ abstract public function change_wp_user_id( $user_id ); + + /** + * Returns a generic locale based on the Blog settings. + * + * @return string The locale of the blog. + */ + public function get_locale() { + $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); + + /** + * Filter the locale of the post. + * + * @param string $lang The locale of the post. + * @param mixed $item The post object. + * + * @return string The filtered locale of the post. + */ + return apply_filters( 'activitypub_locale', $lang, $this->item ); + } } diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index d9ee1e0e9..bf35ab184 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -287,25 +287,4 @@ public function extract_reply_context( $mentions ) { return $mentions; } - - /** - * Returns the locale of the post. - * - * @return string The locale of the post. - */ - public function get_locale() { - $comment_id = $this->item->ID; - $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); - - /** - * Filter the locale of the comment. - * - * @param string $lang The locale of the comment. - * @param int $comment_id The comment ID. - * @param \WP_Post $post The comment object. - * - * @return string The filtered locale of the comment. - */ - return apply_filters( 'activitypub_comment_locale', $lang, $comment_id, $this->item ); - } } diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 8644113e4..beb37697a 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -944,27 +944,6 @@ protected function get_mentions() { ); } - /** - * Returns the locale of the post. - * - * @return string The locale of the post. - */ - public function get_locale() { - $post_id = $this->item->ID; - $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); - - /** - * Filter the locale of the post. - * - * @param string $lang The locale of the post. - * @param int $post_id The post ID. - * @param WP_Post $post The post object. - * - * @return string The filtered locale of the post. - */ - return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->item ); - } - /** * Returns the in-reply-to URL of the post. * From 8133a1ce963c9e0bac5a36ecf1341dc1f3398d0b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 5 Nov 2024 13:57:16 +0100 Subject: [PATCH 20/98] convert JSON to Activity_Object to not have everything duplicated --- includes/transformer/class-base.php | 8 ++++---- includes/transformer/class-json.php | 28 ++++++++++------------------ 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 80deb2644..02e46067d 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -44,7 +44,7 @@ abstract class Base { * * This helps to chain the output of the Transformer. * - * @param WP_Post|WP_Comment|Base_Object|string|array $item The WordPress object. + * @param WP_Post|WP_Comment|Base_Object|string|array $item The item that should be transformed. * * @return Base */ @@ -55,7 +55,7 @@ public static function transform( $item ) { /** * Base constructor. * - * @param WP_Post|WP_Comment|Base_Object|string|array $item The WordPress object. + * @param WP_Post|WP_Comment|Base_Object|string|array $item The item that should be transformed. */ public function __construct( $item ) { $this->item = $item; @@ -65,9 +65,9 @@ public function __construct( $item ) { /** * Transform all properties with available get(ter) functions. * - * @param Base_Object|object $activitypub_object The ActivityPub Object. + * @param Base_Object $activitypub_object The ActivityPub Object. * - * @return Base_Object|object + * @return Base_Object The transformed ActivityPub Object. */ protected function transform_object_properties( $activitypub_object ) { $vars = $activitypub_object->get_object_var_keys(); diff --git a/includes/transformer/class-json.php b/includes/transformer/class-json.php index 1eaeb5062..b518bfbb1 100644 --- a/includes/transformer/class-json.php +++ b/includes/transformer/class-json.php @@ -12,30 +12,22 @@ /** * String Transformer Class file. */ -class Json extends Base { +class Json extends Activity_Object { + /** - * Transform the WordPress Object into an ActivityPub Object. + * JSON constructor. * - * @return Base_Object The ActivityPub Object. + * @param WP_Post|WP_Comment|Base_Object|string|array $item The item that should be transformed. */ - public function to_object() { - $activitypub_object = null; + public function __construct( $item ) { + $item = new Base_Object(); if ( is_array( $this->item ) ) { - $activitypub_object = Base_Object::init_from_array( $this->item ); - } else { - $activitypub_object = Base_Object::init_from_json( $this->item ); + $item = Base_Object::init_from_array( $this->item ); + } elseif ( is_string( $this->item ) ) { + $item = Base_Object::init_from_json( $this->item ); } - return $activitypub_object; - } - - /** - * Get the ID of the WordPress Object. - * - * @return string The ID of the WordPress Object. - */ - protected function get_id() { - return ''; + parent::__construct( $item ); } } From 0bdb30d535cdf47f4e0bd222e0f9e18d1564efa8 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 5 Nov 2024 15:14:03 +0100 Subject: [PATCH 21/98] add support for maps and mentions --- .../transformer/class-activity-object.php | 126 +++++++++++++++++- includes/transformer/class-base.php | 29 ++-- includes/transformer/class-comment.php | 55 +++++--- includes/transformer/class-post.php | 17 +-- 4 files changed, 188 insertions(+), 39 deletions(-) diff --git a/includes/transformer/class-activity-object.php b/includes/transformer/class-activity-object.php index bfe567813..13b3aac19 100644 --- a/includes/transformer/class-activity-object.php +++ b/includes/transformer/class-activity-object.php @@ -17,7 +17,7 @@ class Activity_Object extends Base { * @return Base_Object The ActivityPub Object. */ public function to_object() { - return $this->item; + return $this->transform_object_properties( $this->item ); } /** @@ -28,4 +28,128 @@ public function to_object() { protected function get_id() { return ''; } + + /** + * Helper function to get the @-Mentions from the post content. + * + * @return array The list of @-Mentions. + */ + protected function get_mentions() { + /** + * Filter the mentions in the post content. + * + * @param array $mentions The mentions. + * @param string $content The post content. + * @param WP_Post $post The post object. + * + * @return array The filtered mentions. + */ + return apply_filters( + 'activitypub_extract_mentions', + array(), + $this->item->get_content() . ' ' . $this->item->get_summary(), + $this->item + ); + } + + /** + * Returns a list of Mentions, used in the Post. + * + * @see https://docs.joinmastodon.org/spec/activitypub/#Mention + * + * @return array The list of Mentions. + */ + protected function get_cc() { + $cc = array(); + $mentions = $this->get_mentions(); + + if ( $mentions ) { + foreach ( $mentions as $url ) { + $cc[] = $url; + } + } + + return $cc; + } + + /** + * Returns the content map for the post. + * + * @return array The content map for the post. + */ + protected function get_content_map() { + $content = $this->item->get_content(); + + if ( ! $content ) { + return null; + } + + return array( + $this->get_locale() => $content, + ); + } + + /** + * Returns the name map for the post. + * + * @return array The name map for the post. + */ + protected function get_name_map() { + $name = $this->item->get_name(); + + if ( ! $name ) { + return null; + } + + return array( + $this->get_locale() => $name, + ); + } + + /** + * Returns the summary map for the post. + * + * @return array The summary map for the post. + */ + protected function get_summary_map() { + $summary = $this->item->get_summary(); + + if ( ! $summary ) { + return null; + } + + return array( + $this->get_locale() => $summary, + ); + } + + /** + * Returns a list of Tags, used in the Comment. + * + * This includes Hash-Tags and Mentions. + * + * @return array The list of Tags. + */ + protected function get_tag() { + $tags = $this->item->get_tags(); + + if ( ! $tags ) { + $tags = array(); + } + + $mentions = $this->get_mentions(); + + if ( $mentions ) { + foreach ( $mentions as $mention => $url ) { + $tag = array( + 'type' => 'Mention', + 'href' => \esc_url( $url ), + 'name' => \esc_html( $mention ), + ); + $tags[] = $tag; + } + } + + return \array_unique( $tags, SORT_REGULAR ); + } } diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 02e46067d..3f1accd13 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -128,13 +128,6 @@ public function to_activity( $type ) { */ abstract protected function get_id(); - /** - * Get the replies Collection. - */ - public function get_replies() { - return Replies::get_collection( $this->item ); - } - /** * Returns the ID of the WordPress Object. */ @@ -152,7 +145,7 @@ abstract public function change_wp_user_id( $user_id ); * * @return string The locale of the blog. */ - public function get_locale() { + protected function get_locale() { $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); /** @@ -165,4 +158,24 @@ public function get_locale() { */ return apply_filters( 'activitypub_locale', $lang, $this->item ); } + + /** + * Returns the recipient of the post. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to + * + * @return array The recipient URLs of the post. + */ + protected function get_to() { + return array( + 'https://www.w3.org/ns/activitystreams#Public', + ); + } + + /** + * Get the replies Collection. + */ + public function get_replies() { + return Replies::get_collection( $this->item ); + } } diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index bf35ab184..a51c16c75 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -27,6 +27,13 @@ * - Activitypub\Activity\Base_Object */ class Comment extends Base { + /** + * The User as Actor Object. + * + * @var \Activitypub\Activity\Actor + */ + private $actor_object = null; + /** * Returns the User-ID of the WordPress Comment. * @@ -72,14 +79,6 @@ public function to_object() { $this->get_locale() => $this->get_content(), ) ); - $path = sprintf( 'actors/%d/followers', intval( $comment->comment_author ) ); - - $object->set_to( - array( - 'https://www.w3.org/ns/activitystreams#Public', - get_rest_url_by_path( $path ), - ) - ); return $object; } @@ -92,12 +91,7 @@ public function to_object() { * @return string The User-URL. */ protected function get_attributed_to() { - if ( is_single_user() ) { - $user = new Blog(); - return $user->get_id(); - } - - return Users::get_by_id( $this->item->user_id )->get_id(); + return $this->get_actor_object()->get_id(); } /** @@ -173,6 +167,35 @@ protected function get_id() { return Comment_Utils::generate_id( $comment ); } + /** + * Returns the User-Object of the Author of the Post. + * + * If `single_user` mode is enabled, the Blog-User is returned. + * + * @return \Activitypub\Activity\Actor The User-Object. + */ + protected function get_actor_object() { + if ( $this->actor_object ) { + return $this->actor_object; + } + + $blog_user = new Blog(); + $this->actor_object = $blog_user; + + if ( is_single_user() ) { + return $blog_user; + } + + $user = Users::get_by_id( $this->item->user_id ); + + if ( $user && ! is_wp_error( $user ) ) { + $this->actor_object = $user; + return $user; + } + + return $blog_user; + } + /** * Returns a list of Mentions, used in the Comment. * @@ -181,7 +204,9 @@ protected function get_id() { * @return array The list of Mentions. */ protected function get_cc() { - $cc = array(); + $cc = array( + $this->get_actor_object()->get_followers(), + ); $mentions = $this->get_mentions(); if ( $mentions ) { diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index beb37697a..7077dd42f 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -254,7 +254,7 @@ protected function get_attachment() { * * @return array The media array extended with enclosures. */ - public function get_enclosures( $media ) { + protected function get_enclosures( $media ) { $enclosures = get_enclosures( $this->item->ID ); if ( ! $enclosures ) { @@ -708,19 +708,6 @@ protected function get_type() { return $object_type; } - /** - * Returns the recipient of the post. - * - * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to - * - * @return array The recipient URLs of the post. - */ - public function get_to() { - return array( - 'https://www.w3.org/ns/activitystreams#Public', - ); - } - /** * Returns a list of Mentions, used in the Post. * @@ -748,7 +735,7 @@ protected function get_cc() { * * @return string|null The audience. */ - public function get_audience() { + protected function get_audience() { if ( is_single_user() ) { return null; } else { From 748a266f932b928b012998d4297255de669d2b89 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 12 Nov 2024 13:09:40 +0100 Subject: [PATCH 22/98] it takes a string (JSON) or an array --- includes/transformer/class-json.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/transformer/class-json.php b/includes/transformer/class-json.php index b518bfbb1..8e8dbf05c 100644 --- a/includes/transformer/class-json.php +++ b/includes/transformer/class-json.php @@ -17,7 +17,7 @@ class Json extends Activity_Object { /** * JSON constructor. * - * @param WP_Post|WP_Comment|Base_Object|string|array $item The item that should be transformed. + * @param string|array $item The item that should be transformed. */ public function __construct( $item ) { $item = new Base_Object(); From 22aea420cc917daed3c3e752ce6fc6331779df2c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 13 Nov 2024 19:34:41 +0100 Subject: [PATCH 23/98] simplified based on the feedback of @obenland --- includes/transformer/class-post.php | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 0d0d1bc5b..e80867348 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -716,16 +716,8 @@ protected function get_type() { * @return array The list of Mentions. */ protected function get_cc() { - $cc = array( - $this->get_actor_object()->get_followers(), - ); - - $mentions = $this->get_mentions(); - if ( $mentions ) { - foreach ( $mentions as $url ) { - $cc[] = $url; - } - } + $cc = array_values( $this->get_mentions() ); + $cc[] = $this->get_actor_object()->get_followers(); return $cc; } From 9b20505039d71d6368b590346211bb479201a0bd Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 14 Nov 2024 14:32:28 +0100 Subject: [PATCH 24/98] rearrange code a bit --- includes/class-scheduler.php | 249 ++------------------------- includes/collection/class-outbox.php | 2 +- includes/scheduler/class-actor.php | 99 +++++++++++ includes/scheduler/class-comment.php | 89 ++++++++++ includes/scheduler/class-post.php | 105 +++++++++++ 5 files changed, 309 insertions(+), 235 deletions(-) create mode 100644 includes/scheduler/class-actor.php create mode 100644 includes/scheduler/class-comment.php create mode 100644 includes/scheduler/class-post.php diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index e810aaa4c..6299f0608 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -20,63 +20,27 @@ class Scheduler { * Initialize the class, registering WordPress hooks. */ public static function init() { - // Post transitions. - \add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 ); - \add_action( - 'edit_attachment', - function ( $post_id ) { - self::schedule_post_activity( 'publish', 'publish', $post_id ); - } - ); - \add_action( - 'add_attachment', - function ( $post_id ) { - self::schedule_post_activity( 'publish', '', $post_id ); - } - ); - \add_action( - 'delete_attachment', - function ( $post_id ) { - self::schedule_post_activity( 'trash', '', $post_id ); - } - ); - - if ( ! ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS ) { - // Comment transitions. - \add_action( 'transition_comment_status', array( self::class, 'schedule_comment_activity' ), 20, 3 ); - \add_action( - 'edit_comment', - function ( $comment_id ) { - self::schedule_comment_activity( 'approved', 'approved', $comment_id ); - } - ); - \add_action( - 'wp_insert_comment', - function ( $comment_id ) { - self::schedule_comment_activity( 'approved', '', $comment_id ); - } - ); - } + self::register_schedulers(); // Follower Cleanups. \add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) ); \add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) ); + } - // Profile updates for blog options. - if ( ! is_user_type_disabled( 'blog' ) ) { - \add_action( 'update_option_site_icon', array( self::class, 'blog_user_update' ) ); - \add_action( 'update_option_blogdescription', array( self::class, 'blog_user_update' ) ); - \add_action( 'update_option_blogname', array( self::class, 'blog_user_update' ) ); - \add_filter( 'pre_set_theme_mod_custom_logo', array( self::class, 'blog_user_update' ) ); - \add_filter( 'pre_set_theme_mod_header_image', array( self::class, 'blog_user_update' ) ); - } + /** + * Register handlers. + */ + public static function register_schedulers() { + Post::init(); + Comment::init(); + Post::init(); - // Profile updates for user options. - if ( ! is_user_type_disabled( 'user' ) ) { - \add_action( 'wp_update_user', array( self::class, 'user_update' ) ); - \add_action( 'updated_user_meta', array( self::class, 'user_meta_update' ), 10, 3 ); - // @todo figure out a feasible way of updating the header image since it's not unique to any user. - } + /** + * Register additional schedulers. + * + * @since 5.0.0 + */ + do_action( 'activitypub_register_schedulers' ); } /** @@ -102,124 +66,6 @@ public static function deregister_schedules() { wp_unschedule_hook( 'activitypub_cleanup_followers' ); } - /** - * Schedule Activities. - * - * @param string $new_status New post status. - * @param string $old_status Old post status. - * @param \WP_Post $post Post object. - */ - public static function schedule_post_activity( $new_status, $old_status, $post ) { - $post = get_post( $post ); - - if ( ! $post || is_post_disabled( $post ) ) { - return; - } - - if ( 'ap_extrafield' === $post->post_type ) { - self::schedule_profile_update( $post->post_author ); - return; - } - - if ( 'ap_extrafield_blog' === $post->post_type ) { - self::schedule_profile_update( 0 ); - return; - } - - // Do not send activities if post is password protected. - if ( \post_password_required( $post ) ) { - return; - } - - // Check if post-type supports ActivityPub. - $post_types = \get_post_types_by_support( 'activitypub' ); - if ( ! \in_array( $post->post_type, $post_types, true ) ) { - return; - } - - $type = false; - - if ( - 'publish' === $new_status && - 'publish' !== $old_status - ) { - $type = 'Create'; - } elseif ( - 'publish' === $new_status || - // We want to send updates for posts that are published and then moved to draft. - ( 'draft' === $new_status && - 'publish' === $old_status ) - ) { - $type = 'Update'; - } elseif ( 'trash' === $new_status ) { - $type = 'Delete'; - } - - if ( empty( $type ) ) { - return; - } - - $hook = 'activitypub_send_post'; - $args = array( $post->ID, $type ); - - if ( false === wp_next_scheduled( $hook, $args ) ) { - set_wp_object_state( $post, 'federate' ); - \wp_schedule_single_event( \time() + 10, $hook, $args ); - } - } - - /** - * Schedule Comment Activities. - * - * @see transition_comment_status() - * - * @param string $new_status New comment status. - * @param string $old_status Old comment status. - * @param \WP_Comment $comment Comment object. - */ - public static function schedule_comment_activity( $new_status, $old_status, $comment ) { - $comment = get_comment( $comment ); - - // Federate only comments that are written by a registered user. - if ( ! $comment || ! $comment->user_id ) { - return; - } - - $type = false; - - if ( - 'approved' === $new_status && - 'approved' !== $old_status - ) { - $type = 'Create'; - } elseif ( 'approved' === $new_status ) { - $type = 'Update'; - \update_comment_meta( $comment->comment_ID, 'activitypub_comment_modified', time(), true ); - } elseif ( - 'trash' === $new_status || - 'spam' === $new_status - ) { - $type = 'Delete'; - } - - if ( empty( $type ) ) { - return; - } - - // Check if comment should be federated or not. - if ( ! should_comment_be_federated( $comment ) ) { - return; - } - - $hook = 'activitypub_send_comment'; - $args = array( $comment->comment_ID, $type ); - - if ( false === wp_next_scheduled( $hook, $args ) ) { - set_wp_object_state( $comment, 'federate' ); - \wp_schedule_single_event( \time(), $hook, $args ); - } - } - /** * Update followers. */ @@ -289,69 +135,4 @@ public static function cleanup_followers() { } } } - - /** - * Send a profile update when relevant user meta is updated. - * - * @param int $meta_id Meta ID being updated. - * @param int $user_id User ID being updated. - * @param string $meta_key Meta key being updated. - */ - public static function user_meta_update( $meta_id, $user_id, $meta_key ) { - // Don't bother if the user can't publish. - if ( ! \user_can( $user_id, 'activitypub' ) ) { - return; - } - - // The user meta fields that affect a profile. - $fields = array( - 'activitypub_description', - 'activitypub_header_image', - 'description', - 'user_url', - 'display_name', - ); - if ( in_array( $meta_key, $fields, true ) ) { - self::schedule_profile_update( $user_id ); - } - } - - /** - * Send a profile update when a user is updated. - * - * @param int $user_id User ID being updated. - */ - public static function user_update( $user_id ) { - // Don't bother if the user can't publish. - if ( ! \user_can( $user_id, 'activitypub' ) ) { - return; - } - - self::schedule_profile_update( $user_id ); - } - - /** - * Theme mods only have a dynamic filter so we fudge it like this. - * - * @param mixed $value Optional. The value to be updated. Default null. - * - * @return mixed - */ - public static function blog_user_update( $value = null ) { - self::schedule_profile_update( 0 ); - return $value; - } - - /** - * Send a profile update to all followers. Gets hooked into all relevant options/meta etc. - * - * @param int $user_id The user ID to update (Could be 0 for Blog-User). - */ - public static function schedule_profile_update( $user_id ) { - \wp_schedule_single_event( - \time() + 10, - 'activitypub_send_update_profile_activity', - array( $user_id ) - ); - } } diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 36d95d836..45dca6d1e 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -24,7 +24,7 @@ class Outbox { * * @return mixed The added item or an error. */ - public static function add_item( $item, $user_id, $activity_type = 'Create' ) { + public static function add_item( $item, $user_id, $activity_type = 'Create' ) { // phpcs:ignore $transformer = Factory::get_transformer( $item ); $object = $transformer->transform(); diff --git a/includes/scheduler/class-actor.php b/includes/scheduler/class-actor.php new file mode 100644 index 000000000..b4ea906b9 --- /dev/null +++ b/includes/scheduler/class-actor.php @@ -0,0 +1,99 @@ +user_id ) { + return; + } + + $type = false; + + if ( + 'approved' === $new_status && + 'approved' !== $old_status + ) { + $type = 'Create'; + } elseif ( 'approved' === $new_status ) { + $type = 'Update'; + \update_comment_meta( $comment->comment_ID, 'activitypub_comment_modified', time(), true ); + } elseif ( + 'trash' === $new_status || + 'spam' === $new_status + ) { + $type = 'Delete'; + } + + if ( empty( $type ) ) { + return; + } + + // Check if comment should be federated or not. + if ( ! should_comment_be_federated( $comment ) ) { + return; + } + + $hook = 'activitypub_send_comment'; + $args = array( $comment->comment_ID, $type ); + + if ( false === wp_next_scheduled( $hook, $args ) ) { + set_wp_object_state( $comment, 'federate' ); + \wp_schedule_single_event( \time(), $hook, $args ); + } + } +} diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php new file mode 100644 index 000000000..496923b38 --- /dev/null +++ b/includes/scheduler/class-post.php @@ -0,0 +1,105 @@ +post_type ) { + self::schedule_profile_update( $post->post_author ); + return; + } + + if ( 'ap_extrafield_blog' === $post->post_type ) { + self::schedule_profile_update( 0 ); + return; + } + + // Do not send activities if post is password protected. + if ( \post_password_required( $post ) ) { + return; + } + + // Check if post-type supports ActivityPub. + $post_types = \get_post_types_by_support( 'activitypub' ); + if ( ! \in_array( $post->post_type, $post_types, true ) ) { + return; + } + + $type = false; + + if ( + 'publish' === $new_status && + 'publish' !== $old_status + ) { + $type = 'Create'; + } elseif ( + 'publish' === $new_status || + // We want to send updates for posts that are published and then moved to draft. + ( 'draft' === $new_status && + 'publish' === $old_status ) + ) { + $type = 'Update'; + } elseif ( 'trash' === $new_status ) { + $type = 'Delete'; + } + + if ( empty( $type ) ) { + return; + } + + $hook = 'activitypub_send_post'; + $args = array( $post->ID, $type ); + + if ( false === wp_next_scheduled( $hook, $args ) ) { + set_wp_object_state( $post, 'federate' ); + \wp_schedule_single_event( \time() + 10, $hook, $args ); + } + } +} From 617eff7df982c0ab50998d3c74c4fd183e5d66f5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 14 Nov 2024 15:29:53 +0100 Subject: [PATCH 25/98] add missing uses --- includes/class-scheduler.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 6299f0608..c101093dd 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -7,6 +7,9 @@ namespace Activitypub; +use Activitypun\Scheduler\Post; +use Activitypub\Scheduler\Actor; +use Activitypub\Scheduler\Comment; use Activitypub\Collection\Followers; /** From ad5f35a7aee895dbaef46c65e513042871356ea9 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 14 Nov 2024 15:37:49 +0100 Subject: [PATCH 26/98] fix typo --- includes/class-scheduler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index c101093dd..36ad1b809 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -7,7 +7,7 @@ namespace Activitypub; -use Activitypun\Scheduler\Post; +use Activitypub\Scheduler\Post; use Activitypub\Scheduler\Actor; use Activitypub\Scheduler\Comment; use Activitypub\Collection\Followers; @@ -35,8 +35,8 @@ public static function init() { */ public static function register_schedulers() { Post::init(); + Actor::init(); Comment::init(); - Post::init(); /** * Register additional schedulers. From c1f63b58f80eaecd5de0db4ca0b4ad70fbfe7d47 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 14 Nov 2024 15:45:04 +0100 Subject: [PATCH 27/98] add missing uses --- includes/scheduler/class-actor.php | 2 ++ includes/scheduler/class-comment.php | 3 +++ includes/scheduler/class-post.php | 3 +++ 3 files changed, 8 insertions(+) diff --git a/includes/scheduler/class-actor.php b/includes/scheduler/class-actor.php index b4ea906b9..89acf64f0 100644 --- a/includes/scheduler/class-actor.php +++ b/includes/scheduler/class-actor.php @@ -7,6 +7,8 @@ namespace Activitypub\Scheduler; +use function Activitypub\is_user_type_disabled; + /** * Post scheduler class. */ diff --git a/includes/scheduler/class-comment.php b/includes/scheduler/class-comment.php index c6e963afe..ecb7f8e85 100644 --- a/includes/scheduler/class-comment.php +++ b/includes/scheduler/class-comment.php @@ -7,6 +7,9 @@ namespace Activitypub\Scheduler; +use function Activitypub\set_wp_object_state; +use function Activitypub\should_comment_be_federated; + /** * Post scheduler class. */ diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 496923b38..67a988d8b 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -7,6 +7,9 @@ namespace Activitypub\Scheduler; +use function Activitypub\is_post_disabled; +use function Activitypub\set_wp_object_state; + /** * Post scheduler class. */ From fe29ea4984cb18f8382e4b93948cf189f12c9e2b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 22 Nov 2024 13:23:23 +0100 Subject: [PATCH 28/98] Added Changelog-Entry --- CHANGELOG.md | 1 + readme.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e17d9985..0b3295f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Fediverse Preview on post-overview page * GitHub action to enforce Changelog updates. +* Outbox queue ### Improved diff --git a/readme.txt b/readme.txt index 473b53b60..64d7bdfa5 100644 --- a/readme.txt +++ b/readme.txt @@ -136,6 +136,7 @@ For reasons of data protection, it is not possible to see the followers of other * Added: Fediverse Preview on post-overview page * Added: GitHub action to enforce Changelog updates. +* Added: Outbox queue * Improved: Outsource Constants to a separate file * Improved: Better handling of `readme.txt` and `README.md` From 8f4f8b137f14cfdca22a6a036e1c8d91edba6e5d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 6 Dec 2024 09:29:38 +0100 Subject: [PATCH 29/98] apply changes from @obenland --- includes/scheduler/class-post.php | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 67a988d8b..e6f1420d1 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -75,22 +75,21 @@ public static function schedule_post_activity( $new_status, $old_status, $post ) return; } - $type = false; - - if ( - 'publish' === $new_status && - 'publish' !== $old_status - ) { - $type = 'Create'; - } elseif ( - 'publish' === $new_status || - // We want to send updates for posts that are published and then moved to draft. - ( 'draft' === $new_status && - 'publish' === $old_status ) - ) { - $type = 'Update'; - } elseif ( 'trash' === $new_status ) { - $type = 'Delete'; + switch ( $new_status ) { + case 'publish': + $type = ( 'publish' === $old_status ) ? 'Update' : 'Create'; + break; + + case 'draft': + $type = ( 'publish' === $old_status ) ? 'Update' : false; + break; + + case 'trash': + $type = 'federated' === get_wp_object_state( $post ) ? 'Delete' : false; + break; + + default: + $type = false; } if ( empty( $type ) ) { From f26eb3df61bc406a6a927344ea1237b80fba6a80 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 6 Dec 2024 09:35:11 +0100 Subject: [PATCH 30/98] delay scheduling a bit --- includes/scheduler/class-comment.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/scheduler/class-comment.php b/includes/scheduler/class-comment.php index ecb7f8e85..c92227f4b 100644 --- a/includes/scheduler/class-comment.php +++ b/includes/scheduler/class-comment.php @@ -86,7 +86,7 @@ public static function schedule_comment_activity( $new_status, $old_status, $com if ( false === wp_next_scheduled( $hook, $args ) ) { set_wp_object_state( $comment, 'federate' ); - \wp_schedule_single_event( \time(), $hook, $args ); + \wp_schedule_single_event( \time() + 10, $hook, $args ); } } } From 77f8f06652b9c645535b790895c70c91f708d7a8 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 6 Dec 2024 13:51:20 +0100 Subject: [PATCH 31/98] change scheduling and add possibility to add items to outbox --- includes/collection/class-followers.php | 2 +- includes/collection/class-outbox.php | 29 ++++----- includes/functions.php | 48 +++++++++++++- includes/scheduler/class-actor.php | 11 ++-- includes/scheduler/class-comment.php | 9 +-- includes/scheduler/class-post.php | 11 +--- .../transformer/class-activity-object.php | 18 ++++- includes/transformer/class-comment.php | 2 +- ...s-test-activitypub-activity-dispatcher.php | 2 +- tests/class-test-activitypub-followers.php | 2 +- tests/class-test-activitypub-mention.php | 2 +- ...ass-test-activitypub-outbox-collection.php | 65 +++++++++++++++++++ tests/class-test-enable-mastodon-apps.php | 2 +- 13 files changed, 158 insertions(+), 45 deletions(-) create mode 100644 tests/class-test-activitypub-outbox-collection.php diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index dc4e31507..5991bba25 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -266,7 +266,7 @@ public static function count_followers( $user_id ) { } /** - * Returns all Inboxes for a Users Followers. + * Returns all Inboxes for a Actors Followers. * * @param int $user_id The ID of the WordPress User. * diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 45dca6d1e..803125768 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -7,8 +7,6 @@ namespace Activitypub\Collection; -use Activitypub\Transformer\Factory; - /** * ActivityPub Outbox Collection */ @@ -18,24 +16,17 @@ class Outbox { /** * Add an Item to the outbox. * - * @param string|array|Base_Object|WP_Post|WP_Comment $item The item to add. - * @param int $user_id The user ID. - * @param string $activity_type The activity type. + * @param \Activitypub\Activity\Base_Object $activity_object The Activity-Object to add as JSON. + * @param string $activity_type The activity type. + * @param int $user_id The user ID. * * @return mixed The added item or an error. */ - public static function add_item( $item, $user_id, $activity_type = 'Create' ) { // phpcs:ignore - $transformer = Factory::get_transformer( $item ); - $object = $transformer->transform(); - - if ( ! $object || is_wp_error( $object ) ) { - return $object; - } - + public static function add( $activity_object, $activity_type = 'Create', $user_id ) { // phpcs:ignore $outbox_item = array( 'post_type' => self::POST_TYPE, - 'post_title' => $object->get_id(), - 'post_content' => $object->to_json(), + 'post_title' => $activity_object->get_id(), + 'post_content' => $activity_object->to_json(), 'post_author' => $user_id, 'post_status' => 'draft', ); @@ -46,12 +37,16 @@ public static function add_item( $item, $user_id, $activity_type = 'Create' ) { \kses_remove_filters(); } - $result = \wp_insert_post( $outbox_item, true ); + $id = \wp_insert_post( $outbox_item, true ); if ( $has_kses ) { \kses_init_filters(); } - return $result; + if ( ! $id || \is_wp_error( $id ) ) { + return false; + } + + return $id; } } diff --git a/includes/functions.php b/includes/functions.php index eecc689dc..15f4fb2e6 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -9,9 +9,11 @@ use WP_Error; use Activitypub\Activity\Activity; -use Activitypub\Collection\Followers; use Activitypub\Collection\Actors; +use Activitypub\Collection\Outbox; +use Activitypub\Collection\Followers; use Activitypub\Transformer\Post; +use Activitypub\Transformer\Factory as Transformer_Factory; /** * Returns the ActivityPub default JSON-context. @@ -1529,3 +1531,47 @@ function is_self_ping( $id ) { return false; } + +/** + * Add an object to the outbox. + * + * @param mixed $data The object to add to the outbox. + * @param string $type The type of the Activity. + * @param integer $user_id The User-ID. + * + * @return boolean|int The ID of the outbox item or false on failure. + */ +function add_to_outbox( $data, $type = 'Create', $user_id = 0 ) { + $transformer = Transformer_Factory::get_transformer( $data ); + + if ( ! $transformer || is_wp_error( $transformer ) ) { + return false; + } + + $activity = $transformer->to_object(); + + if ( ! $activity || is_wp_error( $activity ) ) { + return false; + } + + set_wp_object_state( $data, 'federate' ); + + $id = Outbox::add( $activity, $type, $user_id ); + + if ( ! $id ) { + return false; + } + + $hook = 'activitypub_process_outbox'; + $args = array( $id ); + + if ( false === wp_next_scheduled( $hook, $args ) ) { + \wp_schedule_single_event( + \time() + 10, + $hook, + $args + ); + } + + return $id; +} diff --git a/includes/scheduler/class-actor.php b/includes/scheduler/class-actor.php index 89acf64f0..17830cf91 100644 --- a/includes/scheduler/class-actor.php +++ b/includes/scheduler/class-actor.php @@ -7,6 +7,9 @@ namespace Activitypub\Scheduler; +use Activitypub\Collection\Actors; + +use function Activitypub\add_to_outbox; use function Activitypub\is_user_type_disabled; /** @@ -92,10 +95,8 @@ public static function blog_user_update( $value = null ) { * @param int $user_id The user ID to update (Could be 0 for Blog-User). */ public static function schedule_profile_update( $user_id ) { - \wp_schedule_single_event( - \time() + 10, - 'activitypub_send_update_profile_activity', - array( $user_id ) - ); + $actor = Actors::get_by_id( $user_id ); + + add_to_outbox( $actor->get_id(), 'Update', $user_id ); } } diff --git a/includes/scheduler/class-comment.php b/includes/scheduler/class-comment.php index c92227f4b..a6d6bbd1c 100644 --- a/includes/scheduler/class-comment.php +++ b/includes/scheduler/class-comment.php @@ -7,6 +7,7 @@ namespace Activitypub\Scheduler; +use function Activitypub\add_to_outbox; use function Activitypub\set_wp_object_state; use function Activitypub\should_comment_be_federated; @@ -81,12 +82,6 @@ public static function schedule_comment_activity( $new_status, $old_status, $com return; } - $hook = 'activitypub_send_comment'; - $args = array( $comment->comment_ID, $type ); - - if ( false === wp_next_scheduled( $hook, $args ) ) { - set_wp_object_state( $comment, 'federate' ); - \wp_schedule_single_event( \time() + 10, $hook, $args ); - } + add_to_outbox( $comment, $type, $comment->user_id ); } } diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index e6f1420d1..ff3dbeeb9 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -7,8 +7,9 @@ namespace Activitypub\Scheduler; +use function Activitypub\add_to_outbox; use function Activitypub\is_post_disabled; -use function Activitypub\set_wp_object_state; +use function Activitypub\get_wp_object_state; /** * Post scheduler class. @@ -96,12 +97,6 @@ public static function schedule_post_activity( $new_status, $old_status, $post ) return; } - $hook = 'activitypub_send_post'; - $args = array( $post->ID, $type ); - - if ( false === wp_next_scheduled( $hook, $args ) ) { - set_wp_object_state( $post, 'federate' ); - \wp_schedule_single_event( \time() + 10, $hook, $args ); - } + add_to_outbox( $post, $type, $post->post_author ); } } diff --git a/includes/transformer/class-activity-object.php b/includes/transformer/class-activity-object.php index 13b3aac19..b96348f17 100644 --- a/includes/transformer/class-activity-object.php +++ b/includes/transformer/class-activity-object.php @@ -131,7 +131,7 @@ protected function get_summary_map() { * @return array The list of Tags. */ protected function get_tag() { - $tags = $this->item->get_tags(); + $tags = $this->item->get_tag(); if ( ! $tags ) { $tags = array(); @@ -152,4 +152,20 @@ protected function get_tag() { return \array_unique( $tags, SORT_REGULAR ); } + + /** + * Returns the ID of the WordPress Object. + */ + public function get_wp_user_id() { + return ''; + } + + /** + * Change the User-ID of the WordPress Post. + * + * @param int $user_id The new user ID. + */ + public function change_wp_user_id( $user_id ) { + // do nothing. + } } diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index e6dd76ec4..84768099a 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -186,7 +186,7 @@ protected function get_actor_object() { return $blog_user; } - $user = Users::get_by_id( $this->item->user_id ); + $user = Actors::get_by_id( $this->item->user_id ); if ( $user && ! is_wp_error( $user ) ) { $this->actor_object = $user; diff --git a/tests/class-test-activitypub-activity-dispatcher.php b/tests/class-test-activitypub-activity-dispatcher.php index bf463d401..048c61aa5 100644 --- a/tests/class-test-activitypub-activity-dispatcher.php +++ b/tests/class-test-activitypub-activity-dispatcher.php @@ -13,7 +13,7 @@ class Test_Activitypub_Activity_Dispatcher extends ActivityPub_TestCase_Cache_HTTP { /** - * Users. + * Actors. * * @var array[] $users */ diff --git a/tests/class-test-activitypub-followers.php b/tests/class-test-activitypub-followers.php index 79f2176c0..5dc4873e9 100644 --- a/tests/class-test-activitypub-followers.php +++ b/tests/class-test-activitypub-followers.php @@ -13,7 +13,7 @@ class Test_Activitypub_Followers extends WP_UnitTestCase { /** - * Users. + * Actors. * * @var array[] */ diff --git a/tests/class-test-activitypub-mention.php b/tests/class-test-activitypub-mention.php index 1de18ec46..de41830f0 100644 --- a/tests/class-test-activitypub-mention.php +++ b/tests/class-test-activitypub-mention.php @@ -13,7 +13,7 @@ class Test_Activitypub_Mention extends ActivityPub_TestCase_Cache_HTTP { /** - * Users. + * Actors. * * @var array */ diff --git a/tests/class-test-activitypub-outbox-collection.php b/tests/class-test-activitypub-outbox-collection.php new file mode 100644 index 000000000..c5b8dfe3b --- /dev/null +++ b/tests/class-test-activitypub-outbox-collection.php @@ -0,0 +1,65 @@ +assertIsInt( $id ); + + $post = get_post( $id ); + + $this->assertInstanceOf( 'WP_Post', $post ); + $this->assertEquals( 'draft', $post->post_status ); + //$this->assertEquals( $json, $post->post_content ); + } + + /** + * Data provider for test_add. + * + * @return array + */ + public function activity_object_provider() { + return array( + array( + array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'https://example.com/1', + 'type' => 'Note', + 'content' => 'This is a note', + ), + 'Create', + 1, + '{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","id":"https:\/\/example.com\/1","type":"Note","content":"This is a note"}', + ), + array( + array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'https://example.com/2', + 'type' => 'Note', + 'content' => 'This is another note', + ), + 'Create', + 2, + '{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","id":"https:\/\/example.com\/2","type":"Note","content":"This is another note"}', + ), + ); + } +} diff --git a/tests/class-test-enable-mastodon-apps.php b/tests/class-test-enable-mastodon-apps.php index 3c3a0914e..f6169f496 100644 --- a/tests/class-test-enable-mastodon-apps.php +++ b/tests/class-test-enable-mastodon-apps.php @@ -13,7 +13,7 @@ class Test_Enable_Mastodon_Apps extends WP_UnitTestCase { /** - * Users. + * Actors. * * @var array[] */ From abef3dc0b8f43f12b62ef3acef2b9ea44287194d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 6 Dec 2024 14:10:00 +0100 Subject: [PATCH 32/98] remove deprecated functions --- includes/collection/class-outbox.php | 2 +- includes/transformer/class-base.php | 12 ------------ includes/transformer/class-comment.php | 18 ------------------ includes/transformer/class-post.php | 22 ---------------------- 4 files changed, 1 insertion(+), 53 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 803125768..0ae3f52f0 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -22,7 +22,7 @@ class Outbox { * * @return mixed The added item or an error. */ - public static function add( $activity_object, $activity_type = 'Create', $user_id ) { // phpcs:ignore + public static function add( $activity_object, $activity_type, $user_id ) { // phpcs:ignore $outbox_item = array( 'post_type' => self::POST_TYPE, 'post_title' => $activity_object->get_id(), diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 3f1accd13..a8ac663f8 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -128,18 +128,6 @@ public function to_activity( $type ) { */ abstract protected function get_id(); - /** - * Returns the ID of the WordPress Object. - */ - abstract public function get_wp_user_id(); - - /** - * Change the User-ID of the WordPress Post. - * - * @param int $user_id The new user ID. - */ - abstract public function change_wp_user_id( $user_id ); - /** * Returns a generic locale based on the Blog settings. * diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index 84768099a..e83059280 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -34,24 +34,6 @@ class Comment extends Base { */ private $actor_object = null; - /** - * Returns the User-ID of the WordPress Comment. - * - * @return int The User-ID of the WordPress Comment - */ - public function get_wp_user_id() { - return $this->item->user_id; - } - - /** - * Change the User-ID of the WordPress Comment. - * - * @param int $user_id The new user ID. - */ - public function change_wp_user_id( $user_id ) { - $this->item->user_id = $user_id; - } - /** * Transforms the WP_Comment object to an ActivityPub Object. * diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index de7dad16d..df189840e 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -39,28 +39,6 @@ class Post extends Base { */ private $actor_object = null; - /** - * Returns the ID of the WordPress Post. - * - * @return int The ID of the WordPress Post - */ - public function get_wp_user_id() { - return $this->item->post_author; - } - - /** - * Change the User-ID of the WordPress Post. - * - * @param int $user_id The new user ID. - * - * @return Post The Post Object. - */ - public function change_wp_user_id( $user_id ) { - $this->item->post_author = $user_id; - - return $this; - } - /** * Transforms the WP_Post object to an ActivityPub Object * From b93568d8ba3062048477a47c111422e21ace7a0f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 6 Dec 2024 14:43:35 +0100 Subject: [PATCH 33/98] add filter --- includes/transformer/class-base.php | 33 ++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index a8ac663f8..2837c9441 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -65,12 +65,12 @@ public function __construct( $item ) { /** * Transform all properties with available get(ter) functions. * - * @param Base_Object $activitypub_object The ActivityPub Object. + * @param Base_Object $activity_object The ActivityPub Object. * * @return Base_Object The transformed ActivityPub Object. */ - protected function transform_object_properties( $activitypub_object ) { - $vars = $activitypub_object->get_object_var_keys(); + protected function transform_object_properties( $activity_object ) { + $vars = $activity_object->get_object_var_keys(); foreach ( $vars as $var ) { $getter = 'get_' . $var; @@ -81,22 +81,39 @@ protected function transform_object_properties( $activitypub_object ) { if ( isset( $value ) ) { $setter = 'set_' . $var; - call_user_func( array( $activitypub_object, $setter ), $value ); + /** + * Filter the value before it is set to the Activity-Object `$activity_object`. + * + * @param mixed $value The value that should be set. + * @param mixed $item The Object. + */ + $value = apply_filters( 'activitypub_transform_' . $setter, $value, $this->item ); + + /** + * Filter the value before it is set to the Activity-Object `$activity_object`. + * + * @param mixed $value The value that should be set. + * @param string $var The variable name. + * @param mixed $item The Object. + */ + $value = apply_filters( 'activitypub_transform_set', $value, $var, $this->item ); + + call_user_func( array( $activity_object, $setter ), $value ); } } } - return $activitypub_object; + return $activity_object; } /** * Transform the WordPress Object into an ActivityPub Object. * - * @return Base_Object|object The ActivityPub Object. + * @return Base_Object|object The Activity-Object. */ public function to_object() { - $activitypub_object = new Base_Object(); + $activity_object = new Base_Object(); - return $this->transform_object_properties( $activitypub_object ); + return $this->transform_object_properties( $activity_object ); } /** From 17cc89b50cde6b1daeeb181d8578faad53278fe8 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 6 Dec 2024 14:49:21 +0100 Subject: [PATCH 34/98] fix indents --- includes/transformer/class-base.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index af14f846b..4e11e3b7a 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -186,11 +186,11 @@ protected function get_to() { ); } - /** + /** * Get the replies Collection. * * @return array The replies collection. - */ + */ public function get_replies() { return Replies::get_collection( $this->item ); } From 535899cb182c20c2367221d01495f3487832fbb6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 6 Dec 2024 14:54:21 +0100 Subject: [PATCH 35/98] removed deprecated functions --- .../transformer/class-activity-object.php | 25 ------------------- includes/transformer/class-base.php | 5 ---- 2 files changed, 30 deletions(-) diff --git a/includes/transformer/class-activity-object.php b/includes/transformer/class-activity-object.php index b96348f17..23e7cf0ad 100644 --- a/includes/transformer/class-activity-object.php +++ b/includes/transformer/class-activity-object.php @@ -20,15 +20,6 @@ public function to_object() { return $this->transform_object_properties( $this->item ); } - /** - * Get the ID of the WordPress Object. - * - * @return string The ID of the WordPress Object. - */ - protected function get_id() { - return ''; - } - /** * Helper function to get the @-Mentions from the post content. * @@ -152,20 +143,4 @@ protected function get_tag() { return \array_unique( $tags, SORT_REGULAR ); } - - /** - * Returns the ID of the WordPress Object. - */ - public function get_wp_user_id() { - return ''; - } - - /** - * Change the User-ID of the WordPress Post. - * - * @param int $user_id The new user ID. - */ - public function change_wp_user_id( $user_id ) { - // do nothing. - } } diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 4e11e3b7a..1406a5875 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -140,11 +140,6 @@ public function to_activity( $type ) { return $activity; } - /** - * Get the ID of the WordPress Object. - */ - abstract protected function get_id(); - /** * Returns a generic locale based on the Blog settings. * From 9d8c270a8d046081d9e2a94e0b244e1c25fd397c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 9 Dec 2024 14:47:35 +0100 Subject: [PATCH 36/98] add visibility and normalize autor id --- includes/collection/class-outbox.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 0ae3f52f0..6ea0d665a 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -22,12 +22,13 @@ class Outbox { * * @return mixed The added item or an error. */ - public static function add( $activity_object, $activity_type, $user_id ) { // phpcs:ignore + public static function add( $activity_object, $activity_type, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { // phpcs:ignore $outbox_item = array( 'post_type' => self::POST_TYPE, 'post_title' => $activity_object->get_id(), 'post_content' => $activity_object->to_json(), - 'post_author' => $user_id, + // ensure that user ID is always above 0. + 'post_author' => \max( $user_id, 0 ), 'post_status' => 'draft', ); From 757c8c92892c39cfaf0348f6f48c1c4104d7d516 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 11 Dec 2024 15:14:43 +0100 Subject: [PATCH 37/98] add taxonomies to store actor and actitiy-type informations --- includes/class-activitypub.php | 41 ++++++++++++++++++++++++++-- includes/collection/class-outbox.php | 20 +++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 7a7274e1d..5c49607b6 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -534,15 +534,50 @@ private static function register_post_types() { 'name' => _x( 'Outbox', 'post_type plural name', 'activitypub' ), 'singular_name' => _x( 'Outbox Item', 'post_type single name', 'activitypub' ), ), - 'public' => false, - 'hierarchical' => false, + 'capabilities' => array( + 'create_posts' => false, + //'edit_posts' => false, + //'delete_posts' => false, + ), + 'map_meta_cap' => true, + 'public' => true, + 'hierarchical' => true, 'rewrite' => false, 'query_var' => false, - 'delete_with_user' => false, + 'delete_with_user' => true, 'can_export' => true, 'supports' => array(), + 'taxonomies' => array( 'ap_actor', 'ap_activity_type' ), + ) + ); + + \register_taxonomy( + 'ap_actor', + Outbox::POST_TYPE, + array( + 'labels' => array( + 'name' => _x( 'Actor', 'post_type plural name', 'activitypub' ), + 'singular_name' => _x( '', 'post_type single name', 'activitypub' ), + ), + 'hierarchical' => true, + 'public' => false, + ) + ); + \register_taxonomy_for_object_type( 'ap_actor', Outbox::POST_TYPE ); + + \register_taxonomy( + 'ap_activity_type', + Outbox::POST_TYPE, + array( + 'labels' => array( + 'name' => _x( 'Activity Type', 'post_type plural name', 'activitypub' ), + 'singular_name' => _x( '', 'post_type single name', 'activitypub' ), + ), + 'hierarchical' => true, + 'public' => false, ) ); + \register_taxonomy_for_object_type( 'ap_activity_type', Outbox::POST_TYPE ); // Both User and Blog Extra Fields types have the same args. $args = array( diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 6ea0d665a..8b7210d1e 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -23,11 +23,23 @@ class Outbox { * @return mixed The added item or an error. */ public static function add( $activity_object, $activity_type, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { // phpcs:ignore + switch ( $user_id ) { + case -1: + $actor = 'application'; + break; + case 0: + $actor = 'blog'; + break; + default: + $actor = 'user'; + break; + } + $outbox_item = array( 'post_type' => self::POST_TYPE, 'post_title' => $activity_object->get_id(), 'post_content' => $activity_object->to_json(), - // ensure that user ID is always above 0. + // ensure that user ID is not below 0. 'post_author' => \max( $user_id, 0 ), 'post_status' => 'draft', ); @@ -48,6 +60,12 @@ public static function add( $activity_object, $activity_type, $user_id, $visibil return false; } + // Set the actor type. + \wp_set_object_terms( $id, array( $actor ), 'ap_actor' ); + + // Set the activity type. + \wp_set_object_terms( $id, array( strtolower( $activity_type ) ), 'ap_activity_type' ); + return $id; } } From 3fc94d31ba5236c1ab252287dd7ef1bb4dfef90f Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 13 Jan 2025 13:58:49 -0600 Subject: [PATCH 38/98] Fix phpcs --- includes/class-activitypub.php | 12 ++++-------- includes/collection/class-outbox.php | 3 ++- includes/transformer/class-post.php | 8 ++++---- .../collection/class-test-outbox.php} | 16 +++++++++++----- 4 files changed, 21 insertions(+), 18 deletions(-) rename tests/{class-test-activitypub-outbox-collection.php => includes/collection/class-test-outbox.php} (72%) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 5a859e902..128ad0ba2 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -550,8 +550,6 @@ private static function register_post_types() { ), 'capabilities' => array( 'create_posts' => false, - //'edit_posts' => false, - //'delete_posts' => false, ), 'map_meta_cap' => true, 'public' => true, @@ -569,9 +567,8 @@ private static function register_post_types() { 'ap_actor', Outbox::POST_TYPE, array( - 'labels' => array( - 'name' => _x( 'Actor', 'post_type plural name', 'activitypub' ), - 'singular_name' => _x( '', 'post_type single name', 'activitypub' ), + 'labels' => array( + 'name' => _x( 'Actor', 'post_type plural name', 'activitypub' ), ), 'hierarchical' => true, 'public' => false, @@ -583,9 +580,8 @@ private static function register_post_types() { 'ap_activity_type', Outbox::POST_TYPE, array( - 'labels' => array( - 'name' => _x( 'Activity Type', 'post_type plural name', 'activitypub' ), - 'singular_name' => _x( '', 'post_type single name', 'activitypub' ), + 'labels' => array( + 'name' => _x( 'Activity Type', 'post_type plural name', 'activitypub' ), ), 'hierarchical' => true, 'public' => false, diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 8b7210d1e..0174c709c 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -19,8 +19,9 @@ class Outbox { * @param \Activitypub\Activity\Base_Object $activity_object The Activity-Object to add as JSON. * @param string $activity_type The activity type. * @param int $user_id The user ID. + * @param string $visibility Optional. The visibility of the content. Default 'public'. * - * @return mixed The added item or an error. + * @return false|int|\WP_Error The added item or an error. */ public static function add( $activity_object, $activity_type, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { // phpcs:ignore switch ( $user_id ) { diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 2e1eb6c60..fb4e47cd2 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -634,13 +634,13 @@ protected function get_classic_editor_image_attachments( $max_images ) { /** * Filter media IDs by object type. * - * @param array $media The media array grouped by type. - * @param string $type The object type. - * @param WP_Post $item The post object. + * @param array $media The media array grouped by type. + * @param string $type The object type. + * @param WP_Post $item The post object. * * @return array The filtered media IDs. */ - protected function filter_media_by_object_type( $media, $type, $wp_object ) { + protected function filter_media_by_object_type( $media, $type, $item ) { /** * Filter the object type for media attachments. * diff --git a/tests/class-test-activitypub-outbox-collection.php b/tests/includes/collection/class-test-outbox.php similarity index 72% rename from tests/class-test-activitypub-outbox-collection.php rename to tests/includes/collection/class-test-outbox.php index c5b8dfe3b..c9ff66ae5 100644 --- a/tests/class-test-activitypub-outbox-collection.php +++ b/tests/includes/collection/class-test-outbox.php @@ -1,16 +1,18 @@ assertIsInt( $id ); @@ -28,7 +34,7 @@ public function asd_test_add( $data, $type, $user_id, $json ) { $this->assertInstanceOf( 'WP_Post', $post ); $this->assertEquals( 'draft', $post->post_status ); - //$this->assertEquals( $json, $post->post_content ); + $this->assertEquals( $json, $post->post_content ); } /** From 6589bed2b945340f8745ec6dcba1a97e407a5466 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 14 Jan 2025 09:46:00 -0600 Subject: [PATCH 39/98] Fix shortcode tests --- tests/includes/class-test-shortcodes.php | 112 +++++++++-------------- 1 file changed, 44 insertions(+), 68 deletions(-) diff --git a/tests/includes/class-test-shortcodes.php b/tests/includes/class-test-shortcodes.php index 2819e0851..34358022c 100644 --- a/tests/includes/class-test-shortcodes.php +++ b/tests/includes/class-test-shortcodes.php @@ -7,6 +7,7 @@ namespace Activitypub\Tests; +use Activitypub\Scheduler\Post; use Activitypub\Shortcodes; /** @@ -17,103 +18,86 @@ class Test_Shortcodes extends \WP_UnitTestCase { /** - * Test the content shortcode. + * Post object. + * + * @var \WP_Post */ - public function test_content() { + protected $post; + + public function set_up() { + parent::set_up(); + + remove_action( 'transition_post_status', array( Post::class, 'schedule_post_activity' ), 33 ); Shortcodes::register(); + + // Create a post. + $this->post = self::factory()->post->create_and_get( + array( + 'post_title' => 'Test title for shortcode', + 'post_content' => 'Lorem ipsum dolor sit amet, consectetur.', + 'post_excerpt' => '', + ) + ); + } + + public function tear_down() { + parent::tear_down(); + + Shortcodes::unregister(); + + // Delete the post. + wp_delete_post( $this->post->ID, true ); + } + /** + * Test the content shortcode. + */ + public function test_content() { global $post; - $post_id = -99; // Negative ID, to avoid clash with a valid post. - $post = new \stdClass(); - $post->ID = $post_id; - $post->post_author = 1; - $post->post_date = current_time( 'mysql' ); - $post->post_date_gmt = current_time( 'mysql', 1 ); - $post->post_title = 'Some title or other'; - $post->post_content = 'hallo'; - $post->post_status = 'publish'; - $post->comment_status = 'closed'; - $post->ping_status = 'closed'; - $post->post_name = 'fake-post-' . wp_rand( 1, 99999 ); // Append random number to avoid clash. - $post->post_type = 'post'; - $post->filter = 'raw'; // important! - - $content = '[ap_content]'; + $post = $this->post; + $post->post_content = 'hallo'; // Fill in the shortcodes. setup_postdata( $post ); - $content = do_shortcode( $content ); + $content = do_shortcode( '[ap_content]' ); wp_reset_postdata(); $this->assertEquals( '

hallo

', $content ); - Shortcodes::unregister(); } /** * Test the content shortcode with password protected content. */ public function test_password_protected_content() { - Shortcodes::register(); global $post; - $post_id = -98; // Negative ID, to avoid clash with a valid post. - $post = new \stdClass(); - $post->ID = $post_id; - $post->post_author = 1; - $post->post_date = current_time( 'mysql' ); - $post->post_date_gmt = current_time( 'mysql', 1 ); - $post->post_title = 'Some title or other'; - $post->post_content = 'hallo'; - $post->comment_status = 'closed'; - $post->ping_status = 'closed'; - $post->post_name = 'fake-post-' . wp_rand( 1, 99999 ); // Append random number to avoid clash. - $post->post_type = 'post'; - $post->filter = 'raw'; // important! - $post->post_password = 'abc'; - - $content = '[ap_content]'; + $post = $this->post; + $post->post_password = 'abc'; // Fill in the shortcodes. setup_postdata( $post ); - $content = do_shortcode( $content ); + $content = do_shortcode( '[ap_content]' ); wp_reset_postdata(); $this->assertEquals( '', $content ); - Shortcodes::unregister(); } /** * Test the excerpt shortcode. */ public function test_excerpt() { - Shortcodes::register(); global $post; - $post_id = -97; // Negative ID, to avoid clash with a valid post. - $post = new \stdClass(); - $post->ID = $post_id; - $post->post_author = 1; - $post->post_date = current_time( 'mysql' ); - $post->post_date_gmt = current_time( 'mysql', 1 ); - $post->post_title = 'Some title or other'; - $post->post_content = 'Lorem ipsum dolor sit amet, consectetur.'; - $post->post_status = 'publish'; - $post->comment_status = 'closed'; - $post->ping_status = 'closed'; - $post->post_name = 'fake-post-' . wp_rand( 1, 99999 ); // Append random number to avoid clash. - $post->post_type = 'post'; - $post->filter = 'raw'; // important! - - $content = '[ap_excerpt length="25"]'; + $post = $this->post; + $post->post_content = 'Lorem ipsum dolor sit amet, consectetur.'; // Fill in the shortcodes. setup_postdata( $post ); - $content = do_shortcode( $content ); + $content = do_shortcode( '[ap_excerpt length="25"]' ); wp_reset_postdata(); $this->assertEquals( "

Lorem ipsum dolor […]

\n", $content ); - Shortcodes::unregister(); } /** @@ -122,22 +106,14 @@ public function test_excerpt() { * @covers ::title */ public function test_title() { - Shortcodes::register(); global $post; - $post = self::factory()->post->create_and_get( - array( - 'post_title' => 'Test title for shortcode', - ) - ); - - $content = '[ap_title]'; + $post = $this->post; // Fill in the shortcodes. setup_postdata( $post ); - $content = do_shortcode( $content ); + $content = do_shortcode( '[ap_title]' ); wp_reset_postdata(); - Shortcodes::unregister(); $this->assertEquals( 'Test title for shortcode', $content ); } From fad425e8a938c26ff05d631620a21ed409f73e85 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 14 Jan 2025 09:47:40 -0600 Subject: [PATCH 40/98] Update json tests. --- tests/includes/collection/class-test-outbox.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index c9ff66ae5..f3c603513 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -53,7 +53,7 @@ public function activity_object_provider() { ), 'Create', 1, - '{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","id":"https:\/\/example.com\/1","type":"Note","content":"This is a note"}', + '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"type":"Object","tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"mediaType":"text/html","sensitive":false}', ), array( array( @@ -64,7 +64,7 @@ public function activity_object_provider() { ), 'Create', 2, - '{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","id":"https:\/\/example.com\/2","type":"Note","content":"This is another note"}', + '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"type":"Object","tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"mediaType":"text/html","sensitive":false}', ), ); } From 1142be1d654a30ebfd001a3990724f89b1e67939 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 10:45:08 +0100 Subject: [PATCH 41/98] move map functions to base transformer --- includes/transformer/class-base.php | 41 +++++++++++++++++++++++++++++ includes/transformer/class-post.php | 41 ----------------------------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 1406a5875..bfd3d1ee2 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -189,4 +189,45 @@ protected function get_to() { public function get_replies() { return Replies::get_collection( $this->item ); } + + /** + * Returns the content map for the post. + * + * @return array The content map for the post. + */ + public function get_content_map() { + return array( + $this->get_locale() => $this->get_content(), + ); + } + + /** + * Returns the name map for the post. + * + * @return array The name map for the post. + */ + public function get_name_map() { + if ( ! $this->get_name() ) { + return null; + } + + return array( + $this->get_locale() => $this->get_name(), + ); + } + + /** + * Returns the summary map for the post. + * + * @return array The summary map for the post. + */ + public function get_summary_map() { + if ( ! $this->get_summary() ) { + return null; + } + + return array( + $this->get_locale() => $this->get_summary(), + ); + } } diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index fb4e47cd2..ac6d92baa 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -1097,47 +1097,6 @@ public function get_updated() { return null; } - /** - * Returns the content map for the post. - * - * @return array The content map for the post. - */ - public function get_content_map() { - return array( - $this->get_locale() => $this->get_content(), - ); - } - - /** - * Returns the name map for the post. - * - * @return array The name map for the post. - */ - public function get_name_map() { - if ( ! $this->get_name() ) { - return null; - } - - return array( - $this->get_locale() => $this->get_name(), - ); - } - - /** - * Returns the summary map for the post. - * - * @return array The summary map for the post. - */ - public function get_summary_map() { - if ( ! $this->get_summary() ) { - return null; - } - - return array( - $this->get_locale() => $this->get_summary(), - ); - } - /** * Transform Embed blocks to block level link. * From 1824b4bc941a9f6bc65779d2076761e8cb1bfd8b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 10:45:24 +0100 Subject: [PATCH 42/98] use item instead of wp_object --- includes/transformer/class-comment.php | 40 +++----------------------- 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index a7eff5e0e..7145c926f 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -305,35 +305,14 @@ public function extract_reply_context( $mentions = array() ) { return $mentions; } - /** - * Returns the locale of the post. - * - * @return string The locale of the post. - */ - public function get_locale() { - $comment_id = $this->wp_object->ID; - $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); - - /** - * Filter the locale of the comment. - * - * @param string $lang The locale of the comment. - * @param int $comment_id The comment ID. - * @param \WP_Post $post The comment object. - * - * @return string The filtered locale of the comment. - */ - return apply_filters( 'activitypub_comment_locale', $lang, $comment_id, $this->wp_object ); - } - /** * Returns the updated date of the comment. * * @return string|null The updated date of the comment. */ public function get_updated() { - $updated = \get_comment_meta( $this->wp_object->comment_ID, 'activitypub_comment_modified', true ); - $published = \get_comment_meta( $this->wp_object->comment_ID, 'activitypub_comment_published', true ); + $updated = \get_comment_meta( $this->item->comment_ID, 'activitypub_comment_modified', true ); + $published = \get_comment_meta( $this->item->comment_ID, 'activitypub_comment_published', true ); if ( $updated > $published ) { return \gmdate( 'Y-m-d\TH:i:s\Z', $updated ); @@ -348,7 +327,7 @@ public function get_updated() { * @return string The published date of the comment. */ public function get_published() { - return \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $this->wp_object->comment_date_gmt ) ); + return \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $this->item->comment_date_gmt ) ); } /** @@ -375,22 +354,11 @@ public function get_type() { * @return array The to of the comment. */ public function get_to() { - $path = sprintf( 'actors/%d/followers', intval( $this->wp_object->comment_author ) ); + $path = sprintf( 'actors/%d/followers', intval( $this->item->comment_author ) ); return array( 'https://www.w3.org/ns/activitystreams#Public', get_rest_url_by_path( $path ), ); } - - /** - * Returns the content map for the comment. - * - * @return array The content map for the comment. - */ - public function get_content_map() { - return array( - $this->get_locale() => $this->get_content(), - ); - } } From f9ca53d4e4e4b6c1e1ec2a4e0201bd3da917ddb5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 11:21:35 +0100 Subject: [PATCH 43/98] protect functions --- includes/transformer/class-base.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index bfd3d1ee2..3e8e1026e 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -195,7 +195,7 @@ public function get_replies() { * * @return array The content map for the post. */ - public function get_content_map() { + protected function get_content_map() { return array( $this->get_locale() => $this->get_content(), ); @@ -206,7 +206,7 @@ public function get_content_map() { * * @return array The name map for the post. */ - public function get_name_map() { + protected function get_name_map() { if ( ! $this->get_name() ) { return null; } @@ -221,7 +221,7 @@ public function get_name_map() { * * @return array The summary map for the post. */ - public function get_summary_map() { + protected function get_summary_map() { if ( ! $this->get_summary() ) { return null; } From 58a84d907260faf6918107d8a54c2813d0604593 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 11:32:34 +0100 Subject: [PATCH 44/98] fix tests and remove legacy tests --- includes/transformer/class-base.php | 4 +- includes/transformer/class-post.php | 28 +- .../class-test-activity-dispatcher.php | 364 ------------------ tests/includes/class-test-scheduler.php | 221 ----------- 4 files changed, 16 insertions(+), 601 deletions(-) delete mode 100644 tests/includes/class-test-activity-dispatcher.php delete mode 100644 tests/includes/class-test-scheduler.php diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 3e8e1026e..23ce1c603 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -207,7 +207,7 @@ protected function get_content_map() { * @return array The name map for the post. */ protected function get_name_map() { - if ( ! $this->get_name() ) { + if ( ! \method_exists( $this, 'get_name' ) || ! $this->get_name() ) { return null; } @@ -222,7 +222,7 @@ protected function get_name_map() { * @return array The summary map for the post. */ protected function get_summary_map() { - if ( ! $this->get_summary() ) { + if ( ! \method_exists( $this, 'get_summary' ) || ! $this->get_summary() ) { return null; } diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index ac6d92baa..0a7fdb106 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -93,7 +93,7 @@ public function get_actor_object() { return $blog_user; } - $user = Actors::get_by_id( $this->wp_object->post_author ); + $user = Actors::get_by_id( $this->item->post_author ); if ( $user && ! is_wp_error( $user ) ) { $this->actor_object = $user; @@ -165,7 +165,7 @@ protected function get_attributed_to() { * @return array|null The Image or null if no image is available. */ protected function get_image() { - $post_id = $this->wp_object->ID; + $post_id = $this->item->ID; // List post thumbnail first if this post has one. if ( @@ -218,7 +218,7 @@ protected function get_image() { * @return array|null The Icon or null if no icon is available. */ protected function get_icon() { - $post_id = $this->wp_object->ID; + $post_id = $this->item->ID; // List post thumbnail first if this post has one. if ( \has_post_thumbnail( $post_id ) ) { @@ -315,7 +315,7 @@ protected function get_attachment() { $media = $this->get_classic_editor_images( $media, $max_media ); } - $media = $this->filter_media_by_object_type( $media, \get_post_format( $this->wp_object ), $this->wp_object ); + $media = $this->filter_media_by_object_type( $media, \get_post_format( $this->item ), $this->item ); $unique_ids = \array_unique( \array_column( $media, 'id' ) ); $media = \array_intersect_key( $media, $unique_ids ); $media = \array_slice( $media, 0, $max_media ); @@ -534,7 +534,7 @@ protected function get_classic_editor_image_embeds( $max_images ) { $images = array(); $base = get_upload_baseurl(); - $content = \get_post_field( 'post_content', $this->wp_object ); + $content = \get_post_field( 'post_content', $this->item ); $tags = new \WP_HTML_Tag_Processor( $content ); // This linter warning is a false positive - we have to re-count each time here as we modify $images. @@ -805,11 +805,11 @@ protected function get_type() { // Default to Note. $object_type = 'Note'; - $post_type = \get_post_type( $this->wp_object ); + $post_type = \get_post_type( $this->item ); if ( 'page' === $post_type ) { $object_type = 'Page'; - } elseif ( ! \get_post_format( $this->wp_object ) ) { + } elseif ( ! \get_post_format( $this->item ) ) { $object_type = 'Article'; } @@ -897,7 +897,7 @@ protected function get_summary() { } // Remove Teaser from drafts. - if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->wp_object ) ) { + if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { return \__( '(This post is being modified)', 'activitypub' ); } @@ -941,7 +941,7 @@ protected function get_content() { add_filter( 'activitypub_reply_block', '__return_empty_string' ); // Remove Content from drafts. - if ( 'draft' === \get_post_status( $this->wp_object ) ) { + if ( 'draft' === \get_post_status( $this->item ) ) { return \__( '(This post is being modified)', 'activitypub' ); } @@ -1022,9 +1022,9 @@ protected function get_post_content_template() { * generation. * * @param string $template The template string containing shortcodes. - * @param WP_Post $wp_object The WordPress post object being transformed. + * @param WP_Post $item The WordPress post object being transformed. */ - return apply_filters( 'activitypub_object_content_template', $template, $this->wp_object ); + return apply_filters( 'activitypub_object_content_template', $template, $this->item ); } /** @@ -1057,7 +1057,7 @@ protected function get_mentions() { * * @return string|null The in-reply-to URL of the post. */ - public function get_in_reply_to() { + protected function get_in_reply_to() { $blocks = \parse_blocks( $this->item->post_content ); foreach ( $blocks as $block ) { @@ -1075,7 +1075,7 @@ public function get_in_reply_to() { * * @return string The published date of the post. */ - public function get_published() { + protected function get_published() { $published = \strtotime( $this->item->post_date_gmt ); return \gmdate( 'Y-m-d\TH:i:s\Z', $published ); @@ -1086,7 +1086,7 @@ public function get_published() { * * @return string|null The updated date of the post. */ - public function get_updated() { + protected function get_updated() { $published = \strtotime( $this->item->post_date_gmt ); $updated = \strtotime( $this->item->post_modified_gmt ); diff --git a/tests/includes/class-test-activity-dispatcher.php b/tests/includes/class-test-activity-dispatcher.php deleted file mode 100644 index e143cea3f..000000000 --- a/tests/includes/class-test-activity-dispatcher.php +++ /dev/null @@ -1,364 +0,0 @@ - array( - 'id' => 'https://example.org/users/username', - 'url' => 'https://example.org/users/username', - 'inbox' => 'https://example.org/users/username/inbox', - 'name' => 'username', - 'preferredUsername' => 'username', - ), - 'jon@example.com' => array( - 'id' => 'https://example.com/author/jon', - 'url' => 'https://example.com/author/jon', - 'inbox' => 'https://example.com/author/jon/inbox', - 'name' => 'jon', - 'preferredUsername' => 'jon', - ), - ); - - /** - * Set up the test case. - */ - public function set_up() { - parent::set_up(); - add_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ), 10, 2 ); - _delete_all_posts(); - } - - /** - * Tear down the test case. - */ - public function tear_down() { - remove_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ) ); - parent::tear_down(); - } - - /** - * Test dispatch activity. - * - * @covers ::send_activity - */ - public function test_dispatch_activity() { - $followers = array( 'https://example.com/author/jon', 'https://example.org/users/username' ); - - foreach ( $followers as $follower ) { - \Activitypub\Collection\Followers::add_follower( 1, $follower ); - } - - $post = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'hello', - ) - ); - - $pre_http_request = new \MockAction(); - add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 ); - - Activity_Dispatcher::send_activity( get_post( $post ), 'Create' ); - - $this->assertSame( 2, $pre_http_request->get_call_count() ); - $all_args = $pre_http_request->get_args(); - $first_call_args = array_shift( $all_args ); - - $this->assertEquals( 'https://example.com/author/jon/inbox', $first_call_args[2] ); - - $second_call_args = array_shift( $all_args ); - $this->assertEquals( 'https://example.org/users/username/inbox', $second_call_args[2] ); - - $json = json_decode( $second_call_args[1]['body'] ); - $this->assertEquals( 'Create', $json->type ); - $this->assertEquals( 'http://example.org/?author=1', $json->actor ); - $this->assertEquals( 'http://example.org/?author=1', $json->object->attributedTo ); - - remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 ); - } - - /** - * Test dispatch mentions. - * - * @covers ::send_activity - */ - public function test_dispatch_mentions() { - $post = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => '@alex hello', - ) - ); - - self::$users['https://example.com/alex'] = array( - 'id' => 'https://example.com/alex', - 'url' => 'https://example.com/alex', - 'inbox' => 'https://example.com/alex/inbox', - 'name' => 'alex', - ); - - add_filter( - 'activitypub_extract_mentions', - function ( $mentions ) { - $mentions[] = 'https://example.com/alex'; - return $mentions; - } - ); - - $pre_http_request = new \MockAction(); - add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 ); - - Activity_Dispatcher::send_activity( get_post( $post ), 'Create' ); - - $this->assertSame( 1, $pre_http_request->get_call_count() ); - $all_args = $pre_http_request->get_args(); - $first_call_args = $all_args[0]; - $this->assertEquals( 'https://example.com/alex/inbox', $first_call_args[2] ); - - $body = json_decode( $first_call_args[1]['body'], true ); - $this->assertArrayHasKey( 'id', $body ); - - remove_all_filters( 'activitypub_from_post_object' ); - remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 ); - } - - /** - * Test dispatch mentions. - * - * @covers ::send_activity_or_announce - */ - public function test_dispatch_announce() { - add_filter( 'activitypub_is_user_type_disabled', '__return_false' ); - - $followers = array( 'https://example.com/author/jon' ); - - foreach ( $followers as $follower ) { - \Activitypub\Collection\Followers::add_follower( \Activitypub\Collection\Actors::BLOG_USER_ID, $follower ); - } - - $post = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'hello', - ) - ); - - $pre_http_request = new \MockAction(); - add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 ); - - Activity_Dispatcher::send_activity_or_announce( get_post( $post ), 'Create' ); - - $all_args = $pre_http_request->get_args(); - $first_call_args = $all_args[0]; - - $this->assertSame( 1, $pre_http_request->get_call_count() ); - - $user = new \Activitypub\Model\Blog(); - - $json = json_decode( $first_call_args[1]['body'] ); - $this->assertEquals( 'Announce', $json->type ); - $this->assertEquals( $user->get_id(), $json->actor ); - - remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 ); - } - - /** - * Test dispatch blog activity. - * - * @covers ::send_activity_or_announce - */ - public function test_dispatch_blog_activity() { - $followers = array( 'https://example.com/author/jon' ); - - add_filter( - 'activitypub_is_user_type_disabled', - function ( $value, $type ) { - if ( 'blog' === $type ) { - return false; - } else { - return true; - } - }, - 10, - 2 - ); - - $this->assertTrue( \Activitypub\is_single_user() ); - - foreach ( $followers as $follower ) { - \Activitypub\Collection\Followers::add_follower( \Activitypub\Collection\Actors::BLOG_USER_ID, $follower ); - } - - $post = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'hello', - ) - ); - - $pre_http_request = new \MockAction(); - add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 ); - - Activity_Dispatcher::send_activity_or_announce( get_post( $post ), 'Create' ); - - $all_args = $pre_http_request->get_args(); - $first_call_args = $all_args[0]; - - $this->assertSame( 1, $pre_http_request->get_call_count() ); - - $user = new \Activitypub\Model\Blog(); - - $json = json_decode( $first_call_args[1]['body'] ); - $this->assertEquals( 'Create', $json->type ); - $this->assertEquals( $user->get_id(), $json->actor ); - $this->assertEquals( $user->get_id(), $json->object->attributedTo ); - - remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 ); - } - - /** - * Test dispatch fallback activity. - * - * @covers ::send_activity - */ - public function test_dispatch_fallback_activity() { - $followers = array( 'https://example.com/author/jon' ); - - add_filter( 'activitypub_is_user_type_disabled', '__return_false' ); - - add_filter( - 'activitypub_is_user_disabled', - function ( $disabled, $user_id ) { - if ( 1 === (int) $user_id ) { - return true; - } - - return false; - }, - 10, - 2 - ); - - $this->assertFalse( \Activitypub\is_single_user() ); - - foreach ( $followers as $follower ) { - \Activitypub\Collection\Followers::add_follower( \Activitypub\Collection\Actors::BLOG_USER_ID, $follower ); - \Activitypub\Collection\Followers::add_follower( 1, $follower ); - } - - $post = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'hello', - ) - ); - - $pre_http_request = new \MockAction(); - add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 ); - - Activity_Dispatcher::send_activity( get_post( $post ), 'Create' ); - - $all_args = $pre_http_request->get_args(); - $first_call_args = $all_args[0]; - - $this->assertSame( 1, $pre_http_request->get_call_count() ); - - $user = new \Activitypub\Model\Blog(); - - $json = json_decode( $first_call_args[1]['body'] ); - $this->assertEquals( 'Create', $json->type ); - $this->assertEquals( $user->get_id(), $json->actor ); - $this->assertEquals( $user->get_id(), $json->object->attributedTo ); - - remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 ); - } - - /** - * Filters remote metadata by actor. - * - * @param array|bool $pre The metadata for the given URL. - * @param string $actor The URL of the actor. - * @return array|bool - */ - public static function pre_get_remote_metadata_by_actor( $pre, $actor ) { - if ( isset( self::$users[ $actor ] ) ) { - return self::$users[ $actor ]; - } - foreach ( self::$users as $data ) { - if ( $data['url'] === $actor ) { - return $data; - } - } - return $pre; - } - - /** - * Filters the arguments used in an HTTP request. - * - * @param array $args The arguments for the HTTP request. - * @param string $url The request URL. - * @return array - */ - public static function http_request_args( $args, $url ) { - if ( in_array( wp_parse_url( $url, PHP_URL_HOST ), array( 'example.com', 'example.org' ), true ) ) { - $args['reject_unsafe_urls'] = false; - } - return $args; - } - - /** - * Filters the return value of an HTTP request. - * - * @param bool $preempt Whether to preempt an HTTP request's return value. - * @param array $request { - * Array of HTTP request arguments. - * - * @type string $method Request method. - * @type string $body Request body. - * } - * @param string $url The request URL. - * @return array Array containing 'headers', 'body', 'response'. - */ - public static function pre_http_request( $preempt, $request, $url ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - return array( - 'headers' => array( - 'content-type' => 'text/json', - ), - 'body' => '', - 'response' => array( - 'code' => 202, - ), - ); - } - - /** - * Filters the return value of an HTTP request. - * - * @param array $response Response array. - * @param array $args Request arguments. - * @param string $url Request URL. - * @return array - */ - public static function http_response( $response, $args, $url ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - return $response; - } -} diff --git a/tests/includes/class-test-scheduler.php b/tests/includes/class-test-scheduler.php deleted file mode 100644 index d7df36643..000000000 --- a/tests/includes/class-test-scheduler.php +++ /dev/null @@ -1,221 +0,0 @@ -post = self::factory()->post->create_and_get( - array( - 'post_title' => 'Test Post', - 'post_content' => 'Test Content', - 'post_status' => 'draft', - 'post_author' => 1, - ) - ); - } - - /** - * Clean up test resources after each test. - * - * Deletes the test post. - */ - public function tear_down() { - wp_delete_post( $this->post->ID, true ); - parent::tear_down(); - } - - /** - * Test that moving a draft post to trash does not schedule federation. - * - * @covers ::schedule_post_activity - */ - public function test_draft_to_trash_should_not_schedule_federation() { - Scheduler::schedule_post_activity( 'trash', 'draft', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Delete' ) ), - 'Draft to trash transition should not schedule federation' - ); - } - - /** - * Test that moving a published post to trash schedules a delete activity only if federated. - * - * @covers ::schedule_post_activity - */ - public function test_publish_to_trash_should_schedule_delete_only_if_federated() { - wp_publish_post( $this->post->ID ); - $this->post = get_post( $this->post->ID ); - - // Test without federation state. - Scheduler::schedule_post_activity( 'trash', 'publish', $this->post ); - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Delete' ) ), - 'Published to trash transition should not schedule delete activity when not federated' - ); - - // Test with federation state. - \Activitypub\set_wp_object_state( $this->post, 'federated' ); - Scheduler::schedule_post_activity( 'trash', 'publish', $this->post ); - - $this->assertNotFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Delete' ) ), - 'Published to trash transition should schedule delete activity when federated' - ); - } - - /** - * Test that updating a draft post does not schedule federation. - * - * @covers ::schedule_post_activity - */ - public function test_draft_to_draft_should_not_schedule_federation() { - Scheduler::schedule_post_activity( 'draft', 'draft', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Draft to draft transition should not schedule federation' - ); - } - - /** - * Test that moving a published post to draft schedules an update activity. - * - * @covers ::schedule_post_activity - */ - public function test_publish_to_draft_should_schedule_update() { - wp_publish_post( $this->post->ID ); - $this->post = get_post( $this->post->ID ); - Scheduler::schedule_post_activity( 'draft', 'publish', $this->post ); - - $this->assertNotFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Published to draft transition should schedule update activity' - ); - } - - /** - * Test that publishing a draft post schedules a create activity. - * - * @covers ::schedule_post_activity - */ - public function test_draft_to_publish_should_schedule_create() { - Scheduler::schedule_post_activity( 'publish', 'draft', $this->post ); - - $this->assertNotFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Create' ) ), - 'Draft to publish transition should schedule create activity' - ); - } - - /** - * Test that updating a published post schedules an update activity. - * - * @covers ::schedule_post_activity - */ - public function test_publish_to_publish_should_schedule_update() { - wp_publish_post( $this->post->ID ); - $this->post = get_post( $this->post->ID ); - Scheduler::schedule_post_activity( 'publish', 'publish', $this->post ); - - $this->assertNotFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Published to published transition should schedule update activity' - ); - } - - /** - * Test that various non-standard status transitions do not schedule federation. - * - * Tests transitions from pending, private, and future statuses. - * - * @covers ::schedule_post_activity - */ - public function test_other_status_transitions_should_not_schedule_federation() { - // Test pending to draft. - Scheduler::schedule_post_activity( 'draft', 'pending', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Pending to draft transition should not schedule federation' - ); - - // Test private to draft. - Scheduler::schedule_post_activity( 'draft', 'private', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Private to draft transition should not schedule federation' - ); - - // Test future to draft. - Scheduler::schedule_post_activity( 'draft', 'future', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Future to draft transition should not schedule federation' - ); - } - - /** - * Test that disabled posts do not schedule federation activities. - * - * @covers ::schedule_post_activity - */ - public function test_disabled_post_should_not_schedule_federation() { - update_post_meta( $this->post->ID, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ); - Scheduler::schedule_post_activity( 'publish', 'draft', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Create' ) ), - 'Disabled posts should not schedule federation activities' - ); - } - - /** - * Test that password protected posts do not schedule federation activities. - * - * @covers ::schedule_post_activity - */ - public function test_password_protected_post_should_not_schedule_federation() { - wp_update_post( - array( - 'ID' => $this->post->ID, - 'post_password' => 'test-password', - ) - ); - $this->post = get_post( $this->post->ID ); - Scheduler::schedule_post_activity( 'publish', 'draft', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Create' ) ), - 'Password protected posts should not schedule federation activities' - ); - } -} From c291d03553a4e09d0fb97ed94162179ce6372dbd Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 11:34:22 +0100 Subject: [PATCH 45/98] fix phpcs --- tests/includes/class-test-shortcodes.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/includes/class-test-shortcodes.php b/tests/includes/class-test-shortcodes.php index 34358022c..2ae7f188c 100644 --- a/tests/includes/class-test-shortcodes.php +++ b/tests/includes/class-test-shortcodes.php @@ -24,6 +24,9 @@ class Test_Shortcodes extends \WP_UnitTestCase { */ protected $post; + /** + * Set up the test. + */ public function set_up() { parent::set_up(); @@ -41,6 +44,9 @@ public function set_up() { ); } + /** + * Tear down the test. + */ public function tear_down() { parent::tear_down(); @@ -49,6 +55,7 @@ public function tear_down() { // Delete the post. wp_delete_post( $this->post->ID, true ); } + /** * Test the content shortcode. */ From 1aeb48617feef3d1bc3698c36495b8b163d90a60 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 11:42:40 +0100 Subject: [PATCH 46/98] add unittests --- .../transformer/class-test-factory.php | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/includes/transformer/class-test-factory.php diff --git a/tests/includes/transformer/class-test-factory.php b/tests/includes/transformer/class-test-factory.php new file mode 100644 index 000000000..9d8e38b54 --- /dev/null +++ b/tests/includes/transformer/class-test-factory.php @@ -0,0 +1,193 @@ +post->create(); + + // Create test attachment. + self::$attachment_id = $factory->attachment->create_object( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + ) + ); + + // Create test comment. + self::$comment_id = $factory->comment->create( + array( + 'comment_post_ID' => self::$post_id, + ) + ); + } + + /** + * Clean up after tests. + */ + public static function wpTearDownAfterClass() { + wp_delete_post( self::$post_id, true ); + wp_delete_post( self::$attachment_id, true ); + wp_delete_comment( self::$comment_id, true ); + } + + /** + * Test get_transformer with invalid input. + * + * @covers ::get_transformer + */ + public function test_get_transformer_invalid_input() { + $result = Factory::get_transformer( null ); + $this->assertWPError( $result ); + $this->assertEquals( 'invalid_object', $result->get_error_code() ); + } + + /** + * Test get_transformer with post. + * + * @covers ::get_transformer + */ + public function test_get_transformer_post() { + $post = get_post( self::$post_id ); + $transformer = Factory::get_transformer( $post ); + + $this->assertInstanceOf( Post::class, $transformer ); + } + + /** + * Test get_transformer with attachment. + * + * @covers ::get_transformer + */ + public function test_get_transformer_attachment() { + $attachment = get_post( self::$attachment_id ); + $transformer = Factory::get_transformer( $attachment ); + + $this->assertInstanceOf( Attachment::class, $transformer ); + } + + /** + * Test get_transformer with comment. + * + * @covers ::get_transformer + */ + public function test_get_transformer_comment() { + $comment = get_comment( self::$comment_id ); + $transformer = Factory::get_transformer( $comment ); + + $this->assertInstanceOf( Comment::class, $transformer ); + } + + /** + * Test get_transformer with JSON data. + * + * @covers ::get_transformer + */ + public function test_get_transformer_json() { + $json_string = '{"type": "Note", "content": "Test"}'; + $transformer = Factory::get_transformer( $json_string ); + + $this->assertInstanceOf( Json::class, $transformer ); + + $json_array = array( + 'type' => 'Note', + 'content' => 'Test', + ); + $transformer = Factory::get_transformer( $json_array ); + + $this->assertInstanceOf( Json::class, $transformer ); + } + + /** + * Test get_transformer with custom filter. + * + * @covers ::get_transformer + */ + public function test_get_transformer_filter() { + add_filter( + 'activitypub_transformer', + // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.classFound + function ( $transformer, $data, $class ) { + if ( 'WP_Post' === $class && 'post' === $data->post_type ) { + return new Activity_Object( $data ); + } + return $transformer; + }, + 10, + 3 + ); + + $post = get_post( self::$post_id ); + $transformer = Factory::get_transformer( $post ); + + $this->assertInstanceOf( Activity_Object::class, $transformer ); + + remove_all_filters( 'activitypub_transformer' ); + } + + /** + * Test get_transformer with invalid filter return. + * + * @covers ::get_transformer + */ + public function test_get_transformer_invalid_filter() { + add_filter( + 'activitypub_transformer', + function () { + return 'invalid'; + } + ); + + $post = get_post( self::$post_id ); + $result = Factory::get_transformer( $post ); + + $this->assertWPError( $result ); + $this->assertEquals( 'invalid_transformer', $result->get_error_code() ); + + remove_all_filters( 'activitypub_transformer' ); + } +} From 99a0a4802443d457d7d5eceef09eaba40f3f9556 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 12:26:50 +0100 Subject: [PATCH 47/98] fix attachment issues and add tests --- includes/class-shortcodes.php | 30 ++-- includes/transformer/class-attachment.php | 8 +- .../transformer/class-test-attachment.php | 167 ++++++++++++++++++ 3 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 tests/includes/transformer/class-test-attachment.php diff --git a/includes/class-shortcodes.php b/includes/class-shortcodes.php index 6ac73c03c..10018dd47 100644 --- a/includes/class-shortcodes.php +++ b/includes/class-shortcodes.php @@ -166,24 +166,26 @@ public static function content( $atts, $content, $tag ) { if ( empty( $content ) ) { $content = get_post_meta( $item->ID, '_wp_attachment_image_alt', true ); } - } else { - $content = \get_post_field( 'post_content', $item ); + } - if ( 'yes' === $atts['apply_filters'] ) { - /** This filter is documented in wp-includes/post-template.php */ - $content = \apply_filters( 'the_content', $content ); - } else { - $content = do_blocks( $content ); - $content = wptexturize( $content ); - $content = wp_filter_content_tags( $content ); - } + if ( empty( $content ) ) { + $content = \get_post_field( 'post_content', $item ); + } - // Replace script and style elements. - $content = \preg_replace( '@<(script|style)[^>]*?>.*?@si', '', $content ); - $content = strip_shortcodes( $content ); - $content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) ); + if ( 'yes' === $atts['apply_filters'] ) { + /** This filter is documented in wp-includes/post-template.php */ + $content = \apply_filters( 'the_content', $content ); + } else { + $content = do_blocks( $content ); + $content = wptexturize( $content ); + $content = wp_filter_content_tags( $content ); } + // Replace script and style elements. + $content = \preg_replace( '@<(script|style)[^>]*?>.*?@si', '', $content ); + $content = strip_shortcodes( $content ); + $content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) ); + add_shortcode( 'ap_content', array( 'Activitypub\Shortcodes', 'content' ) ); return $content; diff --git a/includes/transformer/class-attachment.php b/includes/transformer/class-attachment.php index ef3e1d1fd..65f500ca8 100644 --- a/includes/transformer/class-attachment.php +++ b/includes/transformer/class-attachment.php @@ -24,11 +24,11 @@ class Attachment extends Post { * @return array The Attachments. */ protected function get_attachment() { - $mime_type = get_post_mime_type( $this->item->ID ); - $media_type = preg_replace( '/(\/[a-zA-Z]+)/i', '', $mime_type ); - $type = ''; + $mime_type = \get_post_mime_type( $this->item->ID ); + $mime_type_parts = \explode( '/', $mime_type ); + $type = ''; - switch ( $media_type ) { + switch ( $mime_type_parts[0] ) { case 'audio': case 'video': $type = 'Document'; diff --git a/tests/includes/transformer/class-test-attachment.php b/tests/includes/transformer/class-test-attachment.php new file mode 100644 index 000000000..6887da527 --- /dev/null +++ b/tests/includes/transformer/class-test-attachment.php @@ -0,0 +1,167 @@ +attachment->create_object( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + 'post_title' => 'Test Image', + 'post_content' => 'Test Image Description', + ) + ); + } + + /** + * Clean up after tests. + */ + public static function wpTearDownAfterClass() { + wp_delete_post( self::$attachment_id, true ); + } + + /** + * Test get_type method. + * + * @covers ::get_type + */ + public function test_get_type() { + $attachment = get_post( self::$attachment_id ); + $transformer = new Attachment( $attachment ); + $type = $this->get_protected_method( $transformer, 'get_type' ); + + $this->assertEquals( 'Note', $type ); + } + + /** + * Test get_attachment method with different mime types. + * + * @covers ::get_attachment + * @dataProvider provide_mime_types + */ + public function test_get_attachment( $mime_type, $expected_type ) { + $attachment_id = self::factory()->attachment->create_object( + array( + 'post_type' => 'attachment', + 'post_mime_type' => $mime_type, + ) + ); + + $attachment = get_post( $attachment_id ); + $transformer = new Attachment( $attachment ); + $result = $this->get_protected_method( $transformer, 'get_attachment' ); + + $this->assertIsArray( $result ); + $this->assertEquals( $expected_type, $result['type'] ); + $this->assertEquals( $mime_type, $result['mediaType'] ); + $this->assertArrayHasKey( 'url', $result ); + + wp_delete_post( $attachment_id, true ); + } + + /** + * Test get_attachment method with alt text. + * + * @covers ::get_attachment + */ + public function test_get_attachment_with_alt() { + $alt_text = 'Test Alt Text'; + update_post_meta( self::$attachment_id, '_wp_attachment_image_alt', $alt_text ); + + $attachment = get_post( self::$attachment_id ); + $transformer = new Attachment( $attachment ); + $result = $this->get_protected_method( $transformer, 'get_attachment' ); + + $this->assertArrayHasKey( 'name', $result ); + $this->assertEquals( $alt_text, $result['name'] ); + } + + /** + * Test to_object method. + * + * @covers ::to_object + */ + public function test_to_object() { + $attachment = get_post( self::$attachment_id ); + $transformer = new Attachment( $attachment ); + $object = $transformer->to_object(); + + $this->assertEquals( 'Note', $object->get_type() ); + $this->assertEquals( home_url( '?p=' . self::$attachment_id ), $object->get_id() ); + $this->assertNull( $object->get_name() ); + } + + /** + * Data provider for mime types. + * + * @return array Test data. + */ + public function provide_mime_types() { + return array( + 'image' => array( + 'image/jpeg', + 'Image', + ), + 'audio' => array( + 'audio/mpeg', + 'Document', + ), + 'video' => array( + 'video/mp4', + 'Document', + ), + 'pdf' => array( + 'application/pdf', + '', + ), + 'text' => array( + 'text/plain', + '', + ), + ); + } + + /** + * Helper method to access protected methods. + * + * @param object $object Object instance. + * @param string $method_name Method name. + * @param array $parameters Optional parameters. + * + * @return mixed Method result. + */ + protected function get_protected_method( $object, $method_name, $parameters = array() ) { + $reflection = new \ReflectionClass( get_class( $object ) ); + $method = $reflection->getMethod( $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( $object, $parameters ); + } +} From 5d89584e912e87a503dd01b6ea8955b24ae40515 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 12:46:53 +0100 Subject: [PATCH 48/98] add tests for JSON transformer and fix detected problems --- includes/transformer/class-base.php | 4 + includes/transformer/class-json.php | 12 +- .../includes/collection/class-test-outbox.php | 4 +- .../includes/transformer/class-test-json.php | 132 ++++++++++++++++++ 4 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 tests/includes/transformer/class-test-json.php diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 23ce1c603..897fc9592 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -70,6 +70,10 @@ public function __construct( $item ) { * @return Base_Object The transformed ActivityPub Object. */ protected function transform_object_properties( $activity_object ) { + if ( ! $activity_object || \is_wp_error( $activity_object ) ) { + return $activity_object; + } + $vars = $activity_object->get_object_var_keys(); foreach ( $vars as $var ) { diff --git a/includes/transformer/class-json.php b/includes/transformer/class-json.php index 8e8dbf05c..6b7c0288e 100644 --- a/includes/transformer/class-json.php +++ b/includes/transformer/class-json.php @@ -20,14 +20,14 @@ class Json extends Activity_Object { * @param string|array $item The item that should be transformed. */ public function __construct( $item ) { - $item = new Base_Object(); + $object = new Base_Object(); - if ( is_array( $this->item ) ) { - $item = Base_Object::init_from_array( $this->item ); - } elseif ( is_string( $this->item ) ) { - $item = Base_Object::init_from_json( $this->item ); + if ( is_array( $item ) ) { + $object = Base_Object::init_from_array( $item ); + } elseif ( is_string( $item ) ) { + $object = Base_Object::init_from_json( $item ); } - parent::__construct( $item ); + parent::__construct( $object ); } } diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index f3c603513..5ed566435 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -53,7 +53,7 @@ public function activity_object_provider() { ), 'Create', 1, - '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"type":"Object","tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"mediaType":"text/html","sensitive":false}', + '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/1","type":"Note","content":"This is a note","contentMap":{"en":"This is a note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"mediaType":"text/html","sensitive":false}', ), array( array( @@ -64,7 +64,7 @@ public function activity_object_provider() { ), 'Create', 2, - '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"type":"Object","tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"mediaType":"text/html","sensitive":false}', + '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/2","type":"Note","content":"This is another note","contentMap":{"en":"This is another note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"mediaType":"text/html","sensitive":false}', ), ); } diff --git a/tests/includes/transformer/class-test-json.php b/tests/includes/transformer/class-test-json.php new file mode 100644 index 000000000..d08f92a25 --- /dev/null +++ b/tests/includes/transformer/class-test-json.php @@ -0,0 +1,132 @@ + 'Note', + 'content' => 'Test Content', + 'id' => 'https://example.com/test', + ) + ); + + $transformer = new Json( $json_string ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( Base_Object::class, $object ); + $this->assertEquals( 'Note', $object->get_type() ); + $this->assertEquals( 'Test Content', $object->get_content() ); + $this->assertEquals( 'https://example.com/test', $object->get_id() ); + } + + /** + * Test constructor with array. + * + * @covers ::__construct + */ + public function test_constructor_with_array() { + $array = array( + 'type' => 'Article', + 'name' => 'Test Title', + 'content' => 'Test Content', + 'url' => 'https://example.com/article', + ); + + $transformer = new Json( $array ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( Base_Object::class, $object ); + $this->assertEquals( 'Article', $object->get_type() ); + $this->assertEquals( 'Test Title', $object->get_name() ); + $this->assertEquals( 'Test Content', $object->get_content() ); + $this->assertEquals( 'https://example.com/article', $object->get_url() ); + } + + /** + * Test constructor with invalid JSON string. + * + * @covers ::__construct + */ + public function test_constructor_with_invalid_json() { + $invalid_json = '{invalid json string}'; + + $transformer = new Json( $invalid_json ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( 'WP_Error', $object ); + } + + /** + * Test constructor with empty input. + * + * @covers ::__construct + */ + public function test_constructor_with_empty_input() { + $transformer = new Json( '' ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( 'WP_Error', $object ); + } + + /** + * Test constructor with complex nested data. + * + * @covers ::__construct + */ + public function test_constructor_with_nested_data() { + $data = array( + 'type' => 'Note', + 'content' => 'Test Content', + 'attachment' => array( + array( + 'type' => 'Image', + 'mediaType' => 'image/jpeg', + 'url' => 'https://example.com/image.jpg', + ), + ), + 'tag' => array( + array( + 'type' => 'Mention', + 'name' => '@test', + 'href' => 'https://example.com/@test', + ), + ), + ); + + $transformer = new Json( $data ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( Base_Object::class, $object ); + $this->assertEquals( 'Note', $object->get_type() ); + $this->assertEquals( 'Test Content', $object->get_content() ); + + $attachment = $object->get_attachment(); + $this->assertIsArray( $attachment ); + $this->assertEquals( 'Image', $attachment[0]['type'] ); + + $tags = $object->get_tag(); + $this->assertIsArray( $tags ); + $this->assertEquals( 'Mention', $tags[0]['type'] ); + } +} From 8bea3685e6582c05b5a8301079581495aa289955 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 12:49:57 +0100 Subject: [PATCH 49/98] fix phpcs issues --- .../includes/transformer/class-test-attachment.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/includes/transformer/class-test-attachment.php b/tests/includes/transformer/class-test-attachment.php index 6887da527..f1f2965c1 100644 --- a/tests/includes/transformer/class-test-attachment.php +++ b/tests/includes/transformer/class-test-attachment.php @@ -29,7 +29,7 @@ class Test_Attachment extends WP_UnitTestCase { * @param WP_UnitTest_Factory $factory Helper that creates fake data. */ public static function wpSetUpBeforeClass( $factory ) { - // Create test attachment + // Create test attachment. self::$attachment_id = $factory->attachment->create_object( array( 'post_type' => 'attachment', @@ -65,6 +65,9 @@ public function test_get_type() { * * @covers ::get_attachment * @dataProvider provide_mime_types + * + * @param string $mime_type The mime type of the attachment. + * @param string $expected_type The expected type of the attachment. */ public function test_get_attachment( $mime_type, $expected_type ) { $attachment_id = self::factory()->attachment->create_object( @@ -151,17 +154,17 @@ public function provide_mime_types() { /** * Helper method to access protected methods. * - * @param object $object Object instance. + * @param object $obj Object instance. * @param string $method_name Method name. * @param array $parameters Optional parameters. * * @return mixed Method result. */ - protected function get_protected_method( $object, $method_name, $parameters = array() ) { - $reflection = new \ReflectionClass( get_class( $object ) ); + protected function get_protected_method( $obj, $method_name, $parameters = array() ) { + $reflection = new \ReflectionClass( get_class( $obj ) ); $method = $reflection->getMethod( $method_name ); $method->setAccessible( true ); - return $method->invokeArgs( $object, $parameters ); + return $method->invokeArgs( $obj, $parameters ); } } From 1c29caf96c02b3ab1c9b07f564aba71d04ff3cde Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 13:04:15 +0100 Subject: [PATCH 50/98] add activity-object transformer tests --- .../class-test-activity-object.php | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 tests/includes/transformer/class-test-activity-object.php diff --git a/tests/includes/transformer/class-test-activity-object.php b/tests/includes/transformer/class-test-activity-object.php new file mode 100644 index 000000000..0a437eca1 --- /dev/null +++ b/tests/includes/transformer/class-test-activity-object.php @@ -0,0 +1,216 @@ +test_object = new Base_Object(); + $this->test_object->set_content( 'Test content with @mention and another @mention2' ); + $this->test_object->set_summary( 'Test summary with @mention3' ); + $this->test_object->set_name( 'Test name' ); + $this->test_object->set_type( 'Note' ); + } + + /** + * Test to_object method. + * + * @covers ::to_object + */ + public function test_to_object() { + $transformer = new Activity_Object( $this->test_object ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( Base_Object::class, $object ); + $this->assertEquals( 'Test content with @mention and another @mention2', $object->get_content() ); + $this->assertEquals( 'Test name', $object->get_name() ); + $this->assertEquals( 'Note', $object->get_type() ); + } + + /** + * Test get_mentions method. + * + * @covers ::get_mentions + */ + public function test_get_mentions() { + add_filter( + 'activitypub_extract_mentions', + function () { + return array( + '@mention' => 'https://example.com/@mention', + '@mention2' => 'https://example.com/@mention2', + '@mention3' => 'https://example.com/@mention3', + ); + }, + 10, + 2 + ); + + $transformer = new Activity_Object( $this->test_object ); + $mentions = $this->get_protected_method( $transformer, 'get_mentions' ); + + $this->assertIsArray( $mentions ); + $this->assertCount( 3, $mentions ); + $this->assertEquals( 'https://example.com/@mention', $mentions['@mention'] ); + + remove_all_filters( 'activitypub_extract_mentions' ); + } + + /** + * Test get_cc method. + * + * @covers ::get_cc + */ + public function test_get_cc() { + add_filter( + 'activitypub_extract_mentions', + function () { + return array( + '@mention' => 'https://example.com/@mention', + '@mention2' => 'https://example.com/@mention2', + ); + } + ); + + $transformer = new Activity_Object( $this->test_object ); + $cc = $this->get_protected_method( $transformer, 'get_cc' ); + + $this->assertIsArray( $cc ); + $this->assertCount( 2, $cc ); + $this->assertContains( 'https://example.com/@mention', $cc ); + $this->assertContains( 'https://example.com/@mention2', $cc ); + + remove_all_filters( 'activitypub_extract_mentions' ); + } + + /** + * Test get_content_map method. + * + * @covers ::get_content_map + */ + public function test_get_content_map() { + $transformer = new Activity_Object( $this->test_object ); + $content_map = $this->get_protected_method( $transformer, 'get_content_map' ); + + $this->assertIsArray( $content_map ); + $this->assertArrayHasKey( $this->get_locale(), $content_map ); + $this->assertEquals( 'Test content with @mention and another @mention2', $content_map[ $this->get_locale() ] ); + + // Test with empty content. + $this->test_object->set_content( '' ); + $content_map = $this->get_protected_method( $transformer, 'get_content_map' ); + $this->assertNull( $content_map ); + } + + /** + * Test get_name_map method. + * + * @covers ::get_name_map + */ + public function test_get_name_map() { + $transformer = new Activity_Object( $this->test_object ); + $name_map = $this->get_protected_method( $transformer, 'get_name_map' ); + + $this->assertIsArray( $name_map ); + $this->assertArrayHasKey( $this->get_locale(), $name_map ); + $this->assertEquals( 'Test name', $name_map[ $this->get_locale() ] ); + + // Test with empty name. + $this->test_object->set_name( '' ); + $name_map = $this->get_protected_method( $transformer, 'get_name_map' ); + $this->assertNull( $name_map ); + } + + /** + * Test get_tag method. + * + * @covers ::get_tag + */ + public function test_get_tag() { + add_filter( + 'activitypub_extract_mentions', + function () { + return array( + '@mention' => 'https://example.com/@mention', + ); + } + ); + + $this->test_object->set_tag( + array( + array( + 'type' => 'Hashtag', + 'name' => '#test', + ), + ) + ); + + $transformer = new Activity_Object( $this->test_object ); + $tags = $this->get_protected_method( $transformer, 'get_tag' ); + + $this->assertIsArray( $tags ); + $this->assertCount( 2, $tags ); + + // Test hashtag. + $this->assertEquals( 'Hashtag', $tags[0]['type'] ); + $this->assertEquals( '#test', $tags[0]['name'] ); + + // Test mention. + $this->assertEquals( 'Mention', $tags[1]['type'] ); + $this->assertEquals( '@mention', $tags[1]['name'] ); + $this->assertEquals( 'https://example.com/@mention', $tags[1]['href'] ); + + remove_all_filters( 'activitypub_extract_mentions' ); + } + + /** + * Helper method to access protected methods. + * + * @param object $obj Object instance. + * @param string $method_name Method name. + * @param array $parameters Optional parameters. + * + * @return mixed Method result. + */ + protected function get_protected_method( $obj, $method_name, $parameters = array() ) { + $reflection = new \ReflectionClass( get_class( $obj ) ); + $method = $reflection->getMethod( $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( $obj, $parameters ); + } +} From b654081388cd8dd4d5e46e737c2ce5e003e089ae Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 14:21:32 +0100 Subject: [PATCH 51/98] simplify code --- includes/transformer/class-base.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 897fc9592..e52ca7ba8 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -83,15 +83,13 @@ protected function transform_object_properties( $activity_object ) { $value = call_user_func( array( $this, $getter ) ); if ( isset( $value ) ) { - $setter = 'set_' . $var; - /** * Filter the value before it is set to the Activity-Object `$activity_object`. * * @param mixed $value The value that should be set. * @param mixed $item The Object. */ - $value = apply_filters( 'activitypub_transform_' . $setter, $value, $this->item ); + $value = apply_filters( "activitypub_transform_set_{$var}", $value, $this->item ); /** * Filter the value before it is set to the Activity-Object `$activity_object`. From 2077a01d5e3eb908379d5580a25f7a1d17d9fb3e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 14:22:23 +0100 Subject: [PATCH 52/98] define as global functions --- includes/transformer/class-base.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index e52ca7ba8..b42f77820 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -79,8 +79,8 @@ protected function transform_object_properties( $activity_object ) { foreach ( $vars as $var ) { $getter = 'get_' . $var; - if ( method_exists( $this, $getter ) ) { - $value = call_user_func( array( $this, $getter ) ); + if ( \method_exists( $this, $getter ) ) { + $value = \call_user_func( array( $this, $getter ) ); if ( isset( $value ) ) { /** @@ -89,7 +89,7 @@ protected function transform_object_properties( $activity_object ) { * @param mixed $value The value that should be set. * @param mixed $item The Object. */ - $value = apply_filters( "activitypub_transform_set_{$var}", $value, $this->item ); + $value = \apply_filters( "activitypub_transform_set_{$var}", $value, $this->item ); /** * Filter the value before it is set to the Activity-Object `$activity_object`. @@ -98,9 +98,9 @@ protected function transform_object_properties( $activity_object ) { * @param string $var The variable name. * @param mixed $item The Object. */ - $value = apply_filters( 'activitypub_transform_set', $value, $var, $this->item ); + $value = \apply_filters( 'activitypub_transform_set', $value, $var, $this->item ); - call_user_func( array( $activity_object, $setter ), $value ); + \call_user_func( array( $activity_object, $setter ), $value ); } } } From 96d1146a84eed224b62a8f61ac6fd0ab4699226f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jan 2025 14:40:51 +0100 Subject: [PATCH 53/98] re-added setter --- includes/transformer/class-base.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index b42f77820..9e382c71b 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -83,13 +83,15 @@ protected function transform_object_properties( $activity_object ) { $value = \call_user_func( array( $this, $getter ) ); if ( isset( $value ) ) { + $setter = 'set_' . $var; + /** * Filter the value before it is set to the Activity-Object `$activity_object`. * * @param mixed $value The value that should be set. * @param mixed $item The Object. */ - $value = \apply_filters( "activitypub_transform_set_{$var}", $value, $this->item ); + $value = \apply_filters( "activitypub_transform_{$setter}", $value, $this->item ); /** * Filter the value before it is set to the Activity-Object `$activity_object`. From 476250c0b5a11911f85525c34fadc61a637985f2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jan 2025 16:34:00 +0100 Subject: [PATCH 54/98] add content visibility --- includes/collection/class-outbox.php | 3 +++ includes/functions.php | 4 ++-- includes/scheduler/class-post.php | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 0174c709c..c3bb105c2 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -67,6 +67,9 @@ public static function add( $activity_object, $activity_type, $user_id, $visibil // Set the activity type. \wp_set_object_terms( $id, array( strtolower( $activity_type ) ), 'ap_activity_type' ); + // Set the content visibility. + \update_post_meta( $id, 'activitypub_content_visibility', $content_visibility, true ); + return $id; } } diff --git a/includes/functions.php b/includes/functions.php index 50c231dae..6d96b5c4e 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1605,7 +1605,7 @@ function is_self_ping( $id ) { * * @return boolean|int The ID of the outbox item or false on failure. */ -function add_to_outbox( $data, $type = 'Create', $user_id = 0 ) { +function add_to_outbox( $data, $type = 'Create', $user_id = 0, $content_visibility = null ) { $transformer = Transformer_Factory::get_transformer( $data ); if ( ! $transformer || is_wp_error( $transformer ) ) { @@ -1620,7 +1620,7 @@ function add_to_outbox( $data, $type = 'Create', $user_id = 0 ) { set_wp_object_state( $data, 'federate' ); - $id = Outbox::add( $activity, $type, $user_id ); + $id = Outbox::add( $activity, $type, $user_id, $content_visibility ); if ( ! $id ) { return false; diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index ff3dbeeb9..6312ff379 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -93,10 +93,15 @@ public static function schedule_post_activity( $new_status, $old_status, $post ) $type = false; } + // Do not send Activities if `$type` is not set or unknown. if ( empty( $type ) ) { return; } - add_to_outbox( $post, $type, $post->post_author ); + // Get the content visibility. + $content_visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); + + // Add the post to the outbox. + add_to_outbox( $post, $type, $post->post_author, $content_visibility ); } } From 511b422f35bfed6c6b1ffe67559d681f1afb819d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jan 2025 16:38:37 +0100 Subject: [PATCH 55/98] fix PHPCS --- includes/collection/class-outbox.php | 14 ++++++++------ includes/functions.php | 9 +++++---- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index c3bb105c2..478475b43 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -16,14 +16,14 @@ class Outbox { /** * Add an Item to the outbox. * - * @param \Activitypub\Activity\Base_Object $activity_object The Activity-Object to add as JSON. - * @param string $activity_type The activity type. - * @param int $user_id The user ID. - * @param string $visibility Optional. The visibility of the content. Default 'public'. + * @param \Activitypub\Activity\Base_Object $activity_object The Activity-Object to add as JSON. + * @param string $activity_type The activity type. + * @param int $user_id The user ID. + * @param string $content_visibility Optional. The visibility of the content. Default 'public'. * * @return false|int|\WP_Error The added item or an error. */ - public static function add( $activity_object, $activity_type, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { // phpcs:ignore + public static function add( $activity_object, $activity_type, $user_id, $content_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { // phpcs:ignore switch ( $user_id ) { case -1: $actor = 'application'; @@ -68,7 +68,9 @@ public static function add( $activity_object, $activity_type, $user_id, $visibil \wp_set_object_terms( $id, array( strtolower( $activity_type ) ), 'ap_activity_type' ); // Set the content visibility. - \update_post_meta( $id, 'activitypub_content_visibility', $content_visibility, true ); + if ( $content_visibility ) { + \update_post_meta( $id, 'activitypub_content_visibility', $content_visibility, true ); + } return $id; } diff --git a/includes/functions.php b/includes/functions.php index 6d96b5c4e..ea86f057a 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1599,13 +1599,14 @@ function is_self_ping( $id ) { /** * Add an object to the outbox. * - * @param mixed $data The object to add to the outbox. - * @param string $type The type of the Activity. - * @param integer $user_id The User-ID. + * @param mixed $data The object to add to the outbox. + * @param string $type The type of the Activity. + * @param integer $user_id The User-ID. + * @param string $content_visibility The visibility of the content. * * @return boolean|int The ID of the outbox item or false on failure. */ -function add_to_outbox( $data, $type = 'Create', $user_id = 0, $content_visibility = null ) { +function add_to_outbox( $data, $type = 'Create', $user_id = 0, $content_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { $transformer = Transformer_Factory::get_transformer( $data ); if ( ! $transformer || is_wp_error( $transformer ) ) { From 3720f30e78caa3324521ced831ff182e01574fbc Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 16 Jan 2025 10:17:25 -0600 Subject: [PATCH 56/98] Outbox: Use post meta instead of taxonomies (#1173) * Outbox: Use post meta instead of taxonomies * Add tests for meta values when adding Outbox items * fix indents --------- Co-authored-by: Matthias Pfefferle --- includes/class-activitypub.php | 58 +++++++++++++------ includes/collection/class-outbox.php | 16 ++--- tests/includes/class-test-activitypub.php | 55 ++++++++++++++++++ .../includes/collection/class-test-outbox.php | 3 + 4 files changed, 104 insertions(+), 28 deletions(-) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index ec973e4e9..7f8577541 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -554,35 +554,59 @@ private static function register_post_types() { 'delete_with_user' => true, 'can_export' => true, 'supports' => array(), - 'taxonomies' => array( 'ap_actor', 'ap_activity_type' ), ) ); - \register_taxonomy( - 'ap_actor', + /** + * Register Activity Type meta for Outbox items. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types + */ + \register_post_meta( Outbox::POST_TYPE, + '_activitypub_activity_type', array( - 'labels' => array( - 'name' => _x( 'Actor', 'post_type plural name', 'activitypub' ), - ), - 'hierarchical' => true, - 'public' => false, + 'type' => 'string', + 'description' => 'The type of the activity', + 'single' => true, + 'sanitize_callback' => function ( $value ) { + $value = ucfirst( strtolower( $value ) ); + $schema = array( + 'type' => 'string', + 'enum' => array( 'Accept', 'Add', 'Announce', 'Arrive', 'Block', 'Create', 'Delete', 'Dislike', 'Flag', 'Follow', 'Ignore', 'Invite', 'Join', 'Leave', 'Like', 'Listen', 'Move', 'Offer', 'Question', 'Reject', 'Read', 'Remove', 'TentativeReject', 'TentativeAccept', 'Travel', 'Undo', 'Update', 'View' ), + 'default' => 'Announce', + ); + + if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) { + return $schema['default']; + } + + return $value; + }, ) ); - \register_taxonomy_for_object_type( 'ap_actor', Outbox::POST_TYPE ); - \register_taxonomy( - 'ap_activity_type', + \register_post_meta( Outbox::POST_TYPE, + '_activitypub_activity_actor', array( - 'labels' => array( - 'name' => _x( 'Activity Type', 'post_type plural name', 'activitypub' ), - ), - 'hierarchical' => true, - 'public' => false, + 'type' => 'integer', + 'single' => true, + 'sanitize_callback' => function ( $value ) { + $schema = array( + 'type' => 'string', + 'enum' => array( 'application', 'blog', 'user' ), + 'default' => 'user', + ); + + if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) { + return $schema['default']; + } + + return $value; + }, ) ); - \register_taxonomy_for_object_type( 'ap_activity_type', Outbox::POST_TYPE ); // Both User and Blog Extra Fields types have the same args. $args = array( diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 478475b43..f816af5f2 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -43,6 +43,11 @@ public static function add( $activity_object, $activity_type, $user_id, $content // ensure that user ID is not below 0. 'post_author' => \max( $user_id, 0 ), 'post_status' => 'draft', + 'meta_input' => array( + '_activitypub_activity_type' => $activity_type, + '_activitypub_activity_actor' => $actor, + 'activitypub_content_visibility' => $content_visibility, + ), ); $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' ); @@ -61,17 +66,6 @@ public static function add( $activity_object, $activity_type, $user_id, $content return false; } - // Set the actor type. - \wp_set_object_terms( $id, array( $actor ), 'ap_actor' ); - - // Set the activity type. - \wp_set_object_terms( $id, array( strtolower( $activity_type ) ), 'ap_activity_type' ); - - // Set the content visibility. - if ( $content_visibility ) { - \update_post_meta( $id, 'activitypub_content_visibility', $content_visibility, true ); - } - return $id; } } diff --git a/tests/includes/class-test-activitypub.php b/tests/includes/class-test-activitypub.php index bf25b155c..796063a53 100644 --- a/tests/includes/class-test-activitypub.php +++ b/tests/includes/class-test-activitypub.php @@ -7,6 +7,8 @@ namespace Activitypub\Tests; +use Activitypub\Collection\Outbox; + /** * Test class for Activitypub. * @@ -14,6 +16,14 @@ */ class Test_Activitypub extends \WP_UnitTestCase { + /** + * Set up test environment. + */ + public function setUp(): void { + parent::setUp(); + \Activitypub\Activitypub::init(); + } + /** * Test post type support. * @@ -55,5 +65,50 @@ function () { // Clean up. unset( $_SERVER['HTTP_ACCEPT'] ); + wp_delete_post( $post_id, true ); + } + + /** + * Test activity type meta sanitization. + * + * @dataProvider activity_meta_sanitization_provider + * @covers ::register_post_types + * + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param mixed $expected Expected value for invalid meta value. + */ + public function test_activity_meta_sanitization( $meta_key, $meta_value, $expected ) { + $post_id = self::factory()->post->create( + array( + 'post_type' => Outbox::POST_TYPE, + 'meta_input' => array( $meta_key => $meta_value ), + ) + ); + + $this->assertEquals( $meta_value, \get_post_meta( $post_id, $meta_key, true ) ); + + wp_update_post( + array( + 'ID' => $post_id, + 'meta_input' => array( $meta_key => 'InvalidType' ), + ) + ); + $this->assertEquals( $expected, \get_post_meta( $post_id, $meta_key, true ) ); + + wp_delete_post( $post_id, true ); + } + + /** + * Data provider for test_activity_meta_sanitization. + * + * @return array + */ + public function activity_meta_sanitization_provider() { + return array( + array( '_activitypub_activity_type', 'Create', 'Announce' ), + array( '_activitypub_activity_actor', 'user', 'user' ), + array( '_activitypub_activity_actor', 'blog', 'user' ), + ); } } diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index 5ed566435..20e82c509 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -35,6 +35,9 @@ public function test_add( $data, $type, $user_id, $json ) { $this->assertInstanceOf( 'WP_Post', $post ); $this->assertEquals( 'draft', $post->post_status ); $this->assertEquals( $json, $post->post_content ); + + $this->assertEquals( $type, get_post_meta( $id, '_activitypub_activity_type', true ) ); + $this->assertEquals( 'user', get_post_meta( $id, '_activitypub_activity_actor', true ) ); } /** From 7e146bf99e36d7eb60bb1dc2efd29321c7c52958 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jan 2025 23:34:16 +0100 Subject: [PATCH 57/98] Update includes/scheduler/class-actor.php Co-authored-by: Matt Wiebe --- includes/scheduler/class-actor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/scheduler/class-actor.php b/includes/scheduler/class-actor.php index 17830cf91..e210c2eea 100644 --- a/includes/scheduler/class-actor.php +++ b/includes/scheduler/class-actor.php @@ -85,7 +85,7 @@ public static function user_update( $user_id ) { * @return mixed */ public static function blog_user_update( $value = null ) { - self::schedule_profile_update( 0 ); + self::schedule_profile_update( Actors::BLOG_USER_ID ); return $value; } From 315493a0d55a433cc56958b8f7cb29fdf6198288 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Jan 2025 09:36:03 +0100 Subject: [PATCH 58/98] register content-visibility meta --- includes/class-activitypub.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 7f8577541..e214f40db 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -608,6 +608,29 @@ private static function register_post_types() { ) ); + \register_post_meta( + Outbox::POST_TYPE, + 'activitypub_content_visibility', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + 'sanitize_callback' => function ( $value ) { + $schema = array( + 'type' => 'string', + 'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ), + 'default' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ); + + if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) { + return $schema['default']; + } + + return $value; + }, + ) + ); + // Both User and Blog Extra Fields types have the same args. $args = array( 'labels' => array( From a6cf6d956d6c65d36b1c2e2f6c94dc4e9e573974 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 20 Jan 2025 10:38:19 -0600 Subject: [PATCH 59/98] Outbox: Update API Endpoint (#1170) * Update file name * Extend WP_REST_Controller * First pass at updated outbox endpoint * Remove expectation of having cc in the response * Update object type to be a bit more descriptive * First pass at create endpoint * Fix tests * Get Activity Type from meta after #1173 * Fix rest_url_path Props @pfefferle * Return accurate number of outbox items Props @pfefferle * Add more tests and remove unnecessary out-of-bounds error * Remove POST endpoint for now We currently don't have a use-case for it. * Limit Outbox by activity type and visibility * Account for posts without visibility meta Props @pfefferle * phpcs * Allow logged in users to see all activity types * Add request context to remaining hooks * Default query to limit activities Adds an exception for requests with privileges. * Slightly improve query --- activitypub.php | 4 +- includes/collection/class-outbox.php | 6 +- includes/rest/class-outbox-controller.php | 306 +++++++++++ includes/rest/class-outbox.php | 181 ------- includes/transformer/class-json.php | 11 + .../includes/collection/class-test-outbox.php | 4 +- .../rest/class-test-outbox-controller.php | 511 ++++++++++++++++++ 7 files changed, 837 insertions(+), 186 deletions(-) create mode 100644 includes/rest/class-outbox-controller.php delete mode 100644 includes/rest/class-outbox.php create mode 100644 tests/includes/rest/class-test-outbox-controller.php diff --git a/activitypub.php b/activitypub.php index 5960bff86..83e943a00 100644 --- a/activitypub.php +++ b/activitypub.php @@ -40,7 +40,6 @@ */ function rest_init() { Rest\Actors::init(); - Rest\Outbox::init(); Rest\Inbox::init(); Rest\Followers::init(); Rest\Following::init(); @@ -48,8 +47,9 @@ function rest_init() { Rest\Server::init(); Rest\Collection::init(); Rest\Post::init(); - ( new Rest\Interaction_Controller() )->register_routes(); ( new Rest\Application_Controller() )->register_routes(); + ( new Rest\Interaction_Controller() )->register_routes(); + ( new Rest\Outbox_Controller() )->register_routes(); ( new Rest\Webfinger_Controller() )->register_routes(); // Load NodeInfo endpoints only if blog is public. diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index f816af5f2..04392a92a 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -62,7 +62,11 @@ public static function add( $activity_object, $activity_type, $user_id, $content \kses_init_filters(); } - if ( ! $id || \is_wp_error( $id ) ) { + if ( \is_wp_error( $id ) ) { + return $id; + } + + if ( ! $id ) { return false; } diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php new file mode 100644 index 000000000..b9aaf6355 --- /dev/null +++ b/includes/rest/class-outbox-controller.php @@ -0,0 +1,306 @@ +[\w\-\.]+)/outbox'; + + /** + * Register routes. + */ + public function register_routes() { + \register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + 'args' => array( + 'user_id' => array( + 'description' => 'The ID of the user or actor.', + 'type' => 'string', + 'validate_callback' => array( $this, 'validate_user_id' ), + ), + ), + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'args' => array( + 'page' => array( + 'description' => 'Current page of the collection.', + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ), + 'per_page' => array( + 'description' => 'Maximum number of items to be returned in result set.', + 'type' => 'integer', + 'default' => 10, + 'minimum' => 1, + 'maximum' => 100, + ), + ), + ), + 'schema' => array( $this, 'get_collection_schema' ), + ) + ); + } + + /** + * Validates the user_id parameter. + * + * @param mixed $user_id The user_id parameter. + * @return bool|\WP_Error True if the user_id is valid, WP_Error otherwise. + */ + public function validate_user_id( $user_id ) { + $user = Actors::get_by_various( $user_id ); + if ( \is_wp_error( $user ) ) { + return $user; + } + + return true; + } + + /** + * Retrieves a collection of outbox items. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $user_id = $request->get_param( 'user_id' ); + $page = $request->get_param( 'page' ); + $user = Actors::get_by_various( $user_id ); + + /** + * Action triggered prior to the ActivityPub profile being created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_rest_outbox_pre', $request ); + + /** + * Filters the list of activity types to include in the outbox. + * + * @param string[] $activity_types The list of activity types. + */ + $activity_types = apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) ); + + $args = array( + 'posts_per_page' => $request->get_param( 'per_page' ), + 'author' => $user_id > 0 ? $user_id : null, + 'paged' => $page, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => $activity_types, + 'compare' => 'IN', + ), + array( + 'relation' => 'OR', + array( + 'key' => 'activitypub_content_visibility', + 'compare' => 'NOT EXISTS', + ), + array( + 'key' => 'activitypub_content_visibility', + 'value' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ), + ), + ); + + if ( current_user_can( 'activitypub' ) ) { + unset( $args['meta_query'] ); + } + + /** + * Filters WP_Query arguments when querying Outbox items via the REST API. + * + * Enables adding extra arguments or setting defaults for an outbox collection request. + * + * @param array $args Array of arguments for WP_Query. + * @param \WP_REST_Request $request The REST API request. + */ + $args = apply_filters( 'rest_activitypub_outbox_query', $args, $request ); + + $outbox_query = new \WP_Query(); + $query_result = $outbox_query->query( $args ); + + $response = array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ), + 'generator' => 'https://wordpress.org/?v=' . \get_bloginfo( 'version' ), + 'actor' => $user->get_id(), + 'type' => 'OrderedCollectionPage', + 'partOf' => get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ), + 'totalItems' => $outbox_query->found_posts, + 'orderedItems' => array(), + ); + + update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); + foreach ( $query_result as $outbox_item ) { + $response['orderedItems'][] = $this->prepare_item_for_response( $outbox_item, $request ); + } + + $max_pages = \ceil( $response['totalItems'] / $request->get_param( 'per_page' ) ); + $response['first'] = \add_query_arg( 'page', 1, $response['partOf'] ); + $response['last'] = \add_query_arg( 'page', $max_pages, $response['partOf'] ); + + if ( $max_pages > $page ) { + $response['next'] = \add_query_arg( 'page', $page + 1, $response['partOf'] ); + } + + if ( $page > 1 ) { + $response['prev'] = \add_query_arg( 'page', $page - 1, $response['partOf'] ); + } + + /** + * Filter the ActivityPub outbox array. + * + * @param array $response The ActivityPub outbox array. + * @param \WP_REST_Request $request The request object. + */ + $response = \apply_filters( 'activitypub_rest_outbox_array', $response, $request ); + + /** + * Action triggered after the ActivityPub profile has been created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_outbox_post', $request ); + + $response = \rest_ensure_response( $response ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Prepares the item for the REST response. + * + * @param mixed $item WordPress representation of the item. + * @param \WP_REST_Request $request Request object. + * @return array Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $type = \get_post_meta( $item->ID, '_activitypub_activity_type', true ); + $transformer = Factory::get_transformer( $item->post_content ); + $activity = $transformer->to_activity( $type ); + + return $activity->to_array( false ); + } + + /** + * Retrieves the outbox schema, conforming to JSON Schema. + * + * @return array Collection schema data. + */ + public function get_collection_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'outbox', + 'type' => 'object', + 'properties' => array( + '@context' => array( + 'description' => 'The JSON-LD context for the collection.', + 'type' => array( 'string', 'array', 'object' ), + 'required' => true, + ), + 'id' => array( + 'description' => 'The unique identifier for the collection.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'type' => array( + 'description' => 'The type of the collection.', + 'type' => 'string', + 'enum' => array( 'OrderedCollection', 'OrderedCollectionPage' ), + 'required' => true, + ), + 'actor' => array( + 'description' => 'The actor who owns this outbox.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'totalItems' => array( + 'description' => 'The total number of items in the collection.', + 'type' => 'integer', + 'minimum' => 0, + 'required' => true, + ), + 'orderedItems' => array( + 'description' => 'The items in the collection.', + 'type' => 'array', + 'items' => array( + 'type' => 'object', + ), + 'required' => true, + ), + 'first' => array( + 'description' => 'The first page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + 'last' => array( + 'description' => 'The last page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + 'next' => array( + 'description' => 'The next page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + 'prev' => array( + 'description' => 'The previous page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } +} diff --git a/includes/rest/class-outbox.php b/includes/rest/class-outbox.php deleted file mode 100644 index 22183bd29..000000000 --- a/includes/rest/class-outbox.php +++ /dev/null @@ -1,181 +0,0 @@ -[\w\-\.]+)/outbox', - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( self::class, 'user_outbox_get' ), - 'args' => self::request_parameters(), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), - ), - ) - ); - } - - /** - * Renders the user-outbox - * - * @param \WP_REST_Request $request The request object. - * @return WP_REST_Response|\WP_Error The response object or WP_Error. - */ - public static function user_outbox_get( $request ) { - $user_id = $request->get_param( 'user_id' ); - $user = Actors::get_by_various( $user_id ); - - if ( is_wp_error( $user ) ) { - return $user; - } - - $post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) ); - - $page = $request->get_param( 'page', 1 ); - - /** - * Action triggered prior to the ActivityPub profile being created and sent to the client. - */ - \do_action( 'activitypub_rest_outbox_pre' ); - - $json = new stdClass(); - - // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $json->{'@context'} = get_context(); - $json->id = get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ); - $json->generator = 'http://wordpress.org/?v=' . get_masked_wp_version(); - $json->actor = $user->get_id(); - $json->type = 'OrderedCollectionPage'; - $json->partOf = get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ); - $json->totalItems = 0; - - if ( $user_id > 0 ) { - $count_posts = \count_user_posts( $user_id, $post_types, true ); - $json->totalItems = \intval( $count_posts ); - } else { - foreach ( $post_types as $post_type ) { - $count_posts = \wp_count_posts( $post_type ); - $json->totalItems += \intval( $count_posts->publish ); - } - } - - $json->first = \add_query_arg( 'page', 1, $json->partOf ); - $json->last = \add_query_arg( 'page', \ceil( $json->totalItems / 10 ), $json->partOf ); - - if ( $page && ( ( \ceil( $json->totalItems / 10 ) ) > $page ) ) { - $json->next = \add_query_arg( 'page', $page + 1, $json->partOf ); - } - - if ( $page && ( $page > 1 ) ) { - $json->prev = \add_query_arg( 'page', $page - 1, $json->partOf ); - } - // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - - if ( $page ) { - $posts = \get_posts( - array( - 'posts_per_page' => 10, - 'author' => $user_id > 0 ? $user_id : null, - 'paged' => $page, - 'post_type' => $post_types, - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - 'relation' => 'OR', - array( - 'key' => 'activitypub_content_visibility', - 'compare' => 'NOT EXISTS', - ), - array( - 'key' => 'activitypub_content_visibility', - 'value' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, - 'compare' => '!=', - ), - ), - ) - ); - - foreach ( $posts as $post ) { - $transformer = Factory::get_transformer( $post ); - - if ( \is_wp_error( $transformer ) ) { - continue; - } - - $post = $transformer->to_object(); - $activity = new Activity(); - $activity->set_type( 'Create' ); - $activity->set_object( $post ); - $json->orderedItems[] = $activity->to_array( false ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - } - } - - /** - * Filter the ActivityPub outbox array. - * - * @param array $json The ActivityPub outbox array. - */ - $json = \apply_filters( 'activitypub_rest_outbox_array', $json ); - - /** - * Action triggered after the ActivityPub profile has been created and sent to the client - */ - \do_action( 'activitypub_outbox_post' ); - - $rest_response = new WP_REST_Response( $json, 200 ); - $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); - - return $rest_response; - } - - /** - * The supported parameters. - * - * @return array List of parameters. - */ - public static function request_parameters() { - $params = array(); - - $params['page'] = array( - 'type' => 'integer', - 'default' => 1, - ); - - return $params; - } -} diff --git a/includes/transformer/class-json.php b/includes/transformer/class-json.php index 6b7c0288e..92974d5af 100644 --- a/includes/transformer/class-json.php +++ b/includes/transformer/class-json.php @@ -30,4 +30,15 @@ public function __construct( $item ) { parent::__construct( $object ); } + + /** + * Returns the public secondary audience of this object + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-cc + * + * @return array The secondary audience of this object. + */ + protected function get_cc() { + return $this->item->get( 'cc' ); + } } diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index 20e82c509..653300589 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -56,7 +56,7 @@ public function activity_object_provider() { ), 'Create', 1, - '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/1","type":"Note","content":"This is a note","contentMap":{"en":"This is a note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"mediaType":"text/html","sensitive":false}', + '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/1","type":"Note","content":"This is a note","contentMap":{"en":"This is a note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"mediaType":"text/html","sensitive":false}', ), array( array( @@ -67,7 +67,7 @@ public function activity_object_provider() { ), 'Create', 2, - '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/2","type":"Note","content":"This is another note","contentMap":{"en":"This is another note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"mediaType":"text/html","sensitive":false}', + '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/2","type":"Note","content":"This is another note","contentMap":{"en":"This is another note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"mediaType":"text/html","sensitive":false}', ), ); } diff --git a/tests/includes/rest/class-test-outbox-controller.php b/tests/includes/rest/class-test-outbox-controller.php new file mode 100644 index 000000000..4b2f29b54 --- /dev/null +++ b/tests/includes/rest/class-test-outbox-controller.php @@ -0,0 +1,511 @@ +post->create_many( 10 ); + } + + /** + * Clean up test fixtures. + */ + public static function wpTearDownAfterClass() { + foreach ( self::$post_ids as $post_id ) { + \wp_delete_post( $post_id, true ); + } + + \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); + } + + /** + * Set up test environment. + */ + public function set_up() { + parent::set_up(); + \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + } + + /** + * Test route registration. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/(?:users|actors)/(?P[\w\-\.]+)/outbox', $routes ); + } + + /** + * Test user ID validation. + * + * @covers ::validate_user_id + */ + public function test_validate_user_id() { + $controller = new Outbox_Controller(); + $this->assertTrue( $controller->validate_user_id( 0 ) ); + $this->assertTrue( $controller->validate_user_id( '1' ) ); + $this->assertWPError( $controller->validate_user_id( 'user-1' ) ); + } + + /** + * Test getting items. + * + * @covers ::get_items + */ + public function test_get_items() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Test schema. + * + * @covers ::get_collection_schema + */ + public function test_get_collection_schema() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $schema = ( new Outbox_Controller() )->get_collection_schema(); + + $valid = \rest_validate_value_from_schema( $data, $schema ); + $this->assertNotWPError( $valid, 'Response failed schema validation: ' . ( \is_wp_error( $valid ) ? $valid->get_error_message() : '' ) ); + } + + /** + * Test getting items with pagination. + * + * @covers ::get_items + */ + public function test_get_items_pagination() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'page', 2 ); + $request->set_param( 'per_page', 3 ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'prev', $data ); + $this->assertArrayHasKey( 'next', $data ); + $this->assertStringContainsString( 'page=1', $data['prev'] ); + $this->assertStringContainsString( 'page=3', $data['next'] ); + } + + /** + * Test getting items response structure. + * + * @covers ::get_items + */ + public function test_get_items_response_structure() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( '@context', $data ); + $this->assertArrayHasKey( 'id', $data ); + $this->assertArrayHasKey( 'type', $data ); + $this->assertArrayHasKey( 'totalItems', $data ); + $this->assertArrayHasKey( 'orderedItems', $data ); + $this->assertEquals( 'OrderedCollectionPage', $data['type'] ); + $this->assertIsArray( $data['orderedItems'] ); + + $headers = $response->get_headers(); + $this->assertEquals( 'application/activity+json; charset=' . \get_option( 'blog_charset' ), $headers['Content-Type'] ); + } + + /** + * Test getting items for specific user. + * + * @covers ::get_items + */ + public function test_get_items_specific_user() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => 'ap_outbox', + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/1', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/1', + 'type' => 'Create', + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 1, (int) $data['totalItems'] ); + $this->assertStringContainsString( (string) $user_id, $data['actor'] ); + + \wp_delete_post( $post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Test outbox filters. + * + * @covers ::get_items + */ + public function test_get_items_filters() { + $filter_called = false; + $pre_called = false; + $post_called = false; + + \add_filter( + 'activitypub_rest_outbox_array', + function ( $response ) use ( &$filter_called ) { + $filter_called = true; + return $response; + } + ); + + \add_action( + 'activitypub_rest_outbox_pre', + function () use ( &$pre_called ) { + $pre_called = true; + } + ); + + \add_action( + 'activitypub_outbox_post', + function () use ( &$post_called ) { + $post_called = true; + } + ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + \rest_get_server()->dispatch( $request ); + + $this->assertTrue( $filter_called, 'activitypub_rest_outbox_array filter was not called.' ); + $this->assertTrue( $pre_called, 'activitypub_rest_outbox_pre action was not called.' ); + $this->assertTrue( $post_called, 'activitypub_outbox_post action was not called.' ); + + \remove_all_filters( 'activitypub_rest_outbox_array' ); + \remove_all_actions( 'activitypub_rest_outbox_pre' ); + \remove_all_actions( 'activitypub_outbox_post' ); + } + + /** + * Test getting items with minimum per_page. + * + * @covers ::get_items + */ + public function test_get_items_minimum_per_page() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'per_page', 1 ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $data['orderedItems'] ); + } + + /** + * Test getting items with maximum per_page. + * + * @covers ::get_items + */ + public function test_get_items_maximum_per_page() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'per_page', 100 ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Data provider for test_get_items_activity_type. + * + * @return array[] Test parameters. + */ + public function data_activity_types() { + return array( + 'create_activity' => array( + 'type' => 'Create', + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + 'allowed' => true, + ), + 'announce_activity' => array( + 'type' => 'Announce', + 'object' => 'https://example.org/note/2', + 'allowed' => true, + ), + 'like_activity' => array( + 'type' => 'Like', + 'object' => 'https://example.org/note/3', + 'allowed' => true, + ), + 'update_activity' => array( + 'type' => 'Update', + 'object' => array( + 'id' => 'https://example.org/note/4', + 'type' => 'Note', + 'content' => 'Updated content', + ), + 'allowed' => true, + ), + 'delete_activity' => array( + 'type' => 'Delete', + 'object' => 'https://example.org/note/5', + 'allowed' => false, + ), + 'follow_activity' => array( + 'type' => 'Follow', + 'object' => 'https://example.org/user/6', + 'allowed' => false, + ), + ); + } + + /** + * Test getting items with different activity types. + * + * @covers ::get_items + * @dataProvider data_activity_types + * + * @param string $type Activity type. + * @param string|array $activity Activity object. + * @param bool $allowed Whether the activity type is allowed for public users. + */ + public function test_get_items_activity_type( $type, $activity, $allowed ) { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => "https://example.org/activity/{$type}", + 'post_content' => \wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => "https://example.org/activity/{$type}", + 'type' => $type, + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => $activity, + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => $type, + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + // Test as logged-out user. + \wp_set_current_user( 0 ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $activity_types = \wp_list_pluck( $data['orderedItems'], 'type' ); + + if ( $allowed ) { + $this->assertContains( $type, $activity_types, sprintf( 'Activity type "%s" should be visible to logged-out users.', $type ) ); + $this->assertSame( 1, (int) $data['totalItems'], sprintf( 'Activity type "%s" should be included in total items for logged-out users.', $type ) ); + } else { + $this->assertNotContains( $type, $activity_types, sprintf( 'Activity type "%s" should not be visible to logged-out users.', $type ) ); + $this->assertSame( 0, (int) $data['totalItems'], sprintf( 'Activity type "%s" should not be included in total items for logged-out users.', $type ) ); + } + + // Test as logged-in user with activitypub capability. + \wp_set_current_user( $user_id ); + $this->assertTrue( \current_user_can( 'activitypub' ) ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $activity_types = \wp_list_pluck( $data['orderedItems'], 'type' ); + + $this->assertContains( $type, $activity_types, sprintf( 'Activity type "%s" should be visible to users with activitypub capability.', $type ) ); + $this->assertSame( 1, (int) $data['totalItems'], sprintf( 'Activity type "%s" should be included in total items for users with activitypub capability.', $type ) ); + + \wp_delete_post( $post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Data provider for test_get_items_content_visibility. + * + * @return array[] Test parameters. + */ + public function data_content_visibility() { + return array( + 'no_visibility' => array( + 'visibility' => null, + 'public_visible' => true, + 'private_visible' => true, + ), + 'public' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + 'public_visible' => true, + 'private_visible' => true, + ), + 'quiet_public' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, + 'public_visible' => false, + 'private_visible' => true, + ), + 'local' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + 'public_visible' => false, + 'private_visible' => true, + ), + ); + } + + /** + * Test content visibility for logged-in and logged-out users. + * + * @covers ::get_items + * @dataProvider data_content_visibility + * + * @param string|null $visibility Content visibility setting. + * @param bool $public_visible Whether content should be visible to public users. + * @param bool $private_visible Whether content should be visible to users with activitypub capability. + */ + public function test_get_items_content_visibility( $visibility, $public_visible, $private_visible ) { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $meta_input = array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'user', + ); + + if ( null !== $visibility ) { + $meta_input['activitypub_content_visibility'] = $visibility; + } + + $post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/1', + 'post_content' => \wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/1', + 'type' => 'Create', + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ), + 'meta_input' => $meta_input, + ) + ); + + // Test as logged-out user. + \wp_set_current_user( 0 ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( + (int) $public_visible, + (int) $data['totalItems'], + sprintf( + 'Content with visibility "%s" should%s be visible to logged-out users.', + $visibility ?? 'none', + $public_visible ? '' : ' not' + ) + ); + + // Test as logged-in user with activitypub capability. + \wp_set_current_user( $user_id ); + $this->assertTrue( \current_user_can( 'activitypub' ) ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( + (int) $private_visible, + (int) $data['totalItems'], + sprintf( + 'Content with visibility "%s" should%s be visible to users with activitypub capability.', + $visibility ?? 'none', + $private_visible ? '' : ' not' + ) + ); + + \wp_delete_post( $post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Test get_item method. + * + * @doesNotPerformAssertions + */ + public function test_get_item() { + // Controller does not implement get_item(). + } + + /** + * Test get_item_schema method. + * + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // Controller does not implement get_item_schema(). + } +} From aa884055d6351191aa6c651f38990f193156bf30 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Jan 2025 17:40:39 +0100 Subject: [PATCH 60/98] fix unittests --- includes/class-comment.php | 2 +- tests/includes/class-test-comment.php | 12 ++++++++++ .../transformer/class-test-factory.php | 24 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/includes/class-comment.php b/includes/class-comment.php index 3c42d02be..b0ad16bc2 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -229,7 +229,7 @@ public static function should_be_federated( $comment ) { return false; } - if ( is_single_user() && \user_can( $user_id, 'publish_posts' ) ) { + if ( is_single_user() && \user_can( $user_id, 'activitypub' ) ) { // On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user. $user_id = Actors::BLOG_USER_ID; } diff --git a/tests/includes/class-test-comment.php b/tests/includes/class-test-comment.php index 1ef7b33c5..145034963 100644 --- a/tests/includes/class-test-comment.php +++ b/tests/includes/class-test-comment.php @@ -293,6 +293,9 @@ public function ability_to_federate_comment() { 'comment_content' => 'This is a sent comment.', 'comment_author_url' => 'https://example.com', 'comment_author_email' => '', + 'comment_meta' => array( + 'activitypub_status' => 'pending', + ), ), 'expected' => array( 'was_sent' => true, @@ -362,6 +365,9 @@ public function ability_to_federate_threaded_comment() { 'comment_content' => 'This is another comment.', 'comment_author_url' => 'https://example.com', 'comment_author_email' => '', + 'comment_meta' => array( + 'activitypub_status' => 'pending', + ), ), 'expected' => array( 'was_sent' => false, @@ -386,6 +392,9 @@ public function ability_to_federate_threaded_comment() { 'comment_content' => 'This is yet another comment.', 'comment_author_url' => 'https://example.com', 'comment_author_email' => '', + 'comment_meta' => array( + 'activitypub_status' => 'pending', + ), ), 'expected' => array( 'was_sent' => true, @@ -440,6 +449,9 @@ public function ability_to_federate_threaded_comment() { 'comment_content' => 'This is a parent comment that should not be possible.', 'comment_author_url' => 'https://example.com', 'comment_author_email' => '', + 'comment_meta' => array( + 'activitypub_status' => 'federated', + ), ), 'comment' => array( 'comment_type' => 'comment', diff --git a/tests/includes/transformer/class-test-factory.php b/tests/includes/transformer/class-test-factory.php index 9d8e38b54..59fff9c39 100644 --- a/tests/includes/transformer/class-test-factory.php +++ b/tests/includes/transformer/class-test-factory.php @@ -42,6 +42,13 @@ class Test_Factory extends WP_UnitTestCase { */ protected static $comment_id; + /** + * Test user ID. + * + * @var int + */ + protected static $user_id; + /** * Create fake data before tests run. * @@ -58,10 +65,20 @@ public static function wpSetUpBeforeClass( $factory ) { ) ); + self::$user_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + // Create test comment. self::$comment_id = $factory->comment->create( array( 'comment_post_ID' => self::$post_id, + 'user_id' => self::$user_id, + 'comment_meta' => array( + 'activitypub_status' => 'pending', + ), ) ); } @@ -73,6 +90,7 @@ public static function wpTearDownAfterClass() { wp_delete_post( self::$post_id, true ); wp_delete_post( self::$attachment_id, true ); wp_delete_comment( self::$comment_id, true ); + wp_delete_user( self::$user_id, true ); } /** @@ -104,10 +122,16 @@ public function test_get_transformer_post() { * @covers ::get_transformer */ public function test_get_transformer_attachment() { + // Allow attachment to be federated. + \add_post_type_support( 'attachment', 'activitypub' ); + $attachment = get_post( self::$attachment_id ); $transformer = Factory::get_transformer( $attachment ); $this->assertInstanceOf( Attachment::class, $transformer ); + + // Remove support for attachment. + \remove_post_type_support( 'attachment', 'activitypub' ); } /** From 8a7b5ccbf3f55bc5ce85b04e7366e214a09d32e0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Jan 2025 23:17:39 +0100 Subject: [PATCH 61/98] pending seems to be the better status --- includes/collection/class-outbox.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 04392a92a..2ba028e84 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -42,7 +42,7 @@ public static function add( $activity_object, $activity_type, $user_id, $content 'post_content' => $activity_object->to_json(), // ensure that user ID is not below 0. 'post_author' => \max( $user_id, 0 ), - 'post_status' => 'draft', + 'post_status' => 'pending', 'meta_input' => array( '_activitypub_activity_type' => $activity_type, '_activitypub_activity_actor' => $actor, From 2d590473cc1095e6ceee7813676010b83692d2fb Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Jan 2025 00:16:17 +0100 Subject: [PATCH 62/98] Revert "pending seems to be the better status" This reverts commit 8a7b5ccbf3f55bc5ce85b04e7366e214a09d32e0. --- includes/collection/class-outbox.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 2ba028e84..04392a92a 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -42,7 +42,7 @@ public static function add( $activity_object, $activity_type, $user_id, $content 'post_content' => $activity_object->to_json(), // ensure that user ID is not below 0. 'post_author' => \max( $user_id, 0 ), - 'post_status' => 'pending', + 'post_status' => 'draft', 'meta_input' => array( '_activitypub_activity_type' => $activity_type, '_activitypub_activity_actor' => $actor, From b0810d00f94b5f5dcf22298171e6126ef33b57d3 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 22 Jan 2025 09:57:10 -0600 Subject: [PATCH 63/98] Outbox: Use callbacks that can be unhooked. (#1188) * Outbox: Use callbacks that can be unhooked. * Add unit tests * Use data provider in no-activity tests * Make sure user has activitypub cap * Use should_be_federated() instead See #1186. * Update with learnings from #1196 --- includes/scheduler/class-comment.php | 25 +-- includes/scheduler/class-post.php | 44 +++-- includes/transformer/class-factory.php | 5 +- .../includes/scheduler/class-test-comment.php | 178 ++++++++++++++++++ 4 files changed, 219 insertions(+), 33 deletions(-) create mode 100644 tests/includes/scheduler/class-test-comment.php diff --git a/includes/scheduler/class-comment.php b/includes/scheduler/class-comment.php index a6d6bbd1c..10e6015cd 100644 --- a/includes/scheduler/class-comment.php +++ b/includes/scheduler/class-comment.php @@ -25,18 +25,7 @@ public static function init() { // Comment transitions. \add_action( 'transition_comment_status', array( self::class, 'schedule_comment_activity' ), 20, 3 ); - \add_action( - 'edit_comment', - function ( $comment_id ) { - self::schedule_comment_activity( 'approved', 'approved', $comment_id ); - } - ); - \add_action( - 'wp_insert_comment', - function ( $comment_id ) { - self::schedule_comment_activity( 'approved', '', $comment_id ); - } - ); + \add_action( 'wp_insert_comment', array( self::class, 'schedule_comment_activity_on_insert' ), 10, 2 ); } /** @@ -84,4 +73,16 @@ public static function schedule_comment_activity( $new_status, $old_status, $com add_to_outbox( $comment, $type, $comment->user_id ); } + + /** + * Schedule Comment Activities on insert. + * + * @param int $comment_id Comment ID. + * @param \WP_Comment $comment Comment object. + */ + public static function schedule_comment_activity_on_insert( $comment_id, $comment ) { + if ( 1 === (int) $comment->comment_approved ) { + self::schedule_comment_activity( 'approved', '', $comment ); + } + } } diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 6312ff379..14e1ac325 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -21,32 +21,38 @@ class Post { public static function init() { // Post transitions. \add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 ); - \add_action( - 'edit_attachment', - function ( $post_id ) { - self::schedule_post_activity( 'publish', 'publish', $post_id ); - } - ); - \add_action( - 'add_attachment', - function ( $post_id ) { + + // Attachment transitions. + \add_action( 'add_attachment', array( self::class, 'transition_attachment_status' ) ); + \add_action( 'edit_attachment', array( self::class, 'transition_attachment_status' ) ); + \add_action( 'delete_attachment', array( self::class, 'transition_attachment_status' ) ); + } + + /** + * Schedules Activities for attachment transitions. + * + * @param int $post_id Attachment ID. + */ + public static function transition_attachment_status( $post_id ) { + switch ( current_action() ) { + case 'add_attachment': self::schedule_post_activity( 'publish', '', $post_id ); - } - ); - \add_action( - 'delete_attachment', - function ( $post_id ) { + break; + case 'edit_attachment': + self::schedule_post_activity( 'publish', 'publish', $post_id ); + break; + case 'delete_attachment': self::schedule_post_activity( 'trash', '', $post_id ); - } - ); + break; + } } /** * Schedule Activities. * - * @param string $new_status New post status. - * @param string $old_status Old post status. - * @param \WP_Post $post Post object. + * @param string $new_status New post status. + * @param string $old_status Old post status. + * @param int|\WP_Post $post Post ID or post object. */ public static function schedule_post_activity( $new_status, $old_status, $post ) { $post = get_post( $post ); diff --git a/includes/transformer/class-factory.php b/includes/transformer/class-factory.php index 95aa4c625..a4662eca4 100644 --- a/includes/transformer/class-factory.php +++ b/includes/transformer/class-factory.php @@ -8,10 +8,11 @@ namespace Activitypub\Transformer; use WP_Error; +use Activitypub\Comment as Comment_Helper; use function Activitypub\is_user_disabled; use function Activitypub\is_post_disabled; -use function Activitypub\is_local_comment; + /** * Transformer Factory. */ @@ -84,7 +85,7 @@ public static function get_transformer( $data ) { } break; case 'WP_Comment': - if ( ! is_local_comment( $data ) ) { + if ( Comment_Helper::should_be_federated( $data ) ) { return new Comment( $data ); } break; diff --git a/tests/includes/scheduler/class-test-comment.php b/tests/includes/scheduler/class-test-comment.php new file mode 100644 index 000000000..62584ea87 --- /dev/null +++ b/tests/includes/scheduler/class-test-comment.php @@ -0,0 +1,178 @@ +user->create( array( 'role' => 'author' ) ); + self::$post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); + + // Add activitypub capability to the user. + get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + + add_filter( 'pre_schedule_event', '__return_false' ); + } + + /** + * Clean up test resources. + */ + public static function tear_down_after_class() { + wp_delete_post( self::$post_id, true ); + wp_delete_user( self::$user_id ); + + remove_filter( 'pre_schedule_event', '__return_false' ); + + $outbox_items = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + 'post_status' => 'any', + 'fields' => 'ids', + ) + ); + + foreach ( $outbox_items as $outbox_item ) { + \wp_delete_post( $outbox_item, true ); + } + } + + /** + * Test scheduling comment activity on approval. + */ + public function test_schedule_comment_activity_on_approval() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'user_id' => self::$user_id, + 'comment_approved' => 0, + ) + ); + $activitpub_id = \Activitypub\Comment::generate_id( $comment_id ); + + wp_set_comment_status( $comment_id, 'approve' ); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + + wp_delete_comment( $comment_id, true ); + } + + /** + * Test scheduling comment activity on direct insert with approval. + */ + public function test_schedule_comment_activity_on_insert() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'user_id' => self::$user_id, + 'comment_approved' => 1, + ) + ); + $activitpub_id = \Activitypub\Comment::generate_id( $comment_id ); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + + wp_delete_comment( $comment_id, true ); + } + + /** + * Data provider for no activity tests. + * + * @return array[] Test parameters. + */ + public function no_activity_comment_provider() { + return array( + 'unapproved_comment' => array( + array( + 'comment_post_ID' => self::$post_id, + 'user_id' => self::$user_id, + 'comment_approved' => 0, + ), + ), + 'non_registered_user' => array( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => 1, + ), + ), + 'federation_disabled' => array( + array( + 'comment_post_ID' => self::$post_id, + 'user_id' => self::$user_id, + 'comment_approved' => 1, + 'comment_meta' => array( + 'protocol' => 'activitypub', + ), + ), + ), + ); + } + + /** + * Test comment activity scheduling under various conditions. + * + * @dataProvider no_activity_comment_provider + * + * @param array $comment_data Comment data for creating the test comment. + */ + public function test_no_activity_scheduled( $comment_data ) { + $comment_id = self::factory()->comment->create( $comment_data ); + $activitpub_id = \Activitypub\Comment::generate_id( $comment_id ); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertNotEquals( $activitpub_id, $post->post_title ); + + wp_delete_comment( $comment_id, true ); + } + + /** + * Retrieve the latest Outbox item to compare against. + * + * @param string $title Title of the Outbox item. + * @return int|\WP_Post|null + */ + private function get_latest_outbox_item( $title = '' ) { + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'draft', + 'post_title' => $title, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + return $outbox ? $outbox[0] : null; + } +} From d283db6cffa566de72b9ab16ca4914012d0d16e5 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 22 Jan 2025 10:01:26 -0600 Subject: [PATCH 64/98] Outbox: Make sure Last page is linked correctly (#1195) Props @pfefferle --- includes/rest/class-outbox-controller.php | 2 +- tests/includes/rest/class-test-outbox-controller.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index b9aaf6355..9adbddbea 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -178,7 +178,7 @@ public function get_items( $request ) { $max_pages = \ceil( $response['totalItems'] / $request->get_param( 'per_page' ) ); $response['first'] = \add_query_arg( 'page', 1, $response['partOf'] ); - $response['last'] = \add_query_arg( 'page', $max_pages, $response['partOf'] ); + $response['last'] = \add_query_arg( 'page', \max( $max_pages, 1 ), $response['partOf'] ); if ( $max_pages > $page ) { $response['next'] = \add_query_arg( 'page', $page + 1, $response['partOf'] ); diff --git a/tests/includes/rest/class-test-outbox-controller.php b/tests/includes/rest/class-test-outbox-controller.php index 4b2f29b54..047ed6237 100644 --- a/tests/includes/rest/class-test-outbox-controller.php +++ b/tests/includes/rest/class-test-outbox-controller.php @@ -117,6 +117,16 @@ public function test_get_items_pagination() { $this->assertArrayHasKey( 'next', $data ); $this->assertStringContainsString( 'page=1', $data['prev'] ); $this->assertStringContainsString( 'page=3', $data['next'] ); + + // Empty collection. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/1/outbox' ); + $request->set_param( 'per_page', 3 ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertStringContainsString( 'page=1', $data['last'] ); + $this->assertArrayNotHasKey( 'prev', $data ); + $this->assertArrayNotHasKey( 'next', $data ); } /** From 0f1257cc9ed673d5a6a4052050d684cddf0cb98a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 22 Jan 2025 19:33:04 +0100 Subject: [PATCH 65/98] Update includes/collection/class-outbox.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: André Menrath <99024746+Menrath@users.noreply.github.com> --- includes/collection/class-outbox.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 04392a92a..bfb029c5f 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -9,6 +9,8 @@ /** * ActivityPub Outbox Collection + * + * @link https://www.w3.org/TR/activitypub/#outbox */ class Outbox { const POST_TYPE = 'ap_outbox'; From c28d03e0d5a3ed73d99658b25a63294100356fdf Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 22 Jan 2025 19:33:22 +0100 Subject: [PATCH 66/98] Update includes/collection/class-outbox.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: André Menrath <99024746+Menrath@users.noreply.github.com> --- includes/collection/class-outbox.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index bfb029c5f..a0fb75d49 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -20,7 +20,7 @@ class Outbox { * * @param \Activitypub\Activity\Base_Object $activity_object The Activity-Object to add as JSON. * @param string $activity_type The activity type. - * @param int $user_id The user ID. + * @param int $user_id The real or imaginary user ID of the actor that published the activity that will be added to the outbox. * @param string $content_visibility Optional. The visibility of the content. Default 'public'. * * @return false|int|\WP_Error The added item or an error. From 4fd5642cc1bd8e6e1b4f22e75292055e0c4e5af4 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 22 Jan 2025 19:35:47 +0100 Subject: [PATCH 67/98] Update includes/collection/class-outbox.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: André Menrath <99024746+Menrath@users.noreply.github.com> --- includes/collection/class-outbox.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index a0fb75d49..d7cd69683 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -18,7 +18,7 @@ class Outbox { /** * Add an Item to the outbox. * - * @param \Activitypub\Activity\Base_Object $activity_object The Activity-Object to add as JSON. + * @param \Activitypub\Activity\Base_Object $activity_object The object of the activity that will be added to the outbox. * @param string $activity_type The activity type. * @param int $user_id The real or imaginary user ID of the actor that published the activity that will be added to the outbox. * @param string $content_visibility Optional. The visibility of the content. Default 'public'. From 6bd1b8019f649af38bd430ef9e894bcf57345d34 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 22 Jan 2025 19:37:24 +0100 Subject: [PATCH 68/98] fix type props @menrath --- includes/class-activitypub.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index cee9a9e8f..f0cb83166 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -574,7 +574,7 @@ private static function register_post_types() { Outbox::POST_TYPE, '_activitypub_activity_actor', array( - 'type' => 'integer', + 'type' => 'string', 'single' => true, 'sanitize_callback' => function ( $value ) { $schema = array( From e0e9e13ab3791c7bcfee9b72082d24e2763d1f12 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 23 Jan 2025 11:43:12 +0100 Subject: [PATCH 69/98] Outbox: Fix Query (#1200) * Outbox: Fix Query If `author` is null, it will return every activity for the blog and application Actor, but we only want to have the Actors activities. * Update includes/rest/class-outbox-controller.php Co-authored-by: Konstantin Obenland * fix user check! * fix phpcs * use constants * Add tests for new functionality * Update tests/includes/rest/class-test-outbox-controller.php --------- Co-authored-by: Konstantin Obenland --- includes/collection/class-outbox.php | 12 +- includes/rest/class-outbox-controller.php | 50 +++-- .../rest/class-test-outbox-controller.php | 212 +++++++++++++++++- 3 files changed, 239 insertions(+), 35 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index d7cd69683..2fa6a47f4 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -27,14 +27,14 @@ class Outbox { */ public static function add( $activity_object, $activity_type, $user_id, $content_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { // phpcs:ignore switch ( $user_id ) { - case -1: - $actor = 'application'; + case Actors::APPLICATION_USER_ID: + $actor_type = 'application'; break; - case 0: - $actor = 'blog'; + case Actors::BLOG_USER_ID: + $actor_type = 'blog'; break; default: - $actor = 'user'; + $actor_type = 'user'; break; } @@ -47,7 +47,7 @@ public static function add( $activity_object, $activity_type, $user_id, $content 'post_status' => 'draft', 'meta_input' => array( '_activitypub_activity_type' => $activity_type, - '_activitypub_activity_actor' => $actor, + '_activitypub_activity_actor' => $actor_type, 'activitypub_content_visibility' => $content_visibility, ), ); diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 9adbddbea..59f2cbd16 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -115,36 +115,52 @@ public function get_items( $request ) { */ $activity_types = apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) ); + switch ( $user_id ) { + case Actors::APPLICATION_USER_ID: + $actor_type = 'application'; + break; + case Actors::BLOG_USER_ID: + $actor_type = 'blog'; + break; + default: + $actor_type = 'user'; + break; + } + $args = array( 'posts_per_page' => $request->get_param( 'per_page' ), 'author' => $user_id > 0 ? $user_id : null, 'paged' => $page, 'post_type' => Outbox::POST_TYPE, - 'post_status' => 'draft', + 'post_status' => 'any', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( - 'key' => '_activitypub_activity_type', - 'value' => $activity_types, - 'compare' => 'IN', - ), - array( - 'relation' => 'OR', - array( - 'key' => 'activitypub_content_visibility', - 'compare' => 'NOT EXISTS', - ), - array( - 'key' => 'activitypub_content_visibility', - 'value' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, - ), + 'key' => '_activitypub_activity_actor', + 'value' => $actor_type, ), ), ); - if ( current_user_can( 'activitypub' ) ) { - unset( $args['meta_query'] ); + if ( get_current_user_id() !== $user_id && ! current_user_can( 'activitypub' ) ) { + $args['meta_query'][] = array( + 'key' => '_activitypub_activity_type', + 'value' => $activity_types, + 'compare' => 'IN', + ); + + $args['meta_query'][] = array( + 'relation' => 'OR', + array( + 'key' => 'activitypub_content_visibility', + 'compare' => 'NOT EXISTS', + ), + array( + 'key' => 'activitypub_content_visibility', + 'value' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ); } /** diff --git a/tests/includes/rest/class-test-outbox-controller.php b/tests/includes/rest/class-test-outbox-controller.php index 047ed6237..300bdbb58 100644 --- a/tests/includes/rest/class-test-outbox-controller.php +++ b/tests/includes/rest/class-test-outbox-controller.php @@ -16,6 +16,14 @@ * @coversDefaultClass \Activitypub\Rest\Outbox_Controller */ class Test_Outbox_Controller extends \Activitypub\Tests\Test_REST_Controller_Testcase { + + /** + * Test user ID. + * + * @var int + */ + public static $user_id; + /** * Test post IDs. * @@ -25,17 +33,20 @@ class Test_Outbox_Controller extends \Activitypub\Tests\Test_REST_Controller_Tes /** * Set up class test fixtures. - * - * @param \WP_UnitTest_Factory $factory WordPress unit test factory. */ - public static function wpSetUpBeforeClass( $factory ) { - self::$post_ids = $factory->post->create_many( 10 ); + public static function set_up_before_class() { + self::$user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + \get_user_by( 'ID', self::$user_id )->add_cap( 'activitypub' ); + + self::$post_ids = self::factory()->post->create_many( 10, array( 'post_author' => self::$user_id ) ); } /** * Clean up test fixtures. */ public static function wpTearDownAfterClass() { + \wp_delete_user( self::$user_id ); + foreach ( self::$post_ids as $post_id ) { \wp_delete_post( $post_id, true ); } @@ -79,7 +90,7 @@ public function test_validate_user_id() { * @covers ::get_items */ public function test_get_items() { - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); $response = \rest_get_server()->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); @@ -91,7 +102,7 @@ public function test_get_items() { * @covers ::get_collection_schema */ public function test_get_collection_schema() { - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $schema = ( new Outbox_Controller() )->get_collection_schema(); @@ -106,7 +117,7 @@ public function test_get_collection_schema() { * @covers ::get_items */ public function test_get_items_pagination() { - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); $request->set_param( 'page', 2 ); $request->set_param( 'per_page', 3 ); $response = \rest_get_server()->dispatch( $request ); @@ -135,7 +146,7 @@ public function test_get_items_pagination() { * @covers ::get_items */ public function test_get_items_response_structure() { - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); $response = \rest_get_server()->dispatch( $request ); $data = $response->get_data(); @@ -161,7 +172,7 @@ public function test_get_items_specific_user() { $post_id = self::factory()->post->create( array( 'post_author' => $user_id, - 'post_type' => 'ap_outbox', + 'post_type' => Outbox::POST_TYPE, 'post_status' => 'draft', 'post_title' => 'https://example.org/activity/1', 'post_content' => wp_json_encode( @@ -229,7 +240,7 @@ function () use ( &$post_called ) { } ); - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); \rest_get_server()->dispatch( $request ); $this->assertTrue( $filter_called, 'activitypub_rest_outbox_array filter was not called.' ); @@ -247,7 +258,7 @@ function () use ( &$post_called ) { * @covers ::get_items */ public function test_get_items_minimum_per_page() { - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); $request->set_param( 'per_page', 1 ); $response = \rest_get_server()->dispatch( $request ); $data = $response->get_data(); @@ -262,7 +273,7 @@ public function test_get_items_minimum_per_page() { * @covers ::get_items */ public function test_get_items_maximum_per_page() { - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); $request->set_param( 'per_page', 100 ); $response = \rest_get_server()->dispatch( $request ); @@ -501,6 +512,183 @@ public function test_get_items_content_visibility( $visibility, $public_visible, \wp_delete_user( $user_id ); } + /** + * Test getting items with correct actor type filtering. + * + * @covers ::get_items + */ + public function test_get_items_actor_type_filtering() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + + // Create a post with user actor type. + $user_post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/1', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/1', + 'type' => 'Create', + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + // Create a post with blog actor type. + $blog_post_id = self::factory()->post->create( + array( + 'post_author' => 0, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/2', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/2', + 'type' => 'Create', + 'actor' => 'https://example.org/blog', + 'object' => array( + 'id' => 'https://example.org/note/2', + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'blog', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + // Test user outbox only returns user actor type. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 1, (int) $data['totalItems'] ); + $this->assertCount( 1, $data['orderedItems'] ); + $this->assertSame( 'https://example.org/activity/1', $data['orderedItems'][0]['object']['id'] ); + + // Test blog outbox only returns blog actor type. + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/0/outbox', ACTIVITYPUB_REST_NAMESPACE ) ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 1, (int) $data['totalItems'] ); + + \wp_delete_post( $user_post_id, true ); + \wp_delete_post( $blog_post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Test meta query behavior for non-privileged users. + * + * @covers ::get_items + */ + public function test_get_items_meta_query_for_non_privileged_users() { + $author_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $viewer_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + // Create a public post. + $public_post_id = self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/1', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/1', + 'type' => 'Create', + 'actor' => 'https://example.org/user/' . $author_id, + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Public content', + ), + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + // Create a private post. + $private_post_id = self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/2', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/2', + 'type' => 'Follow', + 'actor' => 'https://example.org/user/' . $author_id, + 'object' => 'https://example.org/user/123', + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Follow', + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + ), + ) + ); + + // Test as non-privileged user. + wp_set_current_user( $viewer_id ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $author_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 1, (int) $data['totalItems'] ); + $this->assertCount( 1, $data['orderedItems'] ); + $this->assertSame( 'https://example.org/activity/1', $data['orderedItems'][0]['object']['id'] ); + + // Test as privileged user. + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $author_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 2, (int) $data['totalItems'] ); + $this->assertCount( 2, $data['orderedItems'] ); + + \wp_delete_post( $public_post_id, true ); + \wp_delete_post( $private_post_id, true ); + \wp_delete_user( $author_id ); + \wp_delete_user( $viewer_id ); + \wp_delete_user( $admin_id ); + } + /** * Test get_item method. * From b1c89457ac9bdd8333c90128ae4e55166bdce26d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 23 Jan 2025 11:56:24 +0100 Subject: [PATCH 70/98] Fix typo --- CHANGELOG.md | 2 +- readme.txt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c56e2fbc3..1ed6365da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Untitled] +## [Unreleased] ### Changed diff --git a/readme.txt b/readme.txt index af290bc64..ab7ea665e 100644 --- a/readme.txt +++ b/readme.txt @@ -134,7 +134,6 @@ For reasons of data protection, it is not possible to see the followers of other = Unreleased = - * Added: Outbox queue * Changed: Improved content negotiation and AUTHORIZED_FETCH support for third-party plugins From bc6e7ed145585a736dbfa31a63d2f675bc8c0056 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 23 Jan 2025 08:17:03 -0600 Subject: [PATCH 71/98] Outbox: Tests and fixes for Post Scheduler (#1196) * Outbox: Tests and fixes for Post Scheduler * Move extrafields post to Actor class * Add test coverage for remaining Actor methods * Move cap-manipulating tests to the end * Account for inheritance in Transformer Factory Reverts changes to `add_to_outbox()` * Remove outbox items after every test. * Use fully qualified class name * Split extra fields tests to not hit time limit * Adjust bog modes * debug * debug * debug * debug * Use Profile_update action. `wp_update_user` wasn't introduced until WP 6.3. * revert changes to add_to_outbox --- includes/scheduler/class-actor.php | 22 +- includes/scheduler/class-post.php | 20 +- includes/transformer/class-factory.php | 6 +- tests/includes/scheduler/class-test-actor.php | 241 ++++++++++++++++++ tests/includes/scheduler/class-test-post.php | 147 +++++++++++ 5 files changed, 413 insertions(+), 23 deletions(-) create mode 100644 tests/includes/scheduler/class-test-actor.php create mode 100644 tests/includes/scheduler/class-test-post.php diff --git a/includes/scheduler/class-actor.php b/includes/scheduler/class-actor.php index e210c2eea..006e2af63 100644 --- a/includes/scheduler/class-actor.php +++ b/includes/scheduler/class-actor.php @@ -8,6 +8,7 @@ namespace Activitypub\Scheduler; use Activitypub\Collection\Actors; +use Activitypub\Collection\Extra_Fields; use function Activitypub\add_to_outbox; use function Activitypub\is_user_type_disabled; @@ -31,10 +32,12 @@ public static function init() { // Profile updates for user options. if ( ! is_user_type_disabled( 'user' ) ) { - \add_action( 'wp_update_user', array( self::class, 'user_update' ) ); + \add_action( 'profile_update', array( self::class, 'user_update' ) ); \add_action( 'updated_user_meta', array( self::class, 'user_meta_update' ), 10, 3 ); // @todo figure out a feasible way of updating the header image since it's not unique to any user. } + + \add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 ); } /** @@ -89,6 +92,21 @@ public static function blog_user_update( $value = null ) { return $value; } + /** + * Schedule Activities. + * + * @param string $new_status New post status. + * @param string $old_status Old post status. + * @param \WP_Post $post Post object. + */ + public static function schedule_post_activity( $new_status, $old_status, $post ) { + if ( Extra_Fields::USER_POST_TYPE === $post->post_type ) { + self::schedule_profile_update( $post->post_author ); + } elseif ( Extra_Fields::BLOG_POST_TYPE === $post->post_type ) { + self::schedule_profile_update( Actors::BLOG_USER_ID ); + } + } + /** * Send a profile update to all followers. Gets hooked into all relevant options/meta etc. * @@ -97,6 +115,6 @@ public static function blog_user_update( $value = null ) { public static function schedule_profile_update( $user_id ) { $actor = Actors::get_by_id( $user_id ); - add_to_outbox( $actor->get_id(), 'Update', $user_id ); + add_to_outbox( $actor, 'Update', $user_id ); } } diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 14e1ac325..952ab3ffc 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -55,19 +55,7 @@ public static function transition_attachment_status( $post_id ) { * @param int|\WP_Post $post Post ID or post object. */ public static function schedule_post_activity( $new_status, $old_status, $post ) { - $post = get_post( $post ); - - if ( ! $post || is_post_disabled( $post ) ) { - return; - } - - if ( 'ap_extrafield' === $post->post_type ) { - self::schedule_profile_update( $post->post_author ); - return; - } - - if ( 'ap_extrafield_blog' === $post->post_type ) { - self::schedule_profile_update( 0 ); + if ( is_post_disabled( $post ) ) { return; } @@ -76,12 +64,6 @@ public static function schedule_post_activity( $new_status, $old_status, $post ) return; } - // Check if post-type supports ActivityPub. - $post_types = \get_post_types_by_support( 'activitypub' ); - if ( ! \in_array( $post->post_type, $post_types, true ) ) { - return; - } - switch ( $new_status ) { case 'publish': $type = ( 'publish' === $old_status ) ? 'Update' : 'Create'; diff --git a/includes/transformer/class-factory.php b/includes/transformer/class-factory.php index a4662eca4..946390200 100644 --- a/includes/transformer/class-factory.php +++ b/includes/transformer/class-factory.php @@ -94,12 +94,14 @@ public static function get_transformer( $data ) { return new User( $data ); } break; - case 'Base_Object': - return new Activity_Object( $data ); case 'json': return new Json( $data ); } + if ( $data instanceof \Activitypub\Activity\Base_Object ) { + return new Activity_Object( $data ); + } + return new WP_Error( 'invalid_object', __( 'Invalid object', 'activitypub' ) ); } } diff --git a/tests/includes/scheduler/class-test-actor.php b/tests/includes/scheduler/class-test-actor.php new file mode 100644 index 000000000..0f9236a77 --- /dev/null +++ b/tests/includes/scheduler/class-test-actor.php @@ -0,0 +1,241 @@ +user->create( + array( + 'role' => 'author', + 'display_name' => 'Test User', + 'meta_input' => array( + 'activitypub_description' => 'test description', + 'activitypub_header_image' => 'test header image', + 'description' => 'test description', + 'user_url' => 'https://example.org', + 'display_name' => 'Test Name', + ), + ) + ); + + // Add activitypub capability to the user. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + + \add_filter( 'pre_schedule_event', '__return_false' ); + } + + /** + * Clean up test resources. + */ + public static function tear_down_after_class() { + \delete_option( 'activitypub_actor_mode' ); + \wp_delete_user( self::$user_id ); + \remove_filter( 'pre_schedule_event', '__return_false' ); + } + + /** + * Tear down. + */ + public function tear_down() { + $outbox_items = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + 'post_status' => 'any', + 'fields' => 'ids', + ) + ); + + foreach ( $outbox_items as $outbox_item ) { + \wp_delete_post( $outbox_item, true ); + } + } + + /** + * Data provider for user meta update scheduling. + * + * @return string[][] + */ + public function user_meta_provider() { + return array( + array( 'activitypub_description' ), + array( 'activitypub_header_image' ), + array( 'description' ), + array( 'user_url' ), + array( 'display_name' ), + ); + } + + /** + * Test user meta update scheduling. + * + * @dataProvider user_meta_provider + * @covers ::user_meta_update + * + * @param string $meta_key Meta key to test. + */ + public function test_user_meta_update( $meta_key ) { + \update_user_meta( self::$user_id, $meta_key, 'test value' ); + + $activitpub_id = Actors::get_by_id( self::$user_id )->get_id(); + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + } + + /** + * Test user update scheduling. + * + * @covers ::user_update + */ + public function test_user_update() { + self::factory()->user->update_object( self::$user_id, array( 'display_name' => 'Test Name' ) ); + + $activitpub_id = Actors::get_by_id( self::$user_id )->get_id(); + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + } + + /** + * Test blog user update scheduling. + * + * @covers ::blog_user_update + */ + public function test_blog_user_update() { + $test_value = 'test value'; + $result = \Activitypub\Scheduler\Actor::blog_user_update( $test_value ); + + $activitpub_id = Actors::get_by_id( Actors::BLOG_USER_ID )->get_id(); + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + $this->assertSame( $test_value, $result ); + } + + /** + * Test user update scheduling with non-publishing user. + * + * @covers ::user_update + */ + public function test_user_update_no_publish() { + $activitpub_id = Actors::get_by_id( self::$user_id )->get_id(); + + // Temporarily remove the activitypub capability. + \get_user_by( 'id', self::$user_id )->remove_cap( 'activitypub' ); + self::factory()->user->update_object( self::$user_id, array( 'display_name' => 'Test Name No Publish' ) ); + + $this->assertNull( $this->get_latest_outbox_item( $activitpub_id ) ); + + // Restore the activitypub capability. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Test user meta update scheduling with non-publishing user. + * + * @covers ::user_meta_update + */ + public function test_user_meta_update_no_publish() { + $activitpub_id = Actors::get_by_id( self::$user_id )->get_id(); + + // Temporarily remove the activitypub capability. + \get_user_by( 'id', self::$user_id )->remove_cap( 'activitypub' ); + + \update_user_meta( self::$user_id, 'description', 'test value' ); + + $this->assertNull( $this->get_latest_outbox_item( $activitpub_id ) ); + + // Restore the activitypub capability. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Test post activity scheduling for ActivityPub extra fields. + * + * @covers ::schedule_post_activity + */ + public function test_schedule_post_activity_extra_fields() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_type' => Extra_Fields::USER_POST_TYPE, + ) + ); + $activitpub_id = Actors::get_by_id( self::$user_id )->get_id(); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + + \wp_delete_post( $post_id, true ); + } + + /** + * Test post activity scheduling for ActivityPub extra fields. + * + * @covers ::schedule_post_activity + */ + public function test_schedule_post_activity_extra_field_blog() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_BLOG_MODE ); + $blog_post_id = self::factory()->post->create( array( 'post_type' => Extra_Fields::BLOG_POST_TYPE ) ); + $activitpub_id = Actors::get_by_id( Actors::BLOG_USER_ID )->get_id(); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + + // Clean up. + \wp_delete_post( $blog_post_id, true ); + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + } + + /** + * Retrieve the latest Outbox item to compare against. + * + * @param string $title Title of the Outbox item. + * @return int|\WP_Post|null + */ + private function get_latest_outbox_item( $title = '' ) { + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'draft', + 'post_title' => $title, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + return $outbox ? $outbox[0] : null; + } +} diff --git a/tests/includes/scheduler/class-test-post.php b/tests/includes/scheduler/class-test-post.php new file mode 100644 index 000000000..a5fa05eab --- /dev/null +++ b/tests/includes/scheduler/class-test-post.php @@ -0,0 +1,147 @@ +user->create( array( 'role' => 'author' ) ); + + // Add activitypub capability to the user. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + + \add_filter( 'pre_schedule_event', '__return_false' ); + } + + /** + * Clean up test resources. + */ + public static function tear_down_after_class() { + \wp_delete_user( self::$user_id ); + \remove_filter( 'pre_schedule_event', '__return_false' ); + } + + /** + * Tear down. + */ + public function tear_down() { + $outbox_items = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + 'post_status' => 'any', + 'fields' => 'ids', + ) + ); + + foreach ( $outbox_items as $outbox_item ) { + \wp_delete_post( $outbox_item, true ); + } + } + + /** + * Test post activity scheduling for regular posts. + * + * @covers ::schedule_post_activity + */ + public function test_schedule_post_activity_regular_post() { + $post_id = self::factory()->post->create(); + $activitpub_id = \add_query_arg( 'p', $post_id, \trailingslashit( \home_url() ) ); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + + \wp_delete_post( $post_id, true ); + } + + /** + * Data provider for no activity tests. + * + * @return array[] Test parameters. + */ + public function no_activity_post_provider() { + return array( + 'password_protected' => array( + array( 'post_password' => 'test-password' ), + ), + 'unsupported_post_type' => array( + array( 'post_type' => 'nav_menu_item' ), + ), + 'disabled_post' => array( + array( + 'meta_input' => array( + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + ), + ), + ), + ); + } + + /** + * Test post activity scheduling under various conditions. + * + * @dataProvider no_activity_post_provider + * + * @param array $args Post data for creating the test post. + */ + public function test_no_activity_scheduled( $args ) { + $post_id = self::factory()->post->create( $args ); + $activitpub_id = \add_query_arg( 'p', $post_id, \trailingslashit( \home_url() ) ); + + $this->assertNull( $this->get_latest_outbox_item( $activitpub_id ) ); + + \wp_delete_post( $post_id, true ); + } + + /** + * Retrieve the latest Outbox item to compare against. + * + * @param string $title Title of the Outbox item. + * @return int|\WP_Post|null + */ + private function get_latest_outbox_item( $title = '' ) { + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'draft', + 'post_title' => $title, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + return $outbox ? $outbox[0] : null; + } +} From 9bcf121206aa49127adbe17e4182e39e5e6cbc62 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 23 Jan 2025 15:43:42 +0100 Subject: [PATCH 72/98] Update Dispatcher to use Outbox (#1186) * rename dispatcher * Simple dispatcher based on the Outbox-Collection This is a simple rewrite of the current dispatcher system, to use the Outbox instead of the Scheduler. This is a first draft and will be improved over time, to better handle: * Re-tries * Errors * Logging * Batch processing * update changelog * mark post as `publish` after federation id done * show only published activities * fix missing rename * use pending instead of draft * do not check for post_status * fix tests props @obenland * Send `Update`s to Blog Actor in dual mode * Update includes/class-dispatcher.php Co-authored-by: Konstantin Obenland * Update includes/class-dispatcher.php Co-authored-by: Konstantin Obenland * Update includes/rest/class-outbox-controller.php Co-authored-by: Konstantin Obenland * Check if Activity should be sent to followers * the unique check will be done `send_activity_to_followers` * fix tests * fix PHPCS * move scheduler behind action * Add `private` visibility * Add Announce activity * Announce the full object! * fix indent * Update includes/transformer/class-base.php Co-authored-by: Konstantin Obenland * add doc-block * only boost content not profile updates * Also handle `Delete` when bundling Blog Actor inboxes * Update docs * Avoid activitypub_actor_mode bleeding into other tests * Fix comments tests * Account for inheritance in Activity objects * Move hook to the right place * fix typo! * trigger scheduler * Fix tests --------- Co-authored-by: Matt Wiebe Co-authored-by: Konstantin Obenland --- CHANGELOG.md | 9 +- activitypub.php | 2 +- includes/class-activity-dispatcher.php | 348 ------------------ includes/class-activitypub.php | 2 +- includes/class-dispatcher.php | 278 ++++++++++++++ includes/class-scheduler.php | 20 + includes/collection/class-outbox.php | 2 +- includes/constants.php | 1 + includes/functions.php | 34 +- includes/rest/class-outbox-controller.php | 2 +- includes/scheduler/class-post.php | 56 +++ includes/transformer/class-base.php | 2 +- readme.txt | 1 + tests/includes/class-test-dispatcher.php | 93 +++++ .../includes/collection/class-test-outbox.php | 2 +- .../rest/class-test-outbox-controller.php | 11 +- tests/includes/scheduler/class-test-actor.php | 2 +- .../includes/scheduler/class-test-comment.php | 2 +- tests/includes/scheduler/class-test-post.php | 2 +- 19 files changed, 491 insertions(+), 378 deletions(-) delete mode 100644 includes/class-activity-dispatcher.php create mode 100644 includes/class-dispatcher.php create mode 100644 tests/includes/class-test-dispatcher.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed6365da..58f59ed15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for WPML post locale +### Added + +* Outbox queue + +### Changed + +* Rewrite the current dispatcher system, to use the Outbox instead of the Scheduler. + ### Removed * Built-in support for nodeinfo2. Use the [NodeInfo plugin](https://wordpress.org/plugins/nodeinfo/) instead. @@ -42,7 +50,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -* Outbox queue * Comment counts get updated when the plugin is activated/deactivated/deleted * Added a filter to make custom comment types manageable in WP.com Calypso diff --git a/activitypub.php b/activitypub.php index cb52d0c16..4446452bf 100644 --- a/activitypub.php +++ b/activitypub.php @@ -65,7 +65,7 @@ function rest_init() { function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) ); - \add_action( 'init', array( __NAMESPACE__ . '\Activity_Dispatcher', 'init' ) ); + \add_action( 'init', array( __NAMESPACE__ . '\Dispatcher', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Handler', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Admin', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Hashtag', 'init' ) ); diff --git a/includes/class-activity-dispatcher.php b/includes/class-activity-dispatcher.php deleted file mode 100644 index 670a3a2f2..000000000 --- a/includes/class-activity-dispatcher.php +++ /dev/null @@ -1,348 +0,0 @@ -get_wp_user_id() ) && - ! is_user_disabled( Actors::BLOG_USER_ID ) - ) { - $transformer->change_wp_user_id( Actors::BLOG_USER_ID ); - } - - if ( null !== $user_id ) { - $transformer->change_wp_user_id( $user_id ); - } - - $user_id = $transformer->get_wp_user_id(); - - if ( is_user_disabled( $user_id ) ) { - return; - } - - $activity = $transformer->to_activity( $type ); - - self::send_activity_to_followers( $activity, $user_id, $wp_object ); - } - - /** - * Send Announces to followers and mentioned users. - * - * @param mixed $wp_object The ActivityPub Post. - * @param string $type The Activity-Type. - */ - public static function send_announce( $wp_object, $type ) { - if ( ! in_array( $type, array( 'Create', 'Update', 'Delete' ), true ) ) { - return; - } - - if ( is_user_disabled( Actors::BLOG_USER_ID ) ) { - return; - } - - $transformer = Factory::get_transformer( $wp_object ); - - if ( \is_wp_error( $transformer ) ) { - return; - } - - $user_id = Actors::BLOG_USER_ID; - $activity = $transformer->to_activity( $type ); - $user = Actors::get_by_id( Actors::BLOG_USER_ID ); - - $announce = new Activity(); - $announce->set_type( 'Announce' ); - $announce->set_object( $activity ); - $announce->set_actor( $user->get_id() ); - - self::send_activity_to_followers( $announce, $user_id, $wp_object ); - } - - /** - * Send a "Update" Activity when a user updates their profile. - * - * @param int $user_id The user ID to send an update for. - */ - public static function send_profile_update( $user_id ) { - $user = Actors::get_by_various( $user_id ); - - // Bail if that's not a good user. - if ( is_wp_error( $user ) ) { - return; - } - - // Build the update. - $activity = new Activity(); - $activity->set_type( 'Update' ); - $activity->set_actor( $user->get_id() ); - $activity->set_object( $user->get_id() ); - $activity->set_to( array( 'https://www.w3.org/ns/activitystreams#Public' ) ); - - // Send the update. - self::send_activity_to_followers( $activity, $user_id, $user ); - } - - /** - * Send an Activity to all followers and mentioned users. - * - * @param Activity $activity The ActivityPub Activity. - * @param int $user_id The user ID. - * @param \WP_User|WP_Post|WP_Comment $wp_object The WordPress object. - */ - private static function send_activity_to_followers( $activity, $user_id, $wp_object ) { - /** - * Filters whether to send an Activity to followers. - * - * @param bool $send_activity_to_followers Whether to send the Activity to followers. - * @param Activity $activity The ActivityPub Activity. - * @param int $user_id The user ID. - * @param \WP_User|WP_Post|WP_Comment $wp_object The WordPress object. - */ - if ( ! apply_filters( 'activitypub_send_activity_to_followers', true, $activity, $user_id, $wp_object ) ) { - return; - } - - /** - * Filters the list of inboxes to send the Activity to. - * - * @param array $inboxes The list of inboxes to send to. - * @param int $user_id The user ID. - * @param Activity $activity The ActivityPub Activity. - */ - $inboxes = apply_filters( 'activitypub_send_to_inboxes', array(), $user_id, $activity ); - $inboxes = array_unique( $inboxes ); - - if ( empty( $inboxes ) ) { - return; - } - - $json = $activity->to_json(); - - foreach ( $inboxes as $inbox ) { - safe_remote_post( $inbox, $json, $user_id ); - } - - set_wp_object_state( $wp_object, 'federated' ); - } - - /** - * Send a "Create" or "Update" Activity for a WordPress Post. - * - * @param int $id The WordPress Post ID. - * @param string $type The Activity-Type. - */ - public static function send_post( $id, $type ) { - $post = get_post( $id ); - - if ( ! $post || is_post_disabled( $post ) ) { - return; - } - - /** - * Fires when an Activity is being sent for any object type. - * - * @param WP_Post $post The WordPress Post. - * @param string $type The Activity-Type. - */ - do_action( 'activitypub_send_activity', $post, $type ); - - /** - * Fires when a specific type of Activity is being sent. - * - * @param WP_Post $post The WordPress Post. - */ - do_action( sprintf( 'activitypub_send_%s_activity', \strtolower( $type ) ), $post ); - } - - /** - * Send a "Create" or "Update" Activity for a WordPress Comment. - * - * @param int $id The WordPress Comment ID. - * @param string $type The Activity-Type. - */ - public static function send_comment( $id, $type ) { - $comment = get_comment( $id ); - - if ( ! $comment ) { - return; - } - - /** - * Fires when an Activity is being sent for a Comment. - * - * @param WP_Comment $comment The WordPress Comment. - * @param string $type The Activity-Type. - */ - do_action( 'activitypub_send_activity', $comment, $type ); - - /** - * Fires when a specific type of Activity is being sent for a Comment. - * - * @param WP_Comment $comment The WordPress Comment. - */ - do_action( sprintf( 'activitypub_send_%s_activity', \strtolower( $type ) ), $comment ); - } - - /** - * Default filter to add Inboxes of Followers. - * - * @param array $inboxes The list of Inboxes. - * @param int $user_id The WordPress User-ID. - * - * @return array The filtered Inboxes - */ - public static function add_inboxes_of_follower( $inboxes, $user_id ) { - $follower_inboxes = Followers::get_inboxes( $user_id ); - - return array_merge( $inboxes, $follower_inboxes ); - } - - /** - * Default filter to add Inboxes of Mentioned Actors - * - * @param array $inboxes The list of Inboxes. - * @param int $user_id The WordPress User-ID. - * @param array $activity The ActivityPub Activity. - * - * @return array The filtered Inboxes. - */ - public static function add_inboxes_by_mentioned_actors( $inboxes, $user_id, $activity ) { - $cc = $activity->get_cc() ?? array(); - $to = $activity->get_to() ?? array(); - - $audience = array_merge( $cc, $to ); - - // Remove "public placeholder" and "same domain" from the audience. - $audience = array_filter( - $audience, - function ( $actor ) { - return 'https://www.w3.org/ns/activitystreams#Public' !== $actor && ! is_same_domain( $actor ); - } - ); - - if ( $audience ) { - $mentioned_inboxes = Mention::get_inboxes( $audience ); - - return array_merge( $inboxes, $mentioned_inboxes ); - } - - return $inboxes; - } - - /** - * Default filter to add Inboxes of Posts that are set as `in-reply-to` - * - * @param array $inboxes The list of Inboxes. - * @param int $user_id The WordPress User-ID. - * @param array $activity The ActivityPub Activity. - * - * @return array The filtered Inboxes - */ - public static function add_inboxes_of_replied_urls( $inboxes, $user_id, $activity ) { - $in_reply_to = $activity->get_in_reply_to(); - - if ( ! $in_reply_to ) { - return $inboxes; - } - - if ( ! is_array( $in_reply_to ) ) { - $in_reply_to = array( $in_reply_to ); - } - - foreach ( $in_reply_to as $url ) { - $object = Http::get_remote_object( $url ); - - if ( - ! $object || - \is_wp_error( $object ) || - empty( $object['attributedTo'] ) - ) { - continue; - } - - $actor = object_to_uri( $object['attributedTo'] ); - $actor = Http::get_remote_object( $actor ); - - if ( ! $actor || \is_wp_error( $actor ) ) { - continue; - } - - if ( ! empty( $actor['endpoints']['sharedInbox'] ) ) { - $inboxes[] = $actor['endpoints']['sharedInbox']; - } elseif ( ! empty( $actor['inbox'] ) ) { - $inboxes[] = $actor['inbox']; - } - } - - return $inboxes; - } -} diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index f0cb83166..c62a300d9 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -602,7 +602,7 @@ private static function register_post_types() { 'sanitize_callback' => function ( $value ) { $schema = array( 'type' => 'string', - 'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ), + 'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ), 'default' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ); diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php new file mode 100644 index 000000000..9ee12df0f --- /dev/null +++ b/includes/class-dispatcher.php @@ -0,0 +1,278 @@ +ID, '_activitypub_activity_actor', true ); + + switch ( $actor_type ) { + 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; + break; + } + + $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ); + $transformer = Transformer_Factory::get_transformer( $outbox_item->post_content ); + $activity = $transformer->to_activity( $type ); + + self::send_activity_to_followers( $activity, $actor_id, $outbox_item ); + } + + /** + * Send an Activity to all followers and mentioned users. + * + * @param Activity $activity The ActivityPub Activity. + * @param int $actor_id The actor ID. + * @param \WP_Post $outbox_item The WordPress object. + */ + private static function send_activity_to_followers( $activity, $actor_id, $outbox_item = null ) { + /** + * Filters whether to send an Activity to followers. + * + * @param bool $send_activity_to_followers Whether to send the Activity to followers. + * @param Activity $activity The ActivityPub Activity. + * @param int $actor_id The actor ID. + * @param \WP_Post $outbox_item The WordPress object. + */ + if ( ! apply_filters( 'activitypub_send_activity_to_followers', true, $activity, $actor_id, $outbox_item ) ) { + return; + } + + /** + * Filters the list of inboxes to send the Activity to. + * + * @param array $inboxes The list of inboxes to send to. + * @param int $actor_id The actor ID. + * @param Activity $activity The ActivityPub Activity. + */ + $inboxes = apply_filters( 'activitypub_send_to_inboxes', array(), $actor_id, $activity ); + $inboxes = array_unique( $inboxes ); + + if ( empty( $inboxes ) ) { + return; + } + + $json = $activity->to_json(); + + foreach ( $inboxes as $inbox ) { + safe_remote_post( $inbox, $json, $actor_id ); + } + + \wp_publish_post( $outbox_item ); + } + + /** + * Default filter to add Inboxes of Followers. + * + * @param array $inboxes The list of Inboxes. + * @param int $actor_id The WordPress Actor-ID. + * @param Activity $activity The ActivityPub Activity. + * + * @return array The filtered Inboxes + */ + public static function add_inboxes_of_follower( $inboxes, $actor_id, $activity ) { + if ( ! self::should_send_to_followers( $activity, $actor_id ) ) { + return $inboxes; + } + + $follower_inboxes = Followers::get_inboxes( $actor_id ); + + return array_merge( $inboxes, $follower_inboxes ); + } + + /** + * Default filter to add Inboxes of Mentioned Actors + * + * @param array $inboxes The list of Inboxes. + * @param int $actor_id The WordPress Actor-ID. + * @param Activity $activity The ActivityPub Activity. + * + * @return array The filtered Inboxes. + */ + public static function add_inboxes_by_mentioned_actors( $inboxes, $actor_id, $activity ) { + $cc = $activity->get_cc() ?? array(); + $to = $activity->get_to() ?? array(); + + $audience = array_merge( $cc, $to ); + + // Remove "public placeholder" and "same domain" from the audience. + $audience = array_filter( + $audience, + function ( $actor ) { + return 'https://www.w3.org/ns/activitystreams#Public' !== $actor && ! is_same_domain( $actor ); + } + ); + + if ( $audience ) { + $mentioned_inboxes = Mention::get_inboxes( $audience ); + + return array_merge( $inboxes, $mentioned_inboxes ); + } + + return $inboxes; + } + + /** + * Default filter to add Inboxes of Posts that are set as `in-reply-to` + * + * @param array $inboxes The list of Inboxes. + * @param int $actor_id The WordPress Actor-ID. + * @param array $activity The ActivityPub Activity. + * + * @return array The filtered Inboxes + */ + public static function add_inboxes_of_replied_urls( $inboxes, $actor_id, $activity ) { + $in_reply_to = $activity->get_in_reply_to(); + + if ( ! $in_reply_to ) { + return $inboxes; + } + + if ( ! is_array( $in_reply_to ) ) { + $in_reply_to = array( $in_reply_to ); + } + + foreach ( $in_reply_to as $url ) { + $object = Http::get_remote_object( $url ); + + if ( + ! $object || + \is_wp_error( $object ) || + empty( $object['attributedTo'] ) + ) { + continue; + } + + $actor = object_to_uri( $object['attributedTo'] ); + $actor = Http::get_remote_object( $actor ); + + if ( ! $actor || \is_wp_error( $actor ) ) { + continue; + } + + if ( ! empty( $actor['endpoints']['sharedInbox'] ) ) { + $inboxes[] = $actor['endpoints']['sharedInbox']; + } elseif ( ! empty( $actor['inbox'] ) ) { + $inboxes[] = $actor['inbox']; + } + } + + return $inboxes; + } + + /** + * Adds Blog Actor inboxes to Updates so the Blog User's followers are notified of edits. + * + * @param array $inboxes The list of Inboxes. + * @param int $actor_id The WordPress Actor-ID. + * @param Activity $activity The ActivityPub Activity. + * + * @return array The filtered Inboxes + */ + public static function maybe_add_inboxes_of_blog_user( $inboxes, $actor_id, $activity ) { + if ( ! self::should_send_to_followers( $activity, $actor_id ) ) { + return $inboxes; + } + + // 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 $inboxes; + } + // Only if this isn't the Blog Actor. + if ( Actors::BLOG_USER_ID === $actor_id ) { + return $inboxes; + } + // Only if this is an Update or Delete. Create handles its own Announce in dual user mode. + if ( ! in_array( $activity->get_type(), array( 'Update', 'Delete' ), true ) ) { + return $inboxes; + } + + $blog_inboxes = Followers::get_inboxes( Actors::BLOG_USER_ID ); + // array_unique is done in `send_activity_to_followers()`, no need here. + return array_merge( $inboxes, $blog_inboxes ); + } + + /** + * Check if passed Activity is public. + * + * @param Activity $activity The Activity object. + * @param int $actor_id The Actor-ID. + * + * @return boolean True if public, false if not. + */ + protected static function should_send_to_followers( $activity, $actor_id ) { + // Check if follower endpoint is set. + $actor = Actors::get_by_id( $actor_id ); + + if ( ! $actor || is_wp_error( $actor ) ) { + return false; + } + + // Check if follower endpoint is set. + $cc = $activity->get_cc() ?? array(); + $to = $activity->get_to() ?? array(); + + $audience = array_merge( $cc, $to ); + + if ( + // Check if activity is public. + in_array( 'https://www.w3.org/ns/activitystreams#Public', $audience, true ) || + // ...or check if follower endpoint is set. + in_array( $actor->get_followers(), $audience, true ) + ) { + return true; + } + + return false; + } +} diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 36ad1b809..cbcf5f96c 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -28,6 +28,8 @@ public static function init() { // Follower Cleanups. \add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) ); \add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) ); + + \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_outbox_activity_for_federation' ) ); } /** @@ -138,4 +140,22 @@ public static function cleanup_followers() { } } } + + /** + * Schedule the outbox item for federation. + * + * @param int $id The ID of the outbox item. + */ + public static function schedule_outbox_activity_for_federation( $id ) { + $hook = 'activitypub_process_outbox'; + $args = array( $id ); + + if ( false === wp_next_scheduled( $hook, $args ) ) { + \wp_schedule_single_event( + \time() + 10, + $hook, + $args + ); + } + } } diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 2fa6a47f4..87a8a0505 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -44,7 +44,7 @@ public static function add( $activity_object, $activity_type, $user_id, $content 'post_content' => $activity_object->to_json(), // ensure that user ID is not below 0. 'post_author' => \max( $user_id, 0 ), - 'post_status' => 'draft', + 'post_status' => 'pending', 'meta_input' => array( '_activitypub_activity_type' => $activity_type, '_activitypub_activity_actor' => $actor_type, diff --git a/includes/constants.php b/includes/constants.php index e12450205..98da5d4a4 100644 --- a/includes/constants.php +++ b/includes/constants.php @@ -71,4 +71,5 @@ // Post visibility constants. \define( 'ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC', '' ); \define( 'ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC', 'quiet_public' ); +\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE', 'private' ); \define( 'ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL', 'local' ); diff --git a/includes/functions.php b/includes/functions.php index 380777e27..054750216 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1424,6 +1424,7 @@ function get_content_visibility( $post_id ) { $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; $options = array( ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, + ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ); @@ -1554,43 +1555,42 @@ function is_self_ping( $id ) { * Add an object to the outbox. * * @param mixed $data The object to add to the outbox. - * @param string $type The type of the Activity. + * @param string $activity_type The type of the Activity. * @param integer $user_id The User-ID. * @param string $content_visibility The visibility of the content. * * @return boolean|int The ID of the outbox item or false on failure. */ -function add_to_outbox( $data, $type = 'Create', $user_id = 0, $content_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { +function add_to_outbox( $data, $activity_type = 'Create', $user_id = 0, $content_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { $transformer = Transformer_Factory::get_transformer( $data ); if ( ! $transformer || is_wp_error( $transformer ) ) { return false; } - $activity = $transformer->to_object(); + $activity_object = $transformer->to_object(); - if ( ! $activity || is_wp_error( $activity ) ) { + if ( ! $activity_object || \is_wp_error( $activity_object ) ) { return false; } set_wp_object_state( $data, 'federate' ); - $id = Outbox::add( $activity, $type, $user_id, $content_visibility ); + $outbox_activity_id = Outbox::add( $activity_object, $activity_type, $user_id, $content_visibility ); - if ( ! $id ) { + if ( ! $outbox_activity_id ) { return false; } - $hook = 'activitypub_process_outbox'; - $args = array( $id ); - - if ( false === wp_next_scheduled( $hook, $args ) ) { - \wp_schedule_single_event( - \time() + 10, - $hook, - $args - ); - } + /** + * Action triggered after an object has been added to the outbox. + * + * @param int $outbox_activity_id The ID of the outbox item. + * @param \Activitypub\Activity\Base_Object $activity_object The activity object. + * @param int $user_id The User-ID. + * @param string $content_visibility The visibility of the content. + */ + \do_action( 'post_activitypub_add_to_outbox', $outbox_activity_id, $activity_object, $user_id, $content_visibility ); - return $id; + return $outbox_activity_id; } diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 59f2cbd16..ac1b2dd22 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -187,7 +187,7 @@ public function get_items( $request ) { 'orderedItems' => array(), ); - update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); + \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); foreach ( $query_result as $outbox_item ) { $response['orderedItems'][] = $this->prepare_item_for_response( $outbox_item, $request ); } diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 952ab3ffc..69059f6b3 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -7,6 +7,11 @@ namespace Activitypub\Scheduler; +use Activitypub\Scheduler; +use Activitypub\Collection\Outbox; +use Activitypub\Collection\Actors; +use Activitypub\Transformer\Factory; + use function Activitypub\add_to_outbox; use function Activitypub\is_post_disabled; use function Activitypub\get_wp_object_state; @@ -26,6 +31,8 @@ public static function init() { \add_action( 'add_attachment', array( self::class, 'transition_attachment_status' ) ); \add_action( 'edit_attachment', array( self::class, 'transition_attachment_status' ) ); \add_action( 'delete_attachment', array( self::class, 'transition_attachment_status' ) ); + + \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'send_announces' ), 10, 4 ); } /** @@ -92,4 +99,53 @@ public static function schedule_post_activity( $new_status, $old_status, $post ) // Add the post to the outbox. add_to_outbox( $post, $type, $post->post_author, $content_visibility ); } + + /** + * Send announces. + * + * @param int $outbox_activity_id The outbox activity ID. + * @param array $activity_object The activity object. + * @param int $actor_id The actor ID. + * @param int $content_visibility The content visibility. + */ + public static function send_announces( $outbox_activity_id, $activity_object, $actor_id, $content_visibility ) { + // 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; + } + + // Only if this isn't the Blog Actor. + if ( Actors::BLOG_USER_ID === $actor_id ) { + return; + } + + // Only if the content is public or quiet public. + if ( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC !== $content_visibility ) { + return; + } + + $activity_type = \get_post_meta( $outbox_activity_id, '_activitypub_activity_type', true ); + + // Only if the activity is a Create, Update or Delete. + if ( ! in_array( $activity_type, array( 'Create', 'Update', 'Delete' ), true ) ) { + 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_object->get_type(), array( 'Note', 'Article', 'Image', 'Audio', 'Video', 'Event', 'Document' ), true ) ) { + return; + } + + $transformer = Factory::get_transformer( $activity_object ); + $activity = $transformer->to_activity( $activity_type ); + + $outbox_activity_id = Outbox::add( $activity, 'Announce', Actors::BLOG_USER_ID, ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ); + + if ( ! $outbox_activity_id ) { + return false; + } + + // Schedule the outbox item for federation. + Scheduler::schedule_outbox_activity_for_federation( $outbox_activity_id ); + } } diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 94537c9b9..c4ae115d8 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -146,7 +146,7 @@ public function to_activity( $type ) { $activity->set_object( $object ); // Use simple Object (only ID-URI) for Like and Announce. - if ( in_array( $type, array( 'Like', 'Announce' ), true ) ) { + if ( 'Like' === $type ) { $activity->set_object( $object->get_id() ); } diff --git a/readme.txt b/readme.txt index ab7ea665e..a47cf3af6 100644 --- a/readme.txt +++ b/readme.txt @@ -135,6 +135,7 @@ For reasons of data protection, it is not possible to see the followers of other = Unreleased = * Added: Outbox queue +* Changed: Rewrite the current dispatcher system, to use the Outbox instead of a Scheduler. * Changed: Improved content negotiation and AUTHORIZED_FETCH support for third-party plugins = 4.7.3 = diff --git a/tests/includes/class-test-dispatcher.php b/tests/includes/class-test-dispatcher.php new file mode 100644 index 000000000..389cf89b3 --- /dev/null +++ b/tests/includes/class-test-dispatcher.php @@ -0,0 +1,93 @@ +createMock( Activity::class ); + + $result = Dispatcher::maybe_add_inboxes_of_blog_user( $inboxes, 1, $activity ); + $this->assertEquals( $inboxes, $result ); + } + + /** + * Test maybe_add_inboxes_of_blog_user when actor is blog user + * + * @covers ::maybe_add_inboxes_of_blog_user + */ + public function test_maybe_add_inboxes_of_blog_user_is_blog_user() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + + $inboxes = array( 'https://example.com/inbox' ); + $activity = $this->createMock( Activity::class ); + + $result = Dispatcher::maybe_add_inboxes_of_blog_user( $inboxes, Actors::BLOG_USER_ID, $activity ); + $this->assertEquals( $inboxes, $result ); + } + + /** + * Test maybe_add_inboxes_of_blog_user when activity type is not Update + * + * @covers ::maybe_add_inboxes_of_blog_user + */ + public function test_maybe_add_inboxes_of_blog_user_not_update() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + + $inboxes = array( 'https://example.com/inbox' ); + $activity = $this->createMock( Activity::class, array( '__call' ) ); + + // Mock the static method using reflection. + $activity->expects( $this->any() ) + ->method( '__call' ) + ->willReturnCallback( + function ( $name ) { + if ( 'get_to' === $name ) { + return array( 'https://www.w3.org/ns/activitystreams#Public' ); + } + + if ( 'get_cc' === $name ) { + return array(); + } + + if ( 'get_type' === $name ) { + return 'Create'; + } + + return null; + } + ); + + $result = Dispatcher::maybe_add_inboxes_of_blog_user( $inboxes, 1, $activity ); + $this->assertEquals( $inboxes, $result ); + } +} diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index 653300589..57724696e 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -33,7 +33,7 @@ public function test_add( $data, $type, $user_id, $json ) { $post = get_post( $id ); $this->assertInstanceOf( 'WP_Post', $post ); - $this->assertEquals( 'draft', $post->post_status ); + $this->assertEquals( 'pending', $post->post_status ); $this->assertEquals( $json, $post->post_content ); $this->assertEquals( $type, get_post_meta( $id, '_activitypub_activity_type', true ) ); diff --git a/tests/includes/rest/class-test-outbox-controller.php b/tests/includes/rest/class-test-outbox-controller.php index 300bdbb58..c96984a43 100644 --- a/tests/includes/rest/class-test-outbox-controller.php +++ b/tests/includes/rest/class-test-outbox-controller.php @@ -173,8 +173,8 @@ public function test_get_items_specific_user() { array( 'post_author' => $user_id, 'post_type' => Outbox::POST_TYPE, - 'post_status' => 'draft', 'post_title' => 'https://example.org/activity/1', + 'post_status' => 'pending', 'post_content' => wp_json_encode( array( '@context' => array( 'https://www.w3.org/ns/activitystreams' ), @@ -344,7 +344,7 @@ public function test_get_items_activity_type( $type, $activity, $allowed ) { array( 'post_author' => $user_id, 'post_type' => Outbox::POST_TYPE, - 'post_status' => 'draft', + 'post_status' => 'pending', 'post_title' => "https://example.org/activity/{$type}", 'post_content' => \wp_json_encode( array( @@ -420,6 +420,11 @@ public function data_content_visibility() { 'public_visible' => false, 'private_visible' => true, ), + 'private' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, + 'public_visible' => false, + 'private_visible' => true, + ), 'local' => array( 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, 'public_visible' => false, @@ -453,7 +458,7 @@ public function test_get_items_content_visibility( $visibility, $public_visible, array( 'post_author' => $user_id, 'post_type' => Outbox::POST_TYPE, - 'post_status' => 'draft', + 'post_status' => 'pending', 'post_title' => 'https://example.org/activity/1', 'post_content' => \wp_json_encode( array( diff --git a/tests/includes/scheduler/class-test-actor.php b/tests/includes/scheduler/class-test-actor.php index 0f9236a77..48501d0d5 100644 --- a/tests/includes/scheduler/class-test-actor.php +++ b/tests/includes/scheduler/class-test-actor.php @@ -229,7 +229,7 @@ private function get_latest_outbox_item( $title = '' ) { array( 'post_type' => Outbox::POST_TYPE, 'posts_per_page' => 1, - 'post_status' => 'draft', + 'post_status' => 'pending', 'post_title' => $title, 'orderby' => 'date', 'order' => 'DESC', diff --git a/tests/includes/scheduler/class-test-comment.php b/tests/includes/scheduler/class-test-comment.php index 62584ea87..f8eb440f9 100644 --- a/tests/includes/scheduler/class-test-comment.php +++ b/tests/includes/scheduler/class-test-comment.php @@ -166,7 +166,7 @@ private function get_latest_outbox_item( $title = '' ) { array( 'post_type' => Outbox::POST_TYPE, 'posts_per_page' => 1, - 'post_status' => 'draft', + 'post_status' => 'pending', 'post_title' => $title, 'orderby' => 'date', 'order' => 'DESC', diff --git a/tests/includes/scheduler/class-test-post.php b/tests/includes/scheduler/class-test-post.php index a5fa05eab..764621d25 100644 --- a/tests/includes/scheduler/class-test-post.php +++ b/tests/includes/scheduler/class-test-post.php @@ -135,7 +135,7 @@ private function get_latest_outbox_item( $title = '' ) { array( 'post_type' => Outbox::POST_TYPE, 'posts_per_page' => 1, - 'post_status' => 'draft', + 'post_status' => 'pending', 'post_title' => $title, 'orderby' => 'date', 'order' => 'DESC', From 32f075665f4b4ff6c74a47e01cc9517e829c1b32 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 24 Jan 2025 07:52:38 -0600 Subject: [PATCH 73/98] Outbox: Align return type and update docs (#1211) --- includes/scheduler/class-post.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 69059f6b3..c1736cdbe 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -7,6 +7,7 @@ namespace Activitypub\Scheduler; +use Activitypub\Activity\Activity; use Activitypub\Scheduler; use Activitypub\Collection\Outbox; use Activitypub\Collection\Actors; @@ -103,10 +104,10 @@ public static function schedule_post_activity( $new_status, $old_status, $post ) /** * Send announces. * - * @param int $outbox_activity_id The outbox activity ID. - * @param array $activity_object The activity object. - * @param int $actor_id The actor ID. - * @param int $content_visibility The content visibility. + * @param int $outbox_activity_id The outbox activity ID. + * @param Activity $activity_object The activity object. + * @param int $actor_id The actor ID. + * @param int $content_visibility The content visibility. */ public static function send_announces( $outbox_activity_id, $activity_object, $actor_id, $content_visibility ) { // Only if we're in both Blog and User modes. @@ -142,7 +143,7 @@ public static function send_announces( $outbox_activity_id, $activity_object, $a $outbox_activity_id = Outbox::add( $activity, 'Announce', Actors::BLOG_USER_ID, ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ); if ( ! $outbox_activity_id ) { - return false; + return; } // Schedule the outbox item for federation. From c02e6de2350c71283347ce66d8bba8fbe230fce7 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 24 Jan 2025 07:58:32 -0600 Subject: [PATCH 74/98] Outbox: Fix broken HTML tags after decoding Outbox items (#1212) --- includes/collection/class-outbox.php | 2 +- tests/includes/collection/class-test-outbox.php | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 87a8a0505..c14ad8135 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -41,7 +41,7 @@ public static function add( $activity_object, $activity_type, $user_id, $content $outbox_item = array( 'post_type' => self::POST_TYPE, 'post_title' => $activity_object->get_id(), - 'post_content' => $activity_object->to_json(), + 'post_content' => wp_slash( $activity_object->to_json() ), // ensure that user ID is not below 0. 'post_author' => \max( $user_id, 0 ), 'post_status' => 'pending', diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index 57724696e..536ad612f 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -36,6 +36,9 @@ public function test_add( $data, $type, $user_id, $json ) { $this->assertEquals( 'pending', $post->post_status ); $this->assertEquals( $json, $post->post_content ); + $activity = json_decode( $post->post_content ); + $this->assertSame( $data['content'], $activity->content ); + $this->assertEquals( $type, get_post_meta( $id, '_activitypub_activity_type', true ) ); $this->assertEquals( 'user', get_post_meta( $id, '_activitypub_activity_actor', true ) ); } @@ -52,22 +55,22 @@ public function activity_object_provider() { '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => 'https://example.com/1', 'type' => 'Note', - 'content' => 'This is a note', + 'content' => '

This is a note

', ), 'Create', 1, - '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/1","type":"Note","content":"This is a note","contentMap":{"en":"This is a note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"mediaType":"text/html","sensitive":false}', + '{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/1","type":"Note","content":"\u003Cp\u003EThis is a note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is a note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"mediaType":"text\/html","sensitive":false}', ), array( array( '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => 'https://example.com/2', 'type' => 'Note', - 'content' => 'This is another note', + 'content' => '

This is another note

', ), 'Create', 2, - '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/2","type":"Note","content":"This is another note","contentMap":{"en":"This is another note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"mediaType":"text/html","sensitive":false}', + '{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/2","type":"Note","content":"\u003Cp\u003EThis is another note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is another note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"mediaType":"text\/html","sensitive":false}', ), ); } From fe8e966ab017028d4ee7cbb65a0d3755002e0054 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 24 Jan 2025 16:37:05 +0100 Subject: [PATCH 75/98] Outbox: Fix race condition with Post-Metas (#1214) * Outbox: Fix race condition with Post-Metas * Update includes/scheduler/class-post.php Co-authored-by: Konstantin Obenland --------- Co-authored-by: Konstantin Obenland --- includes/scheduler/class-post.php | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index c1736cdbe..ea6808cd1 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -34,6 +34,13 @@ public static function init() { \add_action( 'delete_attachment', array( self::class, 'transition_attachment_status' ) ); \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'send_announces' ), 10, 4 ); + + // Get all post types that support ActivityPub. + $post_types = \get_post_types_by_support( 'activitypub' ); + + foreach ( $post_types as $post_type ) { + \add_filter( "rest_pre_insert_{$post_type}", array( self::class, 'rest_insert' ), 10, 2 ); + } } /** @@ -149,4 +156,35 @@ public static function send_announces( $outbox_activity_id, $activity_object, $a // Schedule the outbox item for federation. Scheduler::schedule_outbox_activity_for_federation( $outbox_activity_id ); } + + /** + * Filter the post data before it is inserted via the REST API. + * + * @param \stdClass $post An object representing a single post prepared for inserting or updating the database. + * @param \WP_REST_Request $request The request object. + * + * @return \stdClass The prepared post. + */ + public static function rest_insert( $post, $request ) { + $metas = $request->get_param( 'meta' ); + + if ( ! $post->ID || ! $metas || ! is_array( $metas ) ) { + return $post; + } + + foreach ( $metas as $meta_key => $meta_value ) { + if ( + \str_starts_with( $meta_key, 'activitypub_' ) || + \str_starts_with( $meta_key, '_activitypub_' ) + ) { + if ( $meta_value ) { + \update_post_meta( $post->ID, $meta_key, $meta_value ); + } else { + \delete_post_meta( $post->ID, $meta_key ); + } + } + } + + return $post; + } } From 03ae03e13c59cfbec553b4dfe71a57c7602ea65b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 24 Jan 2025 18:57:03 +0100 Subject: [PATCH 76/98] Outbox: Generalize functionality of the transformers (#1178) * Generalize functionality of the transformers To better support * extraction of mentions for arbitrary data * generating the audience based on the content visibility * ... * use `cc` or `to` if set and fall back to public * revert renaming * oops! * keep mentions class * revert test class * No need to transform it again! * unify content visibility * fix check * no need to pass the visibility * overwrite post visibility * fix tests * fix most of the issues * fix test * ensure that actor is set before sending the Activity! * fix last tests * Fix PHPCS * add ID * add a more descriptive title * make function global * fix issue if no URI is provided * fix race condition * fix spaces * revert race-condition fix See: https://github.com/Automattic/wordpress-activitypub/pull/1214 * revert title * fix unittests * fix content visibility for comments * Update includes/transformer/class-base.php Co-authored-by: Konstantin Obenland --------- Co-authored-by: Konstantin Obenland --- includes/activity/class-base-object.php | 15 +- includes/class-dispatcher.php | 19 +- includes/collection/class-actors.php | 8 + includes/functions.php | 10 +- includes/scheduler/class-post.php | 5 +- .../transformer/class-activity-object.php | 39 +- includes/transformer/class-base.php | 152 ++- includes/transformer/class-comment.php | 89 +- includes/transformer/class-json.php | 11 - includes/transformer/class-post.php | 1142 ++++++++--------- includes/transformer/class-user.php | 34 - tests/includes/class-test-activitypub.php | 6 +- tests/includes/class-test-comment.php | 7 +- tests/includes/class-test-hashtag.php | 1 + tests/includes/class-test-migration.php | 6 +- .../includes/collection/class-test-outbox.php | 4 +- .../class-test-activity-object.php | 3 +- .../includes/transformer/class-test-post.php | 4 +- 18 files changed, 810 insertions(+), 745 deletions(-) diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index a75647ff2..0ec0799e8 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -483,7 +483,7 @@ public function __call( $method, $params ) { } if ( \strncasecmp( $method, 'add', 3 ) === 0 ) { - $this->add( $var, $params[0] ); + return $this->add( $var, $params[0] ); } } @@ -566,8 +566,17 @@ public function add( $key, $value ) { $this->$key = array(); } - $attributes = $this->$key; - $attributes[] = $value; + if ( is_string( $this->$key ) ) { + $this->$key = array( $this->$key ); + } + + $attributes = $this->$key; + + if ( is_array( $value ) ) { + $attributes = array_merge( $attributes, $value ); + } else { + $attributes[] = $value; + } $this->$key = $attributes; diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index 9ee12df0f..95ca26c66 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -61,9 +61,22 @@ public static function process_outbox( $id ) { break; } - $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ); - $transformer = Transformer_Factory::get_transformer( $outbox_item->post_content ); - $activity = $transformer->to_activity( $type ); + $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ); + $activity = new Activity(); + $activity->set_type( $type ); + $activity->set_id( $outbox_item->guid ); + // Pre-fill the Activity with data (for example cc and to). + $activity->from_json( $outbox_item->post_content ); + + // If the activity doesn't have an actor, set the actor to the post author. + if ( ! $activity->get_actor() ) { + $activity->set_actor( Actors::get_by_id( $outbox_item->post_author )->get_id() ); + } + + // Use simple Object (only ID-URI) for Like and Announce. + if ( 'Like' === $type ) { + $activity->set_object( $activity->get_object()->get_id() ); + } self::send_activity_to_followers( $activity, $actor_id, $outbox_item ); } diff --git a/includes/collection/class-actors.php b/includes/collection/class-actors.php index 88ad59462..44f80af01 100644 --- a/includes/collection/class-actors.php +++ b/includes/collection/class-actors.php @@ -147,6 +147,14 @@ public static function get_by_username( $username ) { public static function get_by_resource( $uri ) { $uri = object_to_uri( $uri ); + if ( ! $uri ) { + return new WP_Error( + 'activitypub_no_uri', + \__( 'No URI provided', 'activitypub' ), + array( 'status' => 404 ) + ); + } + $scheme = 'acct'; $match = array(); // Try to extract the scheme and the host. diff --git a/includes/functions.php b/includes/functions.php index 054750216..90110e5f7 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1420,7 +1420,7 @@ function get_content_visibility( $post_id ) { return false; } - $visibility = get_post_meta( $post->ID, 'activitypub_content_visibility', true ); + $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; $options = array( ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, @@ -1561,13 +1561,19 @@ function is_self_ping( $id ) { * * @return boolean|int The ID of the outbox item or false on failure. */ -function add_to_outbox( $data, $activity_type = 'Create', $user_id = 0, $content_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { +function add_to_outbox( $data, $activity_type = 'Create', $user_id = 0, $content_visibility = null ) { $transformer = Transformer_Factory::get_transformer( $data ); if ( ! $transformer || is_wp_error( $transformer ) ) { return false; } + if ( $content_visibility ) { + $transformer->set_content_visibility( $content_visibility ); + } else { + $content_visibility = $transformer->get_content_visibility(); + } + $activity_object = $transformer->to_object(); if ( ! $activity_object || \is_wp_error( $activity_object ) ) { diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index ea6808cd1..1a419d9c7 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -101,11 +101,8 @@ public static function schedule_post_activity( $new_status, $old_status, $post ) return; } - // Get the content visibility. - $content_visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); - // Add the post to the outbox. - add_to_outbox( $post, $type, $post->post_author, $content_visibility ); + add_to_outbox( $post, $type, $post->post_author ); } /** diff --git a/includes/transformer/class-activity-object.php b/includes/transformer/class-activity-object.php index 23e7cf0ad..efd87b2c7 100644 --- a/includes/transformer/class-activity-object.php +++ b/includes/transformer/class-activity-object.php @@ -17,7 +17,24 @@ class Activity_Object extends Base { * @return Base_Object The ActivityPub Object. */ public function to_object() { - return $this->transform_object_properties( $this->item ); + $activity_object = $this->transform_object_properties( $this->item ); + + if ( \is_wp_error( $activity_object ) ) { + return $activity_object; + } + + $activity_object = $this->set_audience( $activity_object ); + + return $activity_object; + } + + /** + * Get the attributed to. + * + * @return string The attributed to. + */ + public function get_attributed_to() { + return $this->item->get_attributed_to(); } /** @@ -43,26 +60,6 @@ protected function get_mentions() { ); } - /** - * Returns a list of Mentions, used in the Post. - * - * @see https://docs.joinmastodon.org/spec/activitypub/#Mention - * - * @return array The list of Mentions. - */ - protected function get_cc() { - $cc = array(); - $mentions = $this->get_mentions(); - - if ( $mentions ) { - foreach ( $mentions as $url ) { - $cc[] = $url; - } - } - - return $cc; - } - /** * Returns the content map for the post. * diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index c4ae115d8..4871b1eb3 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -11,6 +11,7 @@ use WP_Comment; use Activitypub\Activity\Activity; +use Activitypub\Collection\Actors; use Activitypub\Collection\Replies; use Activitypub\Activity\Base_Object; @@ -39,6 +40,13 @@ abstract class Base { */ protected $wp_object; + /** + * The content visibility. + * + * @var string + */ + protected $content_visibility; + /** * Static function to Transform a WordPress Object. * @@ -106,6 +114,7 @@ protected function transform_object_properties( $activity_object ) { } } } + return $activity_object; } @@ -116,8 +125,74 @@ protected function transform_object_properties( $activity_object ) { */ public function to_object() { $activity_object = new Base_Object(); + $activity_object = $this->transform_object_properties( $activity_object ); + + if ( \is_wp_error( $activity_object ) ) { + return $activity_object; + } + + $activity_object = $this->set_audience( $activity_object ); - return $this->transform_object_properties( $activity_object ); + return $activity_object; + } + + /** + * Get the content visibility. + * + * @return string The content visibility. + */ + public function get_content_visibility() { + if ( ! $this->content_visibility ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; + } + + return $this->content_visibility; + } + + /** + * Set the content visibility. + * + * @param string $content_visibility The content visibility. + */ + public function set_content_visibility( $content_visibility ) { + $this->content_visibility = $content_visibility; + + return $this; + } + + /** + * Set the audience. + * + * @param Base_Object $activity_object The ActivityPub Object. + * + * @return Base_Object The ActivityPub Object. + */ + protected function set_audience( $activity_object ) { + $public = 'https://www.w3.org/ns/activitystreams#Public'; + $actor = Actors::get_by_resource( $this->get_attributed_to() ); + if ( ! $actor || is_wp_error( $actor ) ) { + $followers = array(); + } else { + $followers = $actor->get_followers(); + } + $mentions = array_values( $this->get_mentions() ); + + switch ( $this->get_content_visibility() ) { + case ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC: + $activity_object->add_to( $public ); + $activity_object->add_cc( $followers ); + $activity_object->add_cc( $mentions ); + break; + case ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC: + $activity_object->add_to( $followers ); + $activity_object->add_to( $mentions ); + $activity_object->add_cc( $public ); + break; + case ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE: + $activity_object->add_to( $mentions ); + } + + return $activity_object; } /** @@ -181,19 +256,6 @@ public function get_media_type() { return 'text/html'; } - /** - * Returns the recipient of the post. - * - * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to - * - * @return array The recipient URLs of the post. - */ - protected function get_to() { - return array( - 'https://www.w3.org/ns/activitystreams#Public', - ); - } - /** * Get the replies Collection. * @@ -243,4 +305,66 @@ protected function get_summary_map() { $this->get_locale() => $this->get_summary(), ); } + + /** + * Returns the tags for the post. + * + * @return array The tags for the post. + */ + protected function get_tag() { + $tags = array(); + $mentions = $this->get_mentions(); + + foreach ( $mentions as $mention => $url ) { + $tags[] = array( + 'type' => 'Mention', + 'href' => \esc_url( $url ), + 'name' => \esc_html( $mention ), + ); + } + + return \array_unique( $tags, SORT_REGULAR ); + } + + /** + * Get the attributed to. + * + * @return string The attributed to. + */ + protected function get_attributed_to() { + return null; + } + + /** + * Extracts mentions from the content. + * + * @return array The mentions. + */ + protected function get_mentions() { + $content = ''; + + if ( method_exists( $this, 'get_content' ) ) { + $content = $content . ' ' . $this->get_content(); + } + + if ( method_exists( $this, 'get_summary' ) ) { + $content = $content . ' ' . $this->get_summary(); + } + + /** + * Filter the mentions in the post content. + * + * @param array $mentions The mentions. + * @param string $content The post content. + * @param WP_Post $post The post object. + * + * @return array The filtered mentions. + */ + return apply_filters( + 'activitypub_extract_mentions', + array(), + $content, + $this->item + ); + } } diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index 36c4856f4..d1ac79a74 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -66,6 +66,34 @@ public function to_object() { return $object; } + /** + * Get the content visibility. + * + * @return string The content visibility. + */ + public function get_content_visibility() { + if ( $this->content_visibility ) { + return $this->content_visibility; + } + + $comment = $this->item; + $post = \get_post( $comment->comment_post_ID ); + + if ( ! $post ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; + } + + $content_visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); + + if ( ! $content_visibility ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; + } + + $this->content_visibility = $content_visibility; + + return $this->content_visibility; + } + /** * Returns the User-URL of the Author of the Post. * @@ -194,53 +222,6 @@ protected function get_actor_object() { return $blog_user; } - /** - * Returns a list of Mentions, used in the Comment. - * - * @see https://docs.joinmastodon.org/spec/activitypub/#Mention - * - * @return array The list of Mentions. - */ - protected function get_cc() { - $cc = array( - $this->get_actor_object()->get_followers(), - ); - - $mentions = $this->get_mentions(); - if ( $mentions ) { - foreach ( $mentions as $url ) { - $cc[] = $url; - } - } - - return array_unique( $cc ); - } - - /** - * Returns a list of Tags, used in the Comment. - * - * This includes Hash-Tags and Mentions. - * - * @return array The list of Tags. - */ - protected function get_tag() { - $tags = array(); - - $mentions = $this->get_mentions(); - if ( $mentions ) { - foreach ( $mentions as $mention => $url ) { - $tag = array( - 'type' => 'Mention', - 'href' => \esc_url( $url ), - 'name' => \esc_html( $mention ), - ); - $tags[] = $tag; - } - } - - return \array_unique( $tags, SORT_REGULAR ); - } - /** * Helper function to get the @-Mentions from the comment content. * @@ -353,18 +334,4 @@ public function get_url() { public function get_type() { return 'Note'; } - - /** - * Returns the to of the comment. - * - * @return array The to of the comment. - */ - public function get_to() { - $path = sprintf( 'actors/%d/followers', intval( $this->item->comment_author ) ); - - return array( - 'https://www.w3.org/ns/activitystreams#Public', - get_rest_url_by_path( $path ), - ); - } } diff --git a/includes/transformer/class-json.php b/includes/transformer/class-json.php index 92974d5af..6b7c0288e 100644 --- a/includes/transformer/class-json.php +++ b/includes/transformer/class-json.php @@ -30,15 +30,4 @@ public function __construct( $item ) { parent::__construct( $object ); } - - /** - * Returns the public secondary audience of this object - * - * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-cc - * - * @return array The secondary audience of this object. - */ - protected function get_cc() { - return $this->item->get( 'cc' ); - } } diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 0a7fdb106..18de7d6fe 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -58,20 +58,20 @@ public function to_object() { $object->set_summary_map( null ); } - $visibility = get_content_visibility( $post ); + return $object; + } - switch ( $visibility ) { - case ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC: - $object->set_to( $this->get_cc() ); - $object->set_cc( $this->get_to() ); - break; - case ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL: - $object->set_to( array() ); - $object->set_cc( array() ); - break; + /** + * Get the content visibility. + * + * @return string The content visibility. + */ + public function get_content_visibility() { + if ( ! $this->content_visibility ) { + return get_content_visibility( $this->item ); } - return $object; + return $this->content_visibility; } /** @@ -344,219 +344,508 @@ protected function get_attachment() { } /** - * Get enclosures for a post. + * Returns the ActivityStreams 2.0 Object-Type for a Post based on the + * settings and the Post-Type. * - * @param array $media The media array grouped by type. + * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types * - * @return array The media array extended with enclosures. + * @return string The Object-Type. */ - protected function get_enclosures( $media ) { - $enclosures = get_enclosures( $this->item->ID ); + protected function get_type() { + $post_format_setting = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ); - if ( ! $enclosures ) { - return $media; + if ( 'wordpress-post-format' !== $post_format_setting ) { + return \ucfirst( $post_format_setting ); } - foreach ( $enclosures as $enclosure ) { - // Check if URL is an attachment. - $attachment_id = \attachment_url_to_postid( $enclosure['url'] ); + $has_title = \post_type_supports( $this->item->post_type, 'title' ); + $content = \wp_strip_all_tags( $this->item->post_content ); - if ( $attachment_id ) { - $enclosure['id'] = $attachment_id; - $enclosure['url'] = \wp_get_attachment_url( $attachment_id ); - $enclosure['mediaType'] = \get_post_mime_type( $attachment_id ); - } + // Check if the post has a title. + if ( + ! $has_title || + ! $this->item->post_title || + \strlen( $content ) <= ACTIVITYPUB_NOTE_LENGTH + ) { + return 'Note'; + } - $mime_type = $enclosure['mediaType']; - $mime_type_parts = \explode( '/', $mime_type ); - $enclosure['type'] = \ucfirst( $mime_type_parts[0] ); + // Default to Note. + $object_type = 'Note'; + $post_type = \get_post_type( $this->item ); - switch ( $mime_type_parts[0] ) { - case 'image': - $media['image'][] = $enclosure; - break; - case 'audio': - $media['audio'][] = $enclosure; - break; - case 'video': - $media['video'][] = $enclosure; - break; - } + if ( 'page' === $post_type ) { + $object_type = 'Page'; + } elseif ( ! \get_post_format( $this->item ) ) { + $object_type = 'Article'; } - return $media; + return $object_type; } /** - * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments. - * - * @param array $media The media array grouped by type. - * @param int $max_media The maximum number of attachments to return. + * Returns the Audience for the Post. * - * @return array The attachments. + * @return string|null The audience. */ - protected function get_block_attachments( $media, $max_media ) { - // Max media can't be negative or zero. - if ( $max_media <= 0 ) { - return array(); - } + public function get_audience() { + $actor_mode = \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ); - $blocks = \parse_blocks( $this->item->post_content ); + if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE === $actor_mode ) { + $blog = new Blog(); + return $blog->get_id(); + } - return $this->get_media_from_blocks( $blocks, $media ); + return null; } /** - * Recursively get media IDs from blocks. + * Returns a list of Tags, used in the Post. * - * @param array $blocks The blocks to search for media IDs. - * @param array $media The media IDs to append new IDs to. + * This includes Hash-Tags and Mentions. * - * @return array The image IDs. + * @return array The list of Tags. */ - protected function get_media_from_blocks( $blocks, $media ) { - foreach ( $blocks as $block ) { - // Recurse into inner blocks. - if ( ! empty( $block['innerBlocks'] ) ) { - $media = $this->get_media_from_blocks( $block['innerBlocks'], $media ); - } - - switch ( $block['blockName'] ) { - case 'core/image': - case 'core/cover': - if ( ! empty( $block['attrs']['id'] ) ) { - $alt = ''; - $check = preg_match( '//i', $block['innerHTML'], $match ); - - if ( $check ) { - $alt = $match[2]; - } - - $found = false; - foreach ( $media['image'] as $i => $image ) { - if ( $image['id'] === $block['attrs']['id'] ) { - $media['image'][ $i ]['alt'] = $alt; - $found = true; - break; - } - } + protected function get_tag() { + $tags = parent::get_tag(); - if ( ! $found ) { - $media['image'][] = array( - 'id' => $block['attrs']['id'], - 'alt' => $alt, - ); - } - } - break; - case 'core/audio': - if ( ! empty( $block['attrs']['id'] ) ) { - $media['audio'][] = array( 'id' => $block['attrs']['id'] ); - } - break; - case 'core/video': - case 'videopress/video': - if ( ! empty( $block['attrs']['id'] ) ) { - $media['video'][] = array( 'id' => $block['attrs']['id'] ); - } - break; - case 'jetpack/slideshow': - case 'jetpack/tiled-gallery': - if ( ! empty( $block['attrs']['ids'] ) ) { - $media['image'] = array_merge( - $media['image'], - array_map( - function ( $id ) { - return array( 'id' => $id ); - }, - $block['attrs']['ids'] - ) - ); - } - break; - case 'jetpack/image-compare': - if ( ! empty( $block['attrs']['beforeImageId'] ) ) { - $media['image'][] = array( 'id' => $block['attrs']['beforeImageId'] ); - } - if ( ! empty( $block['attrs']['afterImageId'] ) ) { - $media['image'][] = array( 'id' => $block['attrs']['afterImageId'] ); - } - break; + $post_tags = \get_the_tags( $this->item->ID ); + if ( $post_tags ) { + foreach ( $post_tags as $post_tag ) { + $tag = array( + 'type' => 'Hashtag', + 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ), + 'name' => esc_hashtag( $post_tag->name ), + ); + $tags[] = $tag; } } - return $media; + return \array_unique( $tags, SORT_REGULAR ); } /** - * Get post images from the classic editor. - * Note that audio/video attachments are only supported in the block editor. + * Returns the summary for the ActivityPub Item. * - * @param array $media The media array grouped by type. - * @param int $max_images The maximum number of images to return. + * The summary will be generated based on the user settings and only if the + * object type is not set to `note`. * - * @return array The attachments. + * @return string|null The summary or null if the object type is `note`. */ - protected function get_classic_editor_images( $media, $max_images ) { - // Max images can't be negative or zero. - if ( $max_images <= 0 ) { - return array(); + protected function get_summary() { + if ( 'Note' === $this->get_type() ) { + return null; } - if ( \count( $media['image'] ) <= $max_images ) { - if ( \class_exists( '\WP_HTML_Tag_Processor' ) ) { - $media['image'] = \array_merge( $media['image'], $this->get_classic_editor_image_embeds( $max_images ) ); - } else { - $media['image'] = \array_merge( $media['image'], $this->get_classic_editor_image_attachments( $max_images ) ); - } + // Remove Teaser from drafts. + if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { + return \__( '(This post is being modified)', 'activitypub' ); } - return $media; + return generate_post_summary( $this->item ); } /** - * Get image embeds from the classic editor by parsing HTML. + * Returns the title for the ActivityPub Item. * - * @param int $max_images The maximum number of images to return. + * The title will be generated based on the user settings and only if the + * object type is not set to `note`. * - * @return array The attachments. + * @return string|null The title or null if the object type is `note`. */ - protected function get_classic_editor_image_embeds( $max_images ) { - // If someone calls that function directly, bail. - if ( ! \class_exists( '\WP_HTML_Tag_Processor' ) ) { - return array(); + protected function get_name() { + if ( 'Note' === $this->get_type() ) { + return null; } - // Max images can't be negative or zero. - if ( $max_images <= 0 ) { - return array(); + $title = \get_the_title( $this->item->ID ); + + if ( ! $title ) { + return null; } - $images = array(); - $base = get_upload_baseurl(); - $content = \get_post_field( 'post_content', $this->item ); - $tags = new \WP_HTML_Tag_Processor( $content ); + return \wp_strip_all_tags( + \html_entity_decode( + $title + ) + ); + } - // This linter warning is a false positive - we have to re-count each time here as we modify $images. - // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found - while ( $tags->next_tag( 'img' ) && ( \count( $images ) <= $max_images ) ) { - /** - * Filter the image source URL. - * - * This can be used to modify the image source URL before it is used to - * determine the attachment ID. - * - * @param string $src The image source URL. - */ - $src = \apply_filters( 'activitypub_image_src', $tags->get_attribute( 'src' ) ); + /** + * Returns the content for the ActivityPub Item. + * + * The content will be generated based on the user settings. + * + * @return string The content. + */ + protected function get_content() { + add_filter( 'activitypub_reply_block', '__return_empty_string' ); - /* - * If the img source is in our uploads dir, get the - * associated ID. Note: if there's a -500x500 - * type suffix, we remove it, but we try the original - * first in case the original image is actually called - * that. Likewise, we try adding the -scaled suffix for - * the case that this is a small version of an image + // Remove Content from drafts. + if ( 'draft' === \get_post_status( $this->item ) ) { + return \__( '(This post is being modified)', 'activitypub' ); + } + + global $post; + + /** + * Provides an action hook so plugins can add their own hooks/filters before AP content is generated. + * + * Example: if a plugin adds a filter to `the_content` to add a button to the end of posts, it can also remove that filter here. + * + * @param WP_Post $post The post object. + */ + do_action( 'activitypub_before_get_content', $post ); + + add_filter( 'render_block_core/embed', array( $this, 'revert_embed_links' ), 10, 2 ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post = $this->item; + $content = $this->get_post_content_template(); + + // It seems that shortcodes are only applied to published posts. + if ( is_preview() ) { + $post->post_status = 'publish'; + } + + // Register our shortcodes just in time. + Shortcodes::register(); + // Fill in the shortcodes. + \setup_postdata( $post ); + $content = \do_shortcode( $content ); + \wp_reset_postdata(); + + $content = \wpautop( $content ); + $content = \preg_replace( '/[\n\r\t]/', '', $content ); + $content = \trim( $content ); + + /** + * Filters the post content before it is transformed for ActivityPub. + * + * @param string $content The post content to be transformed. + * @param WP_Post $post The post object being transformed. + */ + $content = \apply_filters( 'activitypub_the_content', $content, $post ); + + // Don't need these anymore, should never appear in a post. + Shortcodes::unregister(); + + return $content; + } + + /** + * Returns the in-reply-to URL of the post. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto + * + * @return string|null The in-reply-to URL of the post. + */ + protected function get_in_reply_to() { + $blocks = \parse_blocks( $this->item->post_content ); + + foreach ( $blocks as $block ) { + if ( 'activitypub/reply' === $block['blockName'] && isset( $block['attrs']['url'] ) ) { + // We only support one reply block per post for now. + return $block['attrs']['url']; + } + } + + return null; + } + + /** + * Returns the published date of the post. + * + * @return string The published date of the post. + */ + protected function get_published() { + $published = \strtotime( $this->item->post_date_gmt ); + + return \gmdate( 'Y-m-d\TH:i:s\Z', $published ); + } + + /** + * Returns the updated date of the post. + * + * @return string|null The updated date of the post. + */ + protected function get_updated() { + $published = \strtotime( $this->item->post_date_gmt ); + $updated = \strtotime( $this->item->post_modified_gmt ); + + if ( $updated > $published ) { + return \gmdate( 'Y-m-d\TH:i:s\Z', $updated ); + } + + return null; + } + + /** + * Helper function to extract the @-Mentions from the post content. + * + * @return array The list of @-Mentions. + */ + protected function get_mentions() { + /** + * Filter the mentions in the post content. + * + * @param array $mentions The mentions. + * @param string $content The post content. + * @param WP_Post $post The post object. + * + * @return array The filtered mentions. + */ + return apply_filters( + 'activitypub_extract_mentions', + array(), + $this->item->post_content . ' ' . $this->item->post_excerpt, + $this->item + ); + } + + /** + * Transform Embed blocks to block level link. + * + * Remote servers will simply drop iframe elements, rendering incomplete content. + * + * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content + * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML + * + * @param string $block_content The block content (html). + * @param object $block The block object. + * + * @return string A block level link + */ + public function revert_embed_links( $block_content, $block ) { + if ( ! isset( $block['attrs']['url'] ) ) { + return $block_content; + } + return '

' . $block['attrs']['url'] . '

'; + } + + /** + * Check if the post is a preview. + * + * @return boolean True if the post is a preview, false otherwise. + */ + private function is_preview() { + return defined( 'ACTIVITYPUB_PREVIEW' ) && ACTIVITYPUB_PREVIEW; + } + + /** + * Get enclosures for a post. + * + * @param array $media The media array grouped by type. + * + * @return array The media array extended with enclosures. + */ + protected function get_enclosures( $media ) { + $enclosures = get_enclosures( $this->item->ID ); + + if ( ! $enclosures ) { + return $media; + } + + foreach ( $enclosures as $enclosure ) { + // Check if URL is an attachment. + $attachment_id = \attachment_url_to_postid( $enclosure['url'] ); + + if ( $attachment_id ) { + $enclosure['id'] = $attachment_id; + $enclosure['url'] = \wp_get_attachment_url( $attachment_id ); + $enclosure['mediaType'] = \get_post_mime_type( $attachment_id ); + } + + $mime_type = $enclosure['mediaType']; + $mime_type_parts = \explode( '/', $mime_type ); + $enclosure['type'] = \ucfirst( $mime_type_parts[0] ); + + switch ( $mime_type_parts[0] ) { + case 'image': + $media['image'][] = $enclosure; + break; + case 'audio': + $media['audio'][] = $enclosure; + break; + case 'video': + $media['video'][] = $enclosure; + break; + } + } + + return $media; + } + + /** + * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments. + * + * @param array $media The media array grouped by type. + * @param int $max_media The maximum number of attachments to return. + * + * @return array The attachments. + */ + protected function get_block_attachments( $media, $max_media ) { + // Max media can't be negative or zero. + if ( $max_media <= 0 ) { + return array(); + } + + $blocks = \parse_blocks( $this->item->post_content ); + + return $this->get_media_from_blocks( $blocks, $media ); + } + + /** + * Recursively get media IDs from blocks. + * + * @param array $blocks The blocks to search for media IDs. + * @param array $media The media IDs to append new IDs to. + * + * @return array The image IDs. + */ + protected function get_media_from_blocks( $blocks, $media ) { + foreach ( $blocks as $block ) { + // Recurse into inner blocks. + if ( ! empty( $block['innerBlocks'] ) ) { + $media = $this->get_media_from_blocks( $block['innerBlocks'], $media ); + } + + switch ( $block['blockName'] ) { + case 'core/image': + case 'core/cover': + if ( ! empty( $block['attrs']['id'] ) ) { + $alt = ''; + $check = preg_match( '//i', $block['innerHTML'], $match ); + + if ( $check ) { + $alt = $match[2]; + } + + $found = false; + foreach ( $media['image'] as $i => $image ) { + if ( $image['id'] === $block['attrs']['id'] ) { + $media['image'][ $i ]['alt'] = $alt; + $found = true; + break; + } + } + + if ( ! $found ) { + $media['image'][] = array( + 'id' => $block['attrs']['id'], + 'alt' => $alt, + ); + } + } + break; + case 'core/audio': + if ( ! empty( $block['attrs']['id'] ) ) { + $media['audio'][] = array( 'id' => $block['attrs']['id'] ); + } + break; + case 'core/video': + case 'videopress/video': + if ( ! empty( $block['attrs']['id'] ) ) { + $media['video'][] = array( 'id' => $block['attrs']['id'] ); + } + break; + case 'jetpack/slideshow': + case 'jetpack/tiled-gallery': + if ( ! empty( $block['attrs']['ids'] ) ) { + $media['image'] = array_merge( + $media['image'], + array_map( + function ( $id ) { + return array( 'id' => $id ); + }, + $block['attrs']['ids'] + ) + ); + } + break; + case 'jetpack/image-compare': + if ( ! empty( $block['attrs']['beforeImageId'] ) ) { + $media['image'][] = array( 'id' => $block['attrs']['beforeImageId'] ); + } + if ( ! empty( $block['attrs']['afterImageId'] ) ) { + $media['image'][] = array( 'id' => $block['attrs']['afterImageId'] ); + } + break; + } + } + + return $media; + } + + /** + * Get post images from the classic editor. + * Note that audio/video attachments are only supported in the block editor. + * + * @param array $media The media array grouped by type. + * @param int $max_images The maximum number of images to return. + * + * @return array The attachments. + */ + protected function get_classic_editor_images( $media, $max_images ) { + // Max images can't be negative or zero. + if ( $max_images <= 0 ) { + return array(); + } + + if ( \count( $media['image'] ) <= $max_images ) { + if ( \class_exists( '\WP_HTML_Tag_Processor' ) ) { + $media['image'] = \array_merge( $media['image'], $this->get_classic_editor_image_embeds( $max_images ) ); + } else { + $media['image'] = \array_merge( $media['image'], $this->get_classic_editor_image_attachments( $max_images ) ); + } + } + + return $media; + } + + /** + * Get image embeds from the classic editor by parsing HTML. + * + * @param int $max_images The maximum number of images to return. + * + * @return array The attachments. + */ + protected function get_classic_editor_image_embeds( $max_images ) { + // If someone calls that function directly, bail. + if ( ! \class_exists( '\WP_HTML_Tag_Processor' ) ) { + return array(); + } + + // Max images can't be negative or zero. + if ( $max_images <= 0 ) { + return array(); + } + + $images = array(); + $base = get_upload_baseurl(); + $content = \get_post_field( 'post_content', $this->item ); + $tags = new \WP_HTML_Tag_Processor( $content ); + + // This linter warning is a false positive - we have to re-count each time here as we modify $images. + // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found + while ( $tags->next_tag( 'img' ) && ( \count( $images ) <= $max_images ) ) { + /** + * Filter the image source URL. + * + * This can be used to modify the image source URL before it is used to + * determine the attachment ID. + * + * @param string $src The image source URL. + */ + $src = \apply_filters( 'activitypub_image_src', $tags->get_attribute( 'src' ) ); + + /* + * If the img source is in our uploads dir, get the + * associated ID. Note: if there's a -500x500 + * type suffix, we remove it, but we try the original + * first in case the original image is actually called + * that. Likewise, we try adding the -scaled suffix for + * the case that this is a small version of an image * that was big enough to get scaled down on upload: * https://make.wordpress.org/core/2019/10/09/introducing-handling-of-big-images-in-wordpress-5-3/ */ @@ -651,345 +940,129 @@ protected function filter_media_by_object_type( $media, $type, $item ) { */ $type = \apply_filters( 'filter_media_by_object_type', \strtolower( $type ), $item ); - if ( ! empty( $media[ $type ] ) ) { - return $media[ $type ]; - } - - return array_filter( array_merge( ...array_values( $media ) ) ); - } - - /** - * Converts a WordPress Attachment to an ActivityPub Attachment. - * - * @param array $media The Attachment array. - * - * @return array The ActivityPub Attachment. - */ - public function wp_attachment_to_activity_attachment( $media ) { - if ( ! isset( $media['id'] ) ) { - return $media; - } - - $id = $media['id']; - $attachment = array(); - $mime_type = \get_post_mime_type( $id ); - $mime_type_parts = \explode( '/', $mime_type ); - // Switching on image/audio/video. - switch ( $mime_type_parts[0] ) { - case 'image': - $image_size = 'large'; - - /** - * Filter the image URL returned for each post. - * - * @param array|false $thumbnail The image URL, or false if no image is available. - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'large' by default. - */ - $thumbnail = apply_filters( - 'activitypub_get_image', - $this->get_wordpress_attachment( $id, $image_size ), - $id, - $image_size - ); - - if ( $thumbnail ) { - $image = array( - 'type' => 'Image', - 'url' => \esc_url( $thumbnail[0] ), - 'mediaType' => \esc_attr( $mime_type ), - ); - - if ( ! empty( $media['alt'] ) ) { - $image['name'] = \wp_strip_all_tags( \html_entity_decode( $media['alt'] ) ); - } else { - $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true ); - if ( $alt ) { - $image['name'] = \wp_strip_all_tags( \html_entity_decode( $alt ) ); - } - } - - $attachment = $image; - } - break; - - case 'audio': - case 'video': - $attachment = array( - 'type' => 'Document', - 'mediaType' => \esc_attr( $mime_type ), - 'url' => \esc_url( \wp_get_attachment_url( $id ) ), - 'name' => \esc_attr( \get_the_title( $id ) ), - ); - $meta = wp_get_attachment_metadata( $id ); - // Height and width for videos. - if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) { - $attachment['width'] = \esc_attr( $meta['width'] ); - $attachment['height'] = \esc_attr( $meta['height'] ); - } - - if ( $this->get_icon() ) { - $attachment['icon'] = object_to_uri( $this->get_icon() ); - } - - break; - } - - /** - * Filter the attachment for a post. - * - * @param array $attachment The attachment. - * @param int $id The attachment ID. - * - * @return array The filtered attachment. - */ - return \apply_filters( 'activitypub_attachment', $attachment, $id ); - } - - /** - * Return details about an image attachment. - * - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'large' by default. - * - * @return array|false Array of image data, or boolean false if no image is available. - */ - protected function get_wordpress_attachment( $id, $image_size = 'large' ) { - /** - * Hook into the image retrieval process. Before image retrieval. - * - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'large' by default. - */ - do_action( 'activitypub_get_image_pre', $id, $image_size ); - - $image = \wp_get_attachment_image_src( $id, $image_size ); - - /** - * Hook into the image retrieval process. After image retrieval. - * - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'large' by default. - */ - do_action( 'activitypub_get_image_post', $id, $image_size ); - - return $image; - } - - /** - * Returns the ActivityStreams 2.0 Object-Type for a Post based on the - * settings and the Post-Type. - * - * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types - * - * @return string The Object-Type. - */ - protected function get_type() { - $post_format_setting = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ); - - if ( 'wordpress-post-format' !== $post_format_setting ) { - return \ucfirst( $post_format_setting ); - } - - $has_title = \post_type_supports( $this->item->post_type, 'title' ); - $content = \wp_strip_all_tags( $this->item->post_content ); - - // Check if the post has a title. - if ( - ! $has_title || - ! $this->item->post_title || - \strlen( $content ) <= ACTIVITYPUB_NOTE_LENGTH - ) { - return 'Note'; - } - - // Default to Note. - $object_type = 'Note'; - $post_type = \get_post_type( $this->item ); - - if ( 'page' === $post_type ) { - $object_type = 'Page'; - } elseif ( ! \get_post_format( $this->item ) ) { - $object_type = 'Article'; - } - - return $object_type; - } - - /** - * Returns a list of Mentions, used in the Post. - * - * @see https://docs.joinmastodon.org/spec/activitypub/#Mention - * - * @return array The list of Mentions. - */ - protected function get_cc() { - $cc = array_values( $this->get_mentions() ); - $cc[] = $this->get_actor_object()->get_followers(); - - return $cc; - } - - /** - * Returns the Audience for the Post. - * - * @return string|null The audience. - */ - public function get_audience() { - $actor_mode = \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ); - - if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE === $actor_mode ) { - $blog = new Blog(); - return $blog->get_id(); - } - - return null; - } - - /** - * Returns a list of Tags, used in the Post. - * - * This includes Hash-Tags and Mentions. - * - * @return array The list of Tags. - */ - protected function get_tag() { - $tags = array(); - - $post_tags = \get_the_tags( $this->item->ID ); - if ( $post_tags ) { - foreach ( $post_tags as $post_tag ) { - $tag = array( - 'type' => 'Hashtag', - 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ), - 'name' => esc_hashtag( $post_tag->name ), - ); - $tags[] = $tag; - } - } - - $mentions = $this->get_mentions(); - if ( $mentions ) { - foreach ( $mentions as $mention => $url ) { - $tag = array( - 'type' => 'Mention', - 'href' => \esc_url( $url ), - 'name' => \esc_html( $mention ), - ); - $tags[] = $tag; - } - } - - return $tags; - } - - /** - * Returns the summary for the ActivityPub Item. - * - * The summary will be generated based on the user settings and only if the - * object type is not set to `note`. - * - * @return string|null The summary or null if the object type is `note`. - */ - protected function get_summary() { - if ( 'Note' === $this->get_type() ) { - return null; - } - - // Remove Teaser from drafts. - if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { - return \__( '(This post is being modified)', 'activitypub' ); + if ( ! empty( $media[ $type ] ) ) { + return $media[ $type ]; } - return generate_post_summary( $this->item ); + return array_filter( array_merge( ...array_values( $media ) ) ); } /** - * Returns the title for the ActivityPub Item. + * Converts a WordPress Attachment to an ActivityPub Attachment. * - * The title will be generated based on the user settings and only if the - * object type is not set to `note`. + * @param array $media The Attachment array. * - * @return string|null The title or null if the object type is `note`. + * @return array The ActivityPub Attachment. */ - protected function get_name() { - if ( 'Note' === $this->get_type() ) { - return null; + public function wp_attachment_to_activity_attachment( $media ) { + if ( ! isset( $media['id'] ) ) { + return $media; } - $title = \get_the_title( $this->item->ID ); + $id = $media['id']; + $attachment = array(); + $mime_type = \get_post_mime_type( $id ); + $mime_type_parts = \explode( '/', $mime_type ); + // Switching on image/audio/video. + switch ( $mime_type_parts[0] ) { + case 'image': + $image_size = 'large'; - if ( ! $title ) { - return null; + /** + * Filter the image URL returned for each post. + * + * @param array|false $thumbnail The image URL, or false if no image is available. + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'large' by default. + */ + $thumbnail = apply_filters( + 'activitypub_get_image', + $this->get_wordpress_attachment( $id, $image_size ), + $id, + $image_size + ); + + if ( $thumbnail ) { + $image = array( + 'type' => 'Image', + 'url' => \esc_url( $thumbnail[0] ), + 'mediaType' => \esc_attr( $mime_type ), + ); + + if ( ! empty( $media['alt'] ) ) { + $image['name'] = \wp_strip_all_tags( \html_entity_decode( $media['alt'] ) ); + } else { + $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true ); + if ( $alt ) { + $image['name'] = \wp_strip_all_tags( \html_entity_decode( $alt ) ); + } + } + + $attachment = $image; + } + break; + + case 'audio': + case 'video': + $attachment = array( + 'type' => 'Document', + 'mediaType' => \esc_attr( $mime_type ), + 'url' => \esc_url( \wp_get_attachment_url( $id ) ), + 'name' => \esc_attr( \get_the_title( $id ) ), + ); + $meta = wp_get_attachment_metadata( $id ); + // Height and width for videos. + if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) { + $attachment['width'] = \esc_attr( $meta['width'] ); + $attachment['height'] = \esc_attr( $meta['height'] ); + } + + if ( $this->get_icon() ) { + $attachment['icon'] = object_to_uri( $this->get_icon() ); + } + + break; } - return \wp_strip_all_tags( - \html_entity_decode( - $title - ) - ); + /** + * Filter the attachment for a post. + * + * @param array $attachment The attachment. + * @param int $id The attachment ID. + * + * @return array The filtered attachment. + */ + return \apply_filters( 'activitypub_attachment', $attachment, $id ); } /** - * Returns the content for the ActivityPub Item. + * Return details about an image attachment. * - * The content will be generated based on the user settings. + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'large' by default. * - * @return string The content. + * @return array|false Array of image data, or boolean false if no image is available. */ - protected function get_content() { - add_filter( 'activitypub_reply_block', '__return_empty_string' ); - - // Remove Content from drafts. - if ( 'draft' === \get_post_status( $this->item ) ) { - return \__( '(This post is being modified)', 'activitypub' ); - } - - global $post; - + protected function get_wordpress_attachment( $id, $image_size = 'large' ) { /** - * Provides an action hook so plugins can add their own hooks/filters before AP content is generated. - * - * Example: if a plugin adds a filter to `the_content` to add a button to the end of posts, it can also remove that filter here. + * Hook into the image retrieval process. Before image retrieval. * - * @param WP_Post $post The post object. + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'large' by default. */ - do_action( 'activitypub_before_get_content', $post ); - - add_filter( 'render_block_core/embed', array( $this, 'revert_embed_links' ), 10, 2 ); - - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $post = $this->item; - $content = $this->get_post_content_template(); - - // It seems that shortcodes are only applied to published posts. - if ( is_preview() ) { - $post->post_status = 'publish'; - } - - // Register our shortcodes just in time. - Shortcodes::register(); - // Fill in the shortcodes. - \setup_postdata( $post ); - $content = \do_shortcode( $content ); - \wp_reset_postdata(); + do_action( 'activitypub_get_image_pre', $id, $image_size ); - $content = \wpautop( $content ); - $content = \preg_replace( '/[\n\r\t]/', '', $content ); - $content = \trim( $content ); + $image = \wp_get_attachment_image_src( $id, $image_size ); /** - * Filters the post content before it is transformed for ActivityPub. + * Hook into the image retrieval process. After image retrieval. * - * @param string $content The post content to be transformed. - * @param WP_Post $post The post object being transformed. + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'large' by default. */ - $content = \apply_filters( 'activitypub_the_content', $content, $post ); - - // Don't need these anymore, should never appear in a post. - Shortcodes::unregister(); + do_action( 'activitypub_get_image_post', $id, $image_size ); - return $content; + return $image; } /** @@ -1026,103 +1099,4 @@ protected function get_post_content_template() { */ return apply_filters( 'activitypub_object_content_template', $template, $this->item ); } - - /** - * Helper function to get the @-Mentions from the post content. - * - * @return array The list of @-Mentions. - */ - protected function get_mentions() { - /** - * Filter the mentions in the post content. - * - * @param array $mentions The mentions. - * @param string $content The post content. - * @param WP_Post $post The post object. - * - * @return array The filtered mentions. - */ - return apply_filters( - 'activitypub_extract_mentions', - array(), - $this->item->post_content . ' ' . $this->item->post_excerpt, - $this->item - ); - } - - /** - * Returns the in-reply-to URL of the post. - * - * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto - * - * @return string|null The in-reply-to URL of the post. - */ - protected function get_in_reply_to() { - $blocks = \parse_blocks( $this->item->post_content ); - - foreach ( $blocks as $block ) { - if ( 'activitypub/reply' === $block['blockName'] && isset( $block['attrs']['url'] ) ) { - // We only support one reply block per post for now. - return $block['attrs']['url']; - } - } - - return null; - } - - /** - * Returns the published date of the post. - * - * @return string The published date of the post. - */ - protected function get_published() { - $published = \strtotime( $this->item->post_date_gmt ); - - return \gmdate( 'Y-m-d\TH:i:s\Z', $published ); - } - - /** - * Returns the updated date of the post. - * - * @return string|null The updated date of the post. - */ - protected function get_updated() { - $published = \strtotime( $this->item->post_date_gmt ); - $updated = \strtotime( $this->item->post_modified_gmt ); - - if ( $updated > $published ) { - return \gmdate( 'Y-m-d\TH:i:s\Z', $updated ); - } - - return null; - } - - /** - * Transform Embed blocks to block level link. - * - * Remote servers will simply drop iframe elements, rendering incomplete content. - * - * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content - * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML - * - * @param string $block_content The block content (html). - * @param object $block The block object. - * - * @return string A block level link - */ - public function revert_embed_links( $block_content, $block ) { - if ( ! isset( $block['attrs']['url'] ) ) { - return $block_content; - } - return '

' . $block['attrs']['url'] . '

'; - } - - /** - * Check if the post is a preview. - * - * @return boolean True if the post is a preview, false otherwise. - */ - private function is_preview() { - return defined( 'ACTIVITYPUB_PREVIEW' ) && ACTIVITYPUB_PREVIEW; - } } diff --git a/includes/transformer/class-user.php b/includes/transformer/class-user.php index 357d9a049..941b07689 100644 --- a/includes/transformer/class-user.php +++ b/includes/transformer/class-user.php @@ -26,38 +26,4 @@ public function to_object() { return $actor; } - - /** - * Get the User ID. - * - * @return int The User ID. - */ - public function get_id() { - // TODO: Will be removed with the new Outbox implementation. - return $this->wp_object->ID; - } - - /** - * Change the User ID. - * - * @param int $user_id The new user ID. - * - * @return User The User Object. - */ - public function change_wp_user_id( $user_id ) { - // TODO: Will be removed with the new Outbox implementation. - $this->wp_object->ID = $user_id; - - return $this; - } - - /** - * Get the WP_User ID. - * - * @return int The WP_User ID. - */ - public function get_wp_user_id() { - // TODO: Will be removed with the new Outbox implementation. - return $this->wp_object->ID; - } } diff --git a/tests/includes/class-test-activitypub.php b/tests/includes/class-test-activitypub.php index 796063a53..3b69ed741 100644 --- a/tests/includes/class-test-activitypub.php +++ b/tests/includes/class-test-activitypub.php @@ -44,7 +44,11 @@ public function test_post_type_support() { */ public function test_preview_template_filter() { // Create a test post. - $post_id = self::factory()->post->create(); + $post_id = self::factory()->post->create( + array( + 'post_author' => 1, + ) + ); $this->go_to( get_permalink( $post_id ) ); // Simulate ActivityPub request and preview mode. diff --git a/tests/includes/class-test-comment.php b/tests/includes/class-test-comment.php index 145034963..4dfd0f64a 100644 --- a/tests/includes/class-test-comment.php +++ b/tests/includes/class-test-comment.php @@ -170,6 +170,7 @@ public function test_pre_comment_approved() { 'post_title' => 'Test Post', 'post_content' => 'This is a test post.', 'post_status' => 'publish', + 'post_author' => 1, ) ); @@ -240,7 +241,11 @@ public function test_pre_comment_approved() { * @covers ::pre_wp_update_comment_count_now */ public function test_pre_wp_update_comment_count_now() { - $post_id = self::factory()->post->create(); + $post_id = self::factory()->post->create( + array( + 'post_author' => 1, + ) + ); // Case 1: $new is null, no approved comments of non-ActivityPub types. $this->assertSame( 0, Comment::pre_wp_update_comment_count_now( null, 0, $post_id ) ); diff --git a/tests/includes/class-test-hashtag.php b/tests/includes/class-test-hashtag.php index dd671fe70..e641cd436 100644 --- a/tests/includes/class-test-hashtag.php +++ b/tests/includes/class-test-hashtag.php @@ -93,6 +93,7 @@ public function test_hashtag_conversion( $content, $excerpt, $expected_tags, $me array( 'post_content' => $content, 'post_excerpt' => $excerpt, + 'post_author' => 1, ) ); diff --git a/tests/includes/class-test-migration.php b/tests/includes/class-test-migration.php index 07d622f90..3182b8af9 100644 --- a/tests/includes/class-test-migration.php +++ b/tests/includes/class-test-migration.php @@ -300,7 +300,11 @@ public function test_update_comment_counts_with_lock() { Comment::register_comment_types(); // Create test comments. - $post_id = $this->factory->post->create(); + $post_id = $this->factory->post->create( + array( + 'post_author' => 1, + ) + ); $comment_id = $this->factory->comment->create( array( 'comment_post_ID' => $post_id, diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index 536ad612f..da46c3117 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -59,7 +59,7 @@ public function activity_object_provider() { ), 'Create', 1, - '{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/1","type":"Note","content":"\u003Cp\u003EThis is a note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is a note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"mediaType":"text\/html","sensitive":false}', + '{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/1","type":"Note","content":"\u003Cp\u003EThis is a note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is a note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":[],"mediaType":"text\/html","sensitive":false}', ), array( array( @@ -70,7 +70,7 @@ public function activity_object_provider() { ), 'Create', 2, - '{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/2","type":"Note","content":"\u003Cp\u003EThis is another note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is another note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"mediaType":"text\/html","sensitive":false}', + '{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/2","type":"Note","content":"\u003Cp\u003EThis is another note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is another note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":[],"mediaType":"text\/html","sensitive":false}', ), ); } diff --git a/tests/includes/transformer/class-test-activity-object.php b/tests/includes/transformer/class-test-activity-object.php index 0a437eca1..a8f8db5d9 100644 --- a/tests/includes/transformer/class-test-activity-object.php +++ b/tests/includes/transformer/class-test-activity-object.php @@ -107,7 +107,8 @@ function () { ); $transformer = new Activity_Object( $this->test_object ); - $cc = $this->get_protected_method( $transformer, 'get_cc' ); + $object = $transformer->to_object(); + $cc = $object->get_cc(); $this->assertIsArray( $cc ); $this->assertCount( 2, $cc ); diff --git a/tests/includes/transformer/class-test-post.php b/tests/includes/transformer/class-test-post.php index 803d86f5c..54242bbc3 100644 --- a/tests/includes/transformer/class-test-post.php +++ b/tests/includes/transformer/class-test-post.php @@ -320,8 +320,8 @@ public function test_content_visibility() { $this->assertTrue( \Activitypub\is_post_disabled( $post_id ) ); $object = Post::transform( get_post( $post_id ) )->to_object(); - $this->assertEquals( array(), $object->get_to() ); - $this->assertEquals( array(), $object->get_cc() ); + $this->assertEmpty( $object->get_to() ); + $this->assertEmpty( $object->get_cc() ); } /** From adeb748a3c17592d36ff3c0e8c397e3af9eb2981 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 27 Jan 2025 08:11:44 -0600 Subject: [PATCH 77/98] Update/testcases (#1215) * Outbox: Add testcase with common * Remove unused post_id * Fix test to actually test something --- tests/bootstrap.php | 1 + tests/class-activitypub-outbox-testcase.php | 77 +++++++++++++++++ tests/includes/scheduler/class-test-actor.php | 75 +---------------- .../includes/scheduler/class-test-comment.php | 83 ++++--------------- tests/includes/scheduler/class-test-post.php | 83 +------------------ 5 files changed, 101 insertions(+), 218 deletions(-) create mode 100644 tests/class-activitypub-outbox-testcase.php diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ec1919333..99d1685c5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -98,6 +98,7 @@ function http_disable_request( $response, $args, $url ) { // Start up the WP testing environment. require $_tests_dir . '/includes/bootstrap.php'; +require __DIR__ . '/class-activitypub-outbox-testcase.php'; require __DIR__ . '/class-activitypub-testcase-cache-http.php'; require __DIR__ . '/class-test-rest-controller-testcase.php'; diff --git a/tests/class-activitypub-outbox-testcase.php b/tests/class-activitypub-outbox-testcase.php new file mode 100644 index 000000000..9f30eb272 --- /dev/null +++ b/tests/class-activitypub-outbox-testcase.php @@ -0,0 +1,77 @@ +user->create( array( 'role' => 'author' ) ); + + // Add activitypub capability to the user. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + + \add_filter( 'pre_schedule_event', '__return_false' ); + } + + /** + * Clean up test resources. + */ + public static function tear_down_after_class() { + \delete_option( 'activitypub_actor_mode' ); + \wp_delete_user( self::$user_id ); + \remove_filter( 'pre_schedule_event', '__return_false' ); + + parent::tear_down_after_class(); + } + + /** + * Tear down. + */ + public function tear_down() { + parent::tear_down(); + + _delete_all_posts(); + } + + /** + * Retrieve the latest Outbox item to compare against. + * + * @param string $title Title of the Outbox item. + * @return int|\WP_Post|null + */ + protected function get_latest_outbox_item( $title = '' ) { + $outbox = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'pending', + 'post_title' => $title, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + return $outbox ? $outbox[0] : null; + } +} diff --git a/tests/includes/scheduler/class-test-actor.php b/tests/includes/scheduler/class-test-actor.php index 48501d0d5..7dd413b48 100644 --- a/tests/includes/scheduler/class-test-actor.php +++ b/tests/includes/scheduler/class-test-actor.php @@ -8,38 +8,24 @@ namespace Activitypub\Tests\Scheduler; use Activitypub\Collection\Actors; -use Activitypub\Collection\Outbox; use Activitypub\Collection\Extra_Fields; -use Activitypub\Scheduler\Actor; /** * Test Post scheduler class. * * @coversDefaultClass \Activitypub\Scheduler\Actor */ -class Test_Actor extends \WP_UnitTestCase { - /** - * User ID for testing. - * - * @var int - */ - protected static $user_id; - - /** - * Post ID for testing. - * - * @var int - */ - protected static $post_id; +class Test_Actor extends \Activitypub\Tests\ActivityPub_Outbox_TestCase { /** * Set up test resources. */ public static function set_up_before_class() { parent::set_up_before_class(); - self::$user_id = self::factory()->user->create( + + self::factory()->user->update_object( + self::$user_id, array( - 'role' => 'author', 'display_name' => 'Test User', 'meta_input' => array( 'activitypub_description' => 'test description', @@ -50,38 +36,6 @@ public static function set_up_before_class() { ), ) ); - - // Add activitypub capability to the user. - \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); - - \add_filter( 'pre_schedule_event', '__return_false' ); - } - - /** - * Clean up test resources. - */ - public static function tear_down_after_class() { - \delete_option( 'activitypub_actor_mode' ); - \wp_delete_user( self::$user_id ); - \remove_filter( 'pre_schedule_event', '__return_false' ); - } - - /** - * Tear down. - */ - public function tear_down() { - $outbox_items = get_posts( - array( - 'post_type' => Outbox::POST_TYPE, - 'posts_per_page' => -1, - 'post_status' => 'any', - 'fields' => 'ids', - ) - ); - - foreach ( $outbox_items as $outbox_item ) { - \wp_delete_post( $outbox_item, true ); - } } /** @@ -217,25 +171,4 @@ public function test_schedule_post_activity_extra_field_blog() { \wp_delete_post( $blog_post_id, true ); \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); } - - /** - * Retrieve the latest Outbox item to compare against. - * - * @param string $title Title of the Outbox item. - * @return int|\WP_Post|null - */ - private function get_latest_outbox_item( $title = '' ) { - $outbox = get_posts( - array( - 'post_type' => Outbox::POST_TYPE, - 'posts_per_page' => 1, - 'post_status' => 'pending', - 'post_title' => $title, - 'orderby' => 'date', - 'order' => 'DESC', - ) - ); - - return $outbox ? $outbox[0] : null; - } } diff --git a/tests/includes/scheduler/class-test-comment.php b/tests/includes/scheduler/class-test-comment.php index f8eb440f9..28cfa82ad 100644 --- a/tests/includes/scheduler/class-test-comment.php +++ b/tests/includes/scheduler/class-test-comment.php @@ -7,62 +7,27 @@ namespace Activitypub\Tests\Scheduler; -use Activitypub\Collection\Outbox; - /** * Test Comment scheduler class. * * @coversDefaultClass \Activitypub\Scheduler\Comment */ -class Test_Comment extends \WP_UnitTestCase { - /** - * User ID for testing. - * - * @var int - */ - protected static $user_id; +class Test_Comment extends \Activitypub\Tests\ActivityPub_Outbox_TestCase { /** * Post ID for testing. * * @var int */ - protected static $post_id; + protected static $comment_post_ID; /** * Set up test resources. */ public static function set_up_before_class() { - self::$user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - self::$post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); - - // Add activitypub capability to the user. - get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + parent::set_up_before_class(); - add_filter( 'pre_schedule_event', '__return_false' ); - } - - /** - * Clean up test resources. - */ - public static function tear_down_after_class() { - wp_delete_post( self::$post_id, true ); - wp_delete_user( self::$user_id ); - - remove_filter( 'pre_schedule_event', '__return_false' ); - - $outbox_items = get_posts( - array( - 'post_type' => Outbox::POST_TYPE, - 'posts_per_page' => -1, - 'post_status' => 'any', - 'fields' => 'ids', - ) - ); - - foreach ( $outbox_items as $outbox_item ) { - \wp_delete_post( $outbox_item, true ); - } + self::$comment_post_ID = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); } /** @@ -71,7 +36,7 @@ public static function tear_down_after_class() { public function test_schedule_comment_activity_on_approval() { $comment_id = self::factory()->comment->create( array( - 'comment_post_ID' => self::$post_id, + 'comment_post_ID' => self::$comment_post_ID, 'user_id' => self::$user_id, 'comment_approved' => 0, ) @@ -92,7 +57,7 @@ public function test_schedule_comment_activity_on_approval() { public function test_schedule_comment_activity_on_insert() { $comment_id = self::factory()->comment->create( array( - 'comment_post_ID' => self::$post_id, + 'comment_post_ID' => self::$comment_post_ID, 'user_id' => self::$user_id, 'comment_approved' => 1, ) @@ -114,20 +79,20 @@ public function no_activity_comment_provider() { return array( 'unapproved_comment' => array( array( - 'comment_post_ID' => self::$post_id, + 'comment_post_ID' => self::$comment_post_ID, 'user_id' => self::$user_id, 'comment_approved' => 0, ), ), 'non_registered_user' => array( array( - 'comment_post_ID' => self::$post_id, + 'comment_post_ID' => self::$comment_post_ID, 'comment_approved' => 1, ), ), 'federation_disabled' => array( array( - 'comment_post_ID' => self::$post_id, + 'comment_post_ID' => self::$comment_post_ID, 'user_id' => self::$user_id, 'comment_approved' => 1, 'comment_meta' => array( @@ -146,33 +111,17 @@ public function no_activity_comment_provider() { * @param array $comment_data Comment data for creating the test comment. */ public function test_no_activity_scheduled( $comment_data ) { + foreach ( array( 'comment_post_ID', 'user_id' ) as $key ) { + if ( isset( $comment_data[ $key ] ) ) { + $comment_data[ $key ] = self::$$key; + } + } + $comment_id = self::factory()->comment->create( $comment_data ); $activitpub_id = \Activitypub\Comment::generate_id( $comment_id ); - $post = $this->get_latest_outbox_item( $activitpub_id ); - $this->assertNotEquals( $activitpub_id, $post->post_title ); + $this->assertNull( $this->get_latest_outbox_item( $activitpub_id ) ); wp_delete_comment( $comment_id, true ); } - - /** - * Retrieve the latest Outbox item to compare against. - * - * @param string $title Title of the Outbox item. - * @return int|\WP_Post|null - */ - private function get_latest_outbox_item( $title = '' ) { - $outbox = get_posts( - array( - 'post_type' => Outbox::POST_TYPE, - 'posts_per_page' => 1, - 'post_status' => 'pending', - 'post_title' => $title, - 'orderby' => 'date', - 'order' => 'DESC', - ) - ); - - return $outbox ? $outbox[0] : null; - } } diff --git a/tests/includes/scheduler/class-test-post.php b/tests/includes/scheduler/class-test-post.php index 764621d25..b814f4ca4 100644 --- a/tests/includes/scheduler/class-test-post.php +++ b/tests/includes/scheduler/class-test-post.php @@ -7,68 +7,12 @@ namespace Activitypub\Tests\Scheduler; -use Activitypub\Collection\Actors; -use Activitypub\Collection\Outbox; -use Activitypub\Collection\Extra_Fields; - /** * Test Post scheduler class. * * @coversDefaultClass \Activitypub\Scheduler\Post */ -class Test_Post extends \WP_UnitTestCase { - /** - * User ID for testing. - * - * @var int - */ - protected static $user_id; - - /** - * Post ID for testing. - * - * @var int - */ - protected static $post_id; - - /** - * Set up test resources. - */ - public static function set_up_before_class() { - parent::set_up_before_class(); - self::$user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - - // Add activitypub capability to the user. - \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); - - \add_filter( 'pre_schedule_event', '__return_false' ); - } - - /** - * Clean up test resources. - */ - public static function tear_down_after_class() { - \wp_delete_user( self::$user_id ); - \remove_filter( 'pre_schedule_event', '__return_false' ); - } - - /** - * Tear down. - */ - public function tear_down() { - $outbox_items = get_posts( - array( - 'post_type' => Outbox::POST_TYPE, - 'posts_per_page' => -1, - 'post_status' => 'any', - 'fields' => 'ids', - ) - ); - - foreach ( $outbox_items as $outbox_item ) { - \wp_delete_post( $outbox_item, true ); - } - } +class Test_Post extends \Activitypub\Tests\ActivityPub_Outbox_TestCase { /** * Test post activity scheduling for regular posts. @@ -77,7 +21,7 @@ public function tear_down() { */ public function test_schedule_post_activity_regular_post() { $post_id = self::factory()->post->create(); - $activitpub_id = \add_query_arg( 'p', $post_id, \trailingslashit( \home_url() ) ); + $activitpub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); $post = $this->get_latest_outbox_item( $activitpub_id ); $this->assertSame( $activitpub_id, $post->post_title ); @@ -117,31 +61,10 @@ public function no_activity_post_provider() { */ public function test_no_activity_scheduled( $args ) { $post_id = self::factory()->post->create( $args ); - $activitpub_id = \add_query_arg( 'p', $post_id, \trailingslashit( \home_url() ) ); + $activitpub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); $this->assertNull( $this->get_latest_outbox_item( $activitpub_id ) ); \wp_delete_post( $post_id, true ); } - - /** - * Retrieve the latest Outbox item to compare against. - * - * @param string $title Title of the Outbox item. - * @return int|\WP_Post|null - */ - private function get_latest_outbox_item( $title = '' ) { - $outbox = get_posts( - array( - 'post_type' => Outbox::POST_TYPE, - 'posts_per_page' => 1, - 'post_status' => 'pending', - 'post_title' => $title, - 'orderby' => 'date', - 'order' => 'DESC', - ) - ); - - return $outbox ? $outbox[0] : null; - } } From 40c953532486f33ad5e9ecf6669204643e201279 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 27 Jan 2025 08:18:38 -0600 Subject: [PATCH 78/98] Outbox: Add upgrade script to populate outbox items (#1175) * Outbox: Add upgrade script to populate outbox items * Don't mess with federation * First pass at tests * Use post fixtures * Split out add_to_outbox method * Split upgrade routines for posts and comments * add unit tests * Account for any post status in tests * Account for publish meaning federated * Skip posts from users who don't have the caps. * Restrict outbox migration by `activitypub_status===federated` meta * lint * `any` post_type props @obenland * do not migrate outbox items. fix tests * Account for an arbitrary number of args to be passed * back to unreleased, add comment * oops * Update function docs and signature * Account for disabled users --------- Co-authored-by: Matt Wiebe --- includes/class-activitypub.php | 22 +- includes/class-migration.php | 158 +++++++++++++++ tests/includes/class-test-migration.php | 256 ++++++++++++++++++++++-- 3 files changed, 410 insertions(+), 26 deletions(-) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index c62a300d9..26bd6af66 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -523,21 +523,23 @@ private static function register_post_types() { register_post_type( Outbox::POST_TYPE, array( - 'labels' => array( + 'labels' => array( 'name' => _x( 'Outbox', 'post_type plural name', 'activitypub' ), 'singular_name' => _x( 'Outbox Item', 'post_type single name', 'activitypub' ), ), - 'capabilities' => array( + 'capabilities' => array( 'create_posts' => false, ), - 'map_meta_cap' => true, - 'public' => true, - 'hierarchical' => true, - 'rewrite' => false, - 'query_var' => false, - 'delete_with_user' => true, - 'can_export' => true, - 'supports' => array(), + 'map_meta_cap' => true, + 'public' => true, + 'hierarchical' => true, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => true, + 'can_export' => true, + 'supports' => array(), + 'exclude_from_search' => true, + 'menu_icon' => 'dashicons-networking', ) ); diff --git a/includes/class-migration.php b/includes/class-migration.php index 0497fda4a..604f690ce 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -9,6 +9,8 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; +use Activitypub\Collection\Outbox; +use Activitypub\Transformer\Factory; /** * ActivityPub Migration Class @@ -21,6 +23,7 @@ class Migration { */ public static function init() { \add_action( 'activitypub_migrate', array( self::class, 'async_migration' ) ); + \add_action( 'activitypub_upgrade', array( self::class, 'async_upgrade' ), 10, 99 ); \add_action( 'activitypub_update_comment_counts', array( self::class, 'update_comment_counts' ), 10, 2 ); self::maybe_migrate(); @@ -170,6 +173,10 @@ public static function maybe_migrate() { if ( \version_compare( $version_from_db, '4.7.3', '<' ) ) { add_action( 'init', 'flush_rewrite_rules', 20 ); } + if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'create_post_outbox_items' ) ); + \wp_schedule_single_event( \time() + 15, 'activitypub_upgrade', array( 'create_comment_outbox_items' ) ); + } /* * Add new update routines above this comment. ^ @@ -207,6 +214,38 @@ public static function async_migration( $version_from_db ) { } } + /** + * Asynchronously runs upgrade routines. + * + * @param callable $callback Callable upgrade routine. Must be a method of this class. + * @params mixed ...$args Optional. Parameters that get passed to the callback. + */ + public static function async_upgrade( $callback ) { + $args = \func_get_args(); + + // Bail if the existing lock is still valid. + if ( self::is_locked() ) { + \wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'activitypub_upgrade', $args ); + return; + } + + self::lock(); + + $callback = array_shift( $args ); // Remove $callback from arguments. + $next = \call_user_func_array( array( self::class, $callback ), $args ); + + self::unlock(); + + if ( ! empty( $next ) ) { + // Schedule the next run, adding the result to the arguments. + \wp_schedule_single_event( + \time() + 30, + 'activitypub_upgrade', + \array_merge( array( $callback ), \array_values( $next ) ) + ); + } + } + /** * Updates the custom template to use shortcodes instead of the deprecated templates. */ @@ -500,6 +539,91 @@ public static function update_comment_counts( $batch_size = 100, $offset = 0 ) { self::unlock(); } + /** + * Create outbox items for posts in batches. + * + * @param int $batch_size Optional. Number of posts to process per batch. Default 50. + * @param int $offset Optional. Number of posts to skip. Default 0. + * @return array|null Array with batch size and offset if there are more posts to process, null otherwise. + */ + public static function create_post_outbox_items( $batch_size = 50, $offset = 0 ) { + $posts = \get_posts( + array( + // our own `ap_outbox` will be excluded from `any` by virtue of its `exclude_from_search` arg. + 'post_type' => 'any', + 'posts_per_page' => $batch_size, + 'offset' => $offset, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => 'activitypub_status', + 'value' => 'federated', + ), + ), + ) + ); + + // Avoid multiple queries for post meta. + \update_postmeta_cache( \wp_list_pluck( $posts, 'ID' ) ); + + foreach ( $posts as $post ) { + $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); + + self::add_to_outbox( $post, 'Create', $post->post_author, $visibility ); + + // Add Update activity when the post has been modified. + if ( $post->post_modified !== $post->post_date ) { + self::add_to_outbox( $post, 'Update', $post->post_author, $visibility ); + } + } + + if ( count( $posts ) === $batch_size ) { + return array( + 'batch_size' => $batch_size, + 'offset' => $offset + $batch_size, + ); + } + + return null; + } + + /** + * Create outbox items for comments in batches. + * + * @param int $batch_size Optional. Number of posts to process per batch. Default 50. + * @param int $offset Optional. Number of posts to skip. Default 0. + * @return array|null Array with batch size and offset if there are more posts to process, null otherwise. + */ + public static function create_comment_outbox_items( $batch_size = 50, $offset = 0 ) { + $comments = \get_comments( + array( + 'author__not_in' => array( 0 ), // Limit to comments by registered users. + 'number' => $batch_size, + 'offset' => $offset, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => 'activitypub_status', + 'value' => 'federated', + ), + ), + ) + ); + + foreach ( $comments as $comment ) { + self::add_to_outbox( $comment, 'Create', $comment->user_id ); + } + + if ( count( $comments ) === $batch_size ) { + return array( + 'batch_size' => $batch_size, + 'offset' => $offset + $batch_size, + ); + } + + return null; + } + /** * Set the defaults needed for the plugin to work. * @@ -510,6 +634,40 @@ public static function add_default_settings() { self::add_notification_defaults(); } + /** + * Add an activity to the outbox without federating it. + * + * @param \WP_Post|\WP_Comment $comment The comment or post object. + * @param string $activity_type The type of activity. + * @param int $user_id The user ID. + * @param string $visibility Optional. The visibility of the content. Default 'public'. + */ + private static function add_to_outbox( $comment, $activity_type, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { + $transformer = Factory::get_transformer( $comment ); + if ( ! $transformer || \is_wp_error( $transformer ) ) { + return; + } + + $activity = $transformer->to_object(); + if ( ! $activity || \is_wp_error( $activity ) ) { + return; + } + + // If the user is disabled, fall back to the blog user when available. + if ( is_user_disabled( $user_id ) ) { + if ( is_user_disabled( Actors::BLOG_USER_ID ) ) { + return; + } else { + $user_id = Actors::BLOG_USER_ID; + } + } + + $post_id = Outbox::add( $activity, $activity_type, $user_id, $visibility ); + + // Immediately set to publish, no federation needed. + \wp_publish_post( $post_id ); + } + /** * Add the ActivityPub capability to all users that can publish posts. */ diff --git a/tests/includes/class-test-migration.php b/tests/includes/class-test-migration.php index 3182b8af9..c25b52649 100644 --- a/tests/includes/class-test-migration.php +++ b/tests/includes/class-test-migration.php @@ -7,6 +7,7 @@ namespace Activitypub\Tests; +use Activitypub\Collection\Outbox; use Activitypub\Migration; use Activitypub\Comment; @@ -17,6 +18,90 @@ */ class Test_Migration extends ActivityPub_TestCase_Cache_HTTP { + /** + * Test fixture. + * + * @var array + */ + public static $fixtures = array(); + + /** + * Set up the test. + */ + public static function set_up_before_class() { + \remove_action( 'transition_post_status', array( \Activitypub\Scheduler\Post::class, 'schedule_post_activity' ), 33 ); + \remove_action( 'transition_comment_status', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity' ), 20 ); + \remove_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ) ); + + // Create test posts. + self::$fixtures['posts'] = self::factory()->post->create_many( + 3, + array( + 'post_author' => 1, + 'meta_input' => array( 'activitypub_status' => 'federated' ), + ) + ); + + $modified_post_id = self::factory()->post->create( + array( + 'post_author' => 1, + 'post_content' => 'Test post 2', + 'post_status' => 'publish', + 'post_type' => 'post', + 'post_date' => '2020-01-01 00:00:00', + 'meta_input' => array( 'activitypub_status' => 'federated' ), + ) + ); + self::factory()->post->update_object( $modified_post_id, array( 'post_content' => 'Test post 2 updated' ) ); + + self::$fixtures['posts'][] = $modified_post_id; + self::$fixtures['posts'][] = self::factory()->post->create( + array( + 'post_author' => 1, + 'post_content' => 'Test post 3', + 'post_status' => 'publish', + 'post_type' => 'page', + ) + ); + self::$fixtures['posts'][] = self::factory()->post->create( + array( + 'post_author' => 1, + 'post_content' => 'Test post 4', + 'post_status' => 'publish', + 'post_type' => 'post', + 'meta_input' => array( + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + ), + ) + ); + + // Create test comment. + self::$fixtures['comment'] = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$fixtures['posts'][0], + 'user_id' => 1, + 'comment_content' => 'Test comment', + 'comment_approved' => '1', + ) + ); + \add_comment_meta( self::$fixtures['comment'], 'activitypub_status', 'federated' ); + } + + /** + * Tear down the test. + */ + public static function tear_down_after_class() { + // Clean up posts. + foreach ( self::$fixtures['posts'] as $post_id ) { + \wp_delete_post( $post_id, true ); + } + + // Clean up comment. + if ( isset( self::$fixtures['comment'] ) ) { + \wp_delete_comment( self::$fixtures['comment'], true ); + } + } + /** * Tear down the test. */ @@ -24,6 +109,20 @@ public function tear_down() { \delete_option( 'activitypub_object_type' ); \delete_option( 'activitypub_custom_post_content' ); \delete_option( 'activitypub_post_content_type' ); + + // Clean up outbox items. + $outbox_items = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + 'post_status' => 'any', + 'fields' => 'ids', + ) + ); + + foreach ( $outbox_items as $item_id ) { + \wp_delete_post( $item_id, true ); + } } /** @@ -184,6 +283,9 @@ public function test_migrate_to_4_1_0() { $this->assertEquals( $custom, $template ); $this->assertFalse( $content_type ); + + \wp_delete_post( $post1, true ); + \wp_delete_post( $post2, true ); } /** @@ -192,19 +294,8 @@ public function test_migrate_to_4_1_0() { * @covers ::migrate_to_4_7_1 */ public function test_migrate_to_4_7_1() { - $post1 = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'Test post 1', - ) - ); - - $post2 = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'Test post 2', - ) - ); + $post1 = self::$fixtures['posts'][0]; + $post2 = self::$fixtures['posts'][1]; // Set up test meta data. $meta_data = array( @@ -274,7 +365,7 @@ public function test_lock_acquire_new() { } /** - * Tests retrieving the timestamp of an existing lock. + * Test retrieving the timestamp of an existing lock. * * @covers ::lock */ @@ -291,7 +382,7 @@ public function test_lock_get_existing() { } /** - * Tests update_comment_counts() properly cleans up the lock. + * Test update_comment_counts() properly cleans up the lock. * * @covers ::update_comment_counts */ @@ -324,7 +415,7 @@ public function test_update_comment_counts_with_lock() { } /** - * Tests update_comment_counts() with existing valid lock. + * Test update_comment_counts() with existing valid lock. * * @covers ::update_comment_counts */ @@ -357,4 +448,137 @@ public function test_update_comment_counts_with_existing_valid_lock() { ) ); } + + /** + * Test create post outbox items. + * + * @covers ::create_post_outbox_items + */ + public function test_create_outbox_items() { + // Create additional post that should not be included in outbox. + $post_id = self::factory()->post->create( array( 'post_author' => 90210 ) ); + + // Run migration. + add_filter( 'pre_schedule_event', '__return_false' ); + Migration::create_post_outbox_items( 10, 0 ); + remove_filter( 'pre_schedule_event', '__return_false' ); + + // Get outbox items. + $outbox_items = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + ) + ); + + // Should now have 5 outbox items total, 4 post Create, 1 post Update. + $this->assertEquals( 5, count( $outbox_items ) ); + + \wp_delete_post( $post_id, true ); + } + + /** + * Test create post outbox items with batching. + * + * @covers ::create_post_outbox_items + */ + public function test_create_outbox_items_batching() { + // Run migration with batch size of 2. + $next = Migration::create_post_outbox_items( 2, 0 ); + + $this->assertSame( + array( + 'batch_size' => 2, + 'offset' => 2, + ), + $next + ); + + // Get outbox items. + $outbox_items = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + ) + ); + + // Should have 2 outbox items. + $this->assertEquals( 2, count( $outbox_items ) ); + + // Run migration with next batch. + Migration::create_post_outbox_items( 2, 2 ); + + // Get outbox items again. + $outbox_items = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + ) + ); + + // Should now have 5 outbox items total, 4 post Create, 1 post Update. + $this->assertEquals( 5, count( $outbox_items ) ); + } + + /** + * Test async upgrade functionality. + * + * @covers ::async_upgrade + * @covers ::lock + * @covers ::unlock + * @covers ::create_post_outbox_items + */ + public function test_async_upgrade() { + // Test that lock prevents simultaneous upgrades. + Migration::lock(); + Migration::async_upgrade( 'create_post_outbox_items' ); + $scheduled = \wp_next_scheduled( 'activitypub_upgrade', array( 'create_post_outbox_items' ) ); + $this->assertNotFalse( $scheduled ); + Migration::unlock(); + + // Test scheduling next batch when callback returns more work. + Migration::async_upgrade( 'create_post_outbox_items', 1, 0 ); // Small batch size to force multiple batches. + $scheduled = \wp_next_scheduled( 'activitypub_upgrade', array( 'create_post_outbox_items', 1, 1 ) ); + $this->assertNotFalse( $scheduled ); + + // Test no scheduling when callback returns null (no more work). + Migration::async_upgrade( 'create_post_outbox_items', 100, 1000 ); // Large offset to ensure no posts found. + $this->assertFalse( + \wp_next_scheduled( 'activitypub_upgrade', array( 'create_post_outbox_items', 100, 1100 ) ) + ); + } + + /** + * Test async upgrade with multiple arguments. + * + * @covers ::async_upgrade + */ + public function test_async_upgrade_multiple_args() { + // Test that multiple arguments are passed correctly. + Migration::async_upgrade( 'update_comment_counts', 50, 100 ); + $scheduled = \wp_next_scheduled( 'activitypub_upgrade', array( 'update_comment_counts', 50, 150 ) ); + $this->assertFalse( $scheduled, 'Should not schedule next batch when no comments found' ); + } + + /** + * Test create_comment_outbox_items batch processing. + * + * @covers ::create_comment_outbox_items + */ + public function test_create_comment_outbox_items_batching() { + // Test with small batch size. + $result = Migration::create_comment_outbox_items( 1, 0 ); + $this->assertIsArray( $result ); + $this->assertEquals( + array( + 'batch_size' => 1, + 'offset' => 1, + ), + $result + ); + + // Test with large offset (no more comments). + $result = Migration::create_comment_outbox_items( 1, 1000 ); + $this->assertNull( $result ); + } } From 0dce3380b232e9bc7bc6c13639236e466c0243fd Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 27 Jan 2025 08:47:02 -0600 Subject: [PATCH 79/98] Outbox: Fall back to blog author for non-authorized authors (#1208) * Outbox: Don't create items for post from not activitypub-authorized authors * Use utility function Props @pfefferle * Move user_id check into outbox helper * Update unit tests * Revert changes in post class * Skip when blog mode is disabled --- includes/functions.php | 9 ++ tests/includes/class-test-signature.php | 7 +- .../includes/collection/class-test-outbox.php | 84 +++++++++++++++++-- tests/includes/scheduler/class-test-actor.php | 4 +- tests/includes/scheduler/class-test-post.php | 2 +- 5 files changed, 94 insertions(+), 12 deletions(-) diff --git a/includes/functions.php b/includes/functions.php index 90110e5f7..e7ecf1c99 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1580,6 +1580,15 @@ function add_to_outbox( $data, $activity_type = 'Create', $user_id = 0, $content return false; } + // If the user is disabled, fall back to the blog user when available. + if ( is_user_disabled( $user_id ) ) { + if ( is_user_disabled( Actors::BLOG_USER_ID ) ) { + return false; + } else { + $user_id = Actors::BLOG_USER_ID; + } + } + set_wp_object_state( $data, 'federate' ); $outbox_activity_id = Outbox::add( $activity_object, $activity_type, $user_id, $content_visibility ); diff --git a/tests/includes/class-test-signature.php b/tests/includes/class-test-signature.php index dfcfd05ef..5d9988d73 100644 --- a/tests/includes/class-test-signature.php +++ b/tests/includes/class-test-signature.php @@ -58,7 +58,7 @@ public function test_signature_legacy() { $this->assertEquals( $key_pair['private_key'], $private_key ); // Check application user. - $user = Actors::get_by_id( -1 ); + $user = Actors::get_by_id( Actors::APPLICATION_USER_ID ); $public_key = 'public key ' . $user->get__id(); $private_key = 'private key ' . $user->get__id(); @@ -73,8 +73,9 @@ public function test_signature_legacy() { $this->assertEquals( $key_pair['private_key'], $private_key ); // Check blog user. - \define( 'ACTIVITYPUB_DISABLE_BLOG_USER', false ); - $user = Actors::get_by_id( 0 ); + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + $user = Actors::get_by_id( Actors::BLOG_USER_ID ); + \delete_option( 'activitypub_actor_mode' ); $public_key = 'public key ' . $user->get__id(); $private_key = 'private key ' . $user->get__id(); diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index da46c3117..e0d9c0d2d 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -12,7 +12,35 @@ * * @coversDefaultClass \Activitypub\Collection\Outbox */ -class Test_Outbox extends \Activitypub\Tests\ActivityPub_TestCase_Cache_HTTP { +class Test_Outbox extends \WP_UnitTestCase { + /** + * User ID for testing. + * + * @var int + */ + protected static $user_id; + + /** + * Set up test resources. + */ + public static function set_up_before_class() { + parent::set_up_before_class(); + self::$user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + + // Add activitypub capability to the user. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + + \add_filter( 'pre_schedule_event', '__return_false' ); + } + + /** + * Clean up test resources. + */ + public static function tear_down_after_class() { + \delete_option( 'activitypub_actor_mode' ); + \wp_delete_user( self::$user_id ); + \remove_filter( 'pre_schedule_event', '__return_false' ); + } /** * Test add an item to the outbox. @@ -26,11 +54,13 @@ class Test_Outbox extends \Activitypub\Tests\ActivityPub_TestCase_Cache_HTTP { * @param string $json The JSON representation of the data. */ public function test_add( $data, $type, $user_id, $json ) { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + $id = \Activitypub\add_to_outbox( $data, $type, $user_id ); $this->assertIsInt( $id ); - $post = get_post( $id ); + $post = \get_post( $id ); $this->assertInstanceOf( 'WP_Post', $post ); $this->assertEquals( 'pending', $post->post_status ); @@ -39,8 +69,11 @@ public function test_add( $data, $type, $user_id, $json ) { $activity = json_decode( $post->post_content ); $this->assertSame( $data['content'], $activity->content ); - $this->assertEquals( $type, get_post_meta( $id, '_activitypub_activity_type', true ) ); - $this->assertEquals( 'user', get_post_meta( $id, '_activitypub_activity_actor', true ) ); + $this->assertEquals( $type, \get_post_meta( $id, '_activitypub_activity_type', true ) ); + + // Fall back to blog if user does not have the activitypub capability. + $actor_type = \user_can( $user_id, 'activitypub' ) ? 'user' : 'blog'; + $this->assertEquals( $actor_type, \get_post_meta( $id, '_activitypub_activity_actor', true ) ); } /** @@ -53,13 +86,13 @@ public function activity_object_provider() { array( array( '@context' => 'https://www.w3.org/ns/activitystreams', - 'id' => 'https://example.com/1', + 'id' => 'https://example.com/' . self::$user_id, 'type' => 'Note', 'content' => '

This is a note

', ), 'Create', 1, - '{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/1","type":"Note","content":"\u003Cp\u003EThis is a note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is a note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":[],"mediaType":"text\/html","sensitive":false}', + '{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/' . self::$user_id . '","type":"Note","content":"\u003Cp\u003EThis is a note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is a note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":[],"mediaType":"text\/html","sensitive":false}', ), array( array( @@ -74,4 +107,43 @@ public function activity_object_provider() { ), ); } + + /** + * Test add an item to the outbox with a user. + * + * @covers ::add + * @dataProvider author_object_provider + * + * @param string $mode The actor mode. + * @param int $user_id The user ID. + * @param string $expected_actor The expected actor. + */ + public function test_author_fallbacks( $mode, $user_id, $expected_actor ) { + \update_option( 'activitypub_actor_mode', $mode ); + + $user_id = $user_id ?? self::$user_id; + $data = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'https://example.com/' . $user_id, + 'type' => 'Note', + 'content' => '

This is a note

', + ); + + $id = \Activitypub\add_to_outbox( $data, 'Create', $user_id ); + $this->assertEquals( $expected_actor, \get_post_meta( $id, '_activitypub_activity_actor', true ) ); + } + + /** + * Data provider for test_author_fallbacks. + * + * @return array[] + */ + public function author_object_provider() { + return array( + array( ACTIVITYPUB_ACTOR_AND_BLOG_MODE, self::$user_id, 'user' ), // Not sure why we can't access self::$user_id directly. + array( ACTIVITYPUB_ACTOR_AND_BLOG_MODE, 90210, 'blog' ), + array( ACTIVITYPUB_BLOG_MODE, 90210, 'blog' ), + array( ACTIVITYPUB_ACTOR_MODE, 90210, false ), + ); + } } diff --git a/tests/includes/scheduler/class-test-actor.php b/tests/includes/scheduler/class-test-actor.php index 7dd413b48..d178018da 100644 --- a/tests/includes/scheduler/class-test-actor.php +++ b/tests/includes/scheduler/class-test-actor.php @@ -88,6 +88,7 @@ public function test_user_update() { * @covers ::blog_user_update */ public function test_blog_user_update() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); $test_value = 'test value'; $result = \Activitypub\Scheduler\Actor::blog_user_update( $test_value ); @@ -160,7 +161,7 @@ public function test_schedule_post_activity_extra_fields() { * @covers ::schedule_post_activity */ public function test_schedule_post_activity_extra_field_blog() { - \update_option( 'activitypub_actor_mode', ACTIVITYPUB_BLOG_MODE ); + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); $blog_post_id = self::factory()->post->create( array( 'post_type' => Extra_Fields::BLOG_POST_TYPE ) ); $activitpub_id = Actors::get_by_id( Actors::BLOG_USER_ID )->get_id(); @@ -169,6 +170,5 @@ public function test_schedule_post_activity_extra_field_blog() { // Clean up. \wp_delete_post( $blog_post_id, true ); - \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); } } diff --git a/tests/includes/scheduler/class-test-post.php b/tests/includes/scheduler/class-test-post.php index b814f4ca4..d4735b96e 100644 --- a/tests/includes/scheduler/class-test-post.php +++ b/tests/includes/scheduler/class-test-post.php @@ -20,7 +20,7 @@ class Test_Post extends \Activitypub\Tests\ActivityPub_Outbox_TestCase { * @covers ::schedule_post_activity */ public function test_schedule_post_activity_regular_post() { - $post_id = self::factory()->post->create(); + $post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); $activitpub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); $post = $this->get_latest_outbox_item( $activitpub_id ); From 081b90597393ff0504f8f3cd5b1689bf1035a126 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 27 Jan 2025 18:31:57 +0100 Subject: [PATCH 80/98] Accept Follower: Migrate to outbox (#1205) * Follower: Migrate to outbox * add missing namespace * remove unused dms/phpunit-arraysubset-asserts dependency * add support for Activities * add tests * fix phpcs issues * re-add dev-dependency * always add actor to get sure it is the right one! * Update includes/functions.php Co-authored-by: Konstantin Obenland * Update includes/functions.php Co-authored-by: Konstantin Obenland * simplify tests * rename function --------- Co-authored-by: Konstantin Obenland --- includes/class-dispatcher.php | 6 +- includes/functions.php | 60 +++++++++ includes/handler/class-follow.php | 24 ++-- includes/transformer/class-json.php | 13 +- tests/includes/class-test-functions.php | 83 +++++++++++++ tests/includes/handler/class-test-follow.php | 124 +++++++++++++++++++ 6 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 tests/includes/handler/class-test-follow.php diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index 95ca26c66..afc398bba 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -67,11 +67,7 @@ public static function process_outbox( $id ) { $activity->set_id( $outbox_item->guid ); // Pre-fill the Activity with data (for example cc and to). $activity->from_json( $outbox_item->post_content ); - - // If the activity doesn't have an actor, set the actor to the post author. - if ( ! $activity->get_actor() ) { - $activity->set_actor( Actors::get_by_id( $outbox_item->post_author )->get_id() ); - } + $activity->set_actor( Actors::get_by_id( $outbox_item->post_author )->get_id() ); // Use simple Object (only ID-URI) for Like and Announce. if ( 'Like' === $type ) { diff --git a/includes/functions.php b/includes/functions.php index e7ecf1c99..6851aeff1 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -9,6 +9,7 @@ use WP_Error; use Activitypub\Activity\Activity; +use Activitypub\Activity\Base_Object; use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; use Activitypub\Collection\Followers; @@ -1609,3 +1610,62 @@ function add_to_outbox( $data, $activity_type = 'Create', $user_id = 0, $content return $outbox_activity_id; } + +/** + * Check if an object is an Activity. + * + * @param array|object $data The object to check. + * + * @see https://www.w3.org/ns/activitystreams#activities + * + * @return boolean True if the object is an Activity, false otherwise. + */ +function is_activity( $data ) { + /** + * Filters the activity types. + * + * @param array $types The activity types. + */ + $types = apply_filters( + 'activitypub_activity_types', + array( + 'Accept', + 'Add', + 'Announce', + 'Arrive', + 'Block', + 'Create', + 'Delete', + 'Dislike', + 'Follow', + 'Flag', + 'Ignore', + 'Invite', + 'Join', + 'Leave', + 'Like', + 'Listen', + 'Move', + 'Offer', + 'Read', + 'Reject', + 'Remove', + 'TentativeAccept', + 'TentativeReject', + 'Travel', + 'Undo', + 'Update', + 'View', + ) + ); + + if ( is_array( $data ) && isset( $data['type'] ) ) { + return in_array( $data['type'], $types, true ); + } + + if ( is_object( $data ) && $data instanceof Base_Object ) { + return in_array( $data->get_type(), $types, true ); + } + + return false; +} diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php index f3e3d5487..4dd4761fc 100644 --- a/includes/handler/class-follow.php +++ b/includes/handler/class-follow.php @@ -13,6 +13,8 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; +use function Activitypub\add_to_outbox; + /** * Handle Follow requests. */ @@ -28,7 +30,7 @@ public static function init() { \add_action( 'activitypub_followers_post_follow', - array( self::class, 'send_follow_response' ), + array( self::class, 'queue_accept' ), 10, 4 ); @@ -83,7 +85,7 @@ public static function handle_follow( $activity ) { * @param int $user_id The ID of the WordPress User. * @param \Activitypub\Model\Follower $follower The Follower object. */ - public static function send_follow_response( $actor, $activity_object, $user_id, $follower ) { + public static function queue_accept( $actor, $activity_object, $user_id, $follower ) { if ( \is_wp_error( $follower ) ) { // Impossible to send a "Reject" because we can not get the Remote-Inbox. return; @@ -102,21 +104,9 @@ public static function send_follow_response( $actor, $activity_object, $user_id, ) ); - $user = Actors::get_by_id( $user_id ); - - // Get inbox. - $inbox = $follower->get_shared_inbox(); - - // Send "Accept" activity. - $activity = new Activity(); - $activity->set_type( 'Accept' ); - $activity->set_object( $activity_object ); - $activity->set_actor( $user->get_id() ); - $activity->set_to( $actor ); - $activity->set_id( $user->get_id() . '#follow-' . \preg_replace( '~^https?://~', '', $actor ) . '-' . \time() ); - - $activity = $activity->to_json(); + // Send response only to the Follower. + $activity_object['to'] = $actor; - Http::post( $inbox, $activity, $user_id ); + add_to_outbox( $activity_object, 'Accept', $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); } } diff --git a/includes/transformer/class-json.php b/includes/transformer/class-json.php index 6b7c0288e..46dd8fffb 100644 --- a/includes/transformer/class-json.php +++ b/includes/transformer/class-json.php @@ -9,6 +9,8 @@ use Activitypub\Activity\Base_Object; +use function Activitypub\is_activity; + /** * String Transformer Class file. */ @@ -22,10 +24,17 @@ class Json extends Activity_Object { public function __construct( $item ) { $object = new Base_Object(); + // Check if the item is an Activity or an Object. + if ( is_activity( $item ) ) { + $class = '\Activitypub\Activity\Activity'; + } else { + $class = '\Activitypub\Activity\Base_Object'; + } + if ( is_array( $item ) ) { - $object = Base_Object::init_from_array( $item ); + $object = $class::init_from_array( $item ); } elseif ( is_string( $item ) ) { - $object = Base_Object::init_from_json( $item ); + $object = $class::init_from_json( $item ); } parent::__construct( $object ); diff --git a/tests/includes/class-test-functions.php b/tests/includes/class-test-functions.php index 480ade24f..4069fbd88 100644 --- a/tests/includes/class-test-functions.php +++ b/tests/includes/class-test-functions.php @@ -216,4 +216,87 @@ public function object_to_uri_provider() { ), ); } + + /** + * Test is_activity with array input. + * + * @covers ::is_activity + * + * @dataProvider is_activity_data + * + * @param mixed $activity The activity object. + * @param bool $expected The expected result. + */ + public function test_is_activity( $activity, $expected ) { + $this->assertEquals( $expected, \Activitypub\is_activity( $activity ) ); + } + + /** + * Data provider for test_is_activity. + * + * @return array[] + */ + public function is_activity_data() { + // Test Activity object. + $create = new \Activitypub\Activity\Activity(); + $create->set_type( 'Create' ); + + // Test Base_Object. + $note = new \Activitypub\Activity\Base_Object(); + $note->set_type( 'Note' ); + + return array( + array( array( 'type' => 'Create' ), true ), + array( array( 'type' => 'Update' ), true ), + array( array( 'type' => 'Delete' ), true ), + array( array( 'type' => 'Follow' ), true ), + array( array( 'type' => 'Accept' ), true ), + array( array( 'type' => 'Reject' ), true ), + array( array( 'type' => 'Add' ), true ), + array( array( 'type' => 'Remove' ), true ), + array( array( 'type' => 'Like' ), true ), + array( array( 'type' => 'Announce' ), true ), + array( array( 'type' => 'Undo' ), true ), + array( array( 'type' => 'Note' ), false ), + array( array( 'type' => 'Article' ), false ), + array( array( 'type' => 'Person' ), false ), + array( array( 'type' => 'Image' ), false ), + array( array( 'type' => 'Video' ), false ), + array( array( 'type' => 'Audio' ), false ), + array( array( 'type' => '' ), false ), + array( array( 'type' => null ), false ), + array( array(), false ), + array( $create, true ), + array( $note, false ), + array( 'string', false ), + array( 123, false ), + array( true, false ), + array( false, false ), + array( null, false ), + array( new \stdClass(), false ), + ); + } + + /** + * Test is_activity with invalid input. + * + * @covers ::is_activity + */ + public function test_is_activity_with_invalid_input() { + $invalid_inputs = array( + 'string', + 123, + true, + false, + null, + new \stdClass(), + ); + + foreach ( $invalid_inputs as $input ) { + $this->assertFalse( + \Activitypub\is_activity( $input ), + sprintf( 'Input of type %s should be invalid', gettype( $input ) ) + ); + } + } } diff --git a/tests/includes/handler/class-test-follow.php b/tests/includes/handler/class-test-follow.php new file mode 100644 index 000000000..068f8c21e --- /dev/null +++ b/tests/includes/handler/class-test-follow.php @@ -0,0 +1,124 @@ +user->create( + array( + 'role' => 'author', + ) + ); + } + + /** + * Clean up after tests. + */ + public static function wpTearDownAfterClass() { + wp_delete_user( self::$user_id ); + } + + /** + * Test queue_accept method. + * + * @covers ::queue_accept + */ + public function test_queue_accept() { + $actor = 'https://example.com/actor'; + $activity_object = array( + 'id' => 'https://example.com/activity/123', + 'type' => 'Follow', + 'actor' => $actor, + 'object' => 'https://example.com/user/1', + ); + + // Test with WP_Error follower - should not create outbox entry. + $wp_error = new \WP_Error( 'test_error', 'Test Error' ); + Follow::queue_accept( $actor, $activity_object, self::$user_id, $wp_error ); + + $outbox_posts = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'author' => self::$user_id, + 'post_status' => 'pending', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_actor', + 'value' => 'user', + ), + ), + ) + ); + $this->assertEmpty( $outbox_posts, 'No outbox entry should be created for WP_Error follower' ); + + // Test with valid follower. + $follower = new Follower(); + $follower->set_actor( $actor ); + $follower->set_type( 'Person' ); + $follower->set_inbox( 'https://example.com/inbox' ); + + Follow::queue_accept( $actor, $activity_object, self::$user_id, $follower ); + + $outbox_posts = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'author' => self::$user_id, + 'post_status' => 'pending', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_actor', + 'value' => 'user', + ), + ), + ) + ); + + $this->assertCount( 1, $outbox_posts, 'One outbox entry should be created' ); + + $outbox_post = $outbox_posts[0]; + $activity_type = \get_post_meta( $outbox_post->ID, '_activitypub_activity_type', true ); + $activity_json = \json_decode( $outbox_post->post_content, true ); + $visibility = \get_post_meta( $outbox_post->ID, 'activitypub_content_visibility', true ); + + // Verify outbox entry. + $this->assertEquals( 'Accept', $activity_type ); + $this->assertEquals( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, $visibility ); + + $this->assertEquals( 'Follow', $activity_json['type'] ); + $this->assertEquals( 'https://example.com/user/1', $activity_json['object'] ); + $this->assertEquals( array( $actor ), $activity_json['to'] ); + $this->assertEquals( $actor, $activity_json['actor'] ); + + // Clean up. + wp_delete_post( $outbox_post->ID, true ); + } +} From cbcaea61d263ac01f73c4af58cd3727d86055705 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 27 Jan 2025 11:59:21 -0600 Subject: [PATCH 81/98] Send Activity and not its object (#1217) * Create failing test * Set activity object as an object. * minimize changes --- includes/class-dispatcher.php | 3 +-- tests/includes/class-test-dispatcher.php | 25 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index afc398bba..b0d748715 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -10,7 +10,6 @@ use Activitypub\Activity\Activity; use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; -use Activitypub\Transformer\Factory as Transformer_Factory; /** * ActivityPub Dispatcher Class. @@ -66,7 +65,7 @@ public static function process_outbox( $id ) { $activity->set_type( $type ); $activity->set_id( $outbox_item->guid ); // Pre-fill the Activity with data (for example cc and to). - $activity->from_json( $outbox_item->post_content ); + $activity->set_object( Activity::init_from_json( $outbox_item->post_content ) ); $activity->set_actor( Actors::get_by_id( $outbox_item->post_author )->get_id() ); // Use simple Object (only ID-URI) for Like and Announce. diff --git a/tests/includes/class-test-dispatcher.php b/tests/includes/class-test-dispatcher.php index 389cf89b3..b0a1ffc90 100644 --- a/tests/includes/class-test-dispatcher.php +++ b/tests/includes/class-test-dispatcher.php @@ -15,7 +15,7 @@ * * @coversDefaultClass Activitypub\Dispatcher */ -class Test_Dispatcher extends WP_UnitTestCase { +class Test_Dispatcher extends \Activitypub\Tests\ActivityPub_Outbox_TestCase { /** * Tear down the test case. */ @@ -90,4 +90,27 @@ function ( $name ) { $result = Dispatcher::maybe_add_inboxes_of_blog_user( $inboxes, 1, $activity ); $this->assertEquals( $inboxes, $result ); } + + /** + * Test process_outbox. + * + * @covers ::process_outbox + */ + public function test_process_outbox() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); + + $test_callback = function ( $send, $activity ) { + $this->assertInstanceOf( Activity::class, $activity ); + $this->assertEquals( 'Create', $activity->get_type() ); + + return $send; + }; + add_filter( 'activitypub_send_activity_to_followers', $test_callback, 10, 2 ); + + $outbox_item = $this->get_latest_outbox_item( \add_query_arg( 'p', $post_id, \home_url( '/' ) ) ); + + Dispatcher::process_outbox( $outbox_item->ID ); + + remove_filter( 'activitypub_send_activity_to_followers', $test_callback ); + } } From aa60f3fbc32637947ee7230c771800ce58559861 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 27 Jan 2025 20:28:07 +0100 Subject: [PATCH 82/98] Outbox: fix to_id function for user transformer (#1218) * fix to_id function for user transformer * fix phpcs * Add test --------- Co-authored-by: Konstantin Obenland --- includes/transformer/class-user.php | 12 +++++++++--- tests/includes/class-test-query.php | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/includes/transformer/class-user.php b/includes/transformer/class-user.php index 941b07689..67d05ff48 100644 --- a/includes/transformer/class-user.php +++ b/includes/transformer/class-user.php @@ -21,9 +21,15 @@ class User extends Base { * @return \Activitypub\Activity\Base_Object The ActivityPub Object */ public function to_object() { - $user = $this->wp_object; - $actor = Actors::get_by_id( $user->ID ); + return Actors::get_by_id( $this->item->ID ); + } - return $actor; + /** + * Get the Actor ID. + * + * @return string The Actor ID. + */ + public function to_id() { + return Actors::get_by_id( $this->item->ID )->get_id(); } } diff --git a/tests/includes/class-test-query.php b/tests/includes/class-test-query.php index 47b698ac2..99d8e7e0d 100644 --- a/tests/includes/class-test-query.php +++ b/tests/includes/class-test-query.php @@ -108,6 +108,19 @@ public function test_get_activitypub_object_id() { $this->assertEquals( get_permalink( self::$post_id ), $query->get_activitypub_object_id() ); } + /** + * Test get_activitypub_object_id method for authors. + * + * @covers ::get_activitypub_object_id + */ + public function test_get_activitypub_object_id_for_author() { + $author_url = get_author_posts_url( self::$user_id ); + $this->go_to( $author_url ); + $query = Query::get_instance(); + + $this->assertEquals( $author_url, $query->get_activitypub_object_id() ); + } + /** * Test get_queried_object method. * From b95375a55e1ec435c23524fa3d95c6eabd0eef45 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 27 Jan 2025 16:14:44 -0600 Subject: [PATCH 83/98] Update class-test-outbox.php (#1220) --- .../includes/collection/class-test-outbox.php | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index e0d9c0d2d..36fa1c537 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -12,35 +12,7 @@ * * @coversDefaultClass \Activitypub\Collection\Outbox */ -class Test_Outbox extends \WP_UnitTestCase { - /** - * User ID for testing. - * - * @var int - */ - protected static $user_id; - - /** - * Set up test resources. - */ - public static function set_up_before_class() { - parent::set_up_before_class(); - self::$user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - - // Add activitypub capability to the user. - \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); - - \add_filter( 'pre_schedule_event', '__return_false' ); - } - - /** - * Clean up test resources. - */ - public static function tear_down_after_class() { - \delete_option( 'activitypub_actor_mode' ); - \wp_delete_user( self::$user_id ); - \remove_filter( 'pre_schedule_event', '__return_false' ); - } +class Test_Outbox extends \Activitypub\Tests\ActivityPub_Outbox_TestCase { /** * Test add an item to the outbox. @@ -140,7 +112,7 @@ public function test_author_fallbacks( $mode, $user_id, $expected_actor ) { */ public function author_object_provider() { return array( - array( ACTIVITYPUB_ACTOR_AND_BLOG_MODE, self::$user_id, 'user' ), // Not sure why we can't access self::$user_id directly. + array( ACTIVITYPUB_ACTOR_AND_BLOG_MODE, null, 'user' ), array( ACTIVITYPUB_ACTOR_AND_BLOG_MODE, 90210, 'blog' ), array( ACTIVITYPUB_BLOG_MODE, 90210, 'blog' ), array( ACTIVITYPUB_ACTOR_MODE, 90210, false ), From f2b209723ae4f448f3b125d273d13dbf0dd22439 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 28 Jan 2025 10:50:50 +0100 Subject: [PATCH 84/98] Add hint where to find the visibility constants --- includes/collection/class-outbox.php | 2 +- includes/functions.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index c14ad8135..b0d762455 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -21,7 +21,7 @@ class Outbox { * @param \Activitypub\Activity\Base_Object $activity_object The object of the activity that will be added to the outbox. * @param string $activity_type The activity type. * @param int $user_id The real or imaginary user ID of the actor that published the activity that will be added to the outbox. - * @param string $content_visibility Optional. The visibility of the content. Default 'public'. + * @param string $content_visibility Optional. The visibility of the content. Default: `ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC`. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`. * * @return false|int|\WP_Error The added item or an error. */ diff --git a/includes/functions.php b/includes/functions.php index 6851aeff1..6f1ab3a2d 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1558,7 +1558,7 @@ function is_self_ping( $id ) { * @param mixed $data The object to add to the outbox. * @param string $activity_type The type of the Activity. * @param integer $user_id The User-ID. - * @param string $content_visibility The visibility of the content. + * @param string $content_visibility The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`. * * @return boolean|int The ID of the outbox item or false on failure. */ @@ -1604,7 +1604,7 @@ function add_to_outbox( $data, $activity_type = 'Create', $user_id = 0, $content * @param int $outbox_activity_id The ID of the outbox item. * @param \Activitypub\Activity\Base_Object $activity_object The activity object. * @param int $user_id The User-ID. - * @param string $content_visibility The visibility of the content. + * @param string $content_visibility The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`. */ \do_action( 'post_activitypub_add_to_outbox', $outbox_activity_id, $activity_object, $user_id, $content_visibility ); From 2d855cc4d93b65e5175bd65ba368dcfb4e3d9b67 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 28 Jan 2025 10:55:17 +0100 Subject: [PATCH 85/98] rename function to match new structure props @obenland --- includes/scheduler/class-post.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 1a419d9c7..cf10824c0 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -33,7 +33,7 @@ public static function init() { \add_action( 'edit_attachment', array( self::class, 'transition_attachment_status' ) ); \add_action( 'delete_attachment', array( self::class, 'transition_attachment_status' ) ); - \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'send_announces' ), 10, 4 ); + \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_announce_activity' ), 10, 4 ); // Get all post types that support ActivityPub. $post_types = \get_post_types_by_support( 'activitypub' ); @@ -113,7 +113,7 @@ public static function schedule_post_activity( $new_status, $old_status, $post ) * @param int $actor_id The actor ID. * @param int $content_visibility The content visibility. */ - public static function send_announces( $outbox_activity_id, $activity_object, $actor_id, $content_visibility ) { + public static function schedule_announce_activity( $outbox_activity_id, $activity_object, $actor_id, $content_visibility ) { // 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; From 845304421873664ac4b6e1e636f2dd2b31fd580f Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 28 Jan 2025 07:58:43 -0600 Subject: [PATCH 86/98] Outbox: Fix attachment transitions (#1219) * Outbox: Correctly handle attachments * Update function docs to reflect reality * Aside: Add some context as to why this callback is necessary --- includes/scheduler/class-post.php | 20 ++++++++---- tests/includes/scheduler/class-test-post.php | 33 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index cf10824c0..9a6315b13 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -49,15 +49,19 @@ public static function init() { * @param int $post_id Attachment ID. */ public static function transition_attachment_status( $post_id ) { + if ( ! \post_type_supports( 'attachment', 'activitypub' ) ) { + return; + } + switch ( current_action() ) { case 'add_attachment': - self::schedule_post_activity( 'publish', '', $post_id ); + self::schedule_post_activity( 'publish', '', get_post( $post_id ) ); break; case 'edit_attachment': - self::schedule_post_activity( 'publish', 'publish', $post_id ); + self::schedule_post_activity( 'publish', 'publish', get_post( $post_id ) ); break; case 'delete_attachment': - self::schedule_post_activity( 'trash', '', $post_id ); + self::schedule_post_activity( 'trash', '', get_post( $post_id ) ); break; } } @@ -65,9 +69,9 @@ public static function transition_attachment_status( $post_id ) { /** * Schedule Activities. * - * @param string $new_status New post status. - * @param string $old_status Old post status. - * @param int|\WP_Post $post Post ID or post object. + * @param string $new_status New post status. + * @param string $old_status Old post status. + * @param \WP_Post $post Post object. */ public static function schedule_post_activity( $new_status, $old_status, $post ) { if ( is_post_disabled( $post ) ) { @@ -157,6 +161,10 @@ public static function schedule_announce_activity( $outbox_activity_id, $activit /** * Filter the post data before it is inserted via the REST API. * + * Posts being inserted via the REST API have a different order of operations than in wp_insert_post(). + * This filter updates post meta before the post is inserted into the database, so that the + * information is available by the time @see Outbox::add() runs. + * * @param \stdClass $post An object representing a single post prepared for inserting or updating the database. * @param \WP_REST_Request $request The request object. * diff --git a/tests/includes/scheduler/class-test-post.php b/tests/includes/scheduler/class-test-post.php index d4735b96e..12d625690 100644 --- a/tests/includes/scheduler/class-test-post.php +++ b/tests/includes/scheduler/class-test-post.php @@ -14,6 +14,39 @@ */ class Test_Post extends \Activitypub\Tests\ActivityPub_Outbox_TestCase { + /** + * Test post activity scheduling for attachments. + * + * @covers ::transition_attachment_status + */ + public function test_transition_attachment_status() { + add_post_type_support( 'attachment', 'activitypub' ); + wp_set_current_user( self::$user_id ); + + // Create. + $post_id = self::factory()->attachment->create_upload_object( dirname( __DIR__, 2 ) . '/assets/test.jpg' ); + $activitpub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); + $outbox_item = $this->get_latest_outbox_item( $activitpub_id ); + + $this->assertNotNull( $outbox_item ); + $this->assertSame( 'Create', \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ) ); + + // Update. + self::factory()->attachment->update_object( $post_id, array( 'post_title' => 'Updated title' ) ); + + $outbox_item = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( 'Update', \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ) ); + + // Delete. + \wp_delete_attachment( $post_id, true ); + + // Not federated, should not send Delete activity. + $outbox_item = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( 'Update', \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ) ); + + remove_post_type_support( 'attachment', 'activitypub' ); + } + /** * Test post activity scheduling for regular posts. * From 6041035ac93bc8a3253937a325dcf42b3c95e397 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 28 Jan 2025 15:24:37 +0100 Subject: [PATCH 87/98] Outbox: Fix deletes (#1224) * Outbox: Fix deletes * Always send deletes --- includes/class-dispatcher.php | 2 +- includes/scheduler/class-post.php | 2 +- tests/includes/scheduler/class-test-post.php | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index b0d748715..fe347d820 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -69,7 +69,7 @@ public static function process_outbox( $id ) { $activity->set_actor( Actors::get_by_id( $outbox_item->post_author )->get_id() ); // Use simple Object (only ID-URI) for Like and Announce. - if ( 'Like' === $type ) { + if ( in_array( $type, array( 'Like', 'Delete' ), true ) ) { $activity->set_object( $activity->get_object()->get_id() ); } diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 9a6315b13..73ff132e1 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -93,7 +93,7 @@ public static function schedule_post_activity( $new_status, $old_status, $post ) break; case 'trash': - $type = 'federated' === get_wp_object_state( $post ) ? 'Delete' : false; + $type = 'Delete'; break; default: diff --git a/tests/includes/scheduler/class-test-post.php b/tests/includes/scheduler/class-test-post.php index 12d625690..082bfb50e 100644 --- a/tests/includes/scheduler/class-test-post.php +++ b/tests/includes/scheduler/class-test-post.php @@ -40,9 +40,8 @@ public function test_transition_attachment_status() { // Delete. \wp_delete_attachment( $post_id, true ); - // Not federated, should not send Delete activity. $outbox_item = $this->get_latest_outbox_item( $activitpub_id ); - $this->assertSame( 'Update', \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ) ); + $this->assertSame( 'Delete', \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ) ); remove_post_type_support( 'attachment', 'activitypub' ); } From 65e7d324cdf8a3b2719012396976be0f51b7ef17 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 28 Jan 2025 15:56:11 +0100 Subject: [PATCH 88/98] Outbox: Basic rescheduling (#1223) * Outbox: Basic rescheduling * add tests * Add tests * reuse ActivityPub_Outbox_TestCase class * fix phpcs issues * update tests * fix tests * fix tests * debug * debug * types? * add missing hook props @obenland * simpified code props @obenland * remove unused namespace definitions --------- Co-authored-by: Konstantin Obenland --- includes/class-scheduler.php | 25 ++++ tests/includes/class-test-scheduler.php | 191 ++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 tests/includes/class-test-scheduler.php diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index cbcf5f96c..894d585dc 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -10,6 +10,7 @@ use Activitypub\Scheduler\Post; use Activitypub\Scheduler\Actor; use Activitypub\Scheduler\Comment; +use Activitypub\Collection\Outbox; use Activitypub\Collection\Followers; /** @@ -29,6 +30,8 @@ public static function init() { \add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) ); \add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) ); + \add_action( 'activitypub_reprocess_outbox', array( self::class, 'reprocess_outbox' ) ); + \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_outbox_activity_for_federation' ) ); } @@ -59,6 +62,10 @@ public static function register_schedules() { if ( ! \wp_next_scheduled( 'activitypub_cleanup_followers' ) ) { \wp_schedule_event( time(), 'daily', 'activitypub_cleanup_followers' ); } + + if ( ! \wp_next_scheduled( 'activitypub_reprocess_outbox' ) ) { + \wp_schedule_event( time(), 'hourly', 'activitypub_reprocess_outbox' ); + } } /** @@ -158,4 +165,22 @@ public static function schedule_outbox_activity_for_federation( $id ) { ); } } + + /** + * Reprocess the outbox. + */ + public static function reprocess_outbox() { + $ids = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'posts_per_page' => 10, + 'fields' => 'ids', + ) + ); + + foreach ( $ids as $id ) { + self::schedule_outbox_activity_for_federation( $id ); + } + } } diff --git a/tests/includes/class-test-scheduler.php b/tests/includes/class-test-scheduler.php new file mode 100644 index 000000000..0a423dc38 --- /dev/null +++ b/tests/includes/class-test-scheduler.php @@ -0,0 +1,191 @@ +user->create( + array( + 'role' => 'author', + ) + ); + } + + /** + * Clean up after tests. + */ + public static function wpTearDownAfterClass() { + wp_delete_user( self::$user_id ); + } + + /** + * Test reprocess_outbox method. + * + * @covers ::reprocess_outbox + */ + public function test_reprocess_outbox() { + // Create test activity objects. + $activity_object = new Base_Object(); + $activity_object->set_content( 'Test Content' ); + $activity_object->set_type( 'Note' ); + $activity_object->set_id( 'https://example.com/test-id' ); + + // Add multiple pending activities. + $pending_ids = array(); + for ( $i = 0; $i < 3; $i++ ) { + $pending_ids[] = Outbox::add( + $activity_object, + 'Create', + self::$user_id, + ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC + ); + } + + // Track scheduled events. + $scheduled_events = array(); + add_filter( + 'schedule_event', + function ( $event ) use ( &$scheduled_events ) { + if ( 'activitypub_process_outbox' === $event->hook ) { + $scheduled_events[] = $event->args[0]; + } + return $event; + } + ); + + // Run reprocess_outbox. + Scheduler::reprocess_outbox(); + + // Verify each pending activity was scheduled. + $this->assertCount( 3, $scheduled_events, 'Should schedule 3 activities for processing' ); + foreach ( $pending_ids as $id ) { + $this->assertContains( $id, $scheduled_events, "Activity $id should be scheduled" ); + } + + // Test with published activities (should not be scheduled). + $published_id = Outbox::add( + $activity_object, + 'Create', + self::$user_id, + ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC + ); + wp_update_post( + array( + 'ID' => $published_id, + 'post_status' => 'publish', + ) + ); + + // Reset tracked events. + $scheduled_events = array(); + + // Run reprocess_outbox again. + Scheduler::reprocess_outbox(); + + // Verify published activity was not scheduled. + $this->assertNotContains( $published_id, $scheduled_events, 'Published activity should not be scheduled' ); + + // Clean up. + foreach ( $pending_ids as $id ) { + wp_delete_post( $id, true ); + } + wp_delete_post( $published_id, true ); + remove_all_filters( 'schedule_event' ); + } + + /** + * Test reprocess_outbox with no pending activities. + * + * @covers ::reprocess_outbox + */ + public function test_reprocess_outbox_no_pending() { + $scheduled_events = array(); + add_filter( + 'schedule_event', + function ( $event ) use ( &$scheduled_events ) { + if ( 'activitypub_process_outbox' === $event->hook ) { + $scheduled_events[] = $event->args[0]; + } + return $event; + } + ); + + // Run reprocess_outbox with no pending activities. + Scheduler::reprocess_outbox(); + + // Verify no events were scheduled. + $this->assertEmpty( $scheduled_events, 'No events should be scheduled when there are no pending activities' ); + + remove_all_filters( 'schedule_event' ); + } + + /** + * Test reprocess_outbox scheduling behavior. + * + * @covers ::reprocess_outbox + */ + public function test_reprocess_outbox_scheduling() { + // Create a test activity. + $activity_object = new Base_Object(); + $activity_object->set_content( 'Test Content' ); + $activity_object->set_type( 'Note' ); + $activity_object->set_id( 'https://example.com/test-id-2' ); + + $pending_id = Outbox::add( + $activity_object, + 'Create', + self::$user_id, + ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC + ); + + // Track scheduled events and their timing. + $scheduled_time = 0; + add_filter( + 'schedule_event', + function ( $event ) use ( &$scheduled_time ) { + if ( 'activitypub_process_outbox' === $event->hook ) { + $scheduled_time = $event->timestamp; + } + return $event; + } + ); + + // Run reprocess_outbox. + Scheduler::reprocess_outbox(); + + // Verify scheduling time. + $this->assertGreaterThan( 0, $scheduled_time, 'Event should be scheduled with a future timestamp' ); + $this->assertGreaterThanOrEqual( time() + 10, $scheduled_time, 'Event should be scheduled at least 10 seconds in the future' ); + + // Clean up. + wp_delete_post( $pending_id, true ); + remove_all_filters( 'schedule_event' ); + } +} From 66007a5e8d321fa6c95334c1909d26618391794b Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 28 Jan 2025 09:17:58 -0600 Subject: [PATCH 89/98] Outbox: Publish item after processing (#1225) --- includes/class-dispatcher.php | 4 ---- tests/includes/class-test-dispatcher.php | 4 ++++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index fe347d820..d3302b60d 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -106,10 +106,6 @@ private static function send_activity_to_followers( $activity, $actor_id, $outbo $inboxes = apply_filters( 'activitypub_send_to_inboxes', array(), $actor_id, $activity ); $inboxes = array_unique( $inboxes ); - if ( empty( $inboxes ) ) { - return; - } - $json = $activity->to_json(); foreach ( $inboxes as $inbox ) { diff --git a/tests/includes/class-test-dispatcher.php b/tests/includes/class-test-dispatcher.php index b0a1ffc90..4eeee4225 100644 --- a/tests/includes/class-test-dispatcher.php +++ b/tests/includes/class-test-dispatcher.php @@ -111,6 +111,10 @@ public function test_process_outbox() { Dispatcher::process_outbox( $outbox_item->ID ); + // Check that the outbox item is now published. + $outbox_item = \get_post( $outbox_item->ID ); + $this->assertEquals( 'publish', $outbox_item->post_status ); + remove_filter( 'activitypub_send_activity_to_followers', $test_callback ); } } From 3ed9bcba98477e38df3cdd05ccc5ffc2ed6b88c6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 28 Jan 2025 16:46:48 +0100 Subject: [PATCH 90/98] Outbox: Fix `to`, `cc` and `id` (#1226) props @obenland --- includes/activity/class-base-object.php | 2 +- includes/rest/class-outbox-controller.php | 1 + .../activity/class-test-base-object.php | 61 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/includes/activity/class-test-base-object.php diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index 0ec0799e8..b2d645f69 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -578,7 +578,7 @@ public function add( $key, $value ) { $attributes[] = $value; } - $this->$key = $attributes; + $this->$key = array_unique( $attributes ); return $this->$key; } diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index ac1b2dd22..078025ec2 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -236,6 +236,7 @@ public function prepare_item_for_response( $item, $request ) { // phpcs:ignore V $type = \get_post_meta( $item->ID, '_activitypub_activity_type', true ); $transformer = Factory::get_transformer( $item->post_content ); $activity = $transformer->to_activity( $type ); + $activity->set_id( $item->guid ); return $activity->to_array( false ); } diff --git a/tests/includes/activity/class-test-base-object.php b/tests/includes/activity/class-test-base-object.php new file mode 100644 index 000000000..261e2d87c --- /dev/null +++ b/tests/includes/activity/class-test-base-object.php @@ -0,0 +1,61 @@ +set_id( 'https://example.com/test' ); + + $this->assertEquals( 'https://example.com/test', $base_object->to_string() ); + } + + /** + * Test the magic add method. + * + * @covers ::add_* Magic function. + * + * @dataProvider data_magic_add + * + * @param array $value The value to add. + * @param array $expected The expected value. + */ + public function test_magic_add( $value, $expected ) { + $base_object = new Base_Object(); + $base_object->add_to( $value ); + + $this->assertEquals( $expected, $base_object->get_to() ); + } + + /** + * Data provider for the magic add method. + * + * @return array The data provider. + */ + public function data_magic_add() { + return array( + array( 'value', array( 'value' ) ), + array( array( 'value' ), array( 'value' ) ), + array( array( 'value', 'value2' ), array( 'value', 'value2' ) ), + array( array( 'value', 'value' ), array( 'value' ) ), + ); + } +} From 97a76ee074a7a6c2dbe679bcb620c35af2b4a37c Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 28 Jan 2025 09:48:03 -0600 Subject: [PATCH 91/98] Outbox: Register new event schedule on upgrade (#1228) --- includes/class-migration.php | 1 + includes/class-scheduler.php | 1 + 2 files changed, 2 insertions(+) diff --git a/includes/class-migration.php b/includes/class-migration.php index 604f690ce..8861880b7 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -174,6 +174,7 @@ public static function maybe_migrate() { add_action( 'init', 'flush_rewrite_rules', 20 ); } if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + Scheduler::register_schedules(); \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'create_post_outbox_items' ) ); \wp_schedule_single_event( \time() + 15, 'activitypub_upgrade', array( 'create_comment_outbox_items' ) ); } diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 894d585dc..d357146b2 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -76,6 +76,7 @@ public static function register_schedules() { public static function deregister_schedules() { wp_unschedule_hook( 'activitypub_update_followers' ); wp_unschedule_hook( 'activitypub_cleanup_followers' ); + wp_unschedule_hook( 'activitypub_reprocess_outbox' ); } /** From 395a90fd4c1ccc41452226e56ffff9d127985fec Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 28 Jan 2025 09:51:38 -0600 Subject: [PATCH 92/98] Outbox: Make post type non-hierarchical (#1227) * Outbox: Make post type non-hierarchical * Default to title and content It helps when setting the post type to public to debug something --- includes/class-activitypub.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 26bd6af66..0fff04439 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -531,15 +531,11 @@ private static function register_post_types() { 'create_posts' => false, ), 'map_meta_cap' => true, - 'public' => true, - 'hierarchical' => true, 'rewrite' => false, 'query_var' => false, 'delete_with_user' => true, 'can_export' => true, - 'supports' => array(), 'exclude_from_search' => true, - 'menu_icon' => 'dashicons-networking', ) ); From 821370d259f8e634d3c9793b55d05e44c4590a6b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 29 Jan 2025 15:44:07 +0100 Subject: [PATCH 93/98] Outbox: Fix profile updates (#1233) --- includes/class-dispatcher.php | 2 +- includes/class-http.php | 9 +++- includes/functions.php | 53 +++++++++++++++++++++-- includes/rest/class-outbox-controller.php | 12 +++-- includes/transformer/class-base.php | 12 +++-- includes/transformer/class-json.php | 13 +++--- includes/transformer/class-user.php | 16 +++++-- 7 files changed, 93 insertions(+), 24 deletions(-) diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index d3302b60d..a1d4bd0a9 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -65,7 +65,7 @@ public static function process_outbox( $id ) { $activity->set_type( $type ); $activity->set_id( $outbox_item->guid ); // Pre-fill the Activity with data (for example cc and to). - $activity->set_object( Activity::init_from_json( $outbox_item->post_content ) ); + $activity->set_object( \json_decode( $outbox_item->post_content, true ) ); $activity->set_actor( Actors::get_by_id( $outbox_item->post_author )->get_id() ); // Use simple Object (only ID-URI) for Like and Announce. diff --git a/includes/class-http.php b/includes/class-http.php index 1c8c885d1..f7692d7cb 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -66,7 +66,14 @@ public static function post( $url, $body, $user_id ) { $code = \wp_remote_retrieve_response_code( $response ); if ( $code >= 400 ) { - $response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) ); + $response = new WP_Error( + $code, + __( 'Failed HTTP Request', 'activitypub' ), + array( + 'status' => $code, + 'response' => $response, + ) + ); } /** diff --git a/includes/functions.php b/includes/functions.php index 6f1ab3a2d..6a2629a9a 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1612,13 +1612,13 @@ function add_to_outbox( $data, $activity_type = 'Create', $user_id = 0, $content } /** - * Check if an object is an Activity. - * - * @param array|object $data The object to check. + * Check if an `$data` is an Activity. * * @see https://www.w3.org/ns/activitystreams#activities * - * @return boolean True if the object is an Activity, false otherwise. + * @param array|object|string $data The data to check. + * + * @return boolean True if the `$data` is an Activity, false otherwise. */ function is_activity( $data ) { /** @@ -1659,6 +1659,51 @@ function is_activity( $data ) { ) ); + 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_object( $data ) && $data instanceof Base_Object ) { + return in_array( $data->get_type(), $types, true ); + } + + return false; +} + +/** + * Check if an `$data` is an Actor. + * + * @see https://www.w3.org/ns/activitystreams#actor + * + * @param array|object|string $data The data to check. + * + * @return boolean True if the `$data` is an Actor, false otherwise. + */ +function is_actor( $data ) { + /** + * Filters the actor types. + * + * @param array $types The actor types. + */ + $types = apply_filters( + 'activitypub_actor_types', + array( + 'Application', + 'Group', + 'Organization', + 'Person', + 'Service', + ) + ); + + if ( is_string( $data ) ) { + return in_array( $data, $types, true ); + } + if ( is_array( $data ) && isset( $data['type'] ) ) { return in_array( $data['type'], $types, true ); } diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 078025ec2..507389ca2 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -232,11 +232,15 @@ public function get_items( $request ) { * @param \WP_REST_Request $request Request object. * @return array Response object on success, or WP_Error object on failure. */ - public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - $type = \get_post_meta( $item->ID, '_activitypub_activity_type', true ); - $transformer = Factory::get_transformer( $item->post_content ); - $activity = $transformer->to_activity( $type ); + public function prepare_item_for_response( $item, $request ) { + $type = \get_post_meta( $item->ID, '_activitypub_activity_type', true ); + + $activity = new Activity(); + $activity->set_type( $type ); $activity->set_id( $item->guid ); + // Pre-fill the Activity with data (for example cc and to). + $activity->set_object( \json_decode( $item->post_content, true ) ); + $activity->set_actor( Actors::get_by_various( $request->get_param( 'user_id' ) )->get_id() ); return $activity->to_array( false ); } diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 4871b1eb3..6a7852c0f 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -75,7 +75,7 @@ public function __construct( $item ) { * * @param Base_Object $activity_object The ActivityPub Object. * - * @return Base_Object The transformed ActivityPub Object. + * @return Base_Object|\WP_Error The transformed ActivityPub Object. */ protected function transform_object_properties( $activity_object ) { if ( ! $activity_object || \is_wp_error( $activity_object ) ) { @@ -268,9 +268,13 @@ public function get_replies() { /** * Returns the content map for the post. * - * @return array The content map for the post. + * @return array|null The content map for the post or null if not set. */ protected function get_content_map() { + if ( ! \method_exists( $this, 'get_content' ) || ! $this->get_content() ) { + return null; + } + return array( $this->get_locale() => $this->get_content(), ); @@ -279,7 +283,7 @@ protected function get_content_map() { /** * Returns the name map for the post. * - * @return array The name map for the post. + * @return array|null The name map for the post or null if not set. */ protected function get_name_map() { if ( ! \method_exists( $this, 'get_name' ) || ! $this->get_name() ) { @@ -294,7 +298,7 @@ protected function get_name_map() { /** * Returns the summary map for the post. * - * @return array The summary map for the post. + * @return array|null The summary map for the post or null if not set. */ protected function get_summary_map() { if ( ! \method_exists( $this, 'get_summary' ) || ! $this->get_summary() ) { diff --git a/includes/transformer/class-json.php b/includes/transformer/class-json.php index 46dd8fffb..e82b6ede2 100644 --- a/includes/transformer/class-json.php +++ b/includes/transformer/class-json.php @@ -9,6 +9,7 @@ use Activitypub\Activity\Base_Object; +use function Activitypub\is_actor; use function Activitypub\is_activity; /** @@ -22,20 +23,20 @@ class Json extends Activity_Object { * @param string|array $item The item that should be transformed. */ public function __construct( $item ) { - $object = new Base_Object(); + if ( \is_string( $item ) ) { + $item = \json_decode( $item, true ); + } // Check if the item is an Activity or an Object. if ( is_activity( $item ) ) { $class = '\Activitypub\Activity\Activity'; + } elseif ( is_actor( $item ) ) { + $class = '\Activitypub\Activity\Actor'; } else { $class = '\Activitypub\Activity\Base_Object'; } - if ( is_array( $item ) ) { - $object = $class::init_from_array( $item ); - } elseif ( is_string( $item ) ) { - $object = $class::init_from_json( $item ); - } + $object = $class::init_from_array( $item ); parent::__construct( $object ); } diff --git a/includes/transformer/class-user.php b/includes/transformer/class-user.php index 67d05ff48..131b61ee2 100644 --- a/includes/transformer/class-user.php +++ b/includes/transformer/class-user.php @@ -14,14 +14,22 @@ */ class User extends Base { /** - * Transforms the WP_User object to an ActivityPub Object + * Transforms the WP_User object to an Actor. * - * @see \Activitypub\Activity\Base_Object + * @see \Activitypub\Activity\Actor * - * @return \Activitypub\Activity\Base_Object The ActivityPub Object + * @return \Activitypub\Activity\Base_Object|\WP_Error The Actor or WP_Error on failure. */ public function to_object() { - return Actors::get_by_id( $this->item->ID ); + $activity_object = $this->transform_object_properties( Actors::get_by_id( $this->item->ID ) ); + + if ( \is_wp_error( $activity_object ) ) { + return $activity_object; + } + + $activity_object = $this->set_audience( $activity_object ); + + return $activity_object; } /** From 69453a1a721705a38a67788b45aa33eb30cdb870 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 29 Jan 2025 09:06:49 -0600 Subject: [PATCH 94/98] Outbox: Account for transformer errors (#1231) * Outbox: Account for transformer errors * Remove debug --- includes/scheduler/class-post.php | 6 ++-- integration/class-enable-mastodon-apps.php | 7 ++++- .../class-test-enable-mastodon-apps.php | 31 ++++++++++++------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 73ff132e1..943fd7dc8 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -146,9 +146,11 @@ public static function schedule_announce_activity( $outbox_activity_id, $activit } $transformer = Factory::get_transformer( $activity_object ); - $activity = $transformer->to_activity( $activity_type ); + if ( ! $transformer || \is_wp_error( $transformer ) ) { + return; + } - $outbox_activity_id = Outbox::add( $activity, 'Announce', Actors::BLOG_USER_ID, ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ); + $outbox_activity_id = Outbox::add( $transformer->to_activity( $activity_type ), 'Announce', Actors::BLOG_USER_ID ); if ( ! $outbox_activity_id ) { return; diff --git a/integration/class-enable-mastodon-apps.php b/integration/class-enable-mastodon-apps.php index 6bffa949e..8ba9d7c3e 100644 --- a/integration/class-enable-mastodon-apps.php +++ b/integration/class-enable-mastodon-apps.php @@ -361,9 +361,14 @@ public static function api_status( $status, $post_id ) { * @return Status|null The Mastodon API status object, or null if the post is not found */ private static function api_post_status( $post_id ) { - $post = Factory::get_transformer( get_post( $post_id ) ); + $post = Factory::get_transformer( get_post( $post_id ) ); + if ( is_wp_error( $post ) ) { + return null; + } + $data = $post->to_object()->to_array(); $account = self::api_account_internal( null, get_post_field( 'post_author', $post_id ) ); + return self::activity_to_status( $data, $account, $post_id ); } diff --git a/tests/integration/class-test-enable-mastodon-apps.php b/tests/integration/class-test-enable-mastodon-apps.php index 3df182df4..900319fae 100644 --- a/tests/integration/class-test-enable-mastodon-apps.php +++ b/tests/integration/class-test-enable-mastodon-apps.php @@ -128,6 +128,25 @@ public function test_api_account_followers_internal() { $this->assertEquals( 3, $account->followers_count ); } + /** + * Test api_status. + * + * @covers ::api_status + */ + public function test_api_status() { + $post_id = self::factory()->post->create( + array( + 'meta_input' => array( + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + ), + ) + ); + + $this->assertNull( Enable_Mastodon_Apps::api_status( null, $post_id ) ); + + \wp_delete_post( $post_id, true ); + } + /** * Filters the HTTP request before it is sent. * @@ -175,18 +194,6 @@ public static function pre_http_request( $preempt, $request, $url ) { return $preempt; } - /** - * Filters the HTTP response before it is returned. - * - * @param array|WP_Error $response HTTP response or WP_Error object. - * @param array $args HTTP request arguments. - * @param string $url The request URL. - * @return array|WP_Error - */ - public static function http_response( $response, $args, $url ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - return $response; - } - /** * Filters the remote metadata for a given URL. * From 86523f04b0a6e659285f326e3bd3adcaa79e3c52 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 29 Jan 2025 09:09:40 -0600 Subject: [PATCH 95/98] Outbox: Account for invalid json (#1230) * Outbox: Account for invalid json * http it is! * Remove unnecessary init --- includes/activity/class-base-object.php | 2 +- includes/collection/class-followers.php | 24 +++++-------------- includes/model/class-follower.php | 7 +++++- .../activity/class-test-base-object.php | 12 ++++++++++ .../collection/class-test-followers.php | 20 ++++++++++++++++ 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index b2d645f69..2624104f1 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -588,7 +588,7 @@ public function add( $key, $value ) { * * @param string $json The JSON string. * - * @return Base_Object An Object built from the JSON string. + * @return Base_Object|WP_Error An Object built from the JSON string or WP_Error when it's not a JSON string. */ public static function init_from_json( $json ) { $array = \json_decode( $json, true ); diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 77ade14e1..d668c4831 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -197,12 +197,8 @@ public static function get_followers_with_count( $user_id, $number = -1, $page = $args = wp_parse_args( $args, $defaults ); $query = new WP_Query( $args ); $total = $query->found_posts; - $followers = array_map( - function ( $post ) { - return Follower::init_from_cpt( $post ); - }, - $query->get_posts() - ); + $followers = array_map( array( Follower::class, 'init_from_cpt' ), $query->get_posts() ); + $followers = array_filter( $followers ); return compact( 'followers', 'total' ); } @@ -354,13 +350,9 @@ public static function get_outdated_followers( $number = 50, $older_than = 86400 ); $posts = new WP_Query( $args ); - $items = array(); - - foreach ( $posts->get_posts() as $follower ) { - $items[] = Follower::init_from_cpt( $follower ); - } + $items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() ); - return $items; + return array_filter( $items ); } /** @@ -403,13 +395,9 @@ public static function get_faulty_followers( $number = 20 ) { ); $posts = new WP_Query( $args ); - $items = array(); - - foreach ( $posts->get_posts() as $follower ) { - $items[] = Follower::init_from_cpt( $follower ); - } + $items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() ); - return $items; + return array_filter( $items ); } /** diff --git a/includes/model/class-follower.php b/includes/model/class-follower.php index bcf5d39dd..477a71886 100644 --- a/includes/model/class-follower.php +++ b/includes/model/class-follower.php @@ -331,11 +331,16 @@ public function get_shared_inbox() { * Convert a Custom-Post-Type input to an Activitypub\Model\Follower. * * @param \WP_Post $post The post object. - * @return \Activitypub\Activity\Base_Object|WP_Error + * @return \Activitypub\Activity\Base_Object|false The Follower object or false on failure. */ public static function init_from_cpt( $post ) { $actor_json = get_post_meta( $post->ID, '_activitypub_actor_json', true ); $object = self::init_from_json( $actor_json ); + + if ( is_wp_error( $object ) ) { + return false; + } + $object->set__id( $post->ID ); $object->set_id( $post->guid ); $object->set_name( $post->post_title ); diff --git a/tests/includes/activity/class-test-base-object.php b/tests/includes/activity/class-test-base-object.php index 261e2d87c..f8da047bc 100644 --- a/tests/includes/activity/class-test-base-object.php +++ b/tests/includes/activity/class-test-base-object.php @@ -58,4 +58,16 @@ public function data_magic_add() { array( array( 'value', 'value' ), array( 'value' ) ), ); } + + /** + * Test init_from_json method. + * + * @covers ::init_from_json + */ + public function test_init_from_json() { + $invalid_json = '{"@context":https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/2","type":"Note","content":"\u003Cp\u003EThis is another note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is another note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":[],"mediaType":"text\/html","sensitive":false}'; + $base_object = Base_Object::init_from_json( $invalid_json ); + + $this->assertInstanceOf( 'WP_Error', $base_object ); + } } diff --git a/tests/includes/collection/class-test-followers.php b/tests/includes/collection/class-test-followers.php index c2cd8656f..9b4a24335 100644 --- a/tests/includes/collection/class-test-followers.php +++ b/tests/includes/collection/class-test-followers.php @@ -114,6 +114,26 @@ function ( $item ) { $this->assertEquals( array( 'http://sally.example.org', 'https://example.org/author/doe', 'https://example.com/author/jon' ), $db_followers ); } + /** + * Tests get_followers with corrupted json. + * + * @covers ::get_followers + */ + public function test_get_followers_without_errors() { + $followers = array( 'https://example.com/author/jon', 'https://example.org/author/doe', 'http://sally.example.org' ); + + foreach ( $followers as $follower ) { + Followers::add_follower( 1, $follower ); + } + + $follower = Followers::get_follower( 1, 'https://example.org/author/doe' ); + update_post_meta( $follower->get__id(), '_activitypub_actor_json', 'invalid json' ); + + $db_followers = Followers::get_followers( 1 ); + + $this->assertEquals( 2, \count( $db_followers ) ); + } + /** * Tests add_follower. * From 984b49069dbd2f6a1706ed563cf8f782038d016f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 29 Jan 2025 19:01:30 +0100 Subject: [PATCH 96/98] Outbox: Fix profile updates (#1236) * Outbox: Fix profile updates This will transform the stored JSON into the correct object! * phpcs fix --- includes/activity/class-activity.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/includes/activity/class-activity.php b/includes/activity/class-activity.php index 3ab28d388..3fa8669f1 100644 --- a/includes/activity/class-activity.php +++ b/includes/activity/class-activity.php @@ -11,6 +11,9 @@ use Activitypub\Link; +use function Activitypub\is_actor; +use function Activitypub\is_activity; + /** * \Activitypub\Activity\Activity implements the common * attributes of an Activity. @@ -154,7 +157,14 @@ class Activity extends Base_Object { public function set_object( $data ) { // Convert array to object. if ( is_array( $data ) ) { - $data = self::init_from_array( $data ); + // Check if the item is an Activity or an Object. + if ( is_activity( $data ) ) { + $data = self::init_from_array( $data ); + } elseif ( is_actor( $data ) ) { + $data = Actor::init_from_array( $data ); + } else { + $data = Base_Object::init_from_array( $data ); + } } // Set object. From dc21f976124c029bcb4f9a95393ddc77bebb836f Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 30 Jan 2025 06:38:10 -0600 Subject: [PATCH 97/98] Outbox: Federate blog user updates (#1237) * Outbox: Federate blog user updates * Add right hooks and complete unit tests --- includes/scheduler/class-actor.php | 6 ++ tests/includes/scheduler/class-test-actor.php | 79 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/includes/scheduler/class-actor.php b/includes/scheduler/class-actor.php index 006e2af63..50e427799 100644 --- a/includes/scheduler/class-actor.php +++ b/includes/scheduler/class-actor.php @@ -26,6 +26,12 @@ public static function init() { \add_action( 'update_option_site_icon', array( self::class, 'blog_user_update' ) ); \add_action( 'update_option_blogdescription', array( self::class, 'blog_user_update' ) ); \add_action( 'update_option_blogname', array( self::class, 'blog_user_update' ) ); + \add_action( 'add_option_activitypub_header_image', array( self::class, 'blog_user_update' ) ); + \add_action( 'update_option_activitypub_header_image', array( self::class, 'blog_user_update' ) ); + \add_action( 'add_option_activitypub_blog_identifier', array( self::class, 'blog_user_update' ) ); + \add_action( 'update_option_activitypub_blog_identifier', array( self::class, 'blog_user_update' ) ); + \add_action( 'add_option_activitypub_blog_description', array( self::class, 'blog_user_update' ) ); + \add_action( 'update_option_activitypub_blog_description', array( self::class, 'blog_user_update' ) ); \add_filter( 'pre_set_theme_mod_custom_logo', array( self::class, 'blog_user_update' ) ); \add_filter( 'pre_set_theme_mod_header_image', array( self::class, 'blog_user_update' ) ); } diff --git a/tests/includes/scheduler/class-test-actor.php b/tests/includes/scheduler/class-test-actor.php index d178018da..599e9c45c 100644 --- a/tests/includes/scheduler/class-test-actor.php +++ b/tests/includes/scheduler/class-test-actor.php @@ -9,6 +9,7 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Extra_Fields; +use Activitypub\Scheduler\Actor; /** * Test Post scheduler class. @@ -98,6 +99,84 @@ public function test_blog_user_update() { $this->assertSame( $test_value, $result ); } + /** + * Data provider for blog user image updates. + * + * @return string[][] + */ + public function blog_user_images_provider() { + return array( + array( 'image', 'activitypub_header_image' ), + array( 'icon', 'site_icon' ), + ); + } + + /** + * Test blog user image updates. + * + * @dataProvider blog_user_images_provider + * @covers ::blog_user_update + * + * @param string $field Field to test. + * @param string $option Option to test. + */ + public function test_blog_user_image_updates( $field, $option ) { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + Actor::init(); + + $attachment_id = self::factory()->attachment->create_upload_object( dirname( __DIR__, 2 ) . '/assets/test.jpg' ); + \update_option( $option, $attachment_id ); + + $expected = array( + 'type' => 'Image', + 'url' => \wp_get_attachment_url( $attachment_id ), + ); + + $activitpub_id = Actors::get_by_id( Actors::BLOG_USER_ID )->get_id(); + $post = $this->get_latest_outbox_item( $activitpub_id ); + + $activity_object = \json_decode( $post->post_content, true ); + $this->assertArrayHasKey( $field, $activity_object ); + $this->assertSame( $expected, $activity_object[ $field ] ); + } + + /** + * Data provider for blog user text updates. + * + * @return string[][] + */ + public function blog_user_text_provider() { + return array( + array( 'preferredUsername', 'activitypub_blog_identifier', 'blog' ), + array( 'summary', 'activitypub_blog_description', 'blog description' ), + array( 'name', 'blogname', 'test site' ), + ); + } + + /** + * Test blog user image updates. + * + * @dataProvider blog_user_text_provider + * @covers ::blog_user_update + * + * @param string $field Field to test. + * @param string $option Option to test. + * @param string $value Value to test. + */ + public function test_blog_user_text_updates( $field, $option, $value ) { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + Actor::init(); + + \update_option( $option, $value ); + + $activitpub_id = Actors::get_by_id( Actors::BLOG_USER_ID )->get_id(); + $post = $this->get_latest_outbox_item( $activitpub_id ); + + $activity_object = \json_decode( $post->post_content, true ); + $this->assertArrayHasKey( $field, $activity_object ); + $this->assertStringContainsString( $value, $activity_object[ $field ] ); + } + /** * Test user update scheduling with non-publishing user. * From a3487a065481601cc884e5ddc1d03ac8faf67489 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 30 Jan 2025 06:49:16 -0600 Subject: [PATCH 98/98] Outbox: Show Outbox processing in Stream (#1229) * Outbox: Show Outbox processing in Stream * Make sure we keep them all and not just the first one * Switch to new action * Keep track of inbox URL --- includes/class-dispatcher.php | 12 +++- integration/class-stream-connector.php | 97 +++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index a1d4bd0a9..b98d867a7 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -108,10 +108,20 @@ private static function send_activity_to_followers( $activity, $actor_id, $outbo $json = $activity->to_json(); + $results = array(); foreach ( $inboxes as $inbox ) { - safe_remote_post( $inbox, $json, $actor_id ); + $results[ $inbox ] = safe_remote_post( $inbox, $json, $actor_id ); } + /** + * Fires after an Activity has been sent to all followers and mentioned users. + * + * @param array $results The results of the remote posts. + * @param Activity $activity The ActivityPub Activity. + * @param \WP_Post $outbox_item The WordPress object. + */ + do_action( 'activitypub_sent_to_followers', $results, $activity, $outbox_item ); + \wp_publish_post( $outbox_item ); } diff --git a/integration/class-stream-connector.php b/integration/class-stream-connector.php index 18ffb5daa..f7a24a40c 100644 --- a/integration/class-stream-connector.php +++ b/integration/class-stream-connector.php @@ -7,6 +7,8 @@ namespace Activitypub\Integration; +use function Activitypub\url_to_commentid; + /** * Stream Connector for ActivityPub. * @@ -29,6 +31,7 @@ class Stream_Connector extends \WP_Stream\Connector { */ public $actions = array( 'activitypub_notification_follow', + 'activitypub_sent_to_followers', ); /** @@ -55,7 +58,9 @@ public function get_context_labels() { * @return array */ public function get_action_labels() { - return array(); + return array( + 'processed' => __( 'Processed', 'activitypub' ), + ); } /** @@ -79,4 +84,94 @@ public function callback_activitypub_notification_follow( $notification ) { $notification->target ); } + + /** + * Add action links to Stream drop row in admin list screen + * + * @filter wp_stream_action_links_{connector} + * + * @param array $links Previous links registered. + * @param Record $record Stream record. + * + * @return array Action links + */ + public function action_links( $links, $record ) { + if ( 'processed' === $record->action ) { + $results = json_decode( $record->get_meta( 'results', true ), true ); + + if ( empty( $results ) ) { + $results = __( 'No inboxes to notify about this activity.', 'activitypub' ); + } else { + $results = array_map( + function ( $inbox, $result ) { + return sprintf( '%1$s: %2$s', $inbox, $result ); + }, + array_keys( $results ), + $results + ); + $results = implode( "\n", $results ); + } + + $message = sprintf( + '
%1$s
%2$s
', + __( 'Notified Inboxes', 'activitypub' ), + $results + ); + + $links[ $message ] = ''; + } + + return $links; + } + + /** + * Callback for activitypub_send_to_inboxes. + * + * @param array $results The results of the remote posts. + * @param \ActivityPub\Activity\Activity $activity The ActivityPub Activity. + * @param \WP_Post $outbox_item The WordPress object. + */ + public function callback_activitypub_sent_to_followers( $results, $activity, $outbox_item ) { + $object_id = $outbox_item->ID; + $object_type = $outbox_item->post_type; + $object_title = $outbox_item->post_title; + + $post_id = url_to_postid( $outbox_item->post_title ); + if ( $post_id ) { + $post = get_post( $post_id ); + + $object_id = $post_id; + $object_type = $post->post_type; + $object_title = $post->post_title; + } + + $comment_id = url_to_commentid( $outbox_item->post_title ); + if ( $comment_id ) { + $comment = get_comment( $comment_id ); + + $object_id = $comment_id; + $object_type = $comment->comment_type; + $object_title = $comment->comment_content; + } + + $data = array(); + foreach ( $results as $inbox => $result ) { + if ( is_wp_error( $result ) ) { + $data[ $inbox ] = $result->get_error_message(); + continue; + } + $data[ $inbox ] = wp_remote_retrieve_response_message( $result ); + } + + $this->log( + // translators: 1: post title. + sprintf( __( 'Outbox processed for "%1$s"', 'activitypub' ), $object_title ), + array( + 'results' => wp_json_encode( $data ), + ), + $object_id, + $object_type, + 'processed' + ); + } }