From 5f6cf78da16434b29d34f15f0f09eb691b8de530 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Wed, 9 Nov 2022 07:08:32 -0700 Subject: [PATCH 01/19] Add a parser to the Friends Plugin --- activitypub.php | 8 + includes/class-health-check.php | 63 ++---- includes/functions.php | 10 +- includes/model/class-activity.php | 2 +- includes/rest/class-inbox.php | 5 +- includes/rest/class-webfinger.php | 40 ++++ .../class-friends-feed-parser-activitypub.php | 198 ++++++++++++++++++ templates/author-json.php | 2 +- 8 files changed, 283 insertions(+), 45 deletions(-) create mode 100644 integration/class-friends-feed-parser-activitypub.php diff --git a/activitypub.php b/activitypub.php index d802cd7..69dfeb6 100644 --- a/activitypub.php +++ b/activitypub.php @@ -132,3 +132,11 @@ function enable_buddypress_features() { \Activitypub\Integration\Buddypress::init(); } add_action( 'bp_include', '\Activitypub\enable_buddypress_features' ); + +add_action( + 'friends_load_parsers', + function( \Friends\Feed $friends_feed ) { + require_once __DIR__ . '/integration/class-friends-feed-parser-activitypub.php'; + $friends_feed->register_parser( Friends_Feed_Parser_ActivityPub::SLUG, new Friends_Feed_Parser_ActivityPub( $friends_feed ) ); + } +); diff --git a/includes/class-health-check.php b/includes/class-health-check.php index 12f8d71..bdc3b0b 100644 --- a/includes/class-health-check.php +++ b/includes/class-health-check.php @@ -1,6 +1,8 @@ ID ); + $user = \wp_get_current_user(); + $account = \Activitypub\get_webfinger_resource( $user->ID ); - $url = \wp_parse_url( \home_url(), \PHP_URL_SCHEME ) . '://' . \wp_parse_url( \home_url(), \PHP_URL_HOST ); - - if ( \wp_parse_url( \home_url(), \PHP_URL_PORT ) ) { - $url .= ':' . \wp_parse_url( \home_url(), \PHP_URL_PORT ); - } - - $url = \trailingslashit( $url ) . '.well-known/webfinger'; - - $url = \add_query_arg( 'resource', 'acct:' . $webfinger, $url ); - - // try to access author URL - $response = \wp_remote_get( - $url, - array( - 'headers' => array( 'Accept' => 'application/activity+json' ), - 'redirection' => 0, - ) - ); - - if ( \is_wp_error( $response ) ) { - return new \WP_Error( - 'webfinger_url_not_accessible', - \sprintf( + $url = Webfinger::resolve( $account ); + if ( \is_wp_error( $url ) ) { + $health_messages = array( + 'webfinger_url_not_accessible' => \sprintf( // translators: %s: Author URL \__( '

Your WebFinger endpoint %s is not accessible. Please check your WordPress setup or permalink structure.

', 'activitypub' ), - $url - ) - ); - } - - $response_code = \wp_remote_retrieve_response_code( $response ); - - // check if response is JSON - $body = \wp_remote_retrieve_body( $response ); - - if ( ! \is_string( $body ) || ! \is_array( \json_decode( $body, true ) ) ) { - return new \WP_Error( - 'webfinger_url_not_accessible', - \sprintf( + $url->get_error_data() + ), + 'webfinger_url_invalid_response' => \sprintf( // translators: %s: Author URL \__( '

Your WebFinger endpoint %s does not return valid JSON for application/jrd+json.

