diff --git a/CHANGELOG.md b/CHANGELOG.md index 717120b4b..1ed6365da 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 @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Outbox queue * Comment counts get updated when the plugin is activated/deactivated/deleted * Added a filter to make custom comment types manageable in WP.com Calypso diff --git a/activitypub.php b/activitypub.php index 331957049..cb52d0c16 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. diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index ab765174f..a75647ff2 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -585,7 +585,7 @@ 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 +600,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-activitypub.php b/includes/class-activitypub.php index 756a2f82f..f0cb83166 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,102 @@ 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, + 'public' => true, + 'hierarchical' => true, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => true, + 'can_export' => true, + 'supports' => array(), + ) + ); + + /** + * 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_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-scheduler.php b/includes/class-scheduler.php index e5bd5802f..36ad1b809 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -7,6 +7,9 @@ namespace Activitypub; +use Activitypub\Scheduler\Post; +use Activitypub\Scheduler\Actor; +use Activitypub\Scheduler\Comment; use Activitypub\Collection\Followers; /** @@ -20,63 +23,27 @@ 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' ) ); - } + /** + * Register handlers. + */ + public static function register_schedulers() { + Post::init(); + Actor::init(); + Comment::init(); - // 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. - } + /** + * Register additional schedulers. + * + * @since 5.0.0 + */ + do_action( 'activitypub_register_schedulers' ); } /** @@ -102,125 +69,6 @@ public static function deregister_schedules() { 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 ); - } - } - /** * Update followers. */ @@ -290,69 +138,4 @@ public static function cleanup_followers() { } } } - - /** - * Send a profile update when relevant user meta is updated. - * - * @param int $meta_id Meta ID being updated. - * @param int $user_id User ID being updated. - * @param string $meta_key Meta key being updated. - */ - 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; - } - - // 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; - } - - 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 - */ - 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 ) - ); - } } 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)[^>]*?>.*?\\1>@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)[^>]*?>.*?\\1>@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-outbox.php b/includes/collection/class-outbox.php new file mode 100644 index 000000000..2fa6a47f4 --- /dev/null +++ b/includes/collection/class-outbox.php @@ -0,0 +1,77 @@ + self::POST_TYPE, + 'post_title' => $activity_object->get_id(), + 'post_content' => $activity_object->to_json(), + // ensure that user ID is not below 0. + 'post_author' => \max( $user_id, 0 ), + 'post_status' => 'draft', + '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/functions.php b/includes/functions.php index 53d02f206..380777e27 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -9,9 +9,11 @@ use WP_Error; use Activitypub\Activity\Activity; -use Activitypub\Collection\Followers; 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. @@ -1547,3 +1549,48 @@ 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 $type The type of the Activity. + * @param integer $user_id The User-ID. + * @param string $content_visibility The visibility of the content. + * + * @return boolean|int The ID of the outbox item or false on failure. + */ +function add_to_outbox( $data, $type = 'Create', $user_id = 0, $content_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { + $transformer = Transformer_Factory::get_transformer( $data ); + + if ( ! $transformer || is_wp_error( $transformer ) ) { + return false; + } + + $activity = $transformer->to_object(); + + if ( ! $activity || is_wp_error( $activity ) ) { + return false; + } + + set_wp_object_state( $data, 'federate' ); + + $id = Outbox::add( $activity, $type, $user_id, $content_visibility ); + + if ( ! $id ) { + return false; + } + + $hook = 'activitypub_process_outbox'; + $args = array( $id ); + + if ( false === wp_next_scheduled( $hook, $args ) ) { + \wp_schedule_single_event( + \time() + 10, + $hook, + $args + ); + } + + return $id; +} diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php new file mode 100644 index 000000000..59f2cbd16 --- /dev/null +++ b/includes/rest/class-outbox-controller.php @@ -0,0 +1,322 @@ +[\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 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $type = \get_post_meta( $item->ID, '_activitypub_activity_type', true ); + $transformer = Factory::get_transformer( $item->post_content ); + $activity = $transformer->to_activity( $type ); + + 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..e210c2eea --- /dev/null +++ b/includes/scheduler/class-actor.php @@ -0,0 +1,102 @@ +get_id(), '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..14e1ac325 --- /dev/null +++ b/includes/scheduler/class-post.php @@ -0,0 +1,113 @@ +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; + } + + // Do not send Activities if `$type` is not set or unknown. + if ( empty( $type ) ) { + return; + } + + // Get the content visibility. + $content_visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); + + // Add the post to the outbox. + add_to_outbox( $post, $type, $post->post_author, $content_visibility ); + } +} diff --git a/includes/transformer/class-activity-object.php b/includes/transformer/class-activity-object.php new file mode 100644 index 000000000..23e7cf0ad --- /dev/null +++ b/includes/transformer/class-activity-object.php @@ -0,0 +1,146 @@ +transform_object_properties( $this->item ); + } + + /** + * 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 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(); + $mentions = $this->get_mentions(); + + if ( $mentions ) { + foreach ( $mentions as $url ) { + $cc[] = $url; + } + } + + return $cc; + } + + /** + * 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..94537c9b9 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -11,8 +11,8 @@ use WP_Comment; use Activitypub\Activity\Activity; -use Activitypub\Activity\Base_Object; use Activitypub\Collection\Replies; +use Activitypub\Activity\Base_Object; /** * WordPress Base Transformer. @@ -26,6 +26,15 @@ 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; @@ -35,58 +44,80 @@ abstract class Base { * * 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 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(); - return $this->transform_object_properties( $activitypub_object ); + return $this->transform_object_properties( $activity_object ); } /** @@ -123,9 +154,45 @@ 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. + */ + 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'; + } + + /** + * Returns the recipient of the post. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to + * + * @return array The recipient URLs of the post. */ - abstract protected function get_id(); + protected function get_to() { + return array( + 'https://www.w3.org/ns/activitystreams#Public', + ); + } /** * Get the replies Collection. @@ -133,27 +200,47 @@ 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 The content map for the post. */ - public function get_media_type() { - return 'text/html'; + protected function get_content_map() { + return array( + $this->get_locale() => $this->get_content(), + ); } /** - * Returns the ID of the WordPress Object. + * Returns the name map for the post. + * + * @return array The name map for the post. */ - 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 The summary map for the post. */ - 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(), + ); + } } diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index 66a71afb4..36c4856f4 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -29,21 +29,41 @@ */ 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; /** - * Change the User-ID of the WordPress Comment. + * Transforms the WP_Comment object to an ActivityPub Object. + * + * @see \Activitypub\Activity\Base_Object * - * @param int $user_id The new user ID. + * @return \Activitypub\Activity\Base_Object The ActivityPub Object. */ - public function change_wp_user_id( $user_id ) { - $this->wp_object->user_id = $user_id; + 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; } /** @@ -59,12 +79,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 +90,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 +133,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,10 +161,39 @@ 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 the User-Object of the Author of the Post. + * + * If `single_user` mode is enabled, the Blog-User is returned. + * + * @return \Activitypub\Activity\Actor The User-Object. + */ + protected function get_actor_object() { + if ( $this->actor_object ) { + return $this->actor_object; + } + + $blog_user = new Blog(); + $this->actor_object = $blog_user; + + if ( is_single_user() ) { + return $blog_user; + } + + $user = Actors::get_by_id( $this->item->user_id ); + + if ( $user && ! is_wp_error( $user ) ) { + $this->actor_object = $user; + return $user; + } + + return $blog_user; + } + /** * Returns a list of Mentions, used in the Comment. * @@ -158,7 +202,9 @@ protected function get_id() { * @return array The list of Mentions. */ protected function get_cc() { - $cc = array(); + $cc = array( + $this->get_actor_object()->get_followers(), + ); $mentions = $this->get_mentions(); if ( $mentions ) { @@ -212,7 +258,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 +267,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 +287,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 +311,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 +333,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 ) ); } /** @@ -335,22 +360,11 @@ public function get_type() { * @return array The to of the comment. */ public function get_to() { - $path = sprintf( 'actors/%d/followers', intval( $this->wp_object->comment_author ) ); + $path = sprintf( 'actors/%d/followers', intval( $this->item->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..a4662eca4 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,12 @@ public static function get_transformer( $data ) { return new User( $data ); } break; + case 'Base_Object': + return new Activity_Object( $data ); + case 'json': + return new Json( $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..92974d5af --- /dev/null +++ b/includes/transformer/class-json.php @@ -0,0 +1,44 @@ +item->get( 'cc' ); + } +} diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index f3cbcf960..0a7fdb106 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -40,28 +40,6 @@ class Post extends Base { */ private $actor_object = null; - /** - * Returns the ID of the WordPress Post. - * - * @return int The ID of the WordPress Post - */ - public function get_wp_user_id() { - return $this->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 ); @@ -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,11 +336,11 @@ 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 ); } /** @@ -372,8 +350,8 @@ protected function get_attachment() { * * @return array The media array extended with enclosures. */ - public function get_enclosures( $media ) { - $enclosures = get_enclosures( $this->wp_object->ID ); + protected function get_enclosures( $media ) { + $enclosures = get_enclosures( $this->item->ID ); if ( ! $enclosures ) { return $media; @@ -423,7 +401,7 @@ protected function get_block_attachments( $media, $max_media ) { return array(); } - $blocks = \parse_blocks( $this->wp_object->post_content ); + $blocks = \parse_blocks( $this->item->post_content ); return $this->get_media_from_blocks( $blocks, $media ); } @@ -556,7 +534,7 @@ protected function get_classic_editor_image_embeds( $max_images ) { $images = array(); $base = get_upload_baseurl(); - $content = \get_post_field( 'post_content', $this->wp_object ); + $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. @@ -634,7 +612,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 +634,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 ]; @@ -813,13 +791,13 @@ protected function get_type() { 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 ); + $has_title = \post_type_supports( $this->item->post_type, 'title' ); + $content = \wp_strip_all_tags( $this->item->post_content ); // Check if the post has a title. if ( ! $has_title || - ! $this->wp_object->post_title || + ! $this->item->post_title || \strlen( $content ) <= ACTIVITYPUB_NOTE_LENGTH ) { return 'Note'; @@ -827,30 +805,17 @@ protected function get_type() { // Default to Note. $object_type = 'Note'; - $post_type = \get_post_type( $this->wp_object ); + $post_type = \get_post_type( $this->item ); if ( 'page' === $post_type ) { $object_type = 'Page'; - } elseif ( ! \get_post_format( $this->wp_object ) ) { + } elseif ( ! \get_post_format( $this->item ) ) { $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. * @@ -859,16 +824,8 @@ public function get_to() { * @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; - } - } + $cc = array_values( $this->get_mentions() ); + $cc[] = $this->get_actor_object()->get_followers(); return $cc; } @@ -899,7 +856,7 @@ public function get_audience() { protected function get_tag() { $tags = array(); - $post_tags = \get_the_tags( $this->wp_object->ID ); + $post_tags = \get_the_tags( $this->item->ID ); if ( $post_tags ) { foreach ( $post_tags as $post_tag ) { $tag = array( @@ -940,11 +897,11 @@ protected function get_summary() { } // Remove Teaser from drafts. - if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->wp_object ) ) { + if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { return \__( '(This post is being modified)', 'activitypub' ); } - return generate_post_summary( $this->wp_object ); + return generate_post_summary( $this->item ); } /** @@ -960,7 +917,7 @@ protected function get_name() { return null; } - $title = \get_the_title( $this->wp_object->ID ); + $title = \get_the_title( $this->item->ID ); if ( ! $title ) { return null; @@ -984,7 +941,7 @@ 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 ) ) { + if ( 'draft' === \get_post_status( $this->item ) ) { return \__( '(This post is being modified)', 'activitypub' ); } @@ -1002,7 +959,7 @@ protected function get_content() { add_filter( 'render_block_core/embed', array( $this, 'revert_embed_links' ), 10, 2 ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $post = $this->wp_object; + $post = $this->item; $content = $this->get_post_content_template(); // It seems that shortcodes are only applied to published posts. @@ -1065,9 +1022,9 @@ 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. + * @param WP_Post $item The WordPress post object being transformed. */ - return apply_filters( 'activitypub_object_content_template', $template, $this->wp_object ); + return apply_filters( 'activitypub_object_content_template', $template, $this->item ); } /** @@ -1088,32 +1045,11 @@ protected function get_mentions() { return apply_filters( 'activitypub_extract_mentions', array(), - $this->wp_object->post_content . ' ' . $this->wp_object->post_excerpt, - $this->wp_object + $this->item->post_content . ' ' . $this->item->post_excerpt, + $this->item ); } - /** - * 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. - */ - return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_object ); - } - /** * Returns the in-reply-to URL of the post. * @@ -1121,8 +1057,8 @@ public function get_locale() { * * @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 ); + 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'] ) ) { @@ -1139,8 +1075,8 @@ public function get_in_reply_to() { * * @return string The published date of the post. */ - public function get_published() { - $published = \strtotime( $this->wp_object->post_date_gmt ); + protected function get_published() { + $published = \strtotime( $this->item->post_date_gmt ); return \gmdate( 'Y-m-d\TH:i:s\Z', $published ); } @@ -1150,9 +1086,9 @@ public function get_published() { * * @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 ); + 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 ); @@ -1161,47 +1097,6 @@ public function get_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. * 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 b4fef8dcd..ab7ea665e 100644 --- a/readme.txt +++ b/readme.txt @@ -134,6 +134,7 @@ For reasons of data protection, it is not possible to see the followers of other = Unreleased = +* Added: Outbox queue * Changed: Improved content negotiation and AUTHORIZED_FETCH support for third-party plugins = 4.7.3 = 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..796063a53 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. * @@ -55,5 +65,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..145034963 100644 --- a/tests/includes/class-test-comment.php +++ b/tests/includes/class-test-comment.php @@ -293,6 +293,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 +365,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 +392,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 +449,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-scheduler.php b/tests/includes/class-test-scheduler.php deleted file mode 100644 index d7df36643..000000000 --- a/tests/includes/class-test-scheduler.php +++ /dev/null @@ -1,221 +0,0 @@ -post = self::factory()->post->create_and_get( - array( - 'post_title' => 'Test Post', - 'post_content' => 'Test Content', - 'post_status' => 'draft', - 'post_author' => 1, - ) - ); - } - - /** - * Clean up test resources after each test. - * - * Deletes the test post. - */ - public function tear_down() { - wp_delete_post( $this->post->ID, true ); - parent::tear_down(); - } - - /** - * Test that moving a draft post to trash does not schedule federation. - * - * @covers ::schedule_post_activity - */ - 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' - ); - } - - /** - * 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' - ); - - // Test with federation state. - \Activitypub\set_wp_object_state( $this->post, 'federated' ); - Scheduler::schedule_post_activity( 'trash', 'publish', $this->post ); - - $this->assertNotFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Delete' ) ), - 'Published to trash transition should schedule delete activity when federated' - ); - } - - /** - * 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 ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Update' ) ), - 'Draft to draft transition should not schedule federation' - ); - } - - /** - * Test that moving a published post to draft schedules an update activity. - * - * @covers ::schedule_post_activity - */ - 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' - ); - } - - /** - * 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 ); - - $this->assertNotFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Create' ) ), - 'Draft to publish transition should schedule create activity' - ); - } - - /** - * 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' - ); - } - - /** - * Test that various non-standard status transitions do not schedule federation. - * - * Tests transitions from pending, private, and future statuses. - * - * @covers ::schedule_post_activity - */ - 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' - ); - - // 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' - ); - } - - /** - * 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' - ); - } - - /** - * 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 ); - - $this->assertFalse( - wp_next_scheduled( 'activitypub_send_post', array( $this->post->ID, 'Create' ) ), - 'Password protected posts should not schedule federation activities' - ); - } -} 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/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php new file mode 100644 index 000000000..653300589 --- /dev/null +++ b/tests/includes/collection/class-test-outbox.php @@ -0,0 +1,74 @@ +assertIsInt( $id ); + + $post = get_post( $id ); + + $this->assertInstanceOf( 'WP_Post', $post ); + $this->assertEquals( 'draft', $post->post_status ); + $this->assertEquals( $json, $post->post_content ); + + $this->assertEquals( $type, get_post_meta( $id, '_activitypub_activity_type', true ) ); + $this->assertEquals( 'user', 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/1', + '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/1","type":"Note","content":"This is a note","contentMap":{"en":"This is a note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"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":"This is another note","contentMap":{"en":"This is another note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"mediaType":"text/html","sensitive":false}', + ), + ); + } +} 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..300bdbb58 --- /dev/null +++ b/tests/includes/rest/class-test-outbox-controller.php @@ -0,0 +1,709 @@ +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