From c46f8a6f463c8c254cc7a65301c1f20425f1d1e8 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 28 Feb 2025 08:37:22 -0600 Subject: [PATCH 01/10] Admin: Move admin-specific callbacks into wp-admin (#1385) * Admin: Move admin-specific callbacks into admin Removes unused Upgrade Notice callback. * Remove deprecated methods outright * Bring back update notice appender * Revert readme changes --- includes/class-activitypub.php | 39 ------------------------------- includes/wp-admin/class-admin.php | 26 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 39 deletions(-) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 010412604..6c58d350d 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -43,12 +43,6 @@ 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' ) ); - } - \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 ); @@ -428,15 +422,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. */ @@ -459,30 +444,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( - '
%s
', - wp_kses( - wpautop( $data['upgrade_notice '] ), - array( - 'p' => array(), - 'a' => array( 'href', 'title' ), - 'strong' => array(), - 'em' => array(), - ) - ) - ); - } - /** * Register Custom Post Types. */ diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 77ddfd942..b23844350 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -55,6 +55,11 @@ public static function init() { \add_filter( 'dashboard_glance_items', array( self::class, 'dashboard_glance_items' ) ); \add_filter( 'plugin_action_links_' . ACTIVITYPUB_PLUGIN_BASENAME, array( self::class, 'add_plugin_settings_link' ) ); + \add_action( 'in_plugin_update_message-' . ACTIVITYPUB_PLUGIN_BASENAME, array( self::class, 'plugin_update_message' ), 10, 2 ); + + if ( site_supports_blocks() ) { + \add_action( 'tool_box', array( self::class, 'tool_box' ) ); + } } /** @@ -595,4 +600,25 @@ public static function add_plugin_settings_link( $actions ) { return $actions; } + + /** + * Display plugin upgrade notice to users. + * + * @param array $data The plugin data. + * @param object $update The plugin update data. + */ + public static function plugin_update_message( $data, $update ) { + if ( ! isset( $update->upgrade_notice ) ) { + return; + } + + echo '
' . wp_strip_all_tags( $update->upgrade_notice ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Adds metabox on wp-admin/tools.php. + */ + public static function tool_box() { + \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/toolbox.php' ); + } } From b3a09d0d4caa4acca28aa9b52213a28659a10ba1 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 28 Feb 2025 09:59:08 -0600 Subject: [PATCH 02/10] Upgrades: Add routine that fixes Follower json (#1383) * Upgrades: Add routine that fixes Follower json * Slashed function calls * Fix merge mishaps. Props @github. --- CHANGELOG.md | 4 +++ includes/class-migration.php | 45 +++++++++++++++++++++++++ readme.txt | 1 + tests/includes/class-test-migration.php | 41 ++++++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 322fb55c4..7af50a343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* Upgrade script to fix Follower json representations with unescaped backslashes. + ### Changed * Bumped minimum required WordPress version to 6.4. diff --git a/includes/class-migration.php b/includes/class-migration.php index 4e859596f..539bb9d76 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -186,6 +186,7 @@ public static function maybe_migrate() { add_action( 'init', 'flush_rewrite_rules', 20 ); } if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_slashing' ) ); \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_comment_author_emails' ) ); } @@ -635,6 +636,50 @@ public static function create_comment_outbox_items( $batch_size = 50, $offset = return null; } + /** + * Update _activitypub_actor_json meta values to ensure they are properly slashed. + * + * @param int $batch_size Optional. Number of meta values to process per batch. Default 100. + * @param int $offset Optional. Number of meta values to skip. Default 0. + * @return array|null Array with batch size and offset if there are more meta values to process, null otherwise. + */ + public static function update_actor_json_slashing( $batch_size = 100, $offset = 0 ) { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $meta_values = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_actor_json' LIMIT %d OFFSET %d", + $batch_size, + $offset + ) + ); + + foreach ( $meta_values as $meta ) { + $json = \json_decode( $meta->meta_value, true ); + + // If json_decode fails, try adding slashes. + if ( null === $json && \json_last_error() !== JSON_ERROR_NONE ) { + $escaped_value = \preg_replace( '#\\\\(?!["\\\\/bfnrtu])#', '\\\\\\\\', $meta->meta_value ); + $json = \json_decode( $escaped_value, true ); + + // Update the meta if json_decode succeeds with slashes. + if ( null !== $json && \json_last_error() === JSON_ERROR_NONE ) { + \update_post_meta( $meta->post_id, '_activitypub_actor_json', \wp_slash( $escaped_value ) ); + } + } + } + + if ( \count( $meta_values ) === $batch_size ) { + return array( + 'batch_size' => $batch_size, + 'offset' => $offset + $batch_size, + ); + } + + return null; + } + /** * Update comment author emails with webfinger addresses for ActivityPub comments. * diff --git a/readme.txt b/readme.txt index 9ddd8520c..7f9e1bb7e 100644 --- a/readme.txt +++ b/readme.txt @@ -131,6 +131,7 @@ For reasons of data protection, it is not possible to see the followers of other = Unreleased = +* Added: Upgrade script to fix Follower json representations with unescaped backslashes. * 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. diff --git a/tests/includes/class-test-migration.php b/tests/includes/class-test-migration.php index a5c979c7c..c51041f0b 100644 --- a/tests/includes/class-test-migration.php +++ b/tests/includes/class-test-migration.php @@ -7,9 +7,11 @@ namespace Activitypub\Tests; +use Activitypub\Collection\Followers; use Activitypub\Collection\Outbox; use Activitypub\Migration; use Activitypub\Comment; +use Activitypub\Model\Follower; /** * Test class for Activitypub Migrate. @@ -598,6 +600,45 @@ public function test_create_comment_outbox_items_batching() { $this->assertNull( $result ); } + /** + * Test update_actor_json_slashing updates unslashed meta values. + * + * @covers ::update_actor_json_slashing + */ + public function test_update_actor_json_slashing() { + $follower = new Follower(); + $follower->from_array( + array( + 'type' => 'Person', + 'summary' => '

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. * From 339ab1d9be3054e67bc3f69a3484d411b7d800c2 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 3 Mar 2025 07:53:38 -0600 Subject: [PATCH 03/10] Readme: Add upgrade notice (#1399) --- readme.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.txt b/readme.txt index 7f9e1bb7e..81cdde857 100644 --- a/readme.txt +++ b/readme.txt @@ -219,9 +219,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 == From 7f4cbdc2b7f6fb8b3bf0ca9385dcf6c9ea060c9d Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 3 Mar 2025 07:56:18 -0600 Subject: [PATCH 04/10] Combine sanitization functions (#1397) * Combine sanitization functions * Add host_list sanitization --- CHANGELOG.md | 1 + includes/class-sanitize.php | 98 ++++++++++++++ includes/wp-admin/class-settings.php | 46 +------ readme.txt | 1 + tests/includes/class-test-sanitize.php | 175 +++++++++++++++++++++++++ 5 files changed, 278 insertions(+), 43 deletions(-) create mode 100644 includes/class-sanitize.php create mode 100644 tests/includes/class-test-sanitize.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af50a343..1c81d1e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Upgrade script to fix Follower json representations with unescaped backslashes. +* Centralized place for sanitization functions. ### Changed diff --git a/includes/class-sanitize.php b/includes/class-sanitize.php new file mode 100644 index 000000000..c669490ee --- /dev/null +++ b/includes/class-sanitize.php @@ -0,0 +1,98 @@ + $sanitized, + 'search_columns' => array( 'user_login', 'user_nicename' ), + 'number' => 1, + 'hide_empty' => true, + 'fields' => 'ID', + ) + ); + + if ( $user->get_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' ) + ); + + return Blog::get_default_username(); + } + + return $sanitized; + } +} diff --git a/includes/wp-admin/class-settings.php b/includes/wp-admin/class-settings.php index 41be113a6..42805140c 100644 --- a/includes/wp-admin/class-settings.php +++ b/includes/wp-admin/class-settings.php @@ -9,6 +9,7 @@ use Activitypub\Collection\Actors; use Activitypub\Model\Blog; +use Activitypub\Sanitize; use function Activitypub\is_user_disabled; /** @@ -136,14 +137,7 @@ public static function register_settings() { 'type' => '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' ), ) ); diff --git a/readme.txt b/readme.txt index 81cdde857..fed549800 100644 --- a/readme.txt +++ b/readme.txt @@ -132,6 +132,7 @@ For reasons of data protection, it is not possible to see the followers of other = Unreleased = * 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. 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 ); + } +} From b32dd859fc307cca445afb94bdbdac46fa7f8809 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 3 Mar 2025 15:32:59 +0100 Subject: [PATCH 05/10] Improve `/@username` URLs (#1390) --- CHANGELOG.md | 4 ++ includes/class-activitypub.php | 60 +++++++++++++++-------- includes/class-migration.php | 4 +- includes/class-query.php | 4 +- includes/rest/class-actors-controller.php | 11 +---- readme.txt | 1 + tests/includes/class-test-query.php | 23 +++++++++ 7 files changed, 71 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c81d1e15..0e8b4cd46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * 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. +### Fixed + +* Do not redirect `/@username` URLs to the API any more, to improve `AUTHORIZED_FETCH` handling. + ## [5.3.2] - 2025-02-27 ### Fixed diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 6c58d350d..1af393613 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; @@ -248,29 +249,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; + } } /** @@ -284,6 +306,7 @@ public static function add_query_vars( $vars ) { $vars[] = 'activitypub'; $vars[] = 'preview'; $vars[] = 'author'; + $vars[] = 'actor'; $vars[] = 'c'; $vars[] = 'p'; @@ -405,12 +428,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 ); } diff --git a/includes/class-migration.php b/includes/class-migration.php index 539bb9d76..9c3207fec 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -182,12 +182,10 @@ public static function maybe_migrate() { if ( \version_compare( $version_from_db, '5.2.0', '<' ) ) { Scheduler::register_schedules(); } - if ( \version_compare( $version_from_db, '5.3.0', '<' ) ) { - add_action( 'init', 'flush_rewrite_rules', 20 ); - } if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_slashing' ) ); \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_comment_author_emails' ) ); + \add_action( 'init', 'flush_rewrite_rules', 20 ); } /* diff --git a/includes/class-query.php b/includes/class-query.php index 7d66a048c..d65587348 100644 --- a/includes/class-query.php +++ b/includes/class-query.php @@ -205,10 +205,10 @@ protected function maybe_get_virtual_object() { $author_id = url_to_authorid( $url ); if ( ! is_numeric( $author_id ) ) { - return null; + $author_id = $url; } - $user = Actors::get_by_id( $author_id ); + $user = Actors::get_by_various( $author_id ); if ( \is_wp_error( $user ) || ! $user ) { return null; diff --git a/includes/rest/class-actors-controller.php b/includes/rest/class-actors-controller.php index 230b709fe..15cd0c5c0 100644 --- a/includes/rest/class-actors-controller.php +++ b/includes/rest/class-actors-controller.php @@ -101,15 +101,6 @@ public function get_item( $request ) { return $user; } - $link_header = \sprintf( '<%1$s>; rel="alternate"; type="application/activity+json"', $user->get_id() ); - - // Redirect to canonical URL if it is not an ActivityPub request. - if ( ! is_activitypub_request() ) { - \header( 'Link: ' . $link_header ); - \header( 'Location: ' . $user->get_canonical_url(), true, 301 ); - exit; - } - /** * Action triggered prior to the ActivityPub profile being created and sent to the client. */ @@ -119,7 +110,7 @@ public function get_item( $request ) { $response = \rest_ensure_response( $data ); $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); - $response->header( 'Link', $link_header ); + $response->header( 'Link', \sprintf( '<%1$s>; rel="alternate"; type="application/activity+json"', $user->get_id() ) ); return $response; } diff --git a/readme.txt b/readme.txt index fed549800..2f3c9bc59 100644 --- a/readme.txt +++ b/readme.txt @@ -136,6 +136,7 @@ For reasons of data protection, it is not possible to see the followers of other * 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. +* Fixed: Do not redirect `/@username` URLs to the API any more, to improve `AUTHORIZED_FETCH` handling. = 5.3.2 = 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. * From 4692cf5dad25aa553fb9579dcaf141ff04771dd7 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 3 Mar 2025 10:31:26 -0600 Subject: [PATCH 06/10] Release 5.4.0 (#1403) --- CHANGELOG.md | 5 +++-- activitypub.php | 4 ++-- includes/class-migration.php | 2 +- readme.txt | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8b4cd46..3ce971635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [5.4.0] - 2025-03-03 ### Added @@ -1361,8 +1361,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 23e4dad44..34ea205bb 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__ ) ); diff --git a/includes/class-migration.php b/includes/class-migration.php index 9c3207fec..a7e1b6536 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -182,7 +182,7 @@ public static function maybe_migrate() { if ( \version_compare( $version_from_db, '5.2.0', '<' ) ) { Scheduler::register_schedules(); } - if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + if ( \version_compare( $version_from_db, '5.4.0', '<' ) ) { \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_slashing' ) ); \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_comment_author_emails' ) ); \add_action( 'init', 'flush_rewrite_rules', 20 ); diff --git a/readme.txt b/readme.txt index 2f3c9bc59..775be8379 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 @@ -129,7 +129,7 @@ For reasons of data protection, it is not possible to see the followers of other == Changelog == -= Unreleased = += 5.4.0 = * Added: Upgrade script to fix Follower json representations with unescaped backslashes. * Added: Centralized place for sanitization functions. From 41b189412d9ab276168aa975f05a449f6bfade1f Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Mon, 3 Mar 2025 18:23:40 +0100 Subject: [PATCH 07/10] EMA: Remove the special handling of comments (#1401) * EMA: Remove the special handling of comments * EMA: Link the mentioned username * Remove unused methods * Fix conflict * Update integration/class-enable-mastodon-apps.php Co-authored-by: Konstantin Obenland * Use the right class --------- Co-authored-by: Konstantin Obenland Co-authored-by: Matthias Pfefferle --- CHANGELOG.md | 1 + integration/class-enable-mastodon-apps.php | 114 +-------------------- readme.txt | 1 + 3 files changed, 4 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce971635..0cf2c1e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * 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 diff --git a/integration/class-enable-mastodon-apps.php b/integration/class-enable-mastodon-apps.php index 8ba9d7c3e..c165f7b69 100644 --- a/integration/class-enable-mastodon-apps.php +++ b/integration/class-enable-mastodon-apps.php @@ -10,11 +10,11 @@ use DateTime; use Activitypub\Webfinger as Webfinger_Util; use Activitypub\Http; +use Activitypub\Mention; use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; use Activitypub\Collection\Extra_Fields; use Activitypub\Transformer\Factory; -use Enable_Mastodon_Apps\Mastodon_API; use Enable_Mastodon_Apps\Entity\Account; use Enable_Mastodon_Apps\Entity\Status; use Enable_Mastodon_Apps\Entity\Media_Attachment; @@ -44,6 +44,7 @@ public static function init() { \add_filter( 'mastodon_api_statuses', array( self::class, 'api_statuses_external' ), 10, 2 ); \add_filter( 'mastodon_api_status_context', array( self::class, 'api_get_replies' ), 10, 3 ); \add_action( 'mastodon_api_update_credentials', array( self::class, 'api_update_credentials' ), 10, 2 ); + \add_action( 'mastodon_api_submit_status_text', array( Mention::class, 'the_content' ), 10, 2 ); } /** @@ -340,14 +341,6 @@ public static function api_status( $status, $post_id ) { return $status; } - // EMA makes a `comment` post_type to mirror comments and so that there can be a single get_posts() call for everything. - if ( get_post_type( $post ) === 'comment' ) { - $comment_id = get_post_meta( $post->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 775be8379..f99f7c6b5 100644 --- a/readme.txt +++ b/readme.txt @@ -136,6 +136,7 @@ For reasons of data protection, it is not possible to see the followers of other * 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 = From f21185613fbcad9cabec1821f3d4cf4d90b8bd06 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 3 Mar 2025 11:38:20 -0600 Subject: [PATCH 08/10] Outbox: Account for user option prefix and added meta data (#1402) * Actor: Add failing tests * Make tests pass. * Use Actor API to trigger updates * Add activitypub_icon to allow list. * Order alphabetically * Only delete post if there is one. * Add changelog * Keep attachment local. --------- Co-authored-by: Matthias Pfefferle --- CHANGELOG.md | 6 +++ includes/scheduler/class-actor.php | 10 +++-- readme.txt | 4 ++ tests/includes/scheduler/class-test-actor.php | 45 ++++++++++++++++++- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf2c1e92..5ed13c464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +* Updates to certain user meta fields did not trigger an Update activity. + ## [5.4.0] - 2025-03-03 ### Added diff --git a/includes/scheduler/class-actor.php b/includes/scheduler/class-actor.php index 3f4d99a58..e7a49f424 100644 --- a/includes/scheduler/class-actor.php +++ b/includes/scheduler/class-actor.php @@ -39,6 +39,7 @@ public static function init() { // Profile updates for user options. if ( ! is_user_type_disabled( 'user' ) ) { \add_action( 'profile_update', array( self::class, 'user_update' ) ); + \add_action( 'added_user_meta', array( self::class, 'user_meta_update' ), 10, 3 ); \add_action( 'updated_user_meta', array( self::class, 'user_meta_update' ), 10, 3 ); // @todo figure out a feasible way of updating the header image since it's not unique to any user. } @@ -62,13 +63,16 @@ public static function user_meta_update( $meta_id, $user_id, $meta_key ) { return; } + $blog_prefix = $GLOBALS['wpdb']->get_blog_prefix(); + // The user meta fields that affect a profile. $fields = array( - 'activitypub_description', - 'activitypub_header_image', + $blog_prefix . 'activitypub_description', + $blog_prefix . 'activitypub_header_image', + $blog_prefix . 'activitypub_icon', 'description', - 'user_url', 'display_name', + 'user_url', ); if ( in_array( $meta_key, $fields, true ) ) { diff --git a/readme.txt b/readme.txt index f99f7c6b5..1fa9da937 100644 --- a/readme.txt +++ b/readme.txt @@ -129,6 +129,10 @@ For reasons of data protection, it is not possible to see the followers of other == Changelog == += 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. 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. * From dd73fb02b7ca209bea1a67df43a5a1035b1a2560 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 3 Mar 2025 18:50:46 +0100 Subject: [PATCH 09/10] Add "Move Account" functionality (#685) * init * simplify code to provide a barebone functionality and improve from there * re-add namespaces * fix PHPCS * Move: Create good starting point * Removes duplicate actor properties. * Removes unused `Move` methods. * Adds unit tests for `extend_actor_profiles` * Move: General method to move accounts (#1362) * Move: General method to move accounts First pass * Check for wp error * Add option to directly store full activity instead of only the activity-object * Change to use Activity-Object instead of Activity * Lint fixes * Filter outbox activity in type handlers * use object_to_uri to check if it is an object first * Update method return type * Add unit tests * use "use" --------- Co-authored-by: Matthias Pfefferle * Move: Add support for Blog-User (#1389) * Move: Add support for Blog-User * add missing extensions to the user * Add CLI command * Update includes/class-cli.php Co-authored-by: Konstantin Obenland * Remove used options --------- Co-authored-by: Konstantin Obenland * Move: Add/Remove/Modify "Account Aliases" (#1396) * Add the ability to set Account Aliases * Fix phpcs issues * add user settings * fix PHPCS * Fix phpcs * Some changes props @obenland * simplify code * Get blog option from options * remove duplicates * First pass at registered user meta * Add general sanitization class * Move description below textarea There's no margin between description and textarea otherwise. * Same for blog user --------- Co-authored-by: Konstantin Obenland --------- Co-authored-by: Konstantin Obenland --- includes/activity/class-actor.php | 8 +- includes/class-activitypub.php | 20 +++ includes/class-cli.php | 32 ++++ includes/class-move.php | 91 +++++++++++ includes/collection/class-outbox.php | 21 +-- includes/handler/class-delete.php | 38 ++--- includes/handler/class-like.php | 22 ++- includes/handler/class-move.php | 17 ++ includes/model/class-blog.php | 18 +++ includes/model/class-user.php | 18 +++ includes/wp-admin/class-admin.php | 7 + .../wp-admin/class-blog-settings-fields.php | 31 ++++ includes/wp-admin/class-settings-fields.php | 8 +- includes/wp-admin/class-settings.php | 11 ++ templates/user-settings.php | 20 +++ tests/includes/class-test-move.php | 145 ++++++++++++++++++ 16 files changed, 468 insertions(+), 39 deletions(-) create mode 100644 includes/class-move.php create mode 100644 tests/includes/class-test-move.php 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 1af393613..4e978ea9c 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -48,6 +48,7 @@ public static function init() { \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(); @@ -89,6 +90,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' ); @@ -717,4 +720,21 @@ public static function updated_postmeta( $meta_id, $object_id, $meta_key, $meta_ \delete_post_meta( $object_id, 'activitypub_content_visibility' ); } } + + /** + * Register user meta. + */ + public static function register_user_meta() { + \register_meta( + 'user', + 'activitypub_also_known_as', + array( + 'type' => 'array', + 'description' => 'An array of URLs that the user is known by.', + 'single' => false, + 'default' => array(), + 'sanitize_callback' => array( Sanitize::class, 'url_list' ), + ) + ); + } } 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 + * + * + * The current URL of the blog. + * + * + * The new URL of the blog. + * + * ## EXAMPLES + * + * $ wp activitypub move https://example.com/ https://newsite.com/ + * + * @synopsis + * + * @param array $args The arguments. + */ + public function move( $args ) { + $from = $args[0]; + $to = $args[1]; + + $outbox_item_id = Move::account( $from, $to ); + + if ( is_wp_error( $outbox_item_id ) ) { + WP_CLI::error( $outbox_item_id->get_error_message() ); + } else { + WP_CLI::success( 'Moved Scheduled.' ); + } + } } diff --git a/includes/class-move.php b/includes/class-move.php new file mode 100644 index 000000000..1f1d806da --- /dev/null +++ b/includes/class-move.php @@ -0,0 +1,91 @@ +get__id() > 0 ) { + \update_user_option( $user->get__id(), 'activitypub_move_to', $to ); + } else { + \update_option( 'activitypub_blog_user_moved_to', $to ); + } + + // Add the old account URL to alsoKnownAs. + if ( $user->get__id() > 0 ) { + self::update_user_also_known_as( $user->get__id(), $from ); + } else { + self::update_blog_also_known_as( $from ); + } + + $response = Http::get_remote_object( $to ); + + if ( \is_wp_error( $response ) ) { + return $response; + } + + $actor = new Actor(); + $actor->from_array( $response ); + + // Check if the `Move` Activity is valid. + $also_known_as = $actor->get_also_known_as(); + if ( ! in_array( $from, $also_known_as, true ) ) { + return new \WP_Error( 'invalid_target', __( 'Invalid target', 'activitypub' ) ); + } + + // Add to outbox. + return add_to_outbox( $actor, 'Move', $user->get__id(), ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ); + } + + /** + * Update the alsoKnownAs property of a user. + * + * @param int $user_id The user ID. + * @param string $from The current account URL. + */ + private static function update_user_also_known_as( $user_id, $from ) { + $also_known_as = (array) \get_user_option( 'activitypub_also_known_as', $user_id ); + $also_known_as[] = $from; + + \update_user_option( $user_id, 'activitypub_also_known_as', $also_known_as ); + } + + /** + * Update the alsoKnownAs property of the blog. + * + * @param string $from The current account URL. + */ + private static function update_blog_also_known_as( $from ) { + $also_known_as = (array) \get_option( 'activitypub_blog_user_also_known_as' ); + $also_known_as[] = $from; + + \update_option( 'activitypub_blog_user_also_known_as', $also_known_as ); + } +} diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index c89a55619..b6fb855a1 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -32,7 +32,7 @@ class Outbox { * * @return false|int|\WP_Error The added item or an error. */ - public static function add( $activity_object, $activity_type, $user_id, $content_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { // phpcs:ignore + public static function add( $activity_object, $activity_type, $user_id, $content_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { switch ( $user_id ) { case Actors::APPLICATION_USER_ID: $actor_type = 'application'; @@ -209,20 +209,21 @@ public static function get_activity( $outbox_item ) { return $actor; } - $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ); $activity = new Activity(); + $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ); $activity->set_type( $type ); $activity->set_id( $outbox_item->guid ); + $activity->set_actor( $actor->get_id() ); // Pre-fill the Activity with data (for example cc and to). $activity->set_object( \json_decode( $outbox_item->post_content, true ) ); - $activity->set_actor( $actor->get_id() ); - - // Use simple Object (only ID-URI) for Like and Announce. - if ( in_array( $type, array( 'Like', 'Delete' ), true ) ) { - $activity->set_object( $activity->get_object()->get_id() ); - } - return $activity; + /** + * Filters the Activity object before it is returned. + * + * @param Activity $activity The Activity object. + * @param \WP_Post $outbox_item The outbox item post object. + */ + return apply_filters( 'activitypub_get_outbox_activity', $activity, $outbox_item ); } /** @@ -259,7 +260,7 @@ public static function get_actor( $outbox_item ) { * @return Activity|\WP_Error The Activity object or WP_Error. */ public static function maybe_get_activity( $outbox_item ) { - if ( ! $outbox_item || ! $outbox_item instanceof \WP_Post ) { + if ( ! $outbox_item instanceof \WP_Post ) { return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' ); } diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 36f7c7851..37163a1f1 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -12,6 +12,8 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Interactions; +use function Activitypub\object_to_uri; + /** * Handles Delete requests. */ @@ -20,24 +22,10 @@ class Delete { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( - 'activitypub_inbox_delete', - array( self::class, 'handle_delete' ) - ); - - // Defer signature verification for `Delete` requests. - \add_filter( - 'activitypub_defer_signature_verification', - array( self::class, 'defer_signature_verification' ), - 10, - 2 - ); - - // Side effect. - \add_action( - 'activitypub_delete_actor_interactions', - array( self::class, 'delete_interactions' ) - ); + \add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ) ); + \add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 ); + \add_action( 'activitypub_delete_actor_interactions', array( self::class, 'delete_interactions' ) ); + \add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) ); } /** @@ -193,4 +181,18 @@ public static function defer_signature_verification( $defer, $request ) { return false; } + + /** + * Set the object to the object ID. + * + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @return \Activitypub\Activity\Activity The filtered Activity object. + */ + public static function outbox_activity( $activity ) { + if ( 'Delete' === $activity->get_type() ) { + $activity->set_object( object_to_uri( $activity->get_object() ) ); + } + + return $activity; + } } diff --git a/includes/handler/class-like.php b/includes/handler/class-like.php index 1e9515763..82676c24b 100644 --- a/includes/handler/class-like.php +++ b/includes/handler/class-like.php @@ -20,12 +20,8 @@ class Like { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( - 'activitypub_inbox_like', - array( self::class, 'handle_like' ), - 10, - 3 - ); + \add_action( 'activitypub_inbox_like', array( self::class, 'handle_like' ), 10, 3 ); + \add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) ); } /** @@ -67,4 +63,18 @@ public static function handle_like( $like, $user_id ) { */ do_action( 'activitypub_handled_like', $like, $user_id, $state, $reaction ); } + + /** + * Set the object to the object ID. + * + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @return \Activitypub\Activity\Activity The filtered Activity object. + */ + public static function outbox_activity( $activity ) { + if ( 'Like' === $activity->get_type() ) { + $activity->set_object( object_to_uri( $activity->get_object() ) ); + } + + return $activity; + } } diff --git a/includes/handler/class-move.php b/includes/handler/class-move.php index 8bbb81ddb..71032a457 100644 --- a/includes/handler/class-move.php +++ b/includes/handler/class-move.php @@ -25,6 +25,7 @@ class Move { */ public static function init() { \add_action( 'activitypub_inbox_move', array( self::class, 'handle_move' ) ); + \add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) ); } /** @@ -101,6 +102,22 @@ public static function handle_move( $activity ) { } } + /** + * Convert the object and origin to the correct format. + * + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @return \Activitypub\Activity\Activity The filtered Activity object. + */ + public static function outbox_activity( $activity ) { + if ( 'Move' === $activity->get_type() ) { + $activity->set_object( object_to_uri( $activity->get_object() ) ); + $activity->set_origin( $activity->get_actor() ); + $activity->set_target( $activity->get_object() ); + } + + return $activity; + } + /** * Extract the target from the activity. * diff --git a/includes/model/class-blog.php b/includes/model/class-blog.php index e94c32806..acec8a061 100644 --- a/includes/model/class-blog.php +++ b/includes/model/class-blog.php @@ -552,4 +552,22 @@ public function get_attachment() { public function get_attribution_domains() { return get_attribution_domains(); } + + /** + * Returns the alsoKnownAs. + * + * @return array The alsoKnownAs. + */ + public function get_also_known_as() { + return \get_option( 'activitypub_blog_user_also_known_as' ); + } + + /** + * Returns the movedTo. + * + * @return string The movedTo. + */ + public function get_moved_to() { + return \get_option( 'activitypub_blog_user_moved_to' ); + } } diff --git a/includes/model/class-user.php b/includes/model/class-user.php index 581e496c7..e48a826fc 100644 --- a/includes/model/class-user.php +++ b/includes/model/class-user.php @@ -421,4 +421,22 @@ public function update_header( $value ) { public function get_attribution_domains() { return get_attribution_domains(); } + + /** + * Returns the alsoKnownAs. + * + * @return array The alsoKnownAs. + */ + public function get_also_known_as() { + return \get_user_option( 'activitypub_also_known_as', $this->_id ); + } + + /** + * Returns the movedTo. + * + * @return string The movedTo. + */ + public function get_moved_to() { + return \get_user_option( 'activitypub_move_to', $this->_id ); + } } diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index b23844350..2763e2166 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -177,6 +177,13 @@ public static function save_user_settings( $user_id ) { } else { \delete_user_option( $user_id, 'activitypub_header_image' ); } + + $also_known_as = ! empty( $_POST['activitypub_blog_user_also_known_as'] ) ? \sanitize_textarea_field( wp_unslash( $_POST['activitypub_blog_user_also_known_as'] ) ) : false; + if ( $also_known_as ) { + \update_user_option( $user_id, 'activitypub_also_known_as', $also_known_as ); + } else { + \delete_user_option( $user_id, 'activitypub_also_known_as' ); + } } /** diff --git a/includes/wp-admin/class-blog-settings-fields.php b/includes/wp-admin/class-blog-settings-fields.php index 00c20eb36..573dcc145 100644 --- a/includes/wp-admin/class-blog-settings-fields.php +++ b/includes/wp-admin/class-blog-settings-fields.php @@ -70,6 +70,14 @@ public static function register_settings() { 'activitypub_blog_settings', 'activitypub_blog_profile' ); + + add_settings_field( + 'activitypub_blog_user_also_known_as', + __( 'Account Aliases', 'activitypub' ), + array( self::class, 'also_known_as_callback' ), + 'activitypub_blog_settings', + 'activitypub_blog_profile' + ); } /** @@ -224,4 +232,27 @@ public static function extra_fields_callback() {

+ +

+ +

+

+ +

+ - +

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/templates/user-settings.php b/templates/user-settings.php index 6d444e15b..b25384f6e 100644 --- a/templates/user-settings.php +++ b/templates/user-settings.php @@ -134,6 +134,26 @@ class=""

+ + + + + + + +

+ +

+

+ +

+ + 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' ); + } +} From f61964c06c6b257d0495fdd307bec2b89ee99746 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 3 Mar 2025 12:33:34 -0600 Subject: [PATCH 10/10] Profile Settings: Use Settings API (#1398) * init * Move: General method to move accounts (#1362) * Move: General method to move accounts First pass * Check for wp error * Add option to directly store full activity instead of only the activity-object * Change to use Activity-Object instead of Activity * Lint fixes * Filter outbox activity in type handlers * use object_to_uri to check if it is an object first * Update method return type * Add unit tests * use "use" --------- Co-authored-by: Matthias Pfefferle * Move: Add support for Blog-User (#1389) * Move: Add support for Blog-User * add missing extensions to the user * Add CLI command * Update includes/class-cli.php Co-authored-by: Konstantin Obenland * Remove used options --------- Co-authored-by: Konstantin Obenland * Profile Settings: Use Settings API * Remove unused parameter. * fix use of options instead of meta and added missing registers * Add blog-prefix because we use user-options, not user-meta * Fix phpcs issue * Add activitypub_icon meta * be more restrictive * use `user_description` instead * Use wp_kses to account for second arg * Holding my breath * fix rebase issues --------- Co-authored-by: Matthias Pfefferle --- activitypub.php | 1 + includes/class-activitypub.php | 44 +++- includes/wp-admin/class-admin.php | 17 +- .../wp-admin/class-user-settings-fields.php | 229 ++++++++++++++++++ templates/user-settings.php | 160 ------------ 5 files changed, 276 insertions(+), 175 deletions(-) create mode 100644 includes/wp-admin/class-user-settings-fields.php delete mode 100644 templates/user-settings.php diff --git a/activitypub.php b/activitypub.php index 34ea205bb..1e73b7142 100644 --- a/activitypub.php +++ b/activitypub.php @@ -83,6 +83,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/class-activitypub.php b/includes/class-activitypub.php index 4e978ea9c..0870d7613 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -725,16 +725,56 @@ public static function updated_postmeta( $meta_id, $object_id, $meta_key, $meta_ * Register user meta. */ public static function register_user_meta() { + $blog_prefix = $GLOBALS['wpdb']->get_blog_prefix(); + \register_meta( 'user', - 'activitypub_also_known_as', + $blog_prefix . 'activitypub_also_known_as', array( 'type' => 'array', 'description' => 'An array of URLs that the user is known by.', - 'single' => false, + '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/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 2763e2166..38207095f 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -125,23 +125,14 @@ public static function add_followers_list_help_tab() { } /** - * Add the profile. - * - * @param \WP_User $user The user object. + * Render user settings. */ - public static function add_profile( $user ) { - $description = \get_user_option( 'activitypub_description', $user->ID ); - + public static function add_profile() { wp_enqueue_media(); wp_enqueue_script( 'activitypub-header-image' ); - \load_template( - ACTIVITYPUB_PLUGIN_DIR . 'templates/user-settings.php', - true, - array( - 'description' => $description, - ) - ); + wp_nonce_field( 'activitypub-user-settings', '_apnonce' ); + do_settings_sections( 'activitypub_user_settings' ); } /** 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() ) + ); + ?> +

+ + +

+ +
+ +
+ + + + +

+ +

+ + + + + + + + + + + + + +

+ + + + + + +

+ + +

+ +

+

+ +

+ '' ) ); - -$user = \Activitypub\Collection\Actors::get_by_id( \get_current_user_id() ); ?> -

- -

- -

- - - - - - - - - - - - - - - - - - - - - - - - -
- - -

- get_webfinger() ); ?> or - get_url() ); ?> -

- -

get_webfinger() ) ); ?>

-
- - - -

-
- - - -
- -
- - - -
- - -

- -

- - - - - - - - - - - -

- - - - - - -

-
- - - - -

- -

-

- -

-
- -