', 'activitypub' ), - $url - ) + $url->get_error_data() + ), + ); + $message = null; + if ( isset( $messages[ $url->get_error_code() ] ) ) { + $message = $health_messages[ $url->get_error_code() ]; + } + return new \WP_Error( + $url->get_error_code(), + $message, + $url->get_error_data() ); } diff --git a/includes/functions.php b/includes/functions.php index 3c33664..1c1470f 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -108,11 +108,19 @@ function get_webfinger_resource( $user_id ) { /** * [get_metadata_by_actor description] * - * @param sting $actor + * @param string $actor * * @return array */ function get_remote_metadata_by_actor( $actor ) { + if ( preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $actor ) ) { + $actor = Rest\Webfinger::resolve( $actor ); + } + + if ( ! $actor ) { + return null; + } + $metadata = \get_transient( 'activitypub_' . $actor ); if ( $metadata ) { diff --git a/includes/model/class-activity.php b/includes/model/class-activity.php index eb96d11..9de1031 100644 --- a/includes/model/class-activity.php +++ b/includes/model/class-activity.php @@ -75,7 +75,7 @@ class Activity { } public function to_array() { - $array = \get_object_vars( $this ); + $array = array_filter( \get_object_vars( $this ) ); if ( $this->context ) { $array = array( '@context' => $this->context ) + $array; diff --git a/includes/rest/class-inbox.php b/includes/rest/class-inbox.php index 1ffe451..3408950 100644 --- a/includes/rest/class-inbox.php +++ b/includes/rest/class-inbox.php @@ -161,7 +161,6 @@ class Inbox { public static function shared_inbox_post( $request ) { $data = $request->get_params(); $type = $request->get_param( 'type' ); - $users = self::extract_recipients( $data ); if ( ! $users ) { @@ -407,6 +406,10 @@ class Inbox { public static function handle_create( $object, $user_id ) { $meta = \Activitypub\get_remote_metadata_by_actor( $object['actor'] ); + if ( ! isset( $object['object']['inReplyTo'] ) ) { + return; + } + $comment_post_id = \url_to_postid( $object['object']['inReplyTo'] ); // save only replys and reactions diff --git a/includes/rest/class-webfinger.php b/includes/rest/class-webfinger.php index 60eb5d2..ebf3890 100644 --- a/includes/rest/class-webfinger.php +++ b/includes/rest/class-webfinger.php @@ -120,4 +120,44 @@ class Webfinger { return $array; } + + public static function resolve( $account ) { + if ( ! preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $account, $m ) ) { + return null; + } + $url = \add_query_arg( 'resource', 'acct:' . ltrim( $account, '@' ), 'https://' . $m[1] . '/.well-known/webfinger' ); + if ( ! \wp_http_validate_url( $url ) ) { + return new \WP_Error( 'invalid_webfinger_url', null, $url ); + } + + // try to access author URL + $response = \wp_remote_get( + $url, + array( + 'headers' => array( 'Accept' => 'application/activity+json' ), + 'redirection' => 0, + ) + ); + + if ( \is_wp_error( $response ) ) { + return new \WP_Error( 'webfinger_url_not_accessible', null, $url ); + } + + $response_code = \wp_remote_retrieve_response_code( $response ); + + $body = \wp_remote_retrieve_body( $response ); + $body = \json_decode( $body, true ); + + if ( ! isset( $body['links'] ) ) { + return new \WP_Error( 'webfinger_url_invalid_response', null, $url ); + } + + foreach ( $body['links'] as $link ) { + if ( $link['rel'] === 'self' && $link['type'] == 'application/activity+json' ) { + return $link['href']; + } + } + + return new \WP_Error( 'webfinger_url_no_activity_pub', null, $body ); + } } diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php new file mode 100644 index 0000000..164e6be --- /dev/null +++ b/integration/class-friends-feed-parser-activitypub.php @@ -0,0 +1,198 @@ +friends_feed = $friends_feed; + + \add_action( 'activitypub_inbox_create', array( $this, 'handle_received_activity' ), 10, 2 ); + \add_action( 'activitypub_inbox_accept', array( $this, 'handle_received_activity' ), 10, 2 ); + \add_filter( 'friends_user_feed_activated', array( $this, 'follow_user' ), 10 ); + \add_filter( 'friends_user_feed_deactivated', array( $this, 'unfollow_user' ), 10 ); + \add_filter( 'friends_rewrite_incoming_url', array( $this, 'friends_rewrite_incoming_url' ), 10, 2 ); + } + + /** + * Determines if this is a supported feed and to what degree we feel it's supported. + * + * @param string $url The url. + * @param string $mime_type The mime type. + * @param string $title The title. + * @param string|null $content The content, it can't be assumed that it's always available. + * + * @return int Return 0 if unsupported, a positive value representing the confidence for the feed, use 10 if you're reasonably confident. + */ + public function feed_support_confidence( $url, $mime_type, $title, $content = null ) { + if ( preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $url ) ) { + return 10; + } + + return 0; + } + + /** + * Format the feed title and autoselect the posts feed. + * + * @param array $feed_details The feed details. + * + * @return array The (potentially) modified feed details. + */ + public function update_feed_details( $feed_details ) { + $meta = \Activitypub\get_remote_metadata_by_actor( $feed_details['url'] ); + if ( $meta && ! is_wp_error( $meta ) ) { + if ( isset( $meta['preferredUsername'] ) ) { + $feed_details['title'] = $meta['preferredUsername']; + } + $feed_details['url'] = $meta['id']; + } + + return $feed_details; + } + + public function friends_rewrite_incoming_url( $url, $incoming_url ) { + if ( preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $incoming_url ) ) { + $resolved_url = \Activitypub\Rest\Webfinger::resolve( $incoming_url ); + if ( ! is_wp_error( $resolved_url ) ) { + return $resolved_url; + } + } + return $url; + } + + /** + * Discover the feeds available at the URL specified. + * + * @param string $content The content for the URL is already provided here. + * @param string $url The url to search. + * + * @return array A list of supported feeds at the URL. + */ + public function discover_available_feeds( $content, $url ) { + $discovered_feeds = array(); + + $meta = \Activitypub\get_remote_metadata_by_actor( $url ); + if ( $meta && ! is_wp_error( $meta ) ) { + $discovered_feeds[ $meta['id'] ] = array( + 'type' => 'application/activity+json', + 'rel' => 'self', + 'post-format' => 'autodetect', + 'parser' => self::SLUG, + 'autoselect' => true, + ); + } + return $discovered_feeds; + } + + /** + * Fetches a feed and returns the processed items. + * + * @param string $url The url. + * + * @return array An array of feed items. + */ + public function fetch_feed( $url ) { + // There is no feed to fetch, we'll receive items via ActivityPub. + return array(); + } + + /** + * Handles "Create" requests + * + * @param array $object The activity-object + * @param int $user_id The id of the local blog-user + */ + public function handle_received_activity( $object, $user_id ) { + $user_feed = $this->friends_feed->get_user_feed_by_url( $object['actor'] ); + if ( is_wp_error( $user_feed ) ) { + $meta = \Activitypub\get_remote_metadata_by_actor( $object['actor'] ); + $user_feed = $this->friends_feed->get_user_feed_by_url( $meta['url'] ); + if ( is_wp_error( $user_feed ) ) { + // We're not following this user. + return false; + } + } + switch ( $object['type'] ) { + case 'Accept': + // nothing to do. + break; + case 'Create': + $this->handle_incoming_post( $object['object'], $user_feed ); + + } + + return true; + } + + private function map_type_to_post_format( $type ) { + return 'status'; + } + + private function handle_incoming_post( $object, \Friends\User_Feed $user_feed ) { + $item = new \Friends\Feed_Item( + array( + 'permalink' => $object['url'], + // 'title' => '', + 'content' => $object['content'], + 'post_format' => $this->map_type_to_post_format( $object['type'] ), + 'date' => $object['published'], + ) + ); + + $this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed ); + } + + public function follow_user( \Friends\User_Feed $user_feed ) { + if ( self::SLUG != $user_feed->get_parser() ) { + return; + } + + $meta = \Activitypub\get_remote_metadata_by_actor( $user_feed->get_url() ); + $to = $meta['id']; + $inbox = \Activitypub\get_inbox_by_actor( $to ); + $user_id = get_current_user_id(); + $actor = \get_author_posts_url( $user_id ); + + $activity = new \Activitypub\Model\Activity( 'Follow', \Activitypub\Model\Activity::TYPE_SIMPLE ); + $activity->set_to( null ); + $activity->set_cc( null ); + $activity->set_actor( \get_author_posts_url( $user_id ) ); + $activity->set_object( $to ); + $activity->set_id( $actor . '#follow-' . \preg_replace( '~^https?://~', '', $to ) ); + $activity = $activity->to_json(); + \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); + } + + public function unfollow_user( \Friends\User_Feed $user_feed ) { + if ( self::SLUG != $user_feed->get_parser() ) { + return; + } + + $meta = \Activitypub\get_remote_metadata_by_actor( $user_feed->get_url() ); + $to = $meta['id']; + $inbox = \Activitypub\get_inbox_by_actor( $to ); + $user_id = get_current_user_id(); + $actor = \get_author_posts_url( $user_id ); + + $activity = new \Activitypub\Model\Activity( 'Unfollow', \Activitypub\Model\Activity::TYPE_SIMPLE ); + $activity->set_to( null ); + $activity->set_cc( null ); + $activity->set_actor( \get_author_posts_url( $user_id ) ); + $activity->set_object( $to ); + $activity->set_id( $actor . '#unfollow-' . \preg_replace( '~^https?://~', '', $to ) ); + $activity = $activity->to_json(); + \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); + } +} diff --git a/templates/author-json.php b/templates/author-json.php index c5d39a5..d5a0b69 100644 --- a/templates/author-json.php +++ b/templates/author-json.php @@ -16,7 +16,7 @@ $json->preferredUsername = \get_the_author_meta( 'login', $author_id ); // phpcs $json->url = \get_author_posts_url( $author_id ); $json->icon = array( 'type' => 'Image', - 'url' => \get_avatar_url( $author_id, array( 'size' => 120 ) ), + 'url' => 'https://akirk.blog/wp-content/uploads/2022/11/alex.kirk-small.jpg', ); if ( \has_header_image() ) { From 04db99730d9bf0293a403860893d5a031f8223f1 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Wed, 9 Nov 2022 07:17:59 -0700 Subject: [PATCH 02/19] phpcs --- includes/rest/class-webfinger.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-webfinger.php b/includes/rest/class-webfinger.php index ebf3890..1b00d86 100644 --- a/includes/rest/class-webfinger.php +++ b/includes/rest/class-webfinger.php @@ -153,7 +153,7 @@ class Webfinger { } foreach ( $body['links'] as $link ) { - if ( $link['rel'] === 'self' && $link['type'] == 'application/activity+json' ) { + if ( 'self' === $link['rel'] && 'application/activity+json' === $link['type'] ) { return $link['href']; } } From eff60ed5ddca8be8ee5b5a0f9f3ea31e16460b3e Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Sun, 6 Nov 2022 16:49:53 -0700 Subject: [PATCH 03/19] Fix the signature for HTTP GET requests --- includes/class-signature.php | 6 +++--- includes/functions.php | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/includes/class-signature.php b/includes/class-signature.php index 5caf884..f78b87d 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -70,7 +70,7 @@ class Signature { \update_user_meta( $user_id, 'magic_sig_public_key', $detail['key'] ); } - public static function generate_signature( $user_id, $url, $date, $digest = null ) { + public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) { $key = self::get_private_key( $user_id ); $url_parts = \wp_parse_url( $url ); @@ -89,9 +89,9 @@ class Signature { } if ( ! empty( $digest ) ) { - $signed_string = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest"; + $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest"; } else { - $signed_string = "(request-target): post $path\nhost: $host\ndate: $date"; + $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date"; } $signature = null; diff --git a/includes/functions.php b/includes/functions.php index 1c1470f..1b1269c 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -35,7 +35,7 @@ function get_context() { function safe_remote_post( $url, $body, $user_id ) { $date = \gmdate( 'D, d M Y H:i:s T' ); $digest = \Activitypub\Signature::generate_digest( $body ); - $signature = \Activitypub\Signature::generate_signature( $user_id, $url, $date, $digest ); + $signature = \Activitypub\Signature::generate_signature( $user_id, 'post', $url, $date, $digest ); $wp_version = \get_bloginfo( 'version' ); $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) ); @@ -63,7 +63,7 @@ function safe_remote_post( $url, $body, $user_id ) { function safe_remote_get( $url, $user_id ) { $date = \gmdate( 'D, d M Y H:i:s T' ); - $signature = \Activitypub\Signature::generate_signature( $user_id, $url, $date ); + $signature = \Activitypub\Signature::generate_signature( $user_id, 'get', $url, $date ); $wp_version = \get_bloginfo( 'version' ); $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) ); From 568b258c771dc73a0814ff5c1a36fd0f9df28989 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Wed, 9 Nov 2022 07:27:05 -0700 Subject: [PATCH 04/19] undo temp change --- templates/author-json.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/author-json.php b/templates/author-json.php index d5a0b69..c5d39a5 100644 --- a/templates/author-json.php +++ b/templates/author-json.php @@ -16,7 +16,7 @@ $json->preferredUsername = \get_the_author_meta( 'login', $author_id ); // phpcs $json->url = \get_author_posts_url( $author_id ); $json->icon = array( 'type' => 'Image', - 'url' => 'https://akirk.blog/wp-content/uploads/2022/11/alex.kirk-small.jpg', + 'url' => \get_avatar_url( $author_id, array( 'size' => 120 ) ), ); if ( \has_header_image() ) { From 3def5832697fedc8ab72b5bd902767d5b102acf6 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Wed, 9 Nov 2022 07:27:50 -0700 Subject: [PATCH 05/19] typo --- includes/class-health-check.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-health-check.php b/includes/class-health-check.php index bdc3b0b..8989c2f 100644 --- a/includes/class-health-check.php +++ b/includes/class-health-check.php @@ -224,7 +224,7 @@ class Health_Check { ), ); $message = null; - if ( isset( $messages[ $url->get_error_code() ] ) ) { + if ( isset( $health_messages[ $url->get_error_code() ] ) ) { $message = $health_messages[ $url->get_error_code() ]; } return new \WP_Error( From 4300c579aa64f77ed8951787ff2ff06d9205686c Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Mon, 14 Nov 2022 20:04:01 -0500 Subject: [PATCH 06/19] Queue the activitypub request --- .../class-friends-feed-parser-activitypub.php | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index 164e6be..293322f 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -20,8 +20,10 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { \add_action( 'activitypub_inbox_create', array( $this, 'handle_received_activity' ), 10, 2 ); \add_action( 'activitypub_inbox_accept', array( $this, 'handle_received_activity' ), 10, 2 ); - \add_filter( 'friends_user_feed_activated', array( $this, 'follow_user' ), 10 ); - \add_filter( 'friends_user_feed_deactivated', array( $this, 'unfollow_user' ), 10 ); + \add_filter( 'friends_user_feed_activated', array( $this, 'queue_follow_user' ), 10 ); + \add_filter( 'friends_user_feed_deactivated', array( $this, 'queue_unfollow_user' ), 10 ); + \add_filter( 'friends_feed_parser_activitypub_follow', array( $this, 'follow_user' ), 10 ); + \add_filter( 'friends_feed_parser_activitypub_unfollow', array( $this, 'unfollow_user' ), 10 ); \add_filter( 'friends_rewrite_incoming_url', array( $this, 'friends_rewrite_incoming_url' ), 10, 2 ); } @@ -154,6 +156,18 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed ); } + public function queue_follow_user( \Friends\User_Feed $user_feed ) { + if ( self::SLUG != $user_feed->get_parser() ) { + return; + } + + if ( wp_next_scheduled( 'friends_feed_parser_activitypub_follow', array( $user_feed ) ) ) { + return; + } + + return \wp_schedule_single_event( \time(), 'friends_feed_parser_activitypub_follow', array( $user_feed ) ); + } + public function follow_user( \Friends\User_Feed $user_feed ) { if ( self::SLUG != $user_feed->get_parser() ) { return; @@ -175,6 +189,18 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); } + public function queue_unfollow_user( \Friends\User_Feed $user_feed ) { + if ( self::SLUG != $user_feed->get_parser() ) { + return; + } + + if ( wp_next_scheduled( 'friends_feed_parser_activitypub_unfollow', array( $user_feed ) ) ) { + return; + } + + return \wp_schedule_single_event( \time(), 'friends_feed_parser_activitypub_unfollow', array( $user_feed ) ); + } + public function unfollow_user( \Friends\User_Feed $user_feed ) { if ( self::SLUG != $user_feed->get_parser() ) { return; From 8ab20c5de0efdaa4afc2b711ce6b26da87c89eff Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Tue, 15 Nov 2022 18:46:40 +0100 Subject: [PATCH 07/19] Don't use full object as cron parameters --- .../class-friends-feed-parser-activitypub.php | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index 293322f..d2f75b5 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -20,10 +20,10 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { \add_action( 'activitypub_inbox_create', array( $this, 'handle_received_activity' ), 10, 2 ); \add_action( 'activitypub_inbox_accept', array( $this, 'handle_received_activity' ), 10, 2 ); - \add_filter( 'friends_user_feed_activated', array( $this, 'queue_follow_user' ), 10 ); - \add_filter( 'friends_user_feed_deactivated', array( $this, 'queue_unfollow_user' ), 10 ); - \add_filter( 'friends_feed_parser_activitypub_follow', array( $this, 'follow_user' ), 10 ); - \add_filter( 'friends_feed_parser_activitypub_unfollow', array( $this, 'unfollow_user' ), 10 ); + \add_action( 'friends_user_feed_activated', array( $this, 'queue_follow_user' ), 10 ); + \add_action( 'friends_user_feed_deactivated', array( $this, 'queue_unfollow_user' ), 10 ); + \add_action( 'friends_feed_parser_activitypub_follow', array( $this, 'follow_user' ), 10, 2 ); + \add_action( 'friends_feed_parser_activitypub_unfollow', array( $this, 'unfollow_user' ), 10, 2 ); \add_filter( 'friends_rewrite_incoming_url', array( $this, 'friends_rewrite_incoming_url' ), 10, 2 ); } @@ -161,14 +161,16 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { return; } - if ( wp_next_scheduled( 'friends_feed_parser_activitypub_follow', array( $user_feed ) ) ) { + $args = array( $user_feed->get_id(), get_current_user_id() ); + if ( wp_next_scheduled( 'friends_feed_parser_activitypub_follow', $args ) ) { return; } - return \wp_schedule_single_event( \time(), 'friends_feed_parser_activitypub_follow', array( $user_feed ) ); + return \wp_schedule_single_event( \time(), 'friends_feed_parser_activitypub_follow', $args ); } - public function follow_user( \Friends\User_Feed $user_feed ) { + public function follow_user( $user_feed_id, $user_id ) { + $user_feed = \Friends\User_Feed::get_by_id( $user_feed_id ); if ( self::SLUG != $user_feed->get_parser() ) { return; } @@ -176,7 +178,6 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $meta = \Activitypub\get_remote_metadata_by_actor( $user_feed->get_url() ); $to = $meta['id']; $inbox = \Activitypub\get_inbox_by_actor( $to ); - $user_id = get_current_user_id(); $actor = \get_author_posts_url( $user_id ); $activity = new \Activitypub\Model\Activity( 'Follow', \Activitypub\Model\Activity::TYPE_SIMPLE ); @@ -194,14 +195,16 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { return; } - if ( wp_next_scheduled( 'friends_feed_parser_activitypub_unfollow', array( $user_feed ) ) ) { + $args = array( $user_feed->get_id(), get_current_user_id() ); + if ( wp_next_scheduled( 'friends_feed_parser_activitypub_unfollow', $args ) ) { return; } - return \wp_schedule_single_event( \time(), 'friends_feed_parser_activitypub_unfollow', array( $user_feed ) ); + return \wp_schedule_single_event( \time(), 'friends_feed_parser_activitypub_unfollow', $args ); } - public function unfollow_user( \Friends\User_Feed $user_feed ) { + public function unfollow_user( $user_feed_id, $user_id ) { + $user_feed = \Friends\User_Feed::get_by_id( $user_feed_id ); if ( self::SLUG != $user_feed->get_parser() ) { return; } @@ -209,7 +212,6 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $meta = \Activitypub\get_remote_metadata_by_actor( $user_feed->get_url() ); $to = $meta['id']; $inbox = \Activitypub\get_inbox_by_actor( $to ); - $user_id = get_current_user_id(); $actor = \get_author_posts_url( $user_id ); $activity = new \Activitypub\Model\Activity( 'Unfollow', \Activitypub\Model\Activity::TYPE_SIMPLE ); From 4cc9cda67a5d309248270982751347dcbfd342c1 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Tue, 15 Nov 2022 20:04:01 +0100 Subject: [PATCH 08/19] Remove potentially queued reverse follow/unfollow events --- .../class-friends-feed-parser-activitypub.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index d2f75b5..ef5265d 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -162,6 +162,13 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { } $args = array( $user_feed->get_id(), get_current_user_id() ); + + $unfollow_timestamp = wp_next_scheduled( 'friends_feed_parser_activitypub_unfollow', $args ); + if ( $unfollow_timestamp ) { + // If we just unfollowed, we don't want the event to potentially be executed after our follow event. + wp_unschedule_event( $unfollow_timestamp, $args ); + } + if ( wp_next_scheduled( 'friends_feed_parser_activitypub_follow', $args ) ) { return; } @@ -196,6 +203,13 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { } $args = array( $user_feed->get_id(), get_current_user_id() ); + + $follow_timestamp = wp_next_scheduled( 'friends_feed_parser_activitypub_follow', $args ); + if ( $follow_timestamp ) { + // If we just followed, we don't want the event to potentially be executed after our unfollow event. + wp_unschedule_event( $follow_timestamp, $args ); + } + if ( wp_next_scheduled( 'friends_feed_parser_activitypub_unfollow', $args ) ) { return; } From 8320856e6a74f0ef81620ce705f72b86b9744634 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 18 Nov 2022 21:28:40 +0100 Subject: [PATCH 09/19] Suggest better display name and username --- .../class-friends-feed-parser-activitypub.php | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index ef5265d..021f30e 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -54,11 +54,18 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { */ public function update_feed_details( $feed_details ) { $meta = \Activitypub\get_remote_metadata_by_actor( $feed_details['url'] ); - if ( $meta && ! is_wp_error( $meta ) ) { - if ( isset( $meta['preferredUsername'] ) ) { - $feed_details['title'] = $meta['preferredUsername']; - } - $feed_details['url'] = $meta['id']; + if ( ! $meta && is_wp_error( $meta ) ) { + return $meta; + } + + if ( isset( $meta['name'] ) ) { + $feed_details['title'] = $meta['name']; + } elseif ( isset( $meta['preferredUsername'] ) ) { + $feed_details['title'] = $meta['preferredUsername']; + } + + if ( isset( $meta['url'] ) ) { + $feed_details['url'] = $meta['url']; } return $feed_details; @@ -90,7 +97,7 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $discovered_feeds[ $meta['id'] ] = array( 'type' => 'application/activity+json', 'rel' => 'self', - 'post-format' => 'autodetect', + 'post-format' => 'status', 'parser' => self::SLUG, 'autoselect' => true, ); From f2b77251cef1ff059fad14f2924426417d47779e Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 18 Nov 2022 21:41:54 +0100 Subject: [PATCH 10/19] Add doc blocks --- .../class-friends-feed-parser-activitypub.php | 82 ++++++++++++++----- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index 021f30e..9832fe8 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -15,6 +15,11 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { const URL = 'https://www.w3.org/TR/activitypub/'; private $friends_feed; + /** + * Constructor. + * + * @param \Friends\Feed $friends_feed The friends feed + */ public function __construct( \Friends\Feed $friends_feed ) { $this->friends_feed = $friends_feed; @@ -71,6 +76,14 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { return $feed_details; } + /** + * Rewrite a Mastodon style URL @username@server to a URL via webfinger. + * + * @param string $url The URL to filter. + * @param string $incoming_url Potentially a mastodon identifier. + * + * @return ( description_of_the_return_value ) + */ public function friends_rewrite_incoming_url( $url, $incoming_url ) { if ( preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $incoming_url ) ) { $resolved_url = \Activitypub\Rest\Webfinger::resolve( $incoming_url ); @@ -145,10 +158,23 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { return true; } + /** + * Map the Activity type to a post fomat. + * + * @param string $type The type. + * + * @return string The determined post format. + */ private function map_type_to_post_format( $type ) { return 'status'; } + /** + * We received a post for a feed, handle it. + * + * @param array $object The object from ActivityPub. + * @param \Friends\User_Feed $user_feed The user feed. + */ private function handle_incoming_post( $object, \Friends\User_Feed $user_feed ) { $item = new \Friends\Feed_Item( array( @@ -163,12 +189,19 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed ); } + /** + * Prepare to follow the user via a scheduled event. + * + * @param \Friends\User_Feed $user_feed The user feed. + * + * @return bool|WP_Error Whether the event was queued. + */ public function queue_follow_user( \Friends\User_Feed $user_feed ) { if ( self::SLUG != $user_feed->get_parser() ) { return; } - $args = array( $user_feed->get_id(), get_current_user_id() ); + $args = array( $user_feed->get_url(), get_current_user_id() ); $unfollow_timestamp = wp_next_scheduled( 'friends_feed_parser_activitypub_unfollow', $args ); if ( $unfollow_timestamp ) { @@ -183,13 +216,14 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { return \wp_schedule_single_event( \time(), 'friends_feed_parser_activitypub_follow', $args ); } - public function follow_user( $user_feed_id, $user_id ) { - $user_feed = \Friends\User_Feed::get_by_id( $user_feed_id ); - if ( self::SLUG != $user_feed->get_parser() ) { - return; - } - - $meta = \Activitypub\get_remote_metadata_by_actor( $user_feed->get_url() ); + /** + * Follow a user via ActivityPub at a URL. + * + * @param string $url The url. + * @param int $user_id The current user id. + */ + public function follow_user( $url, $user_id ) { + $meta = \Activitypub\get_remote_metadata_by_actor( $url ); $to = $meta['id']; $inbox = \Activitypub\get_inbox_by_actor( $to ); $actor = \get_author_posts_url( $user_id ); @@ -197,19 +231,26 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $activity = new \Activitypub\Model\Activity( 'Follow', \Activitypub\Model\Activity::TYPE_SIMPLE ); $activity->set_to( null ); $activity->set_cc( null ); - $activity->set_actor( \get_author_posts_url( $user_id ) ); + $activity->set_actor( $actor ); $activity->set_object( $to ); $activity->set_id( $actor . '#follow-' . \preg_replace( '~^https?://~', '', $to ) ); $activity = $activity->to_json(); \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); } + /** + * Prepare to unfollow the user via a scheduled event. + * + * @param \Friends\User_Feed $user_feed The user feed. + * + * @return bool|WP_Error Whether the event was queued. + */ public function queue_unfollow_user( \Friends\User_Feed $user_feed ) { if ( self::SLUG != $user_feed->get_parser() ) { - return; + return false; } - $args = array( $user_feed->get_id(), get_current_user_id() ); + $args = array( $user_feed->get_url(), get_current_user_id() ); $follow_timestamp = wp_next_scheduled( 'friends_feed_parser_activitypub_follow', $args ); if ( $follow_timestamp ) { @@ -218,19 +259,20 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { } if ( wp_next_scheduled( 'friends_feed_parser_activitypub_unfollow', $args ) ) { - return; + return true; } return \wp_schedule_single_event( \time(), 'friends_feed_parser_activitypub_unfollow', $args ); } - public function unfollow_user( $user_feed_id, $user_id ) { - $user_feed = \Friends\User_Feed::get_by_id( $user_feed_id ); - if ( self::SLUG != $user_feed->get_parser() ) { - return; - } - - $meta = \Activitypub\get_remote_metadata_by_actor( $user_feed->get_url() ); + /** + * Unfllow a user via ActivityPub at a URL. + * + * @param string $url The url. + * @param int $user_id The current user id. + */ + public function unfollow_user( $url, $user_id ) { + $meta = \Activitypub\get_remote_metadata_by_actor( $url ); $to = $meta['id']; $inbox = \Activitypub\get_inbox_by_actor( $to ); $actor = \get_author_posts_url( $user_id ); @@ -238,7 +280,7 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $activity = new \Activitypub\Model\Activity( 'Unfollow', \Activitypub\Model\Activity::TYPE_SIMPLE ); $activity->set_to( null ); $activity->set_cc( null ); - $activity->set_actor( \get_author_posts_url( $user_id ) ); + $activity->set_actor( $actor ); $activity->set_object( $to ); $activity->set_id( $actor . '#unfollow-' . \preg_replace( '~^https?://~', '', $to ) ); $activity = $activity->to_json(); From c2a19a175c13e648226cc45a34d1e93d3884a324 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 18 Nov 2022 22:04:39 +0100 Subject: [PATCH 11/19] Replace unfollow with undo follow --- .../class-friends-feed-parser-activitypub.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index 9832fe8..49a89c8 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -69,8 +69,8 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $feed_details['title'] = $meta['preferredUsername']; } - if ( isset( $meta['url'] ) ) { - $feed_details['url'] = $meta['url']; + if ( isset( $meta['id'] ) ) { + $feed_details['url'] = $meta['id']; } return $feed_details; @@ -277,11 +277,16 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $inbox = \Activitypub\get_inbox_by_actor( $to ); $actor = \get_author_posts_url( $user_id ); - $activity = new \Activitypub\Model\Activity( 'Unfollow', \Activitypub\Model\Activity::TYPE_SIMPLE ); + $activity = new \Activitypub\Model\Activity( 'Undo', \Activitypub\Model\Activity::TYPE_SIMPLE ); $activity->set_to( null ); $activity->set_cc( null ); $activity->set_actor( $actor ); - $activity->set_object( $to ); + $activity->set_object( array( + 'type' => 'Follow', + 'actor' => $actor, + 'object' => $to, + 'id' => $to, + ) ); $activity->set_id( $actor . '#unfollow-' . \preg_replace( '~^https?://~', '', $to ) ); $activity = $activity->to_json(); \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); From 60cf0889d08702aa9c70aa41ee83079eaeb1d4ed Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 25 Nov 2022 11:05:34 +0100 Subject: [PATCH 12/19] lint fixes --- .../class-friends-feed-parser-activitypub.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index 49a89c8..480800f 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -179,7 +179,6 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $item = new \Friends\Feed_Item( array( 'permalink' => $object['url'], - // 'title' => '', 'content' => $object['content'], 'post_format' => $this->map_type_to_post_format( $object['type'] ), 'date' => $object['published'], @@ -197,7 +196,7 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { * @return bool|WP_Error Whether the event was queued. */ public function queue_follow_user( \Friends\User_Feed $user_feed ) { - if ( self::SLUG != $user_feed->get_parser() ) { + if ( self::SLUG !== $user_feed->get_parser() ) { return; } @@ -246,7 +245,7 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { * @return bool|WP_Error Whether the event was queued. */ public function queue_unfollow_user( \Friends\User_Feed $user_feed ) { - if ( self::SLUG != $user_feed->get_parser() ) { + if ( self::SLUG !== $user_feed->get_parser() ) { return false; } @@ -281,12 +280,14 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $activity->set_to( null ); $activity->set_cc( null ); $activity->set_actor( $actor ); - $activity->set_object( array( - 'type' => 'Follow', - 'actor' => $actor, - 'object' => $to, - 'id' => $to, - ) ); + $activity->set_object( + array( + 'type' => 'Follow', + 'actor' => $actor, + 'object' => $to, + 'id' => $to, + ) + ); $activity->set_id( $actor . '#unfollow-' . \preg_replace( '~^https?://~', '', $to ) ); $activity = $activity->to_json(); \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); From 7036a659917dad33b94a0fada7f96b4e4f4c957f Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 2 Dec 2022 11:30:52 +0100 Subject: [PATCH 13/19] Add support for announce activities --- .../class-friends-feed-parser-activitypub.php | 115 +++++++++++++++--- 1 file changed, 100 insertions(+), 15 deletions(-) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index 480800f..cc4b236 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -23,8 +23,7 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { public function __construct( \Friends\Feed $friends_feed ) { $this->friends_feed = $friends_feed; - \add_action( 'activitypub_inbox_create', array( $this, 'handle_received_activity' ), 10, 2 ); - \add_action( 'activitypub_inbox_accept', array( $this, 'handle_received_activity' ), 10, 2 ); + \add_action( 'activitypub_inbox', array( $this, 'handle_received_activity' ), 10, 3 ); \add_action( 'friends_user_feed_activated', array( $this, 'queue_follow_user' ), 10 ); \add_action( 'friends_user_feed_deactivated', array( $this, 'queue_unfollow_user' ), 10 ); \add_action( 'friends_feed_parser_activitypub_follow', array( $this, 'follow_user' ), 10, 2 ); @@ -32,6 +31,16 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { \add_filter( 'friends_rewrite_incoming_url', array( $this, 'friends_rewrite_incoming_url' ), 10, 2 ); } + /** + * Allow logging a message via an action. + * @param string $message The message to log. + * @param array $objects Optional objects as meta data. + * @return void + */ + private function log( $message, $objects = array() ) { + do_action( 'friends_activitypub_log', $message, $objects ); + } + /** * Determines if this is a supported feed and to what degree we feel it's supported. * @@ -135,23 +144,53 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { * * @param array $object The activity-object * @param int $user_id The id of the local blog-user + * @param string $type The type of the activity. */ - public function handle_received_activity( $object, $user_id ) { - $user_feed = $this->friends_feed->get_user_feed_by_url( $object['actor'] ); - if ( is_wp_error( $user_feed ) ) { - $meta = \Activitypub\get_remote_metadata_by_actor( $object['actor'] ); - $user_feed = $this->friends_feed->get_user_feed_by_url( $meta['url'] ); - if ( is_wp_error( $user_feed ) ) { - // We're not following this user. + public function handle_received_activity( $object, $user_id, $type ) { + if ( ! in_array( + $type, + array( + // We don't need to handle 'Accept' types since it's handled by the ActivityPub plugin itself. + 'create', + 'announce', + ) + ) ) { + return false; + } + + $actor_url = $object['actor']; + $user_feed = false; + if ( \wp_http_validate_url( $actor_url ) ) { + // Let's check if we follow this actor. If not it might be a different URL representation. + $user_feed = $this->friends_feed->get_user_feed_by_url( $actor_url ); + } + + if ( is_wp_error( $user_feed ) || ! \wp_http_validate_url( $actor_url ) ) { + $meta = \Activitypub\get_remote_metadata_by_actor( $actor_url ); + if ( ! $meta || ! isset( $meta['url'] ) ) { + $this->log( 'Received invalid meta for ' . $actor_url ); + return false; + } + + $actor_url = $meta['url']; + if ( ! \wp_http_validate_url( $actor_url ) ) { + $this->log( 'Received invalid meta url for ' . $actor_url ); return false; } } - switch ( $object['type'] ) { - case 'Accept': - // nothing to do. - break; - case 'Create': - $this->handle_incoming_post( $object['object'], $user_feed ); + + $user_feed = $this->friends_feed->get_user_feed_by_url( $actor_url ); + if ( ! $user_feed || is_wp_error( $user_feed ) ) { + $this->log( 'We\'re not following ' . $actor_url ); + // We're not following this user. + return false; + } + + switch ( $type ) { + case 'create': + return $this->handle_incoming_post( $object['object'], $user_feed ); + case 'announce': + return $this->handle_incoming_announce( $object['object'], $user_feed, $user_id ); } @@ -188,6 +227,52 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed ); } + /** + * We received an announced URL (boost) for a feed, handle it. + * + * @param array $url The announced URL. + * @param \Friends\User_Feed $user_feed The user feed. + */ + private function handle_incoming_announce( $url, \Friends\User_Feed $user_feed, $user_id ) { + $this->log( 'Received announce for ' . $url ); + if ( ! \wp_http_validate_url( $url ) ) { + return false; + } + $response = \Activitypub\safe_remote_get( $url, $user_id ); + if ( \is_wp_error( $response ) ) { + return $response; + } + $json = \wp_remote_retrieve_body( $response ); + $object = \json_decode( $json, true ); + if ( ! $object ) { + $this->log( 'Received invalid json', compact( 'json' ) ); + return false; + } + $this->log( 'Received response', compact( 'url', 'object' ) ); + + $data = array( + 'permalink' => $url, + 'content' => $object['content'], + 'post_format' => $this->map_type_to_post_format( $object['type'] ), + 'date' => $object['published'], + ); + + if ( isset( $object['attributedTo'] ) ) { + $meta = \Activitypub\get_remote_metadata_by_actor( $object['attributedTo'] ); + $this->log( 'Attributed to ' . $object['attributedTo'], compact( 'meta' ) ); + if ( isset( $meta['name'] ) ) { + $data['author'] = $meta['name']; + } elseif ( isset( $meta['preferredUsername'] ) ) { + $data['author'] = $meta['preferredUsername']; + } + } + $this->log( 'Received feed item', compact( 'url', 'data' ) ); + + $item = new \Friends\Feed_Item( $data ); + + $this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed ); + } + /** * Prepare to follow the user via a scheduled event. * From a82dea0685363418885bf3141429e32a318dadd2 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 2 Dec 2022 12:46:42 +0100 Subject: [PATCH 14/19] Add unit test --- .gitignore | 2 + includes/functions.php | 4 + .../class-friends-feed-parser-activitypub.php | 58 ++--- tests/bootstrap.php | 4 + ...-class-friends-feed-parser-activitypub.php | 200 ++++++++++++++++++ 5 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 tests/test-class-friends-feed-parser-activitypub.php diff --git a/.gitignore b/.gitignore index 6012ccb..aa42b00 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ composer.lock .DS_Store .idea/ .php_cs.cache +.phpunit.result.cache + diff --git a/includes/functions.php b/includes/functions.php index 1b1269c..290bb57 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -113,6 +113,10 @@ function get_webfinger_resource( $user_id ) { * @return array */ function get_remote_metadata_by_actor( $actor ) { + $pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor ); + if ( $pre ) { + return $pre; + } if ( preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $actor ) ) { $actor = Rest\Webfinger::resolve( $actor ); } diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index cc4b236..80eabc7 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -153,7 +153,8 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { // We don't need to handle 'Accept' types since it's handled by the ActivityPub plugin itself. 'create', 'announce', - ) + ), + true ) ) { return false; } @@ -215,16 +216,35 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { * @param \Friends\User_Feed $user_feed The user feed. */ private function handle_incoming_post( $object, \Friends\User_Feed $user_feed ) { - $item = new \Friends\Feed_Item( - array( - 'permalink' => $object['url'], - 'content' => $object['content'], - 'post_format' => $this->map_type_to_post_format( $object['type'] ), - 'date' => $object['published'], - ) + $data = array( + 'permalink' => $object['url'], + 'content' => $object['content'], + 'post_format' => $this->map_type_to_post_format( $object['type'] ), + 'date' => $object['published'], ); + if ( isset( $object['attributedTo'] ) ) { + $meta = \Activitypub\get_remote_metadata_by_actor( $object['attributedTo'] ); + $this->log( 'Attributed to ' . $object['attributedTo'], compact( 'meta' ) ); + if ( isset( $meta['name'] ) ) { + $override_author = $meta['name']; + } elseif ( isset( $meta['preferredUsername'] ) ) { + $override_author = $meta['preferredUsername']; + } + } + + $this->log( + 'Received feed item', + array( + 'url' => $object['url'], + 'data' => $data, + ) + ); + $item = new \Friends\Feed_Item( $data ); + $this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed ); + + return true; } /** @@ -250,27 +270,7 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { } $this->log( 'Received response', compact( 'url', 'object' ) ); - $data = array( - 'permalink' => $url, - 'content' => $object['content'], - 'post_format' => $this->map_type_to_post_format( $object['type'] ), - 'date' => $object['published'], - ); - - if ( isset( $object['attributedTo'] ) ) { - $meta = \Activitypub\get_remote_metadata_by_actor( $object['attributedTo'] ); - $this->log( 'Attributed to ' . $object['attributedTo'], compact( 'meta' ) ); - if ( isset( $meta['name'] ) ) { - $data['author'] = $meta['name']; - } elseif ( isset( $meta['preferredUsername'] ) ) { - $data['author'] = $meta['preferredUsername']; - } - } - $this->log( 'Received feed item', compact( 'url', 'data' ) ); - - $item = new \Friends\Feed_Item( $data ); - - $this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed ); + return $this->handle_incoming_post( $object, $user_feed ); } /** diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9acf920..67da18a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -19,6 +19,10 @@ require_once $_tests_dir . '/includes/functions.php'; */ function _manually_load_plugin() { require \dirname( \dirname( __FILE__ ) ) . '/activitypub.php'; + $friends_plugin = \dirname( \dirname( \dirname( __FILE__ ) ) ) . '/friends/friends.php'; + if ( file_exists( $friends_plugin ) ) { + require $friends_plugin; + } } \tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); diff --git a/tests/test-class-friends-feed-parser-activitypub.php b/tests/test-class-friends-feed-parser-activitypub.php new file mode 100644 index 0000000..a7ac7a8 --- /dev/null +++ b/tests/test-class-friends-feed-parser-activitypub.php @@ -0,0 +1,200 @@ +markTestSkipped( 'The Friends plugin is not loaded.' ); + } + parent::set_up(); + + // Manually activate the REST server. + global $wp_rest_server; + $wp_rest_server = new \Spy_REST_Server(); + $this->server = $wp_rest_server; + do_action( 'rest_api_init' ); + + add_filter( + 'rest_url', + function() { + return get_option( 'home' ) . '/wp-json/'; + } + ); + + add_filter( 'pre_http_request', array( get_called_class(), 'pre_http_request' ), 10, 3 ); + add_filter( 'http_request_host_is_external', array( get_called_class(), 'http_request_host_is_external' ), 10, 2 ); + add_filter( 'http_request_args', array( get_called_class(), 'http_request_args' ), 10, 2 ); + add_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ), 10, 2 ); + + } + + public function tear_down() { + remove_filter( 'pre_http_request', array( get_called_class(), 'pre_http_request' ) ); + remove_filter( 'http_request_host_is_external', array( get_called_class(), 'http_request_host_is_external' ) ); + remove_filter( 'http_request_args', array( get_called_class(), 'http_request_args' ) ); + remove_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ) ); + } + + public static function pre_get_remote_metadata_by_actor( $pre, $actor ) { + if ( isset( self::$users[ $actor ] ) ) { + return self::$users[ $actor ]; + } + return $pre; + } + public static function http_request_host_is_external( $in, $host ) { + if ( in_array( $host, array( 'mastodon.local' ), true ) ) { + return true; + } + return $in; + } + public static function http_request_args( $args, $url ) { + if ( in_array( parse_url( $url, PHP_URL_HOST ), array( 'mastodon.local' ), true ) ) { + $args['reject_unsafe_urls'] = false; + } + return $args; + } + public static function pre_http_request( $preempt, $request, $url ) { + $home_url = home_url(); + + // Pretend the url now is the requested one. + update_option( 'home', $p['scheme'] . '://' . $p['host'] ); + $rest_prefix = home_url() . '/wp-json'; + + if ( false === strpos( $url, $rest_prefix ) ) { + // Restore the old home_url. + update_option( 'home', $home_url ); + return $preempt; + } + + $url = substr( $url, strlen( $rest_prefix ) ); + $r = new \WP_REST_Request( $request['method'], $url ); + if ( ! empty( $request['body'] ) ) { + foreach ( $request['body'] as $key => $value ) { + $r->set_param( $key, $value ); + } + } + global $wp_rest_server; + $response = $wp_rest_server->dispatch( $r ); + // Restore the old url. + update_option( 'home', $home_url ); + + return apply_filters( + 'fake_http_response', + array( + 'headers' => array( + 'content-type' => 'text/json', + ), + 'body' => wp_json_encode( $response->data ), + 'response' => array( + 'code' => $response->status, + ), + ), + $p['scheme'] . '://' . $p['host'], + $url, + $request + ); + } + + public function test_incoming_post() { + $now = time() - 10; + $status_id = 123; + + $friend_name = 'Alex'; + $actor = 'https://mastodon.local/users/alex'; + + $friend_id = $this->factory->user->create( + array( + 'user_login' => 'alex-mastodon.local', + 'display_name' => $friend_name, + 'role' => 'friend', + ) + ); + \Friends\User_Feed::save( + new \Friends\User( $friend_id ), + $actor, + array( + 'parser' => 'activitypub', + ) + ); + + self::$users[ $actor ] = array( + 'url' => $actor, + 'name' => $friend_name, + ); + self::$users['https://mastodon.local/@alex'] = self::$users[ $actor ]; + + $posts = get_posts( + array( + 'post_type' => \Friends\Friends::CPT, + 'author' => $friend_id, + ) + ); + + $this->assertEquals( 0, count( $posts ) ); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); + $request->set_param( 'type', 'Create' ); + $request->set_param( 'id', 'test1' ); + $request->set_param( 'actor', $actor ); + $date = date( \DATE_W3C, $now++ ); + $content = 'Test ' . $date . ' ' . rand(); + $request->set_param( + 'object', + array( + 'type' => 'Note', + 'id' => 'test1', + 'attributedTo' => $actor, + 'content' => $content, + 'url' => 'https://mastodon.local/users/alex/statuses/' . ( $status_id++ ), + 'published' => $date, + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 202, $response->get_status() ); + + $posts = get_posts( + array( + 'post_type' => \Friends\Friends::CPT, + 'author' => $friend_id, + ) + ); + + $this->assertEquals( 1, count( $posts ) ); + $this->assertEquals( $content, $posts[0]->post_content ); + $this->assertEquals( $friend_id, $posts[0]->post_author ); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); + $request->set_param( 'type', 'Create' ); + $request->set_param( 'id', 'test1' ); + $request->set_param( 'actor', 'https://mastodon.local/@alex' ); + $date = date( \DATE_W3C, $now++ ); + $content = 'Test ' . $date . ' ' . rand(); + $request->set_param( + 'object', + array( + 'type' => 'Note', + 'id' => 'test2', + 'attributedTo' => 'https://mastodon.local/@alex', + 'content' => $content, + 'url' => 'https://mastodon.local/users/alex/statuses/' . ( $status_id++ ), + 'published' => $date, + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 202, $response->get_status() ); + + $posts = get_posts( + array( + 'post_type' => \Friends\Friends::CPT, + 'author' => $friend_id, + ) + ); + + $this->assertEquals( 2, count( $posts ) ); + $this->assertEquals( $content, $posts[0]->post_content ); + $this->assertEquals( $friend_id, $posts[0]->post_author ); + } +} From b3a26788eb4e02784d65b3cf729c5df4fd2ce241 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 2 Dec 2022 13:43:09 +0100 Subject: [PATCH 15/19] Add test for announce --- .../class-friends-feed-parser-activitypub.php | 16 +- tests/bootstrap.php | 2 + ...z-blog-2022-11-14-the-at-protocol.response | 1 + ...-class-friends-feed-parser-activitypub.php | 201 ++++++++++++++---- 4 files changed, 169 insertions(+), 51 deletions(-) create mode 100644 tests/fixtures/notiz-blog-2022-11-14-the-at-protocol.response diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index 80eabc7..79ded63 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -156,9 +156,8 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { ), true ) ) { - return false; + return false; } - $actor_url = $object['actor']; $user_feed = false; if ( \wp_http_validate_url( $actor_url ) ) { @@ -216,8 +215,13 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { * @param \Friends\User_Feed $user_feed The user feed. */ private function handle_incoming_post( $object, \Friends\User_Feed $user_feed ) { + $permalink = $object['id']; + if ( isset( $object['url'] ) ) { + $permalink = $object['url']; + } + $data = array( - 'permalink' => $object['url'], + 'permalink' => $permalink, 'content' => $object['content'], 'post_format' => $this->map_type_to_post_format( $object['type'] ), 'date' => $object['published'], @@ -236,7 +240,7 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $this->log( 'Received feed item', array( - 'url' => $object['url'], + 'url' => $permalink, 'data' => $data, ) ); @@ -254,10 +258,12 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { * @param \Friends\User_Feed $user_feed The user feed. */ private function handle_incoming_announce( $url, \Friends\User_Feed $user_feed, $user_id ) { - $this->log( 'Received announce for ' . $url ); if ( ! \wp_http_validate_url( $url ) ) { + $this->log( 'Received invalid announce', compact( 'url' ) ); return false; } + $this->log( 'Received announce for ' . $url ); + $response = \Activitypub\safe_remote_get( $url, $user_id ); if ( \is_wp_error( $response ) ) { return $response; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 67da18a..3867ab2 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -19,6 +19,8 @@ require_once $_tests_dir . '/includes/functions.php'; */ function _manually_load_plugin() { require \dirname( \dirname( __FILE__ ) ) . '/activitypub.php'; + + // Load the Friends plugin if available to test the integrations. $friends_plugin = \dirname( \dirname( \dirname( __FILE__ ) ) ) . '/friends/friends.php'; if ( file_exists( $friends_plugin ) ) { require $friends_plugin; diff --git a/tests/fixtures/notiz-blog-2022-11-14-the-at-protocol.response b/tests/fixtures/notiz-blog-2022-11-14-the-at-protocol.response new file mode 100644 index 0000000..d6705a1 --- /dev/null +++ b/tests/fixtures/notiz-blog-2022-11-14-the-at-protocol.response @@ -0,0 +1 @@ +a:3:{s:7:"headers";a:15:{s:4:"date";s:29:"Fri, 02 Dec 2022 12:09:11 GMT";s:12:"content-type";s:25:"application/activity+json";s:6:"server";s:5:"nginx";s:15:"x-xrds-location";s:24:"https://notiz.blog/?xrds";s:16:"x-yadis-location";s:24:"https://notiz.blog/?xrds";s:10:"x-pingback";s:29:"https://notiz.blog/xmlrpc.php";s:4:"link";s:541:"; rel="micropub_media", ; rel="micropub", ; rel="friends-base-url", ; rel="webmention", ; rel="http://webmention.org/", ; rel="https://api.w.org/", ; rel="alternate"; type="application/json", ; rel=shortlink";s:13:"cache-control";s:17:"max-age=0, public";s:7:"expires";s:29:"Fri, 02 Dec 2022 12:09:11 GMT";s:16:"x-xss-protection";s:13:"1; mode=block";s:22:"x-content-type-options";s:7:"nosniff";s:25:"strict-transport-security";s:16:"max-age=31536000";s:15:"x-frame-options";s:10:"SAMEORIGIN";s:15:"referrer-policy";s:31:"strict-origin-when-cross-origin";s:17:"x-clacks-overhead";s:19:"GNU Terry Pratchett";}s:4:"body";s:18048:"{"@context":["https:\/\/www.w3.org\/ns\/activitystreams","https:\/\/w3id.org\/security\/v1",{"manuallyApprovesFollowers":"as:manuallyApprovesFollowers","PropertyValue":"schema:PropertyValue","schema":"http:\/\/schema.org#","pt":"https:\/\/joinpeertube.org\/ns#","toot":"http:\/\/joinmastodon.org\/ns#","value":"schema:value","Hashtag":"as:Hashtag","featured":{"@id":"toot:featured","@type":"@id"},"featuredTags":{"@id":"toot:featuredTags","@type":"@id"}}],"id":"https:\/\/notiz.blog\/2022\/11\/14\/the-at-protocol\/","type":"Note","published":"2022-11-14T16:49:01Z","attributedTo":"https:\/\/notiz.blog\/author\/matthias-pfefferle\/","summary":null,"inReplyTo":null,"content":"\u003Cp\u003EVor zwei Jahren wollte Twitter in das \u201eDezentrale Netzwerke\u201c-Business einsteigen und gr\u00fcndete eigens daf\u00fcr das \u003Ca href=\u0022https:\/\/notiz.blog\/2019\/12\/13\/twitiverse\/\u0022 data-type=\u0022post\u0022 data-id=\u002218831\u0022\u003EProjekt Bluesky\u003C\/a\u003E. In den folgenden zwei Jahren wurde viel evaluiert und diskutiert, was wohl die beste L\u00f6sung f\u00fcr Twitter sei und wir alle \u003Cem\u003Efieberten\u003C\/em\u003E mit ob es nun \u003Ca href=\u0022https:\/\/www.w3.org\/TR\/activitypub\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/www.w3.org\/TR\/activitypub\/\u0022\u003EActivityPub\u003C\/a\u003E oder doch \u003Ca href=\u0022https:\/\/matrix.org\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/matrix.org\/\u0022\u003EMatrix\u003C\/a\u003E werden w\u00fcrde\u2026 \u003C\/p\u003E\u003Cp\u003EAber das Warten hat ein Ende! \u003Ca href=\u0022https:\/\/blueskyweb.xyz\/blog\/10-18-2022-the-at-protocol\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/blueskyweb.xyz\/blog\/10-18-2022-the-at-protocol\u0022\u003EBluesky hat verk\u00fcndet wie es weiter geht\u003C\/a\u003E!\u003C\/p\u003E\u003Cp\u003E\u003Cstrong\u003ESie entwickeln ein neues Protokoll!\u003C\/strong\u003E\u003C\/p\u003E\u003Cp\u003EDas \u003Cem\u003EAT Protocol\u003C\/em\u003E, kurz f\u00fcr \u003Cem\u003EAuthenticated Transfer Protocol\u003C\/em\u003E!\u003C\/p\u003E\u003Cp\u003EIch hab mir die \u003Ca href=\u0022https:\/\/atproto.com\/guides\/faq\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/atproto.com\/guides\/faq\u0022\u003EFAQ\u003C\/a\u003E mal angeschaut und dort steht warum Bluesky sich gegen ActivityPub entschieden hat:\u003C\/p\u003E\u003Cblockquote class=\u0022wp-block-quote\u0022\u003E\n\u003Cp\u003EAccount portability is the major reason why we chose to build a separate protocol. We consider portability to be crucial because it protects users from sudden bans, server shutdowns, and policy disagreements. Our solution for portability requires both \u003Ca href=\u0022https:\/\/atproto.com\/guides\/data-repos\u0022\u003Esigned data repositories\u003C\/a\u003E and \u003Ca href=\u0022https:\/\/atproto.com\/guides\/identity\u0022\u003EDIDs\u003C\/a\u003E, neither of which are easy to retrofit into ActivityPub. The migration tools for ActivityPub are comparatively limited; they require the original server to provide a redirect and cannot migrate the user\u2019s previous data.\u003C\/p\u003E\n\u003C\/blockquote\u003E\u003Cp\u003EDas erinnert mich ein bisschen an die Subline von meinem \u003Ca href=\u0022https:\/\/pfefferle.dev\/openwebicons\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/pfefferle.dev\/openwebicons\/\u0022\u003EOpenWeb-Icons Font\u003C\/a\u003E:\u003C\/p\u003E\u003Cblockquote class=\u0022wp-block-quote\u0022\u003E\n\u003Cp\u003EWhy \u003Cem\u003EOpenWeb Icons\u003C\/em\u003E? Because \u003Ca href=\u0022https:\/\/fortawesome.com\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/fortawesome.com\/\u0022\u003EFont Awesome\u003C\/a\u003E had no RSS-icon [\u2026]\u003C\/p\u003E\n\u003C\/blockquote\u003E\u003Cp\u003E\u003Cstrong\u003EWeil ActivityPub keine perfekte L\u00f6sung f\u00fcr \u201eAccount portability\u201c hat, bauen sie ein komplett neues Protokoll?\u003C\/strong\u003E\u003C\/p\u003E\u003Cp\u003EActivityPub ist sicherlich nicht \u201efeature complete\u201c, aber ein guter erster Wurf, was das Fediverse erfolgreich bewiesen hat! Warum arbeitet Twitter also lieber an einem eigen Format anstatt mit dem W3C zusammen an ActivityPub v2?\u003C\/p\u003E\u003Cp\u003EWarum macht sich das W3C \u00fcberhaupt noch die M\u00fche \u201eStandards\u201c zu definieren?\u003C\/p\u003E\u003Cp\u003E\u003Cstrong\u003EWegen der Interoperabilit\u00e4t!\u003C\/strong\u003E\u003C\/p\u003E\u003Cp\u003EW\u00fcrde Twitter mit HTTP(S), HTML oder CSS \u00e4hnlich umgehen, w\u00fcrde der Browser einfach leer bleiben, weil das \u0026$%\u00a7\u0026 Internet nur mit einheitlichen Standards funktioniert!\u003C\/p\u003E\u003Cp\u003EUnd das gleiche gilt auch f\u00fcr dezentralte Netze, zumindest wenn sie erfolgreich sein wollen! Dar\u00fcber hab ich tragischerweise schon vor \u003Cstrong\u003E10 Jahren\u003C\/strong\u003E geschrieben!\u003C\/p\u003E\u003Cblockquote class=\u0022wp-block-quote\u0022\u003E\n\u003Cp\u003EDiaspora* wurde kaum \u003Ca href=\u0022https:\/\/web.archive.org\/web\/20130630113539\/http:\/\/blog.diasporafoundation.org\/2012\/08\/27\/announcement-diaspora-will-now-be-a-community-project.html\u0022\u003Ef\u00fcr \u201etot\u201c erkl\u00e4rt\u003C\/a\u003E und schon steht das n\u00e4chste Projekt in den Startl\u00f6chern! \u003Ca href=\u0022https:\/\/web.archive.org\/web\/20190603031810\/https:\/\/tent.io\/\u0022\u003ETent.io\u003C\/a\u003E soll ein protocol for distributed social networking and personal data storage werden. Alles neu, alles anders, alles besser als OStatus, DiSo oder Diaspora*. Aber mal ganz ehrlich\u2026 was haben die Diasporas \u0026 Co. bisher geschaffen? Ziel war es Facebooks \u201eWalled Gardens\u201c aufzubrechen und was kam wirklich dabei rum? Eine ganze Reihe an dezentralen \u201eWalled Gardens\u201c. Na danke!\u003C\/p\u003E\n\u003Ccite\u003E\u003Ca href=\u0022https:\/\/notiz.blog\/2012\/11\/15\/dezentrale-walled-gardens\/\u0022\u003EDezentrale \u201eWalled Gardens\u201c\u003C\/a\u003E\u003C\/cite\u003E\u003C\/blockquote\u003E\u003Cp\u003EDas \u003Ca href=\u0022https:\/\/the-federation.info\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/the-federation.info\/\u0022\u003Efediverse\u003C\/a\u003E hat (wie schon erw\u00e4hnt) bisher einen gro\u00dfartigen Job gemacht und verschiedenste Netzwerke mit den verschiedensten Auspr\u00e4gungen vernetzt! Ich \u003Cs\u003Eglaube\u003C\/s\u003E bin der festen \u00dcberzeugung, dass sich diesmal wirklich das offene Format (\u003Cem\u003EActivityPub\u003C\/em\u003E) durchsetzen wird und Blueskys \u003Cem\u003EAuthenticated Transfer Protocol\u003C\/em\u003E auch in ein paar Monaten oder Jahren keine Rolle spielen wird! \u003C\/p\u003E\u003Cp\u003E\u003Ca href=\u0022https:\/\/twitter.com\/benwerd\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/twitter.com\/benwerd\u0022\u003EBen Werdmuller\u003C\/a\u003E hat eine gesunde Einstellung zu dem Thema:\u003C\/p\u003E\u003Cblockquote class=\u0022wp-block-quote\u0022\u003E\n\u003Cp\u003EI\u2019m so burned out by open source social, but I\u2019m glad to see people throw energy at the problem, even if it\u2019s not how I would have gone about it.\u003C\/p\u003E\n\u003Ccite\u003E\u003Ca href=\u0022https:\/\/twitter.com\/benwerd\/status\/1582554417693270016\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/twitter.com\/benwerd\/status\/1582554417693270016\u0022\u003ETwitter\u003C\/a\u003E\u003C\/cite\u003E\u003C\/blockquote\u003E\u003Cp\u003EMehr hab ich dazu eigentlich nicht zu sagen, au\u00dfer dass wir in der \u003Ca href=\u0022https:\/\/neunetz.fm\/neunetzcast-93-was-wir-unter-dezentralitaet-verstehen-und-was-wir-uns-davon-erhoffen\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/neunetz.fm\/neunetzcast-93-was-wir-unter-dezentralitaet-verstehen-und-was-wir-uns-davon-erhoffen\/\u0022\u003Eaktuellen Folge\u003C\/a\u003E des neunetzcasts sehr ausgiebig \u00fcber genau dieses Problem gesprochen haben!\u003C\/p\u003E\u003Cp\u003E\u003Ca rel=\u0022tag\u0022 class=\u0022u-tag u-category\u0022 href=\u0022https:\/\/notiz.blog\/tag\/activitypub\/\u0022\u003E#activitypub\u003C\/a\u003E \u003Ca rel=\u0022tag\u0022 class=\u0022u-tag u-category\u0022 href=\u0022https:\/\/notiz.blog\/tag\/bluesky\/\u0022\u003E#bluesky\u003C\/a\u003E \u003Ca rel=\u0022tag\u0022 class=\u0022u-tag u-category\u0022 href=\u0022https:\/\/notiz.blog\/tag\/fediverse\/\u0022\u003E#fediverse\u003C\/a\u003E \u003Ca rel=\u0022tag\u0022 class=\u0022u-tag u-category\u0022 href=\u0022https:\/\/notiz.blog\/tag\/matrix\/\u0022\u003E#matrix\u003C\/a\u003E \u003Ca rel=\u0022tag\u0022 class=\u0022u-tag u-category\u0022 href=\u0022https:\/\/notiz.blog\/tag\/twitter\/\u0022\u003E#twitter\u003C\/a\u003E\u003C\/p\u003E\u003Cp\u003E\u003Ca href=\u0022https:\/\/notiz.blog\/2022\/11\/14\/the-at-protocol\/\u0022\u003Ehttps:\/\/notiz.blog\/2022\/11\/14\/the-at-protocol\/\u003C\/a\u003E\u003C\/p\u003E","contentMap":{"de":"\u003Cp\u003EVor zwei Jahren wollte Twitter in das \u201eDezentrale Netzwerke\u201c-Business einsteigen und gr\u00fcndete eigens daf\u00fcr das \u003Ca href=\u0022https:\/\/notiz.blog\/2019\/12\/13\/twitiverse\/\u0022 data-type=\u0022post\u0022 data-id=\u002218831\u0022\u003EProjekt Bluesky\u003C\/a\u003E. In den folgenden zwei Jahren wurde viel evaluiert und diskutiert, was wohl die beste L\u00f6sung f\u00fcr Twitter sei und wir alle \u003Cem\u003Efieberten\u003C\/em\u003E mit ob es nun \u003Ca href=\u0022https:\/\/www.w3.org\/TR\/activitypub\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/www.w3.org\/TR\/activitypub\/\u0022\u003EActivityPub\u003C\/a\u003E oder doch \u003Ca href=\u0022https:\/\/matrix.org\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/matrix.org\/\u0022\u003EMatrix\u003C\/a\u003E werden w\u00fcrde\u2026 \u003C\/p\u003E\u003Cp\u003EAber das Warten hat ein Ende! \u003Ca href=\u0022https:\/\/blueskyweb.xyz\/blog\/10-18-2022-the-at-protocol\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/blueskyweb.xyz\/blog\/10-18-2022-the-at-protocol\u0022\u003EBluesky hat verk\u00fcndet wie es weiter geht\u003C\/a\u003E!\u003C\/p\u003E\u003Cp\u003E\u003Cstrong\u003ESie entwickeln ein neues Protokoll!\u003C\/strong\u003E\u003C\/p\u003E\u003Cp\u003EDas \u003Cem\u003EAT Protocol\u003C\/em\u003E, kurz f\u00fcr \u003Cem\u003EAuthenticated Transfer Protocol\u003C\/em\u003E!\u003C\/p\u003E\u003Cp\u003EIch hab mir die \u003Ca href=\u0022https:\/\/atproto.com\/guides\/faq\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/atproto.com\/guides\/faq\u0022\u003EFAQ\u003C\/a\u003E mal angeschaut und dort steht warum Bluesky sich gegen ActivityPub entschieden hat:\u003C\/p\u003E\u003Cblockquote class=\u0022wp-block-quote\u0022\u003E\n\u003Cp\u003EAccount portability is the major reason why we chose to build a separate protocol. We consider portability to be crucial because it protects users from sudden bans, server shutdowns, and policy disagreements. Our solution for portability requires both \u003Ca href=\u0022https:\/\/atproto.com\/guides\/data-repos\u0022\u003Esigned data repositories\u003C\/a\u003E and \u003Ca href=\u0022https:\/\/atproto.com\/guides\/identity\u0022\u003EDIDs\u003C\/a\u003E, neither of which are easy to retrofit into ActivityPub. The migration tools for ActivityPub are comparatively limited; they require the original server to provide a redirect and cannot migrate the user\u2019s previous data.\u003C\/p\u003E\n\u003C\/blockquote\u003E\u003Cp\u003EDas erinnert mich ein bisschen an die Subline von meinem \u003Ca href=\u0022https:\/\/pfefferle.dev\/openwebicons\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/pfefferle.dev\/openwebicons\/\u0022\u003EOpenWeb-Icons Font\u003C\/a\u003E:\u003C\/p\u003E\u003Cblockquote class=\u0022wp-block-quote\u0022\u003E\n\u003Cp\u003EWhy \u003Cem\u003EOpenWeb Icons\u003C\/em\u003E? Because \u003Ca href=\u0022https:\/\/fortawesome.com\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/fortawesome.com\/\u0022\u003EFont Awesome\u003C\/a\u003E had no RSS-icon [\u2026]\u003C\/p\u003E\n\u003C\/blockquote\u003E\u003Cp\u003E\u003Cstrong\u003EWeil ActivityPub keine perfekte L\u00f6sung f\u00fcr \u201eAccount portability\u201c hat, bauen sie ein komplett neues Protokoll?\u003C\/strong\u003E\u003C\/p\u003E\u003Cp\u003EActivityPub ist sicherlich nicht \u201efeature complete\u201c, aber ein guter erster Wurf, was das Fediverse erfolgreich bewiesen hat! Warum arbeitet Twitter also lieber an einem eigen Format anstatt mit dem W3C zusammen an ActivityPub v2?\u003C\/p\u003E\u003Cp\u003EWarum macht sich das W3C \u00fcberhaupt noch die M\u00fche \u201eStandards\u201c zu definieren?\u003C\/p\u003E\u003Cp\u003E\u003Cstrong\u003EWegen der Interoperabilit\u00e4t!\u003C\/strong\u003E\u003C\/p\u003E\u003Cp\u003EW\u00fcrde Twitter mit HTTP(S), HTML oder CSS \u00e4hnlich umgehen, w\u00fcrde der Browser einfach leer bleiben, weil das \u0026$%\u00a7\u0026 Internet nur mit einheitlichen Standards funktioniert!\u003C\/p\u003E\u003Cp\u003EUnd das gleiche gilt auch f\u00fcr dezentralte Netze, zumindest wenn sie erfolgreich sein wollen! Dar\u00fcber hab ich tragischerweise schon vor \u003Cstrong\u003E10 Jahren\u003C\/strong\u003E geschrieben!\u003C\/p\u003E\u003Cblockquote class=\u0022wp-block-quote\u0022\u003E\n\u003Cp\u003EDiaspora* wurde kaum \u003Ca href=\u0022https:\/\/web.archive.org\/web\/20130630113539\/http:\/\/blog.diasporafoundation.org\/2012\/08\/27\/announcement-diaspora-will-now-be-a-community-project.html\u0022\u003Ef\u00fcr \u201etot\u201c erkl\u00e4rt\u003C\/a\u003E und schon steht das n\u00e4chste Projekt in den Startl\u00f6chern! \u003Ca href=\u0022https:\/\/web.archive.org\/web\/20190603031810\/https:\/\/tent.io\/\u0022\u003ETent.io\u003C\/a\u003E soll ein protocol for distributed social networking and personal data storage werden. Alles neu, alles anders, alles besser als OStatus, DiSo oder Diaspora*. Aber mal ganz ehrlich\u2026 was haben die Diasporas \u0026 Co. bisher geschaffen? Ziel war es Facebooks \u201eWalled Gardens\u201c aufzubrechen und was kam wirklich dabei rum? Eine ganze Reihe an dezentralen \u201eWalled Gardens\u201c. Na danke!\u003C\/p\u003E\n\u003Ccite\u003E\u003Ca href=\u0022https:\/\/notiz.blog\/2012\/11\/15\/dezentrale-walled-gardens\/\u0022\u003EDezentrale \u201eWalled Gardens\u201c\u003C\/a\u003E\u003C\/cite\u003E\u003C\/blockquote\u003E\u003Cp\u003EDas \u003Ca href=\u0022https:\/\/the-federation.info\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/the-federation.info\/\u0022\u003Efediverse\u003C\/a\u003E hat (wie schon erw\u00e4hnt) bisher einen gro\u00dfartigen Job gemacht und verschiedenste Netzwerke mit den verschiedensten Auspr\u00e4gungen vernetzt! Ich \u003Cs\u003Eglaube\u003C\/s\u003E bin der festen \u00dcberzeugung, dass sich diesmal wirklich das offene Format (\u003Cem\u003EActivityPub\u003C\/em\u003E) durchsetzen wird und Blueskys \u003Cem\u003EAuthenticated Transfer Protocol\u003C\/em\u003E auch in ein paar Monaten oder Jahren keine Rolle spielen wird! \u003C\/p\u003E\u003Cp\u003E\u003Ca href=\u0022https:\/\/twitter.com\/benwerd\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/twitter.com\/benwerd\u0022\u003EBen Werdmuller\u003C\/a\u003E hat eine gesunde Einstellung zu dem Thema:\u003C\/p\u003E\u003Cblockquote class=\u0022wp-block-quote\u0022\u003E\n\u003Cp\u003EI\u2019m so burned out by open source social, but I\u2019m glad to see people throw energy at the problem, even if it\u2019s not how I would have gone about it.\u003C\/p\u003E\n\u003Ccite\u003E\u003Ca href=\u0022https:\/\/twitter.com\/benwerd\/status\/1582554417693270016\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/twitter.com\/benwerd\/status\/1582554417693270016\u0022\u003ETwitter\u003C\/a\u003E\u003C\/cite\u003E\u003C\/blockquote\u003E\u003Cp\u003EMehr hab ich dazu eigentlich nicht zu sagen, au\u00dfer dass wir in der \u003Ca href=\u0022https:\/\/neunetz.fm\/neunetzcast-93-was-wir-unter-dezentralitaet-verstehen-und-was-wir-uns-davon-erhoffen\/\u0022 data-type=\u0022URL\u0022 data-id=\u0022https:\/\/neunetz.fm\/neunetzcast-93-was-wir-unter-dezentralitaet-verstehen-und-was-wir-uns-davon-erhoffen\/\u0022\u003Eaktuellen Folge\u003C\/a\u003E des neunetzcasts sehr ausgiebig \u00fcber genau dieses Problem gesprochen haben!\u003C\/p\u003E\u003Cp\u003E\u003Ca rel=\u0022tag\u0022 class=\u0022u-tag u-category\u0022 href=\u0022https:\/\/notiz.blog\/tag\/activitypub\/\u0022\u003E#activitypub\u003C\/a\u003E \u003Ca rel=\u0022tag\u0022 class=\u0022u-tag u-category\u0022 href=\u0022https:\/\/notiz.blog\/tag\/bluesky\/\u0022\u003E#bluesky\u003C\/a\u003E \u003Ca rel=\u0022tag\u0022 class=\u0022u-tag u-category\u0022 href=\u0022https:\/\/notiz.blog\/tag\/fediverse\/\u0022\u003E#fediverse\u003C\/a\u003E \u003Ca rel=\u0022tag\u0022 class=\u0022u-tag u-category\u0022 href=\u0022https:\/\/notiz.blog\/tag\/matrix\/\u0022\u003E#matrix\u003C\/a\u003E \u003Ca rel=\u0022tag\u0022 class=\u0022u-tag u-category\u0022 href=\u0022https:\/\/notiz.blog\/tag\/twitter\/\u0022\u003E#twitter\u003C\/a\u003E\u003C\/p\u003E\u003Cp\u003E\u003Ca href=\u0022https:\/\/notiz.blog\/2022\/11\/14\/the-at-protocol\/\u0022\u003Ehttps:\/\/notiz.blog\/2022\/11\/14\/the-at-protocol\/\u003C\/a\u003E\u003C\/p\u003E"},"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"attachment":[{"type":"Image","url":"https:\/\/notiz.blog\/wp-content\/uploads\/2022\/11\/the-at-protocol.png","mediaType":"image\/png","name":"The Logo of the AT Protocol"}],"tag":[{"type":"Hashtag","href":"https:\/\/notiz.blog\/tag\/activitypub\/","name":"#activitypub"},{"type":"Hashtag","href":"https:\/\/notiz.blog\/tag\/bluesky\/","name":"#bluesky"},{"type":"Hashtag","href":"https:\/\/notiz.blog\/tag\/fediverse\/","name":"#fediverse"},{"type":"Hashtag","href":"https:\/\/notiz.blog\/tag\/matrix\/","name":"#matrix"},{"type":"Hashtag","href":"https:\/\/notiz.blog\/tag\/twitter\/","name":"#twitter"}]}";s:8:"response";a:1:{s:4:"code";i:200;}} \ No newline at end of file diff --git a/tests/test-class-friends-feed-parser-activitypub.php b/tests/test-class-friends-feed-parser-activitypub.php index a7ac7a8..6d1568f 100644 --- a/tests/test-class-friends-feed-parser-activitypub.php +++ b/tests/test-class-friends-feed-parser-activitypub.php @@ -2,6 +2,9 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { public static $users = array(); + private $friend_id; + private $friend_name; + private $actor; public function set_up() { if ( ! class_exists( '\Friends\Friends' ) ) { @@ -23,14 +26,52 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { ); add_filter( 'pre_http_request', array( get_called_class(), 'pre_http_request' ), 10, 3 ); + add_filter( 'http_response', array( get_called_class(), 'http_response' ), 10, 3 ); add_filter( 'http_request_host_is_external', array( get_called_class(), 'http_request_host_is_external' ), 10, 2 ); add_filter( 'http_request_args', array( get_called_class(), 'http_request_args' ), 10, 2 ); add_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ), 10, 2 ); + $user_id = $this->factory->user->create( + array( + 'role' => 'administrator', + ) + ); + wp_set_current_user( $user_id ); + + $this->friend_name = 'Alex Kirk'; + $this->actor = 'https://mastodon.local/users/akirk'; + + $user_feed = \Friends\User_Feed::get_by_url( $this->actor ); + if ( is_wp_error( $user_feed ) ) { + $this->friend_id = $this->factory->user->create( + array( + 'display_name' => $this->friend_name, + 'role' => 'friend', + ) + ); + \Friends\User_Feed::save( + new \Friends\User( $this->friend_id ), + $this->actor, + array( + 'parser' => 'activitypub', + ) + ); + } else { + $this->friend_id = $user_feed->get_friend_user()->ID; + } + + self::$users[ $this->actor ] = array( + 'url' => $this->actor, + 'name' => $this->friend_name, + ); + self::$users['https://mastodon.local/@akirk'] = self::$users[ $this->actor ]; + + _delete_all_posts(); } public function tear_down() { remove_filter( 'pre_http_request', array( get_called_class(), 'pre_http_request' ) ); + remove_filter( 'http_response', array( get_called_class(), 'http_response' ) ); remove_filter( 'http_request_host_is_external', array( get_called_class(), 'http_request_host_is_external' ) ); remove_filter( 'http_request_args', array( get_called_class(), 'http_request_args' ) ); remove_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ) ); @@ -55,6 +96,18 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { return $args; } public static function pre_http_request( $preempt, $request, $url ) { + $p = wp_parse_url( $url ); + $cache = __DIR__ . '/fixtures/' . sanitize_title( $p['host'] . '-' . $p['path'] ) . '.response'; + if ( file_exists( $cache ) ) { + return apply_filters( + 'fake_http_response', + unserialize( file_get_contents( $cache ) ), + $p['scheme'] . '://' . $p['host'], + $url, + $request + ); + } + $home_url = home_url(); // Pretend the url now is the requested one. @@ -96,57 +149,58 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { ); } + public static function http_response( $response, $args, $url ) { + $p = wp_parse_url( $url ); + $cache = __DIR__ . '/fixtures/' . sanitize_title( $p['host'] . '-' . $p['path'] ) . '.response'; + if ( ! file_exists( $cache ) ) { + $headers = wp_remote_retrieve_headers( $response ); + file_put_contents( + $cache, + serialize( + array( + 'headers' => $headers->getAll(), + 'body' => wp_remote_retrieve_body( $response ), + 'response' => array( + 'code' => wp_remote_retrieve_response_code( $response ), + ), + ) + ) + ); + } + return $response; + } + public function test_incoming_post() { $now = time() - 10; $status_id = 123; - $friend_name = 'Alex'; - $actor = 'https://mastodon.local/users/alex'; - - $friend_id = $this->factory->user->create( - array( - 'user_login' => 'alex-mastodon.local', - 'display_name' => $friend_name, - 'role' => 'friend', - ) - ); - \Friends\User_Feed::save( - new \Friends\User( $friend_id ), - $actor, - array( - 'parser' => 'activitypub', - ) - ); - - self::$users[ $actor ] = array( - 'url' => $actor, - 'name' => $friend_name, - ); - self::$users['https://mastodon.local/@alex'] = self::$users[ $actor ]; - $posts = get_posts( array( 'post_type' => \Friends\Friends::CPT, - 'author' => $friend_id, + 'author' => $this->friend_id, ) ); - $this->assertEquals( 0, count( $posts ) ); + $post_count = count( $posts ); + + // Let's post a new Note through the REST API. + $date = gmdate( \DATE_W3C, $now++ ); + $id = 'test' . $status_id; + $content = 'Test ' . $date . ' ' . rand(); $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); $request->set_param( 'type', 'Create' ); - $request->set_param( 'id', 'test1' ); - $request->set_param( 'actor', $actor ); - $date = date( \DATE_W3C, $now++ ); - $content = 'Test ' . $date . ' ' . rand(); + $request->set_param( 'id', $id ); + $request->set_param( 'actor', $this->actor ); + $request->set_param( 'object', array( 'type' => 'Note', - 'id' => 'test1', - 'attributedTo' => $actor, + 'id' => $id, + 'attributedTo' => $this->actor, 'content' => $content, - 'url' => 'https://mastodon.local/users/alex/statuses/' . ( $status_id++ ), + 'url' => 'https://mastodon.local/users/akirk/statuses/' . ( $status_id++ ), 'published' => $date, ) ); @@ -157,28 +211,31 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { $posts = get_posts( array( 'post_type' => \Friends\Friends::CPT, - 'author' => $friend_id, + 'author' => $this->friend_id, ) ); - $this->assertEquals( 1, count( $posts ) ); + $this->assertEquals( $post_count + 1, count( $posts ) ); $this->assertEquals( $content, $posts[0]->post_content ); - $this->assertEquals( $friend_id, $posts[0]->post_author ); + $this->assertEquals( $this->friend_id, $posts[0]->post_author ); + + // Do another test post, this time with a URL that has an @-id. + $date = gmdate( \DATE_W3C, $now++ ); + $id = 'test' . $status_id; + $content = 'Test ' . $date . ' ' . rand(); $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); $request->set_param( 'type', 'Create' ); - $request->set_param( 'id', 'test1' ); - $request->set_param( 'actor', 'https://mastodon.local/@alex' ); - $date = date( \DATE_W3C, $now++ ); - $content = 'Test ' . $date . ' ' . rand(); + $request->set_param( 'id', $id ); + $request->set_param( 'actor', 'https://mastodon.local/@akirk' ); $request->set_param( 'object', array( 'type' => 'Note', - 'id' => 'test2', - 'attributedTo' => 'https://mastodon.local/@alex', + 'id' => $id, + 'attributedTo' => 'https://mastodon.local/@akirk', 'content' => $content, - 'url' => 'https://mastodon.local/users/alex/statuses/' . ( $status_id++ ), + 'url' => 'https://mastodon.local/users/akirk/statuses/' . ( $status_id++ ), 'published' => $date, ) ); @@ -189,12 +246,64 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { $posts = get_posts( array( 'post_type' => \Friends\Friends::CPT, - 'author' => $friend_id, + 'author' => $this->friend_id, ) ); - $this->assertEquals( 2, count( $posts ) ); + $this->assertEquals( $post_count + 2, count( $posts ) ); $this->assertEquals( $content, $posts[0]->post_content ); - $this->assertEquals( $friend_id, $posts[0]->post_author ); + $this->assertEquals( $this->friend_id, $posts[0]->post_author ); + } + + public function test_incoming_announce() { + $now = time() - 10; + $status_id = 123; + + self::$users['https://notiz.blog/author/matthias-pfefferle/'] = array( + 'url' => 'https://notiz.blog/author/matthias-pfefferle/', + 'name' => 'Matthias Pfefferle', + ); + + $posts = get_posts( + array( + 'post_type' => \Friends\Friends::CPT, + 'author' => $this->friend_id, + ) + ); + + $post_count = count( $posts ); + + $date = gmdate( \DATE_W3C, $now++ ); + $id = 'test' . $status_id; + + $object = 'https://notiz.blog/2022/11/14/the-at-protocol/'; + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); + $request->set_param( 'type', 'Announce' ); + $request->set_param( 'id', $id ); + $request->set_param( 'actor', $this->actor ); + $request->set_param( 'published', $date ); + $request->set_param( 'object', $object ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 202, $response->get_status() ); + + $p = wp_parse_url( $object ); + $cache = __DIR__ . '/fixtures/' . sanitize_title( $p['host'] . '-' . $p['path'] ) . '.response'; + $this->assertFileExists( $cache ); + + $object = json_decode( wp_remote_retrieve_body( unserialize( file_get_contents( $cache ) ) ) ); + + $posts = get_posts( + array( + 'post_type' => \Friends\Friends::CPT, + 'author' => $this->friend_id, + ) + ); + + $this->assertEquals( $post_count + 1, count( $posts ) ); + $this->assertStringContainsString( 'Dezentrale Netzwerke', $posts[0]->post_content ); + $this->assertEquals( $this->friend_id, $posts[0]->post_author ); + } } From db9e69f6e860992bc52a4be985d238056beb081f Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 2 Dec 2022 13:59:00 +0100 Subject: [PATCH 16/19] Fix author name override for announced posts --- integration/class-friends-feed-parser-activitypub.php | 4 ++-- tests/test-class-friends-feed-parser-activitypub.php | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index 79ded63..69b1c76 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -231,9 +231,9 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $meta = \Activitypub\get_remote_metadata_by_actor( $object['attributedTo'] ); $this->log( 'Attributed to ' . $object['attributedTo'], compact( 'meta' ) ); if ( isset( $meta['name'] ) ) { - $override_author = $meta['name']; + $data['author'] = $meta['name']; } elseif ( isset( $meta['preferredUsername'] ) ) { - $override_author = $meta['preferredUsername']; + $data['author'] = $meta['preferredUsername']; } } diff --git a/tests/test-class-friends-feed-parser-activitypub.php b/tests/test-class-friends-feed-parser-activitypub.php index 6d1568f..4bbd44a 100644 --- a/tests/test-class-friends-feed-parser-activitypub.php +++ b/tests/test-class-friends-feed-parser-activitypub.php @@ -253,6 +253,7 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { $this->assertEquals( $post_count + 2, count( $posts ) ); $this->assertEquals( $content, $posts[0]->post_content ); $this->assertEquals( $this->friend_id, $posts[0]->post_author ); + $this->assertEquals( $this->friend_name, get_post_meta( $posts[0]->ID, 'author', true ) ); } public function test_incoming_announce() { @@ -304,6 +305,7 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { $this->assertEquals( $post_count + 1, count( $posts ) ); $this->assertStringContainsString( 'Dezentrale Netzwerke', $posts[0]->post_content ); $this->assertEquals( $this->friend_id, $posts[0]->post_author ); + $this->assertEquals( 'Matthias Pfefferle', get_post_meta( $posts[0]->ID, 'author', true ) ); } } From 2542127d720e07b456291fc577a145fa31637427 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 2 Dec 2022 14:00:07 +0100 Subject: [PATCH 17/19] Move tests to front of file --- ...-class-friends-feed-parser-activitypub.php | 279 +++++++++--------- 1 file changed, 140 insertions(+), 139 deletions(-) diff --git a/tests/test-class-friends-feed-parser-activitypub.php b/tests/test-class-friends-feed-parser-activitypub.php index 4bbd44a..397eaab 100644 --- a/tests/test-class-friends-feed-parser-activitypub.php +++ b/tests/test-class-friends-feed-parser-activitypub.php @@ -6,6 +6,146 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { private $friend_name; private $actor; + public function test_incoming_post() { + $now = time() - 10; + $status_id = 123; + + $posts = get_posts( + array( + 'post_type' => \Friends\Friends::CPT, + 'author' => $this->friend_id, + ) + ); + + $post_count = count( $posts ); + + // Let's post a new Note through the REST API. + $date = gmdate( \DATE_W3C, $now++ ); + $id = 'test' . $status_id; + $content = 'Test ' . $date . ' ' . rand(); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); + $request->set_param( 'type', 'Create' ); + $request->set_param( 'id', $id ); + $request->set_param( 'actor', $this->actor ); + + $request->set_param( + 'object', + array( + 'type' => 'Note', + 'id' => $id, + 'attributedTo' => $this->actor, + 'content' => $content, + 'url' => 'https://mastodon.local/users/akirk/statuses/' . ( $status_id++ ), + 'published' => $date, + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 202, $response->get_status() ); + + $posts = get_posts( + array( + 'post_type' => \Friends\Friends::CPT, + 'author' => $this->friend_id, + ) + ); + + $this->assertEquals( $post_count + 1, count( $posts ) ); + $this->assertEquals( $content, $posts[0]->post_content ); + $this->assertEquals( $this->friend_id, $posts[0]->post_author ); + + // Do another test post, this time with a URL that has an @-id. + $date = gmdate( \DATE_W3C, $now++ ); + $id = 'test' . $status_id; + $content = 'Test ' . $date . ' ' . rand(); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); + $request->set_param( 'type', 'Create' ); + $request->set_param( 'id', $id ); + $request->set_param( 'actor', 'https://mastodon.local/@akirk' ); + $request->set_param( + 'object', + array( + 'type' => 'Note', + 'id' => $id, + 'attributedTo' => 'https://mastodon.local/@akirk', + 'content' => $content, + 'url' => 'https://mastodon.local/users/akirk/statuses/' . ( $status_id++ ), + 'published' => $date, + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 202, $response->get_status() ); + + $posts = get_posts( + array( + 'post_type' => \Friends\Friends::CPT, + 'author' => $this->friend_id, + ) + ); + + $this->assertEquals( $post_count + 2, count( $posts ) ); + $this->assertEquals( $content, $posts[0]->post_content ); + $this->assertEquals( $this->friend_id, $posts[0]->post_author ); + $this->assertEquals( $this->friend_name, get_post_meta( $posts[0]->ID, 'author', true ) ); + } + + public function test_incoming_announce() { + $now = time() - 10; + $status_id = 123; + + self::$users['https://notiz.blog/author/matthias-pfefferle/'] = array( + 'url' => 'https://notiz.blog/author/matthias-pfefferle/', + 'name' => 'Matthias Pfefferle', + ); + + $posts = get_posts( + array( + 'post_type' => \Friends\Friends::CPT, + 'author' => $this->friend_id, + ) + ); + + $post_count = count( $posts ); + + $date = gmdate( \DATE_W3C, $now++ ); + $id = 'test' . $status_id; + + $object = 'https://notiz.blog/2022/11/14/the-at-protocol/'; + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); + $request->set_param( 'type', 'Announce' ); + $request->set_param( 'id', $id ); + $request->set_param( 'actor', $this->actor ); + $request->set_param( 'published', $date ); + $request->set_param( 'object', $object ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 202, $response->get_status() ); + + $p = wp_parse_url( $object ); + $cache = __DIR__ . '/fixtures/' . sanitize_title( $p['host'] . '-' . $p['path'] ) . '.response'; + $this->assertFileExists( $cache ); + + $object = json_decode( wp_remote_retrieve_body( unserialize( file_get_contents( $cache ) ) ) ); + + $posts = get_posts( + array( + 'post_type' => \Friends\Friends::CPT, + 'author' => $this->friend_id, + ) + ); + + $this->assertEquals( $post_count + 1, count( $posts ) ); + $this->assertStringContainsString( 'Dezentrale Netzwerke', $posts[0]->post_content ); + $this->assertEquals( $this->friend_id, $posts[0]->post_author ); + $this->assertEquals( 'Matthias Pfefferle', get_post_meta( $posts[0]->ID, 'author', true ) ); + + } + + public function set_up() { if ( ! class_exists( '\Friends\Friends' ) ) { return $this->markTestSkipped( 'The Friends plugin is not loaded.' ); @@ -169,143 +309,4 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { } return $response; } - - public function test_incoming_post() { - $now = time() - 10; - $status_id = 123; - - $posts = get_posts( - array( - 'post_type' => \Friends\Friends::CPT, - 'author' => $this->friend_id, - ) - ); - - $post_count = count( $posts ); - - // Let's post a new Note through the REST API. - $date = gmdate( \DATE_W3C, $now++ ); - $id = 'test' . $status_id; - $content = 'Test ' . $date . ' ' . rand(); - - $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); - $request->set_param( 'type', 'Create' ); - $request->set_param( 'id', $id ); - $request->set_param( 'actor', $this->actor ); - - $request->set_param( - 'object', - array( - 'type' => 'Note', - 'id' => $id, - 'attributedTo' => $this->actor, - 'content' => $content, - 'url' => 'https://mastodon.local/users/akirk/statuses/' . ( $status_id++ ), - 'published' => $date, - ) - ); - - $response = $this->server->dispatch( $request ); - $this->assertEquals( 202, $response->get_status() ); - - $posts = get_posts( - array( - 'post_type' => \Friends\Friends::CPT, - 'author' => $this->friend_id, - ) - ); - - $this->assertEquals( $post_count + 1, count( $posts ) ); - $this->assertEquals( $content, $posts[0]->post_content ); - $this->assertEquals( $this->friend_id, $posts[0]->post_author ); - - // Do another test post, this time with a URL that has an @-id. - $date = gmdate( \DATE_W3C, $now++ ); - $id = 'test' . $status_id; - $content = 'Test ' . $date . ' ' . rand(); - - $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); - $request->set_param( 'type', 'Create' ); - $request->set_param( 'id', $id ); - $request->set_param( 'actor', 'https://mastodon.local/@akirk' ); - $request->set_param( - 'object', - array( - 'type' => 'Note', - 'id' => $id, - 'attributedTo' => 'https://mastodon.local/@akirk', - 'content' => $content, - 'url' => 'https://mastodon.local/users/akirk/statuses/' . ( $status_id++ ), - 'published' => $date, - ) - ); - - $response = $this->server->dispatch( $request ); - $this->assertEquals( 202, $response->get_status() ); - - $posts = get_posts( - array( - 'post_type' => \Friends\Friends::CPT, - 'author' => $this->friend_id, - ) - ); - - $this->assertEquals( $post_count + 2, count( $posts ) ); - $this->assertEquals( $content, $posts[0]->post_content ); - $this->assertEquals( $this->friend_id, $posts[0]->post_author ); - $this->assertEquals( $this->friend_name, get_post_meta( $posts[0]->ID, 'author', true ) ); - } - - public function test_incoming_announce() { - $now = time() - 10; - $status_id = 123; - - self::$users['https://notiz.blog/author/matthias-pfefferle/'] = array( - 'url' => 'https://notiz.blog/author/matthias-pfefferle/', - 'name' => 'Matthias Pfefferle', - ); - - $posts = get_posts( - array( - 'post_type' => \Friends\Friends::CPT, - 'author' => $this->friend_id, - ) - ); - - $post_count = count( $posts ); - - $date = gmdate( \DATE_W3C, $now++ ); - $id = 'test' . $status_id; - - $object = 'https://notiz.blog/2022/11/14/the-at-protocol/'; - - $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' ); - $request->set_param( 'type', 'Announce' ); - $request->set_param( 'id', $id ); - $request->set_param( 'actor', $this->actor ); - $request->set_param( 'published', $date ); - $request->set_param( 'object', $object ); - - $response = $this->server->dispatch( $request ); - $this->assertEquals( 202, $response->get_status() ); - - $p = wp_parse_url( $object ); - $cache = __DIR__ . '/fixtures/' . sanitize_title( $p['host'] . '-' . $p['path'] ) . '.response'; - $this->assertFileExists( $cache ); - - $object = json_decode( wp_remote_retrieve_body( unserialize( file_get_contents( $cache ) ) ) ); - - $posts = get_posts( - array( - 'post_type' => \Friends\Friends::CPT, - 'author' => $this->friend_id, - ) - ); - - $this->assertEquals( $post_count + 1, count( $posts ) ); - $this->assertStringContainsString( 'Dezentrale Netzwerke', $posts[0]->post_content ); - $this->assertEquals( $this->friend_id, $posts[0]->post_author ); - $this->assertEquals( 'Matthias Pfefferle', get_post_meta( $posts[0]->ID, 'author', true ) ); - - } } From 57a95fad0108bfb0abbb4ea03115ca1252d36ae1 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 2 Dec 2022 14:27:00 +0100 Subject: [PATCH 18/19] Add attachment support --- .../class-friends-feed-parser-activitypub.php | 23 +++++++++++++++++++ ...-class-friends-feed-parser-activitypub.php | 20 +++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index 69b1c76..34f3d77 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -237,6 +237,29 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { } } + if ( ! empty( $object['attachment'] ) ) { + foreach ( $object['attachment'] as $attachment ) { + if ( ! isset( $attachment['type'] ) || ! isset( $attachment['mediaType'] ) ) { + continue; + } + if ( 'Document' !== $attachment['type'] || strpos( $attachment['mediaType'], 'image/' ) !== 0 ) { + continue; + } + + $data['content'] .= PHP_EOL; + $data['content'] .= ''; + $data['content'] .= '

