diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf72759f..a02d0aafb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +* Updates to certain user meta fields did not trigger an Update activity. + +## [5.4.0] - 2025-03-03 + +### Added + +* Upgrade script to fix Follower json representations with unescaped backslashes. +* Centralized place for sanitization functions. + ### Changed * Bumped minimum required WordPress version to 6.4. * Use a later hook for Posts to get published to the Outbox, to get sure all `post_meta`s and `taxonomy`s are set stored properly. * Use webfinger as author email for comments from the Fediverse. +* Remove the special handling of comments from Enable Mastodon Apps. + +### Fixed + +* Do not redirect `/@username` URLs to the API any more, to improve `AUTHORIZED_FETCH` handling. ## [5.3.2] - 2025-02-27 @@ -1353,8 +1369,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * initial -[Unreleased]: https://github.com/Automattic/wordpress-activitypub/compare/5.3.2...trunk +[Unreleased]: https://github.com/Automattic/wordpress-activitypub/compare/5.4.0...trunk +[5.4.0]: https://github.com/Automattic/wordpress-activitypub/compare/5.3.2...5.4.0 [5.3.2]: https://github.com/Automattic/wordpress-activitypub/compare/5.3.1...5.3.2 [5.3.1]: https://github.com/Automattic/wordpress-activitypub/compare/5.3.0...5.3.1 [5.3.0]: https://github.com/Automattic/wordpress-activitypub/compare/5.2.0...5.3.0 diff --git a/activitypub.php b/activitypub.php index 5cd7f5af0..58b15b183 100644 --- a/activitypub.php +++ b/activitypub.php @@ -3,7 +3,7 @@ * Plugin Name: ActivityPub * Plugin URI: https://github.com/Automattic/wordpress-activitypub * Description: The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format. - * Version: 5.3.2 + * Version: 5.4.0 * Author: Matthias Pfefferle & Automattic * Author URI: https://automattic.com/ * License: MIT @@ -19,7 +19,7 @@ use WP_CLI; -\define( 'ACTIVITYPUB_PLUGIN_VERSION', '5.3.2' ); +\define( 'ACTIVITYPUB_PLUGIN_VERSION', '5.4.0' ); // Plugin related constants. \define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); @@ -84,6 +84,7 @@ function plugin_init() { \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings_Fields', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Blog_Settings_Fields', 'init' ) ); + \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\User_Settings_Fields', 'init' ) ); if ( site_supports_blocks() ) { \add_action( 'init', array( __NAMESPACE__ . '\Blocks', 'init' ) ); diff --git a/includes/activity/class-actor.php b/includes/activity/class-actor.php index 92ffb8a9c..2656938be 100644 --- a/includes/activity/class-actor.php +++ b/includes/activity/class-actor.php @@ -43,10 +43,6 @@ class Actor extends Base_Object { '@id' => 'lemmy:moderators', '@type' => '@id', ), - 'attributionDomains' => array( - '@id' => 'toot:attributionDomains', - '@type' => '@id', - ), 'alsoKnownAs' => array( '@id' => 'as:alsoKnownAs', '@type' => '@id', @@ -55,6 +51,10 @@ class Actor extends Base_Object { '@id' => 'as:movedTo', '@type' => '@id', ), + 'attributionDomains' => array( + '@id' => 'toot:attributionDomains', + '@type' => '@id', + ), 'postingRestrictedToMods' => 'lemmy:postingRestrictedToMods', 'discoverable' => 'toot:discoverable', 'indexable' => 'toot:indexable', diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 59f9a2cf6..c39090116 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -8,6 +8,7 @@ namespace Activitypub; use Exception; +use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; use Activitypub\Collection\Followers; use Activitypub\Collection\Extra_Fields; @@ -43,22 +44,17 @@ public static function init() { \add_action( 'user_register', array( self::class, 'user_register' ) ); - \add_action( 'in_plugin_update_message-' . ACTIVITYPUB_PLUGIN_BASENAME, array( self::class, 'plugin_update_message' ) ); - - if ( site_supports_blocks() ) { - \add_action( 'tool_box', array( self::class, 'tool_box' ) ); - Embed::init(); - } - \add_filter( 'activitypub_get_actor_extra_fields', array( Extra_Fields::class, 'default_actor_extra_fields' ), 10, 2 ); \add_action( 'updated_postmeta', array( self::class, 'updated_postmeta' ), 10, 4 ); \add_action( 'added_post_meta', array( self::class, 'updated_postmeta' ), 10, 4 ); + \add_action( 'init', array( self::class, 'register_user_meta' ), 11 ); // Register several post_types. self::register_post_types(); - + self::register_oembed_providers(); + Embed::init(); } /** @@ -97,6 +93,8 @@ public static function uninstall() { delete_option( 'activitypub_authorized_fetch' ); delete_option( 'activitypub_application_user_private_key' ); delete_option( 'activitypub_application_user_public_key' ); + delete_option( 'activitypub_blog_user_also_known_as' ); + delete_option( 'activitypub_blog_user_moved_to' ); delete_option( 'activitypub_blog_user_private_key' ); delete_option( 'activitypub_blog_user_public_key' ); delete_option( 'activitypub_blog_description' ); @@ -257,29 +255,50 @@ public static function redirect_canonical( $redirect_url, $requested_url ) { * @return void */ public static function template_redirect() { + global $wp_query; + $comment_id = get_query_var( 'c', null ); // Check if it seems to be a comment. - if ( ! $comment_id ) { - return; - } + if ( $comment_id ) { + $comment = get_comment( $comment_id ); - $comment = get_comment( $comment_id ); + // Load a 404-page if `c` is set but not valid. + if ( ! $comment ) { + $wp_query->set_404(); + return; + } - // Load a 404-page if `c` is set but not valid. - if ( ! $comment ) { - global $wp_query; - $wp_query->set_404(); - return; - } + // Stop if it's not an ActivityPub comment. + if ( is_activitypub_request() && ! is_local_comment( $comment ) ) { + return; + } - // Stop if it's not an ActivityPub comment. - if ( is_activitypub_request() && ! is_local_comment( $comment ) ) { - return; + wp_safe_redirect( get_comment_link( $comment ) ); + exit; } - wp_safe_redirect( get_comment_link( $comment ) ); - exit; + $actor = get_query_var( 'actor', null ); + if ( $actor ) { + $actor = Actors::get_by_username( $actor ); + if ( ! $actor || \is_wp_error( $actor ) ) { + $wp_query->set_404(); + return; + } + + if ( is_activitypub_request() ) { + return; + } + + if ( $actor->get__id() > 0 ) { + $redirect_url = $actor->get_url(); + } else { + $redirect_url = get_bloginfo( 'url' ); + } + + wp_safe_redirect( $redirect_url, 301 ); + exit; + } } /** @@ -293,6 +312,7 @@ public static function add_query_vars( $vars ) { $vars[] = 'activitypub'; $vars[] = 'preview'; $vars[] = 'author'; + $vars[] = 'actor'; $vars[] = 'c'; $vars[] = 'p'; @@ -414,12 +434,7 @@ public static function add_rewrite_rules() { ); } - \add_rewrite_rule( - '^@([\w\-\.]+)$', - 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/$matches[1]', - 'top' - ); - + \add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' ); \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES ); } @@ -431,15 +446,6 @@ public static function flush_rewrite_rules() { \flush_rewrite_rules(); } - /** - * Adds metabox on wp-admin/tools.php. - */ - public static function tool_box() { - if ( \current_user_can( 'edit_posts' ) ) { - \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/toolbox.php' ); - } - } - /** * Theme compatibility stuff. */ @@ -462,30 +468,6 @@ public static function theme_compat() { } } - /** - * Display plugin upgrade notice to users. - * - * @param array $data The plugin data. - */ - public static function plugin_update_message( $data ) { - if ( ! isset( $data['upgrade_notice'] ) ) { - return; - } - - printf( - '
', - wp_kses( - wpautop( $data['upgrade_notice '] ), - array( - 'p' => array(), - 'a' => array( 'href', 'title' ), - 'strong' => array(), - 'em' => array(), - ) - ) - ); - } - /** * Register Custom Post Types. */ @@ -752,5 +734,62 @@ public static function register_oembed_providers() { \wp_oembed_add_provider( '#https?://mstdn\.social/(@.+)/([0-9]+)#i', 'https://mstdn.social/api/oembed', true ); \wp_oembed_add_provider( '#https?://mastodon\.world/(@.+)/([0-9]+)#i', 'https://mastodon.world/api/oembed', true ); \wp_oembed_add_provider( '#https?://mas\.to/(@.+)/([0-9]+)#i', 'https://mas.to/api/oembed', true ); + } + + /* + * Register user meta. + */ + public static function register_user_meta() { + $blog_prefix = $GLOBALS['wpdb']->get_blog_prefix(); + + \register_meta( + 'user', + $blog_prefix . 'activitypub_also_known_as', + array( + 'type' => 'array', + 'description' => 'An array of URLs that the user is known by.', + 'single' => true, + 'default' => array(), + 'sanitize_callback' => array( Sanitize::class, 'url_list' ), + ) + ); + + \register_meta( + 'user', + $blog_prefix . 'activitypub_description', + array( + 'type' => 'string', + 'description' => 'The user’s description.', + 'single' => true, + 'default' => '', + 'sanitize_callback' => function ( $value ) { + return wp_kses( $value, 'user_description' ); + }, + ) + ); + + \register_meta( + 'user', + $blog_prefix . 'activitypub_icon', + array( + 'type' => 'integer', + 'description' => 'The attachment ID for user’s profile image.', + 'single' => true, + 'default' => 0, + 'sanitize_callback' => 'absint', + ) + ); + + \register_meta( + 'user', + $blog_prefix . 'activitypub_header_image', + array( + 'type' => 'integer', + 'description' => 'The attachment ID for the user’s header image.', + 'single' => true, + 'default' => 0, + 'sanitize_callback' => 'absint', + ) + ); } } diff --git a/includes/class-cli.php b/includes/class-cli.php index 28936400a..4d42693af 100644 --- a/includes/class-cli.php +++ b/includes/class-cli.php @@ -199,4 +199,36 @@ public function reschedule( $args ) { WP_CLI::success( 'Rescheduled activity.' ); } + + /** + * Move the blog to a new URL. + * + * ## OPTIONS + * + *+ +
++ +
+ - + 'string', 'description' => \__( 'Websites allowed to credit you.', 'activitypub' ), 'default' => \Activitypub\home_host(), - 'sanitize_callback' => function ( $value ) { - $value = explode( PHP_EOL, $value ); - $value = array_filter( array_map( 'trim', $value ) ); - $value = array_filter( array_map( 'esc_attr', $value ) ); - $value = implode( PHP_EOL, $value ); - - return $value; - }, + 'sanitize_callback' => array( Sanitize::class, 'host_list' ), ) ); @@ -197,41 +191,7 @@ public static function register_settings() { 'description' => \esc_html__( 'The Identifier of the Blog-User', 'activitypub' ), 'show_in_rest' => true, 'default' => Blog::get_default_username(), - 'sanitize_callback' => function ( $value ) { - // Hack to allow dots in the username. - $parts = explode( '.', $value ); - $sanitized = array(); - - foreach ( $parts as $part ) { - $sanitized[] = \sanitize_title( $part ); - } - - $sanitized = implode( '.', $sanitized ); - - // Check for login or nicename. - $user = new \WP_User_Query( - array( - 'search' => $sanitized, - 'search_columns' => array( 'user_login', 'user_nicename' ), - 'number' => 1, - 'hide_empty' => true, - 'fields' => 'ID', - ) - ); - - if ( $user->results ) { - add_settings_error( - 'activitypub_blog_identifier', - 'activitypub_blog_identifier', - \esc_html__( 'You cannot use an existing author\'s name for the blog profile ID.', 'activitypub' ), - 'error' - ); - - return Blog::get_default_username(); - } - - return $sanitized; - }, + 'sanitize_callback' => array( Sanitize::class, 'blog_identifier' ), ) ); @@ -244,6 +204,17 @@ public static function register_settings() { 'default' => null, ) ); + + \register_setting( + 'activitypub_blog', + 'activitypub_blog_user_also_known_as', + array( + 'type' => 'array', + 'description' => 'An array of URLs that the blog user is known by.', + 'default' => array(), + 'sanitize_callback' => array( Sanitize::class, 'url_list' ), + ) + ); } /** diff --git a/includes/wp-admin/class-user-settings-fields.php b/includes/wp-admin/class-user-settings-fields.php new file mode 100644 index 000000000..4b2640433 --- /dev/null +++ b/includes/wp-admin/class-user-settings-fields.php @@ -0,0 +1,229 @@ + 'activitypub_description' ) + ); + + \add_settings_field( + 'activitypub_header_image', + \esc_html__( 'Header Image', 'activitypub' ), + array( self::class, 'header_image_callback' ), + 'activitypub_user_settings', + 'activitypub_user_profile', + array( 'label_for' => 'activitypub_header_image' ) + ); + + \add_settings_field( + 'activitypub_extra_fields', + \esc_html__( 'Extra Fields', 'activitypub' ), + array( self::class, 'extra_fields_callback' ), + 'activitypub_user_settings', + 'activitypub_user_profile' + ); + + \add_settings_field( + 'activitypub_also_known_as', + \esc_html__( 'Account Aliases', 'activitypub' ), + array( self::class, 'also_known_as_callback' ), + 'activitypub_user_settings', + 'activitypub_user_profile', + array( 'label_for' => 'activitypub_blog_user_also_known_as' ) + ); + } + + /** + * Section description callback. + */ + public static function section_description() { + echo '' . \esc_html__( 'Define what others can see on your public Fediverse profile and next to your posts. With a profile picture and a fully completed profile, you are more likely to gain interactions and followers.', 'activitypub' ) . '
'; + echo '' . \esc_html__( 'The ActivityPub plugin tries to take as much information as possible from your profile settings. However, the following settings are not supported by WordPress or should be adjusted independently of the WordPress settings.', 'activitypub' ) . '
'; + } + + /** + * Profile URL field callback. + */ + public static function profile_url_callback() { + $user = \Activitypub\Collection\Actors::get_by_id( \get_current_user_id() ); + ?> +
+ ' . \esc_html( $user->get_webfinger() ) . '',
+ '' . \esc_url( $user->get_url() ) . '
'
+ );
+ ?>
+
+ get_webfinger() ) + ); + ?> +
+ + + + ++ +
+ + +post_title ); ?> | ++ | + + + + | +
+ +
++ +
+ ID, 'comment_id', true ); - if ( $comment_id ) { - return self::api_comment_status( $comment_id, $post_id ); - } - } - return self::api_post_status( $post_id ); } @@ -372,109 +365,6 @@ private static function api_post_status( $post_id ) { return self::activity_to_status( $data, $account, $post_id ); } - /** - * Traditional WP commenters may leave a URL, which itself may be a valid actor. - * If so, we'll use that actor's data to represent the comment. - * - * @param string $url The URL. - * @return Account|false The account or false. - */ - private static function maybe_get_account_for_actor( $url ) { - if ( empty( $url ) ) { - return false; - } - $uri = Webfinger_Util::resolve( $url ); - if ( $uri && ! is_wp_error( $uri ) ) { - return self::get_account_for_actor( $uri ); - } - // Next, if the URL does not have a path, we'll try to resolve it in the form of domain.com@domain.com. - $parts = \wp_parse_url( $url ); - if ( ( ! isset( $parts['path'] ) || ! $parts['path'] ) && isset( $parts['host'] ) ) { - $url = trailingslashit( $url ) . '@' . $parts['host']; - $acct = Webfinger_Util::uri_to_acct( $url ); - if ( $acct && ! is_wp_error( $acct ) ) { - return self::get_account_for_actor( $acct ); - } - } - - return false; - } - - /** - * Convert an local WP comment into a pseudo-account, after first checking if their - * supplied URL is a valid actor. - * - * @param \WP_Comment $comment The comment. - * @return Account The account. - */ - private static function get_account_for_local_comment( $comment ) { - $maybe_actor = self::maybe_get_account_for_actor( $comment->comment_author_url ); - if ( $maybe_actor ) { - return $maybe_actor; - } - - // We will make a pretend local account for this comment. - $account = new Account(); - $account->id = 999999; // This is a fake ID. - $account->username = $comment->comment_author; - $account->acct = sprintf( 'comments@%s', wp_parse_url( home_url(), PHP_URL_HOST ) ); - $account->display_name = $comment->comment_author; - $account->url = get_comment_link( $comment ); - $account->avatar = get_avatar_url( $comment->comment_author_email ); - $account->avatar_static = $account->avatar; - $account->created_at = new DateTime( $comment->comment_date_gmt ); - $account->last_status_at = new DateTime( $comment->comment_date_gmt ); - $account->note = sprintf( - /* translators: %s: comment author name */ - __( 'This is a local comment by %s, not a fediverse comment. This profile cannot be followed.', 'activitypub' ), - $comment->comment_author - ); - - return $account; - } - - /** - * Convert a WordPress comment to a Status. - * - * @param int $comment_id The comment ID. - * @param int $post_id The post ID (this is the mirrored `comment` post). - * - * @return Status|null The status. - */ - private static function api_comment_status( $comment_id, $post_id ) { - $comment = get_comment( $comment_id ); - $post = get_post( $post_id ); - if ( ! $comment || ! $post ) { - return null; - } - - $is_remote_comment = get_comment_meta( $comment->comment_ID, 'protocol', true ) === 'activitypub'; - - if ( $is_remote_comment ) { - $account = self::get_account_for_actor( $comment->comment_author_url ); - // @todo fallback to locally stored data from the time the comment was made, - // if the remote actor is not found/no longer available. - } else { - $account = self::get_account_for_local_comment( $comment ); - } - - if ( ! $account ) { - return null; - } - - $status = new Status(); - $status->id = $comment->comment_ID; - $status->created_at = new DateTime( $comment->comment_date_gmt ); - $status->content = $comment->comment_content; - $status->account = $account; - $status->visibility = 'public'; - $status->uri = get_comment_link( $comment ); - $status->in_reply_to_id = $post->post_parent; - - return $status; - } - - /** * Get account for actor. * diff --git a/readme.txt b/readme.txt index 8802459a2..a45645032 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: automattic, pfefferle, mattwiebe, obenland, akirk, jeherve, mediaf Tags: OStatus, fediverse, activitypub, activitystream Requires at least: 6.4 Tested up to: 6.7 -Stable tag: 5.3.2 +Stable tag: 5.4.0 Requires PHP: 7.2 License: MIT License URI: http://opensource.org/licenses/MIT @@ -131,9 +131,17 @@ For reasons of data protection, it is not possible to see the followers of other = Unreleased = +* Fixed: Updates to certain user meta fields did not trigger an Update activity. + += 5.4.0 = + +* Added: Upgrade script to fix Follower json representations with unescaped backslashes. +* Added: Centralized place for sanitization functions. * Changed: Bumped minimum required WordPress version to 6.4. * Changed: Use a later hook for Posts to get published to the Outbox, to get sure all `post_meta`s and `taxonomy`s are set stored properly. * Changed: Use webfinger as author email for comments from the Fediverse. +* Changed: Remove the special handling of comments from Enable Mastodon Apps. +* Fixed: Do not redirect `/@username` URLs to the API any more, to improve `AUTHORIZED_FETCH` handling. = 5.3.2 = @@ -219,9 +227,9 @@ See full Changelog on [GitHub](https://github.com/Automattic/wordpress-activityp == Upgrade Notice == -= 1.0.0 = += 5.4.0 = -For version 1.0.0 we have completely rebuilt the followers lists. There is a migration from the old format to the new, but it may take some time until the migration is complete. No data will be lost in the process, please give the migration some time. +Note: This update requires WordPress 6.4+. Please ensure your site meets this requirement before upgrading. == Installation == diff --git a/templates/user-settings.php b/templates/user-settings.php deleted file mode 100644 index 6d444e15b..000000000 --- a/templates/user-settings.php +++ /dev/null @@ -1,140 +0,0 @@ - '' ) ); - -$user = \Activitypub\Collection\Actors::get_by_id( \get_current_user_id() ); ?> - - - - - - -- - | -
-
- get_webfinger() ) ); ?> - |
- |||
---|---|---|---|---|
- - | -- - - | -|||
- - | -
-
-
-
-
-
-
- |
- |||
- - | -
- - - - -
|
-
unescaped backslash 04\2024
', + ) + ); + $unslashed_json = $follower->to_json(); + + $post_id = self::factory()->post->create( + array( + 'post_type' => Followers::POST_TYPE, + 'meta_input' => array( '_activitypub_actor_json' => $unslashed_json ), + ) + ); + + $original_meta = \get_post_meta( $post_id, '_activitypub_actor_json', true ); + $this->assertNull( \json_decode( $original_meta, true ) ); + $this->assertEquals( JSON_ERROR_SYNTAX, \json_last_error() ); + + $result = Migration::update_actor_json_slashing(); + + // No additional batch should be scheduled. + $this->assertNull( $result ); + + $updated_meta = \get_post_meta( $post_id, '_activitypub_actor_json', true ); + + // Verify the updated value can be successfully decoded. + $decoded = \json_decode( $updated_meta, true ); + $this->assertNotNull( $decoded, 'Updated meta should be valid JSON' ); + $this->assertEquals( JSON_ERROR_NONE, \json_last_error() ); + } + /** * Test update_comment_author_emails updates emails with webfinger addresses. * diff --git a/tests/includes/class-test-move.php b/tests/includes/class-test-move.php new file mode 100644 index 000000000..b334ec4cf --- /dev/null +++ b/tests/includes/class-test-move.php @@ -0,0 +1,145 @@ +user->create( array( 'role' => 'author' ) ); + } + + /** + * Clean up after tests. + */ + public static function tear_down_after_class() { + wp_delete_user( self::$user_id ); + } + + /** + * Test the account() method with valid input. + * + * @covers ::account + */ + public function test_account_with_valid_input() { + $from = Actors::get_by_id( self::$user_id )->get_id(); + $to = 'https://newsite.com/user/1'; + + \Activitypub\Move::account( $from, $to ); + + $moved_to = Actors::get_by_id( self::$user_id )->get_moved_to(); + $this->assertEquals( $to, $moved_to ); + + $also_known_as = Actors::get_by_id( self::$user_id )->get_also_known_as(); + $this->assertContains( $from, $also_known_as ); + } + + /** + * Test the account() method with invalid user. + * + * @covers ::account + */ + public function test_account_with_invalid_user() { + $result = \Activitypub\Move::account( + 'https://example.com/nonexistent/user', + 'https://newsite.com/user/999' + ); + + $this->assertWPError( $result ); + $this->assertEquals( 'activitypub_no_user_found', $result->get_error_code() ); + } + + /** + * Test the account() method with invalid target URL. + * + * @covers ::account + */ + public function test_account_with_invalid_target() { + $from = Actors::get_by_id( self::$user_id )->get_id(); + $to = 'https://invalid-url.com/user/1'; + + $filter = function () { + return new \WP_Error( 'http_request_failed', 'Invalid URL' ); + }; + \add_filter( 'pre_http_request', $filter ); + + $result = \Activitypub\Move::account( $from, $to ); + + $this->assertWPError( $result ); + $this->assertEquals( 'http_request_failed', $result->get_error_code() ); + + \remove_filter( 'pre_http_request', $filter ); + } + + /** + * Test the account() method with duplicate moves. + * + * @covers ::account + */ + public function test_account_with_duplicate_moves() { + $from = Actors::get_by_id( self::$user_id )->get_id(); + $to = 'https://newsite.com/user/1'; + + \update_user_option( self::$user_id, 'activitypub_also_known_as', array( 'https://old.example.com/user/1' ) ); + + $filter = function () use ( $from ) { + return array( + 'body' => wp_json_encode( array( 'also_known_as' => array( $from ) ) ), + 'response' => array( 'code' => 200 ), + ); + }; + \add_filter( 'pre_http_request', $filter ); + + \Activitypub\Move::account( $from, $to ); + + $also_known_as = Actors::get_by_id( self::$user_id )->get_also_known_as(); + $this->assertCount( 2, $also_known_as ); + $this->assertContains( $from, $also_known_as ); + $this->assertContains( 'https://old.example.com/user/1', $also_known_as ); + + \remove_filter( 'pre_http_request', $filter ); + } + + /** + * Test the account() method with duplicate moves. + * + * @covers ::account + */ + public function test_account_with_blog_author_as_actor() { + // Change user mode to blog author. + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_BLOG_MODE ); + + $from = Actors::get_by_id( Actors::BLOG_USER_ID )->get_id(); + $to = 'https://newsite.com/user/0'; + + \Activitypub\Move::account( $from, $to ); + + $also_known_as = Actors::get_by_id( Actors::BLOG_USER_ID )->get_also_known_as(); + $this->assertCount( 2, $also_known_as ); + $this->assertContains( $from, $also_known_as ); + + \delete_option( 'activitypub_actor_mode' ); + } +} diff --git a/tests/includes/class-test-query.php b/tests/includes/class-test-query.php index b096b5078..9e3716cb3 100644 --- a/tests/includes/class-test-query.php +++ b/tests/includes/class-test-query.php @@ -265,6 +265,29 @@ public function test_comment_activitypub_object() { $this->assertNull( Query::get_instance()->get_activitypub_object() ); } + /** + * Test user at URL activity object. + * + * @covers ::get_activitypub_object + */ + public function test_user_at_url_activity_object() { + $user_id = self::factory()->user->create( + array( + 'user_login' => 'testuser', + 'role' => 'author', + ) + ); + + Query::get_instance()->__destruct(); + $user = get_user_by( 'id', $user_id ); + $at_url = home_url( '/@' . $user->user_login . '/?activitypub' ); + + $this->go_to( $at_url ); + $this->assertNotNull( Query::get_instance()->get_activitypub_object() ); + + \wp_delete_user( $user_id ); + } + /** * Test user activitypub object. * diff --git a/tests/includes/class-test-sanitize.php b/tests/includes/class-test-sanitize.php new file mode 100644 index 000000000..514cdb5bc --- /dev/null +++ b/tests/includes/class-test-sanitize.php @@ -0,0 +1,175 @@ + array( + array( + 'https://example.com', + 'https://example.com', + 'not-a-url', + 'https://wordpress.org', + ), + array( + 'https://example.com', + 'http://not-a-url', + 'https://wordpress.org', + ), + ), + 'mixed_urls_in_string_whitespace' => array( + "https://example.com\nnot-a-url\nhttps://wordpress.org ", + array( + 'https://example.com', + 'http://not-a-url', + 'https://wordpress.org', + ), + ), + 'special_characters' => array( + array( + 'https://example.com/path with spaces ', + 'https://example.com/über/path', + 'https://example.com/path?param=value¶m2=value2#section', + ), + array( + 'https://example.com/path%20with%20spaces', + 'https://example.com/über/path', + 'https://example.com/path?param=value¶m2=value2#section', + ), + ), + 'empty_array' => array( array(), array() ), + ); + } + + /** + * Test url_list with various inputs. + * + * @dataProvider url_list_provider + * @covers ::url_list + * + * @param mixed $input Input value. + * @param array $expected Expected output. + */ + public function test_url_list( $input, $expected ) { + $this->assertEquals( $expected, Sanitize::url_list( $input ) ); + } + + /** + * Data provider for host list tests. + * + * @return array Test data. + */ + public function host_list_provider() { + return array( + 'single_valid_host' => array( + 'example.com', + 'example.com', + ), + 'multiple_valid_hosts' => array( + "ftp://example.com\nhttp://wordpress.org\nhttps://test.example.com", + "example.com\nwordpress.org\ntest.example.com", + ), + 'mixed_case_hosts' => array( + "ExAmPlE.cOm\nWoRdPrEsS.oRg", + "example.com\nwordpress.org", + ), + 'invalid_hosts' => array( + " not-a-domain\n\nexample.com\n\t@invalid.com", + "not-a-domain\nexample.com\ninvalid.com", + ), + 'empty_string' => array( + '', + '', + ), + ); + } + + /** + * Test host_list with various inputs. + * + * @dataProvider host_list_provider + * @covers ::host_list + * + * @param string $input Input value. + * @param string $expected Expected output. + */ + public function test_host_list( $input, $expected ) { + $this->assertEquals( $expected, Sanitize::host_list( $input ) ); + } + + /** + * Data provider for blog identifier tests. + * + * @return array Test data. + */ + public function blog_identifier_provider() { + return array( + 'simple_string' => array( 'test-Blog', 'test-blog' ), + 'with_spaces' => array( 'test blog', 'test-blog' ), + 'with_dots' => array( 'test.blog', 'test.blog' ), + 'special_chars' => array( 'test@#$%^&*blog', 'testblog' ), + 'multiple_dots' => array( 'test.blog.name', 'test.blog.name' ), + ); + } + + /** + * Test blog_identifier with various inputs. + * + * @dataProvider blog_identifier_provider + * @covers ::blog_identifier + * + * @param string $input Input value. + * @param string $expected Expected output. + */ + public function test_blog_identifier( $input, $expected ) { + $this->assertEquals( $expected, Sanitize::blog_identifier( $input ) ); + } + + /** + * Test blog_identifier with an existing username. + * + * @covers ::blog_identifier + */ + public function test_blog_identifier_with_existing_user() { + $user_id = self::factory()->user->create( + array( + 'user_login' => 'existing-user', + 'user_nicename' => 'test-nicename', + ) + ); + + $result = Sanitize::blog_identifier( 'existing-user' ); + + $this->assertEquals( \Activitypub\Model\Blog::get_default_username(), $result ); + $this->assertNotEmpty( get_settings_errors( 'activitypub_blog_identifier' ) ); + + // Reset. + $GLOBALS['wp_settings_errors'] = array(); + + $result = Sanitize::blog_identifier( 'test-nicename' ); + + $this->assertEquals( \Activitypub\Model\Blog::get_default_username(), $result ); + $this->assertNotEmpty( get_settings_errors( 'activitypub_blog_identifier' ) ); + + \wp_delete_user( $user_id ); + } +} diff --git a/tests/includes/scheduler/class-test-actor.php b/tests/includes/scheduler/class-test-actor.php index 25dec9011..0c4478cc9 100644 --- a/tests/includes/scheduler/class-test-actor.php +++ b/tests/includes/scheduler/class-test-actor.php @@ -46,8 +46,6 @@ public static function set_up_before_class() { */ public function user_meta_provider() { return array( - array( 'activitypub_description' ), - array( 'activitypub_header_image' ), array( 'description' ), array( 'user_url' ), array( 'display_name' ), @@ -71,6 +69,49 @@ public function test_user_meta_update( $meta_key ) { $this->assertSame( $activitpub_id, $id ); } + /** + * Test user option update scheduling. + * + * @covers ::user_meta_update + */ + public function test_user_option_update() { + $actor = Actors::get_by_id( self::$user_id ); + $post = $this->get_latest_outbox_item( $actor->get_id() ); + if ( $post ) { + \wp_delete_post( $post->ID, true ); + } + + $attachment_id = self::factory()->attachment->create_upload_object( dirname( __DIR__, 2 ) . '/assets/test.jpg' ); + + // Update activitypub_description. + $actor->update_summary( 'test summary' ); + + $post = $this->get_latest_outbox_item( $actor->get_id() ); + $id = \get_post_meta( $post->ID, '_activitypub_object_id', true ); + $this->assertSame( $actor->get_id(), $id ); + + \wp_delete_post( $post->ID, true ); + + // Update activitypub_icon. + $actor->update_icon( $attachment_id ); + + $post = $this->get_latest_outbox_item( $actor->get_id() ); + $id = \get_post_meta( $post->ID, '_activitypub_object_id', true ); + $this->assertSame( $actor->get_id(), $id ); + + \wp_delete_post( $post->ID, true ); + + // Update activitypub_header_image. + $actor->update_header( $attachment_id ); + + $post = $this->get_latest_outbox_item( $actor->get_id() ); + $id = \get_post_meta( $post->ID, '_activitypub_object_id', true ); + $this->assertSame( $actor->get_id(), $id ); + + \wp_delete_post( $post->ID, true ); + \wp_delete_attachment( $attachment_id, true ); + } + /** * Test user update scheduling. *