diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c730bfc1..90b3f1ce4 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). -## [Untitled] +## [Unreleased] ### Changed @@ -33,6 +33,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for WPML post locale +### Added + +* Outbox queue + +### Changed + +* Rewrite the current dispatcher system, to use the Outbox instead of the Scheduler. + ### Removed * Built-in support for nodeinfo2. Use the [NodeInfo plugin](https://wordpress.org/plugins/nodeinfo/) instead. diff --git a/activitypub.php b/activitypub.php index 331957049..4446452bf 100644 --- a/activitypub.php +++ b/activitypub.php @@ -40,7 +40,6 @@ */ function rest_init() { Rest\Actors::init(); - Rest\Outbox::init(); Rest\Inbox::init(); Rest\Followers::init(); Rest\Following::init(); @@ -48,8 +47,9 @@ function rest_init() { Rest\Server::init(); Rest\Collection::init(); Rest\Post::init(); - ( new Rest\Interaction_Controller() )->register_routes(); ( new Rest\Application_Controller() )->register_routes(); + ( new Rest\Interaction_Controller() )->register_routes(); + ( new Rest\Outbox_Controller() )->register_routes(); ( new Rest\Webfinger_Controller() )->register_routes(); // Load NodeInfo endpoints only if blog is public. @@ -65,7 +65,7 @@ function rest_init() { function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) ); - \add_action( 'init', array( __NAMESPACE__ . '\Activity_Dispatcher', 'init' ) ); + \add_action( 'init', array( __NAMESPACE__ . '\Dispatcher', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Handler', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Admin', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Hashtag', 'init' ) ); diff --git a/includes/activity/class-activity.php b/includes/activity/class-activity.php index 3ab28d388..2d3e87e7a 100644 --- a/includes/activity/class-activity.php +++ b/includes/activity/class-activity.php @@ -154,7 +154,7 @@ class Activity extends Base_Object { public function set_object( $data ) { // Convert array to object. if ( is_array( $data ) ) { - $data = self::init_from_array( $data ); + unset( $data['@context'] ); } // Set object. @@ -168,37 +168,42 @@ public function set_object( $data ) { } // Check if `$data` is an object and copy some properties otherwise do nothing. - if ( ! is_object( $data ) ) { - return; + if ( is_object( $data ) ) { + $data = $data->to_array(); } foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) { - $this->set( $i, $data->get( $i ) ); + if ( isset( $data[ $i ] ) ) { + $this->set( $i, $data[ $i ] ); + } } - if ( $data->get_published() && ! $this->get_published() ) { - $this->set( 'published', $data->get_published() ); + if ( isset( $data['published'] ) && ! $this->get_published() ) { + $this->set( 'published', $data['published'] ); } - if ( $data->get_updated() && ! $this->get_updated() ) { - $this->set( 'updated', $data->get_updated() ); + if ( isset( $data['updated'] ) && ! $this->get_updated() ) { + $this->set( 'updated', $data['updated'] ); } - if ( $data->get_attributed_to() && ! $this->get_actor() ) { - $this->set( 'actor', $data->get_attributed_to() ); + if ( isset( $data['attributed_to'] ) && ! $this->get_actor() ) { + $this->set( 'actor', $data['attributed_to'] ); } - if ( $data->get_in_reply_to() ) { - $this->set( 'in_reply_to', $data->get_in_reply_to() ); + if ( isset( $data['in_reply_to'] ) ) { + $this->set( 'in_reply_to', $data['in_reply_to'] ); } - if ( $data->get_id() && ! $this->get_id() ) { - $id = strtok( $data->get_id(), '#' ); - if ( $data->get_updated() ) { - $updated = $data->get_updated(); + if ( isset( $data['id'] ) && ! $this->get_id() ) { + $id = strtok( $data['id'], '#' ); + if ( isset( $data['updated'] ) ) { + $updated = $data['updated']; + } elseif ( isset( $data['published'] ) ) { + $updated = $data['published']; } else { - $updated = $data->get_published(); + $updated = time(); } + $this->set( 'id', $id . '#activity-' . strtolower( $this->get_type() ) . '-' . $updated ); } } diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index ab765174f..2624104f1 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -483,7 +483,7 @@ public function __call( $method, $params ) { } if ( \strncasecmp( $method, 'add', 3 ) === 0 ) { - $this->add( $var, $params[0] ); + return $this->add( $var, $params[0] ); } } @@ -566,10 +566,19 @@ public function add( $key, $value ) { $this->$key = array(); } - $attributes = $this->$key; - $attributes[] = $value; + if ( is_string( $this->$key ) ) { + $this->$key = array( $this->$key ); + } + + $attributes = $this->$key; + + if ( is_array( $value ) ) { + $attributes = array_merge( $attributes, $value ); + } else { + $attributes[] = $value; + } - $this->$key = $attributes; + $this->$key = array_unique( $attributes ); return $this->$key; } @@ -579,13 +588,13 @@ public function add( $key, $value ) { * * @param string $json The JSON string. * - * @return Base_Object An Object built from the JSON string. + * @return Base_Object|WP_Error An Object built from the JSON string or WP_Error when it's not a JSON string. */ public static function init_from_json( $json ) { $array = \json_decode( $json, true ); if ( ! is_array( $array ) ) { - $array = array(); + return new WP_Error( 'invalid_json', __( 'Invalid JSON', 'activitypub' ), array( 'status' => 400 ) ); } return self::init_from_array( $array ); @@ -600,15 +609,11 @@ public static function init_from_json( $json ) { */ public static function init_from_array( $data ) { if ( ! is_array( $data ) ) { - return new WP_Error( 'invalid_array', __( 'Invalid array', 'activitypub' ), array( 'status' => 404 ) ); + return new WP_Error( 'invalid_array', __( 'Invalid array', 'activitypub' ), array( 'status' => 400 ) ); } $object = new static(); - - foreach ( $data as $key => $value ) { - $key = camel_to_snake_case( $key ); - call_user_func( array( $object, 'set_' . $key ), $value ); - } + $object->from_array( $data ); return $object; } diff --git a/includes/class-activity-dispatcher.php b/includes/class-activity-dispatcher.php deleted file mode 100644 index 670a3a2f2..000000000 --- a/includes/class-activity-dispatcher.php +++ /dev/null @@ -1,348 +0,0 @@ -get_wp_user_id() ) && - ! is_user_disabled( Actors::BLOG_USER_ID ) - ) { - $transformer->change_wp_user_id( Actors::BLOG_USER_ID ); - } - - if ( null !== $user_id ) { - $transformer->change_wp_user_id( $user_id ); - } - - $user_id = $transformer->get_wp_user_id(); - - if ( is_user_disabled( $user_id ) ) { - return; - } - - $activity = $transformer->to_activity( $type ); - - self::send_activity_to_followers( $activity, $user_id, $wp_object ); - } - - /** - * Send Announces to followers and mentioned users. - * - * @param mixed $wp_object The ActivityPub Post. - * @param string $type The Activity-Type. - */ - public static function send_announce( $wp_object, $type ) { - if ( ! in_array( $type, array( 'Create', 'Update', 'Delete' ), true ) ) { - return; - } - - if ( is_user_disabled( Actors::BLOG_USER_ID ) ) { - return; - } - - $transformer = Factory::get_transformer( $wp_object ); - - if ( \is_wp_error( $transformer ) ) { - return; - } - - $user_id = Actors::BLOG_USER_ID; - $activity = $transformer->to_activity( $type ); - $user = Actors::get_by_id( Actors::BLOG_USER_ID ); - - $announce = new Activity(); - $announce->set_type( 'Announce' ); - $announce->set_object( $activity ); - $announce->set_actor( $user->get_id() ); - - self::send_activity_to_followers( $announce, $user_id, $wp_object ); - } - - /** - * Send a "Update" Activity when a user updates their profile. - * - * @param int $user_id The user ID to send an update for. - */ - public static function send_profile_update( $user_id ) { - $user = Actors::get_by_various( $user_id ); - - // Bail if that's not a good user. - if ( is_wp_error( $user ) ) { - return; - } - - // Build the update. - $activity = new Activity(); - $activity->set_type( 'Update' ); - $activity->set_actor( $user->get_id() ); - $activity->set_object( $user->get_id() ); - $activity->set_to( array( 'https://www.w3.org/ns/activitystreams#Public' ) ); - - // Send the update. - self::send_activity_to_followers( $activity, $user_id, $user ); - } - - /** - * Send an Activity to all followers and mentioned users. - * - * @param Activity $activity The ActivityPub Activity. - * @param int $user_id The user ID. - * @param \WP_User|WP_Post|WP_Comment $wp_object The WordPress object. - */ - private static function send_activity_to_followers( $activity, $user_id, $wp_object ) { - /** - * Filters whether to send an Activity to followers. - * - * @param bool $send_activity_to_followers Whether to send the Activity to followers. - * @param Activity $activity The ActivityPub Activity. - * @param int $user_id The user ID. - * @param \WP_User|WP_Post|WP_Comment $wp_object The WordPress object. - */ - if ( ! apply_filters( 'activitypub_send_activity_to_followers', true, $activity, $user_id, $wp_object ) ) { - return; - } - - /** - * Filters the list of inboxes to send the Activity to. - * - * @param array $inboxes The list of inboxes to send to. - * @param int $user_id The user ID. - * @param Activity $activity The ActivityPub Activity. - */ - $inboxes = apply_filters( 'activitypub_send_to_inboxes', array(), $user_id, $activity ); - $inboxes = array_unique( $inboxes ); - - if ( empty( $inboxes ) ) { - return; - } - - $json = $activity->to_json(); - - foreach ( $inboxes as $inbox ) { - safe_remote_post( $inbox, $json, $user_id ); - } - - set_wp_object_state( $wp_object, 'federated' ); - } - - /** - * Send a "Create" or "Update" Activity for a WordPress Post. - * - * @param int $id The WordPress Post ID. - * @param string $type The Activity-Type. - */ - public static function send_post( $id, $type ) { - $post = get_post( $id ); - - if ( ! $post || is_post_disabled( $post ) ) { - return; - } - - /** - * Fires when an Activity is being sent for any object type. - * - * @param WP_Post $post The WordPress Post. - * @param string $type The Activity-Type. - */ - do_action( 'activitypub_send_activity', $post, $type ); - - /** - * Fires when a specific type of Activity is being sent. - * - * @param WP_Post $post The WordPress Post. - */ - do_action( sprintf( 'activitypub_send_%s_activity', \strtolower( $type ) ), $post ); - } - - /** - * Send a "Create" or "Update" Activity for a WordPress Comment. - * - * @param int $id The WordPress Comment ID. - * @param string $type The Activity-Type. - */ - public static function send_comment( $id, $type ) { - $comment = get_comment( $id ); - - if ( ! $comment ) { - return; - } - - /** - * Fires when an Activity is being sent for a Comment. - * - * @param WP_Comment $comment The WordPress Comment. - * @param string $type The Activity-Type. - */ - do_action( 'activitypub_send_activity', $comment, $type ); - - /** - * Fires when a specific type of Activity is being sent for a Comment. - * - * @param WP_Comment $comment The WordPress Comment. - */ - do_action( sprintf( 'activitypub_send_%s_activity', \strtolower( $type ) ), $comment ); - } - - /** - * Default filter to add Inboxes of Followers. - * - * @param array $inboxes The list of Inboxes. - * @param int $user_id The WordPress User-ID. - * - * @return array The filtered Inboxes - */ - public static function add_inboxes_of_follower( $inboxes, $user_id ) { - $follower_inboxes = Followers::get_inboxes( $user_id ); - - return array_merge( $inboxes, $follower_inboxes ); - } - - /** - * Default filter to add Inboxes of Mentioned Actors - * - * @param array $inboxes The list of Inboxes. - * @param int $user_id The WordPress User-ID. - * @param array $activity The ActivityPub Activity. - * - * @return array The filtered Inboxes. - */ - public static function add_inboxes_by_mentioned_actors( $inboxes, $user_id, $activity ) { - $cc = $activity->get_cc() ?? array(); - $to = $activity->get_to() ?? array(); - - $audience = array_merge( $cc, $to ); - - // Remove "public placeholder" and "same domain" from the audience. - $audience = array_filter( - $audience, - function ( $actor ) { - return 'https://www.w3.org/ns/activitystreams#Public' !== $actor && ! is_same_domain( $actor ); - } - ); - - if ( $audience ) { - $mentioned_inboxes = Mention::get_inboxes( $audience ); - - return array_merge( $inboxes, $mentioned_inboxes ); - } - - return $inboxes; - } - - /** - * Default filter to add Inboxes of Posts that are set as `in-reply-to` - * - * @param array $inboxes The list of Inboxes. - * @param int $user_id The WordPress User-ID. - * @param array $activity The ActivityPub Activity. - * - * @return array The filtered Inboxes - */ - public static function add_inboxes_of_replied_urls( $inboxes, $user_id, $activity ) { - $in_reply_to = $activity->get_in_reply_to(); - - if ( ! $in_reply_to ) { - return $inboxes; - } - - if ( ! is_array( $in_reply_to ) ) { - $in_reply_to = array( $in_reply_to ); - } - - foreach ( $in_reply_to as $url ) { - $object = Http::get_remote_object( $url ); - - if ( - ! $object || - \is_wp_error( $object ) || - empty( $object['attributedTo'] ) - ) { - continue; - } - - $actor = object_to_uri( $object['attributedTo'] ); - $actor = Http::get_remote_object( $actor ); - - if ( ! $actor || \is_wp_error( $actor ) ) { - continue; - } - - if ( ! empty( $actor['endpoints']['sharedInbox'] ) ) { - $inboxes[] = $actor['endpoints']['sharedInbox']; - } elseif ( ! empty( $actor['inbox'] ) ) { - $inboxes[] = $actor['inbox']; - } - } - - return $inboxes; - } -} diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 756a2f82f..0fff04439 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -9,6 +9,7 @@ use Exception; use Activitypub\Transformer\Factory; +use Activitypub\Collection\Outbox; use Activitypub\Collection\Followers; use Activitypub\Collection\Extra_Fields; @@ -448,7 +449,7 @@ public static function plugin_update_message( $data ) { } /** - * Register the "Followers" Taxonomy. + * Register Custom Post Types. */ private static function register_post_types() { \register_post_type( @@ -518,6 +519,100 @@ private static function register_post_types() { ) ); + // Register Outbox Post-Type. + register_post_type( + Outbox::POST_TYPE, + array( + 'labels' => array( + 'name' => _x( 'Outbox', 'post_type plural name', 'activitypub' ), + 'singular_name' => _x( 'Outbox Item', 'post_type single name', 'activitypub' ), + ), + 'capabilities' => array( + 'create_posts' => false, + ), + 'map_meta_cap' => true, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => true, + 'can_export' => true, + 'exclude_from_search' => true, + ) + ); + + /** + * Register Activity Type meta for Outbox items. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types + */ + \register_post_meta( + Outbox::POST_TYPE, + '_activitypub_activity_type', + array( + 'type' => 'string', + 'description' => 'The type of the activity', + 'single' => true, + 'sanitize_callback' => function ( $value ) { + $value = ucfirst( strtolower( $value ) ); + $schema = array( + 'type' => 'string', + 'enum' => array( 'Accept', 'Add', 'Announce', 'Arrive', 'Block', 'Create', 'Delete', 'Dislike', 'Flag', 'Follow', 'Ignore', 'Invite', 'Join', 'Leave', 'Like', 'Listen', 'Move', 'Offer', 'Question', 'Reject', 'Read', 'Remove', 'TentativeReject', 'TentativeAccept', 'Travel', 'Undo', 'Update', 'View' ), + 'default' => 'Announce', + ); + + if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) { + return $schema['default']; + } + + return $value; + }, + ) + ); + + \register_post_meta( + Outbox::POST_TYPE, + '_activitypub_activity_actor', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => function ( $value ) { + $schema = array( + 'type' => 'string', + 'enum' => array( 'application', 'blog', 'user' ), + 'default' => 'user', + ); + + if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) { + return $schema['default']; + } + + return $value; + }, + ) + ); + + \register_post_meta( + Outbox::POST_TYPE, + 'activitypub_content_visibility', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + 'sanitize_callback' => function ( $value ) { + $schema = array( + 'type' => 'string', + 'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ), + 'default' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ); + + if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) { + return $schema['default']; + } + + return $value; + }, + ) + ); + // Both User and Blog Extra Fields types have the same args. $args = array( 'labels' => array( diff --git a/includes/class-comment.php b/includes/class-comment.php index 3c42d02be..b0ad16bc2 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -229,7 +229,7 @@ public static function should_be_federated( $comment ) { return false; } - if ( is_single_user() && \user_can( $user_id, 'publish_posts' ) ) { + if ( is_single_user() && \user_can( $user_id, 'activitypub' ) ) { // On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user. $user_id = Actors::BLOG_USER_ID; } diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php new file mode 100644 index 000000000..e619c0c84 --- /dev/null +++ b/includes/class-dispatcher.php @@ -0,0 +1,282 @@ +ID, '_activitypub_activity_actor', true ); + + switch ( $actor_type ) { + case 'blog': + $actor_id = Actors::BLOG_USER_ID; + break; + case 'application': + $actor_id = Actors::APPLICATION_USER_ID; + break; + case 'user': + default: + $actor_id = $outbox_item->post_author; + break; + } + + $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ); + $activity = new Activity(); + $activity->set_type( $type ); + $activity->set_id( $outbox_item->guid ); + // Pre-fill the Activity with data (for example cc and to). + $activity->set_object( \json_decode( $outbox_item->post_content, true ) ); + $activity->set_actor( Actors::get_by_id( $outbox_item->post_author )->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()['id'] ); + } + + self::send_activity_to_followers( $activity, $actor_id, $outbox_item ); + } + + /** + * Send an Activity to all followers and mentioned users. + * + * @param Activity $activity The ActivityPub Activity. + * @param int $actor_id The actor ID. + * @param \WP_Post $outbox_item The WordPress object. + */ + private static function send_activity_to_followers( $activity, $actor_id, $outbox_item = null ) { + /** + * Filters whether to send an Activity to followers. + * + * @param bool $send_activity_to_followers Whether to send the Activity to followers. + * @param Activity $activity The ActivityPub Activity. + * @param int $actor_id The actor ID. + * @param \WP_Post $outbox_item The WordPress object. + */ + if ( ! apply_filters( 'activitypub_send_activity_to_followers', true, $activity, $actor_id, $outbox_item ) ) { + return; + } + + /** + * Filters the list of inboxes to send the Activity to. + * + * @param array $inboxes The list of inboxes to send to. + * @param int $actor_id The actor ID. + * @param Activity $activity The ActivityPub Activity. + */ + $inboxes = apply_filters( 'activitypub_send_to_inboxes', array(), $actor_id, $activity ); + $inboxes = array_unique( $inboxes ); + + $json = $activity->to_json(); + + foreach ( $inboxes as $inbox ) { + safe_remote_post( $inbox, $json, $actor_id ); + } + + \wp_publish_post( $outbox_item ); + } + + /** + * Default filter to add Inboxes of Followers. + * + * @param array $inboxes The list of Inboxes. + * @param int $actor_id The WordPress Actor-ID. + * @param Activity $activity The ActivityPub Activity. + * + * @return array The filtered Inboxes + */ + public static function add_inboxes_of_follower( $inboxes, $actor_id, $activity ) { + if ( ! self::should_send_to_followers( $activity, $actor_id ) ) { + return $inboxes; + } + + $follower_inboxes = Followers::get_inboxes( $actor_id ); + + return array_merge( $inboxes, $follower_inboxes ); + } + + /** + * Default filter to add Inboxes of Mentioned Actors + * + * @param array $inboxes The list of Inboxes. + * @param int $actor_id The WordPress Actor-ID. + * @param Activity $activity The ActivityPub Activity. + * + * @return array The filtered Inboxes. + */ + public static function add_inboxes_by_mentioned_actors( $inboxes, $actor_id, $activity ) { + $cc = $activity->get_cc() ?? array(); + $to = $activity->get_to() ?? array(); + + $audience = array_merge( $cc, $to ); + + // Remove "public placeholder" and "same domain" from the audience. + $audience = array_filter( + $audience, + function ( $actor ) { + return 'https://www.w3.org/ns/activitystreams#Public' !== $actor && ! is_same_domain( $actor ); + } + ); + + if ( $audience ) { + $mentioned_inboxes = Mention::get_inboxes( $audience ); + + return array_merge( $inboxes, $mentioned_inboxes ); + } + + return $inboxes; + } + + /** + * Default filter to add Inboxes of Posts that are set as `in-reply-to` + * + * @param array $inboxes The list of Inboxes. + * @param int $actor_id The WordPress Actor-ID. + * @param array $activity The ActivityPub Activity. + * + * @return array The filtered Inboxes + */ + public static function add_inboxes_of_replied_urls( $inboxes, $actor_id, $activity ) { + $in_reply_to = $activity->get_in_reply_to(); + + if ( ! $in_reply_to ) { + return $inboxes; + } + + if ( ! is_array( $in_reply_to ) ) { + $in_reply_to = array( $in_reply_to ); + } + + foreach ( $in_reply_to as $url ) { + $object = Http::get_remote_object( $url ); + + if ( + ! $object || + \is_wp_error( $object ) || + empty( $object['attributedTo'] ) + ) { + continue; + } + + $actor = object_to_uri( $object['attributedTo'] ); + $actor = Http::get_remote_object( $actor ); + + if ( ! $actor || \is_wp_error( $actor ) ) { + continue; + } + + if ( ! empty( $actor['endpoints']['sharedInbox'] ) ) { + $inboxes[] = $actor['endpoints']['sharedInbox']; + } elseif ( ! empty( $actor['inbox'] ) ) { + $inboxes[] = $actor['inbox']; + } + } + + return $inboxes; + } + + /** + * Adds Blog Actor inboxes to Updates so the Blog User's followers are notified of edits. + * + * @param array $inboxes The list of Inboxes. + * @param int $actor_id The WordPress Actor-ID. + * @param Activity $activity The ActivityPub Activity. + * + * @return array The filtered Inboxes + */ + public static function maybe_add_inboxes_of_blog_user( $inboxes, $actor_id, $activity ) { + if ( ! self::should_send_to_followers( $activity, $actor_id ) ) { + return $inboxes; + } + + // Only if we're in both Blog and User modes. + if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE !== \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) { + return $inboxes; + } + // Only if this isn't the Blog Actor. + if ( Actors::BLOG_USER_ID === $actor_id ) { + return $inboxes; + } + // Only if this is an Update or Delete. Create handles its own Announce in dual user mode. + if ( ! in_array( $activity->get_type(), array( 'Update', 'Delete' ), true ) ) { + return $inboxes; + } + + $blog_inboxes = Followers::get_inboxes( Actors::BLOG_USER_ID ); + // array_unique is done in `send_activity_to_followers()`, no need here. + return array_merge( $inboxes, $blog_inboxes ); + } + + /** + * Check if passed Activity is public. + * + * @param Activity $activity The Activity object. + * @param int $actor_id The Actor-ID. + * + * @return boolean True if public, false if not. + */ + protected static function should_send_to_followers( $activity, $actor_id ) { + // Check if follower endpoint is set. + $actor = Actors::get_by_id( $actor_id ); + + if ( ! $actor || is_wp_error( $actor ) ) { + return false; + } + + // Check if follower endpoint is set. + $cc = $activity->get_cc() ?? array(); + $to = $activity->get_to() ?? array(); + + $audience = array_merge( $cc, $to ); + + if ( + // Check if activity is public. + in_array( 'https://www.w3.org/ns/activitystreams#Public', $audience, true ) || + // ...or check if follower endpoint is set. + in_array( $actor->get_followers(), $audience, true ) + ) { + return true; + } + + return false; + } +} diff --git a/includes/class-http.php b/includes/class-http.php index 1c8c885d1..f7692d7cb 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -66,7 +66,14 @@ public static function post( $url, $body, $user_id ) { $code = \wp_remote_retrieve_response_code( $response ); if ( $code >= 400 ) { - $response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) ); + $response = new WP_Error( + $code, + __( 'Failed HTTP Request', 'activitypub' ), + array( + 'status' => $code, + 'response' => $response, + ) + ); } /** diff --git a/includes/class-migration.php b/includes/class-migration.php index 0497fda4a..8861880b7 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -9,6 +9,8 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; +use Activitypub\Collection\Outbox; +use Activitypub\Transformer\Factory; /** * ActivityPub Migration Class @@ -21,6 +23,7 @@ class Migration { */ public static function init() { \add_action( 'activitypub_migrate', array( self::class, 'async_migration' ) ); + \add_action( 'activitypub_upgrade', array( self::class, 'async_upgrade' ), 10, 99 ); \add_action( 'activitypub_update_comment_counts', array( self::class, 'update_comment_counts' ), 10, 2 ); self::maybe_migrate(); @@ -170,6 +173,11 @@ public static function maybe_migrate() { if ( \version_compare( $version_from_db, '4.7.3', '<' ) ) { add_action( 'init', 'flush_rewrite_rules', 20 ); } + if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + Scheduler::register_schedules(); + \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'create_post_outbox_items' ) ); + \wp_schedule_single_event( \time() + 15, 'activitypub_upgrade', array( 'create_comment_outbox_items' ) ); + } /* * Add new update routines above this comment. ^ @@ -207,6 +215,38 @@ public static function async_migration( $version_from_db ) { } } + /** + * Asynchronously runs upgrade routines. + * + * @param callable $callback Callable upgrade routine. Must be a method of this class. + * @params mixed ...$args Optional. Parameters that get passed to the callback. + */ + public static function async_upgrade( $callback ) { + $args = \func_get_args(); + + // Bail if the existing lock is still valid. + if ( self::is_locked() ) { + \wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'activitypub_upgrade', $args ); + return; + } + + self::lock(); + + $callback = array_shift( $args ); // Remove $callback from arguments. + $next = \call_user_func_array( array( self::class, $callback ), $args ); + + self::unlock(); + + if ( ! empty( $next ) ) { + // Schedule the next run, adding the result to the arguments. + \wp_schedule_single_event( + \time() + 30, + 'activitypub_upgrade', + \array_merge( array( $callback ), \array_values( $next ) ) + ); + } + } + /** * Updates the custom template to use shortcodes instead of the deprecated templates. */ @@ -500,6 +540,91 @@ public static function update_comment_counts( $batch_size = 100, $offset = 0 ) { self::unlock(); } + /** + * Create outbox items for posts in batches. + * + * @param int $batch_size Optional. Number of posts to process per batch. Default 50. + * @param int $offset Optional. Number of posts to skip. Default 0. + * @return array|null Array with batch size and offset if there are more posts to process, null otherwise. + */ + public static function create_post_outbox_items( $batch_size = 50, $offset = 0 ) { + $posts = \get_posts( + array( + // our own `ap_outbox` will be excluded from `any` by virtue of its `exclude_from_search` arg. + 'post_type' => 'any', + 'posts_per_page' => $batch_size, + 'offset' => $offset, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => 'activitypub_status', + 'value' => 'federated', + ), + ), + ) + ); + + // Avoid multiple queries for post meta. + \update_postmeta_cache( \wp_list_pluck( $posts, 'ID' ) ); + + foreach ( $posts as $post ) { + $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); + + self::add_to_outbox( $post, 'Create', $post->post_author, $visibility ); + + // Add Update activity when the post has been modified. + if ( $post->post_modified !== $post->post_date ) { + self::add_to_outbox( $post, 'Update', $post->post_author, $visibility ); + } + } + + if ( count( $posts ) === $batch_size ) { + return array( + 'batch_size' => $batch_size, + 'offset' => $offset + $batch_size, + ); + } + + return null; + } + + /** + * Create outbox items for comments in batches. + * + * @param int $batch_size Optional. Number of posts to process per batch. Default 50. + * @param int $offset Optional. Number of posts to skip. Default 0. + * @return array|null Array with batch size and offset if there are more posts to process, null otherwise. + */ + public static function create_comment_outbox_items( $batch_size = 50, $offset = 0 ) { + $comments = \get_comments( + array( + 'author__not_in' => array( 0 ), // Limit to comments by registered users. + 'number' => $batch_size, + 'offset' => $offset, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => 'activitypub_status', + 'value' => 'federated', + ), + ), + ) + ); + + foreach ( $comments as $comment ) { + self::add_to_outbox( $comment, 'Create', $comment->user_id ); + } + + if ( count( $comments ) === $batch_size ) { + return array( + 'batch_size' => $batch_size, + 'offset' => $offset + $batch_size, + ); + } + + return null; + } + /** * Set the defaults needed for the plugin to work. * @@ -510,6 +635,40 @@ public static function add_default_settings() { self::add_notification_defaults(); } + /** + * Add an activity to the outbox without federating it. + * + * @param \WP_Post|\WP_Comment $comment The comment or post object. + * @param string $activity_type The type of activity. + * @param int $user_id The user ID. + * @param string $visibility Optional. The visibility of the content. Default 'public'. + */ + private static function add_to_outbox( $comment, $activity_type, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { + $transformer = Factory::get_transformer( $comment ); + if ( ! $transformer || \is_wp_error( $transformer ) ) { + return; + } + + $activity = $transformer->to_object(); + if ( ! $activity || \is_wp_error( $activity ) ) { + return; + } + + // If the user is disabled, fall back to the blog user when available. + if ( is_user_disabled( $user_id ) ) { + if ( is_user_disabled( Actors::BLOG_USER_ID ) ) { + return; + } else { + $user_id = Actors::BLOG_USER_ID; + } + } + + $post_id = Outbox::add( $activity, $activity_type, $user_id, $visibility ); + + // Immediately set to publish, no federation needed. + \wp_publish_post( $post_id ); + } + /** * Add the ActivityPub capability to all users that can publish posts. */ diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index e5bd5802f..d357146b2 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -7,6 +7,10 @@ namespace Activitypub; +use Activitypub\Scheduler\Post; +use Activitypub\Scheduler\Actor; +use Activitypub\Scheduler\Comment; +use Activitypub\Collection\Outbox; use Activitypub\Collection\Followers; /** @@ -20,63 +24,31 @@ class Scheduler { * Initialize the class, registering WordPress hooks. */ public static function init() { - // Post transitions. - \add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 ); - \add_action( - 'edit_attachment', - function ( $post_id ) { - self::schedule_post_activity( 'publish', 'publish', $post_id ); - } - ); - \add_action( - 'add_attachment', - function ( $post_id ) { - self::schedule_post_activity( 'publish', '', $post_id ); - } - ); - \add_action( - 'delete_attachment', - function ( $post_id ) { - self::schedule_post_activity( 'trash', '', $post_id ); - } - ); - - if ( ! ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS ) { - // Comment transitions. - \add_action( 'transition_comment_status', array( self::class, 'schedule_comment_activity' ), 20, 3 ); - \add_action( - 'edit_comment', - function ( $comment_id ) { - self::schedule_comment_activity( 'approved', 'approved', $comment_id ); - } - ); - \add_action( - 'wp_insert_comment', - function ( $comment_id ) { - self::schedule_comment_activity( 'approved', '', $comment_id ); - } - ); - } + self::register_schedulers(); // Follower Cleanups. \add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) ); \add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) ); - // Profile updates for blog options. - if ( ! is_user_type_disabled( 'blog' ) ) { - \add_action( 'update_option_site_icon', array( self::class, 'blog_user_update' ) ); - \add_action( 'update_option_blogdescription', array( self::class, 'blog_user_update' ) ); - \add_action( 'update_option_blogname', array( self::class, 'blog_user_update' ) ); - \add_filter( 'pre_set_theme_mod_custom_logo', array( self::class, 'blog_user_update' ) ); - \add_filter( 'pre_set_theme_mod_header_image', array( self::class, 'blog_user_update' ) ); - } + \add_action( 'activitypub_reprocess_outbox', array( self::class, 'reprocess_outbox' ) ); - // Profile updates for user options. - if ( ! is_user_type_disabled( 'user' ) ) { - \add_action( 'wp_update_user', array( self::class, 'user_update' ) ); - \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. - } + \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_outbox_activity_for_federation' ) ); + } + + /** + * Register handlers. + */ + public static function register_schedulers() { + Post::init(); + Actor::init(); + Comment::init(); + + /** + * Register additional schedulers. + * + * @since 5.0.0 + */ + do_action( 'activitypub_register_schedulers' ); } /** @@ -90,6 +62,10 @@ public static function register_schedules() { if ( ! \wp_next_scheduled( 'activitypub_cleanup_followers' ) ) { \wp_schedule_event( time(), 'daily', 'activitypub_cleanup_followers' ); } + + if ( ! \wp_next_scheduled( 'activitypub_reprocess_outbox' ) ) { + \wp_schedule_event( time(), 'hourly', 'activitypub_reprocess_outbox' ); + } } /** @@ -100,125 +76,7 @@ public static function register_schedules() { public static function deregister_schedules() { wp_unschedule_hook( 'activitypub_update_followers' ); wp_unschedule_hook( 'activitypub_cleanup_followers' ); - } - - - /** - * Schedule Activities. - * - * @param string $new_status New post status. - * @param string $old_status Old post status. - * @param \WP_Post $post Post object. - */ - public static function schedule_post_activity( $new_status, $old_status, $post ) { - $post = get_post( $post ); - - if ( ! $post || is_post_disabled( $post ) ) { - return; - } - - if ( 'ap_extrafield' === $post->post_type ) { - self::schedule_profile_update( $post->post_author ); - return; - } - - if ( 'ap_extrafield_blog' === $post->post_type ) { - self::schedule_profile_update( 0 ); - return; - } - - // Do not send activities if post is password protected. - if ( \post_password_required( $post ) ) { - return; - } - - // Check if post-type supports ActivityPub. - $post_types = \get_post_types_by_support( 'activitypub' ); - if ( ! \in_array( $post->post_type, $post_types, true ) ) { - return; - } - - switch ( $new_status ) { - case 'publish': - $type = ( 'publish' === $old_status ) ? 'Update' : 'Create'; - break; - - case 'draft': - $type = ( 'publish' === $old_status ) ? 'Update' : false; - break; - - case 'trash': - $type = 'federated' === get_wp_object_state( $post ) ? 'Delete' : false; - break; - - default: - $type = false; - } - - // No activity to schedule. - if ( empty( $type ) ) { - return; - } - - $hook = 'activitypub_send_post'; - $args = array( $post->ID, $type ); - - if ( false === wp_next_scheduled( $hook, $args ) ) { - set_wp_object_state( $post, 'federate' ); - \wp_schedule_single_event( \time() + 10, $hook, $args ); - } - } - - /** - * Schedule Comment Activities. - * - * @see transition_comment_status() - * - * @param string $new_status New comment status. - * @param string $old_status Old comment status. - * @param \WP_Comment $comment Comment object. - */ - public static function schedule_comment_activity( $new_status, $old_status, $comment ) { - $comment = get_comment( $comment ); - - // Federate only comments that are written by a registered user. - if ( ! $comment || ! $comment->user_id ) { - return; - } - - $type = false; - - if ( - 'approved' === $new_status && - 'approved' !== $old_status - ) { - $type = 'Create'; - } elseif ( 'approved' === $new_status ) { - $type = 'Update'; - \update_comment_meta( $comment->comment_ID, 'activitypub_comment_modified', time(), true ); - } elseif ( - 'trash' === $new_status || - 'spam' === $new_status - ) { - $type = 'Delete'; - } - - if ( empty( $type ) ) { - return; - } - - // Check if comment should be federated or not. - if ( ! should_comment_be_federated( $comment ) ) { - return; - } - - $hook = 'activitypub_send_comment'; - $args = array( $comment->comment_ID, $type ); - - if ( false === wp_next_scheduled( $hook, $args ) ) { - set_wp_object_state( $comment, 'federate' ); - \wp_schedule_single_event( \time(), $hook, $args ); - } + wp_unschedule_hook( 'activitypub_reprocess_outbox' ); } /** @@ -292,67 +150,38 @@ public static function cleanup_followers() { } /** - * Send a profile update when relevant user meta is updated. + * Schedule the outbox item for federation. * - * @param int $meta_id Meta ID being updated. - * @param int $user_id User ID being updated. - * @param string $meta_key Meta key being updated. + * @param int $id The ID of the outbox item. */ - public static function user_meta_update( $meta_id, $user_id, $meta_key ) { - // Don't bother if the user can't publish. - if ( ! \user_can( $user_id, 'activitypub' ) ) { - return; - } + public static function schedule_outbox_activity_for_federation( $id ) { + $hook = 'activitypub_process_outbox'; + $args = array( $id ); - // The user meta fields that affect a profile. - $fields = array( - 'activitypub_description', - 'activitypub_header_image', - 'description', - 'user_url', - 'display_name', - ); - if ( in_array( $meta_key, $fields, true ) ) { - self::schedule_profile_update( $user_id ); - } - } - - /** - * Send a profile update when a user is updated. - * - * @param int $user_id User ID being updated. - */ - public static function user_update( $user_id ) { - // Don't bother if the user can't publish. - if ( ! \user_can( $user_id, 'activitypub' ) ) { - return; + if ( false === wp_next_scheduled( $hook, $args ) ) { + \wp_schedule_single_event( + \time() + 10, + $hook, + $args + ); } - - self::schedule_profile_update( $user_id ); } /** - * Theme mods only have a dynamic filter so we fudge it like this. - * - * @param mixed $value Optional. The value to be updated. Default null. - * - * @return mixed + * Reprocess the outbox. */ - public static function blog_user_update( $value = null ) { - self::schedule_profile_update( 0 ); - return $value; - } - - /** - * Send a profile update to all followers. Gets hooked into all relevant options/meta etc. - * - * @param int $user_id The user ID to update (Could be 0 for Blog-User). - */ - public static function schedule_profile_update( $user_id ) { - \wp_schedule_single_event( - \time() + 10, - 'activitypub_send_update_profile_activity', - array( $user_id ) + public static function reprocess_outbox() { + $ids = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'posts_per_page' => 10, + 'fields' => 'ids', + ) ); + + foreach ( $ids as $id ) { + self::schedule_outbox_activity_for_federation( $id ); + } } } diff --git a/includes/class-shortcodes.php b/includes/class-shortcodes.php index 6ac73c03c..10018dd47 100644 --- a/includes/class-shortcodes.php +++ b/includes/class-shortcodes.php @@ -166,24 +166,26 @@ public static function content( $atts, $content, $tag ) { if ( empty( $content ) ) { $content = get_post_meta( $item->ID, '_wp_attachment_image_alt', true ); } - } else { - $content = \get_post_field( 'post_content', $item ); + } - if ( 'yes' === $atts['apply_filters'] ) { - /** This filter is documented in wp-includes/post-template.php */ - $content = \apply_filters( 'the_content', $content ); - } else { - $content = do_blocks( $content ); - $content = wptexturize( $content ); - $content = wp_filter_content_tags( $content ); - } + if ( empty( $content ) ) { + $content = \get_post_field( 'post_content', $item ); + } - // Replace script and style elements. - $content = \preg_replace( '@<(script|style)[^>]*?>.*?@si', '', $content ); - $content = strip_shortcodes( $content ); - $content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) ); + if ( 'yes' === $atts['apply_filters'] ) { + /** This filter is documented in wp-includes/post-template.php */ + $content = \apply_filters( 'the_content', $content ); + } else { + $content = do_blocks( $content ); + $content = wptexturize( $content ); + $content = wp_filter_content_tags( $content ); } + // Replace script and style elements. + $content = \preg_replace( '@<(script|style)[^>]*?>.*?@si', '', $content ); + $content = strip_shortcodes( $content ); + $content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) ); + add_shortcode( 'ap_content', array( 'Activitypub\Shortcodes', 'content' ) ); return $content; diff --git a/includes/collection/class-actors.php b/includes/collection/class-actors.php index 88ad59462..44f80af01 100644 --- a/includes/collection/class-actors.php +++ b/includes/collection/class-actors.php @@ -147,6 +147,14 @@ public static function get_by_username( $username ) { public static function get_by_resource( $uri ) { $uri = object_to_uri( $uri ); + if ( ! $uri ) { + return new WP_Error( + 'activitypub_no_uri', + \__( 'No URI provided', 'activitypub' ), + array( 'status' => 404 ) + ); + } + $scheme = 'acct'; $match = array(); // Try to extract the scheme and the host. diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 77ade14e1..d668c4831 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -197,12 +197,8 @@ public static function get_followers_with_count( $user_id, $number = -1, $page = $args = wp_parse_args( $args, $defaults ); $query = new WP_Query( $args ); $total = $query->found_posts; - $followers = array_map( - function ( $post ) { - return Follower::init_from_cpt( $post ); - }, - $query->get_posts() - ); + $followers = array_map( array( Follower::class, 'init_from_cpt' ), $query->get_posts() ); + $followers = array_filter( $followers ); return compact( 'followers', 'total' ); } @@ -354,13 +350,9 @@ public static function get_outdated_followers( $number = 50, $older_than = 86400 ); $posts = new WP_Query( $args ); - $items = array(); - - foreach ( $posts->get_posts() as $follower ) { - $items[] = Follower::init_from_cpt( $follower ); - } + $items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() ); - return $items; + return array_filter( $items ); } /** @@ -403,13 +395,9 @@ public static function get_faulty_followers( $number = 20 ) { ); $posts = new WP_Query( $args ); - $items = array(); - - foreach ( $posts->get_posts() as $follower ) { - $items[] = Follower::init_from_cpt( $follower ); - } + $items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() ); - return $items; + return array_filter( $items ); } /** diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php new file mode 100644 index 000000000..b0d762455 --- /dev/null +++ b/includes/collection/class-outbox.php @@ -0,0 +1,77 @@ + self::POST_TYPE, + 'post_title' => $activity_object->get_id(), + 'post_content' => wp_slash( $activity_object->to_json() ), + // ensure that user ID is not below 0. + 'post_author' => \max( $user_id, 0 ), + 'post_status' => 'pending', + 'meta_input' => array( + '_activitypub_activity_type' => $activity_type, + '_activitypub_activity_actor' => $actor_type, + 'activitypub_content_visibility' => $content_visibility, + ), + ); + + $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' ); + if ( $has_kses ) { + // Prevent KSES from corrupting JSON in post_content. + \kses_remove_filters(); + } + + $id = \wp_insert_post( $outbox_item, true ); + + if ( $has_kses ) { + \kses_init_filters(); + } + + if ( \is_wp_error( $id ) ) { + return $id; + } + + if ( ! $id ) { + return false; + } + + return $id; + } +} diff --git a/includes/collection/class-replies.php b/includes/collection/class-replies.php index 2f10c004b..3981e2636 100644 --- a/includes/collection/class-replies.php +++ b/includes/collection/class-replies.php @@ -74,7 +74,7 @@ private static function get_id( $wp_object ) { } elseif ( $wp_object instanceof WP_Comment ) { return get_rest_url_by_path( sprintf( 'comments/%d/replies', $wp_object->comment_ID ) ); } else { - return new WP_Error(); + return new WP_Error( 'unsupported_object', 'The object is not a post or comment.' ); } } @@ -88,7 +88,7 @@ private static function get_id( $wp_object ) { public static function get_collection( $wp_object ) { $id = self::get_id( $wp_object ); - if ( ! $id ) { + if ( ! $id || is_wp_error( $id ) ) { return null; } diff --git a/includes/constants.php b/includes/constants.php index e12450205..98da5d4a4 100644 --- a/includes/constants.php +++ b/includes/constants.php @@ -71,4 +71,5 @@ // Post visibility constants. \define( 'ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC', '' ); \define( 'ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC', 'quiet_public' ); +\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE', 'private' ); \define( 'ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL', 'local' ); diff --git a/includes/functions.php b/includes/functions.php index 53d02f206..6a2629a9a 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -9,9 +9,12 @@ use WP_Error; use Activitypub\Activity\Activity; -use Activitypub\Collection\Followers; +use Activitypub\Activity\Base_Object; use Activitypub\Collection\Actors; +use Activitypub\Collection\Outbox; +use Activitypub\Collection\Followers; use Activitypub\Transformer\Post; +use Activitypub\Transformer\Factory as Transformer_Factory; /** * Returns the ActivityPub default JSON-context. @@ -1418,10 +1421,11 @@ function get_content_visibility( $post_id ) { return false; } - $visibility = get_post_meta( $post->ID, 'activitypub_content_visibility', true ); + $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; $options = array( ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, + ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ); @@ -1547,3 +1551,166 @@ function is_self_ping( $id ) { return false; } + +/** + * Add an object to the outbox. + * + * @param mixed $data The object to add to the outbox. + * @param string $activity_type The type of the Activity. + * @param integer $user_id The User-ID. + * @param string $content_visibility The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`. + * + * @return boolean|int The ID of the outbox item or false on failure. + */ +function add_to_outbox( $data, $activity_type = 'Create', $user_id = 0, $content_visibility = null ) { + $transformer = Transformer_Factory::get_transformer( $data ); + + if ( ! $transformer || is_wp_error( $transformer ) ) { + return false; + } + + if ( $content_visibility ) { + $transformer->set_content_visibility( $content_visibility ); + } else { + $content_visibility = $transformer->get_content_visibility(); + } + + $activity_object = $transformer->to_object(); + + if ( ! $activity_object || \is_wp_error( $activity_object ) ) { + return false; + } + + // If the user is disabled, fall back to the blog user when available. + if ( is_user_disabled( $user_id ) ) { + if ( is_user_disabled( Actors::BLOG_USER_ID ) ) { + return false; + } else { + $user_id = Actors::BLOG_USER_ID; + } + } + + set_wp_object_state( $data, 'federate' ); + + $outbox_activity_id = Outbox::add( $activity_object, $activity_type, $user_id, $content_visibility ); + + if ( ! $outbox_activity_id ) { + return false; + } + + /** + * Action triggered after an object has been added to the outbox. + * + * @param int $outbox_activity_id The ID of the outbox item. + * @param \Activitypub\Activity\Base_Object $activity_object The activity object. + * @param int $user_id The User-ID. + * @param string $content_visibility The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`. + */ + \do_action( 'post_activitypub_add_to_outbox', $outbox_activity_id, $activity_object, $user_id, $content_visibility ); + + return $outbox_activity_id; +} + +/** + * Check if an `$data` is an Activity. + * + * @see https://www.w3.org/ns/activitystreams#activities + * + * @param array|object|string $data The data to check. + * + * @return boolean True if the `$data` is an Activity, false otherwise. + */ +function is_activity( $data ) { + /** + * Filters the activity types. + * + * @param array $types The activity types. + */ + $types = apply_filters( + 'activitypub_activity_types', + array( + 'Accept', + 'Add', + 'Announce', + 'Arrive', + 'Block', + 'Create', + 'Delete', + 'Dislike', + 'Follow', + 'Flag', + 'Ignore', + 'Invite', + 'Join', + 'Leave', + 'Like', + 'Listen', + 'Move', + 'Offer', + 'Read', + 'Reject', + 'Remove', + 'TentativeAccept', + 'TentativeReject', + 'Travel', + 'Undo', + 'Update', + 'View', + ) + ); + + if ( is_string( $data ) ) { + return in_array( $data, $types, true ); + } + + if ( is_array( $data ) && isset( $data['type'] ) ) { + return in_array( $data['type'], $types, true ); + } + + if ( is_object( $data ) && $data instanceof Base_Object ) { + return in_array( $data->get_type(), $types, true ); + } + + return false; +} + +/** + * Check if an `$data` is an Actor. + * + * @see https://www.w3.org/ns/activitystreams#actor + * + * @param array|object|string $data The data to check. + * + * @return boolean True if the `$data` is an Actor, false otherwise. + */ +function is_actor( $data ) { + /** + * Filters the actor types. + * + * @param array $types The actor types. + */ + $types = apply_filters( + 'activitypub_actor_types', + array( + 'Application', + 'Group', + 'Organization', + 'Person', + 'Service', + ) + ); + + if ( is_string( $data ) ) { + return in_array( $data, $types, true ); + } + + if ( is_array( $data ) && isset( $data['type'] ) ) { + return in_array( $data['type'], $types, true ); + } + + if ( is_object( $data ) && $data instanceof Base_Object ) { + return in_array( $data->get_type(), $types, true ); + } + + return false; +} diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php index f3e3d5487..4dd4761fc 100644 --- a/includes/handler/class-follow.php +++ b/includes/handler/class-follow.php @@ -13,6 +13,8 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; +use function Activitypub\add_to_outbox; + /** * Handle Follow requests. */ @@ -28,7 +30,7 @@ public static function init() { \add_action( 'activitypub_followers_post_follow', - array( self::class, 'send_follow_response' ), + array( self::class, 'queue_accept' ), 10, 4 ); @@ -83,7 +85,7 @@ public static function handle_follow( $activity ) { * @param int $user_id The ID of the WordPress User. * @param \Activitypub\Model\Follower $follower The Follower object. */ - public static function send_follow_response( $actor, $activity_object, $user_id, $follower ) { + public static function queue_accept( $actor, $activity_object, $user_id, $follower ) { if ( \is_wp_error( $follower ) ) { // Impossible to send a "Reject" because we can not get the Remote-Inbox. return; @@ -102,21 +104,9 @@ public static function send_follow_response( $actor, $activity_object, $user_id, ) ); - $user = Actors::get_by_id( $user_id ); - - // Get inbox. - $inbox = $follower->get_shared_inbox(); - - // Send "Accept" activity. - $activity = new Activity(); - $activity->set_type( 'Accept' ); - $activity->set_object( $activity_object ); - $activity->set_actor( $user->get_id() ); - $activity->set_to( $actor ); - $activity->set_id( $user->get_id() . '#follow-' . \preg_replace( '~^https?://~', '', $actor ) . '-' . \time() ); - - $activity = $activity->to_json(); + // Send response only to the Follower. + $activity_object['to'] = $actor; - Http::post( $inbox, $activity, $user_id ); + add_to_outbox( $activity_object, 'Accept', $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); } } diff --git a/includes/model/class-follower.php b/includes/model/class-follower.php index bcf5d39dd..477a71886 100644 --- a/includes/model/class-follower.php +++ b/includes/model/class-follower.php @@ -331,11 +331,16 @@ public function get_shared_inbox() { * Convert a Custom-Post-Type input to an Activitypub\Model\Follower. * * @param \WP_Post $post The post object. - * @return \Activitypub\Activity\Base_Object|WP_Error + * @return \Activitypub\Activity\Base_Object|false The Follower object or false on failure. */ public static function init_from_cpt( $post ) { $actor_json = get_post_meta( $post->ID, '_activitypub_actor_json', true ); $object = self::init_from_json( $actor_json ); + + if ( is_wp_error( $object ) ) { + return false; + } + $object->set__id( $post->ID ); $object->set_id( $post->guid ); $object->set_name( $post->post_title ); diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php new file mode 100644 index 000000000..507389ca2 --- /dev/null +++ b/includes/rest/class-outbox-controller.php @@ -0,0 +1,327 @@ +[\w\-\.]+)/outbox'; + + /** + * Register routes. + */ + public function register_routes() { + \register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + 'args' => array( + 'user_id' => array( + 'description' => 'The ID of the user or actor.', + 'type' => 'string', + 'validate_callback' => array( $this, 'validate_user_id' ), + ), + ), + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'args' => array( + 'page' => array( + 'description' => 'Current page of the collection.', + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ), + 'per_page' => array( + 'description' => 'Maximum number of items to be returned in result set.', + 'type' => 'integer', + 'default' => 10, + 'minimum' => 1, + 'maximum' => 100, + ), + ), + ), + 'schema' => array( $this, 'get_collection_schema' ), + ) + ); + } + + /** + * Validates the user_id parameter. + * + * @param mixed $user_id The user_id parameter. + * @return bool|\WP_Error True if the user_id is valid, WP_Error otherwise. + */ + public function validate_user_id( $user_id ) { + $user = Actors::get_by_various( $user_id ); + if ( \is_wp_error( $user ) ) { + return $user; + } + + return true; + } + + /** + * Retrieves a collection of outbox items. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $user_id = $request->get_param( 'user_id' ); + $page = $request->get_param( 'page' ); + $user = Actors::get_by_various( $user_id ); + + /** + * Action triggered prior to the ActivityPub profile being created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_rest_outbox_pre', $request ); + + /** + * Filters the list of activity types to include in the outbox. + * + * @param string[] $activity_types The list of activity types. + */ + $activity_types = apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) ); + + switch ( $user_id ) { + case Actors::APPLICATION_USER_ID: + $actor_type = 'application'; + break; + case Actors::BLOG_USER_ID: + $actor_type = 'blog'; + break; + default: + $actor_type = 'user'; + break; + } + + $args = array( + 'posts_per_page' => $request->get_param( 'per_page' ), + 'author' => $user_id > 0 ? $user_id : null, + 'paged' => $page, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'any', + + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_actor', + 'value' => $actor_type, + ), + ), + ); + + if ( get_current_user_id() !== $user_id && ! current_user_can( 'activitypub' ) ) { + $args['meta_query'][] = array( + 'key' => '_activitypub_activity_type', + 'value' => $activity_types, + 'compare' => 'IN', + ); + + $args['meta_query'][] = array( + 'relation' => 'OR', + array( + 'key' => 'activitypub_content_visibility', + 'compare' => 'NOT EXISTS', + ), + array( + 'key' => 'activitypub_content_visibility', + 'value' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ); + } + + /** + * Filters WP_Query arguments when querying Outbox items via the REST API. + * + * Enables adding extra arguments or setting defaults for an outbox collection request. + * + * @param array $args Array of arguments for WP_Query. + * @param \WP_REST_Request $request The REST API request. + */ + $args = apply_filters( 'rest_activitypub_outbox_query', $args, $request ); + + $outbox_query = new \WP_Query(); + $query_result = $outbox_query->query( $args ); + + $response = array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ), + 'generator' => 'https://wordpress.org/?v=' . \get_bloginfo( 'version' ), + 'actor' => $user->get_id(), + 'type' => 'OrderedCollectionPage', + 'partOf' => get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ), + 'totalItems' => $outbox_query->found_posts, + 'orderedItems' => array(), + ); + + \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); + foreach ( $query_result as $outbox_item ) { + $response['orderedItems'][] = $this->prepare_item_for_response( $outbox_item, $request ); + } + + $max_pages = \ceil( $response['totalItems'] / $request->get_param( 'per_page' ) ); + $response['first'] = \add_query_arg( 'page', 1, $response['partOf'] ); + $response['last'] = \add_query_arg( 'page', \max( $max_pages, 1 ), $response['partOf'] ); + + if ( $max_pages > $page ) { + $response['next'] = \add_query_arg( 'page', $page + 1, $response['partOf'] ); + } + + if ( $page > 1 ) { + $response['prev'] = \add_query_arg( 'page', $page - 1, $response['partOf'] ); + } + + /** + * Filter the ActivityPub outbox array. + * + * @param array $response The ActivityPub outbox array. + * @param \WP_REST_Request $request The request object. + */ + $response = \apply_filters( 'activitypub_rest_outbox_array', $response, $request ); + + /** + * Action triggered after the ActivityPub profile has been created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_outbox_post', $request ); + + $response = \rest_ensure_response( $response ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Prepares the item for the REST response. + * + * @param mixed $item WordPress representation of the item. + * @param \WP_REST_Request $request Request object. + * @return array Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $type = \get_post_meta( $item->ID, '_activitypub_activity_type', true ); + + $activity = new Activity(); + $activity->set_type( $type ); + $activity->set_id( $item->guid ); + // Pre-fill the Activity with data (for example cc and to). + $activity->set_object( \json_decode( $item->post_content, true ) ); + $activity->set_actor( Actors::get_by_various( $request->get_param( 'user_id' ) )->get_id() ); + + return $activity->to_array( false ); + } + + /** + * Retrieves the outbox schema, conforming to JSON Schema. + * + * @return array Collection schema data. + */ + public function get_collection_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'outbox', + 'type' => 'object', + 'properties' => array( + '@context' => array( + 'description' => 'The JSON-LD context for the collection.', + 'type' => array( 'string', 'array', 'object' ), + 'required' => true, + ), + 'id' => array( + 'description' => 'The unique identifier for the collection.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'type' => array( + 'description' => 'The type of the collection.', + 'type' => 'string', + 'enum' => array( 'OrderedCollection', 'OrderedCollectionPage' ), + 'required' => true, + ), + 'actor' => array( + 'description' => 'The actor who owns this outbox.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'totalItems' => array( + 'description' => 'The total number of items in the collection.', + 'type' => 'integer', + 'minimum' => 0, + 'required' => true, + ), + 'orderedItems' => array( + 'description' => 'The items in the collection.', + 'type' => 'array', + 'items' => array( + 'type' => 'object', + ), + 'required' => true, + ), + 'first' => array( + 'description' => 'The first page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + 'last' => array( + 'description' => 'The last page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + 'next' => array( + 'description' => 'The next page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + 'prev' => array( + 'description' => 'The previous page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } +} diff --git a/includes/rest/class-outbox.php b/includes/rest/class-outbox.php deleted file mode 100644 index 22183bd29..000000000 --- a/includes/rest/class-outbox.php +++ /dev/null @@ -1,181 +0,0 @@ -[\w\-\.]+)/outbox', - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( self::class, 'user_outbox_get' ), - 'args' => self::request_parameters(), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), - ), - ) - ); - } - - /** - * Renders the user-outbox - * - * @param \WP_REST_Request $request The request object. - * @return WP_REST_Response|\WP_Error The response object or WP_Error. - */ - public static function user_outbox_get( $request ) { - $user_id = $request->get_param( 'user_id' ); - $user = Actors::get_by_various( $user_id ); - - if ( is_wp_error( $user ) ) { - return $user; - } - - $post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) ); - - $page = $request->get_param( 'page', 1 ); - - /** - * Action triggered prior to the ActivityPub profile being created and sent to the client. - */ - \do_action( 'activitypub_rest_outbox_pre' ); - - $json = new stdClass(); - - // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $json->{'@context'} = get_context(); - $json->id = get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ); - $json->generator = 'http://wordpress.org/?v=' . get_masked_wp_version(); - $json->actor = $user->get_id(); - $json->type = 'OrderedCollectionPage'; - $json->partOf = get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ); - $json->totalItems = 0; - - if ( $user_id > 0 ) { - $count_posts = \count_user_posts( $user_id, $post_types, true ); - $json->totalItems = \intval( $count_posts ); - } else { - foreach ( $post_types as $post_type ) { - $count_posts = \wp_count_posts( $post_type ); - $json->totalItems += \intval( $count_posts->publish ); - } - } - - $json->first = \add_query_arg( 'page', 1, $json->partOf ); - $json->last = \add_query_arg( 'page', \ceil( $json->totalItems / 10 ), $json->partOf ); - - if ( $page && ( ( \ceil( $json->totalItems / 10 ) ) > $page ) ) { - $json->next = \add_query_arg( 'page', $page + 1, $json->partOf ); - } - - if ( $page && ( $page > 1 ) ) { - $json->prev = \add_query_arg( 'page', $page - 1, $json->partOf ); - } - // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - - if ( $page ) { - $posts = \get_posts( - array( - 'posts_per_page' => 10, - 'author' => $user_id > 0 ? $user_id : null, - 'paged' => $page, - 'post_type' => $post_types, - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - 'relation' => 'OR', - array( - 'key' => 'activitypub_content_visibility', - 'compare' => 'NOT EXISTS', - ), - array( - 'key' => 'activitypub_content_visibility', - 'value' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, - 'compare' => '!=', - ), - ), - ) - ); - - foreach ( $posts as $post ) { - $transformer = Factory::get_transformer( $post ); - - if ( \is_wp_error( $transformer ) ) { - continue; - } - - $post = $transformer->to_object(); - $activity = new Activity(); - $activity->set_type( 'Create' ); - $activity->set_object( $post ); - $json->orderedItems[] = $activity->to_array( false ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - } - } - - /** - * Filter the ActivityPub outbox array. - * - * @param array $json The ActivityPub outbox array. - */ - $json = \apply_filters( 'activitypub_rest_outbox_array', $json ); - - /** - * Action triggered after the ActivityPub profile has been created and sent to the client - */ - \do_action( 'activitypub_outbox_post' ); - - $rest_response = new WP_REST_Response( $json, 200 ); - $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); - - return $rest_response; - } - - /** - * The supported parameters. - * - * @return array List of parameters. - */ - public static function request_parameters() { - $params = array(); - - $params['page'] = array( - 'type' => 'integer', - 'default' => 1, - ); - - return $params; - } -} diff --git a/includes/scheduler/class-actor.php b/includes/scheduler/class-actor.php new file mode 100644 index 000000000..006e2af63 --- /dev/null +++ b/includes/scheduler/class-actor.php @@ -0,0 +1,120 @@ +post_type ) { + self::schedule_profile_update( $post->post_author ); + } elseif ( Extra_Fields::BLOG_POST_TYPE === $post->post_type ) { + self::schedule_profile_update( Actors::BLOG_USER_ID ); + } + } + + /** + * Send a profile update to all followers. Gets hooked into all relevant options/meta etc. + * + * @param int $user_id The user ID to update (Could be 0 for Blog-User). + */ + public static function schedule_profile_update( $user_id ) { + $actor = Actors::get_by_id( $user_id ); + + add_to_outbox( $actor, 'Update', $user_id ); + } +} diff --git a/includes/scheduler/class-comment.php b/includes/scheduler/class-comment.php new file mode 100644 index 000000000..10e6015cd --- /dev/null +++ b/includes/scheduler/class-comment.php @@ -0,0 +1,88 @@ +user_id ) { + return; + } + + $type = false; + + if ( + 'approved' === $new_status && + 'approved' !== $old_status + ) { + $type = 'Create'; + } elseif ( 'approved' === $new_status ) { + $type = 'Update'; + \update_comment_meta( $comment->comment_ID, 'activitypub_comment_modified', time(), true ); + } elseif ( + 'trash' === $new_status || + 'spam' === $new_status + ) { + $type = 'Delete'; + } + + if ( empty( $type ) ) { + return; + } + + // Check if comment should be federated or not. + if ( ! should_comment_be_federated( $comment ) ) { + return; + } + + add_to_outbox( $comment, $type, $comment->user_id ); + } + + /** + * Schedule Comment Activities on insert. + * + * @param int $comment_id Comment ID. + * @param \WP_Comment $comment Comment object. + */ + public static function schedule_comment_activity_on_insert( $comment_id, $comment ) { + if ( 1 === (int) $comment->comment_approved ) { + self::schedule_comment_activity( 'approved', '', $comment ); + } + } +} diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php new file mode 100644 index 000000000..943fd7dc8 --- /dev/null +++ b/includes/scheduler/class-post.php @@ -0,0 +1,197 @@ +post_author ); + } + + /** + * Send announces. + * + * @param int $outbox_activity_id The outbox activity ID. + * @param Activity $activity_object The activity object. + * @param int $actor_id The actor ID. + * @param int $content_visibility The content visibility. + */ + public static function schedule_announce_activity( $outbox_activity_id, $activity_object, $actor_id, $content_visibility ) { + // Only if we're in both Blog and User modes. + if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE !== \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) { + return; + } + + // Only if this isn't the Blog Actor. + if ( Actors::BLOG_USER_ID === $actor_id ) { + return; + } + + // Only if the content is public or quiet public. + if ( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC !== $content_visibility ) { + return; + } + + $activity_type = \get_post_meta( $outbox_activity_id, '_activitypub_activity_type', true ); + + // Only if the activity is a Create, Update or Delete. + if ( ! in_array( $activity_type, array( 'Create', 'Update', 'Delete' ), true ) ) { + return; + } + + // Check if the object is an article, image, audio, video, event or document and ignore profile updates and other activities. + if ( ! in_array( $activity_object->get_type(), array( 'Note', 'Article', 'Image', 'Audio', 'Video', 'Event', 'Document' ), true ) ) { + return; + } + + $transformer = Factory::get_transformer( $activity_object ); + if ( ! $transformer || \is_wp_error( $transformer ) ) { + return; + } + + $outbox_activity_id = Outbox::add( $transformer->to_activity( $activity_type ), 'Announce', Actors::BLOG_USER_ID ); + + if ( ! $outbox_activity_id ) { + return; + } + + // Schedule the outbox item for federation. + Scheduler::schedule_outbox_activity_for_federation( $outbox_activity_id ); + } + + /** + * Filter the post data before it is inserted via the REST API. + * + * Posts being inserted via the REST API have a different order of operations than in wp_insert_post(). + * This filter updates post meta before the post is inserted into the database, so that the + * information is available by the time @see Outbox::add() runs. + * + * @param \stdClass $post An object representing a single post prepared for inserting or updating the database. + * @param \WP_REST_Request $request The request object. + * + * @return \stdClass The prepared post. + */ + public static function rest_insert( $post, $request ) { + $metas = $request->get_param( 'meta' ); + + if ( ! $post->ID || ! $metas || ! is_array( $metas ) ) { + return $post; + } + + foreach ( $metas as $meta_key => $meta_value ) { + if ( + \str_starts_with( $meta_key, 'activitypub_' ) || + \str_starts_with( $meta_key, '_activitypub_' ) + ) { + if ( $meta_value ) { + \update_post_meta( $post->ID, $meta_key, $meta_value ); + } else { + \delete_post_meta( $post->ID, $meta_key ); + } + } + } + + return $post; + } +} diff --git a/includes/transformer/class-activity-object.php b/includes/transformer/class-activity-object.php new file mode 100644 index 000000000..efd87b2c7 --- /dev/null +++ b/includes/transformer/class-activity-object.php @@ -0,0 +1,143 @@ +transform_object_properties( $this->item ); + + if ( \is_wp_error( $activity_object ) ) { + return $activity_object; + } + + $activity_object = $this->set_audience( $activity_object ); + + return $activity_object; + } + + /** + * Get the attributed to. + * + * @return string The attributed to. + */ + public function get_attributed_to() { + return $this->item->get_attributed_to(); + } + + /** + * Helper function to get the @-Mentions from the post content. + * + * @return array The list of @-Mentions. + */ + protected function get_mentions() { + /** + * Filter the mentions in the post content. + * + * @param array $mentions The mentions. + * @param string $content The post content. + * @param WP_Post $post The post object. + * + * @return array The filtered mentions. + */ + return apply_filters( + 'activitypub_extract_mentions', + array(), + $this->item->get_content() . ' ' . $this->item->get_summary(), + $this->item + ); + } + + /** + * Returns the content map for the post. + * + * @return array The content map for the post. + */ + protected function get_content_map() { + $content = $this->item->get_content(); + + if ( ! $content ) { + return null; + } + + return array( + $this->get_locale() => $content, + ); + } + + /** + * Returns the name map for the post. + * + * @return array The name map for the post. + */ + protected function get_name_map() { + $name = $this->item->get_name(); + + if ( ! $name ) { + return null; + } + + return array( + $this->get_locale() => $name, + ); + } + + /** + * Returns the summary map for the post. + * + * @return array The summary map for the post. + */ + protected function get_summary_map() { + $summary = $this->item->get_summary(); + + if ( ! $summary ) { + return null; + } + + return array( + $this->get_locale() => $summary, + ); + } + + /** + * Returns a list of Tags, used in the Comment. + * + * This includes Hash-Tags and Mentions. + * + * @return array The list of Tags. + */ + protected function get_tag() { + $tags = $this->item->get_tag(); + + if ( ! $tags ) { + $tags = array(); + } + + $mentions = $this->get_mentions(); + + if ( $mentions ) { + foreach ( $mentions as $mention => $url ) { + $tag = array( + 'type' => 'Mention', + 'href' => \esc_url( $url ), + 'name' => \esc_html( $mention ), + ); + $tags[] = $tag; + } + } + + return \array_unique( $tags, SORT_REGULAR ); + } +} diff --git a/includes/transformer/class-attachment.php b/includes/transformer/class-attachment.php index 98aaf8bf4..65f500ca8 100644 --- a/includes/transformer/class-attachment.php +++ b/includes/transformer/class-attachment.php @@ -24,11 +24,11 @@ class Attachment extends Post { * @return array The Attachments. */ protected function get_attachment() { - $mime_type = get_post_mime_type( $this->wp_object->ID ); - $media_type = preg_replace( '/(\/[a-zA-Z]+)/i', '', $mime_type ); - $type = ''; + $mime_type = \get_post_mime_type( $this->item->ID ); + $mime_type_parts = \explode( '/', $mime_type ); + $type = ''; - switch ( $media_type ) { + switch ( $mime_type_parts[0] ) { case 'audio': case 'video': $type = 'Document'; @@ -40,11 +40,11 @@ protected function get_attachment() { $attachment = array( 'type' => $type, - 'url' => wp_get_attachment_url( $this->wp_object->ID ), + 'url' => wp_get_attachment_url( $this->item->ID ), 'mediaType' => $mime_type, ); - $alt = \get_post_meta( $this->wp_object->ID, '_wp_attachment_image_alt', true ); + $alt = \get_post_meta( $this->item->ID, '_wp_attachment_image_alt', true ); if ( $alt ) { $attachment['name'] = $alt; } diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 9962c7e3d..6a7852c0f 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -11,8 +11,9 @@ use WP_Comment; use Activitypub\Activity\Activity; -use Activitypub\Activity\Base_Object; +use Activitypub\Collection\Actors; use Activitypub\Collection\Replies; +use Activitypub\Activity\Base_Object; /** * WordPress Base Transformer. @@ -26,67 +27,172 @@ abstract class Base { * * This is the source object of the transformer. * + * @var WP_Post|WP_Comment|Base_Object|string|array + */ + protected $item; + + /** + * The WP_Post or WP_Comment object. + * + * @deprecated version 5.0.0 + * * @var WP_Post|WP_Comment */ protected $wp_object; + /** + * The content visibility. + * + * @var string + */ + protected $content_visibility; + /** * Static function to Transform a WordPress Object. * * This helps to chain the output of the Transformer. * - * @param WP_Post|WP_Comment $wp_object The WordPress object. + * @param WP_Post|WP_Comment|Base_Object|string|array $item The item that should be transformed. * * @return Base */ - public static function transform( $wp_object ) { - return new static( $wp_object ); + public static function transform( $item ) { + return new static( $item ); } /** * Base constructor. * - * @param WP_Post|WP_Comment $wp_object The WordPress object. + * @param WP_Post|WP_Comment|Base_Object|string|array $item The item that should be transformed. */ - public function __construct( $wp_object ) { - $this->wp_object = $wp_object; + public function __construct( $item ) { + $this->item = $item; + $this->wp_object = $item; } /** * Transform all properties with available get(ter) functions. * - * @param Base_Object|object $activitypub_object The ActivityPub Object. + * @param Base_Object $activity_object The ActivityPub Object. * - * @return Base_Object|object + * @return Base_Object|\WP_Error The transformed ActivityPub Object. */ - protected function transform_object_properties( $activitypub_object ) { - $vars = $activitypub_object->get_object_var_keys(); + protected function transform_object_properties( $activity_object ) { + if ( ! $activity_object || \is_wp_error( $activity_object ) ) { + return $activity_object; + } + + $vars = $activity_object->get_object_var_keys(); foreach ( $vars as $var ) { $getter = 'get_' . $var; - if ( method_exists( $this, $getter ) ) { - $value = call_user_func( array( $this, $getter ) ); + if ( \method_exists( $this, $getter ) ) { + $value = \call_user_func( array( $this, $getter ) ); if ( isset( $value ) ) { $setter = 'set_' . $var; - call_user_func( array( $activitypub_object, $setter ), $value ); + /** + * Filter the value before it is set to the Activity-Object `$activity_object`. + * + * @param mixed $value The value that should be set. + * @param mixed $item The Object. + */ + $value = \apply_filters( "activitypub_transform_{$setter}", $value, $this->item ); + + /** + * Filter the value before it is set to the Activity-Object `$activity_object`. + * + * @param mixed $value The value that should be set. + * @param string $var The variable name. + * @param mixed $item The Object. + */ + $value = \apply_filters( 'activitypub_transform_set', $value, $var, $this->item ); + + \call_user_func( array( $activity_object, $setter ), $value ); } } } - return $activitypub_object; + + return $activity_object; } /** * Transform the item into an ActivityPub Object. * - * @return Base_Object|object The ActivityPub Object. + * @return Base_Object|object The Activity-Object. */ public function to_object() { - $activitypub_object = new Base_Object(); + $activity_object = new Base_Object(); + $activity_object = $this->transform_object_properties( $activity_object ); + + if ( \is_wp_error( $activity_object ) ) { + return $activity_object; + } + + $activity_object = $this->set_audience( $activity_object ); + + return $activity_object; + } + + /** + * Get the content visibility. + * + * @return string The content visibility. + */ + public function get_content_visibility() { + if ( ! $this->content_visibility ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; + } + + return $this->content_visibility; + } - return $this->transform_object_properties( $activitypub_object ); + /** + * Set the content visibility. + * + * @param string $content_visibility The content visibility. + */ + public function set_content_visibility( $content_visibility ) { + $this->content_visibility = $content_visibility; + + return $this; + } + + /** + * Set the audience. + * + * @param Base_Object $activity_object The ActivityPub Object. + * + * @return Base_Object The ActivityPub Object. + */ + protected function set_audience( $activity_object ) { + $public = 'https://www.w3.org/ns/activitystreams#Public'; + $actor = Actors::get_by_resource( $this->get_attributed_to() ); + if ( ! $actor || is_wp_error( $actor ) ) { + $followers = array(); + } else { + $followers = $actor->get_followers(); + } + $mentions = array_values( $this->get_mentions() ); + + switch ( $this->get_content_visibility() ) { + case ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC: + $activity_object->add_to( $public ); + $activity_object->add_cc( $followers ); + $activity_object->add_cc( $mentions ); + break; + case ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC: + $activity_object->add_to( $followers ); + $activity_object->add_to( $mentions ); + $activity_object->add_cc( $public ); + break; + case ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE: + $activity_object->add_to( $mentions ); + } + + return $activity_object; } /** @@ -115,7 +221,7 @@ public function to_activity( $type ) { $activity->set_object( $object ); // Use simple Object (only ID-URI) for Like and Announce. - if ( in_array( $type, array( 'Like', 'Announce' ), true ) ) { + if ( 'Like' === $type ) { $activity->set_object( $object->get_id() ); } @@ -123,9 +229,32 @@ public function to_activity( $type ) { } /** - * Get the ID of the WordPress Object. + * Returns a generic locale based on the Blog settings. + * + * @return string The locale of the blog. */ - abstract protected function get_id(); + protected function get_locale() { + $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); + + /** + * Filter the locale of the post. + * + * @param string $lang The locale of the post. + * @param mixed $item The post object. + * + * @return string The filtered locale of the post. + */ + return apply_filters( 'activitypub_locale', $lang, $this->item ); + } + + /** + * Returns the default media type for an Object. + * + * @return string The media type. + */ + public function get_media_type() { + return 'text/html'; + } /** * Get the replies Collection. @@ -133,27 +262,113 @@ abstract protected function get_id(); * @return array The replies collection. */ public function get_replies() { - return Replies::get_collection( $this->wp_object ); + return Replies::get_collection( $this->item ); } /** - * Returns the default media type for an Object. + * Returns the content map for the post. * - * @return string The media type. + * @return array|null The content map for the post or null if not set. */ - public function get_media_type() { - return 'text/html'; + protected function get_content_map() { + if ( ! \method_exists( $this, 'get_content' ) || ! $this->get_content() ) { + return null; + } + + return array( + $this->get_locale() => $this->get_content(), + ); } /** - * Returns the ID of the WordPress Object. + * Returns the name map for the post. + * + * @return array|null The name map for the post or null if not set. */ - abstract public function get_wp_user_id(); + protected function get_name_map() { + if ( ! \method_exists( $this, 'get_name' ) || ! $this->get_name() ) { + return null; + } + + return array( + $this->get_locale() => $this->get_name(), + ); + } /** - * Change the User-ID of the WordPress Post. + * Returns the summary map for the post. * - * @param int $user_id The new user ID. + * @return array|null The summary map for the post or null if not set. */ - abstract public function change_wp_user_id( $user_id ); + protected function get_summary_map() { + if ( ! \method_exists( $this, 'get_summary' ) || ! $this->get_summary() ) { + return null; + } + + return array( + $this->get_locale() => $this->get_summary(), + ); + } + + /** + * Returns the tags for the post. + * + * @return array The tags for the post. + */ + protected function get_tag() { + $tags = array(); + $mentions = $this->get_mentions(); + + foreach ( $mentions as $mention => $url ) { + $tags[] = array( + 'type' => 'Mention', + 'href' => \esc_url( $url ), + 'name' => \esc_html( $mention ), + ); + } + + return \array_unique( $tags, SORT_REGULAR ); + } + + /** + * Get the attributed to. + * + * @return string The attributed to. + */ + protected function get_attributed_to() { + return null; + } + + /** + * Extracts mentions from the content. + * + * @return array The mentions. + */ + protected function get_mentions() { + $content = ''; + + if ( method_exists( $this, 'get_content' ) ) { + $content = $content . ' ' . $this->get_content(); + } + + if ( method_exists( $this, 'get_summary' ) ) { + $content = $content . ' ' . $this->get_summary(); + } + + /** + * Filter the mentions in the post content. + * + * @param array $mentions The mentions. + * @param string $content The post content. + * @param WP_Post $post The post object. + * + * @return array The filtered mentions. + */ + return apply_filters( + 'activitypub_extract_mentions', + array(), + $content, + $this->item + ); + } } diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index 66a71afb4..d1ac79a74 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -29,21 +29,69 @@ */ class Comment extends Base { /** - * Returns the User-ID of the WordPress Comment. + * The User as Actor Object. * - * @return int The User-ID of the WordPress Comment + * @var \Activitypub\Activity\Actor */ - public function get_wp_user_id() { - return $this->wp_object->user_id; + private $actor_object = null; + + /** + * Transforms the WP_Comment object to an ActivityPub Object. + * + * @see \Activitypub\Activity\Base_Object + * + * @return \Activitypub\Activity\Base_Object The ActivityPub Object. + */ + public function to_object() { + $comment = $this->item; + $object = parent::to_object(); + + $object->set_url( $this->get_id() ); + $object->set_type( 'Note' ); + + $published = \strtotime( $comment->comment_date_gmt ); + $object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) ); + + $updated = \get_comment_meta( $comment->comment_ID, 'activitypub_comment_modified', true ); + if ( $updated > $published ) { + $object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) ); + } + + $object->set_content_map( + array( + $this->get_locale() => $this->get_content(), + ) + ); + + return $object; } /** - * Change the User-ID of the WordPress Comment. + * Get the content visibility. * - * @param int $user_id The new user ID. + * @return string The content visibility. */ - public function change_wp_user_id( $user_id ) { - $this->wp_object->user_id = $user_id; + public function get_content_visibility() { + if ( $this->content_visibility ) { + return $this->content_visibility; + } + + $comment = $this->item; + $post = \get_post( $comment->comment_post_ID ); + + if ( ! $post ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; + } + + $content_visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); + + if ( ! $content_visibility ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; + } + + $this->content_visibility = $content_visibility; + + return $this->content_visibility; } /** @@ -59,12 +107,7 @@ protected function get_attributed_to() { return $this->wp_object->comment_author_url; } - if ( is_single_user() ) { - $user = new Blog(); - return $user->get_id(); - } - - return Actors::get_by_id( $this->wp_object->user_id )->get_id(); + return $this->get_actor_object()->get_id(); } /** @@ -75,7 +118,7 @@ protected function get_attributed_to() { * @return string The content. */ protected function get_content() { - $comment = $this->wp_object; + $comment = $this->item; $content = $comment->comment_content; $mentions = ''; @@ -118,7 +161,7 @@ protected function get_content() { * @return false|string|null The URL of the in-reply-to. */ protected function get_in_reply_to() { - $comment = $this->wp_object; + $comment = $this->item; $parent_comment = null; if ( $comment->comment_parent ) { @@ -146,53 +189,37 @@ protected function get_in_reply_to() { * @return string ActivityPub URI for comment */ protected function get_id() { - $comment = $this->wp_object; + $comment = $this->item; return Comment_Utils::generate_id( $comment ); } /** - * Returns a list of Mentions, used in the Comment. + * Returns the User-Object of the Author of the Post. * - * @see https://docs.joinmastodon.org/spec/activitypub/#Mention + * If `single_user` mode is enabled, the Blog-User is returned. * - * @return array The list of Mentions. + * @return \Activitypub\Activity\Actor The User-Object. */ - protected function get_cc() { - $cc = array(); + protected function get_actor_object() { + if ( $this->actor_object ) { + return $this->actor_object; + } - $mentions = $this->get_mentions(); - if ( $mentions ) { - foreach ( $mentions as $url ) { - $cc[] = $url; - } + $blog_user = new Blog(); + $this->actor_object = $blog_user; + + if ( is_single_user() ) { + return $blog_user; } - return array_unique( $cc ); - } + $user = Actors::get_by_id( $this->item->user_id ); - /** - * Returns a list of Tags, used in the Comment. - * - * This includes Hash-Tags and Mentions. - * - * @return array The list of Tags. - */ - protected function get_tag() { - $tags = array(); - - $mentions = $this->get_mentions(); - if ( $mentions ) { - foreach ( $mentions as $mention => $url ) { - $tag = array( - 'type' => 'Mention', - 'href' => \esc_url( $url ), - 'name' => \esc_html( $mention ), - ); - $tags[] = $tag; - } + if ( $user && ! is_wp_error( $user ) ) { + $this->actor_object = $user; + return $user; } - return \array_unique( $tags, SORT_REGULAR ); + return $blog_user; } /** @@ -212,7 +239,7 @@ protected function get_mentions() { * * @return array The filtered list of mentions. */ - return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_object->comment_content, $this->wp_object ); + return apply_filters( 'activitypub_extract_mentions', array(), $this->item->comment_content, $this->item ); } /** @@ -221,7 +248,7 @@ protected function get_mentions() { * @return array The list of ancestors. */ protected function get_comment_ancestors() { - $ancestors = get_comment_ancestors( $this->wp_object ); + $ancestors = get_comment_ancestors( $this->item ); // Now that we have the full tree of ancestors, only return the ones received from the fediverse. return array_filter( @@ -241,8 +268,8 @@ function ( $comment_id ) { * @return array The list of all Repliers. */ public function extract_reply_context( $mentions = array() ) { - // Check if `$this->wp_object` is a WP_Comment. - if ( 'WP_Comment' !== get_class( $this->wp_object ) ) { + // Check if `$this->item` is a WP_Comment. + if ( 'WP_Comment' !== get_class( $this->item ) ) { return $mentions; } @@ -265,35 +292,14 @@ public function extract_reply_context( $mentions = array() ) { return $mentions; } - /** - * Returns the locale of the post. - * - * @return string The locale of the post. - */ - public function get_locale() { - $comment_id = $this->wp_object->ID; - $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); - - /** - * Filter the locale of the comment. - * - * @param string $lang The locale of the comment. - * @param int $comment_id The comment ID. - * @param \WP_Post $post The comment object. - * - * @return string The filtered locale of the comment. - */ - return apply_filters( 'activitypub_comment_locale', $lang, $comment_id, $this->wp_object ); - } - /** * Returns the updated date of the comment. * * @return string|null The updated date of the comment. */ public function get_updated() { - $updated = \get_comment_meta( $this->wp_object->comment_ID, 'activitypub_comment_modified', true ); - $published = \get_comment_meta( $this->wp_object->comment_ID, 'activitypub_comment_published', true ); + $updated = \get_comment_meta( $this->item->comment_ID, 'activitypub_comment_modified', true ); + $published = \get_comment_meta( $this->item->comment_ID, 'activitypub_comment_published', true ); if ( $updated > $published ) { return \gmdate( 'Y-m-d\TH:i:s\Z', $updated ); @@ -308,7 +314,7 @@ public function get_updated() { * @return string The published date of the comment. */ public function get_published() { - return \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $this->wp_object->comment_date_gmt ) ); + return \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $this->item->comment_date_gmt ) ); } /** @@ -328,29 +334,4 @@ public function get_url() { public function get_type() { return 'Note'; } - - /** - * Returns the to of the comment. - * - * @return array The to of the comment. - */ - public function get_to() { - $path = sprintf( 'actors/%d/followers', intval( $this->wp_object->comment_author ) ); - - return array( - 'https://www.w3.org/ns/activitystreams#Public', - get_rest_url_by_path( $path ), - ); - } - - /** - * Returns the content map for the comment. - * - * @return array The content map for the comment. - */ - public function get_content_map() { - return array( - $this->get_locale() => $this->get_content(), - ); - } } diff --git a/includes/transformer/class-factory.php b/includes/transformer/class-factory.php index 7b28c8442..946390200 100644 --- a/includes/transformer/class-factory.php +++ b/includes/transformer/class-factory.php @@ -8,10 +8,11 @@ namespace Activitypub\Transformer; use WP_Error; +use Activitypub\Comment as Comment_Helper; use function Activitypub\is_user_disabled; use function Activitypub\is_post_disabled; -use function Activitypub\is_local_comment; + /** * Transformer Factory. */ @@ -24,12 +25,14 @@ class Factory { * @return Base|WP_Error The transformer to use, or an error. */ public static function get_transformer( $data ) { - if ( ! \is_object( $data ) ) { + if ( \is_array( $data ) || \is_string( $data ) ) { + $class = 'json'; + } elseif ( \is_object( $data ) ) { + $class = \get_class( $data ); + } else { return new WP_Error( 'invalid_object', __( 'Invalid object', 'activitypub' ) ); } - $class = \get_class( $data ); - /** * Filter the transformer for a given object. * @@ -82,7 +85,7 @@ public static function get_transformer( $data ) { } break; case 'WP_Comment': - if ( ! is_local_comment( $data ) ) { + if ( Comment_Helper::should_be_federated( $data ) ) { return new Comment( $data ); } break; @@ -91,8 +94,14 @@ public static function get_transformer( $data ) { return new User( $data ); } break; + case 'json': + return new Json( $data ); + } + + if ( $data instanceof \Activitypub\Activity\Base_Object ) { + return new Activity_Object( $data ); } - return null; + return new WP_Error( 'invalid_object', __( 'Invalid object', 'activitypub' ) ); } } diff --git a/includes/transformer/class-json.php b/includes/transformer/class-json.php new file mode 100644 index 000000000..e82b6ede2 --- /dev/null +++ b/includes/transformer/class-json.php @@ -0,0 +1,43 @@ +wp_object->post_author; - } - - /** - * Change the User-ID of the WordPress Post. - * - * @param int $user_id The new user ID. - * - * @return Post The Post Object. - */ - public function change_wp_user_id( $user_id ) { - $this->wp_object->post_author = $user_id; - - return $this; - } - /** * Transforms the WP_Post object to an ActivityPub Object * @@ -70,7 +48,7 @@ public function change_wp_user_id( $user_id ) { * @return \Activitypub\Activity\Base_Object The ActivityPub Object */ public function to_object() { - $post = $this->wp_object; + $post = $this->item; $object = parent::to_object(); $content_warning = get_content_warning( $post ); @@ -80,20 +58,20 @@ public function to_object() { $object->set_summary_map( null ); } - $visibility = get_content_visibility( $post ); + return $object; + } - switch ( $visibility ) { - case ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC: - $object->set_to( $this->get_cc() ); - $object->set_cc( $this->get_to() ); - break; - case ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL: - $object->set_to( array() ); - $object->set_cc( array() ); - break; + /** + * Get the content visibility. + * + * @return string The content visibility. + */ + public function get_content_visibility() { + if ( ! $this->content_visibility ) { + return get_content_visibility( $this->item ); } - return $object; + return $this->content_visibility; } /** @@ -115,7 +93,7 @@ public function get_actor_object() { return $blog_user; } - $user = Actors::get_by_id( $this->wp_object->post_author ); + $user = Actors::get_by_id( $this->item->post_author ); if ( $user && ! is_wp_error( $user ) ) { $this->actor_object = $user; @@ -132,7 +110,7 @@ public function get_actor_object() { */ public function get_id() { $last_legacy_id = (int) \get_option( 'activitypub_last_post_with_permalink_as_id', 0 ); - $post_id = (int) $this->wp_object->ID; + $post_id = (int) $this->item->ID; if ( $post_id > $last_legacy_id ) { // Generate URI based on post ID. @@ -148,7 +126,7 @@ public function get_id() { * @return string The Posts URL. */ public function get_url() { - $post = $this->wp_object; + $post = $this->item; switch ( \get_post_status( $post ) ) { case 'trash': @@ -187,7 +165,7 @@ protected function get_attributed_to() { * @return array|null The Image or null if no image is available. */ protected function get_image() { - $post_id = $this->wp_object->ID; + $post_id = $this->item->ID; // List post thumbnail first if this post has one. if ( @@ -240,7 +218,7 @@ protected function get_image() { * @return array|null The Icon or null if no icon is available. */ protected function get_icon() { - $post_id = $this->wp_object->ID; + $post_id = $this->item->ID; // List post thumbnail first if this post has one. if ( \has_post_thumbnail( $post_id ) ) { @@ -297,7 +275,7 @@ protected function get_icon() { */ protected function get_attachment() { // Remove attachments from drafts. - if ( 'draft' === \get_post_status( $this->wp_object ) ) { + if ( 'draft' === \get_post_status( $this->item ) ) { return array(); } @@ -322,7 +300,7 @@ protected function get_attachment() { 'audio' => array(), 'video' => array(), ); - $id = $this->wp_object->ID; + $id = $this->item->ID; // List post thumbnail first if this post has one. if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) { @@ -331,13 +309,13 @@ protected function get_attachment() { $media = $this->get_enclosures( $media ); - if ( site_supports_blocks() && \has_blocks( $this->wp_object->post_content ) ) { + if ( site_supports_blocks() && \has_blocks( $this->item->post_content ) ) { $media = $this->get_block_attachments( $media, $max_media ); } else { $media = $this->get_classic_editor_images( $media, $max_media ); } - $media = $this->filter_media_by_object_type( $media, \get_post_format( $this->wp_object ), $this->wp_object ); + $media = $this->filter_media_by_object_type( $media, \get_post_format( $this->item ), $this->item ); $unique_ids = \array_unique( \array_column( $media, 'id' ) ); $media = \array_intersect_key( $media, $unique_ids ); $media = \array_slice( $media, 0, $max_media ); @@ -346,11 +324,11 @@ protected function get_attachment() { * Filter the attachment IDs for a post. * * @param array $media The media array grouped by type. - * @param WP_Post $this->wp_object The post object. + * @param WP_Post $this->item The post object. * * @return array The filtered attachment IDs. */ - $media = \apply_filters( 'activitypub_attachment_ids', $media, $this->wp_object ); + $media = \apply_filters( 'activitypub_attachment_ids', $media, $this->item ); $attachments = \array_filter( \array_map( array( $this, 'wp_attachment_to_activity_attachment' ), $media ) ); @@ -358,228 +336,517 @@ protected function get_attachment() { * Filter the attachments for a post. * * @param array $attachments The attachments. - * @param WP_Post $this->wp_object The post object. + * @param WP_Post $this->item The post object. * * @return array The filtered attachments. */ - return \apply_filters( 'activitypub_attachments', $attachments, $this->wp_object ); + return \apply_filters( 'activitypub_attachments', $attachments, $this->item ); } /** - * Get enclosures for a post. + * Returns the ActivityStreams 2.0 Object-Type for a Post based on the + * settings and the Post-Type. * - * @param array $media The media array grouped by type. + * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types * - * @return array The media array extended with enclosures. + * @return string The Object-Type. */ - public function get_enclosures( $media ) { - $enclosures = get_enclosures( $this->wp_object->ID ); + protected function get_type() { + $post_format_setting = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ); - if ( ! $enclosures ) { - return $media; + if ( 'wordpress-post-format' !== $post_format_setting ) { + return \ucfirst( $post_format_setting ); } - foreach ( $enclosures as $enclosure ) { - // Check if URL is an attachment. - $attachment_id = \attachment_url_to_postid( $enclosure['url'] ); + $has_title = \post_type_supports( $this->item->post_type, 'title' ); + $content = \wp_strip_all_tags( $this->item->post_content ); - if ( $attachment_id ) { - $enclosure['id'] = $attachment_id; - $enclosure['url'] = \wp_get_attachment_url( $attachment_id ); - $enclosure['mediaType'] = \get_post_mime_type( $attachment_id ); - } + // Check if the post has a title. + if ( + ! $has_title || + ! $this->item->post_title || + \strlen( $content ) <= ACTIVITYPUB_NOTE_LENGTH + ) { + return 'Note'; + } - $mime_type = $enclosure['mediaType']; - $mime_type_parts = \explode( '/', $mime_type ); - $enclosure['type'] = \ucfirst( $mime_type_parts[0] ); + // Default to Note. + $object_type = 'Note'; + $post_type = \get_post_type( $this->item ); - switch ( $mime_type_parts[0] ) { - case 'image': - $media['image'][] = $enclosure; - break; - case 'audio': - $media['audio'][] = $enclosure; - break; - case 'video': - $media['video'][] = $enclosure; - break; - } + if ( 'page' === $post_type ) { + $object_type = 'Page'; + } elseif ( ! \get_post_format( $this->item ) ) { + $object_type = 'Article'; } - return $media; + return $object_type; } /** - * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments. - * - * @param array $media The media array grouped by type. - * @param int $max_media The maximum number of attachments to return. + * Returns the Audience for the Post. * - * @return array The attachments. + * @return string|null The audience. */ - protected function get_block_attachments( $media, $max_media ) { - // Max media can't be negative or zero. - if ( $max_media <= 0 ) { - return array(); - } + public function get_audience() { + $actor_mode = \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ); - $blocks = \parse_blocks( $this->wp_object->post_content ); + if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE === $actor_mode ) { + $blog = new Blog(); + return $blog->get_id(); + } - return $this->get_media_from_blocks( $blocks, $media ); + return null; } /** - * Recursively get media IDs from blocks. + * Returns a list of Tags, used in the Post. * - * @param array $blocks The blocks to search for media IDs. - * @param array $media The media IDs to append new IDs to. + * This includes Hash-Tags and Mentions. * - * @return array The image IDs. + * @return array The list of Tags. */ - protected function get_media_from_blocks( $blocks, $media ) { - foreach ( $blocks as $block ) { - // Recurse into inner blocks. - if ( ! empty( $block['innerBlocks'] ) ) { - $media = $this->get_media_from_blocks( $block['innerBlocks'], $media ); - } - - switch ( $block['blockName'] ) { - case 'core/image': - case 'core/cover': - if ( ! empty( $block['attrs']['id'] ) ) { - $alt = ''; - $check = preg_match( '//i', $block['innerHTML'], $match ); - - if ( $check ) { - $alt = $match[2]; - } - - $found = false; - foreach ( $media['image'] as $i => $image ) { - if ( $image['id'] === $block['attrs']['id'] ) { - $media['image'][ $i ]['alt'] = $alt; - $found = true; - break; - } - } + protected function get_tag() { + $tags = parent::get_tag(); - if ( ! $found ) { - $media['image'][] = array( - 'id' => $block['attrs']['id'], - 'alt' => $alt, - ); - } - } - break; - case 'core/audio': - if ( ! empty( $block['attrs']['id'] ) ) { - $media['audio'][] = array( 'id' => $block['attrs']['id'] ); - } - break; - case 'core/video': - case 'videopress/video': - if ( ! empty( $block['attrs']['id'] ) ) { - $media['video'][] = array( 'id' => $block['attrs']['id'] ); - } - break; - case 'jetpack/slideshow': - case 'jetpack/tiled-gallery': - if ( ! empty( $block['attrs']['ids'] ) ) { - $media['image'] = array_merge( - $media['image'], - array_map( - function ( $id ) { - return array( 'id' => $id ); - }, - $block['attrs']['ids'] - ) - ); - } - break; - case 'jetpack/image-compare': - if ( ! empty( $block['attrs']['beforeImageId'] ) ) { - $media['image'][] = array( 'id' => $block['attrs']['beforeImageId'] ); - } - if ( ! empty( $block['attrs']['afterImageId'] ) ) { - $media['image'][] = array( 'id' => $block['attrs']['afterImageId'] ); - } - break; + $post_tags = \get_the_tags( $this->item->ID ); + if ( $post_tags ) { + foreach ( $post_tags as $post_tag ) { + $tag = array( + 'type' => 'Hashtag', + 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ), + 'name' => esc_hashtag( $post_tag->name ), + ); + $tags[] = $tag; } } - return $media; + return \array_unique( $tags, SORT_REGULAR ); } /** - * Get post images from the classic editor. - * Note that audio/video attachments are only supported in the block editor. + * Returns the summary for the ActivityPub Item. * - * @param array $media The media array grouped by type. - * @param int $max_images The maximum number of images to return. + * The summary will be generated based on the user settings and only if the + * object type is not set to `note`. * - * @return array The attachments. + * @return string|null The summary or null if the object type is `note`. */ - protected function get_classic_editor_images( $media, $max_images ) { - // Max images can't be negative or zero. - if ( $max_images <= 0 ) { - return array(); + protected function get_summary() { + if ( 'Note' === $this->get_type() ) { + return null; } - if ( \count( $media['image'] ) <= $max_images ) { - if ( \class_exists( '\WP_HTML_Tag_Processor' ) ) { - $media['image'] = \array_merge( $media['image'], $this->get_classic_editor_image_embeds( $max_images ) ); - } else { - $media['image'] = \array_merge( $media['image'], $this->get_classic_editor_image_attachments( $max_images ) ); - } + // Remove Teaser from drafts. + if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { + return \__( '(This post is being modified)', 'activitypub' ); } - return $media; + return generate_post_summary( $this->item ); } /** - * Get image embeds from the classic editor by parsing HTML. + * Returns the title for the ActivityPub Item. * - * @param int $max_images The maximum number of images to return. + * The title will be generated based on the user settings and only if the + * object type is not set to `note`. * - * @return array The attachments. + * @return string|null The title or null if the object type is `note`. */ - protected function get_classic_editor_image_embeds( $max_images ) { - // If someone calls that function directly, bail. - if ( ! \class_exists( '\WP_HTML_Tag_Processor' ) ) { - return array(); + protected function get_name() { + if ( 'Note' === $this->get_type() ) { + return null; } - // Max images can't be negative or zero. - if ( $max_images <= 0 ) { - return array(); + $title = \get_the_title( $this->item->ID ); + + if ( ! $title ) { + return null; } - $images = array(); - $base = get_upload_baseurl(); - $content = \get_post_field( 'post_content', $this->wp_object ); - $tags = new \WP_HTML_Tag_Processor( $content ); + return \wp_strip_all_tags( + \html_entity_decode( + $title + ) + ); + } - // This linter warning is a false positive - we have to re-count each time here as we modify $images. - // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found - while ( $tags->next_tag( 'img' ) && ( \count( $images ) <= $max_images ) ) { - /** - * Filter the image source URL. - * - * This can be used to modify the image source URL before it is used to - * determine the attachment ID. - * - * @param string $src The image source URL. - */ - $src = \apply_filters( 'activitypub_image_src', $tags->get_attribute( 'src' ) ); + /** + * Returns the content for the ActivityPub Item. + * + * The content will be generated based on the user settings. + * + * @return string The content. + */ + protected function get_content() { + add_filter( 'activitypub_reply_block', '__return_empty_string' ); - /* - * If the img source is in our uploads dir, get the - * associated ID. Note: if there's a -500x500 - * type suffix, we remove it, but we try the original - * first in case the original image is actually called - * that. Likewise, we try adding the -scaled suffix for - * the case that this is a small version of an image - * that was big enough to get scaled down on upload: + // Remove Content from drafts. + if ( 'draft' === \get_post_status( $this->item ) ) { + return \__( '(This post is being modified)', 'activitypub' ); + } + + global $post; + + /** + * Provides an action hook so plugins can add their own hooks/filters before AP content is generated. + * + * Example: if a plugin adds a filter to `the_content` to add a button to the end of posts, it can also remove that filter here. + * + * @param WP_Post $post The post object. + */ + do_action( 'activitypub_before_get_content', $post ); + + add_filter( 'render_block_core/embed', array( $this, 'revert_embed_links' ), 10, 2 ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post = $this->item; + $content = $this->get_post_content_template(); + + // It seems that shortcodes are only applied to published posts. + if ( is_preview() ) { + $post->post_status = 'publish'; + } + + // Register our shortcodes just in time. + Shortcodes::register(); + // Fill in the shortcodes. + \setup_postdata( $post ); + $content = \do_shortcode( $content ); + \wp_reset_postdata(); + + $content = \wpautop( $content ); + $content = \preg_replace( '/[\n\r\t]/', '', $content ); + $content = \trim( $content ); + + /** + * Filters the post content before it is transformed for ActivityPub. + * + * @param string $content The post content to be transformed. + * @param WP_Post $post The post object being transformed. + */ + $content = \apply_filters( 'activitypub_the_content', $content, $post ); + + // Don't need these anymore, should never appear in a post. + Shortcodes::unregister(); + + return $content; + } + + /** + * Returns the in-reply-to URL of the post. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto + * + * @return string|null The in-reply-to URL of the post. + */ + protected function get_in_reply_to() { + $blocks = \parse_blocks( $this->item->post_content ); + + foreach ( $blocks as $block ) { + if ( 'activitypub/reply' === $block['blockName'] && isset( $block['attrs']['url'] ) ) { + // We only support one reply block per post for now. + return $block['attrs']['url']; + } + } + + return null; + } + + /** + * Returns the published date of the post. + * + * @return string The published date of the post. + */ + protected function get_published() { + $published = \strtotime( $this->item->post_date_gmt ); + + return \gmdate( 'Y-m-d\TH:i:s\Z', $published ); + } + + /** + * Returns the updated date of the post. + * + * @return string|null The updated date of the post. + */ + protected function get_updated() { + $published = \strtotime( $this->item->post_date_gmt ); + $updated = \strtotime( $this->item->post_modified_gmt ); + + if ( $updated > $published ) { + return \gmdate( 'Y-m-d\TH:i:s\Z', $updated ); + } + + return null; + } + + /** + * Helper function to extract the @-Mentions from the post content. + * + * @return array The list of @-Mentions. + */ + protected function get_mentions() { + /** + * Filter the mentions in the post content. + * + * @param array $mentions The mentions. + * @param string $content The post content. + * @param WP_Post $post The post object. + * + * @return array The filtered mentions. + */ + return apply_filters( + 'activitypub_extract_mentions', + array(), + $this->item->post_content . ' ' . $this->item->post_excerpt, + $this->item + ); + } + + /** + * Transform Embed blocks to block level link. + * + * Remote servers will simply drop iframe elements, rendering incomplete content. + * + * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content + * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML + * + * @param string $block_content The block content (html). + * @param object $block The block object. + * + * @return string A block level link + */ + public function revert_embed_links( $block_content, $block ) { + if ( ! isset( $block['attrs']['url'] ) ) { + return $block_content; + } + return '

