diff --git a/README.md b/README.md index fd03554..e83f274 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Tags:** OStatus, fediverse, activitypub, activitystream **Requires at least:** 4.7 **Tested up to:** 6.4 -**Stable tag:** 1.2.0 +**Stable tag:** 1.3.0 **Requires PHP:** 5.6 **License:** MIT **License URI:** http://opensource.org/licenses/MIT @@ -105,6 +105,23 @@ Where 'blog' is the path to the subdirectory at which your blog resides. Project maintained on GitHub at [automattic/wordpress-activitypub](https://github.com/automattic/wordpress-activitypub). +### 2.0.0 ### + +* Removed: Deprecated Classes +* Fixed: Normalize attributes that can have mixed value types +* Added: URL support for WebFinger +* Added: Make Post-Template filterable +* Addes: CSS class for ActivityPub comments to allow custom designs + +### 1.3.0 ### + +* Added: Threaded-Comments support +* Improved: alt text for avatars in Follow Me/Followers blocks +* Improved: `Delete`, `Update` and `Follow` Activities +* Improved: better/more effective handling of `Delete` Activities +* Improved: allow `

` and `
` for Comments +* Fixed: removed default limit of WP_Query to send updates to all Inboxes and not only to the first 10 + ### 1.2.0 ### * Add: Search and order followerer lists diff --git a/activitypub.php b/activitypub.php index cacdce2..5179fda 100644 --- a/activitypub.php +++ b/activitypub.php @@ -3,7 +3,7 @@ * Plugin Name: ActivityPub * Plugin URI: https://github.com/pfefferle/wordpress-activitypub/ * Description: The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format. - * Version: 1.2.0 + * Version: 1.3.0 * Author: Matthias Pfefferle & Automattic * Author URI: https://automattic.com/ * License: MIT @@ -69,7 +69,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' ) ); @@ -174,7 +174,7 @@ function plugin_settings_link( $actions ) { */ add_action( 'bp_include', - function() { + function () { require_once __DIR__ . '/integration/class-buddypress.php'; Integration\Buddypress::init(); }, diff --git a/build/follow-me/index.asset.php b/build/follow-me/index.asset.php index 760f433..c6138c7 100644 --- a/build/follow-me/index.asset.php +++ b/build/follow-me/index.asset.php @@ -1 +1 @@ - array('wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '6aeec6336fd28aa836a7'); + array( 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives' ), 'version' => '6aeec6336fd28aa836a7' ); diff --git a/build/follow-me/view.asset.php b/build/follow-me/view.asset.php index 76495ef..1c33822 100644 --- a/build/follow-me/view.asset.php +++ b/build/follow-me/view.asset.php @@ -1 +1 @@ - array('wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '5b48281e37700a970a66'); + array( 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives' ), 'version' => '5b48281e37700a970a66' ); diff --git a/build/followers/index.asset.php b/build/followers/index.asset.php index 324ac5e..fec0ba9 100644 --- a/build/followers/index.asset.php +++ b/build/followers/index.asset.php @@ -1 +1 @@ - array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => '59d9702e06860a6d13e4'); + array( 'react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url' ), 'version' => '59d9702e06860a6d13e4' ); diff --git a/build/followers/view.asset.php b/build/followers/view.asset.php index ba6c322..305d9f5 100644 --- a/build/followers/view.asset.php +++ b/build/followers/view.asset.php @@ -1 +1 @@ - array('react', 'wp-api-fetch', 'wp-components', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-url'), 'version' => '04e51e7562fe28b0b2c3'); + array( 'react', 'wp-api-fetch', 'wp-components', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-url' ), 'version' => '04e51e7562fe28b0b2c3' ); diff --git a/composer.json b/composer.json index 054226f..604ab85 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.5.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 9b9f044..3e25105 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -644,7 +644,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; @@ -670,7 +670,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 0b768a7..6a07b2d 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' ); + } + + /** + * Filters the CSS classes to add an ActivityPub class. + * + * @param string[] $classes An array of comment classes. + * @param string[] $css_class An array of additional classes added to the list. + * @param string $comment_id The comment ID as a numeric string. + * + * @return string[] An array of classes. + */ + public static function comment_class( $classes, $css_class, $comment_id ) { + // check if ActivityPub comment + if ( 'activitypub' === get_comment_meta( $comment_id, 'protocol', true ) ) { + $classes[] = 'activitypub-comment'; + } + + return $classes; + } } diff --git a/includes/class-admin.php b/includes/class-admin.php index 4d8655a..1889e41 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -215,7 +215,7 @@ class Admin { 'description' => \esc_html__( 'The Identifier of the Blog-User', 'activitypub' ), 'show_in_rest' => true, 'default' => Blog_User::get_default_username(), - 'sanitize_callback' => function( $value ) { + 'sanitize_callback' => function ( $value ) { // hack to allow dots in the username $parts = explode( '.', $value ); $sanitized = array(); diff --git a/includes/class-blocks.php b/includes/class-blocks.php index 02f9659..c127795 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -3,7 +3,9 @@ namespace Activitypub; use Activitypub\Collection\Followers; use Activitypub\Collection\Users as User_Collection; -use Activitypub\is_user_type_disabled; + +use function Activitypub\object_to_uri; +use function Activitypub\is_user_type_disabled; class Blocks { public static function init() { @@ -94,7 +96,7 @@ class Blocks { $attrs['followerData']['total'] = $follower_data['total']; $attrs['followerData']['followers'] = array_map( - function( $follower ) { + function ( $follower ) { return self::filter_array_by_keys( $follower->to_array(), array( 'icon', 'name', 'preferredUsername', 'url' ) @@ -140,7 +142,7 @@ class Blocks { return sprintf( $template, - esc_url( $data['url'] ), + esc_url( object_to_uri( $data['url'] ) ), esc_attr( $data['name'] ), esc_attr( $data['icon']['url'] ), esc_html( $data['name'] ), 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 @@ + 'ID', + 'capability__in' => array( 'publish_posts' ), + ) + ); + + foreach ( $user_ids as $user_id ) { + wp_cache_delete( sprintf( Followers::CACHE_KEY_INBOXES, $user_id ), 'activitypub' ); + } + } } diff --git a/includes/class-signature.php b/includes/class-signature.php index d021cf0..e59a1f9 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -292,7 +292,8 @@ class Signature { if ( is_array( $headers['digest'] ) ) { $headers['digest'] = $headers['digest'][0]; } - $digest = explode( '=', $headers['digest'], 2 ); + $hashalg = 'sha256'; + $digest = explode( '=', $headers['digest'], 2 ); if ( 'SHA-256' === $digest[0] ) { $hashalg = 'sha256'; } diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index c2ad01f..b51224f 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -2,14 +2,10 @@ namespace Activitypub\Collection; use WP_Error; -use Exception; use WP_Query; use Activitypub\Http; use Activitypub\Webfinger; use Activitypub\Model\Follower; -use Activitypub\Collection\Users; -use Activitypub\Activity\Activity; -use Activitypub\Activity\Base_Object; use function Activitypub\is_tombstone; use function Activitypub\get_remote_metadata_by_actor; @@ -24,136 +20,6 @@ class Followers { const POST_TYPE = 'ap_follower'; const CACHE_KEY_INBOXES = 'follower_inboxes_%s'; - /** - * Register WordPress hooks/actions and register Taxonomy - * - * @return void - */ - public static function init() { - // register "followers" post_type - self::register_post_type(); - - \add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow_request' ), 10, 2 ); - \add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo_request' ), 10, 2 ); - - \add_action( 'activitypub_followers_post_follow', array( self::class, 'send_follow_response' ), 10, 4 ); - } - - /** - * Register the "Followers" Taxonomy - * - * @return void - */ - private static function register_post_type() { - register_post_type( - self::POST_TYPE, - array( - 'labels' => 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 * @@ -214,16 +80,17 @@ class Followers { } /** - * Get a Follower + * Get a Follower. * * @param int $user_id The ID of the WordPress User * @param string $actor The Actor URL * - * @return \Activitypub\Model\Follower The Follower object + * @return \Activitypub\Model\Follower|null The Follower object or null */ public static function get_follower( $user_id, $actor ) { global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $post_id = $wpdb->get_var( $wpdb->prepare( "SELECT DISTINCT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key = 'activitypub_user_id' AND pm.meta_value = %d AND p.guid = %s", @@ -244,51 +111,29 @@ class Followers { } /** - * Send Accept response + * Get a Follower by Actor indepenent from the User. * - * @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 + * @param string $actor The Actor URL. * - * @return void + * @return \Activitypub\Model\Follower|null The Follower object or null */ - 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; - } + public static function get_follower_by_actor( $actor ) { + global $wpdb; - // only send minimal data - $object = array_intersect_key( - $object, - array_flip( - array( - 'id', - 'type', - 'actor', - 'object', - ) + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $post_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM $wpdb->posts WHERE guid=%s", + esc_sql( $actor ) ) ); - $user = Users::get_by_id( $user_id ); + if ( $post_id ) { + $post = get_post( $post_id ); + return Follower::init_from_cpt( $post ); + } - // 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 ); + return null; } /** @@ -337,7 +182,7 @@ class Followers { $query = new WP_Query( $args ); $total = $query->found_posts; $followers = array_map( - function( $post ) { + function ( $post ) { return Follower::init_from_cpt( $post ); }, $query->get_posts() @@ -354,6 +199,7 @@ class Followers { */ public static function get_all_followers() { $args = array( + 'nopaging' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( 'relation' => 'AND', @@ -422,6 +268,7 @@ class Followers { // get all Followers of a ID of the WordPress User $posts = new WP_Query( array( + 'nopaging' => true, 'post_type' => self::POST_TYPE, 'fields' => 'ids', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php new file mode 100644 index 0000000..87e4334 --- /dev/null +++ b/includes/collection/class-interactions.php @@ -0,0 +1,235 @@ +comment_post_ID; + } + + // not a reply to a post or comment + if ( ! $comment_post_id ) { + return false; + } + + $meta = get_remote_metadata_by_actor( $activity['actor'] ); + + if ( ! $meta || \is_wp_error( $meta ) ) { + return false; + } + + $commentdata = array( + 'comment_post_ID' => $comment_post_id, + 'comment_author' => \esc_attr( $meta['name'] ), + 'comment_author_url' => \esc_url_raw( $meta['url'] ), + 'comment_content' => \addslashes( $activity['object']['content'] ), + 'comment_type' => 'comment', + 'comment_author_email' => '', + 'comment_parent' => $parent_comment ? $parent_comment->comment_ID : 0, + 'comment_meta' => array( + 'source_id' => \esc_url_raw( $activity['object']['id'] ), + 'source_url' => \esc_url_raw( $activity['object']['url'] ), + 'protocol' => 'activitypub', + ), + ); + + if ( isset( $meta['icon']['url'] ) ) { + $commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] ); + } + + // 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'; + } + ); + \add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 ); + + $comment = \wp_new_comment( $commentdata, true ); + + \remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10 ); + \remove_filter( 'pre_option_require_name_email', '__return_false' ); + // re-add flood control + \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + + return $comment; + } + + /** + * Update a comment + * + * @param array $activity The activity-object + * + * @return array|false The commentdata or false on failure + */ + public static function update_comment( $activity ) { + $meta = get_remote_metadata_by_actor( $activity['actor'] ); + + //Determine comment_ID + $object_comment_id = url_to_commentid( \esc_url_raw( $activity['object']['id'] ) ); + + if ( ! $object_comment_id ) { + return false; + } + + //found a local comment id + $commentdata = \get_comment( $object_comment_id, ARRAY_A ); + $commentdata['comment_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] ); + $commentdata['comment_content'] = \addslashes( $activity['object']['content'] ); + if ( isset( $meta['icon']['url'] ) ) { + $commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] ); + } + + // 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'; + } + ); + \add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 ); + + $comment = \wp_update_comment( $commentdata, true ); + + \remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10 ); + \remove_filter( 'pre_option_require_name_email', '__return_false' ); + // re-add flood control + \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + + return $comment; + } + + /** + * Get interaction(s) for a given URL/ID. + * + * @param strin $url The URL/ID to get interactions for. + * + * @return array The interactions as WP_Comment objects. + */ + public static function get_interaction_by_id( $url ) { + $args = array( + 'nopaging' => true, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => 'protocol', + 'value' => 'activitypub', + ), + array( + 'relation' => 'OR', + array( + 'key' => 'source_url', + 'value' => $url, + ), + array( + 'key' => 'source_id', + 'value' => $url, + ), + ), + ), + ); + + $query = new WP_Comment_Query( $args ); + return $query->comments; + } + + /** + * Get interaction(s) for a given actor. + * + * @param string $actor The Actor-URL. + * + * @return array The interactions as WP_Comment objects. + */ + public static function get_interactions_by_actor( $actor ) { + $meta = get_remote_metadata_by_actor( $actor ); + + // get URL, because $actor seems to be the ID + if ( $meta && ! is_wp_error( $meta ) && isset( $meta['url'] ) ) { + $actor = $meta['url']; + } + + $args = array( + 'nopaging' => true, + 'author_url' => $actor, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => 'protocol', + 'value' => 'activitypub', + 'compare' => '=', + ), + ), + ); + $comment_query = new WP_Comment_Query( $args ); + return $comment_query->comments; + } + + /** + * Adds line breaks to the list of allowed comment tags. + * + * @param array $allowed_tags Allowed HTML tags. + * @param string $context Context. + * + * @return array Filtered tag list. + */ + public static function allowed_comment_html( $allowed_tags, $context = '' ) { + if ( 'pre_comment_content' !== $context ) { + // Do nothing. + return $allowed_tags; + } + + // Add `p` and `br` to the list of allowed tags. + if ( ! array_key_exists( 'br', $allowed_tags ) ) { + $allowed_tags['br'] = array(); + } + + if ( ! array_key_exists( 'p', $allowed_tags ) ) { + $allowed_tags['p'] = array(); + } + + return $allowed_tags; + } +} diff --git a/includes/collection/class-users.php b/includes/collection/class-users.php index f6e35a6..ad94297 100644 --- a/includes/collection/class-users.php +++ b/includes/collection/class-users.php @@ -7,6 +7,7 @@ use Activitypub\Model\User; use Activitypub\Model\Blog_User; use Activitypub\Model\Application_User; +use function Activitypub\url_to_authorid; use function Activitypub\is_user_disabled; class Users { @@ -103,6 +104,8 @@ class Users { return self::get_by_id( $user->results[0] ); } + $username = str_replace( array( '*', '%' ), '', $username ); + // check for login or nicename. $user = new WP_User_Query( array( @@ -133,29 +136,79 @@ class Users { * @return \Acitvitypub\Model\User The User. */ public static function get_by_resource( $resource ) { - if ( \strpos( $resource, '@' ) === false ) { - return new WP_Error( - 'activitypub_unsupported_resource', - \__( 'Resource is invalid', 'activitypub' ), - array( 'status' => 400 ) - ); + $scheme = 'acct'; + $match = array(); + // try to extract the scheme and the host + if ( preg_match( '/^([a-zA-Z^:]+):(.*)$/i', $resource, $match ) ) { + // extract the scheme + $scheme = esc_attr( $match[1] ); } - $resource = \str_replace( 'acct:', '', $resource ); + switch ( $scheme ) { + // check for http(s) URIs + case 'http': + case 'https': + $url_parts = wp_parse_url( $resource ); - $resource_identifier = \substr( $resource, 0, \strrpos( $resource, '@' ) ); - $resource_host = self::normalize_host( \substr( \strrchr( $resource, '@' ), 1 ) ); - $blog_host = self::normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) ); + // check for http(s)://blog.example.com/@username + if ( + isset( $url_parts['path'] ) && + str_starts_with( $url_parts['path'], '/@' ) + ) { + $identifier = str_replace( '/@', '', $url_parts['path'] ); + $identifier = untrailingslashit( $identifier ); - if ( $blog_host !== $resource_host ) { - return new WP_Error( - 'activitypub_wrong_host', - \__( 'Resource host does not match blog host', 'activitypub' ), - array( 'status' => 404 ) - ); + return self::get_by_username( $identifier ); + } + + // check for http(s)://blog.example.com/author/username + $user_id = url_to_authorid( $resource ); + + if ( $user_id ) { + return self::get_by_id( $user_id ); + } + + // check for http(s)://blog.example.com/ + if ( + self::normalize_url( site_url() ) === self::normalize_url( $resource ) || + self::normalize_url( home_url() ) === self::normalize_url( $resource ) + ) { + return self::get_by_id( self::BLOG_USER_ID ); + } + + return new WP_Error( + 'activitypub_no_user_found', + \__( 'User not found', 'activitypub' ), + array( 'status' => 404 ) + ); + // check for acct URIs + case 'acct': + $resource = \str_replace( 'acct:', '', $resource ); + $identifier = \substr( $resource, 0, \strrpos( $resource, '@' ) ); + $host = self::normalize_host( \substr( \strrchr( $resource, '@' ), 1 ) ); + $blog_host = self::normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) ); + + if ( $blog_host !== $host ) { + return new WP_Error( + 'activitypub_wrong_host', + \__( 'Resource host does not match blog host', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + // prepare wildcards https://github.com/mastodon/mastodon/issues/22213 + if ( in_array( $identifier, array( '_', '*', '' ), true ) ) { + return self::get_by_id( self::BLOG_USER_ID ); + } + + return self::get_by_username( $identifier ); + default: + return new WP_Error( + 'activitypub_wrong_scheme', + \__( 'Wrong scheme', 'activitypub' ), + array( 'status' => 404 ) + ); } - - return self::get_by_username( $resource_identifier ); } /** @@ -168,7 +221,12 @@ class Users { public static function get_by_various( $id ) { if ( is_numeric( $id ) ) { return self::get_by_id( $id ); - } elseif ( filter_var( $id, FILTER_VALIDATE_URL ) ) { + } elseif ( + // is URL + filter_var( $id, FILTER_VALIDATE_URL ) || + // is acct + str_starts_with( $id, 'acct:' ) + ) { return self::get_by_resource( $id ); } else { return self::get_by_username( $id ); @@ -176,7 +234,7 @@ class Users { } /** - * Normalize the host. + * Normalize a host. * * @param string $host The host. * @@ -186,6 +244,22 @@ class Users { return \str_replace( 'www.', '', $host ); } + /** + * Normalize a URL. + * + * @param string $url The URL. + * + * @return string The normalized URL. + */ + public static function normalize_url( $url ) { + $url = \untrailingslashit( $url ); + $url = \str_replace( 'https://', '', $url ); + $url = \str_replace( 'http://', '', $url ); + $url = \str_replace( 'www.', '', $url ); + + return $url; + } + /** * Get the User collection. * diff --git a/includes/compat.php b/includes/compat.php index 3dd405c..d1047df 100644 --- a/includes/compat.php +++ b/includes/compat.php @@ -44,6 +44,35 @@ if ( ! function_exists( 'is_countable' ) ) { * @return bool True if `$value` is countable, otherwise false. */ function is_countable( $value ) { - return is_array( $value ) || $value instanceof \Countable; + return is_array( $value ) || $value instanceof \Countable; + } +} + +/** + * Polyfill for `array_is_list()` function added in PHP 7.3. + * + * @param array $array The array to check. + * + * @return bool True if `$array` is a list, otherwise false. + */ +if ( ! function_exists( 'array_is_list' ) ) { + function array_is_list( $array ) { + if ( ! is_array( $array ) ) { + return false; + } + + if ( array_values( $array ) === $array ) { + return true; + } + + $next_key = -1; + + foreach ( $array as $k => $v ) { + if ( ++$next_key !== $k ) { + return false; + } + } + + return true; } } diff --git a/includes/functions.php b/includes/functions.php index f9d602f..1140724 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -2,6 +2,7 @@ namespace Activitypub; use WP_Error; +use WP_Comment_Query; use Activitypub\Http; use Activitypub\Activity\Activity; use Activitypub\Collection\Followers; @@ -486,6 +487,81 @@ 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 ); +} + /** * Get active users based on a given duration * @@ -557,3 +633,105 @@ function get_total_users() { return $users + 1; } + +/** + * Examine a comment ID and look up an existing comment it represents. + * + * @param string $id ActivityPub object ID (usually a URL) to check. + * + * @return int|boolean Comment ID, or false on failure. + */ +function object_id_to_comment( $id ) { + $comment_query = new WP_Comment_Query( + array( + 'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + ) + ); + + if ( ! $comment_query->comments ) { + return false; + } + + if ( count( $comment_query->comments ) > 1 ) { + return false; + } + + return $comment_query->comments[0]; +} + +/** + * Verify if URL is a local comment, + * Or if it is a previously received remote comment + * (For threading comments locally) + * + * @param string $url The URL to check. + * + * @return int comment_ID or null if not found + */ +function url_to_commentid( $url ) { + if ( ! $url || ! filter_var( $url, FILTER_VALIDATE_URL ) ) { + return null; + } + + $args = array( + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + 'relation' => 'OR', + array( + 'key' => 'source_url', + 'value' => $url, + ), + array( + 'key' => 'source_id', + 'value' => $url, + ), + ), + ); + + $query = new \WP_Comment_Query(); + $comments = $query->query( $args ); + + if ( $comments && is_array( $comments ) ) { + return $comments[0]->comment_ID; + } + + return null; +} + +/** + * Get the URI of an ActivityPub object + * + * @param array $object The ActivityPub object + * + * @return string The URI of the ActivityPub object + */ +function object_to_uri( $object ) { + // check if it is already simple + if ( ! $object || is_string( $object ) ) { + return $object; + } + + // check if it is a list, then take first item + // this plugin does not support collections + if ( array_is_list( $object ) ) { + $object = $object[0]; + } + + // check if it is simplified now + if ( is_string( $object ) ) { + return $object; + } + + // return part of Object that makes most sense + switch ( $object['type'] ) { + case 'Link': + $object = $object['href']; + break; + default: + $object = $object['id']; + break; + } + + return $object; +} diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php new file mode 100644 index 0000000..2e5d76a --- /dev/null +++ b/includes/handler/class-create.php @@ -0,0 +1,61 @@ +delete(); + } + } + + /** + * Delete Reactions if Actor-URL is a Tombstone. + * + * @param array $activity The delete activity. + */ + public static function maybe_delete_interactions( $activity ) { + // verify if Actor is deleted. + if ( Http::is_tombstone( $activity['actor'] ) ) { + \wp_schedule_single_event( + \time(), + 'activitypub_delete_actor_interactions', + array( $activity['actor'] ) + ); + } + } + + /** + * Delete comments from an Actor. + * + * @param array $comments The comments to delete. + */ + public static function delete_interactions( $actor ) { + $comments = Interactions::get_interactions_by_actor( $actor ); + + if ( is_array( $comments ) ) { + foreach ( $comments as $comment ) { + wp_delete_comment( $comment->comment_ID ); + } + } + } + + /** + * Delete a Reaction if URL is a Tombstone. + * + * @param array $activity The delete activity. + * + * @return void + */ + public static function maybe_delete_interaction( $activity ) { + if ( is_array( $activity['object'] ) ) { + $id = $activity['object']['id']; + } else { + $id = $activity['object']; + } + + $comments = Interactions::get_interaction_by_id( $id ); + + if ( $comments && Http::is_tombstone( $id ) ) { + foreach ( $comments as $comment ) { + wp_delete_comment( $comment->comment_ID, true ); + } + } + } + + /** + * Defer signature verification for `Delete` requests. + * + * @param bool $defer Whether to defer signature verification. + * @param WP_REST_Request $request The request object. + * + * @return bool Whether to defer signature verification. + */ + public static function defer_signature_verification( $defer, $request ) { + $json = $request->get_json_params(); + + if ( isset( $json['type'] ) && 'Delete' === $json['type'] ) { + return true; + } + + return false; + } +} diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php new file mode 100644 index 0000000..6855dbd --- /dev/null +++ b/includes/handler/class-follow.php @@ -0,0 +1,81 @@ +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..13c06f3 --- /dev/null +++ b/includes/handler/class-undo.php @@ -0,0 +1,31 @@ +post = $post; - $transformer = new Transformer_Post(); - $this->object = $transformer->transform( $post ); - } - - /** - * Returns the User ID. - * - * @return int the User ID. - */ - public function get_user_id() { - return apply_filters( 'activitypub_post_user_id', $this->post->post_author, $this->post ); - } - - /** - * Converts this Object into an Array. - * - * @return array the array representation of a Post. - */ - public function to_array() { - return \apply_filters( 'activitypub_post', $this->object->to_array(), $this->post ); - } - - /** - * Returns the Actor of this Object. - * - * @return string The URL of the Actor. - */ - public function get_actor() { - $user = User_Factory::get_by_id( $this->get_user_id() ); - - return $user->get_url(); - } - - /** - * Converts this Object into a JSON String - * - * @return string - */ - public function to_json() { - return \wp_json_encode( $this->to_array(), \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT ); - } - - /** - * Returns the URL of an Activity Object - * - * @return string - */ - public function get_url() { - return $this->object->get_url(); - } - - /** - * Returns the ID of an Activity Object - * - * @return string - */ - public function get_id() { - return $this->object->get_id(); - } - - /** - * Returns a list of Image Attachments - * - * @return array - */ - public function get_attachments() { - return $this->object->get_attachment(); - } - - /** - * Returns a list of Tags, used in the Post - * - * @return array - */ - public function get_tags() { - return $this->object->get_tag(); - } - - /** - * Returns the as2 object-type for a given post - * - * @return string the object-type - */ - public function get_object_type() { - return $this->object->get_type(); - } - - /** - * Returns the content for the ActivityPub Item. - * - * @return string the content - */ - public function get_content() { - return $this->object->get_content(); - } -} diff --git a/includes/peer/class-followers.php b/includes/peer/class-followers.php deleted file mode 100644 index e0e6ddb..0000000 --- a/includes/peer/class-followers.php +++ /dev/null @@ -1,34 +0,0 @@ -orderedItems = array_map( - function( $item ) use ( $context ) { + function ( $item ) use ( $context ) { if ( 'full' === $context ) { return $item->to_array(); } diff --git a/includes/rest/class-inbox.php b/includes/rest/class-inbox.php index 15fb4b2..938ca90 100644 --- a/includes/rest/class-inbox.php +++ b/includes/rest/class-inbox.php @@ -8,9 +8,11 @@ use Activitypub\Activity\Activity; use Activitypub\Collection\Users as User_Collection; use function Activitypub\get_context; +use function Activitypub\object_to_uri; 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 @@ -25,8 +27,6 @@ class Inbox { */ public static function init() { self::register_routes(); - - \add_action( 'activitypub_inbox_create', array( self::class, 'handle_create' ), 10, 2 ); } /** @@ -130,12 +130,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 +152,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 +183,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 ); @@ -235,15 +237,8 @@ class Inbox { $params['actor'] = array( 'required' => true, - 'sanitize_callback' => function( $param, $request, $key ) { - if ( \is_array( $param ) ) { - if ( isset( $param['id'] ) ) { - $param = $param['id']; - } else { - $param = $param['url']; - } - } - return \esc_url_raw( $param ); + 'sanitize_callback' => function ( $param, $request, $key ) { + return object_to_uri( $param ); }, ); @@ -251,7 +246,7 @@ class Inbox { 'required' => true, //'type' => 'enum', //'enum' => array( 'Create' ), - //'sanitize_callback' => function( $param, $request, $key ) { + //'sanitize_callback' => function ( $param, $request, $key ) { // return \strtolower( $param ); //}, ); @@ -284,11 +279,8 @@ class Inbox { $params['actor'] = array( 'required' => true, //'type' => array( 'object', 'string' ), - 'sanitize_callback' => function( $param, $request, $key ) { - if ( ! \is_string( $param ) ) { - $param = $param['id']; - } - return \esc_url_raw( $param ); + 'sanitize_callback' => function ( $param, $request, $key ) { + return object_to_uri( $param ); }, ); @@ -296,7 +288,7 @@ class Inbox { 'required' => true, //'type' => 'enum', //'enum' => array( 'Create' ), - //'sanitize_callback' => function( $param, $request, $key ) { + //'sanitize_callback' => function ( $param, $request, $key ) { // return \strtolower( $param ); //}, ); @@ -308,7 +300,7 @@ class Inbox { $params['to'] = array( 'required' => false, - 'sanitize_callback' => function( $param, $request, $key ) { + 'sanitize_callback' => function ( $param, $request, $key ) { if ( \is_string( $param ) ) { $param = array( $param ); } @@ -318,7 +310,7 @@ class Inbox { ); $params['cc'] = array( - 'sanitize_callback' => function( $param, $request, $key ) { + 'sanitize_callback' => function ( $param, $request, $key ) { if ( \is_string( $param ) ) { $param = array( $param ); } @@ -328,7 +320,7 @@ class Inbox { ); $params['bcc'] = array( - 'sanitize_callback' => function( $param, $request, $key ) { + 'sanitize_callback' => function ( $param, $request, $key ) { if ( \is_string( $param ) ) { $param = array( $param ); } @@ -340,121 +332,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' => addslashes( \wp_kses( $object['object']['content'], 'pre_comment_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'; - } - ); - - \add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 ); - - $state = \wp_new_comment( $commentdata, true ); - - \remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ) ); - \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 * @@ -463,7 +340,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 ) { @@ -478,41 +355,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 ); - } - - /** - * Adds line breaks to the list of allowed comment tags. - * - * @param array $allowedtags Allowed HTML tags. - * @param string $context Context. - * @return array Filtered tag list. - */ - public static function allowed_comment_html( $allowedtags, $context = '' ) { - if ( 'pre_comment_content' !== $context ) { - // Do nothing. - return $allowedtags; - } - - // Add `p` and `br` to the list of allowed tags. - if ( ! array_key_exists( 'br', $allowedtags ) ) { - $allowedtags['br'] = array(); - } - - if ( ! array_key_exists( 'p', $allowedtags ) ) { - $allowedtags['p'] = array(); - } - - return $allowedtags; - } } diff --git a/includes/rest/class-server.php b/includes/rest/class-server.php index bf89038..1bad5a7 100644 --- a/includes/rest/class-server.php +++ b/includes/rest/class-server.php @@ -110,14 +110,20 @@ class Server { if ( 'GET' !== $request->get_method() ) { $verified_request = Signature::verify_http_signature( $request ); if ( \is_wp_error( $verified_request ) ) { - return new WP_Error( 'activitypub_signature_verification', $verified_request->get_error_message(), array( 'status' => 401 ) ); + return new WP_Error( + 'activitypub_signature_verification', + $verified_request->get_error_message(), + array( 'status' => 401 ) + ); } - } elseif ( 'GET' === $request->get_method() ) { // GET-Requests are only signed in secure mode - if ( ACTIVITYPUB_AUTHORIZED_FETCH ) { - $verified_request = Signature::verify_http_signature( $request ); - if ( \is_wp_error( $verified_request ) ) { - return new WP_Error( 'activitypub_signature_verification', $verified_request->get_error_message(), array( 'status' => 401 ) ); - } + } elseif ( 'GET' === $request->get_method() && ACTIVITYPUB_AUTHORIZED_FETCH ) { // GET-Requests are only signed in secure mode + $verified_request = Signature::verify_http_signature( $request ); + if ( \is_wp_error( $verified_request ) ) { + return new WP_Error( + 'activitypub_signature_verification', + $verified_request->get_error_message(), + array( 'status' => 401 ) + ); } } diff --git a/includes/rest/class-webfinger.php b/includes/rest/class-webfinger.php index 34ae392..52abd9f 100644 --- a/includes/rest/class-webfinger.php +++ b/includes/rest/class-webfinger.php @@ -72,7 +72,7 @@ class Webfinger { $params['resource'] = array( 'required' => true, 'type' => 'string', - 'pattern' => '^acct:(.+)@(.+)$', + 'pattern' => '^(acct:)|^(https?://)(.+)$', ); return $params; diff --git a/includes/table/class-followers.php b/includes/table/class-followers.php index 3045ddd..df9747b 100644 --- a/includes/table/class-followers.php +++ b/includes/table/class-followers.php @@ -5,6 +5,8 @@ use WP_List_Table; use Activitypub\Collection\Users; use Activitypub\Collection\Followers as FollowerCollection; +use function Activitypub\object_to_uri; + if ( ! \class_exists( '\WP_List_Table' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; } @@ -101,7 +103,7 @@ class Followers extends WP_List_Table { 'icon' => esc_attr( $follower->get_icon_url() ), 'post_title' => esc_attr( $follower->get_name() ), 'username' => esc_attr( $follower->get_preferred_username() ), - 'url' => esc_attr( $follower->get_url() ), + 'url' => esc_attr( object_to_uri( $follower->get_url() ) ), 'identifier' => esc_attr( $follower->get_id() ), 'published' => esc_attr( $follower->get_published() ), 'modified' => esc_attr( $follower->get_updated() ), diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index b5fec37..8682e53 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -314,19 +314,24 @@ abstract class Base { // todo renmae to Base_tranformer * @return string The Template. */ protected function get_post_content_template() { - if ( 'excerpt' === \get_option( 'activitypub_post_content_type', 'content' ) ) { - return "[ap_excerpt]\n\n[ap_permalink type=\"html\"]"; + $type = \get_option( 'activitypub_post_content_type', 'content' ); + + switch ( $type ) { + case 'excerpt': + $template = "[ap_excerpt]\n\n[ap_permalink type=\"html\"]"; + break; + case 'title': + $template = "[ap_title]\n\n[ap_permalink type=\"html\"]"; + break; + case 'content': + $template = "[ap_content]\n\n[ap_permalink type=\"html\"]\n\n[ap_hashtags]"; + break; + default: + $template = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT ); + break; } - if ( 'title' === \get_option( 'activitypub_post_content_type', 'content' ) ) { - return "[ap_title]\n\n[ap_permalink type=\"html\"]"; - } - - if ( 'content' === \get_option( 'activitypub_post_content_type', 'content' ) ) { - return "[ap_content]\n\n[ap_permalink type=\"html\"]\n\n[ap_hashtags]"; - } - - return \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT ); + return apply_filters( 'activitypub_object_content_template', $template, $this->wp_post ); } /** diff --git a/integration/class-webfinger.php b/integration/class-webfinger.php index c9dd565..6b3b6dd 100644 --- a/integration/class-webfinger.php +++ b/integration/class-webfinger.php @@ -53,10 +53,12 @@ class Webfinger { * @return array the jrd array */ public static function add_pseudo_user_discovery( $array, $resource ) { - if ( $array ) { + $user = Webfinger_Rest::get_profile( $resource ); + + if ( ! $user || is_wp_error( $user ) ) { return $array; } - return Webfinger_Rest::get_profile( $resource ); + return $user; } } diff --git a/readme.txt b/readme.txt index 5ffb987..40f210a 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: automattic, pfefferle, mediaformat, mattwiebe, akirk, jeherve, nur Tags: OStatus, fediverse, activitypub, activitystream Requires at least: 4.7 Tested up to: 6.4 -Stable tag: 1.2.0 +Stable tag: 1.3.0 Requires PHP: 5.6 License: MIT License URI: http://opensource.org/licenses/MIT @@ -105,9 +105,22 @@ Where 'blog' is the path to the subdirectory at which your blog resides. Project maintained on GitHub at [automattic/wordpress-activitypub](https://github.com/automattic/wordpress-activitypub). -= 1.2.1 = += 2.0.0 = +* Removed: Deprecated Classes +* Fixed: Normalize attributes that can have mixed value types +* Added: URL support for WebFinger +* Added: Make Post-Template filterable +* Addes: CSS class for ActivityPub comments to allow custom designs + += 1.3.0 = + +* Added: Threaded-Comments support * Improved: alt text for avatars in Follow Me/Followers blocks +* Improved: `Delete`, `Update` and `Follow` Activities +* Improved: better/more effective handling of `Delete` Activities +* Improved: allow `

