diff --git a/.github/changelog/2311-from-description b/.github/changelog/2311-from-description new file mode 100644 index 000000000..d71311093 --- /dev/null +++ b/.github/changelog/2311-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added a new ap_object post type and taxonomies for storing and managing incoming ActivityPub objects, with updated handlers diff --git a/includes/class-post-types.php b/includes/class-post-types.php index b9994ec12..f13efd55c 100644 --- a/includes/class-post-types.php +++ b/includes/class-post-types.php @@ -12,6 +12,7 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Inbox; use Activitypub\Collection\Outbox; +use Activitypub\Collection\Posts; use Activitypub\Collection\Remote_Actors; /** @@ -25,6 +26,7 @@ public static function init() { \add_action( 'init', array( self::class, 'register_remote_actors_post_type' ), 11 ); \add_action( 'init', array( self::class, 'register_inbox_post_type' ), 11 ); \add_action( 'init', array( self::class, 'register_outbox_post_type' ), 11 ); + \add_action( 'init', array( self::class, 'register_post_post_type' ), 11 ); \add_action( 'init', array( self::class, 'register_extra_fields_post_types' ), 11 ); \add_action( 'init', array( self::class, 'register_activitypub_post_meta' ), 11 ); @@ -345,6 +347,65 @@ public static function register_outbox_post_type() { ); } + /** + * Register the Object post type. + */ + public static function register_post_post_type() { + \register_post_type( + Posts::POST_TYPE, + array( + 'labels' => array( + 'name' => \_x( 'Posts', 'post_type plural name', 'activitypub' ), + 'singular_name' => \_x( 'Post', 'post_type single name', 'activitypub' ), + ), + 'capabilities' => array( + 'create_posts' => false, + ), + 'map_meta_cap' => true, + 'public' => false, + 'show_in_rest' => true, + 'rewrite' => false, + 'query_var' => false, + 'supports' => array( 'title', 'editor', 'author', 'custom-fields', 'excerpt', 'comments' ), + 'delete_with_user' => true, + 'can_export' => true, + 'exclude_from_search' => true, + 'taxonomies' => array( 'ap_tag', 'ap_object_type' ), + ) + ); + + \register_taxonomy( + 'ap_tag', + array( Posts::POST_TYPE ), + array( + 'public' => false, + 'query_var' => true, + 'show_in_rest' => true, + ) + ); + + \register_taxonomy( + 'ap_object_type', + array( Posts::POST_TYPE ), + array( + 'public' => false, + 'query_var' => true, + 'show_in_rest' => true, + ) + ); + + \register_post_meta( + Posts::POST_TYPE, + '_activitypub_remote_actor_id', + array( + 'type' => 'integer', + 'single' => true, + 'description' => 'The local ID of the remote actor that created the object.', + 'sanitize_callback' => 'absint', + ) + ); + } + /** * Register the Extra Fields post types. */ diff --git a/includes/class-sanitize.php b/includes/class-sanitize.php index 1f006b66e..5c6569e44 100644 --- a/includes/class-sanitize.php +++ b/includes/class-sanitize.php @@ -182,4 +182,19 @@ public static function webfinger( $value ) { return $value; } + + /** + * Sanitize content for ActivityPub. + * + * @param string $content The content to convert. + * + * @return string The converted content. + */ + public static function content( $content ) { + $content = \make_clickable( $content ); + $content = \wpautop( $content ); + $content = \wp_kses_post( $content ); + + return $content; + } } diff --git a/includes/collection/class-inbox.php b/includes/collection/class-inbox.php index fadf9a53d..4242f4ee2 100644 --- a/includes/collection/class-inbox.php +++ b/includes/collection/class-inbox.php @@ -20,6 +20,11 @@ * @link https://www.w3.org/TR/activitypub/#inbox */ class Inbox { + /** + * The post type for the objects. + * + * @var string + */ const POST_TYPE = 'ap_inbox'; /** diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 9e3011891..83fa00b4f 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -20,6 +20,11 @@ * @link https://www.w3.org/TR/activitypub/#outbox */ class Outbox { + /** + * The post type for the objects. + * + * @var string + */ const POST_TYPE = 'ap_outbox'; /** diff --git a/includes/collection/class-posts.php b/includes/collection/class-posts.php new file mode 100644 index 000000000..1e2a63076 --- /dev/null +++ b/includes/collection/class-posts.php @@ -0,0 +1,167 @@ +ID ); + + self::add_taxonomies( $post_id, $activity_object ); + + return \get_post( $post_id ); + } + + /** + * Get an object from the collection. + * + * @param int $id The object ID. + * + * @return \WP_Post|null The post object or null on failure. + */ + public static function get( $id ) { + return \get_post( $id ); + } + + /** + * Get an object by its GUID. + * + * @param string $guid The object GUID. + * + * @return \WP_Post|\WP_Error The object post or WP_Error on failure. + */ + public static function get_by_guid( $guid ) { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $post_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s", + \esc_url( $guid ), + self::POST_TYPE + ) + ); + + if ( ! $post_id ) { + return new \WP_Error( + 'activitypub_object_not_found', + \__( 'Object not found', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + return \get_post( $post_id ); + } + + /** + * Update an object in the collection. + * + * @param array $activity The activity object data. + * + * @return \WP_Post|\WP_Error The updated object post or WP_Error on failure. + */ + public static function update( $activity ) { + $post = self::get_by_guid( $activity['object']['id'] ); + if ( \is_wp_error( $post ) ) { + return $post; + } + + $post_array = self::activity_to_post( $activity['object'] ); + $post_array['ID'] = $post->ID; + $post_id = \wp_update_post( $post_array, true ); + + if ( \is_wp_error( $post_id ) ) { + return $post_id; + } + + self::add_taxonomies( $post_id, $activity['object'] ); + + return \get_post( $post_id ); + } + + /** + * Convert an activity to a post array. + * + * @param array $activity The activity array. + * + * @return array|\WP_Error The post array or WP_Error on failure. + */ + private static function activity_to_post( $activity ) { + if ( ! is_array( $activity ) ) { + return new \WP_Error( 'invalid_activity', __( 'Invalid activity format', 'activitypub' ) ); + } + + return array( + 'post_title' => isset( $activity['name'] ) ? \wp_strip_all_tags( $activity['name'] ) : '', + 'post_content' => isset( $activity['content'] ) ? Sanitize::content( $activity['content'] ) : '', + 'post_excerpt' => isset( $activity['summary'] ) ? \wp_strip_all_tags( $activity['summary'] ) : '', + 'post_status' => 'publish', + 'post_type' => self::POST_TYPE, + 'guid' => isset( $activity['id'] ) ? \esc_url_raw( $activity['id'] ) : '', + ); + } + + /** + * Add taxonomies to the object post. + * + * @param int $post_id The post ID. + * @param array $activity_object The activity object data. + */ + private static function add_taxonomies( $post_id, $activity_object ) { + // Save Object Type as Taxonomy item. + \wp_set_post_terms( $post_id, array( $activity_object['type'] ), 'ap_object_type' ); + + $tags = array(); + + // Save the Hashtags as Taxonomy items. + if ( ! empty( $activity_object['tag'] ) && \is_array( $activity_object['tag'] ) ) { + foreach ( $activity_object['tag'] as $tag ) { + if ( isset( $tag['type'] ) && 'Hashtag' === $tag['type'] && isset( $tag['name'] ) ) { + $tags[] = \wp_strip_all_tags( ltrim( $tag['name'], '#' ) ); + } + } + } + + \wp_set_post_terms( $post_id, $tags, 'ap_tag' ); + } +} diff --git a/includes/debug.php b/includes/debug.php index e629ccd65..abf2a8377 100644 --- a/includes/debug.php +++ b/includes/debug.php @@ -9,6 +9,7 @@ use Activitypub\Collection\Inbox; use Activitypub\Collection\Outbox; +use Activitypub\Collection\Posts; /** * Allow localhost URLs if WP_DEBUG is true. @@ -32,8 +33,8 @@ function allow_localhost( $parsed_args ) { * * @return array The arguments for the post type. */ -function debug_outbox_post_type( $args, $post_type ) { - if ( ! \in_array( $post_type, array( Outbox::POST_TYPE, Inbox::POST_TYPE ), true ) ) { +function debug_post_type( $args, $post_type ) { + if ( ! \in_array( $post_type, array( Outbox::POST_TYPE, Inbox::POST_TYPE, Posts::POST_TYPE ), true ) ) { return $args; } @@ -43,11 +44,33 @@ function debug_outbox_post_type( $args, $post_type ) { $args['menu_icon'] = 'dashicons-upload'; } elseif ( Inbox::POST_TYPE === $post_type ) { $args['menu_icon'] = 'dashicons-download'; + } elseif ( Posts::POST_TYPE === $post_type ) { + $args['menu_icon'] = 'dashicons-media-document'; + } + + return $args; +} +\add_filter( 'register_post_type_args', '\Activitypub\debug_post_type', 10, 2 ); + +/** + * Debug the object type taxonomy. + * + * @param array $args The arguments for the taxonomy. + * @param string $taxonomy The taxonomy. + * + * @return array The arguments for the taxonomy. + */ +function debug_taxonomy( $args, $taxonomy ) { + if ( ! in_array( $taxonomy, array( 'ap_object_type', 'ap_tag' ), true ) ) { + return $args; } + $args['show_ui'] = true; + $args['show_in_menu'] = true; + return $args; } -\add_filter( 'register_post_type_args', '\Activitypub\debug_outbox_post_type', 10, 2 ); +\add_filter( 'register_taxonomy_args', '\Activitypub\debug_taxonomy', 10, 2 ); /** * Debug the outbox post type column. diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index 579562201..b74a4d378 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -8,6 +8,7 @@ namespace Activitypub\Handler; use Activitypub\Collection\Interactions; +use Activitypub\Collection\Posts; use function Activitypub\get_activity_visibility; use function Activitypub\is_activity_reply; @@ -34,14 +35,42 @@ public static function init() { * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null. */ public static function handle_create( $activity, $user_id, $activity_object = null ) { - // Check if Activity is public or not. - if ( - ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) || - ! is_activity_reply( $activity ) - ) { + // Check for private and/or direct messages. + if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) { + $result = false; + } elseif ( is_activity_reply( $activity ) ) { // Check for replies. + $result = self::create_interaction( $activity, $user_id, $activity_object ); + } else { // Handle non-interaction objects. + $result = self::create_post( $activity, $user_id, $activity_object ); + } + + if ( false === $result ) { return; } + $success = ! \is_wp_error( $result ); + + /** + * Fires after an ActivityPub Create activity has been handled. + * + * @param array $activity The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param \WP_Comment|\WP_Post|\WP_Error $result The WP_Comment object of the created comment, or null if creation failed. + */ + \do_action( 'activitypub_handled_create', $activity, $user_id, $success, $result ); + } + + /** + * Handle interactions like replies. + * + * @param array $activity The activity-object. + * @param int $user_id The id of the local blog-user. + * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null. + * + * @return \WP_Comment|\WP_Error|false The created comment, WP_Error on failure, false if not processed. + */ + public static function create_interaction( $activity, $user_id, $activity_object = null ) { $check_dupe = object_id_to_comment( $activity['object']['id'] ); // If comment exists, call update action. @@ -54,30 +83,48 @@ public static function handle_create( $activity, $user_id, $activity_object = nu * @param \Activitypub\Activity\Activity $activity_object The activity object. */ \do_action( 'activitypub_inbox_update', $activity, $user_id, $activity_object ); - return; + return false; } if ( is_self_ping( $activity['object']['id'] ) ) { - return; + return false; } - $success = false; - $result = Interactions::add_comment( $activity ); + $result = Interactions::add_comment( $activity ); - if ( $result && ! \is_wp_error( $result ) ) { - $success = true; - $result = \get_comment( $result ); + if ( ! $result || \is_wp_error( $result ) ) { + return $result; } - /** - * Fires after an ActivityPub Create activity has been handled. - * - * @param array $activity The ActivityPub activity data. - * @param int $user_id The local user ID. - * @param bool $success True on success, false otherwise. - * @param array|string|int|\WP_Error|false $result The WP_Comment object of the created comment, or null if creation failed. - */ - \do_action( 'activitypub_handled_create', $activity, $user_id, $success, $result ); + return \get_comment( $result ); + } + + /** + * Handle non-interaction posts like posts. + * + * @param array $activity The activity-object. + * @param int $user_id The id of the local blog-user. + * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null. + * + * @return \WP_Post|\WP_Error|false The post on success or WP_Error on failure. + */ + public static function create_post( $activity, $user_id, $activity_object = null ) { + $check_dupe = Posts::get_by_guid( $activity['object']['id'] ); + + // If comment exists, call update action. + if ( ! \is_wp_error( $check_dupe ) ) { + /** + * Fires when a Create activity is received for an existing object. + * + * @param array $activity The activity-object. + * @param int $user_id The id of the local blog-user. + * @param \Activitypub\Activity\Activity $activity_object The activity object. + */ + \do_action( 'activitypub_inbox_update', $activity, $user_id, $activity_object ); + return false; + } + + return Posts::add( $activity ); } /** diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index 7ff72ac2a..b7c355506 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -8,9 +8,11 @@ namespace Activitypub\Handler; use Activitypub\Collection\Interactions; +use Activitypub\Collection\Posts; use Activitypub\Collection\Remote_Actors; use function Activitypub\get_remote_metadata_by_actor; +use function Activitypub\is_activity_reply; /** * Handle Update requests. @@ -58,7 +60,7 @@ public static function handle_update( $activity, $user_id ) { case 'Video': case 'Event': case 'Document': - self::update_interaction( $activity, $user_id ); + self::update_object( $activity, $user_id ); break; /* @@ -72,29 +74,34 @@ public static function handle_update( $activity, $user_id ) { } /** - * Update an Interaction. + * Update an Object. * * @param array $activity The Activity object. * @param int $user_id The user ID. Always null for Update activities. */ - public static function update_interaction( $activity, $user_id ) { - $comment_data = Interactions::update_comment( $activity ); - $success = false; + public static function update_object( $activity, $user_id ) { + $result = new \WP_Error( 'activitypub_update_failed', 'Update failed' ); - if ( ! empty( $comment_data['comment_ID'] ) ) { - $success = true; - $result = \get_comment( $comment_data['comment_ID'] ); + // Check for private and/or direct messages. + if ( is_activity_reply( $activity ) ) { + $comment_data = Interactions::update_comment( $activity ); + + if ( ! empty( $comment_data['comment_ID'] ) ) { + $result = \get_comment( $comment_data['comment_ID'] ); + } } else { - $result = $comment_data; + $result = Posts::update( $activity ); } + $success = ( $result && ! \is_wp_error( $result ) ); + /** * Fires after an ActivityPub Update activity has been handled. * - * @param array $activity The ActivityPub activity data. - * @param int $user_id The local user ID. - * @param bool $success True on success, false otherwise. - * @param array|string|int|\WP_Error|false $result The updated comment, or null if update failed. + * @param array $activity The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param \WP_Comment|\WP_Post|\WP_Error $result The updated post, comment, or error. */ \do_action( 'activitypub_handled_update', $activity, $user_id, $success, $result ); } @@ -110,11 +117,11 @@ public static function update_actor( $activity, $user_id ) { $actor = get_remote_metadata_by_actor( $activity['actor'], false ); if ( ! $actor || \is_wp_error( $actor ) || ! isset( $actor['id'] ) ) { - return; + $state = new \WP_Error( 'activitypub_update_failed', 'Update failed: could not fetch actor data' ); + } else { + $state = Remote_Actors::upsert( $actor ); } - $state = Remote_Actors::upsert( $actor ); - /** * Fires after an ActivityPub Update activity has been handled. * diff --git a/tests/phpunit/tests/includes/class-test-sanitize.php b/tests/phpunit/tests/includes/class-test-sanitize.php index f8c9860ae..aff8cff19 100644 --- a/tests/phpunit/tests/includes/class-test-sanitize.php +++ b/tests/phpunit/tests/includes/class-test-sanitize.php @@ -182,4 +182,76 @@ public function test_blog_identifier_with_existing_user() { \wp_delete_user( $user_id ); } + + /** + * Test content sanitization without blocks support. + * + * @covers ::content + */ + public function test_content_without_blocks() { + // Mock site_supports_blocks to return false. + add_filter( 'activitypub_site_supports_blocks', '__return_false' ); + + $content = '

Test Heading

Test paragraph

'; + $result = Sanitize::content( $content ); + + // Should not convert to blocks when blocks are not supported. + $this->assertStringNotContainsString( '