'; + $data['content'] .= ''; + } + $meta = \Activitypub\get_remote_metadata_by_actor( $object['attributedTo'] ); + $this->log( 'Attributed to ' . $object['attributedTo'], compact( 'meta' ) ); + if ( isset( $meta['name'] ) ) { + $data['author'] = $meta['name']; + } elseif ( isset( $meta['preferredUsername'] ) ) { + $data['author'] = $meta['preferredUsername']; + } + } + $this->log( 'Received feed item', array( diff --git a/tests/test-class-friends-feed-parser-activitypub.php b/tests/test-class-friends-feed-parser-activitypub.php index 397eaab..f48efb1 100644 --- a/tests/test-class-friends-feed-parser-activitypub.php +++ b/tests/test-class-friends-feed-parser-activitypub.php @@ -29,6 +29,9 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { $request->set_param( 'id', $id ); $request->set_param( 'actor', $this->actor ); + $attachment_url = 'https://mastodon.local/files/original/1234.png'; + $attachment_width = 400; + $attachment_height = 600; $request->set_param( 'object', array( @@ -38,6 +41,18 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { 'content' => $content, 'url' => 'https://mastodon.local/users/akirk/statuses/' . ( $status_id++ ), 'published' => $date, + 'attachment' => array( + array( + 'type' => 'Document', + 'mediaType' => 'image/png', + 'url' => $attachment_url, + 'name' => '', + 'blurhash' => '', + 'width' => $attachment_width, + 'height' => $attachment_height, + + ), + ), ) ); @@ -52,7 +67,8 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { ); $this->assertEquals( $post_count + 1, count( $posts ) ); - $this->assertEquals( $content, $posts[0]->post_content ); + $this->assertStringStartsWith( $content, $posts[0]->post_content ); + $this->assertStringContainsString( '', $posts[0]->post_content ); $this->assertEquals( $this->friend_id, $posts[0]->post_author ); // Do another test post, this time with a URL that has an @-id. @@ -144,8 +160,6 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { $this->assertEquals( 'Matthias Pfefferle', get_post_meta( $posts[0]->ID, 'author', true ) ); } - - public function set_up() { if ( ! class_exists( '\Friends\Friends' ) ) { return $this->markTestSkipped( 'The Friends plugin is not loaded.' ); From f916bca388e3c0e4977c36e4ba732b2ce0f938bb Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Sat, 3 Dec 2022 07:38:02 +0100 Subject: [PATCH 19/19] Add a css class to the image attachments --- integration/class-friends-feed-parser-activitypub.php | 2 +- tests/test-class-friends-feed-parser-activitypub.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/class-friends-feed-parser-activitypub.php b/integration/class-friends-feed-parser-activitypub.php index 34f3d77..db155c1 100644 --- a/integration/class-friends-feed-parser-activitypub.php +++ b/integration/class-friends-feed-parser-activitypub.php @@ -248,7 +248,7 @@ class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser { $data['content'] .= PHP_EOL; $data['content'] .= ''; - $data['content'] .= '

'; + $data['content'] .= '

'; $data['content'] .= ''; } $meta = \Activitypub\get_remote_metadata_by_actor( $object['attributedTo'] ); diff --git a/tests/test-class-friends-feed-parser-activitypub.php b/tests/test-class-friends-feed-parser-activitypub.php index f48efb1..79f5a74 100644 --- a/tests/test-class-friends-feed-parser-activitypub.php +++ b/tests/test-class-friends-feed-parser-activitypub.php @@ -68,7 +68,7 @@ class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase { $this->assertEquals( $post_count + 1, count( $posts ) ); $this->assertStringStartsWith( $content, $posts[0]->post_content ); - $this->assertStringContainsString( '', $posts[0]->post_content ); + $this->assertStringContainsString( 'post_content ); $this->assertEquals( $this->friend_id, $posts[0]->post_author ); // Do another test post, this time with a URL that has an @-id.