From 862bef1c168bdf7172c73cc5af5897efed297457 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 8 Nov 2023 16:46:02 +0100 Subject: [PATCH] init --- activitypub.php | 2 +- composer.json | 3 +- includes/activity/class-activity.php | 6 + includes/activity/class-base-object.php | 4 +- includes/class-activitypub.php | 84 ++++++++- includes/class-handler.php | 33 ++++ includes/collection/class-followers.php | 182 -------------------- includes/functions.php | 75 ++++++++ includes/handler/class-create.php | 118 +++++++++++++ includes/handler/class-delete.php | 54 ++++++ includes/handler/class-follow.php | 81 +++++++++ includes/handler/class-undo.php | 32 ++++ includes/handler/class-update.php | 45 +++++ includes/rest/class-inbox.php | 149 ++-------------- tests/test-class-activitypub-activity.php | 19 ++ tests/test-class-activitypub-rest-inbox.php | 2 +- 16 files changed, 566 insertions(+), 323 deletions(-) create mode 100644 includes/class-handler.php create mode 100644 includes/handler/class-create.php create mode 100644 includes/handler/class-delete.php create mode 100644 includes/handler/class-follow.php create mode 100644 includes/handler/class-undo.php create mode 100644 includes/handler/class-update.php diff --git a/activitypub.php b/activitypub.php index 97f6067..0b41709 100644 --- a/activitypub.php +++ b/activitypub.php @@ -66,7 +66,7 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Activity_Dispatcher', 'init' ) ); - \add_action( 'init', array( __NAMESPACE__ . '\Collection\Followers', 'init' ) ); + \add_action( 'init', array( __NAMESPACE__ . '\Handler', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Admin', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Hashtag', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) ); diff --git a/composer.json b/composer.json index 054226f..746f86f 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "yoast/phpunit-polyfills": "^2.0", "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", "sirbrillig/phpcs-variable-analysis": "^2.11", - "phpcsstandards/phpcsextra": "^1.1.0" + "phpcsstandards/phpcsextra": "^1.1.0", + "dms/phpunit-arraysubset-asserts": "^0.4.0" }, "config": { "allow-plugins": true diff --git a/includes/activity/class-activity.php b/includes/activity/class-activity.php index 6c59866..96ee095 100644 --- a/includes/activity/class-activity.php +++ b/includes/activity/class-activity.php @@ -194,6 +194,12 @@ class Activity extends Base_Object { * @return void */ public function set_object( $object ) { + // convert array to object + if ( is_array( $object ) ) { + $object = Base_Object::init_from_array( $object ); + } + + // set object $this->set( 'object', $object ); if ( ! is_object( $object ) ) { diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index a75ed16..b73c621 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -585,7 +585,7 @@ class Base_Object { foreach ( $array as $key => $value ) { $key = camel_to_snake_case( $key ); - $object->set( $key, $value ); + call_user_func( array( $object, 'set_' . $key ), $value ); } return $object; @@ -611,7 +611,7 @@ class Base_Object { foreach ( $array as $key => $value ) { if ( $value ) { $key = camel_to_snake_case( $key ); - $this->set( $key, $value ); + call_user_func( array( $this, 'set_' . $key ), $value ); } } } diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 24228f4..6f654c5 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -1,8 +1,12 @@ array( + 'name' => _x( 'Followers', 'post_type plural name', 'activitypub' ), + 'singular_name' => _x( 'Follower', 'post_type single name', 'activitypub' ), + ), + 'public' => false, + 'hierarchical' => false, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => false, + 'can_export' => true, + 'supports' => array(), + ) + ); + + register_post_meta( + Followers::POST_TYPE, + 'activitypub_inbox', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => 'sanitize_url', + ) + ); + + register_post_meta( + Followers::POST_TYPE, + 'activitypub_errors', + array( + 'type' => 'string', + 'single' => false, + 'sanitize_callback' => function( $value ) { + if ( ! is_string( $value ) ) { + throw new Exception( 'Error message is no valid string' ); + } + + return esc_sql( $value ); + }, + ) + ); + + register_post_meta( + Followers::POST_TYPE, + 'activitypub_user_id', + array( + 'type' => 'string', + 'single' => false, + 'sanitize_callback' => function( $value ) { + return esc_sql( $value ); + }, + ) + ); + + register_post_meta( + Followers::POST_TYPE, + 'activitypub_actor_json', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => function( $value ) { + return sanitize_text_field( $value ); + }, + ) + ); + + do_action( 'activitypub_after_register_post_type' ); + } } diff --git a/includes/class-handler.php b/includes/class-handler.php new file mode 100644 index 0000000..fcabd63 --- /dev/null +++ b/includes/class-handler.php @@ -0,0 +1,33 @@ + array( - 'name' => _x( 'Followers', 'post_type plural name', 'activitypub' ), - 'singular_name' => _x( 'Follower', 'post_type single name', 'activitypub' ), - ), - 'public' => false, - 'hierarchical' => false, - 'rewrite' => false, - 'query_var' => false, - 'delete_with_user' => false, - 'can_export' => true, - 'supports' => array(), - ) - ); - - register_post_meta( - self::POST_TYPE, - 'activitypub_inbox', - array( - 'type' => 'string', - 'single' => true, - 'sanitize_callback' => array( self::class, 'sanitize_url' ), - ) - ); - - register_post_meta( - self::POST_TYPE, - 'activitypub_errors', - array( - 'type' => 'string', - 'single' => false, - 'sanitize_callback' => function( $value ) { - if ( ! is_string( $value ) ) { - throw new Exception( 'Error message is no valid string' ); - } - - return esc_sql( $value ); - }, - ) - ); - - register_post_meta( - self::POST_TYPE, - 'activitypub_user_id', - array( - 'type' => 'string', - 'single' => false, - 'sanitize_callback' => function( $value ) { - return esc_sql( $value ); - }, - ) - ); - - register_post_meta( - self::POST_TYPE, - 'activitypub_actor_json', - array( - 'type' => 'string', - 'single' => true, - 'sanitize_callback' => function( $value ) { - return sanitize_text_field( $value ); - }, - ) - ); - - do_action( 'activitypub_after_register_post_type' ); - } - - public static function sanitize_url( $value ) { - if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) { - return null; - } - - return esc_url_raw( $value ); - } - - /** - * Handle the "Follow" Request - * - * @param array $object The JSON "Follow" Activity - * @param int $user_id The ID of the ID of the WordPress User - * - * @return void - */ - public static function handle_follow_request( $object, $user_id ) { - // save follower - $follower = self::add_follower( $user_id, $object['actor'] ); - - do_action( 'activitypub_followers_post_follow', $object['actor'], $object, $user_id, $follower ); - } - - /** - * Handle "Unfollow" requests - * - * @param array $object The JSON "Undo" Activity - * @param int $user_id The ID of the ID of the WordPress User - */ - public static function handle_undo_request( $object, $user_id ) { - if ( - isset( $object['object'] ) && - isset( $object['object']['type'] ) && - 'Follow' === $object['object']['type'] - ) { - self::remove_follower( $user_id, $object['actor'] ); - } - } - /** * Add new Follower * @@ -243,54 +109,6 @@ class Followers { return null; } - /** - * Send Accept response - * - * @param string $actor The Actor URL - * @param array $object The Activity object - * @param int $user_id The ID of the WordPress User - * @param Activitypub\Model\Follower $follower The Follower object - * - * @return void - */ - public static function send_follow_response( $actor, $object, $user_id, $follower ) { - if ( is_wp_error( $follower ) ) { - // it is not even possible to send a "Reject" because - // we can not get the Remote-Inbox - return; - } - - // only send minimal data - $object = array_intersect_key( - $object, - array_flip( - array( - 'id', - 'type', - 'actor', - 'object', - ) - ) - ); - - $user = Users::get_by_id( $user_id ); - - // get inbox - $inbox = $follower->get_shared_inbox(); - - // send "Accept" activity - $activity = new Activity(); - $activity->set_type( 'Accept' ); - $activity->set_object( $object ); - $activity->set_actor( $user->get_id() ); - $activity->set_to( $actor ); - $activity->set_id( $user->get_id() . '#follow-' . \preg_replace( '~^https?://~', '', $actor ) . '-' . \time() ); - - $activity = $activity->to_json(); - - Http::post( $inbox, $activity, $user_id ); - } - /** * Get the Followers of a given user * diff --git a/includes/functions.php b/includes/functions.php index b2972c0..d5e6503 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -474,3 +474,78 @@ function is_json( $data ) { function is_blog_public() { return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) ); } + +/** + * Sanitize a URL + * + * @param string $value The URL to sanitize + * + * @return string|null The sanitized URL or null if invalid + */ +function sanitize_url( $value ) { + if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) { + return null; + } + + return esc_url_raw( $value ); +} + +/** + * Extract recipient URLs from Activity object + * + * @param array $data + * + * @return array The list of user URLs + */ +function extract_recipients_from_activity( $data ) { + $recipient_items = array(); + + foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) { + if ( array_key_exists( $i, $data ) ) { + if ( is_array( $data[ $i ] ) ) { + $recipient = $data[ $i ]; + } else { + $recipient = array( $data[ $i ] ); + } + $recipient_items = array_merge( $recipient_items, $recipient ); + } + + if ( is_array( $data['object'] ) && array_key_exists( $i, $data['object'] ) ) { + if ( is_array( $data['object'][ $i ] ) ) { + $recipient = $data['object'][ $i ]; + } else { + $recipient = array( $data['object'][ $i ] ); + } + $recipient_items = array_merge( $recipient_items, $recipient ); + } + } + + $recipients = array(); + + // flatten array + foreach ( $recipient_items as $recipient ) { + if ( is_array( $recipient ) ) { + // check if recipient is an object + if ( array_key_exists( 'id', $recipient ) ) { + $recipients[] = $recipient['id']; + } + } else { + $recipients[] = $recipient; + } + } + + return array_unique( $recipients ); +} + +/** + * Check if passed Activity is Public + * + * @param array $data The Activity object as array + * + * @return boolean True if public, false if not + */ +function is_activity_public( $data ) { + $recipients = extract_recipients_from_activity( $data ); + + return in_array( 'https://www.w3.org/ns/activitystreams#Public', $recipients, true ); +} diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php new file mode 100644 index 0000000..a474c35 --- /dev/null +++ b/includes/handler/class-create.php @@ -0,0 +1,118 @@ + $comment_post_id, + 'comment_author' => \esc_attr( $meta['name'] ), + 'comment_author_url' => \esc_url_raw( $activity['actor'] ), + 'comment_content' => \wp_filter_kses( $activity['object']['content'] ), + 'comment_type' => 'comment', + 'comment_author_email' => '', + 'comment_parent' => 0, + 'comment_meta' => array( + 'source_url' => \esc_url_raw( $activity['object']['url'] ), + 'avatar_url' => \esc_url_raw( $meta['icon']['url'] ), + 'protocol' => 'activitypub', + ), + ); + + // disable flood control + \remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); + + // do not require email for AP entries + \add_filter( 'pre_option_require_name_email', '__return_false' ); + + // No nonce possible for this submission route + \add_filter( + 'akismet_comment_nonce', + function() { + return 'inactive'; + } + ); + + $state = \wp_new_comment( $commentdata, true ); + + \remove_filter( 'pre_option_require_name_email', '__return_false' ); + + // re-add flood control + \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + + \do_action( 'activitypub_handled_create', $activity, $user_id, $state, $commentdata ); + } + + /** + * Handles "Create" requests + * + * @param array $object The activity-object + * @param int $user_id The id of the local blog-user + */ + public static function handle_create_alt( $object, $user_id ) { + $commentdata = self::convert_object_to_comment_data( $object, $user_id ); + if ( ! $commentdata ) { + return false; + } + + // disable flood control + \remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); + + // do not require email for AP entries + \add_filter( 'pre_option_require_name_email', '__return_false' ); + + // No nonce possible for this submission route + \add_filter( + 'akismet_comment_nonce', + function() { + return 'inactive'; + } + ); + + $state = \wp_new_comment( $commentdata, true ); + + \remove_filter( 'pre_option_require_name_email', '__return_false' ); + + // re-add flood control + \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + + do_action( 'activitypub_handled_create', $object, $user_id, $state, $commentdata ); + } +} diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php new file mode 100644 index 0000000..d4530c9 --- /dev/null +++ b/includes/handler/class-delete.php @@ -0,0 +1,54 @@ +get_shared_inbox(); + + // send "Accept" activity + $activity = new Activity(); + $activity->set_type( 'Accept' ); + $activity->set_object( $object ); + $activity->set_actor( $user->get_id() ); + $activity->set_to( $actor ); + $activity->set_id( $user->get_id() . '#follow-' . \preg_replace( '~^https?://~', '', $actor ) . '-' . \time() ); + + $activity = $activity->to_json(); + + Http::post( $inbox, $activity, $user_id ); + } +} diff --git a/includes/handler/class-undo.php b/includes/handler/class-undo.php new file mode 100644 index 0000000..9c68924 --- /dev/null +++ b/includes/handler/class-undo.php @@ -0,0 +1,32 @@ + 404 ) ); + } + + // Check if the user has permission to edit the post. + if ( ! \current_user_can( 'edit_post', $post->ID ) ) { + return new WP_Error( 'activitypub_permission_denied', __( 'You do not have permission to edit this post.', 'activitypub' ), array( 'status' => 403 ) ); + } + + // Update the post content. + $post_data = array( + 'ID' => $post->ID, + 'post_content' => $activity['object']['content'], + ); + wp_update_post( $post_data ); + } +} diff --git a/includes/rest/class-inbox.php b/includes/rest/class-inbox.php index 9088993..8d35fa9 100644 --- a/includes/rest/class-inbox.php +++ b/includes/rest/class-inbox.php @@ -11,6 +11,7 @@ use function Activitypub\get_context; use function Activitypub\url_to_authorid; use function Activitypub\get_rest_url_by_path; use function Activitypub\get_remote_metadata_by_actor; +use function Activitypub\extract_recipients_from_activity; /** * ActivityPub Inbox REST-Class @@ -130,12 +131,13 @@ class Inbox { return $user; } - $data = $request->get_json_params(); - $type = $request->get_param( 'type' ); - $type = \strtolower( $type ); + $data = $request->get_json_params(); + $activity = Activity::init_from_array( $data ); + $type = $request->get_param( 'type' ); + $type = \strtolower( $type ); - \do_action( 'activitypub_inbox', $data, $user->get__id(), $type ); - \do_action( "activitypub_inbox_{$type}", $data, $user->get__id() ); + \do_action( 'activitypub_inbox', $data, $user->get__id(), $type, $activity ); + \do_action( "activitypub_inbox_{$type}", $data, $user->get__id(), $activity ); $rest_response = new WP_REST_Response( array(), 202 ); $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); @@ -151,9 +153,10 @@ class Inbox { * @return WP_REST_Response */ public static function shared_inbox_post( $request ) { - $data = $request->get_json_params(); - $type = $request->get_param( 'type' ); - $users = self::extract_recipients( $data ); + $data = $request->get_json_params(); + $activity = Activity::init_from_array( $data ); + $type = $request->get_param( 'type' ); + $users = self::get_recipients( $data ); if ( ! $users ) { return new WP_Error( @@ -181,8 +184,8 @@ class Inbox { $type = \strtolower( $type ); - \do_action( 'activitypub_inbox', $data, $user->ID, $type ); - \do_action( "activitypub_inbox_{$type}", $data, $user->ID ); + \do_action( 'activitypub_inbox', $data, $user->ID, $type, $activity ); + \do_action( "activitypub_inbox_{$type}", $data, $user->ID, $activity ); } $rest_response = new WP_REST_Response( array(), 202 ); @@ -336,118 +339,6 @@ class Inbox { return $params; } - /** - * Handles "Create" requests - * - * @param array $object The activity-object - * @param int $user_id The id of the local blog-user - */ - public static function handle_create( $object, $user_id ) { - $meta = get_remote_metadata_by_actor( $object['actor'] ); - - if ( ! isset( $object['object']['inReplyTo'] ) ) { - return; - } - - // check if Activity is public or not - if ( ! self::is_activity_public( $object ) ) { - // @todo maybe send email - return; - } - - $comment_post_id = \url_to_postid( $object['object']['inReplyTo'] ); - - // save only replys and reactions - if ( ! $comment_post_id ) { - return false; - } - - $commentdata = array( - 'comment_post_ID' => $comment_post_id, - 'comment_author' => \esc_attr( $meta['name'] ), - 'comment_author_url' => \esc_url_raw( $object['actor'] ), - 'comment_content' => \wp_filter_kses( $object['object']['content'] ), - 'comment_type' => 'comment', - 'comment_author_email' => '', - 'comment_parent' => 0, - 'comment_meta' => array( - 'source_url' => \esc_url_raw( $object['object']['url'] ), - 'avatar_url' => \esc_url_raw( $meta['icon']['url'] ), - 'protocol' => 'activitypub', - ), - ); - - // disable flood control - \remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); - - // do not require email for AP entries - \add_filter( 'pre_option_require_name_email', '__return_false' ); - - // No nonce possible for this submission route - \add_filter( - 'akismet_comment_nonce', - function() { - return 'inactive'; - } - ); - - $state = \wp_new_comment( $commentdata, true ); - - \remove_filter( 'pre_option_require_name_email', '__return_false' ); - - // re-add flood control - \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); - - do_action( 'activitypub_handled_create', $object, $user_id, $state, $commentdata ); - } - - /** - * Extract recipient URLs from Activity object - * - * @param array $data - * - * @return array The list of user URLs - */ - public static function extract_recipients( $data ) { - $recipient_items = array(); - - foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) { - if ( array_key_exists( $i, $data ) ) { - if ( is_array( $data[ $i ] ) ) { - $recipient = $data[ $i ]; - } else { - $recipient = array( $data[ $i ] ); - } - $recipient_items = array_merge( $recipient_items, $recipient ); - } - - if ( is_array( $data['object'] ) && array_key_exists( $i, $data['object'] ) ) { - if ( is_array( $data['object'][ $i ] ) ) { - $recipient = $data['object'][ $i ]; - } else { - $recipient = array( $data['object'][ $i ] ); - } - $recipient_items = array_merge( $recipient_items, $recipient ); - } - } - - $recipients = array(); - - // flatten array - foreach ( $recipient_items as $recipient ) { - if ( is_array( $recipient ) ) { - // check if recipient is an object - if ( array_key_exists( 'id', $recipient ) ) { - $recipients[] = $recipient['id']; - } - } else { - $recipients[] = $recipient; - } - } - - return array_unique( $recipients ); - } - /** * Get local user recipients * @@ -456,7 +347,7 @@ class Inbox { * @return array The list of local users */ public static function get_recipients( $data ) { - $recipients = self::extract_recipients( $data ); + $recipients = extract_recipients_from_activity( $data ); $users = array(); foreach ( $recipients as $recipient ) { @@ -471,16 +362,4 @@ class Inbox { return $users; } - - /** - * Check if passed Activity is Public - * - * @param array $data - * @return boolean - */ - public static function is_activity_public( $data ) { - $recipients = self::extract_recipients( $data ); - - return in_array( 'https://www.w3.org/ns/activitystreams#Public', $recipients, true ); - } } diff --git a/tests/test-class-activitypub-activity.php b/tests/test-class-activitypub-activity.php index ba9f5a2..6ee078e 100644 --- a/tests/test-class-activitypub-activity.php +++ b/tests/test-class-activitypub-activity.php @@ -1,4 +1,6 @@ assertEquals( 'Hello world!', $object->get_content() ); $this->assertEquals( $test_array, $object->to_array() ); } + + public function test_activity_object() { + $test_array = array( + 'id' => 'https://example.com/post/123', + 'type' => 'Create', + 'object' => array( + 'id' => 'https://example.com/post/123/activity', + 'type' => 'Note', + 'content' => 'Hello world!', + ), + ); + + $activity = \Activitypub\Activity\Activity::init_from_array( $test_array ); + + $this->assertEquals( 'Hello world!', $activity->get_object()->get_content() ); + Assert::assertArraySubset( $test_array, $activity->to_array() ); + } } diff --git a/tests/test-class-activitypub-rest-inbox.php b/tests/test-class-activitypub-rest-inbox.php index 58f16f3..0368d5b 100644 --- a/tests/test-class-activitypub-rest-inbox.php +++ b/tests/test-class-activitypub-rest-inbox.php @@ -5,7 +5,7 @@ class Test_Activitypub_Rest_Inbox extends WP_UnitTestCase { */ public function test_is_activity_public( $data, $check ) { - $this->assertEquals( $check, Activitypub\Rest\Inbox::is_activity_public( $data ) ); + $this->assertEquals( $check, Activitypub\is_activity_public( $data ) ); } public function the_data_provider() {