` and `
` for Comments +* Fixed: removed default limit of WP_Query to send updates to all Inboxes and not only to the first 10 = 1.2.0 = diff --git a/tests/class-activitypub-testcase-cache-http.php b/tests/class-activitypub-testcase-cache-http.php index b8b0e43..a75c092 100644 --- a/tests/class-activitypub-testcase-cache-http.php +++ b/tests/class-activitypub-testcase-cache-http.php @@ -12,7 +12,7 @@ class ActivityPub_TestCase_Cache_HTTP extends \WP_UnitTestCase { add_filter( 'rest_url', - function() { + function () { return get_option( 'home' ) . '/wp-json/'; } ); diff --git a/tests/test-class-activitypub-activity-dispatcher.php b/tests/test-class-activitypub-activity-dispatcher.php index b7534a0..c42d391 100644 --- a/tests/test-class-activitypub-activity-dispatcher.php +++ b/tests/test-class-activitypub-activity-dispatcher.php @@ -70,7 +70,7 @@ class Test_Activitypub_Activity_Dispatcher extends ActivityPub_TestCase_Cache_HT add_filter( 'activitypub_extract_mentions', - function( $mentions ) { + function ( $mentions ) { $mentions[] = 'https://example.com/alex'; return $mentions; }, @@ -134,7 +134,7 @@ class Test_Activitypub_Activity_Dispatcher extends ActivityPub_TestCase_Cache_HT add_filter( 'activitypub_is_user_type_disabled', - function( $value, $type ) { + function ( $value, $type ) { if ( 'blog' === $type ) { return false; } else { diff --git a/tests/test-class-activitypub-activity.php b/tests/test-class-activitypub-activity.php index 4748106..5b73f9d 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-create-handler.php b/tests/test-class-activitypub-create-handler.php new file mode 100644 index 0000000..95c5025 --- /dev/null +++ b/tests/test-class-activitypub-create-handler.php @@ -0,0 +1,70 @@ +user_id = 1; + $authordata = \get_userdata( $this->user_id ); + $this->user_url = $authordata->user_url; + + $this->post_id = \wp_insert_post( + array( + 'post_author' => $this->user_id, + 'post_content' => 'test', + ) + ); + $this->post_permalink = \get_permalink( $this->post_id ); + + \add_filter( 'pre_get_remote_metadata_by_actor', array( '\Test_Activitypub_Create_Handler', 'get_remote_metadata_by_actor' ), 0, 2 ); + } + + public static function get_remote_metadata_by_actor( $value, $actor ) { + return array( + 'name' => 'Example User', + 'icon' => array( + 'url' => 'https://example.com/icon', + ), + 'url' => $actor, + 'id' => 'http://example.org/users/example', + ); + } + + public function create_test_object( $id = 'https://example.com/123' ) { + return array( + 'actor' => $this->user_url, + 'id' => 'https://example.com/id/' . microtime( true ), + 'to' => [ $this->user_url ], + 'cc' => [ 'https://www.w3.org/ns/activitystreams#Public' ], + 'object' => array( + 'id' => $id, + 'url' => 'https://example.com/example', + 'inReplyTo' => $this->post_permalink, + 'content' => 'example', + ), + ); + } + + public function test_handle_create_object_unset_rejected() { + $object = $this->create_test_object(); + unset( $object['object'] ); + $converted = Activitypub\Handler\Create::handle_create( $object, $this->user_id ); + $this->assertNull( $converted ); + } + + public function test_handle_create_non_public_rejected() { + $object = $this->create_test_object(); + $object['cc'] = []; + $converted = Activitypub\Handler\Create::handle_create( $object, $this->user_id ); + $this->assertNull( $converted ); + } + + public function test_handle_create_no_id_rejected() { + $object = $this->create_test_object(); + unset( $object['object']['id'] ); + $converted = Activitypub\Handler\Create::handle_create( $object, $this->user_id ); + $this->assertNull( $converted ); + } +} diff --git a/tests/test-class-db-activitypub-followers.php b/tests/test-class-activitypub-followers.php similarity index 85% rename from tests/test-class-db-activitypub-followers.php rename to tests/test-class-activitypub-followers.php index 8fc0068..8d5fb32 100644 --- a/tests/test-class-db-activitypub-followers.php +++ b/tests/test-class-activitypub-followers.php @@ -1,5 +1,5 @@ array( 'id' => 'https://example.org/users/username', @@ -76,7 +76,7 @@ class Test_Db_Activitypub_Followers extends WP_UnitTestCase { $this->assertEquals( 3, \count( $db_followers ) ); $db_followers = array_map( - function( $item ) { + function ( $item ) { return $item->get_url(); }, $db_followers @@ -329,6 +329,82 @@ class Test_Db_Activitypub_Followers extends WP_UnitTestCase { $this->assertEquals( $name, $follower->get_name() ); } + public function test_get_inboxes() { + for ( $i = 0; $i < 30; $i++ ) { + $meta = array( + 'id' => 'https://example.org/users/' . $i, + 'url' => 'https://example.org/users/' . $i, + 'inbox' => 'https://example.org/users/' . $i . '/inbox', + 'name' => 'user' . $i, + 'preferredUsername' => 'user' . $i, + 'publicKey' => 'https://example.org/users/' . $i . '#main-key', + 'publicKeyPem' => $i, + ); + + $follower = new \Activitypub\Model\Follower(); + $follower->from_array( $meta ); + + $id = $follower->upsert(); + + add_post_meta( $id, 'activitypub_user_id', 1 ); + } + + $inboxes = \Activitypub\Collection\Followers::get_inboxes( 1 ); + + $this->assertCount( 30, $inboxes ); + + wp_cache_delete( sprintf( \Activitypub\Collection\Followers::CACHE_KEY_INBOXES, 1 ), 'activitypub' ); + + for ( $j = 0; $j < 5; $j++ ) { + $k = $j + 100; + $meta = array( + 'id' => 'https://example.org/users/' . $k, + 'url' => 'https://example.org/users/' . $k, + 'inbox' => 'https://example.org/users/' . $j . '/inbox', + 'name' => 'user' . $k, + 'preferredUsername' => 'user' . $k, + 'publicKey' => 'https://example.org/users/' . $k . '#main-key', + 'publicKeyPem' => $k, + ); + + $follower = new \Activitypub\Model\Follower(); + $follower->from_array( $meta ); + + $id = $follower->upsert(); + + add_post_meta( $id, 'activitypub_user_id', 1 ); + } + + $inboxes2 = \Activitypub\Collection\Followers::get_inboxes( 1 ); + + $this->assertCount( 30, $inboxes2 ); + } + + public function test_get_all_followers() { + for ( $i = 0; $i < 30; $i++ ) { + $meta = array( + 'id' => 'https://example.org/users/' . $i, + 'url' => 'https://example.org/users/' . $i, + 'inbox' => 'https://example.org/users/' . $i . '/inbox', + 'name' => 'user' . $i, + 'preferredUsername' => 'user' . $i, + 'publicKey' => 'https://example.org/users/' . $i . '#main-key', + 'publicKeyPem' => $i, + ); + + $follower = new \Activitypub\Model\Follower(); + $follower->from_array( $meta ); + + $id = $follower->upsert(); + + add_post_meta( $id, 'activitypub_user_id', 1 ); + } + + $followers = \Activitypub\Collection\Followers::get_all_followers(); + + $this->assertCount( 30, $followers ); + } + public static function http_request_host_is_external( $in, $host ) { if ( in_array( $host, array( 'example.com', 'example.org' ), true ) ) { return true; diff --git a/tests/test-class-activitypub-interactions.php b/tests/test-class-activitypub-interactions.php new file mode 100644 index 0000000..6345095 --- /dev/null +++ b/tests/test-class-activitypub-interactions.php @@ -0,0 +1,174 @@ +user_id = 1; + $authordata = \get_userdata( $this->user_id ); + $this->user_url = $authordata->user_url; + + $this->post_id = \wp_insert_post( + array( + 'post_author' => $this->user_id, + 'post_content' => 'test', + ) + ); + $this->post_permalink = \get_permalink( $this->post_id ); + + \add_filter( 'pre_get_remote_metadata_by_actor', array( '\Test_Activitypub_Interactions', 'get_remote_metadata_by_actor' ), 0, 2 ); + } + + public static function get_remote_metadata_by_actor( $value, $actor ) { + return array( + 'name' => 'Example User', + 'icon' => array( + 'url' => 'https://example.com/icon', + ), + 'url' => $actor, + 'id' => 'http://example.org/users/example', + ); + } + + public function create_test_object( $id = 'https://example.com/123' ) { + return array( + 'actor' => $this->user_url, + 'id' => 'https://example.com/id/' . microtime( true ), + 'to' => [ $this->user_url ], + 'cc' => [ 'https://www.w3.org/ns/activitystreams#Public' ], + 'object' => array( + 'id' => $id, + 'url' => 'https://example.com/example', + 'inReplyTo' => $this->post_permalink, + 'content' => 'example', + ), + ); + } + + public function create_test_rich_object( $id = 'https://example.com/123' ) { + return array( + 'actor' => $this->user_url, + 'id' => 'https://example.com/id/' . microtime( true ), + 'to' => [ $this->user_url ], + 'cc' => [ 'https://www.w3.org/ns/activitystreams#Public' ], + 'object' => array( + 'id' => $id, + 'url' => 'https://example.com/example', + 'inReplyTo' => $this->post_permalink, + 'content' => 'Hello
example

example

', + ), + ); + } + + public function test_handle_create_basic() { + $comment_id = Activitypub\Collection\Interactions::add_comment( $this->create_test_object() ); + $comment = get_comment( $comment_id, ARRAY_A ); + + $this->assertIsArray( $comment ); + $this->assertEquals( $this->post_id, $comment['comment_post_ID'] ); + $this->assertEquals( 'Example User', $comment['comment_author'] ); + $this->assertEquals( $this->user_url, $comment['comment_author_url'] ); + $this->assertEquals( 'example', $comment['comment_content'] ); + $this->assertEquals( 'comment', $comment['comment_type'] ); + $this->assertEquals( '', $comment['comment_author_email'] ); + $this->assertEquals( 0, $comment['comment_parent'] ); + $this->assertEquals( 'https://example.com/123', get_comment_meta( $comment_id, 'source_id', true ) ); + $this->assertEquals( 'https://example.com/example', get_comment_meta( $comment_id, 'source_url', true ) ); + $this->assertEquals( 'https://example.com/icon', get_comment_meta( $comment_id, 'avatar_url', true ) ); + $this->assertEquals( 'activitypub', get_comment_meta( $comment_id, 'protocol', true ) ); + } + + public function test_handle_create_rich() { + $comment_id = Activitypub\Collection\Interactions::add_comment( $this->create_test_rich_object() ); + $comment = get_comment( $comment_id, ARRAY_A ); + + $this->assertEquals( 'Hello
example

example

', $comment['comment_content'] ); + + $commentarray = array( + 'comment_post_ID' => $this->post_id, + 'comment_author' => 'Example User', + 'comment_author_url' => $this->user_url, + 'comment_content' => 'Hello
example

example

', + 'comment_type' => 'comment', + 'comment_author_email' => '', + 'comment_parent' => 0, + 'comment_meta' => array( + 'source_id' => 'https://example.com/123', + 'source_url' => 'https://example.com/example', + 'protocol' => 'activitypub', + ), + ); + + \remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); + $comment_id = wp_new_comment( $commentarray ); + \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + $comment = get_comment( $comment_id, ARRAY_A ); + + $this->assertEquals( 'Helloexampleexample', $comment['comment_content'] ); + } + + public function test_convert_object_to_comment_not_reply_rejected() { + $object = $this->create_test_object(); + unset( $object['object']['inReplyTo'] ); + $converted = Activitypub\Collection\Interactions::add_comment( $object ); + $this->assertFalse( $converted ); + } + + public function test_convert_object_to_comment_already_exists_rejected() { + $object = $this->create_test_object( 'https://example.com/test_convert_object_to_comment_already_exists_rejected' ); + Activitypub\Collection\Interactions::add_comment( $object ); + $converted = Activitypub\Collection\Interactions::add_comment( $object ); + $this->assertEquals( $converted->get_error_code(), 'comment_duplicate' ); + } + + public function test_convert_object_to_comment_reply_to_comment() { + $id = 'https://example.com/test_convert_object_to_comment_reply_to_comment'; + $object = $this->create_test_object( $id ); + Activitypub\Collection\Interactions::add_comment( $object ); + $comment = \Activitypub\object_id_to_comment( $id ); + + $object['object']['inReplyTo'] = $id; + $object['object']['id'] = 'https://example.com/234'; + $id = Activitypub\Collection\Interactions::add_comment( $object ); + $converted = get_comment( $id, ARRAY_A ); + + $this->assertIsArray( $converted ); + $this->assertEquals( $this->post_id, $converted['comment_post_ID'] ); + $this->assertEquals( $comment->comment_ID, $converted['comment_parent'] ); + } + + public function test_convert_object_to_comment_reply_to_non_existent_comment_rejected() { + $object = $this->create_test_object(); + $object['object']['inReplyTo'] = 'https://example.com/not_found'; + $converted = Activitypub\Collection\Interactions::add_comment( $object ); + $this->assertFalse( $converted ); + } + + public function test_handle_create_basic2() { + $id = 'https://example.com/test_handle_create_basic'; + $object = $this->create_test_object( $id ); + Activitypub\Collection\Interactions::add_comment( $object ); + $comment = \Activitypub\object_id_to_comment( $id ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + } + + public function test_get_interaction_by_id() { + $id = 'https://example.com/test_get_interaction_by_id'; + $url = 'https://example.com/test_get_interaction_by_url'; + $object = $this->create_test_object( $id ); + $object['object']['url'] = $url; + + Activitypub\Collection\Interactions::add_comment( $object ); + $comment = \Activitypub\object_id_to_comment( $id ); + $interactions = Activitypub\Collection\Interactions::get_interaction_by_id( $id ); + $this->assertIsArray( $interactions ); + $this->assertEquals( $comment->comment_ID, $interactions[0]->comment_ID ); + + $comment = \Activitypub\object_id_to_comment( $id ); + $interactions = Activitypub\Collection\Interactions::get_interaction_by_id( $url ); + $this->assertIsArray( $interactions ); + $this->assertEquals( $comment->comment_ID, $interactions[0]->comment_ID ); + } +} 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() { diff --git a/tests/test-class-activitypub-rest-post-signature-verification.php b/tests/test-class-activitypub-rest-post-signature-verification.php index 98e7855..12b0abe 100644 --- a/tests/test-class-activitypub-rest-post-signature-verification.php +++ b/tests/test-class-activitypub-rest-post-signature-verification.php @@ -55,7 +55,7 @@ class Test_Activitypub_Signature_Verification extends WP_UnitTestCase { public function test_rest_activity_signature() { add_filter( 'pre_get_remote_metadata_by_actor', - function( $json, $actor ) { + function ( $json, $actor ) { $user = Activitypub\Collection\Users::get_by_id( 1 ); $public_key = Activitypub\Signature::get_public_key_for( $user->get__id() ); // return ActivityPub Profile with signature diff --git a/tests/test-class-activitypub-users-collection.php b/tests/test-class-activitypub-users-collection.php new file mode 100644 index 0000000..a5fd60c --- /dev/null +++ b/tests/test-class-activitypub-users-collection.php @@ -0,0 +1,43 @@ +assertInstanceOf( $expected, $user ); + } + + public function the_resource_provider() { + return array( + array( 'http://example.org/?author=1', 'Activitypub\Model\User' ), + array( 'https://example.org/?author=1', 'Activitypub\Model\User' ), + array( 'http://example.org/?author=7', 'WP_Error' ), + array( 'acct:admin@example.org', 'Activitypub\Model\User' ), + array( 'acct:blog@example.org', 'Activitypub\Model\Blog_User' ), + array( 'acct:*@example.org', 'Activitypub\Model\Blog_User' ), + array( 'acct:_@example.org', 'Activitypub\Model\Blog_User' ), + array( 'acct:aksd@example.org', 'WP_Error' ), + array( 'admin@example.org', 'Activitypub\Model\User' ), + array( 'acct:application@example.org', 'Activitypub\Model\Application_User' ), + array( 'http://example.org/@admin', 'Activitypub\Model\User' ), + array( 'http://example.org/@blog', 'Activitypub\Model\Blog_User' ), + array( 'https://example.org/@blog', 'Activitypub\Model\Blog_User' ), + array( 'http://example.org/@blog/', 'Activitypub\Model\Blog_User' ), + array( 'http://example.org/', 'Activitypub\Model\Blog_User' ), + array( 'http://example.org', 'Activitypub\Model\Blog_User' ), + array( 'https://example.org/', 'Activitypub\Model\Blog_User' ), + array( 'https://example.org', 'Activitypub\Model\Blog_User' ), + array( 'http://example.org/@blog/s', 'WP_Error' ), + array( 'http://example.org/@blogs/', 'WP_Error' ), + ); + } +} diff --git a/tests/test-functions.php b/tests/test-functions.php index 68140e0..4ee049c 100644 --- a/tests/test-functions.php +++ b/tests/test-functions.php @@ -1,9 +1,149 @@ assertEquals( 'https://notiz.blog/author/matthias-pfefferle/', $metadata['url'] ); $this->assertEquals( 'pfefferle', $metadata['preferredUsername'] ); $this->assertEquals( 'Matthias Pfefferle', $metadata['name'] ); } + + public function set_up() { + $this->post_id = \wp_insert_post( + array( + 'post_author' => $this->user_id, + 'post_content' => 'test', + ) + ); + } + + public function test_object_id_to_comment_basic() { + $single_comment_source_id = 'https://example.com/single'; + $content = 'example'; + $comment_id = \wp_new_comment( + array( + 'comment_post_ID' => $this->post_id, + 'comment_author' => 'Example User', + 'comment_author_url' => 'https://example.com/user', + 'comment_content' => $content, + 'comment_type' => '', + 'comment_author_email' => '', + 'comment_parent' => 0, + 'comment_meta' => array( + 'source_id' => $single_comment_source_id, + 'source_url' => 'https://example.com/123', + 'avatar_url' => 'https://example.com/icon', + 'protocol' => 'activitypub', + ), + ), + true + ); + $query_result = \Activitypub\object_id_to_comment( $single_comment_source_id ); + $this->assertInstanceOf( WP_Comment::class, $query_result ); + $this->assertEquals( $comment_id, $query_result->comment_ID ); + $this->assertEquals( $content, $query_result->comment_content ); + } + + public function test_object_id_to_comment_none() { + $single_comment_source_id = 'https://example.com/none'; + $query_result = \Activitypub\object_id_to_comment( $single_comment_source_id ); + $this->assertFalse( $query_result ); + } + + public function test_object_id_to_comment_duplicate() { + $duplicate_comment_source_id = 'https://example.com/duplicate'; + for ( $i = 0; $i < 2; ++$i ) { + \wp_new_comment( + array( + 'comment_post_ID' => $this->post_id, + 'comment_author' => 'Example User', + 'comment_author_url' => 'https://example.com/user', + 'comment_content' => 'example', + 'comment_type' => '', + 'comment_author_email' => '', + 'comment_parent' => 0, + 'comment_meta' => array( + 'source_id' => $duplicate_comment_source_id, + 'source_url' => 'https://example.com/123', + 'avatar_url' => 'https://example.com/icon', + 'protocol' => 'activitypub', + ), + ), + true + ); + } + $query_result = \Activitypub\object_id_to_comment( $duplicate_comment_source_id ); + $this->assertFalse( $query_result ); + } + + /** + * @dataProvider object_to_uri_provider + */ + public function test_object_to_uri( $input, $output ) { + $this->assertEquals( $output, \Activitypub\object_to_uri( $input ) ); + } + + public function object_to_uri_provider() { + return array( + array( null, null ), + array( 'https://example.com', 'https://example.com' ), + array( array( 'https://example.com' ), 'https://example.com' ), + array( + array( + 'https://example.com', + 'https://example.org', + ), + 'https://example.com', + ), + array( + array( + 'type' => 'Link', + 'href' => 'https://example.com', + ), + 'https://example.com', + ), + array( + array( + array( + 'type' => 'Link', + 'href' => 'https://example.com', + ), + array( + 'type' => 'Link', + 'href' => 'https://example.org', + ), + ), + 'https://example.com', + ), + array( + array( + 'type' => 'Actor', + 'id' => 'https://example.com', + ), + 'https://example.com', + ), + array( + array( + array( + 'type' => 'Actor', + 'id' => 'https://example.com', + ), + array( + 'type' => 'Actor', + 'id' => 'https://example.org', + ), + ), + 'https://example.com', + ), + array( + array( + 'type' => 'Activity', + 'id' => 'https://example.com', + ), + 'https://example.com', + ), + ); + } }