' . $block['attrs']['url'] . '

'; + } + + /** + * Check if the post is a preview. + * + * @return boolean True if the post is a preview, false otherwise. + */ + private function is_preview() { + return defined( 'ACTIVITYPUB_PREVIEW' ) && ACTIVITYPUB_PREVIEW; + } + + /** + * Get enclosures for a post. + * + * @param array $media The media array grouped by type. + * + * @return array The media array extended with enclosures. + */ + protected function get_enclosures( $media ) { + $enclosures = get_enclosures( $this->item->ID ); + + if ( ! $enclosures ) { + return $media; + } + + foreach ( $enclosures as $enclosure ) { + // Check if URL is an attachment. + $attachment_id = \attachment_url_to_postid( $enclosure['url'] ); + + if ( $attachment_id ) { + $enclosure['id'] = $attachment_id; + $enclosure['url'] = \wp_get_attachment_url( $attachment_id ); + $enclosure['mediaType'] = \get_post_mime_type( $attachment_id ); + } + + $mime_type = $enclosure['mediaType']; + $mime_type_parts = \explode( '/', $mime_type ); + $enclosure['type'] = \ucfirst( $mime_type_parts[0] ); + + switch ( $mime_type_parts[0] ) { + case 'image': + $media['image'][] = $enclosure; + break; + case 'audio': + $media['audio'][] = $enclosure; + break; + case 'video': + $media['video'][] = $enclosure; + break; + } + } + + return $media; + } + + /** + * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments. + * + * @param array $media The media array grouped by type. + * @param int $max_media The maximum number of attachments to return. + * + * @return array The attachments. + */ + protected function get_block_attachments( $media, $max_media ) { + // Max media can't be negative or zero. + if ( $max_media <= 0 ) { + return array(); + } + + $blocks = \parse_blocks( $this->item->post_content ); + + return $this->get_media_from_blocks( $blocks, $media ); + } + + /** + * Recursively get media IDs from blocks. + * + * @param array $blocks The blocks to search for media IDs. + * @param array $media The media IDs to append new IDs to. + * + * @return array The image IDs. + */ + protected function get_media_from_blocks( $blocks, $media ) { + foreach ( $blocks as $block ) { + // Recurse into inner blocks. + if ( ! empty( $block['innerBlocks'] ) ) { + $media = $this->get_media_from_blocks( $block['innerBlocks'], $media ); + } + + switch ( $block['blockName'] ) { + case 'core/image': + case 'core/cover': + if ( ! empty( $block['attrs']['id'] ) ) { + $alt = ''; + $check = preg_match( '//i', $block['innerHTML'], $match ); + + if ( $check ) { + $alt = $match[2]; + } + + $found = false; + foreach ( $media['image'] as $i => $image ) { + if ( $image['id'] === $block['attrs']['id'] ) { + $media['image'][ $i ]['alt'] = $alt; + $found = true; + break; + } + } + + if ( ! $found ) { + $media['image'][] = array( + 'id' => $block['attrs']['id'], + 'alt' => $alt, + ); + } + } + break; + case 'core/audio': + if ( ! empty( $block['attrs']['id'] ) ) { + $media['audio'][] = array( 'id' => $block['attrs']['id'] ); + } + break; + case 'core/video': + case 'videopress/video': + if ( ! empty( $block['attrs']['id'] ) ) { + $media['video'][] = array( 'id' => $block['attrs']['id'] ); + } + break; + case 'jetpack/slideshow': + case 'jetpack/tiled-gallery': + if ( ! empty( $block['attrs']['ids'] ) ) { + $media['image'] = array_merge( + $media['image'], + array_map( + function ( $id ) { + return array( 'id' => $id ); + }, + $block['attrs']['ids'] + ) + ); + } + break; + case 'jetpack/image-compare': + if ( ! empty( $block['attrs']['beforeImageId'] ) ) { + $media['image'][] = array( 'id' => $block['attrs']['beforeImageId'] ); + } + if ( ! empty( $block['attrs']['afterImageId'] ) ) { + $media['image'][] = array( 'id' => $block['attrs']['afterImageId'] ); + } + break; + } + } + + return $media; + } + + /** + * Get post images from the classic editor. + * Note that audio/video attachments are only supported in the block editor. + * + * @param array $media The media array grouped by type. + * @param int $max_images The maximum number of images to return. + * + * @return array The attachments. + */ + protected function get_classic_editor_images( $media, $max_images ) { + // Max images can't be negative or zero. + if ( $max_images <= 0 ) { + return array(); + } + + if ( \count( $media['image'] ) <= $max_images ) { + if ( \class_exists( '\WP_HTML_Tag_Processor' ) ) { + $media['image'] = \array_merge( $media['image'], $this->get_classic_editor_image_embeds( $max_images ) ); + } else { + $media['image'] = \array_merge( $media['image'], $this->get_classic_editor_image_attachments( $max_images ) ); + } + } + + return $media; + } + + /** + * Get image embeds from the classic editor by parsing HTML. + * + * @param int $max_images The maximum number of images to return. + * + * @return array The attachments. + */ + protected function get_classic_editor_image_embeds( $max_images ) { + // If someone calls that function directly, bail. + if ( ! \class_exists( '\WP_HTML_Tag_Processor' ) ) { + return array(); + } + + // Max images can't be negative or zero. + if ( $max_images <= 0 ) { + return array(); + } + + $images = array(); + $base = get_upload_baseurl(); + $content = \get_post_field( 'post_content', $this->item ); + $tags = new \WP_HTML_Tag_Processor( $content ); + + // This linter warning is a false positive - we have to re-count each time here as we modify $images. + // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found + while ( $tags->next_tag( 'img' ) && ( \count( $images ) <= $max_images ) ) { + /** + * Filter the image source URL. + * + * This can be used to modify the image source URL before it is used to + * determine the attachment ID. + * + * @param string $src The image source URL. + */ + $src = \apply_filters( 'activitypub_image_src', $tags->get_attribute( 'src' ) ); + + /* + * If the img source is in our uploads dir, get the + * associated ID. Note: if there's a -500x500 + * type suffix, we remove it, but we try the original + * first in case the original image is actually called + * that. Likewise, we try adding the -scaled suffix for + * the case that this is a small version of an image + * that was big enough to get scaled down on upload: * https://make.wordpress.org/core/2019/10/09/introducing-handling-of-big-images-in-wordpress-5-3/ */ if ( null !== $src && \str_starts_with( $src, $base ) ) { @@ -634,7 +901,7 @@ protected function get_classic_editor_image_attachments( $max_images ) { $images = array(); $query = new \WP_Query( array( - 'post_parent' => $this->wp_object->ID, + 'post_parent' => $this->item->ID, 'post_status' => 'inherit', 'post_type' => 'attachment', 'post_mime_type' => 'image', @@ -656,22 +923,22 @@ protected function get_classic_editor_image_attachments( $max_images ) { /** * Filter media IDs by object type. * - * @param array $media The media array grouped by type. - * @param string $type The object type. - * @param WP_Post $wp_object The post object. + * @param array $media The media array grouped by type. + * @param string $type The object type. + * @param WP_Post $item The post object. * * @return array The filtered media IDs. */ - protected function filter_media_by_object_type( $media, $type, $wp_object ) { + protected function filter_media_by_object_type( $media, $type, $item ) { /** * Filter the object type for media attachments. * * @param string $type The object type. - * @param WP_Post $wp_object The post object. + * @param WP_Post $item The post object. * * @return string The filtered object type. */ - $type = \apply_filters( 'filter_media_by_object_type', \strtolower( $type ), $wp_object ); + $type = \apply_filters( 'filter_media_by_object_type', \strtolower( $type ), $item ); if ( ! empty( $media[ $type ] ) ) { return $media[ $type ]; @@ -709,330 +976,93 @@ public function wp_attachment_to_activity_attachment( $media ) { * @param string $image_size The image size to retrieve. Set to 'large' by default. */ $thumbnail = apply_filters( - 'activitypub_get_image', - $this->get_wordpress_attachment( $id, $image_size ), - $id, - $image_size - ); - - if ( $thumbnail ) { - $image = array( - 'type' => 'Image', - 'url' => \esc_url( $thumbnail[0] ), - 'mediaType' => \esc_attr( $mime_type ), - ); - - if ( ! empty( $media['alt'] ) ) { - $image['name'] = \wp_strip_all_tags( \html_entity_decode( $media['alt'] ) ); - } else { - $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true ); - if ( $alt ) { - $image['name'] = \wp_strip_all_tags( \html_entity_decode( $alt ) ); - } - } - - $attachment = $image; - } - break; - - case 'audio': - case 'video': - $attachment = array( - 'type' => 'Document', - 'mediaType' => \esc_attr( $mime_type ), - 'url' => \esc_url( \wp_get_attachment_url( $id ) ), - 'name' => \esc_attr( \get_the_title( $id ) ), - ); - $meta = wp_get_attachment_metadata( $id ); - // Height and width for videos. - if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) { - $attachment['width'] = \esc_attr( $meta['width'] ); - $attachment['height'] = \esc_attr( $meta['height'] ); - } - - if ( $this->get_icon() ) { - $attachment['icon'] = object_to_uri( $this->get_icon() ); - } - - break; - } - - /** - * Filter the attachment for a post. - * - * @param array $attachment The attachment. - * @param int $id The attachment ID. - * - * @return array The filtered attachment. - */ - return \apply_filters( 'activitypub_attachment', $attachment, $id ); - } - - /** - * Return details about an image attachment. - * - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'large' by default. - * - * @return array|false Array of image data, or boolean false if no image is available. - */ - protected function get_wordpress_attachment( $id, $image_size = 'large' ) { - /** - * Hook into the image retrieval process. Before image retrieval. - * - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'large' by default. - */ - do_action( 'activitypub_get_image_pre', $id, $image_size ); - - $image = \wp_get_attachment_image_src( $id, $image_size ); - - /** - * Hook into the image retrieval process. After image retrieval. - * - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'large' by default. - */ - do_action( 'activitypub_get_image_post', $id, $image_size ); - - return $image; - } - - /** - * Returns the ActivityStreams 2.0 Object-Type for a Post based on the - * settings and the Post-Type. - * - * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types - * - * @return string The Object-Type. - */ - protected function get_type() { - $post_format_setting = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ); - - if ( 'wordpress-post-format' !== $post_format_setting ) { - return \ucfirst( $post_format_setting ); - } - - $has_title = \post_type_supports( $this->wp_object->post_type, 'title' ); - $content = \wp_strip_all_tags( $this->wp_object->post_content ); - - // Check if the post has a title. - if ( - ! $has_title || - ! $this->wp_object->post_title || - \strlen( $content ) <= ACTIVITYPUB_NOTE_LENGTH - ) { - return 'Note'; - } - - // Default to Note. - $object_type = 'Note'; - $post_type = \get_post_type( $this->wp_object ); - - if ( 'page' === $post_type ) { - $object_type = 'Page'; - } elseif ( ! \get_post_format( $this->wp_object ) ) { - $object_type = 'Article'; - } - - return $object_type; - } - - /** - * Returns the recipient of the post. - * - * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to - * - * @return array The recipient URLs of the post. - */ - public function get_to() { - return array( - 'https://www.w3.org/ns/activitystreams#Public', - ); - } - - /** - * Returns a list of Mentions, used in the Post. - * - * @see https://docs.joinmastodon.org/spec/activitypub/#Mention - * - * @return array The list of Mentions. - */ - protected function get_cc() { - $cc = array( - $this->get_actor_object()->get_followers(), - ); - - $mentions = $this->get_mentions(); - if ( $mentions ) { - foreach ( $mentions as $url ) { - $cc[] = $url; - } - } - - return $cc; - } - - /** - * Returns the Audience for the Post. - * - * @return string|null The audience. - */ - public function get_audience() { - $actor_mode = \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ); - - if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE === $actor_mode ) { - $blog = new Blog(); - return $blog->get_id(); - } - - return null; - } - - /** - * Returns a list of Tags, used in the Post. - * - * This includes Hash-Tags and Mentions. - * - * @return array The list of Tags. - */ - protected function get_tag() { - $tags = array(); - - $post_tags = \get_the_tags( $this->wp_object->ID ); - if ( $post_tags ) { - foreach ( $post_tags as $post_tag ) { - $tag = array( - 'type' => 'Hashtag', - 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ), - 'name' => esc_hashtag( $post_tag->name ), - ); - $tags[] = $tag; - } - } - - $mentions = $this->get_mentions(); - if ( $mentions ) { - foreach ( $mentions as $mention => $url ) { - $tag = array( - 'type' => 'Mention', - 'href' => \esc_url( $url ), - 'name' => \esc_html( $mention ), - ); - $tags[] = $tag; - } - } - - return $tags; - } - - /** - * Returns the summary for the ActivityPub Item. - * - * The summary will be generated based on the user settings and only if the - * object type is not set to `note`. - * - * @return string|null The summary or null if the object type is `note`. - */ - protected function get_summary() { - if ( 'Note' === $this->get_type() ) { - return null; - } + 'activitypub_get_image', + $this->get_wordpress_attachment( $id, $image_size ), + $id, + $image_size + ); - // Remove Teaser from drafts. - if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->wp_object ) ) { - return \__( '(This post is being modified)', 'activitypub' ); - } + if ( $thumbnail ) { + $image = array( + 'type' => 'Image', + 'url' => \esc_url( $thumbnail[0] ), + 'mediaType' => \esc_attr( $mime_type ), + ); - return generate_post_summary( $this->wp_object ); - } + if ( ! empty( $media['alt'] ) ) { + $image['name'] = \wp_strip_all_tags( \html_entity_decode( $media['alt'] ) ); + } else { + $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true ); + if ( $alt ) { + $image['name'] = \wp_strip_all_tags( \html_entity_decode( $alt ) ); + } + } - /** - * Returns the title for the ActivityPub Item. - * - * The title will be generated based on the user settings and only if the - * object type is not set to `note`. - * - * @return string|null The title or null if the object type is `note`. - */ - protected function get_name() { - if ( 'Note' === $this->get_type() ) { - return null; - } + $attachment = $image; + } + break; + + case 'audio': + case 'video': + $attachment = array( + 'type' => 'Document', + 'mediaType' => \esc_attr( $mime_type ), + 'url' => \esc_url( \wp_get_attachment_url( $id ) ), + 'name' => \esc_attr( \get_the_title( $id ) ), + ); + $meta = wp_get_attachment_metadata( $id ); + // Height and width for videos. + if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) { + $attachment['width'] = \esc_attr( $meta['width'] ); + $attachment['height'] = \esc_attr( $meta['height'] ); + } - $title = \get_the_title( $this->wp_object->ID ); + if ( $this->get_icon() ) { + $attachment['icon'] = object_to_uri( $this->get_icon() ); + } - if ( ! $title ) { - return null; + break; } - return \wp_strip_all_tags( - \html_entity_decode( - $title - ) - ); + /** + * Filter the attachment for a post. + * + * @param array $attachment The attachment. + * @param int $id The attachment ID. + * + * @return array The filtered attachment. + */ + return \apply_filters( 'activitypub_attachment', $attachment, $id ); } /** - * Returns the content for the ActivityPub Item. + * Return details about an image attachment. * - * The content will be generated based on the user settings. + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'large' by default. * - * @return string The content. + * @return array|false Array of image data, or boolean false if no image is available. */ - protected function get_content() { - add_filter( 'activitypub_reply_block', '__return_empty_string' ); - - // Remove Content from drafts. - if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->wp_object ) ) { - return \__( '(This post is being modified)', 'activitypub' ); - } - - global $post; - + protected function get_wordpress_attachment( $id, $image_size = 'large' ) { /** - * Provides an action hook so plugins can add their own hooks/filters before AP content is generated. - * - * Example: if a plugin adds a filter to `the_content` to add a button to the end of posts, it can also remove that filter here. + * Hook into the image retrieval process. Before image retrieval. * - * @param WP_Post $post The post object. + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'large' by default. */ - do_action( 'activitypub_before_get_content', $post ); - - add_filter( 'render_block_core/embed', array( $this, 'revert_embed_links' ), 10, 2 ); - - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $post = $this->wp_object; - $content = $this->get_post_content_template(); - - // It seems that shortcodes are only applied to published posts. - if ( is_preview() ) { - $post->post_status = 'publish'; - } - - // Register our shortcodes just in time. - Shortcodes::register(); - // Fill in the shortcodes. - \setup_postdata( $post ); - $content = \do_shortcode( $content ); - \wp_reset_postdata(); + do_action( 'activitypub_get_image_pre', $id, $image_size ); - $content = \wpautop( $content ); - $content = \preg_replace( '/[\n\r\t]/', '', $content ); - $content = \trim( $content ); + $image = \wp_get_attachment_image_src( $id, $image_size ); /** - * Filters the post content before it is transformed for ActivityPub. + * Hook into the image retrieval process. After image retrieval. * - * @param string $content The post content to be transformed. - * @param WP_Post $post The post object being transformed. + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'large' by default. */ - $content = \apply_filters( 'activitypub_the_content', $content, $post ); - - // Don't need these anymore, should never appear in a post. - Shortcodes::unregister(); + do_action( 'activitypub_get_image_post', $id, $image_size ); - return $content; + return $image; } /** @@ -1065,169 +1095,8 @@ protected function get_post_content_template() { * generation. * * @param string $template The template string containing shortcodes. - * @param WP_Post $wp_object The WordPress post object being transformed. - */ - return apply_filters( 'activitypub_object_content_template', $template, $this->wp_object ); - } - - /** - * Helper function to get the @-Mentions from the post content. - * - * @return array The list of @-Mentions. - */ - protected function get_mentions() { - /** - * Filter the mentions in the post content. - * - * @param array $mentions The mentions. - * @param string $content The post content. - * @param WP_Post $post The post object. - * - * @return array The filtered mentions. - */ - return apply_filters( - 'activitypub_extract_mentions', - array(), - $this->wp_object->post_content . ' ' . $this->wp_object->post_excerpt, - $this->wp_object - ); - } - - /** - * Returns the locale of the post. - * - * @return string The locale of the post. - */ - public function get_locale() { - $post_id = $this->wp_object->ID; - $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); - - /** - * Filter the locale of the post. - * - * @param string $lang The locale of the post. - * @param int $post_id The post ID. - * @param WP_Post $post The post object. - * - * @return string The filtered locale of the post. + * @param WP_Post $item The WordPress post object being transformed. */ - return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_object ); - } - - /** - * Returns the in-reply-to URL of the post. - * - * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto - * - * @return string|null The in-reply-to URL of the post. - */ - public function get_in_reply_to() { - $blocks = \parse_blocks( $this->wp_object->post_content ); - - foreach ( $blocks as $block ) { - if ( 'activitypub/reply' === $block['blockName'] && isset( $block['attrs']['url'] ) ) { - // We only support one reply block per post for now. - return $block['attrs']['url']; - } - } - - return null; - } - - /** - * Returns the published date of the post. - * - * @return string The published date of the post. - */ - public function get_published() { - $published = \strtotime( $this->wp_object->post_date_gmt ); - - return \gmdate( 'Y-m-d\TH:i:s\Z', $published ); - } - - /** - * Returns the updated date of the post. - * - * @return string|null The updated date of the post. - */ - public function get_updated() { - $published = \strtotime( $this->wp_object->post_date_gmt ); - $updated = \strtotime( $this->wp_object->post_modified_gmt ); - - if ( $updated > $published ) { - return \gmdate( 'Y-m-d\TH:i:s\Z', $updated ); - } - - return null; - } - - /** - * Returns the content map for the post. - * - * @return array The content map for the post. - */ - public function get_content_map() { - return array( - $this->get_locale() => $this->get_content(), - ); - } - - /** - * Returns the name map for the post. - * - * @return array The name map for the post. - */ - public function get_name_map() { - if ( ! $this->get_name() ) { - return null; - } - - return array( - $this->get_locale() => $this->get_name(), - ); - } - - /** - * Returns the summary map for the post. - * - * @return array The summary map for the post. - */ - public function get_summary_map() { - if ( ! $this->get_summary() ) { - return null; - } - - return array( - $this->get_locale() => $this->get_summary(), - ); - } - - /** - * Transform Embed blocks to block level link. - * - * Remote servers will simply drop iframe elements, rendering incomplete content. - * - * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content - * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML - * - * @param string $block_content The block content (html). - * @param object $block The block object. - * - * @return string A block level link - */ - public function revert_embed_links( $block_content, $block ) { - if ( ! isset( $block['attrs']['url'] ) ) { - return $block_content; - } - return '

' . $block['attrs']['url'] . '

'; - } - - /** - * Check if the post is a preview. - * - * @return boolean True if the post is a preview, false otherwise. - */ - private function is_preview() { - return defined( 'ACTIVITYPUB_PREVIEW' ) && ACTIVITYPUB_PREVIEW; + return apply_filters( 'activitypub_object_content_template', $template, $this->item ); } } diff --git a/includes/transformer/class-user.php b/includes/transformer/class-user.php index 357d9a049..131b61ee2 100644 --- a/includes/transformer/class-user.php +++ b/includes/transformer/class-user.php @@ -14,50 +14,30 @@ */ class User extends Base { /** - * Transforms the WP_User object to an ActivityPub Object + * Transforms the WP_User object to an Actor. * - * @see \Activitypub\Activity\Base_Object + * @see \Activitypub\Activity\Actor * - * @return \Activitypub\Activity\Base_Object The ActivityPub Object + * @return \Activitypub\Activity\Base_Object|\WP_Error The Actor or WP_Error on failure. */ public function to_object() { - $user = $this->wp_object; - $actor = Actors::get_by_id( $user->ID ); + $activity_object = $this->transform_object_properties( Actors::get_by_id( $this->item->ID ) ); - return $actor; - } - - /** - * Get the User ID. - * - * @return int The User ID. - */ - public function get_id() { - // TODO: Will be removed with the new Outbox implementation. - return $this->wp_object->ID; - } + if ( \is_wp_error( $activity_object ) ) { + return $activity_object; + } - /** - * Change the User ID. - * - * @param int $user_id The new user ID. - * - * @return User The User Object. - */ - public function change_wp_user_id( $user_id ) { - // TODO: Will be removed with the new Outbox implementation. - $this->wp_object->ID = $user_id; + $activity_object = $this->set_audience( $activity_object ); - return $this; + return $activity_object; } /** - * Get the WP_User ID. + * Get the Actor ID. * - * @return int The WP_User ID. + * @return string The Actor ID. */ - public function get_wp_user_id() { - // TODO: Will be removed with the new Outbox implementation. - return $this->wp_object->ID; + public function to_id() { + return Actors::get_by_id( $this->item->ID )->get_id(); } } diff --git a/integration/class-enable-mastodon-apps.php b/integration/class-enable-mastodon-apps.php index 6bffa949e..8ba9d7c3e 100644 --- a/integration/class-enable-mastodon-apps.php +++ b/integration/class-enable-mastodon-apps.php @@ -361,9 +361,14 @@ public static function api_status( $status, $post_id ) { * @return Status|null The Mastodon API status object, or null if the post is not found */ private static function api_post_status( $post_id ) { - $post = Factory::get_transformer( get_post( $post_id ) ); + $post = Factory::get_transformer( get_post( $post_id ) ); + if ( is_wp_error( $post ) ) { + return null; + } + $data = $post->to_object()->to_array(); $account = self::api_account_internal( null, get_post_field( 'post_author', $post_id ) ); + return self::activity_to_status( $data, $account, $post_id ); } diff --git a/integration/class-seriously-simple-podcasting.php b/integration/class-seriously-simple-podcasting.php index 167af7e3e..7533db98a 100644 --- a/integration/class-seriously-simple-podcasting.php +++ b/integration/class-seriously-simple-podcasting.php @@ -29,7 +29,7 @@ class Seriously_Simple_Podcasting extends Post { * @return array The attachments array. */ public function get_attachment() { - $post = $this->wp_object; + $post = $this->item; $attachment = array( 'type' => \esc_attr( ucfirst( \get_post_meta( $post->ID, 'episode_type', true ) ?? 'Audio' ) ), 'url' => \esc_url( \get_post_meta( $post->ID, 'audio_file', true ) ), @@ -67,6 +67,6 @@ public function get_type() { * @return string The content. */ public function get_content() { - return generate_post_summary( $this->wp_object ); + return generate_post_summary( $this->item ); } } diff --git a/readme.txt b/readme.txt index 3a11ea7ac..d2bcbc023 100644 --- a/readme.txt +++ b/readme.txt @@ -133,6 +133,8 @@ For reasons of data protection, it is not possible to see the followers of other = Unreleased = +* Added: Outbox queue +* Changed: Rewrite the current dispatcher system, to use the Outbox instead of a Scheduler. * Changed: Improved content negotiation and AUTHORIZED_FETCH support for third-party plugins * Fixed: Handle deletes from remote servers that leave behind an accessible Tombstone object. * Fixed: No longer parses tags for post types that don't support Activitypub. diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ec1919333..99d1685c5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -98,6 +98,7 @@ function http_disable_request( $response, $args, $url ) { // Start up the WP testing environment. require $_tests_dir . '/includes/bootstrap.php'; +require __DIR__ . '/class-activitypub-outbox-testcase.php'; require __DIR__ . '/class-activitypub-testcase-cache-http.php'; require __DIR__ . '/class-test-rest-controller-testcase.php'; diff --git a/tests/class-activitypub-outbox-testcase.php b/tests/class-activitypub-outbox-testcase.php new file mode 100644 index 000000000..9f30eb272 --- /dev/null +++ b/tests/class-activitypub-outbox-testcase.php @@ -0,0 +1,77 @@ +user->create( array( 'role' => 'author' ) ); + + // Add activitypub capability to the user. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + + \add_filter( 'pre_schedule_event', '__return_false' ); + } + + /** + * Clean up test resources. + */ + public static function tear_down_after_class() { + \delete_option( 'activitypub_actor_mode' ); + \wp_delete_user( self::$user_id ); + \remove_filter( 'pre_schedule_event', '__return_false' ); + + parent::tear_down_after_class(); + } + + /** + * Tear down. + */ + public function tear_down() { + parent::tear_down(); + + _delete_all_posts(); + } + + /** + * Retrieve the latest Outbox item to compare against. + * + * @param string $title Title of the Outbox item. + * @return int|\WP_Post|null + */ + protected function get_latest_outbox_item( $title = '' ) { + $outbox = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'pending', + 'post_title' => $title, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + return $outbox ? $outbox[0] : null; + } +} diff --git a/tests/includes/activity/class-test-activity.php b/tests/includes/activity/class-test-activity.php index c8b894aee..52ab1de1f 100644 --- a/tests/includes/activity/class-test-activity.php +++ b/tests/includes/activity/class-test-activity.php @@ -86,7 +86,7 @@ public function test_activity_object() { $activity = Activity::init_from_array( $test_array ); - $this->assertEquals( 'Hello world!', $activity->get_object()->get_content() ); + $this->assertEquals( 'Hello world!', $activity->get_object()['content'] ); Assert::assertArraySubset( $test_array, $activity->to_array() ); } diff --git a/tests/includes/activity/class-test-base-object.php b/tests/includes/activity/class-test-base-object.php new file mode 100644 index 000000000..f8da047bc --- /dev/null +++ b/tests/includes/activity/class-test-base-object.php @@ -0,0 +1,73 @@ +set_id( 'https://example.com/test' ); + + $this->assertEquals( 'https://example.com/test', $base_object->to_string() ); + } + + /** + * Test the magic add method. + * + * @covers ::add_* Magic function. + * + * @dataProvider data_magic_add + * + * @param array $value The value to add. + * @param array $expected The expected value. + */ + public function test_magic_add( $value, $expected ) { + $base_object = new Base_Object(); + $base_object->add_to( $value ); + + $this->assertEquals( $expected, $base_object->get_to() ); + } + + /** + * Data provider for the magic add method. + * + * @return array The data provider. + */ + public function data_magic_add() { + return array( + array( 'value', array( 'value' ) ), + array( array( 'value' ), array( 'value' ) ), + array( array( 'value', 'value2' ), array( 'value', 'value2' ) ), + array( array( 'value', 'value' ), array( 'value' ) ), + ); + } + + /** + * Test init_from_json method. + * + * @covers ::init_from_json + */ + public function test_init_from_json() { + $invalid_json = '{"@context":https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/2","type":"Note","content":"\u003Cp\u003EThis is another note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is another note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":[],"mediaType":"text\/html","sensitive":false}'; + $base_object = Base_Object::init_from_json( $invalid_json ); + + $this->assertInstanceOf( 'WP_Error', $base_object ); + } +} diff --git a/tests/includes/class-test-activity-dispatcher.php b/tests/includes/class-test-activity-dispatcher.php deleted file mode 100644 index 09a3628b3..000000000 --- a/tests/includes/class-test-activity-dispatcher.php +++ /dev/null @@ -1,364 +0,0 @@ - array( - 'id' => 'https://example.org/users/username', - 'url' => 'https://example.org/users/username', - 'inbox' => 'https://example.org/users/username/inbox', - 'name' => 'username', - 'preferredUsername' => 'username', - ), - 'jon@example.com' => array( - 'id' => 'https://example.com/author/jon', - 'url' => 'https://example.com/author/jon', - 'inbox' => 'https://example.com/author/jon/inbox', - 'name' => 'jon', - 'preferredUsername' => 'jon', - ), - ); - - /** - * Set up the test case. - */ - public function set_up() { - parent::set_up(); - add_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ), 10, 2 ); - _delete_all_posts(); - } - - /** - * Tear down the test case. - */ - public function tear_down() { - remove_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ) ); - parent::tear_down(); - } - - /** - * Test dispatch activity. - * - * @covers ::send_activity - */ - public function test_dispatch_activity() { - $followers = array( 'https://example.com/author/jon', 'https://example.org/users/username' ); - - foreach ( $followers as $follower ) { - \Activitypub\Collection\Followers::add_follower( 1, $follower ); - } - - $post = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'hello', - ) - ); - - $pre_http_request = new \MockAction(); - add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 ); - - Activity_Dispatcher::send_activity( get_post( $post ), 'Create' ); - - $this->assertSame( 2, $pre_http_request->get_call_count() ); - $all_args = $pre_http_request->get_args(); - $first_call_args = array_shift( $all_args ); - - $this->assertEquals( 'https://example.com/author/jon/inbox', $first_call_args[2] ); - - $second_call_args = array_shift( $all_args ); - $this->assertEquals( 'https://example.org/users/username/inbox', $second_call_args[2] ); - - $json = json_decode( $second_call_args[1]['body'] ); - $this->assertEquals( 'Create', $json->type ); - $this->assertEquals( 'http://example.org/?author=1', $json->actor ); - $this->assertEquals( 'http://example.org/?author=1', $json->object->attributedTo ); - - remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 ); - } - - /** - * Test dispatch mentions. - * - * @covers ::send_activity - */ - public function test_dispatch_mentions() { - $post = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => '@alex hello', - ) - ); - - self::$actors['https://example.com/alex'] = array( - 'id' => 'https://example.com/alex', - 'url' => 'https://example.com/alex', - 'inbox' => 'https://example.com/alex/inbox', - 'name' => 'alex', - ); - - add_filter( - 'activitypub_extract_mentions', - function ( $mentions ) { - $mentions[] = 'https://example.com/alex'; - return $mentions; - } - ); - - $pre_http_request = new \MockAction(); - add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 ); - - Activity_Dispatcher::send_activity( get_post( $post ), 'Create' ); - - $this->assertSame( 1, $pre_http_request->get_call_count() ); - $all_args = $pre_http_request->get_args(); - $first_call_args = $all_args[0]; - $this->assertEquals( 'https://example.com/alex/inbox', $first_call_args[2] ); - - $body = json_decode( $first_call_args[1]['body'], true ); - $this->assertArrayHasKey( 'id', $body ); - - remove_all_filters( 'activitypub_from_post_object' ); - remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 ); - } - - /** - * Test dispatch mentions. - * - * @covers ::send_activity_or_announce - */ - public function test_dispatch_announce() { - add_filter( 'activitypub_is_user_type_disabled', '__return_false' ); - - $followers = array( 'https://example.com/author/jon' ); - - foreach ( $followers as $follower ) { - \Activitypub\Collection\Followers::add_follower( \Activitypub\Collection\Actors::BLOG_USER_ID, $follower ); - } - - $post = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'hello', - ) - ); - - $pre_http_request = new \MockAction(); - add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 ); - - Activity_Dispatcher::send_activity_or_announce( get_post( $post ), 'Create' ); - - $all_args = $pre_http_request->get_args(); - $first_call_args = $all_args[0]; - - $this->assertSame( 1, $pre_http_request->get_call_count() ); - - $user = new \Activitypub\Model\Blog(); - - $json = json_decode( $first_call_args[1]['body'] ); - $this->assertEquals( 'Announce', $json->type ); - $this->assertEquals( $user->get_id(), $json->actor ); - - remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 ); - } - - /** - * Test dispatch blog activity. - * - * @covers ::send_activity_or_announce - */ - public function test_dispatch_blog_activity() { - $followers = array( 'https://example.com/author/jon' ); - - add_filter( - 'activitypub_is_user_type_disabled', - function ( $value, $type ) { - if ( 'blog' === $type ) { - return false; - } else { - return true; - } - }, - 10, - 2 - ); - - $this->assertTrue( \Activitypub\is_single_user() ); - - foreach ( $followers as $follower ) { - \Activitypub\Collection\Followers::add_follower( \Activitypub\Collection\Actors::BLOG_USER_ID, $follower ); - } - - $post = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'hello', - ) - ); - - $pre_http_request = new \MockAction(); - add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 ); - - Activity_Dispatcher::send_activity_or_announce( get_post( $post ), 'Create' ); - - $all_args = $pre_http_request->get_args(); - $first_call_args = $all_args[0]; - - $this->assertSame( 1, $pre_http_request->get_call_count() ); - - $user = new \Activitypub\Model\Blog(); - - $json = json_decode( $first_call_args[1]['body'] ); - $this->assertEquals( 'Create', $json->type ); - $this->assertEquals( $user->get_id(), $json->actor ); - $this->assertEquals( $user->get_id(), $json->object->attributedTo ); - - remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 ); - } - - /** - * Test dispatch fallback activity. - * - * @covers ::send_activity - */ - public function test_dispatch_fallback_activity() { - $followers = array( 'https://example.com/author/jon' ); - - add_filter( 'activitypub_is_user_type_disabled', '__return_false' ); - - add_filter( - 'activitypub_is_user_disabled', - function ( $disabled, $user_id ) { - if ( 1 === (int) $user_id ) { - return true; - } - - return false; - }, - 10, - 2 - ); - - $this->assertFalse( \Activitypub\is_single_user() ); - - foreach ( $followers as $follower ) { - \Activitypub\Collection\Followers::add_follower( \Activitypub\Collection\Actors::BLOG_USER_ID, $follower ); - \Activitypub\Collection\Followers::add_follower( 1, $follower ); - } - - $post = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'hello', - ) - ); - - $pre_http_request = new \MockAction(); - add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 ); - - Activity_Dispatcher::send_activity( get_post( $post ), 'Create' ); - - $all_args = $pre_http_request->get_args(); - $first_call_args = $all_args[0]; - - $this->assertSame( 1, $pre_http_request->get_call_count() ); - - $user = new \Activitypub\Model\Blog(); - - $json = json_decode( $first_call_args[1]['body'] ); - $this->assertEquals( 'Create', $json->type ); - $this->assertEquals( $user->get_id(), $json->actor ); - $this->assertEquals( $user->get_id(), $json->object->attributedTo ); - - remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 ); - } - - /** - * Filters remote metadata by actor. - * - * @param array|bool $pre The metadata for the given URL. - * @param string $actor The URL of the actor. - * @return array|bool - */ - public static function pre_get_remote_metadata_by_actor( $pre, $actor ) { - if ( isset( self::$actors[ $actor ] ) ) { - return self::$actors[ $actor ]; - } - foreach ( self::$actors as $data ) { - if ( $data['url'] === $actor ) { - return $data; - } - } - return $pre; - } - - /** - * Filters the arguments used in an HTTP request. - * - * @param array $args The arguments for the HTTP request. - * @param string $url The request URL. - * @return array - */ - public static function http_request_args( $args, $url ) { - if ( in_array( wp_parse_url( $url, PHP_URL_HOST ), array( 'example.com', 'example.org' ), true ) ) { - $args['reject_unsafe_urls'] = false; - } - return $args; - } - - /** - * Filters the return value of an HTTP request. - * - * @param bool $preempt Whether to preempt an HTTP request's return value. - * @param array $request { - * Array of HTTP request arguments. - * - * @type string $method Request method. - * @type string $body Request body. - * } - * @param string $url The request URL. - * @return array Array containing 'headers', 'body', 'response'. - */ - public static function pre_http_request( $preempt, $request, $url ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - return array( - 'headers' => array( - 'content-type' => 'text/json', - ), - 'body' => '', - 'response' => array( - 'code' => 202, - ), - ); - } - - /** - * Filters the return value of an HTTP request. - * - * @param array $response Response array. - * @param array $args Request arguments. - * @param string $url Request URL. - * @return array - */ - public static function http_response( $response, $args, $url ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - return $response; - } -} diff --git a/tests/includes/class-test-activitypub.php b/tests/includes/class-test-activitypub.php index bf25b155c..3b69ed741 100644 --- a/tests/includes/class-test-activitypub.php +++ b/tests/includes/class-test-activitypub.php @@ -7,6 +7,8 @@ namespace Activitypub\Tests; +use Activitypub\Collection\Outbox; + /** * Test class for Activitypub. * @@ -14,6 +16,14 @@ */ class Test_Activitypub extends \WP_UnitTestCase { + /** + * Set up test environment. + */ + public function setUp(): void { + parent::setUp(); + \Activitypub\Activitypub::init(); + } + /** * Test post type support. * @@ -34,7 +44,11 @@ public function test_post_type_support() { */ public function test_preview_template_filter() { // Create a test post. - $post_id = self::factory()->post->create(); + $post_id = self::factory()->post->create( + array( + 'post_author' => 1, + ) + ); $this->go_to( get_permalink( $post_id ) ); // Simulate ActivityPub request and preview mode. @@ -55,5 +69,50 @@ function () { // Clean up. unset( $_SERVER['HTTP_ACCEPT'] ); + wp_delete_post( $post_id, true ); + } + + /** + * Test activity type meta sanitization. + * + * @dataProvider activity_meta_sanitization_provider + * @covers ::register_post_types + * + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param mixed $expected Expected value for invalid meta value. + */ + public function test_activity_meta_sanitization( $meta_key, $meta_value, $expected ) { + $post_id = self::factory()->post->create( + array( + 'post_type' => Outbox::POST_TYPE, + 'meta_input' => array( $meta_key => $meta_value ), + ) + ); + + $this->assertEquals( $meta_value, \get_post_meta( $post_id, $meta_key, true ) ); + + wp_update_post( + array( + 'ID' => $post_id, + 'meta_input' => array( $meta_key => 'InvalidType' ), + ) + ); + $this->assertEquals( $expected, \get_post_meta( $post_id, $meta_key, true ) ); + + wp_delete_post( $post_id, true ); + } + + /** + * Data provider for test_activity_meta_sanitization. + * + * @return array + */ + public function activity_meta_sanitization_provider() { + return array( + array( '_activitypub_activity_type', 'Create', 'Announce' ), + array( '_activitypub_activity_actor', 'user', 'user' ), + array( '_activitypub_activity_actor', 'blog', 'user' ), + ); } } diff --git a/tests/includes/class-test-comment.php b/tests/includes/class-test-comment.php index 1ef7b33c5..4dfd0f64a 100644 --- a/tests/includes/class-test-comment.php +++ b/tests/includes/class-test-comment.php @@ -170,6 +170,7 @@ public function test_pre_comment_approved() { 'post_title' => 'Test Post', 'post_content' => 'This is a test post.', 'post_status' => 'publish', + 'post_author' => 1, ) ); @@ -240,7 +241,11 @@ public function test_pre_comment_approved() { * @covers ::pre_wp_update_comment_count_now */ public function test_pre_wp_update_comment_count_now() { - $post_id = self::factory()->post->create(); + $post_id = self::factory()->post->create( + array( + 'post_author' => 1, + ) + ); // Case 1: $new is null, no approved comments of non-ActivityPub types. $this->assertSame( 0, Comment::pre_wp_update_comment_count_now( null, 0, $post_id ) ); @@ -293,6 +298,9 @@ public function ability_to_federate_comment() { 'comment_content' => 'This is a sent comment.', 'comment_author_url' => 'https://example.com', 'comment_author_email' => '', + 'comment_meta' => array( + 'activitypub_status' => 'pending', + ), ), 'expected' => array( 'was_sent' => true, @@ -362,6 +370,9 @@ public function ability_to_federate_threaded_comment() { 'comment_content' => 'This is another comment.', 'comment_author_url' => 'https://example.com', 'comment_author_email' => '', + 'comment_meta' => array( + 'activitypub_status' => 'pending', + ), ), 'expected' => array( 'was_sent' => false, @@ -386,6 +397,9 @@ public function ability_to_federate_threaded_comment() { 'comment_content' => 'This is yet another comment.', 'comment_author_url' => 'https://example.com', 'comment_author_email' => '', + 'comment_meta' => array( + 'activitypub_status' => 'pending', + ), ), 'expected' => array( 'was_sent' => true, @@ -440,6 +454,9 @@ public function ability_to_federate_threaded_comment() { 'comment_content' => 'This is a parent comment that should not be possible.', 'comment_author_url' => 'https://example.com', 'comment_author_email' => '', + 'comment_meta' => array( + 'activitypub_status' => 'federated', + ), ), 'comment' => array( 'comment_type' => 'comment', diff --git a/tests/includes/class-test-dispatcher.php b/tests/includes/class-test-dispatcher.php new file mode 100644 index 000000000..4eeee4225 --- /dev/null +++ b/tests/includes/class-test-dispatcher.php @@ -0,0 +1,120 @@ +createMock( Activity::class ); + + $result = Dispatcher::maybe_add_inboxes_of_blog_user( $inboxes, 1, $activity ); + $this->assertEquals( $inboxes, $result ); + } + + /** + * Test maybe_add_inboxes_of_blog_user when actor is blog user + * + * @covers ::maybe_add_inboxes_of_blog_user + */ + public function test_maybe_add_inboxes_of_blog_user_is_blog_user() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + + $inboxes = array( 'https://example.com/inbox' ); + $activity = $this->createMock( Activity::class ); + + $result = Dispatcher::maybe_add_inboxes_of_blog_user( $inboxes, Actors::BLOG_USER_ID, $activity ); + $this->assertEquals( $inboxes, $result ); + } + + /** + * Test maybe_add_inboxes_of_blog_user when activity type is not Update + * + * @covers ::maybe_add_inboxes_of_blog_user + */ + public function test_maybe_add_inboxes_of_blog_user_not_update() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + + $inboxes = array( 'https://example.com/inbox' ); + $activity = $this->createMock( Activity::class, array( '__call' ) ); + + // Mock the static method using reflection. + $activity->expects( $this->any() ) + ->method( '__call' ) + ->willReturnCallback( + function ( $name ) { + if ( 'get_to' === $name ) { + return array( 'https://www.w3.org/ns/activitystreams#Public' ); + } + + if ( 'get_cc' === $name ) { + return array(); + } + + if ( 'get_type' === $name ) { + return 'Create'; + } + + return null; + } + ); + + $result = Dispatcher::maybe_add_inboxes_of_blog_user( $inboxes, 1, $activity ); + $this->assertEquals( $inboxes, $result ); + } + + /** + * Test process_outbox. + * + * @covers ::process_outbox + */ + public function test_process_outbox() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); + + $test_callback = function ( $send, $activity ) { + $this->assertInstanceOf( Activity::class, $activity ); + $this->assertEquals( 'Create', $activity->get_type() ); + + return $send; + }; + add_filter( 'activitypub_send_activity_to_followers', $test_callback, 10, 2 ); + + $outbox_item = $this->get_latest_outbox_item( \add_query_arg( 'p', $post_id, \home_url( '/' ) ) ); + + Dispatcher::process_outbox( $outbox_item->ID ); + + // Check that the outbox item is now published. + $outbox_item = \get_post( $outbox_item->ID ); + $this->assertEquals( 'publish', $outbox_item->post_status ); + + remove_filter( 'activitypub_send_activity_to_followers', $test_callback ); + } +} diff --git a/tests/includes/class-test-functions.php b/tests/includes/class-test-functions.php index 480ade24f..4069fbd88 100644 --- a/tests/includes/class-test-functions.php +++ b/tests/includes/class-test-functions.php @@ -216,4 +216,87 @@ public function object_to_uri_provider() { ), ); } + + /** + * Test is_activity with array input. + * + * @covers ::is_activity + * + * @dataProvider is_activity_data + * + * @param mixed $activity The activity object. + * @param bool $expected The expected result. + */ + public function test_is_activity( $activity, $expected ) { + $this->assertEquals( $expected, \Activitypub\is_activity( $activity ) ); + } + + /** + * Data provider for test_is_activity. + * + * @return array[] + */ + public function is_activity_data() { + // Test Activity object. + $create = new \Activitypub\Activity\Activity(); + $create->set_type( 'Create' ); + + // Test Base_Object. + $note = new \Activitypub\Activity\Base_Object(); + $note->set_type( 'Note' ); + + return array( + array( array( 'type' => 'Create' ), true ), + array( array( 'type' => 'Update' ), true ), + array( array( 'type' => 'Delete' ), true ), + array( array( 'type' => 'Follow' ), true ), + array( array( 'type' => 'Accept' ), true ), + array( array( 'type' => 'Reject' ), true ), + array( array( 'type' => 'Add' ), true ), + array( array( 'type' => 'Remove' ), true ), + array( array( 'type' => 'Like' ), true ), + array( array( 'type' => 'Announce' ), true ), + array( array( 'type' => 'Undo' ), true ), + array( array( 'type' => 'Note' ), false ), + array( array( 'type' => 'Article' ), false ), + array( array( 'type' => 'Person' ), false ), + array( array( 'type' => 'Image' ), false ), + array( array( 'type' => 'Video' ), false ), + array( array( 'type' => 'Audio' ), false ), + array( array( 'type' => '' ), false ), + array( array( 'type' => null ), false ), + array( array(), false ), + array( $create, true ), + array( $note, false ), + array( 'string', false ), + array( 123, false ), + array( true, false ), + array( false, false ), + array( null, false ), + array( new \stdClass(), false ), + ); + } + + /** + * Test is_activity with invalid input. + * + * @covers ::is_activity + */ + public function test_is_activity_with_invalid_input() { + $invalid_inputs = array( + 'string', + 123, + true, + false, + null, + new \stdClass(), + ); + + foreach ( $invalid_inputs as $input ) { + $this->assertFalse( + \Activitypub\is_activity( $input ), + sprintf( 'Input of type %s should be invalid', gettype( $input ) ) + ); + } + } } diff --git a/tests/includes/class-test-hashtag.php b/tests/includes/class-test-hashtag.php index 4bf9503c4..5a7efd7d4 100644 --- a/tests/includes/class-test-hashtag.php +++ b/tests/includes/class-test-hashtag.php @@ -95,6 +95,7 @@ public function test_hashtag_conversion( $content, $excerpt, $expected_tags, $me array( 'post_content' => $content, 'post_excerpt' => $excerpt, + 'post_author' => 1, ) ); diff --git a/tests/includes/class-test-migration.php b/tests/includes/class-test-migration.php index 07d622f90..c25b52649 100644 --- a/tests/includes/class-test-migration.php +++ b/tests/includes/class-test-migration.php @@ -7,6 +7,7 @@ namespace Activitypub\Tests; +use Activitypub\Collection\Outbox; use Activitypub\Migration; use Activitypub\Comment; @@ -17,6 +18,90 @@ */ class Test_Migration extends ActivityPub_TestCase_Cache_HTTP { + /** + * Test fixture. + * + * @var array + */ + public static $fixtures = array(); + + /** + * Set up the test. + */ + public static function set_up_before_class() { + \remove_action( 'transition_post_status', array( \Activitypub\Scheduler\Post::class, 'schedule_post_activity' ), 33 ); + \remove_action( 'transition_comment_status', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity' ), 20 ); + \remove_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ) ); + + // Create test posts. + self::$fixtures['posts'] = self::factory()->post->create_many( + 3, + array( + 'post_author' => 1, + 'meta_input' => array( 'activitypub_status' => 'federated' ), + ) + ); + + $modified_post_id = self::factory()->post->create( + array( + 'post_author' => 1, + 'post_content' => 'Test post 2', + 'post_status' => 'publish', + 'post_type' => 'post', + 'post_date' => '2020-01-01 00:00:00', + 'meta_input' => array( 'activitypub_status' => 'federated' ), + ) + ); + self::factory()->post->update_object( $modified_post_id, array( 'post_content' => 'Test post 2 updated' ) ); + + self::$fixtures['posts'][] = $modified_post_id; + self::$fixtures['posts'][] = self::factory()->post->create( + array( + 'post_author' => 1, + 'post_content' => 'Test post 3', + 'post_status' => 'publish', + 'post_type' => 'page', + ) + ); + self::$fixtures['posts'][] = self::factory()->post->create( + array( + 'post_author' => 1, + 'post_content' => 'Test post 4', + 'post_status' => 'publish', + 'post_type' => 'post', + 'meta_input' => array( + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + ), + ) + ); + + // Create test comment. + self::$fixtures['comment'] = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$fixtures['posts'][0], + 'user_id' => 1, + 'comment_content' => 'Test comment', + 'comment_approved' => '1', + ) + ); + \add_comment_meta( self::$fixtures['comment'], 'activitypub_status', 'federated' ); + } + + /** + * Tear down the test. + */ + public static function tear_down_after_class() { + // Clean up posts. + foreach ( self::$fixtures['posts'] as $post_id ) { + \wp_delete_post( $post_id, true ); + } + + // Clean up comment. + if ( isset( self::$fixtures['comment'] ) ) { + \wp_delete_comment( self::$fixtures['comment'], true ); + } + } + /** * Tear down the test. */ @@ -24,6 +109,20 @@ public function tear_down() { \delete_option( 'activitypub_object_type' ); \delete_option( 'activitypub_custom_post_content' ); \delete_option( 'activitypub_post_content_type' ); + + // Clean up outbox items. + $outbox_items = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + 'post_status' => 'any', + 'fields' => 'ids', + ) + ); + + foreach ( $outbox_items as $item_id ) { + \wp_delete_post( $item_id, true ); + } } /** @@ -184,6 +283,9 @@ public function test_migrate_to_4_1_0() { $this->assertEquals( $custom, $template ); $this->assertFalse( $content_type ); + + \wp_delete_post( $post1, true ); + \wp_delete_post( $post2, true ); } /** @@ -192,19 +294,8 @@ public function test_migrate_to_4_1_0() { * @covers ::migrate_to_4_7_1 */ public function test_migrate_to_4_7_1() { - $post1 = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'Test post 1', - ) - ); - - $post2 = \wp_insert_post( - array( - 'post_author' => 1, - 'post_content' => 'Test post 2', - ) - ); + $post1 = self::$fixtures['posts'][0]; + $post2 = self::$fixtures['posts'][1]; // Set up test meta data. $meta_data = array( @@ -274,7 +365,7 @@ public function test_lock_acquire_new() { } /** - * Tests retrieving the timestamp of an existing lock. + * Test retrieving the timestamp of an existing lock. * * @covers ::lock */ @@ -291,7 +382,7 @@ public function test_lock_get_existing() { } /** - * Tests update_comment_counts() properly cleans up the lock. + * Test update_comment_counts() properly cleans up the lock. * * @covers ::update_comment_counts */ @@ -300,7 +391,11 @@ public function test_update_comment_counts_with_lock() { Comment::register_comment_types(); // Create test comments. - $post_id = $this->factory->post->create(); + $post_id = $this->factory->post->create( + array( + 'post_author' => 1, + ) + ); $comment_id = $this->factory->comment->create( array( 'comment_post_ID' => $post_id, @@ -320,7 +415,7 @@ public function test_update_comment_counts_with_lock() { } /** - * Tests update_comment_counts() with existing valid lock. + * Test update_comment_counts() with existing valid lock. * * @covers ::update_comment_counts */ @@ -353,4 +448,137 @@ public function test_update_comment_counts_with_existing_valid_lock() { ) ); } + + /** + * Test create post outbox items. + * + * @covers ::create_post_outbox_items + */ + public function test_create_outbox_items() { + // Create additional post that should not be included in outbox. + $post_id = self::factory()->post->create( array( 'post_author' => 90210 ) ); + + // Run migration. + add_filter( 'pre_schedule_event', '__return_false' ); + Migration::create_post_outbox_items( 10, 0 ); + remove_filter( 'pre_schedule_event', '__return_false' ); + + // Get outbox items. + $outbox_items = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + ) + ); + + // Should now have 5 outbox items total, 4 post Create, 1 post Update. + $this->assertEquals( 5, count( $outbox_items ) ); + + \wp_delete_post( $post_id, true ); + } + + /** + * Test create post outbox items with batching. + * + * @covers ::create_post_outbox_items + */ + public function test_create_outbox_items_batching() { + // Run migration with batch size of 2. + $next = Migration::create_post_outbox_items( 2, 0 ); + + $this->assertSame( + array( + 'batch_size' => 2, + 'offset' => 2, + ), + $next + ); + + // Get outbox items. + $outbox_items = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + ) + ); + + // Should have 2 outbox items. + $this->assertEquals( 2, count( $outbox_items ) ); + + // Run migration with next batch. + Migration::create_post_outbox_items( 2, 2 ); + + // Get outbox items again. + $outbox_items = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => -1, + ) + ); + + // Should now have 5 outbox items total, 4 post Create, 1 post Update. + $this->assertEquals( 5, count( $outbox_items ) ); + } + + /** + * Test async upgrade functionality. + * + * @covers ::async_upgrade + * @covers ::lock + * @covers ::unlock + * @covers ::create_post_outbox_items + */ + public function test_async_upgrade() { + // Test that lock prevents simultaneous upgrades. + Migration::lock(); + Migration::async_upgrade( 'create_post_outbox_items' ); + $scheduled = \wp_next_scheduled( 'activitypub_upgrade', array( 'create_post_outbox_items' ) ); + $this->assertNotFalse( $scheduled ); + Migration::unlock(); + + // Test scheduling next batch when callback returns more work. + Migration::async_upgrade( 'create_post_outbox_items', 1, 0 ); // Small batch size to force multiple batches. + $scheduled = \wp_next_scheduled( 'activitypub_upgrade', array( 'create_post_outbox_items', 1, 1 ) ); + $this->assertNotFalse( $scheduled ); + + // Test no scheduling when callback returns null (no more work). + Migration::async_upgrade( 'create_post_outbox_items', 100, 1000 ); // Large offset to ensure no posts found. + $this->assertFalse( + \wp_next_scheduled( 'activitypub_upgrade', array( 'create_post_outbox_items', 100, 1100 ) ) + ); + } + + /** + * Test async upgrade with multiple arguments. + * + * @covers ::async_upgrade + */ + public function test_async_upgrade_multiple_args() { + // Test that multiple arguments are passed correctly. + Migration::async_upgrade( 'update_comment_counts', 50, 100 ); + $scheduled = \wp_next_scheduled( 'activitypub_upgrade', array( 'update_comment_counts', 50, 150 ) ); + $this->assertFalse( $scheduled, 'Should not schedule next batch when no comments found' ); + } + + /** + * Test create_comment_outbox_items batch processing. + * + * @covers ::create_comment_outbox_items + */ + public function test_create_comment_outbox_items_batching() { + // Test with small batch size. + $result = Migration::create_comment_outbox_items( 1, 0 ); + $this->assertIsArray( $result ); + $this->assertEquals( + array( + 'batch_size' => 1, + 'offset' => 1, + ), + $result + ); + + // Test with large offset (no more comments). + $result = Migration::create_comment_outbox_items( 1, 1000 ); + $this->assertNull( $result ); + } } diff --git a/tests/includes/class-test-query.php b/tests/includes/class-test-query.php index 47b698ac2..99d8e7e0d 100644 --- a/tests/includes/class-test-query.php +++ b/tests/includes/class-test-query.php @@ -108,6 +108,19 @@ public function test_get_activitypub_object_id() { $this->assertEquals( get_permalink( self::$post_id ), $query->get_activitypub_object_id() ); } + /** + * Test get_activitypub_object_id method for authors. + * + * @covers ::get_activitypub_object_id + */ + public function test_get_activitypub_object_id_for_author() { + $author_url = get_author_posts_url( self::$user_id ); + $this->go_to( $author_url ); + $query = Query::get_instance(); + + $this->assertEquals( $author_url, $query->get_activitypub_object_id() ); + } + /** * Test get_queried_object method. * diff --git a/tests/includes/class-test-scheduler.php b/tests/includes/class-test-scheduler.php index d7df36643..0a423dc38 100644 --- a/tests/includes/class-test-scheduler.php +++ b/tests/includes/class-test-scheduler.php @@ -1,6 +1,6 @@ post = self::factory()->post->create_and_get( + public static function wpSetUpBeforeClass( $factory ) { + self::$user_id = $factory->user->create( array( - 'post_title' => 'Test Post', - 'post_content' => 'Test Content', - 'post_status' => 'draft', - 'post_author' => 1, + 'role' => 'author', ) ); } /** - * Clean up test resources after each test. - * - * Deletes the test post. + * Clean up after tests. */ - public function tear_down() { - wp_delete_post( $this->post->ID, true ); - parent::tear_down(); + public static function wpTearDownAfterClass() { + wp_delete_user( self::$user_id ); } /** - * Test that moving a draft post to trash does not schedule federation. + * Test reprocess_outbox method. * - * @covers ::schedule_post_activity + * @covers ::reprocess_outbox */ - public function test_draft_to_trash_should_not_schedule_federation() { - Scheduler::schedule_post_activity( 'trash', 'draft', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Delete' ) ), - 'Draft to trash transition should not schedule federation' + public function test_reprocess_outbox() { + // Create test activity objects. + $activity_object = new Base_Object(); + $activity_object->set_content( 'Test Content' ); + $activity_object->set_type( 'Note' ); + $activity_object->set_id( 'https://example.com/test-id' ); + + // Add multiple pending activities. + $pending_ids = array(); + for ( $i = 0; $i < 3; $i++ ) { + $pending_ids[] = Outbox::add( + $activity_object, + 'Create', + self::$user_id, + ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC + ); + } + + // Track scheduled events. + $scheduled_events = array(); + add_filter( + 'schedule_event', + function ( $event ) use ( &$scheduled_events ) { + if ( 'activitypub_process_outbox' === $event->hook ) { + $scheduled_events[] = $event->args[0]; + } + return $event; + } ); - } - /** - * Test that moving a published post to trash schedules a delete activity only if federated. - * - * @covers ::schedule_post_activity - */ - public function test_publish_to_trash_should_schedule_delete_only_if_federated() { - wp_publish_post( $this->post->ID ); - $this->post = get_post( $this->post->ID ); - - // Test without federation state. - Scheduler::schedule_post_activity( 'trash', 'publish', $this->post ); - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Delete' ) ), - 'Published to trash transition should not schedule delete activity when not federated' + // Run reprocess_outbox. + Scheduler::reprocess_outbox(); + + // Verify each pending activity was scheduled. + $this->assertCount( 3, $scheduled_events, 'Should schedule 3 activities for processing' ); + foreach ( $pending_ids as $id ) { + $this->assertContains( $id, $scheduled_events, "Activity $id should be scheduled" ); + } + + // Test with published activities (should not be scheduled). + $published_id = Outbox::add( + $activity_object, + 'Create', + self::$user_id, + ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC + ); + wp_update_post( + array( + 'ID' => $published_id, + 'post_status' => 'publish', + ) ); - // Test with federation state. - \Activitypub\set_wp_object_state( $this->post, 'federated' ); - Scheduler::schedule_post_activity( 'trash', 'publish', $this->post ); + // Reset tracked events. + $scheduled_events = array(); - $this->assertNotFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Delete' ) ), - 'Published to trash transition should schedule delete activity when federated' - ); - } + // Run reprocess_outbox again. + Scheduler::reprocess_outbox(); - /** - * Test that updating a draft post does not schedule federation. - * - * @covers ::schedule_post_activity - */ - public function test_draft_to_draft_should_not_schedule_federation() { - Scheduler::schedule_post_activity( 'draft', 'draft', $this->post ); + // Verify published activity was not scheduled. + $this->assertNotContains( $published_id, $scheduled_events, 'Published activity should not be scheduled' ); - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Draft to draft transition should not schedule federation' - ); + // Clean up. + foreach ( $pending_ids as $id ) { + wp_delete_post( $id, true ); + } + wp_delete_post( $published_id, true ); + remove_all_filters( 'schedule_event' ); } /** - * Test that moving a published post to draft schedules an update activity. + * Test reprocess_outbox with no pending activities. * - * @covers ::schedule_post_activity + * @covers ::reprocess_outbox */ - public function test_publish_to_draft_should_schedule_update() { - wp_publish_post( $this->post->ID ); - $this->post = get_post( $this->post->ID ); - Scheduler::schedule_post_activity( 'draft', 'publish', $this->post ); - - $this->assertNotFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Published to draft transition should schedule update activity' + public function test_reprocess_outbox_no_pending() { + $scheduled_events = array(); + add_filter( + 'schedule_event', + function ( $event ) use ( &$scheduled_events ) { + if ( 'activitypub_process_outbox' === $event->hook ) { + $scheduled_events[] = $event->args[0]; + } + return $event; + } ); - } - /** - * Test that publishing a draft post schedules a create activity. - * - * @covers ::schedule_post_activity - */ - public function test_draft_to_publish_should_schedule_create() { - Scheduler::schedule_post_activity( 'publish', 'draft', $this->post ); + // Run reprocess_outbox with no pending activities. + Scheduler::reprocess_outbox(); - $this->assertNotFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Create' ) ), - 'Draft to publish transition should schedule create activity' - ); - } + // Verify no events were scheduled. + $this->assertEmpty( $scheduled_events, 'No events should be scheduled when there are no pending activities' ); - /** - * Test that updating a published post schedules an update activity. - * - * @covers ::schedule_post_activity - */ - public function test_publish_to_publish_should_schedule_update() { - wp_publish_post( $this->post->ID ); - $this->post = get_post( $this->post->ID ); - Scheduler::schedule_post_activity( 'publish', 'publish', $this->post ); - - $this->assertNotFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Published to published transition should schedule update activity' - ); + remove_all_filters( 'schedule_event' ); } /** - * Test that various non-standard status transitions do not schedule federation. + * Test reprocess_outbox scheduling behavior. * - * Tests transitions from pending, private, and future statuses. - * - * @covers ::schedule_post_activity + * @covers ::reprocess_outbox */ - public function test_other_status_transitions_should_not_schedule_federation() { - // Test pending to draft. - Scheduler::schedule_post_activity( 'draft', 'pending', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Pending to draft transition should not schedule federation' - ); - - // Test private to draft. - Scheduler::schedule_post_activity( 'draft', 'private', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Private to draft transition should not schedule federation' + public function test_reprocess_outbox_scheduling() { + // Create a test activity. + $activity_object = new Base_Object(); + $activity_object->set_content( 'Test Content' ); + $activity_object->set_type( 'Note' ); + $activity_object->set_id( 'https://example.com/test-id-2' ); + + $pending_id = Outbox::add( + $activity_object, + 'Create', + self::$user_id, + ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ); - // Test future to draft. - Scheduler::schedule_post_activity( 'draft', 'future', $this->post ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Future to draft transition should not schedule federation' + // Track scheduled events and their timing. + $scheduled_time = 0; + add_filter( + 'schedule_event', + function ( $event ) use ( &$scheduled_time ) { + if ( 'activitypub_process_outbox' === $event->hook ) { + $scheduled_time = $event->timestamp; + } + return $event; + } ); - } - - /** - * Test that disabled posts do not schedule federation activities. - * - * @covers ::schedule_post_activity - */ - public function test_disabled_post_should_not_schedule_federation() { - update_post_meta( $this->post->ID, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ); - Scheduler::schedule_post_activity( 'publish', 'draft', $this->post ); - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Create' ) ), - 'Disabled posts should not schedule federation activities' - ); - } + // Run reprocess_outbox. + Scheduler::reprocess_outbox(); - /** - * Test that password protected posts do not schedule federation activities. - * - * @covers ::schedule_post_activity - */ - public function test_password_protected_post_should_not_schedule_federation() { - wp_update_post( - array( - 'ID' => $this->post->ID, - 'post_password' => 'test-password', - ) - ); - $this->post = get_post( $this->post->ID ); - Scheduler::schedule_post_activity( 'publish', 'draft', $this->post ); + // Verify scheduling time. + $this->assertGreaterThan( 0, $scheduled_time, 'Event should be scheduled with a future timestamp' ); + $this->assertGreaterThanOrEqual( time() + 10, $scheduled_time, 'Event should be scheduled at least 10 seconds in the future' ); - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Create' ) ), - 'Password protected posts should not schedule federation activities' - ); + // Clean up. + wp_delete_post( $pending_id, true ); + remove_all_filters( 'schedule_event' ); } } diff --git a/tests/includes/class-test-shortcodes.php b/tests/includes/class-test-shortcodes.php index 2819e0851..2ae7f188c 100644 --- a/tests/includes/class-test-shortcodes.php +++ b/tests/includes/class-test-shortcodes.php @@ -7,6 +7,7 @@ namespace Activitypub\Tests; +use Activitypub\Scheduler\Post; use Activitypub\Shortcodes; /** @@ -17,103 +18,93 @@ class Test_Shortcodes extends \WP_UnitTestCase { /** - * Test the content shortcode. + * Post object. + * + * @var \WP_Post */ - public function test_content() { + protected $post; + + /** + * Set up the test. + */ + public function set_up() { + parent::set_up(); + + remove_action( 'transition_post_status', array( Post::class, 'schedule_post_activity' ), 33 ); Shortcodes::register(); + + // Create a post. + $this->post = self::factory()->post->create_and_get( + array( + 'post_title' => 'Test title for shortcode', + 'post_content' => 'Lorem ipsum dolor sit amet, consectetur.', + 'post_excerpt' => '', + ) + ); + } + + /** + * Tear down the test. + */ + public function tear_down() { + parent::tear_down(); + + Shortcodes::unregister(); + + // Delete the post. + wp_delete_post( $this->post->ID, true ); + } + + /** + * Test the content shortcode. + */ + public function test_content() { global $post; - $post_id = -99; // Negative ID, to avoid clash with a valid post. - $post = new \stdClass(); - $post->ID = $post_id; - $post->post_author = 1; - $post->post_date = current_time( 'mysql' ); - $post->post_date_gmt = current_time( 'mysql', 1 ); - $post->post_title = 'Some title or other'; - $post->post_content = 'hallo'; - $post->post_status = 'publish'; - $post->comment_status = 'closed'; - $post->ping_status = 'closed'; - $post->post_name = 'fake-post-' . wp_rand( 1, 99999 ); // Append random number to avoid clash. - $post->post_type = 'post'; - $post->filter = 'raw'; // important! - - $content = '[ap_content]'; + $post = $this->post; + $post->post_content = 'hallo'; // Fill in the shortcodes. setup_postdata( $post ); - $content = do_shortcode( $content ); + $content = do_shortcode( '[ap_content]' ); wp_reset_postdata(); $this->assertEquals( '

hallo

', $content ); - Shortcodes::unregister(); } /** * Test the content shortcode with password protected content. */ public function test_password_protected_content() { - Shortcodes::register(); global $post; - $post_id = -98; // Negative ID, to avoid clash with a valid post. - $post = new \stdClass(); - $post->ID = $post_id; - $post->post_author = 1; - $post->post_date = current_time( 'mysql' ); - $post->post_date_gmt = current_time( 'mysql', 1 ); - $post->post_title = 'Some title or other'; - $post->post_content = 'hallo'; - $post->comment_status = 'closed'; - $post->ping_status = 'closed'; - $post->post_name = 'fake-post-' . wp_rand( 1, 99999 ); // Append random number to avoid clash. - $post->post_type = 'post'; - $post->filter = 'raw'; // important! - $post->post_password = 'abc'; - - $content = '[ap_content]'; + $post = $this->post; + $post->post_password = 'abc'; // Fill in the shortcodes. setup_postdata( $post ); - $content = do_shortcode( $content ); + $content = do_shortcode( '[ap_content]' ); wp_reset_postdata(); $this->assertEquals( '', $content ); - Shortcodes::unregister(); } /** * Test the excerpt shortcode. */ public function test_excerpt() { - Shortcodes::register(); global $post; - $post_id = -97; // Negative ID, to avoid clash with a valid post. - $post = new \stdClass(); - $post->ID = $post_id; - $post->post_author = 1; - $post->post_date = current_time( 'mysql' ); - $post->post_date_gmt = current_time( 'mysql', 1 ); - $post->post_title = 'Some title or other'; - $post->post_content = 'Lorem ipsum dolor sit amet, consectetur.'; - $post->post_status = 'publish'; - $post->comment_status = 'closed'; - $post->ping_status = 'closed'; - $post->post_name = 'fake-post-' . wp_rand( 1, 99999 ); // Append random number to avoid clash. - $post->post_type = 'post'; - $post->filter = 'raw'; // important! - - $content = '[ap_excerpt length="25"]'; + $post = $this->post; + $post->post_content = 'Lorem ipsum dolor sit amet, consectetur.'; // Fill in the shortcodes. setup_postdata( $post ); - $content = do_shortcode( $content ); + $content = do_shortcode( '[ap_excerpt length="25"]' ); wp_reset_postdata(); $this->assertEquals( "

Lorem ipsum dolor […]

\n", $content ); - Shortcodes::unregister(); } /** @@ -122,22 +113,14 @@ public function test_excerpt() { * @covers ::title */ public function test_title() { - Shortcodes::register(); global $post; - $post = self::factory()->post->create_and_get( - array( - 'post_title' => 'Test title for shortcode', - ) - ); - - $content = '[ap_title]'; + $post = $this->post; // Fill in the shortcodes. setup_postdata( $post ); - $content = do_shortcode( $content ); + $content = do_shortcode( '[ap_title]' ); wp_reset_postdata(); - Shortcodes::unregister(); $this->assertEquals( 'Test title for shortcode', $content ); } diff --git a/tests/includes/class-test-signature.php b/tests/includes/class-test-signature.php index dfcfd05ef..5d9988d73 100644 --- a/tests/includes/class-test-signature.php +++ b/tests/includes/class-test-signature.php @@ -58,7 +58,7 @@ public function test_signature_legacy() { $this->assertEquals( $key_pair['private_key'], $private_key ); // Check application user. - $user = Actors::get_by_id( -1 ); + $user = Actors::get_by_id( Actors::APPLICATION_USER_ID ); $public_key = 'public key ' . $user->get__id(); $private_key = 'private key ' . $user->get__id(); @@ -73,8 +73,9 @@ public function test_signature_legacy() { $this->assertEquals( $key_pair['private_key'], $private_key ); // Check blog user. - \define( 'ACTIVITYPUB_DISABLE_BLOG_USER', false ); - $user = Actors::get_by_id( 0 ); + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + $user = Actors::get_by_id( Actors::BLOG_USER_ID ); + \delete_option( 'activitypub_actor_mode' ); $public_key = 'public key ' . $user->get__id(); $private_key = 'private key ' . $user->get__id(); diff --git a/tests/includes/collection/class-test-followers.php b/tests/includes/collection/class-test-followers.php index c2cd8656f..9b4a24335 100644 --- a/tests/includes/collection/class-test-followers.php +++ b/tests/includes/collection/class-test-followers.php @@ -114,6 +114,26 @@ function ( $item ) { $this->assertEquals( array( 'http://sally.example.org', 'https://example.org/author/doe', 'https://example.com/author/jon' ), $db_followers ); } + /** + * Tests get_followers with corrupted json. + * + * @covers ::get_followers + */ + public function test_get_followers_without_errors() { + $followers = array( 'https://example.com/author/jon', 'https://example.org/author/doe', 'http://sally.example.org' ); + + foreach ( $followers as $follower ) { + Followers::add_follower( 1, $follower ); + } + + $follower = Followers::get_follower( 1, 'https://example.org/author/doe' ); + update_post_meta( $follower->get__id(), '_activitypub_actor_json', 'invalid json' ); + + $db_followers = Followers::get_followers( 1 ); + + $this->assertEquals( 2, \count( $db_followers ) ); + } + /** * Tests add_follower. * diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php new file mode 100644 index 000000000..36fa1c537 --- /dev/null +++ b/tests/includes/collection/class-test-outbox.php @@ -0,0 +1,121 @@ +assertIsInt( $id ); + + $post = \get_post( $id ); + + $this->assertInstanceOf( 'WP_Post', $post ); + $this->assertEquals( 'pending', $post->post_status ); + $this->assertEquals( $json, $post->post_content ); + + $activity = json_decode( $post->post_content ); + $this->assertSame( $data['content'], $activity->content ); + + $this->assertEquals( $type, \get_post_meta( $id, '_activitypub_activity_type', true ) ); + + // Fall back to blog if user does not have the activitypub capability. + $actor_type = \user_can( $user_id, 'activitypub' ) ? 'user' : 'blog'; + $this->assertEquals( $actor_type, \get_post_meta( $id, '_activitypub_activity_actor', true ) ); + } + + /** + * Data provider for test_add. + * + * @return array + */ + public function activity_object_provider() { + return array( + array( + array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'https://example.com/' . self::$user_id, + 'type' => 'Note', + 'content' => '

This is a note

', + ), + 'Create', + 1, + '{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/' . self::$user_id . '","type":"Note","content":"\u003Cp\u003EThis is a note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is a note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":[],"mediaType":"text\/html","sensitive":false}', + ), + array( + array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'https://example.com/2', + 'type' => 'Note', + 'content' => '

This is another note

', + ), + 'Create', + 2, + '{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/2","type":"Note","content":"\u003Cp\u003EThis is another note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is another note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":[],"mediaType":"text\/html","sensitive":false}', + ), + ); + } + + /** + * Test add an item to the outbox with a user. + * + * @covers ::add + * @dataProvider author_object_provider + * + * @param string $mode The actor mode. + * @param int $user_id The user ID. + * @param string $expected_actor The expected actor. + */ + public function test_author_fallbacks( $mode, $user_id, $expected_actor ) { + \update_option( 'activitypub_actor_mode', $mode ); + + $user_id = $user_id ?? self::$user_id; + $data = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'https://example.com/' . $user_id, + 'type' => 'Note', + 'content' => '

This is a note

', + ); + + $id = \Activitypub\add_to_outbox( $data, 'Create', $user_id ); + $this->assertEquals( $expected_actor, \get_post_meta( $id, '_activitypub_activity_actor', true ) ); + } + + /** + * Data provider for test_author_fallbacks. + * + * @return array[] + */ + public function author_object_provider() { + return array( + array( ACTIVITYPUB_ACTOR_AND_BLOG_MODE, null, 'user' ), + array( ACTIVITYPUB_ACTOR_AND_BLOG_MODE, 90210, 'blog' ), + array( ACTIVITYPUB_BLOG_MODE, 90210, 'blog' ), + array( ACTIVITYPUB_ACTOR_MODE, 90210, false ), + ); + } +} diff --git a/tests/includes/handler/class-test-follow.php b/tests/includes/handler/class-test-follow.php new file mode 100644 index 000000000..068f8c21e --- /dev/null +++ b/tests/includes/handler/class-test-follow.php @@ -0,0 +1,124 @@ +user->create( + array( + 'role' => 'author', + ) + ); + } + + /** + * Clean up after tests. + */ + public static function wpTearDownAfterClass() { + wp_delete_user( self::$user_id ); + } + + /** + * Test queue_accept method. + * + * @covers ::queue_accept + */ + public function test_queue_accept() { + $actor = 'https://example.com/actor'; + $activity_object = array( + 'id' => 'https://example.com/activity/123', + 'type' => 'Follow', + 'actor' => $actor, + 'object' => 'https://example.com/user/1', + ); + + // Test with WP_Error follower - should not create outbox entry. + $wp_error = new \WP_Error( 'test_error', 'Test Error' ); + Follow::queue_accept( $actor, $activity_object, self::$user_id, $wp_error ); + + $outbox_posts = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'author' => self::$user_id, + 'post_status' => 'pending', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_actor', + 'value' => 'user', + ), + ), + ) + ); + $this->assertEmpty( $outbox_posts, 'No outbox entry should be created for WP_Error follower' ); + + // Test with valid follower. + $follower = new Follower(); + $follower->set_actor( $actor ); + $follower->set_type( 'Person' ); + $follower->set_inbox( 'https://example.com/inbox' ); + + Follow::queue_accept( $actor, $activity_object, self::$user_id, $follower ); + + $outbox_posts = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'author' => self::$user_id, + 'post_status' => 'pending', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_actor', + 'value' => 'user', + ), + ), + ) + ); + + $this->assertCount( 1, $outbox_posts, 'One outbox entry should be created' ); + + $outbox_post = $outbox_posts[0]; + $activity_type = \get_post_meta( $outbox_post->ID, '_activitypub_activity_type', true ); + $activity_json = \json_decode( $outbox_post->post_content, true ); + $visibility = \get_post_meta( $outbox_post->ID, 'activitypub_content_visibility', true ); + + // Verify outbox entry. + $this->assertEquals( 'Accept', $activity_type ); + $this->assertEquals( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, $visibility ); + + $this->assertEquals( 'Follow', $activity_json['type'] ); + $this->assertEquals( 'https://example.com/user/1', $activity_json['object'] ); + $this->assertEquals( array( $actor ), $activity_json['to'] ); + $this->assertEquals( $actor, $activity_json['actor'] ); + + // Clean up. + wp_delete_post( $outbox_post->ID, true ); + } +} diff --git a/tests/includes/rest/class-test-outbox-controller.php b/tests/includes/rest/class-test-outbox-controller.php new file mode 100644 index 000000000..c96984a43 --- /dev/null +++ b/tests/includes/rest/class-test-outbox-controller.php @@ -0,0 +1,714 @@ +user->create( array( 'role' => 'author' ) ); + \get_user_by( 'ID', self::$user_id )->add_cap( 'activitypub' ); + + self::$post_ids = self::factory()->post->create_many( 10, array( 'post_author' => self::$user_id ) ); + } + + /** + * Clean up test fixtures. + */ + public static function wpTearDownAfterClass() { + \wp_delete_user( self::$user_id ); + + foreach ( self::$post_ids as $post_id ) { + \wp_delete_post( $post_id, true ); + } + + \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); + } + + /** + * Set up test environment. + */ + public function set_up() { + parent::set_up(); + \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + } + + /** + * Test route registration. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/(?:users|actors)/(?P[\w\-\.]+)/outbox', $routes ); + } + + /** + * Test user ID validation. + * + * @covers ::validate_user_id + */ + public function test_validate_user_id() { + $controller = new Outbox_Controller(); + $this->assertTrue( $controller->validate_user_id( 0 ) ); + $this->assertTrue( $controller->validate_user_id( '1' ) ); + $this->assertWPError( $controller->validate_user_id( 'user-1' ) ); + } + + /** + * Test getting items. + * + * @covers ::get_items + */ + public function test_get_items() { + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Test schema. + * + * @covers ::get_collection_schema + */ + public function test_get_collection_schema() { + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $schema = ( new Outbox_Controller() )->get_collection_schema(); + + $valid = \rest_validate_value_from_schema( $data, $schema ); + $this->assertNotWPError( $valid, 'Response failed schema validation: ' . ( \is_wp_error( $valid ) ? $valid->get_error_message() : '' ) ); + } + + /** + * Test getting items with pagination. + * + * @covers ::get_items + */ + public function test_get_items_pagination() { + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + $request->set_param( 'page', 2 ); + $request->set_param( 'per_page', 3 ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'prev', $data ); + $this->assertArrayHasKey( 'next', $data ); + $this->assertStringContainsString( 'page=1', $data['prev'] ); + $this->assertStringContainsString( 'page=3', $data['next'] ); + + // Empty collection. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/1/outbox' ); + $request->set_param( 'per_page', 3 ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertStringContainsString( 'page=1', $data['last'] ); + $this->assertArrayNotHasKey( 'prev', $data ); + $this->assertArrayNotHasKey( 'next', $data ); + } + + /** + * Test getting items response structure. + * + * @covers ::get_items + */ + public function test_get_items_response_structure() { + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( '@context', $data ); + $this->assertArrayHasKey( 'id', $data ); + $this->assertArrayHasKey( 'type', $data ); + $this->assertArrayHasKey( 'totalItems', $data ); + $this->assertArrayHasKey( 'orderedItems', $data ); + $this->assertEquals( 'OrderedCollectionPage', $data['type'] ); + $this->assertIsArray( $data['orderedItems'] ); + + $headers = $response->get_headers(); + $this->assertEquals( 'application/activity+json; charset=' . \get_option( 'blog_charset' ), $headers['Content-Type'] ); + } + + /** + * Test getting items for specific user. + * + * @covers ::get_items + */ + public function test_get_items_specific_user() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => Outbox::POST_TYPE, + 'post_title' => 'https://example.org/activity/1', + 'post_status' => 'pending', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/1', + 'type' => 'Create', + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 1, (int) $data['totalItems'] ); + $this->assertStringContainsString( (string) $user_id, $data['actor'] ); + + \wp_delete_post( $post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Test outbox filters. + * + * @covers ::get_items + */ + public function test_get_items_filters() { + $filter_called = false; + $pre_called = false; + $post_called = false; + + \add_filter( + 'activitypub_rest_outbox_array', + function ( $response ) use ( &$filter_called ) { + $filter_called = true; + return $response; + } + ); + + \add_action( + 'activitypub_rest_outbox_pre', + function () use ( &$pre_called ) { + $pre_called = true; + } + ); + + \add_action( + 'activitypub_outbox_post', + function () use ( &$post_called ) { + $post_called = true; + } + ); + + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + \rest_get_server()->dispatch( $request ); + + $this->assertTrue( $filter_called, 'activitypub_rest_outbox_array filter was not called.' ); + $this->assertTrue( $pre_called, 'activitypub_rest_outbox_pre action was not called.' ); + $this->assertTrue( $post_called, 'activitypub_outbox_post action was not called.' ); + + \remove_all_filters( 'activitypub_rest_outbox_array' ); + \remove_all_actions( 'activitypub_rest_outbox_pre' ); + \remove_all_actions( 'activitypub_outbox_post' ); + } + + /** + * Test getting items with minimum per_page. + * + * @covers ::get_items + */ + public function test_get_items_minimum_per_page() { + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + $request->set_param( 'per_page', 1 ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $data['orderedItems'] ); + } + + /** + * Test getting items with maximum per_page. + * + * @covers ::get_items + */ + public function test_get_items_maximum_per_page() { + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/%s/outbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + $request->set_param( 'per_page', 100 ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Data provider for test_get_items_activity_type. + * + * @return array[] Test parameters. + */ + public function data_activity_types() { + return array( + 'create_activity' => array( + 'type' => 'Create', + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + 'allowed' => true, + ), + 'announce_activity' => array( + 'type' => 'Announce', + 'object' => 'https://example.org/note/2', + 'allowed' => true, + ), + 'like_activity' => array( + 'type' => 'Like', + 'object' => 'https://example.org/note/3', + 'allowed' => true, + ), + 'update_activity' => array( + 'type' => 'Update', + 'object' => array( + 'id' => 'https://example.org/note/4', + 'type' => 'Note', + 'content' => 'Updated content', + ), + 'allowed' => true, + ), + 'delete_activity' => array( + 'type' => 'Delete', + 'object' => 'https://example.org/note/5', + 'allowed' => false, + ), + 'follow_activity' => array( + 'type' => 'Follow', + 'object' => 'https://example.org/user/6', + 'allowed' => false, + ), + ); + } + + /** + * Test getting items with different activity types. + * + * @covers ::get_items + * @dataProvider data_activity_types + * + * @param string $type Activity type. + * @param string|array $activity Activity object. + * @param bool $allowed Whether the activity type is allowed for public users. + */ + public function test_get_items_activity_type( $type, $activity, $allowed ) { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'post_title' => "https://example.org/activity/{$type}", + 'post_content' => \wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => "https://example.org/activity/{$type}", + 'type' => $type, + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => $activity, + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => $type, + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + // Test as logged-out user. + \wp_set_current_user( 0 ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $activity_types = \wp_list_pluck( $data['orderedItems'], 'type' ); + + if ( $allowed ) { + $this->assertContains( $type, $activity_types, sprintf( 'Activity type "%s" should be visible to logged-out users.', $type ) ); + $this->assertSame( 1, (int) $data['totalItems'], sprintf( 'Activity type "%s" should be included in total items for logged-out users.', $type ) ); + } else { + $this->assertNotContains( $type, $activity_types, sprintf( 'Activity type "%s" should not be visible to logged-out users.', $type ) ); + $this->assertSame( 0, (int) $data['totalItems'], sprintf( 'Activity type "%s" should not be included in total items for logged-out users.', $type ) ); + } + + // Test as logged-in user with activitypub capability. + \wp_set_current_user( $user_id ); + $this->assertTrue( \current_user_can( 'activitypub' ) ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $activity_types = \wp_list_pluck( $data['orderedItems'], 'type' ); + + $this->assertContains( $type, $activity_types, sprintf( 'Activity type "%s" should be visible to users with activitypub capability.', $type ) ); + $this->assertSame( 1, (int) $data['totalItems'], sprintf( 'Activity type "%s" should be included in total items for users with activitypub capability.', $type ) ); + + \wp_delete_post( $post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Data provider for test_get_items_content_visibility. + * + * @return array[] Test parameters. + */ + public function data_content_visibility() { + return array( + 'no_visibility' => array( + 'visibility' => null, + 'public_visible' => true, + 'private_visible' => true, + ), + 'public' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + 'public_visible' => true, + 'private_visible' => true, + ), + 'quiet_public' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, + 'public_visible' => false, + 'private_visible' => true, + ), + 'private' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, + 'public_visible' => false, + 'private_visible' => true, + ), + 'local' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + 'public_visible' => false, + 'private_visible' => true, + ), + ); + } + + /** + * Test content visibility for logged-in and logged-out users. + * + * @covers ::get_items + * @dataProvider data_content_visibility + * + * @param string|null $visibility Content visibility setting. + * @param bool $public_visible Whether content should be visible to public users. + * @param bool $private_visible Whether content should be visible to users with activitypub capability. + */ + public function test_get_items_content_visibility( $visibility, $public_visible, $private_visible ) { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $meta_input = array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'user', + ); + + if ( null !== $visibility ) { + $meta_input['activitypub_content_visibility'] = $visibility; + } + + $post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'post_title' => 'https://example.org/activity/1', + 'post_content' => \wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/1', + 'type' => 'Create', + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ), + 'meta_input' => $meta_input, + ) + ); + + // Test as logged-out user. + \wp_set_current_user( 0 ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( + (int) $public_visible, + (int) $data['totalItems'], + sprintf( + 'Content with visibility "%s" should%s be visible to logged-out users.', + $visibility ?? 'none', + $public_visible ? '' : ' not' + ) + ); + + // Test as logged-in user with activitypub capability. + \wp_set_current_user( $user_id ); + $this->assertTrue( \current_user_can( 'activitypub' ) ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( + (int) $private_visible, + (int) $data['totalItems'], + sprintf( + 'Content with visibility "%s" should%s be visible to users with activitypub capability.', + $visibility ?? 'none', + $private_visible ? '' : ' not' + ) + ); + + \wp_delete_post( $post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Test getting items with correct actor type filtering. + * + * @covers ::get_items + */ + public function test_get_items_actor_type_filtering() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + + // Create a post with user actor type. + $user_post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/1', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/1', + 'type' => 'Create', + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + // Create a post with blog actor type. + $blog_post_id = self::factory()->post->create( + array( + 'post_author' => 0, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/2', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/2', + 'type' => 'Create', + 'actor' => 'https://example.org/blog', + 'object' => array( + 'id' => 'https://example.org/note/2', + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'blog', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + // Test user outbox only returns user actor type. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 1, (int) $data['totalItems'] ); + $this->assertCount( 1, $data['orderedItems'] ); + $this->assertSame( 'https://example.org/activity/1', $data['orderedItems'][0]['object']['id'] ); + + // Test blog outbox only returns blog actor type. + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/actors/0/outbox', ACTIVITYPUB_REST_NAMESPACE ) ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 1, (int) $data['totalItems'] ); + + \wp_delete_post( $user_post_id, true ); + \wp_delete_post( $blog_post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Test meta query behavior for non-privileged users. + * + * @covers ::get_items + */ + public function test_get_items_meta_query_for_non_privileged_users() { + $author_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $viewer_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + // Create a public post. + $public_post_id = self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/1', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/1', + 'type' => 'Create', + 'actor' => 'https://example.org/user/' . $author_id, + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Public content', + ), + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + // Create a private post. + $private_post_id = self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/2', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/2', + 'type' => 'Follow', + 'actor' => 'https://example.org/user/' . $author_id, + 'object' => 'https://example.org/user/123', + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Follow', + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + ), + ) + ); + + // Test as non-privileged user. + wp_set_current_user( $viewer_id ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $author_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 1, (int) $data['totalItems'] ); + $this->assertCount( 1, $data['orderedItems'] ); + $this->assertSame( 'https://example.org/activity/1', $data['orderedItems'][0]['object']['id'] ); + + // Test as privileged user. + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $author_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 2, (int) $data['totalItems'] ); + $this->assertCount( 2, $data['orderedItems'] ); + + \wp_delete_post( $public_post_id, true ); + \wp_delete_post( $private_post_id, true ); + \wp_delete_user( $author_id ); + \wp_delete_user( $viewer_id ); + \wp_delete_user( $admin_id ); + } + + /** + * Test get_item method. + * + * @doesNotPerformAssertions + */ + public function test_get_item() { + // Controller does not implement get_item(). + } + + /** + * Test get_item_schema method. + * + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // Controller does not implement get_item_schema(). + } +} diff --git a/tests/includes/scheduler/class-test-actor.php b/tests/includes/scheduler/class-test-actor.php new file mode 100644 index 000000000..d178018da --- /dev/null +++ b/tests/includes/scheduler/class-test-actor.php @@ -0,0 +1,174 @@ +user->update_object( + self::$user_id, + array( + 'display_name' => 'Test User', + 'meta_input' => array( + 'activitypub_description' => 'test description', + 'activitypub_header_image' => 'test header image', + 'description' => 'test description', + 'user_url' => 'https://example.org', + 'display_name' => 'Test Name', + ), + ) + ); + } + + /** + * Data provider for user meta update scheduling. + * + * @return string[][] + */ + public function user_meta_provider() { + return array( + array( 'activitypub_description' ), + array( 'activitypub_header_image' ), + array( 'description' ), + array( 'user_url' ), + array( 'display_name' ), + ); + } + + /** + * Test user meta update scheduling. + * + * @dataProvider user_meta_provider + * @covers ::user_meta_update + * + * @param string $meta_key Meta key to test. + */ + public function test_user_meta_update( $meta_key ) { + \update_user_meta( self::$user_id, $meta_key, 'test value' ); + + $activitpub_id = Actors::get_by_id( self::$user_id )->get_id(); + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + } + + /** + * Test user update scheduling. + * + * @covers ::user_update + */ + public function test_user_update() { + self::factory()->user->update_object( self::$user_id, array( 'display_name' => 'Test Name' ) ); + + $activitpub_id = Actors::get_by_id( self::$user_id )->get_id(); + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + } + + /** + * Test blog user update scheduling. + * + * @covers ::blog_user_update + */ + public function test_blog_user_update() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + $test_value = 'test value'; + $result = \Activitypub\Scheduler\Actor::blog_user_update( $test_value ); + + $activitpub_id = Actors::get_by_id( Actors::BLOG_USER_ID )->get_id(); + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + $this->assertSame( $test_value, $result ); + } + + /** + * Test user update scheduling with non-publishing user. + * + * @covers ::user_update + */ + public function test_user_update_no_publish() { + $activitpub_id = Actors::get_by_id( self::$user_id )->get_id(); + + // Temporarily remove the activitypub capability. + \get_user_by( 'id', self::$user_id )->remove_cap( 'activitypub' ); + self::factory()->user->update_object( self::$user_id, array( 'display_name' => 'Test Name No Publish' ) ); + + $this->assertNull( $this->get_latest_outbox_item( $activitpub_id ) ); + + // Restore the activitypub capability. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Test user meta update scheduling with non-publishing user. + * + * @covers ::user_meta_update + */ + public function test_user_meta_update_no_publish() { + $activitpub_id = Actors::get_by_id( self::$user_id )->get_id(); + + // Temporarily remove the activitypub capability. + \get_user_by( 'id', self::$user_id )->remove_cap( 'activitypub' ); + + \update_user_meta( self::$user_id, 'description', 'test value' ); + + $this->assertNull( $this->get_latest_outbox_item( $activitpub_id ) ); + + // Restore the activitypub capability. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Test post activity scheduling for ActivityPub extra fields. + * + * @covers ::schedule_post_activity + */ + public function test_schedule_post_activity_extra_fields() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_type' => Extra_Fields::USER_POST_TYPE, + ) + ); + $activitpub_id = Actors::get_by_id( self::$user_id )->get_id(); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + + \wp_delete_post( $post_id, true ); + } + + /** + * Test post activity scheduling for ActivityPub extra fields. + * + * @covers ::schedule_post_activity + */ + public function test_schedule_post_activity_extra_field_blog() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + $blog_post_id = self::factory()->post->create( array( 'post_type' => Extra_Fields::BLOG_POST_TYPE ) ); + $activitpub_id = Actors::get_by_id( Actors::BLOG_USER_ID )->get_id(); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + + // Clean up. + \wp_delete_post( $blog_post_id, true ); + } +} diff --git a/tests/includes/scheduler/class-test-comment.php b/tests/includes/scheduler/class-test-comment.php new file mode 100644 index 000000000..28cfa82ad --- /dev/null +++ b/tests/includes/scheduler/class-test-comment.php @@ -0,0 +1,127 @@ +post->create( array( 'post_author' => self::$user_id ) ); + } + + /** + * Test scheduling comment activity on approval. + */ + public function test_schedule_comment_activity_on_approval() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$comment_post_ID, + 'user_id' => self::$user_id, + 'comment_approved' => 0, + ) + ); + $activitpub_id = \Activitypub\Comment::generate_id( $comment_id ); + + wp_set_comment_status( $comment_id, 'approve' ); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + + wp_delete_comment( $comment_id, true ); + } + + /** + * Test scheduling comment activity on direct insert with approval. + */ + public function test_schedule_comment_activity_on_insert() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$comment_post_ID, + 'user_id' => self::$user_id, + 'comment_approved' => 1, + ) + ); + $activitpub_id = \Activitypub\Comment::generate_id( $comment_id ); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + + wp_delete_comment( $comment_id, true ); + } + + /** + * Data provider for no activity tests. + * + * @return array[] Test parameters. + */ + public function no_activity_comment_provider() { + return array( + 'unapproved_comment' => array( + array( + 'comment_post_ID' => self::$comment_post_ID, + 'user_id' => self::$user_id, + 'comment_approved' => 0, + ), + ), + 'non_registered_user' => array( + array( + 'comment_post_ID' => self::$comment_post_ID, + 'comment_approved' => 1, + ), + ), + 'federation_disabled' => array( + array( + 'comment_post_ID' => self::$comment_post_ID, + 'user_id' => self::$user_id, + 'comment_approved' => 1, + 'comment_meta' => array( + 'protocol' => 'activitypub', + ), + ), + ), + ); + } + + /** + * Test comment activity scheduling under various conditions. + * + * @dataProvider no_activity_comment_provider + * + * @param array $comment_data Comment data for creating the test comment. + */ + public function test_no_activity_scheduled( $comment_data ) { + foreach ( array( 'comment_post_ID', 'user_id' ) as $key ) { + if ( isset( $comment_data[ $key ] ) ) { + $comment_data[ $key ] = self::$$key; + } + } + + $comment_id = self::factory()->comment->create( $comment_data ); + $activitpub_id = \Activitypub\Comment::generate_id( $comment_id ); + + $this->assertNull( $this->get_latest_outbox_item( $activitpub_id ) ); + + wp_delete_comment( $comment_id, true ); + } +} diff --git a/tests/includes/scheduler/class-test-post.php b/tests/includes/scheduler/class-test-post.php new file mode 100644 index 000000000..082bfb50e --- /dev/null +++ b/tests/includes/scheduler/class-test-post.php @@ -0,0 +1,102 @@ +attachment->create_upload_object( dirname( __DIR__, 2 ) . '/assets/test.jpg' ); + $activitpub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); + $outbox_item = $this->get_latest_outbox_item( $activitpub_id ); + + $this->assertNotNull( $outbox_item ); + $this->assertSame( 'Create', \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ) ); + + // Update. + self::factory()->attachment->update_object( $post_id, array( 'post_title' => 'Updated title' ) ); + + $outbox_item = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( 'Update', \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ) ); + + // Delete. + \wp_delete_attachment( $post_id, true ); + + $outbox_item = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( 'Delete', \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ) ); + + remove_post_type_support( 'attachment', 'activitypub' ); + } + + /** + * Test post activity scheduling for regular posts. + * + * @covers ::schedule_post_activity + */ + public function test_schedule_post_activity_regular_post() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); + $activitpub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); + + $post = $this->get_latest_outbox_item( $activitpub_id ); + $this->assertSame( $activitpub_id, $post->post_title ); + + \wp_delete_post( $post_id, true ); + } + + /** + * Data provider for no activity tests. + * + * @return array[] Test parameters. + */ + public function no_activity_post_provider() { + return array( + 'password_protected' => array( + array( 'post_password' => 'test-password' ), + ), + 'unsupported_post_type' => array( + array( 'post_type' => 'nav_menu_item' ), + ), + 'disabled_post' => array( + array( + 'meta_input' => array( + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + ), + ), + ), + ); + } + + /** + * Test post activity scheduling under various conditions. + * + * @dataProvider no_activity_post_provider + * + * @param array $args Post data for creating the test post. + */ + public function test_no_activity_scheduled( $args ) { + $post_id = self::factory()->post->create( $args ); + $activitpub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); + + $this->assertNull( $this->get_latest_outbox_item( $activitpub_id ) ); + + \wp_delete_post( $post_id, true ); + } +} diff --git a/tests/includes/transformer/class-test-activity-object.php b/tests/includes/transformer/class-test-activity-object.php new file mode 100644 index 000000000..a8f8db5d9 --- /dev/null +++ b/tests/includes/transformer/class-test-activity-object.php @@ -0,0 +1,217 @@ +test_object = new Base_Object(); + $this->test_object->set_content( 'Test content with @mention and another @mention2' ); + $this->test_object->set_summary( 'Test summary with @mention3' ); + $this->test_object->set_name( 'Test name' ); + $this->test_object->set_type( 'Note' ); + } + + /** + * Test to_object method. + * + * @covers ::to_object + */ + public function test_to_object() { + $transformer = new Activity_Object( $this->test_object ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( Base_Object::class, $object ); + $this->assertEquals( 'Test content with @mention and another @mention2', $object->get_content() ); + $this->assertEquals( 'Test name', $object->get_name() ); + $this->assertEquals( 'Note', $object->get_type() ); + } + + /** + * Test get_mentions method. + * + * @covers ::get_mentions + */ + public function test_get_mentions() { + add_filter( + 'activitypub_extract_mentions', + function () { + return array( + '@mention' => 'https://example.com/@mention', + '@mention2' => 'https://example.com/@mention2', + '@mention3' => 'https://example.com/@mention3', + ); + }, + 10, + 2 + ); + + $transformer = new Activity_Object( $this->test_object ); + $mentions = $this->get_protected_method( $transformer, 'get_mentions' ); + + $this->assertIsArray( $mentions ); + $this->assertCount( 3, $mentions ); + $this->assertEquals( 'https://example.com/@mention', $mentions['@mention'] ); + + remove_all_filters( 'activitypub_extract_mentions' ); + } + + /** + * Test get_cc method. + * + * @covers ::get_cc + */ + public function test_get_cc() { + add_filter( + 'activitypub_extract_mentions', + function () { + return array( + '@mention' => 'https://example.com/@mention', + '@mention2' => 'https://example.com/@mention2', + ); + } + ); + + $transformer = new Activity_Object( $this->test_object ); + $object = $transformer->to_object(); + $cc = $object->get_cc(); + + $this->assertIsArray( $cc ); + $this->assertCount( 2, $cc ); + $this->assertContains( 'https://example.com/@mention', $cc ); + $this->assertContains( 'https://example.com/@mention2', $cc ); + + remove_all_filters( 'activitypub_extract_mentions' ); + } + + /** + * Test get_content_map method. + * + * @covers ::get_content_map + */ + public function test_get_content_map() { + $transformer = new Activity_Object( $this->test_object ); + $content_map = $this->get_protected_method( $transformer, 'get_content_map' ); + + $this->assertIsArray( $content_map ); + $this->assertArrayHasKey( $this->get_locale(), $content_map ); + $this->assertEquals( 'Test content with @mention and another @mention2', $content_map[ $this->get_locale() ] ); + + // Test with empty content. + $this->test_object->set_content( '' ); + $content_map = $this->get_protected_method( $transformer, 'get_content_map' ); + $this->assertNull( $content_map ); + } + + /** + * Test get_name_map method. + * + * @covers ::get_name_map + */ + public function test_get_name_map() { + $transformer = new Activity_Object( $this->test_object ); + $name_map = $this->get_protected_method( $transformer, 'get_name_map' ); + + $this->assertIsArray( $name_map ); + $this->assertArrayHasKey( $this->get_locale(), $name_map ); + $this->assertEquals( 'Test name', $name_map[ $this->get_locale() ] ); + + // Test with empty name. + $this->test_object->set_name( '' ); + $name_map = $this->get_protected_method( $transformer, 'get_name_map' ); + $this->assertNull( $name_map ); + } + + /** + * Test get_tag method. + * + * @covers ::get_tag + */ + public function test_get_tag() { + add_filter( + 'activitypub_extract_mentions', + function () { + return array( + '@mention' => 'https://example.com/@mention', + ); + } + ); + + $this->test_object->set_tag( + array( + array( + 'type' => 'Hashtag', + 'name' => '#test', + ), + ) + ); + + $transformer = new Activity_Object( $this->test_object ); + $tags = $this->get_protected_method( $transformer, 'get_tag' ); + + $this->assertIsArray( $tags ); + $this->assertCount( 2, $tags ); + + // Test hashtag. + $this->assertEquals( 'Hashtag', $tags[0]['type'] ); + $this->assertEquals( '#test', $tags[0]['name'] ); + + // Test mention. + $this->assertEquals( 'Mention', $tags[1]['type'] ); + $this->assertEquals( '@mention', $tags[1]['name'] ); + $this->assertEquals( 'https://example.com/@mention', $tags[1]['href'] ); + + remove_all_filters( 'activitypub_extract_mentions' ); + } + + /** + * Helper method to access protected methods. + * + * @param object $obj Object instance. + * @param string $method_name Method name. + * @param array $parameters Optional parameters. + * + * @return mixed Method result. + */ + protected function get_protected_method( $obj, $method_name, $parameters = array() ) { + $reflection = new \ReflectionClass( get_class( $obj ) ); + $method = $reflection->getMethod( $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( $obj, $parameters ); + } +} diff --git a/tests/includes/transformer/class-test-attachment.php b/tests/includes/transformer/class-test-attachment.php new file mode 100644 index 000000000..f1f2965c1 --- /dev/null +++ b/tests/includes/transformer/class-test-attachment.php @@ -0,0 +1,170 @@ +attachment->create_object( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + 'post_title' => 'Test Image', + 'post_content' => 'Test Image Description', + ) + ); + } + + /** + * Clean up after tests. + */ + public static function wpTearDownAfterClass() { + wp_delete_post( self::$attachment_id, true ); + } + + /** + * Test get_type method. + * + * @covers ::get_type + */ + public function test_get_type() { + $attachment = get_post( self::$attachment_id ); + $transformer = new Attachment( $attachment ); + $type = $this->get_protected_method( $transformer, 'get_type' ); + + $this->assertEquals( 'Note', $type ); + } + + /** + * Test get_attachment method with different mime types. + * + * @covers ::get_attachment + * @dataProvider provide_mime_types + * + * @param string $mime_type The mime type of the attachment. + * @param string $expected_type The expected type of the attachment. + */ + public function test_get_attachment( $mime_type, $expected_type ) { + $attachment_id = self::factory()->attachment->create_object( + array( + 'post_type' => 'attachment', + 'post_mime_type' => $mime_type, + ) + ); + + $attachment = get_post( $attachment_id ); + $transformer = new Attachment( $attachment ); + $result = $this->get_protected_method( $transformer, 'get_attachment' ); + + $this->assertIsArray( $result ); + $this->assertEquals( $expected_type, $result['type'] ); + $this->assertEquals( $mime_type, $result['mediaType'] ); + $this->assertArrayHasKey( 'url', $result ); + + wp_delete_post( $attachment_id, true ); + } + + /** + * Test get_attachment method with alt text. + * + * @covers ::get_attachment + */ + public function test_get_attachment_with_alt() { + $alt_text = 'Test Alt Text'; + update_post_meta( self::$attachment_id, '_wp_attachment_image_alt', $alt_text ); + + $attachment = get_post( self::$attachment_id ); + $transformer = new Attachment( $attachment ); + $result = $this->get_protected_method( $transformer, 'get_attachment' ); + + $this->assertArrayHasKey( 'name', $result ); + $this->assertEquals( $alt_text, $result['name'] ); + } + + /** + * Test to_object method. + * + * @covers ::to_object + */ + public function test_to_object() { + $attachment = get_post( self::$attachment_id ); + $transformer = new Attachment( $attachment ); + $object = $transformer->to_object(); + + $this->assertEquals( 'Note', $object->get_type() ); + $this->assertEquals( home_url( '?p=' . self::$attachment_id ), $object->get_id() ); + $this->assertNull( $object->get_name() ); + } + + /** + * Data provider for mime types. + * + * @return array Test data. + */ + public function provide_mime_types() { + return array( + 'image' => array( + 'image/jpeg', + 'Image', + ), + 'audio' => array( + 'audio/mpeg', + 'Document', + ), + 'video' => array( + 'video/mp4', + 'Document', + ), + 'pdf' => array( + 'application/pdf', + '', + ), + 'text' => array( + 'text/plain', + '', + ), + ); + } + + /** + * Helper method to access protected methods. + * + * @param object $obj Object instance. + * @param string $method_name Method name. + * @param array $parameters Optional parameters. + * + * @return mixed Method result. + */ + protected function get_protected_method( $obj, $method_name, $parameters = array() ) { + $reflection = new \ReflectionClass( get_class( $obj ) ); + $method = $reflection->getMethod( $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( $obj, $parameters ); + } +} diff --git a/tests/includes/transformer/class-test-factory.php b/tests/includes/transformer/class-test-factory.php new file mode 100644 index 000000000..59fff9c39 --- /dev/null +++ b/tests/includes/transformer/class-test-factory.php @@ -0,0 +1,217 @@ +post->create(); + + // Create test attachment. + self::$attachment_id = $factory->attachment->create_object( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + ) + ); + + self::$user_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + + // Create test comment. + self::$comment_id = $factory->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'user_id' => self::$user_id, + 'comment_meta' => array( + 'activitypub_status' => 'pending', + ), + ) + ); + } + + /** + * Clean up after tests. + */ + public static function wpTearDownAfterClass() { + wp_delete_post( self::$post_id, true ); + wp_delete_post( self::$attachment_id, true ); + wp_delete_comment( self::$comment_id, true ); + wp_delete_user( self::$user_id, true ); + } + + /** + * Test get_transformer with invalid input. + * + * @covers ::get_transformer + */ + public function test_get_transformer_invalid_input() { + $result = Factory::get_transformer( null ); + $this->assertWPError( $result ); + $this->assertEquals( 'invalid_object', $result->get_error_code() ); + } + + /** + * Test get_transformer with post. + * + * @covers ::get_transformer + */ + public function test_get_transformer_post() { + $post = get_post( self::$post_id ); + $transformer = Factory::get_transformer( $post ); + + $this->assertInstanceOf( Post::class, $transformer ); + } + + /** + * Test get_transformer with attachment. + * + * @covers ::get_transformer + */ + public function test_get_transformer_attachment() { + // Allow attachment to be federated. + \add_post_type_support( 'attachment', 'activitypub' ); + + $attachment = get_post( self::$attachment_id ); + $transformer = Factory::get_transformer( $attachment ); + + $this->assertInstanceOf( Attachment::class, $transformer ); + + // Remove support for attachment. + \remove_post_type_support( 'attachment', 'activitypub' ); + } + + /** + * Test get_transformer with comment. + * + * @covers ::get_transformer + */ + public function test_get_transformer_comment() { + $comment = get_comment( self::$comment_id ); + $transformer = Factory::get_transformer( $comment ); + + $this->assertInstanceOf( Comment::class, $transformer ); + } + + /** + * Test get_transformer with JSON data. + * + * @covers ::get_transformer + */ + public function test_get_transformer_json() { + $json_string = '{"type": "Note", "content": "Test"}'; + $transformer = Factory::get_transformer( $json_string ); + + $this->assertInstanceOf( Json::class, $transformer ); + + $json_array = array( + 'type' => 'Note', + 'content' => 'Test', + ); + $transformer = Factory::get_transformer( $json_array ); + + $this->assertInstanceOf( Json::class, $transformer ); + } + + /** + * Test get_transformer with custom filter. + * + * @covers ::get_transformer + */ + public function test_get_transformer_filter() { + add_filter( + 'activitypub_transformer', + // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.classFound + function ( $transformer, $data, $class ) { + if ( 'WP_Post' === $class && 'post' === $data->post_type ) { + return new Activity_Object( $data ); + } + return $transformer; + }, + 10, + 3 + ); + + $post = get_post( self::$post_id ); + $transformer = Factory::get_transformer( $post ); + + $this->assertInstanceOf( Activity_Object::class, $transformer ); + + remove_all_filters( 'activitypub_transformer' ); + } + + /** + * Test get_transformer with invalid filter return. + * + * @covers ::get_transformer + */ + public function test_get_transformer_invalid_filter() { + add_filter( + 'activitypub_transformer', + function () { + return 'invalid'; + } + ); + + $post = get_post( self::$post_id ); + $result = Factory::get_transformer( $post ); + + $this->assertWPError( $result ); + $this->assertEquals( 'invalid_transformer', $result->get_error_code() ); + + remove_all_filters( 'activitypub_transformer' ); + } +} diff --git a/tests/includes/transformer/class-test-json.php b/tests/includes/transformer/class-test-json.php new file mode 100644 index 000000000..d08f92a25 --- /dev/null +++ b/tests/includes/transformer/class-test-json.php @@ -0,0 +1,132 @@ + 'Note', + 'content' => 'Test Content', + 'id' => 'https://example.com/test', + ) + ); + + $transformer = new Json( $json_string ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( Base_Object::class, $object ); + $this->assertEquals( 'Note', $object->get_type() ); + $this->assertEquals( 'Test Content', $object->get_content() ); + $this->assertEquals( 'https://example.com/test', $object->get_id() ); + } + + /** + * Test constructor with array. + * + * @covers ::__construct + */ + public function test_constructor_with_array() { + $array = array( + 'type' => 'Article', + 'name' => 'Test Title', + 'content' => 'Test Content', + 'url' => 'https://example.com/article', + ); + + $transformer = new Json( $array ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( Base_Object::class, $object ); + $this->assertEquals( 'Article', $object->get_type() ); + $this->assertEquals( 'Test Title', $object->get_name() ); + $this->assertEquals( 'Test Content', $object->get_content() ); + $this->assertEquals( 'https://example.com/article', $object->get_url() ); + } + + /** + * Test constructor with invalid JSON string. + * + * @covers ::__construct + */ + public function test_constructor_with_invalid_json() { + $invalid_json = '{invalid json string}'; + + $transformer = new Json( $invalid_json ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( 'WP_Error', $object ); + } + + /** + * Test constructor with empty input. + * + * @covers ::__construct + */ + public function test_constructor_with_empty_input() { + $transformer = new Json( '' ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( 'WP_Error', $object ); + } + + /** + * Test constructor with complex nested data. + * + * @covers ::__construct + */ + public function test_constructor_with_nested_data() { + $data = array( + 'type' => 'Note', + 'content' => 'Test Content', + 'attachment' => array( + array( + 'type' => 'Image', + 'mediaType' => 'image/jpeg', + 'url' => 'https://example.com/image.jpg', + ), + ), + 'tag' => array( + array( + 'type' => 'Mention', + 'name' => '@test', + 'href' => 'https://example.com/@test', + ), + ), + ); + + $transformer = new Json( $data ); + $object = $transformer->to_object(); + + $this->assertInstanceOf( Base_Object::class, $object ); + $this->assertEquals( 'Note', $object->get_type() ); + $this->assertEquals( 'Test Content', $object->get_content() ); + + $attachment = $object->get_attachment(); + $this->assertIsArray( $attachment ); + $this->assertEquals( 'Image', $attachment[0]['type'] ); + + $tags = $object->get_tag(); + $this->assertIsArray( $tags ); + $this->assertEquals( 'Mention', $tags[0]['type'] ); + } +} diff --git a/tests/includes/transformer/class-test-post.php b/tests/includes/transformer/class-test-post.php index 803d86f5c..54242bbc3 100644 --- a/tests/includes/transformer/class-test-post.php +++ b/tests/includes/transformer/class-test-post.php @@ -320,8 +320,8 @@ public function test_content_visibility() { $this->assertTrue( \Activitypub\is_post_disabled( $post_id ) ); $object = Post::transform( get_post( $post_id ) )->to_object(); - $this->assertEquals( array(), $object->get_to() ); - $this->assertEquals( array(), $object->get_cc() ); + $this->assertEmpty( $object->get_to() ); + $this->assertEmpty( $object->get_cc() ); } /** diff --git a/tests/integration/class-test-enable-mastodon-apps.php b/tests/integration/class-test-enable-mastodon-apps.php index 3796614e4..900319fae 100644 --- a/tests/integration/class-test-enable-mastodon-apps.php +++ b/tests/integration/class-test-enable-mastodon-apps.php @@ -17,7 +17,7 @@ class Test_Enable_Mastodon_Apps extends \WP_UnitTestCase { /** - * Users. + * Actors. * * @var array[] */ @@ -128,6 +128,25 @@ public function test_api_account_followers_internal() { $this->assertEquals( 3, $account->followers_count ); } + /** + * Test api_status. + * + * @covers ::api_status + */ + public function test_api_status() { + $post_id = self::factory()->post->create( + array( + 'meta_input' => array( + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + ), + ) + ); + + $this->assertNull( Enable_Mastodon_Apps::api_status( null, $post_id ) ); + + \wp_delete_post( $post_id, true ); + } + /** * Filters the HTTP request before it is sent. * @@ -175,18 +194,6 @@ public static function pre_http_request( $preempt, $request, $url ) { return $preempt; } - /** - * Filters the HTTP response before it is returned. - * - * @param array|WP_Error $response HTTP response or WP_Error object. - * @param array $args HTTP request arguments. - * @param string $url The request URL. - * @return array|WP_Error - */ - public static function http_response( $response, $args, $url ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - return $response; - } - /** * Filters the remote metadata for a given URL. *