diff --git a/activitypub.php b/activitypub.php index a52a6d5..3ae1258 100644 --- a/activitypub.php +++ b/activitypub.php @@ -30,6 +30,7 @@ function init() { require_once \dirname( __FILE__ ) . '/includes/model/class-activity.php'; require_once \dirname( __FILE__ ) . '/includes/model/class-post.php'; + require_once \dirname( __FILE__ ) . '/includes/model/class-comment.php'; require_once \dirname( __FILE__ ) . '/includes/class-activity-dispatcher.php'; \Activitypub\Activity_Dispatcher::init(); diff --git a/includes/activitypub.js b/includes/activitypub.js new file mode 100644 index 0000000..655be51 --- /dev/null +++ b/includes/activitypub.js @@ -0,0 +1,22 @@ +(function($) { + /** + * Reply Comment-edit screen + */ + + //Insert Mentions into comment content on reply + $('.comment-inline.button-link').on('click', function( event){ + // Summary/ContentWarning Syntax [CW] + var summary = $(this).attr('data-summary') ? '[' + $(this).attr('data-summary') + '] ' : ''; + var recipients = $(this).attr('data-recipients') ? $(this).attr('data-recipients') + ' ' : ''; + setTimeout(function() { + if ( summary || recipients ){ + $('#replycontent').val( summary + recipients ) + } + }, 100); + }) + //Clear Mentions from content on cancel + $('.cancel.button').on('click', function(){ + $('#replycontent').val(''); + }); + +})( jQuery ); \ No newline at end of file diff --git a/includes/class-activity-dispatcher.php b/includes/class-activity-dispatcher.php index a4ed0c7..64ee03b 100644 --- a/includes/class-activity-dispatcher.php +++ b/includes/class-activity-dispatcher.php @@ -16,6 +16,10 @@ class Activity_Dispatcher { \add_action( 'activitypub_send_post_activity', array( '\Activitypub\Activity_Dispatcher', 'send_post_activity' ) ); \add_action( 'activitypub_send_update_activity', array( '\Activitypub\Activity_Dispatcher', 'send_update_activity' ) ); \add_action( 'activitypub_send_delete_activity', array( '\Activitypub\Activity_Dispatcher', 'send_delete_activity' ) ); + + \add_action( 'activitypub_send_comment_activity', array( '\Activitypub\Activity_Dispatcher', 'send_comment_activity' ) ); + \add_action( 'activitypub_inbox_forward_activity', array( '\Activitypub\Activity_Dispatcher', 'inbox_forward_activity' ) ); + \add_action( 'activitypub_send_delete_comment_activity', array( '\Activitypub\Activity_Dispatcher', 'send_delete_comment_activity' ) ); } /** @@ -77,4 +81,126 @@ class Activity_Dispatcher { \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); } } + + /** + * Send "create" activities for comments + * + * @param \Activitypub\Model\Comment $activitypub_comment + */ + public static function send_comment_activity( $activitypub_comment_id ) { + //ONLY FOR LOCAL USERS ? + $activitypub_comment = \get_comment( $activitypub_comment_id ); + $user_id = $activitypub_comment->user_id; + $replyto = get_comment_meta( $activitypub_comment->comment_parent, 'comment_author_url', true );// + $mentions = get_comment_meta( $activitypub_comment_id, 'mentions', true );// + //error_log( 'dispatcher:send_comment:$activitypub_comment: ' . print_r( $activitypub_comment, true ) ); + + $activitypub_comment = new \Activitypub\Model\Comment( $activitypub_comment ); + $activitypub_activity = new \Activitypub\Model\Activity( 'Create', \Activitypub\Model\Activity::TYPE_FULL ); + $activitypub_activity->from_comment( $activitypub_comment->to_array() ); + + \error_log( 'Activity_Dispatcher::send_comment_activity: ' . print_r($activitypub_activity, true)); + foreach ( \Activitypub\get_follower_inboxes( $user_id ) as $inbox => $to ) { + \error_log( '$user_id: ' . $user_id . ', $inbox: '. $inbox . ', $to: '. print_r($to, true ) ); + $activitypub_activity->set_to( $to[0] ); + $activity = $activitypub_activity->to_json(); // phpcs:ignore + + // Send reply to followers, skip if replying to followers (avoid duplicate replies) + // if( in_array( $to, $replyto ) || ( $replyto == $to ) ) { + // break; + // } + \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); + } + // TODO: Reply (to followers and non-followers) + // if( is_array( $replyto ) && count( $replyto ) > 1 ) { + // foreach ( $replyto as $to ) { + // $inbox = \Activitypub\get_inbox_by_actor( $to ); + // $activitypub_activity->set_to( $to ); + // $activity = $activitypub_activity->to_json(); // phpcs:ignore + // error_log( 'dispatches->replyto: ' . $to ); + // \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); + // } + // } elseif ( !is_array( $replyto ) ) { + // $inbox = \Activitypub\get_inbox_by_actor( $to ); + // $activitypub_activity->set_to( $replyto ); + // $activity = $activitypub_activity->to_json(); // phpcs:ignore + // error_log( 'dispatch->replyto: ' . $replyto ); + // \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); + // } + + } + + /** + * Forward replies to followers + * + * @param \Activitypub\Model\Comment $activitypub_comment + */ + public static function inbox_forward_activity( $activitypub_comment_id ) { + //\error_log( 'Activity_Dispatcher::inbox_forward_activity' . print_r( $activitypub_comment, true ) ); + $activitypub_comment = \get_comment( $activitypub_comment_id ); + + //original author should NOT recieve a copy of ther own post + $replyto[] = $activitypub_comment->comment_author_url; + $activitypub_activity = unserialize( get_comment_meta( $activitypub_comment->comment_ID, 'ap_object', true ) ); + + //will be forwarded to the parent_comment->author or post_author followers collection + //TODO verify that ... what? + $parent_comment = \get_comment( $activitypub_comment->comment_parent ); + if ( !is_null( $parent_comment ) ) { + $user_id = $parent_comment->user_id; + } else { + $original_post = \get_post( $activitypub_comment->comment_post_ID ); + $user_id = $original_post->post_author; + } + + //remove user_id from $activitypub_comment + unset($activitypub_activity['user_id']); + + foreach ( \Activitypub\get_follower_inboxes( $user_id ) as $inbox => $to ) { + \error_log( '$user_id: ' . $user_id . ', $inbox: '. $inbox . ', $to: '. print_r($to, true ) ); + + //Forward reply to followers, skip sender + if( in_array( $to, $replyto ) || ( $replyto == $to ) ) { + error_log( 'dispatch:forward: nope:' . print_r( $to, true ) ); + break; + } + + $activitypub_activity['object']['to'] = $to; + $activitypub_activity['to'] = $to; + + //$activitypub_activity + //$activitypub_activity->set_to( $to ); + //$activity = $activitypub_activity->to_json(); // phpcs:ignore + + $activity = \wp_json_encode( $activitypub_activity, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT ); + error_log( 'dispatch:forward:activity:' . print_r( $activity, true ) ); + \Activitypub\forward_remote_post( $inbox, $activity, $user_id ); + + //reset //unnecessary + //array_pop( $activitypub_activity->object->to[] ); + //array_pop( $activitypub_activity->to[] ); + } + } + + /** + * Send "delete" activities. + * + * @param \Activitypub\Model\Comment $activitypub_comment + */ + public static function send_delete_comment_activity( $activitypub_comment_id ) { + // get comment + $activitypub_comment = \get_comment( $activitypub_comment_id ); + $user_id = $activitypub_comment->post_author; + + $activitypub_comment = new \Activitypub\Model\Comment( $activitypub_comment ); + $activitypub_activity = new \Activitypub\Model\Activity( 'Delete', \Activitypub\Model\Activity::TYPE_FULL ); + $activitypub_activity->from_comment( $activitypub_comment->to_array() ); + + foreach ( \Activitypub\get_follower_inboxes( $user_id ) as $inbox => $to ) { + $activitypub_activity->set_to( $to ); + $activity = $activitypub_activity->to_json(); // phpcs:ignore + + \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); + } + } } diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index c2a068d..6393aef 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -24,6 +24,11 @@ class Activitypub { } \add_action( 'transition_post_status', array( '\Activitypub\Activitypub', 'schedule_post_activity' ), 10, 3 ); + + \add_filter( 'preprocess_comment' , array( '\Activitypub\Activitypub', 'preprocess_comment' ) ); + \add_filter( 'comment_post' , array( '\Activitypub\Activitypub', 'postprocess_comment' ), 10, 3 ); + \add_action( 'transition_comment_status', array( '\Activitypub\Activitypub', 'schedule_comment_activity' ), 20, 3 ); + } /** @@ -126,6 +131,109 @@ class Activitypub { } } + /** + * preprocess local comments for federated replies + */ + public static function preprocess_comment( $commentdata ) { + + //must only process replies from local actors + if ( !empty( $commentdata['user_id'] ) ) { + //\error_log( 'is_local user' );//TODO Test + //TODO TEST + $post_type = \get_object_subtype( 'post', $commentdata['comment_post_ID'] ); + $ap_post_types = \get_option( 'activitypub_support_post_types' ); + if ( !\is_null( $ap_post_types ) ) { + if ( in_array( $post_type, $ap_post_types ) ) { + $commentdata['comment_type'] = 'activitypub'; + // transform webfinger mentions to links and add @mentions to cc + $tagged_content = \Activitypub\transform_tags( $commentdata['comment_content'] ); + $commentdata['comment_content'] = $tagged_content['content']; + $commentdata['comment_meta']['mentions'] = $tagged_content['mentions']; + } + } + } + return $commentdata; + } + + /** + * postprocess_comment for federating replies and inbox-forwarding + */ + public static function postprocess_comment( $comment_id, $comment_approved, $commentdata ) { + //Admin users comments bypass transition_comment_status (auto approved) + + //\error_log( 'postprocess_comment_handler: comment_status: ' . $comment_approved ); + if ( $commentdata['comment_type'] === 'activitypub' ) { + if ( + ( $comment_approved === 1 ) && + ! empty( $commentdata['user_id'] ) && + ( $user = get_userdata( $commentdata['user_id'] ) ) && // get the user data + in_array( 'administrator', $user->roles ) // check the roles + ) { + // Only for Admins? + $mentions = \get_comment_meta( $comment_id, 'mentions', true ); + //\ActivityPub\Activity_Dispatcher::send_comment_activity( $comment_id ); // performance > followers collection + \wp_schedule_single_event( \time(), 'activitypub_send_comment_activity', array( $comment_id ) ); + + } else { + // TODO check that this is unused + // TODO comment test as anon + // TODO comment test as registered + // TODO comment test as anyother site settings + + + // $replyto = get_comment_meta( $comment_id, 'replyto', true ); + + //inbox forward prep + // if ( !empty( $ap_object ) ) { + // //if is remote user (has ap_object) + // //error_log( print_r( $ap_object, true ) ); + // // TODO verify that deduplication check happens at object create. + + // //if to/cc/audience contains local followers collection + // //$local_user = \get_comment_author_url( $comment_id ); + // //$is_local_user = \Activitypub\url_to_authorid( $commentdata['comment_author_url'] ); + + // } + } + } + } + + /** + * Schedule Activities + * + * @param int $comment + */ + public static function schedule_comment_activity( $new_status, $old_status, $activitypub_comment ) { + + // TODO format $activitypub_comment = new \Activitypub\Model\Comment( $comment ); + if ( 'approved' === $new_status && 'approved' !== $old_status ) { + //should only federate replies from local actors + //should only federate replies to federated actors + + $ap_object = unserialize( \get_comment_meta( $activitypub_comment->comment_ID, 'ap_object', true ) ); + if ( empty( $ap_object ) ) { + \wp_schedule_single_event( \time(), 'activitypub_send_comment_activity', array( $activitypub_comment->comment_ID ) ); + } else { + $local_user = \get_author_posts_url( $ap_object['user_id'] ); + if ( !is_null( $local_user ) ) { + if ( in_array( $local_user, $ap_object['to'] ) + || in_array( $local_user, $ap_object['cc'] ) + || in_array( $local_user, $ap_object['audience'] ) + || in_array( $local_user, $ap_object['tag'] ) + ) { + //if inReplyTo, object, target and/or tag are (local-wp) objects + //\ActivityPub\Activity_Dispatcher::inbox_forward_activity( $activitypub_comment ); + \wp_schedule_single_event( \time(), 'activitypub_inbox_forward_activity', array( $activitypub_comment->comment_ID ) ); + } + } + } + } elseif ( 'trash' === $new_status ) { + \wp_schedule_single_event( \time(), 'activitypub_send_delete_comment_activity', array( $activitypub_comment ) ); + } else { + } + } + + /** * Replaces the default avatar. * @@ -143,7 +251,7 @@ class Activitypub { return $args; } - $allowed_comment_types = \apply_filters( 'get_avatar_comment_types', array( 'comment' ) ); + $allowed_comment_types = \apply_filters( 'get_avatar_comment_types', array( 'comment', 'activitypub' ) ); if ( ! empty( $id_or_email->comment_type ) && ! \in_array( $id_or_email->comment_type, (array) $allowed_comment_types, true ) ) { $args['url'] = false; /** This filter is documented in wp-includes/link-template.php */ diff --git a/includes/class-admin.php b/includes/class-admin.php index 14ed13c..7016336 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -14,6 +14,9 @@ class Admin { \add_action( 'admin_menu', array( '\Activitypub\Admin', 'admin_menu' ) ); \add_action( 'admin_init', array( '\Activitypub\Admin', 'register_settings' ) ); \add_action( 'show_user_profile', array( '\Activitypub\Admin', 'add_fediverse_profile' ) ); + \add_action( 'admin_enqueue_scripts', array( '\Activitypub\Admin', 'scripts_reply_comments' ), 10, 2 ); + \add_filter( 'comment_row_actions', array( '\Activitypub\Admin', 'reply_comments_actions' ), 10, 2 ); + } /** @@ -139,4 +142,54 @@ class Admin { ID ); } + + public static function reply_comments_actions( $actions, $comment ) { + //unset( $actions['reply'] ); + $recipients = \Activitypub\get_recipients( $comment->comment_ID ); + $summary = \Activitypub\get_summary( $comment->comment_ID ); + + //TODO revise for non-js reply action + // Public Reply + $reply_button = ''; + $actions['reply'] = sprintf( + $reply_button, + $comment->comment_ID, + $comment->comment_post_ID, + 'replyto', + 'vim-r comment-inline', + esc_attr__( 'Reply to this comment' ), + $recipients, + $summary, + __( 'Reply', 'activitypub' ) + ); + + // Private + // $actions['private_reply'] = sprintf( + // $format, + // $comment->comment_ID, + // $comment->comment_post_ID, + // 'private_replyto', + // 'vim-r comment-inline', + // esc_attr__( 'Reply in private to this comment' ), + // $recipients, + // $summary, + // __( 'Private reply', 'activitypub' ) + // ); + + return $actions; + } + + public static function scripts_reply_comments( $hook ) { + if ('edit-comments.php' !== $hook) { + return; + } + wp_enqueue_script( 'activitypub_client', + plugin_dir_url(__FILE__) . '/activitypub.js', + array('jquery'), + filemtime( plugin_dir_path(__FILE__) . '/activitypub.js' ), + true + ); + } + + } diff --git a/includes/functions.php b/includes/functions.php index a5a8495..7a30b59 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1,6 +1,8 @@ 100, + 'limit_response_size' => 1048576, + 'redirection' => 3, + 'user-agent' => "$user_agent; ActivityPub", + 'headers' => array( + 'Accept' => 'application/activity+json', + 'Content-Type' => 'application/activity+json', + 'Digest' => "SHA-256=$digest", + 'Signature' => $signature, + 'Date' => $date, + ), + 'body' => $body, + ); + + $response = \wp_safe_remote_post( $url, $args ); + + \do_action( 'activitypub_forward_remote_post_response', $response, $url, $body, $user_id ); + + return $response; +} + function safe_remote_get( $url, $user_id ) { $date = \gmdate( 'D, d M Y H:i:s T' ); $signature = \Activitypub\Signature::generate_signature( $user_id, $url, $date ); @@ -300,3 +330,199 @@ function url_to_authorid( $url ) { return 0; } +/** + * Verify if url is a local comment, + * Or if it is a previously received remote comment + * + * return int comment_id + */ +function url_to_commentid( $comment_url ) { + if ( empty( $comment_url ) ) { + return null; + } + $post_url = \url_to_postid( $comment_url ); + + if ( $post_url ) { + //for local comment parent + $comment_id = explode( '#comment-', $comment_url ); + if ( isset( $comment_id[1] ) ){ + return $comment_id[1]; + } else { + return null; + } + + } else { + //remote comment parent, assuming the parent was also recieved + //Compare inReplyTo with source_url from meta, to determine if local comment_id exists for peer replied object + $comment_args = array( + 'type' => 'activitypub', + 'meta_query' => array( + array( + 'key' => 'source_url', + 'value' => $comment_url, + ) + ) + ); + $comments_query = new \WP_Comment_Query; + $comments = $comments_query->query( $comment_args ); + $found_comment_ids = array(); + if ( $comments ) { + foreach ( $comments as $comment ) { + $found_comment_ids[] = $comment->comment_ID; + } + return $found_comment_ids[0]; + } + return null; + } +} + +/** + * Get tagged users from received AP object meta + * @param string $object_id a comment_id to search + * @param boolean $post defaults to searching a comment_id + * + * @return array of tagged users + */ +function get_recipients( $object_id, $post = null ) { + $tagged_users_name = null; + if ( $post ) { + //post + $ap_object = \unserialize( \get_post_meta( $object_id, 'ap_object', true ) ); + } else { + //comment + $ap_object = \unserialize( \get_comment_meta( $object_id, 'ap_object', true ) ); + } + + if ( !empty( $ap_object ) ) { + $tagged_users_name[] = \Activitypub\url_to_webfinger( $ap_object['actor'] ); + if ( !empty( $ap_object['object']['tag'] ) ) { + $author_post_url = \get_author_posts_url( $ap_object['user_id'] ); + foreach ( $ap_object['object']['tag'] as $tag ) { + if ( $author_post_url == $tag['href'] ) { + continue; + } + if ( in_array( 'Mention', $tag ) ) { + $tagged_users_name[] = $tag['name']; + } + } + } + return implode( ' ', $tagged_users_name ); + } +} + +/** + * Add summary to reply + */ +function get_summary( $comment_id ) { + $ap_object = \unserialize( \get_comment_meta( $comment_id, 'ap_object', true ) ); + if ( !empty( $ap_object ) ) { + if ( !empty( $ap_object['object']['summary'] ) ) { + return \esc_attr( $ap_object['object']['summary'] ); + } + } +} + +/** + * parse content for tags to transform + * @param string $content to search + */ +function transform_tags( $content ) { + //#tags + + //@Mentions + $mentions = null; + $webfinger_tags = \Activitypub\webfinger_extract( $content, true ); + if ( !empty( $webfinger_tags) ) { + foreach ( $webfinger_tags[0] as $webfinger_tag ) { + $ap_profile = \Activitypub\Rest\Webfinger::webfinger_lookup( $webfinger_tag ); + if ( ! empty( $ap_profile ) ) { + $short_tag = \Activitypub\webfinger_short_tag( $webfinger_tag ); + $webfinger_link = "{$short_tag}"; + $content = str_replace( $webfinger_tag, $webfinger_link, $content ); + $mentions[] = $ap_profile; + } + } + } + // Return mentions separately to attach to comment/post meta + $content_mentions['mentions'] = $mentions; + $content_mentions['content'] = $content; + return $content_mentions; +} + +function tag_user( $recipient ) { + $tagged_user = array( + 'type' => 'Mention', + 'href' => $recipient, + 'name' => \Activitypub\url_to_webfinger( $recipient ), + ); + $tag[] = $tagged_user; + return $tag; +} + +/** + * @param string $content + * @return array of all matched webfinger + */ +function webfinger_extract( $string ) { + preg_match_all("/@[\._a-zA-Z0-9-]+@[\._a-zA-Z0-9-]+/i", $string, $matches); + return $matches; +} + +/** + * @param string full $webfinger + * @return string short @webfinger + */ +function webfinger_short_tag( $webfinger ) { + $short_tag = explode( '@', $webfinger ); + return '@' . $short_tag[1]; +} + +/** + * @param string $user_url + * @return string $webfinger + */ +function url_to_webfinger( $user_url ) { + $user_url = \untrailingslashit( $user_url ); + $user_url_array = explode( '/', $user_url ); + $user_name = end( $user_url_array ); + $url_host = parse_url( $user_url , PHP_URL_HOST ); + $webfinger = '@' . $user_name . '@' . $url_host; + return $webfinger; +} + +/** + * Transform comment url, replace #fragment with ?query + * + * AP Object ID must be unique + * + * https://www.w3.org/TR/activitypub/#obj-id + * https://github.com/tootsuite/mastodon/issues/13879 + */ +function normalize_comment_url( $comment ) { + $comment_id = explode( '#comment-', \get_comment_link( $comment ) ); + $comment_id = $comment_id[0] . '?comment-' . $comment_id[1]; + return $comment_id; +} + +/** + * Determine AP audience of incoming object + * @param string $object + * @return string audience + */ +function get_audience( $object ) { + if ( in_array( AS_PUBLIC, $object['to'] ) ) { + return 'public'; + } + if ( in_array( AS_PUBLIC, $object['cc'] ) ) { + return 'unlisted';//is unlisted even relevant? + } + if ( !in_array( AS_PUBLIC, $object['to'] ) && !in_array( AS_PUBLIC, $object['cc'] ) ) { + $author_post_url = get_author_posts_url( $object['user_id'] ); + if ( in_array( $author_post_url, $object['cc'] ) ) { + return 'followers_only'; + } + if ( in_array( $author_post_url, $object['to'] ) ) { + return 'private'; + } + } +} diff --git a/includes/model/class-activity.php b/includes/model/class-activity.php index 1b5931e..931367a 100644 --- a/includes/model/class-activity.php +++ b/includes/model/class-activity.php @@ -61,7 +61,12 @@ class Activity { } public function from_comment( $object ) { - + $this->object = $object; + $this->published = $object['published']; + $this->actor = $object['attributedTo']; + $this->id = $object['id'] . '-activity'; + $this->cc = $object['cc']; + $this->tag = $object['tag']; } public function to_comment() { diff --git a/includes/model/class-comment.php b/includes/model/class-comment.php new file mode 100644 index 0000000..fa42f70 --- /dev/null +++ b/includes/model/class-comment.php @@ -0,0 +1,167 @@ +comment = $comment; + + $this->comment_author_url = \get_author_posts_url( $this->comment->user_id ); + $this->safe_comment_id = $this->generate_comment_id(); + $this->inReplyTo = $this->generate_parent_url(); + $this->contentWarning = $this->generate_content_warning(); + $this->permalink = $this->generate_permalink(); + $this->cc_recipients = $this->generate_recipients(); + $this->tags = $this->generate_tags(); + } + + public function __call( $method, $params ) { + $var = \strtolower( \substr( $method, 4 ) ); + + if ( \strncasecmp( $method, 'get', 3 ) === 0 ) { + return $this->$var; + } + + if ( \strncasecmp( $method, 'set', 3 ) === 0 ) { + $this->$var = $params[0]; + } + } + + public function to_array() { + $comment = $this->comment; + + $array = array( + 'id' => \Activitypub\Model\Comment::normalize_comment_id( $comment ), + 'type' => 'Note', + 'published' => \date( 'Y-m-d\TH:i:s\Z', \strtotime( $comment->comment_date_gmt ) ), + 'attributedTo' => $this->comment_author_url, + 'summary' => $this->contentWarning, + 'inReplyTo' => $this->inReplyTo, + 'content' => $comment->comment_content, + 'contentMap' => array( + \strstr( \get_locale(), '_', true ) => $comment->comment_content, + ), + 'source' => \get_comment_link( $comment ), + 'url' => \get_comment_link( $comment ),//link for mastodon + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),//audience logic + 'cc' => $this->cc_recipients, + 'tag' => $this->tags, + ); + + return \apply_filters( 'activitypub_comment', $array ); + } + + public function to_json() { + return \wp_json_encode( $this->to_array(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT ); + } + + public function generate_comment_author_link() { + return \get_author_posts_url( $this->comment->comment_author ); + } + + public function generate_permalink() { + $comment = $this->comment; + $permalink = \get_comment_link( $comment ); + + // replace 'trashed' for delete activity + return \str_replace( '__trashed', '', $permalink ); + } + + /** + * What is status is being replied to + * Comment ID or Post ID + */ + public function generate_parent_url() { + $comment = $this->comment; + $parent_comment = \get_comment( $comment->comment_parent ); + if ( $parent_comment ) { + //reply to local (received) comment + $inReplyTo = \get_comment_meta( $comment->comment_parent, 'source_url', true ); + } else { + //reply to local post + $inReplyTo = \get_permalink( $comment->comment_post_ID ); + } + return $inReplyTo; + } + + /** + * Generate Content Warning from peer + * If peer used CW let's just copy it + * TODO: Move to preprocess_comment / row_actions + * Add option for wrapping CW in Details/Summary markup + * Figure out some CW syntax: [shortcode-style], {brackets-style}? + * So it can be inserted into reply textbox, and removed or modified at will + */ + public function generate_content_warning() { + $comment = $this->comment; + $contentWarning = null; + $parent_comment = \get_comment( $comment->comment_parent ); + if ( $parent_comment ) { + //get (received) comment + $ap_object = \unserialize( \get_comment_meta( $comment->comment_parent, 'ap_object', true ) ); + if ( isset( $ap_object['object']['summary'] ) ) { + $contentWarning = $ap_object['object']['summary']; + } + } + /*$summary = \get_comment_meta( $this->comment->comment_ID, 'summary', true ) ; + if ( !empty( $summary ) ) { + $contentWarning = \Activitypub\add_summary( $summary ); + } */ + return $contentWarning; + } + + /** + * Who is being replied to + */ + public function generate_recipients() { + //TODO Add audience logic get parent audience + $recipients = array( AS_PUBLIC ); + $mentions = \get_comment_meta( $this->comment->comment_ID, 'mentions', true ) ; + if ( !empty( $mentions ) ) { + foreach ($mentions as $mention) { + $recipients[] = $mention['href']; + } + } + return $recipients; + } + + /** + * Mention user being replied to + */ + public function generate_tags() { + $mentions = \get_comment_meta( $this->comment->comment_ID, 'mentions', true ) ; + if ( !empty( $mentions ) ) { + foreach ($mentions as $mention) { + $mention_tags[] = array( + 'type' => 'Mention', + 'href' => $mention['href'], + 'name' => '@' . $mention['name'], + ); + } + return $mention_tags; + } + } + + /** + * Transform comment url, replace #fragment with ?query + * + * AP Object ID must be unique + * + * https://www.w3.org/TR/activitypub/#obj-id + * https://github.com/tootsuite/mastodon/issues/13879 + */ + public function normalize_comment_id( $comment ) { + $comment_id = explode( '#comment-', \get_comment_link( $comment ) ); + $comment_id = $comment_id[0] . '?comment-' . $comment_id[1]; + return $comment_id; + } +} \ No newline at end of file diff --git a/includes/rest/class-inbox.php b/includes/rest/class-inbox.php index 4dcbd8c..5818b4c 100644 --- a/includes/rest/class-inbox.php +++ b/includes/rest/class-inbox.php @@ -395,41 +395,81 @@ class Inbox { */ public static function handle_create( $object, $user_id ) { $meta = \Activitypub\get_remote_metadata_by_actor( $object['actor'] ); + $avatar_url = null; + $audience = \Activitypub\get_audience( $object ); - $comment_post_id = \url_to_postid( $object['object']['inReplyTo'] ); - - // save only replys and reactions - if ( ! $comment_post_id ) { - return false; + //Determine parent post and/or parent comment + $comment_post_ID = $object_parent = $object_parent_ID = 0; + if ( isset( $object['object']['inReplyTo'] ) ) { + $comment_post_ID = \url_to_postid( $object['object']['inReplyTo'] ); + //if not a direct reply to a post, remote post parent + if ( $comment_post_ID === 0 ) { + //verify if reply to a local or remote received comment + $object_parent_ID = \Activitypub\url_to_commentid( \esc_url_raw( $object['object']['inReplyTo'] ) ); + if ( !is_null( $object_parent_ID ) ) { + //replied to a local comment (which has a post_ID) + $object_parent = get_comment( $object_parent_ID ); + $comment_post_ID = $object_parent->comment_post_ID; + } + } } - $commentdata = array( - 'comment_post_ID' => $comment_post_id, - 'comment_author' => \esc_attr( $meta['name'] ), - 'comment_author_url' => \esc_url_raw( $object['actor'] ), - 'comment_content' => \wp_filter_kses( $object['object']['content'] ), - 'comment_type' => '', - 'comment_author_email' => '', - 'comment_parent' => 0, - 'comment_meta' => array( - 'source_url' => \esc_url_raw( $object['object']['url'] ), - 'avatar_url' => \esc_url_raw( $meta['icon']['url'] ), - 'protocol' => 'activitypub', - ), - ); + //not all implementaions use url + if ( isset( $object['object']['url'] ) ) { + $source_url = \esc_url_raw( $object['object']['url'] ); + } else { + //could also try $object['object']['source']? + $source_url = \esc_url_raw( $object['object']['id'] ); + } - // disable flood control - \remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); + // if no name is set use peer username + if ( !empty( $meta['name'] ) ) { + $name = \esc_attr( $meta['name'] ); + } else { + $name = \esc_attr( $meta['preferredUsername'] ); + } + // if avatar is set + if ( !empty( $meta['icon']['url'] ) ) { + $avatar_url = \esc_attr( $meta['icon']['url'] ); + } - // do not require email for AP entries - \add_filter( 'pre_option_require_name_email', '__return_false' ); + //Only create WP_Comment for public replies to local posts + if ( ( in_array( AS_PUBLIC, $object['to'] ) + || in_array( AS_PUBLIC, $object['cc'] ) ) + && ( !empty( $comment_post_ID ) + || !empty ( $object_parent ) + ) ) { - $state = \wp_new_comment( $commentdata, true ); + $commentdata = array( + 'comment_post_ID' => $comment_post_ID, + 'comment_author' => $name, + 'comment_author_url' => \esc_url_raw( $object['actor'] ), + 'comment_content' => \wp_filter_kses( $object['object']['content'] ), + 'comment_type' => 'activitypub', + 'comment_author_email' => '', + 'comment_parent' => $object_parent_ID, + 'comment_meta' => array( + 'ap_object' => \serialize( $object ), + 'source_url' => $source_url, + 'avatar_url' => $avatar_url, + 'protocol' => 'activitypub', + ), + ); - \remove_filter( 'pre_option_require_name_email', '__return_false' ); + // disable flood control + \remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); - // re-add flood control - \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + // do not require email for AP entries + \add_filter( 'pre_option_require_name_email', '__return_false' ); + + $state = \wp_new_comment( $commentdata, true ); + + \remove_filter( 'pre_option_require_name_email', '__return_false' ); + + // re-add flood control + \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + + } } public static function extract_recipients( $data ) { diff --git a/includes/rest/class-webfinger.php b/includes/rest/class-webfinger.php index b5d3e4c..40e2736 100644 --- a/includes/rest/class-webfinger.php +++ b/includes/rest/class-webfinger.php @@ -15,6 +15,8 @@ class Webfinger { public static function init() { \add_action( 'rest_api_init', array( '\Activitypub\Rest\Webfinger', 'register_routes' ) ); \add_action( 'webfinger_user_data', array( '\Activitypub\Rest\Webfinger', 'add_webfinger_discovery' ), 10, 3 ); + \add_action( 'webfinger_lookup', array( '\Activitypub\Rest\Webfinger', 'webfinger_lookup' ), 10, 3 ); + } /** @@ -117,4 +119,36 @@ class Webfinger { return $array; } + + /** + * WebFinger Lookup to find user uri + * + * @param string $resource the WebFinger resource + */ + public static function webfinger_lookup( $webfinger ) { + $activity_profile = null; + if ( \substr($webfinger, 0, 1) === '@' ) { + $webfinger = substr( $webfinger, 1 ); + } + $url_host = \explode( '@', $webfinger ); + $webfinger_query = 'https://' . \end( $url_host ) . '/.well-known/webfinger?resource=acct%3A' . \urlencode( $webfinger ); + + $response = \wp_safe_remote_get( $webfinger_query ); + if ( ! is_wp_error( $response ) ) { + $ap_link = json_decode( $response['body'] ); + if ( isset( $ap_link->links ) ) { + foreach ( $ap_link->links as $link ) { + if ( !property_exists( $link, 'type' ) ) { + continue; + } + if ( isset( $link->type ) && $link->type === 'application/activity+json' ) { + $activity_profile['href'] = $link->href; + $activity_profile['name'] = $webfinger; + } + } + } + } + + return $activity_profile; + } }