From 8938c670736b03fa4707fa1dc7235fa100305c9a Mon Sep 17 00:00:00 2001 From: Django Doucet Date: Thu, 14 Apr 2022 19:04:43 -0600 Subject: [PATCH] Comments update --- includes/class-activity-dispatcher.php | 79 +++++++------- includes/class-activitypub.php | 91 +++++++++++----- includes/functions.php | 126 +++++++++++++++++----- includes/model/class-activity.php | 10 +- includes/model/class-comment.php | 138 +++++++++++++++++++++---- includes/model/class-post.php | 85 ++++++++++++++- includes/rest/class-inbox.php | 121 ++++++++++++++++++---- templates/comment-json.php | 36 +++++++ 8 files changed, 546 insertions(+), 140 deletions(-) create mode 100644 templates/comment-json.php diff --git a/includes/class-activity-dispatcher.php b/includes/class-activity-dispatcher.php index 64ee03b..7cd3329 100644 --- a/includes/class-activity-dispatcher.php +++ b/includes/class-activity-dispatcher.php @@ -18,6 +18,7 @@ class Activity_Dispatcher { \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_send_update_comment_activity', array( '\Activitypub\Activity_Dispatcher', 'send_update_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' ) ); } @@ -48,8 +49,14 @@ class Activity_Dispatcher { * @param \Activitypub\Model\Post $activitypub_post */ public static function send_update_activity( $activitypub_post ) { + // save permalink for delete + $post_id = \url_to_postid( $activitypub_post->get_id() ); + //shouldn't this go in schedule_*_activity? yeah + \update_post_meta( $post_id, '_ap_deleted_slug', $activitypub_post->get_id() ); + // get latest version of post $user_id = $activitypub_post->get_post_author(); + $updated = \wp_date( 'Y-m-d\TH:i:s\Z', \strtotime( $activitypub_post->get_updated() ) ); $activitypub_activity = new \Activitypub\Model\Activity( 'Update', \Activitypub\Model\Activity::TYPE_FULL ); $activitypub_activity->from_post( $activitypub_post->to_array() ); @@ -70,14 +77,17 @@ class Activity_Dispatcher { public static function send_delete_activity( $activitypub_post ) { // get latest version of post $user_id = $activitypub_post->get_post_author(); + $deleted = \current_time( 'Y-m-d\TH:i:s\Z', true ); + $activitypub_post->set_deleted( $deleted ); $activitypub_activity = new \Activitypub\Model\Activity( 'Delete', \Activitypub\Model\Activity::TYPE_FULL ); $activitypub_activity->from_post( $activitypub_post->to_array() ); + $activitypub_activity->set_deleted( $deleted ); 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 ); } } @@ -91,43 +101,31 @@ class Activity_Dispatcher { //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 ) ); + $replyto = get_comment_meta( $activitypub_comment->comment_parent, 'comment_author_url', true );// must include in replyto + $mentions = get_comment_meta( $activitypub_comment_id, 'mentions', true );//might be tagged $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] ); + foreach ( \Activitypub\get_mentioned_inboxes( $mentions ) as $inbox => $to ) { + $activitypub_activity->set_to( $to );//all users at shared inbox + $activity = $activitypub_activity->to_json(); // phpcs:ignore + + \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); + } + //will this reset the activities? + + foreach ( \Activitypub\get_follower_inboxes( $user_id ) as $inbox => $cc ) { + $activitypub_activity->set_cc( $cc );//set_cc $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; - // } + if( in_array( $cc, $replyto ) || in_array( $cc, $mentions ) ) { + continue; + } \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 ); - // } - } /** @@ -136,7 +134,6 @@ class Activity_Dispatcher { * @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 @@ -157,28 +154,17 @@ class Activity_Dispatcher { 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; + continue; } $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[] ); } } @@ -190,16 +176,23 @@ class Activity_Dispatcher { 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; + $user_id = $activitypub_comment->user_id; + // Prevent sending received/anonymous comments + if ( ! $user_id ) { + return; + } + + $deleted = \wp_date( 'Y-m-d\TH:i:s\Z', \strtotime( $activitypub_comment->comment_date_gmt ) ); $activitypub_comment = new \Activitypub\Model\Comment( $activitypub_comment ); + $activitypub_comment->set_deleted( $deleted ); $activitypub_activity = new \Activitypub\Model\Activity( 'Delete', \Activitypub\Model\Activity::TYPE_FULL ); $activitypub_activity->from_comment( $activitypub_comment->to_array() ); + $activitypub_activity->set_deleted( $deleted ); 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 6393aef..d81e8e9 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -27,7 +27,10 @@ class Activitypub { \add_filter( 'preprocess_comment' , array( '\Activitypub\Activitypub', 'preprocess_comment' ) ); \add_filter( 'comment_post' , array( '\Activitypub\Activitypub', 'postprocess_comment' ), 10, 3 ); + \add_filter( 'wp_update_comment_data', array( '\Activitypub\Activitypub', 'comment_updated_published' ), 20, 3 ); \add_action( 'transition_comment_status', array( '\Activitypub\Activitypub', 'schedule_comment_activity' ), 20, 3 ); + \add_action( 'edit_comment', array( '\Activitypub\Activitypub', 'edit_comment' ), 20, 2 );//schedule_admin_comment_activity + \add_filter( 'get_comment_text', array( '\Activitypub\Activitypub', 'comment_append_edit_datetime' ), 10, 3 ); } @@ -39,12 +42,16 @@ class Activitypub { * @return string The new path to the JSON template. */ public static function render_json_template( $template ) { - if ( ! \is_author() && ! \is_singular() && ! \is_home() ) { + if ( ! \is_author() && ! \is_singular() && ! \is_home() && ! \Activitypub\is_ap_comment() ) { return $template; } if ( \is_author() ) { $json_template = \dirname( __FILE__ ) . '/../templates/author-json.php'; + } elseif ( \Activitypub\is_ap_replies() ) { + $json_template = \dirname( __FILE__ ) . '/../templates/replies-json.php'; + } elseif ( \Activitypub\is_ap_comment() ) { + $json_template = \dirname( __FILE__ ) . '/../templates/comment-json.php'; } elseif ( \is_singular() ) { $json_template = \dirname( __FILE__ ) . '/../templates/post-json.php'; } elseif ( \is_home() ) { @@ -90,6 +97,12 @@ class Activitypub { */ public static function add_query_vars( $vars ) { $vars[] = 'activitypub'; + $vars[] = 'ap_comment_id';//comment_id doesn't work, 'c' is probably too short and prone to collisions + + //Collections review + $vars[] = 'replies'; + $vars[] = 'collection_page'; + $vars[] = 'only_other_accounts'; return $vars; } @@ -124,44 +137,35 @@ class Activitypub { if ( 'publish' === $new_status && 'publish' !== $old_status ) { \wp_schedule_single_event( \time(), 'activitypub_send_post_activity', array( $activitypub_post ) ); - } elseif ( 'publish' === $new_status ) { + } elseif ( 'publish' === $new_status ) { //this triggers when restored post from trash, which may not be desired \wp_schedule_single_event( \time(), 'activitypub_send_update_activity', array( $activitypub_post ) ); } elseif ( 'trash' === $new_status ) { \wp_schedule_single_event( \time(), 'activitypub_send_delete_activity', array( $activitypub_post ) ); } } - /** + /** * preprocess local comments for federated replies */ public static function preprocess_comment( $commentdata ) { - - //must only process replies from local actors + // 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']; - } - } + $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; - } + return $commentdata; + } /** + * comment_post() * 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 ) && @@ -176,7 +180,7 @@ class Activitypub { } else { // TODO check that this is unused - // TODO comment test as anon + // TODO comment test as anon / no auth_url, no fetchable status? // TODO comment test as registered // TODO comment test as anyother site settings @@ -197,14 +201,27 @@ class Activitypub { } } } - + + /** + * edit_comment() + * + * Fires immediately after a comment is updated in the database. + * Fires immediately before comment status transition hooks are fired. (useful only for admin) + */ + public static function edit_comment( $comment_ID, $data ) { + // advantage of ap_published is it would be set once, (does preprocess fire on edit?) + if ( ! is_null( $data['user_id'] ) ) { + \wp_schedule_single_event( \time(), 'activitypub_send_update_comment_activity', array( $comment_ID ) ); + } + } + /** * Schedule Activities - * + * + * transition_comment_status() * @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 @@ -222,17 +239,35 @@ class Activitypub { || 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 ) ); + \wp_schedule_single_event( \time(), 'activitypub_send_delete_comment_activity', array( $activitypub_comment ) ); + } elseif ( $old_status === $new_status ) { + //TODO Test with non-admin user + \wp_schedule_single_event( \time(), 'activitypub_send_update_comment_activity', array( $activitypub_comment->comment_ID ) ); } else { + //error_log( 'schedule_update_comment_activity: else?:' ); } } - + + /** + * get_comment_text( $comment ) + * + * Filters the comment content before it is updated in the database. + */ + public static function comment_append_edit_datetime( $comment_text, $comment, $args ) { + if ( 'activitypub' === $comment->comment_type ) { + $updated = \wp_date( 'Y-m-d H:i:s', \strtotime( \get_comment_meta( $comment->comment_ID, 'ap_last_modified', true ) ) ); + if( $updated ) { + $append_updated = "
(Last edited on )
"; + $comment_text .= $append_updated; + } + } + return $comment_text; + } /** * Replaces the default avatar. diff --git a/includes/functions.php b/includes/functions.php index be9549c..8bed133 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -58,14 +58,15 @@ function safe_remote_post( $url, $body, $user_id ) { $response = \wp_safe_remote_post( $url, $args ); - \do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id ); + //\do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id ); return $response; } function forward_remote_post( $url, $body, $user_id ) { $date = \gmdate( 'D, d M Y H:i:s T' ); - $signature = \Activitypub\Signature::generate_signature( $user_id, $url, $date ); + $digest = \Activitypub\Signature::generate_digest( $body ); + $signature = \Activitypub\Signature::generate_signature( 1, $url, $date ); $wp_version = \get_bloginfo( 'version' ); $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) ); @@ -250,6 +251,24 @@ function get_follower_inboxes( $user_id ) { return $inboxes; } +function get_mentioned_inboxes( $mentions ) { + $inboxes = array(); + + foreach ( $mentions as $mention ) { + $inbox = \Activitypub\get_inbox_by_actor( $mention['href'] ); + if ( ! $inbox || \is_wp_error( $inbox ) ) { + continue; + } + // init array if empty + if ( ! isset( $inboxes[ $inbox ] ) ) { + $inboxes[ $inbox ] = array(); + } + $inboxes[ $inbox ][] = $mention; + } + + return $inboxes; +} + function get_identifier_settings( $user_id ) { ?> @@ -332,36 +351,40 @@ function url_to_authorid( $url ) { return 0; } + /** - * Verify if url is a local comment, + * Verify if in_replyto_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 ) ) { +function url_to_commentid( $in_replyto_url ) { + if ( empty( $in_replyto_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]; + //rewrite for activitypub object id simplification + $url_maybe_id = \wp_parse_url( $in_replyto_url ); + + if ( $url_maybe_id['scheme'] . '://' . $url_maybe_id['host'] == site_url() ) { + //is local post or comment + \parse_str( $url_maybe_id['query'], $reply_query ); + if (isset( $reply_query['ap_comment_id'] ) && is_int( $reply_query['ap_comment_id'] ) ){ + //is local comment + return $reply_query['ap_comment_id']; } else { + //not a comment 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 + //is remote url + //verify if in_replyto_url corresponds to a previously received comment $comment_args = array( 'type' => 'activitypub', 'meta_query' => array( array( - 'key' => 'source_url', - 'value' => $comment_url, + 'key' => 'source_url',//$object['object']['id'] + 'value' => $in_replyto_url, ) ) ); @@ -369,13 +392,46 @@ function url_to_commentid( $comment_url ) { $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]; + foreach ( $comments as $comment ) { + $found_comment_ids[] = $comment->comment_ID; + } + return $found_comment_ids[0]; } return null; - } + } +} + +/** + * Verify if url is a wp_ap_comment, + * Or if it is a previously received remote comment + * + * return int comment_id + */ +function is_ap_comment() { + $comment_id = get_query_var( 'ap_comment_id', null ); + if( ! is_null( $comment_id ) ) { + $comment = \get_comment( $comment_id ); + // Only return local origin comments + if( $comment->user_id ) { + return $comment_id; + } + } + return null; +} + +/** + * Verify if url is a /replies endoint, + * + * return int true + */ +function is_ap_replies() { + global $wp; + $replies = get_query_var( 'replies' ); + //$page = get_query_var( 'collection_page' ); + if( ( $replies ) ) { + return $replies; + } + return null; } /** @@ -502,10 +558,34 @@ function url_to_webfinger( $user_url ) { */ function normalize_comment_url( $comment ) { $comment_id = explode( '#comment-', \get_comment_link( $comment ) ); - $comment_id = $comment_id[0] . '?comment-' . $comment_id[1]; + $comment_id = $comment_id[0] . '?ap_comment_id=' . $comment_id[1]; return $comment_id; } +/** + * Set ap_comment_id + * + * AP Object ID must be unique + * + * https://www.w3.org/TR/activitypub/#obj-id + * https://github.com/tootsuite/mastodon/issues/13879 + */ +function set_ap_comment_id( $comment ) { + $ap_comment_id = add_query_arg( + array( + 'p' => $comment->comment_post_ID, + 'ap_comment_id' => $comment->comment_ID,//should probably rename to ap_comment or something + ), + trailingslashit( site_url() ) + ); + return $ap_comment_id; +} +/* comment_id_to_url( $comment_id ) { + //get remote from post_id from comment meta + //get local normalized comment_link + } +*/ + /** * Determine AP audience of incoming object * @param string $object @@ -527,4 +607,4 @@ function get_audience( $object ) { return 'private'; } } -} +} diff --git a/includes/model/class-activity.php b/includes/model/class-activity.php index 5e16912..76515ee 100644 --- a/includes/model/class-activity.php +++ b/includes/model/class-activity.php @@ -17,6 +17,7 @@ class Activity { private $to = array( 'https://www.w3.org/ns/activitystreams#Public' ); private $cc = array( 'https://www.w3.org/ns/activitystreams#Public' ); private $object = null; + private $tag = null; const TYPE_SIMPLE = 'simple'; const TYPE_FULL = 'full'; @@ -71,8 +72,13 @@ class Activity { $this->tag = $object['tag']; } - public function to_comment() { - + public function to_comment( $timestamp ) { + if ( $this->trash ) { + $this->deleted = $timestamp['deleted']; + } + if ( $this->updated) { + $this->updated = $timestamp['updated']; + } } public function from_remote_array( $array ) { diff --git a/includes/model/class-comment.php b/includes/model/class-comment.php index fa42f70..b81b27c 100644 --- a/includes/model/class-comment.php +++ b/includes/model/class-comment.php @@ -8,20 +8,26 @@ namespace Activitypub\Model; */ class Comment { private $comment; + private $updated; + private $deleted; /** * Initialize the class */ public function __construct( $comment = null ) { $this->comment = $comment; - + $this->id = $this->generate_comment_id(); $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->context = $this->generate_context(); $this->cc_recipients = $this->generate_recipients(); $this->tags = $this->generate_tags(); + $this->update = $this->generate_update(); + $this->deleted = $this->generate_trash(); + $this->replies = $this->generate_replies(); } public function __call( $method, $params ) { @@ -40,7 +46,7 @@ class Comment { $comment = $this->comment; $array = array( - 'id' => \Activitypub\Model\Comment::normalize_comment_id( $comment ), + 'id' => $this->safe_comment_id, 'type' => 'Note', 'published' => \date( 'Y-m-d\TH:i:s\Z', \strtotime( $comment->comment_date_gmt ) ), 'attributedTo' => $this->comment_author_url, @@ -50,12 +56,22 @@ class Comment { 'contentMap' => array( \strstr( \get_locale(), '_', true ) => $comment->comment_content, ), - 'source' => \get_comment_link( $comment ), + 'context' => $this->context, + //'source' => \get_comment_link( $comment ), //non-conforming, see https://www.w3.org/TR/activitypub/#source-property '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, ); + if ( $this->replies ) { + $array['replies'] = $this->replies; + } + if ( $this->update ) { + $array['updated'] = $this->update; + } + if ( $this->deleted ) { + $array['deleted'] = $this->deleted; + } return \apply_filters( 'activitypub_comment', $array ); } @@ -68,10 +84,13 @@ class Comment { return \get_author_posts_url( $this->comment->comment_author ); } + public function generate_comment_id() { + return \Activitypub\set_ap_comment_id( $this->comment ); + } + public function generate_permalink() { $comment = $this->comment; $permalink = \get_comment_link( $comment ); - // replace 'trashed' for delete activity return \str_replace( '__trashed', '', $permalink ); } @@ -83,18 +102,42 @@ class Comment { public function generate_parent_url() { $comment = $this->comment; $parent_comment = \get_comment( $comment->comment_parent ); - if ( $parent_comment ) { - //reply to local (received) comment + if ( $comment->comment_parent ) { + //is parent remote? $inReplyTo = \get_comment_meta( $comment->comment_parent, 'source_url', true ); + if ( ! $inReplyTo ) { + $inReplyTo = add_query_arg( + array( + 'p' => $comment->comment_post_ID, + 'ap_comment_id' => $comment->comment_parent, + ), + trailingslashit( site_url() ) + ); + } } else { - //reply to local post - $inReplyTo = \get_permalink( $comment->comment_post_ID ); + $inReplyTo = add_query_arg( + array( + 'p' => $comment->comment_post_ID, + ), + trailingslashit( site_url() ) + ); } return $inReplyTo; } + public function generate_context() { + $comment = $this->comment; + $inReplyTo = add_query_arg( + array( + 'p' => $comment->comment_post_ID, + ), + trailingslashit( site_url() ) + ); + return $inReplyTo; + } + /** - * Generate Content Warning from peer + * Generate courtesy Content Warning * 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 @@ -104,6 +147,8 @@ class Comment { public function generate_content_warning() { $comment = $this->comment; $contentWarning = null; + + // TODO Replace auto CW, with Title field or CW shortcode $parent_comment = \get_comment( $comment->comment_parent ); if ( $parent_comment ) { //get (received) comment @@ -112,9 +157,9 @@ class Comment { $contentWarning = $ap_object['object']['summary']; } } - /*$summary = \get_comment_meta( $this->comment->comment_ID, 'summary', true ) ; + /*summary = \get_comment_meta( $this->comment->comment_ID, 'summary', true ) ; if ( !empty( $summary ) ) { - $contentWarning = \Activitypub\add_summary( $summary ); + $contentWarning = \Activitypub\add_summary( $summary ); //TODO } */ return $contentWarning; } @@ -124,6 +169,7 @@ class Comment { */ public function generate_recipients() { //TODO Add audience logic get parent audience + //TODO shouldn't mentions go in 'to'? $recipients = array( AS_PUBLIC ); $mentions = \get_comment_meta( $this->comment->comment_ID, 'mentions', true ) ; if ( !empty( $mentions ) ) { @@ -152,16 +198,66 @@ class Comment { } /** - * 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 + * Generate updated datetime */ - 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; + public function generate_update() { + $comment = $this->comment; + $updated = null; + if ( \get_comment_meta( $comment->comment_ID, 'ap_last_modified', true ) ) { + $updated = \wp_date( 'Y-m-d\TH:i:s\Z', \get_comment_meta( $comment->comment_ID, 'ap_last_modified', true ) ); + } + return $updated; + } + + /** + * Generate deleted datetime + */ + public function generate_trash() { + $comment = $this->comment; + $deleted = null; + if ( 'trash' == $comment->status ) { + $deleted = \date( 'Y-m-d\TH:i:s\Z', \strtotime( $comment->comment_date_gmt ) ); + } + return $deleted; + } + + /** + * Generate replies collections + */ + public function generate_replies() { + $comment = $this->comment; + $args = array( + 'post_id' => $comment->comment_post_ID, + 'parent' => $comment->comment_ID, + 'author__in' => $comment->user_id, + 'status' => 'approve', + 'hierarchical' => false, + ); + $children = \get_comments( $args ); + $replies = null; + if ( $children ) { + $items = []; + foreach ( $children as $child_comment ){ + $comment_url = \add_query_arg( + array( + 'p' => $child_comment->comment_post_ID, + 'ap_comment_id' => $child_comment->comment_ID + ), + trailingslashit( site_url() ) + ); + $items[] = $comment_url; + } + $replies = (object) [ + 'type' => 'Collection', + 'id' => \add_query_arg( array( 'replies' => '' ), $this->id ), + 'first' => (object) [ + 'type' => 'CollectionPage', + 'partOf'=> \add_query_arg( array( 'replies' => '' ), $this->id ), + 'next' => \add_query_arg( array( 'replies' => '', 'page' => 1 ), $this->id ), + 'items' => $items + ], + ]; + } + return $replies; } } \ No newline at end of file diff --git a/includes/model/class-post.php b/includes/model/class-post.php index e885199..d4801c7 100644 --- a/includes/model/class-post.php +++ b/includes/model/class-post.php @@ -15,6 +15,9 @@ class Post { private $attachments; private $tags; private $object_type; + private $deleted; + private $updated; + private $slug; public function __construct( $post = null ) { $this->post = \get_post( $post ); @@ -26,6 +29,11 @@ class Post { $this->attachments = $this->generate_attachments(); $this->tags = $this->generate_tags(); $this->object_type = $this->generate_object_type(); + $this->replies = $this->generate_replies(); + //$this->updated = $this->generate_updated(); + $this->slug = \get_permalink( $this->id ); + $this->updated = null; + $this->delete = $this->get_deleted(); } public function __call( $method, $params ) { @@ -50,6 +58,7 @@ class Post { 'attributedTo' => \get_author_posts_url( $post->post_author ), 'summary' => $this->summary, 'inReplyTo' => null, + 'url' => \get_permalink( $post->ID ), 'content' => $this->content, 'contentMap' => array( \strstr( \get_locale(), '_', true ) => $this->content, @@ -59,7 +68,18 @@ class Post { 'attachment' => $this->attachments, 'tag' => $this->tags, ); - + if ( $this->replies ) { + $array['replies'] = $this->replies; + } + if ( $this->deleted ) { + $array['deleted'] = \date( 'Y-m-d\TH:i:s\Z', \strtotime( $post->post_modified_gmt ) ); + // TODO if using slugs instead of ids _ap_deleted_slug + //$deleted_post_slug = \get_post_meta( $post->ID, '_ap_deleted_slug', true ); + //$array['id'] = $deleted_post_slug; + } + if ( $this->updated ) { + $array['updated'] = \date( 'Y-m-d\TH:i:s\Z', \strtotime( $post->post_modified_gmt ) ); + } return \apply_filters( 'activitypub_post', $array ); } @@ -69,7 +89,12 @@ class Post { public function generate_id() { $post = $this->post; - $permalink = \get_permalink( $post ); + $permalink = \add_query_arg( // + array( + 'p' => $post->ID, + ), + trailingslashit( site_url() ) + ); // replace 'trashed' for delete activity return \str_replace( '__trashed', '', $permalink ); @@ -152,6 +177,48 @@ class Post { return $tags; } + + public function generate_replies() { + $replies = null; + //\error_log( 'generate_replies: $post' . print_r( $this->post, true ) ); + if ( $this->post->comment_count > 0 ) { + $args = array( + 'post_id' => $this->post->ID, // Use post_id, not post_ID + 'hierarchical' => false, + 'status' => 'approve', + ); + $comments = \get_comments( $args ); + $items = []; + + foreach ( $comments as $comment ){ + // include self replies + if ( $this->post->post_author === $comment->user_id ) { + //$comment_url = $comment->comment_ID; + $comment_url = \add_query_arg( // + array( + 'p' => $this->post->ID, + 'ap_comment_id' => $comment->comment_ID + ), + trailingslashit( site_url() ) + ); + //\error_log( 'generate_replies: $comment' . print_r( $comment, true ) ); + $items[] = $comment_url; + } + } + //\error_log( 'generate_replies: $comments' . print_r( $comments, true ) ); + $replies = (object) [ + 'type' => 'Collection', + 'id' => \add_query_arg( array( 'replies' => '' ), $this->id ), + 'first' => (object) [ + 'type' => 'CollectionPage', + 'partOf'=> \add_query_arg( array( 'replies' => '' ), $this->id ), + 'next' => \add_query_arg( array( 'replies' => '', 'page' => 1 ), $this->id ), + 'items' => $items + ], + ]; + } + return $replies; + } /** * Returns the as2 object-type for a given post @@ -357,4 +424,18 @@ class Post { return \implode( ' ', $hash_tags ); } + + /** + * Get deleted datetime + */ + public function get_deleted() { + $post = $this->post; + $deleted = null; + if ( 'trash' == $post->post_status ) { + + $deleted = \date( 'Y-m-d\TH:i:s\Z', \strtotime( $post->post_modified_gmt ) ); + \error_log( 'trash: ' . print_r( $deleted, true ) ); + } + return $deleted; + } } diff --git a/includes/rest/class-inbox.php b/includes/rest/class-inbox.php index 2111599..05abc56 100644 --- a/includes/rest/class-inbox.php +++ b/includes/rest/class-inbox.php @@ -20,6 +20,8 @@ class Inbox { //\add_action( 'activitypub_inbox_like', array( '\Activitypub\Rest\Inbox', 'handle_reaction' ), 10, 2 ); //\add_action( 'activitypub_inbox_announce', array( '\Activitypub\Rest\Inbox', 'handle_reaction' ), 10, 2 ); \add_action( 'activitypub_inbox_create', array( '\Activitypub\Rest\Inbox', 'handle_create' ), 10, 2 ); + \add_action( 'activitypub_inbox_update', array( '\Activitypub\Rest\Inbox', 'handle_update' ), 10, 2 ); + \add_action( 'activitypub_inbox_delete', array( '\Activitypub\Rest\Inbox', 'handle_delete' ), 10, 2 ); } /** @@ -409,28 +411,27 @@ class Inbox { $avatar_url = null; $audience = \Activitypub\get_audience( $object ); - //Determine parent post and/or parent comment - $comment_post_ID = $object_parent = $object_parent_ID = 0; + //Determine comment_post_ID and/or comment_parent + $comment_post_ID = $comment_parent = $comment_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; - } + + $comment_parent_ID = \Activitypub\url_to_commentid( \esc_url_raw( $object['object']['inReplyTo'] ) ); + + if ( !is_null( $comment_parent_ID ) ) { + //inReplyTo a known local comment + $comment_parent = \get_comment( $comment_parent_ID ); + $comment_post_ID = $comment_parent->comment_post_ID; + } else { + //inReplyTo a known post + $comment_post_ID = \url_to_postid( $object['object']['inReplyTo'] ); } - } + } //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']? + if ( isset( $object['object']['id'] ) ) { $source_url = \esc_url_raw( $object['object']['id'] ); + } else { + $source_url = \esc_url_raw( $object['object']['url'] ); } // if no name is set use peer username @@ -448,7 +449,7 @@ class Inbox { if ( ( in_array( AS_PUBLIC, $object['to'] ) || in_array( AS_PUBLIC, $object['cc'] ) ) && ( !empty( $comment_post_ID ) - || !empty ( $object_parent ) + || !empty ( $comment_parent ) ) ) { $commentdata = array( @@ -458,7 +459,7 @@ class Inbox { 'comment_content' => \wp_filter_kses( $object['object']['content'] ), 'comment_type' => 'activitypub', 'comment_author_email' => '', - 'comment_parent' => $object_parent_ID, + 'comment_parent' => $comment_parent_ID, 'comment_meta' => array( 'ap_object' => \serialize( $object ), 'source_url' => $source_url, @@ -468,7 +469,7 @@ class Inbox { ); // disable flood control - \remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); + //\remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); // do not require email for AP entries \add_filter( 'pre_option_require_name_email', '__return_false' ); @@ -478,11 +479,89 @@ class Inbox { \remove_filter( 'pre_option_require_name_email', '__return_false' ); // re-add flood control - \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + //\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); } } + /** + * Handles "Update" requests + * + * @param array $object The activity-object + * @param int $user_id The id of the local blog-user + */ + public static function handle_update( $object, $user_id ) { + $meta = \Activitypub\get_remote_metadata_by_actor( $object['actor'] ); + $avatar_url = null; + $audience = \Activitypub\get_audience( $object ); + + //Determine comment_ID + $object_comment_ID = \Activitypub\url_to_commentid( \esc_url_raw( $object['object']['id'] ) ); + if ( !is_null( $object_comment_ID ) ) { + + //found a local comment id + $commentdata = \get_comment( $object_comment_ID, ARRAY_A ); + + //$commentdata['comment_ID'] = \esc_url_raw( $object_comment_ID ); + $commentdata['comment_content'] = \wp_filter_kses( $object['object']['content'] ); + $commentdata['comment_meta']['ap_published'] = \wp_date( 'Y-m-d H:i:s', strtotime( $object['object']['published'] ) ); + $commentdata['comment_meta']['ap_last_modified'] = $object['object']['updated']; + $commentdata['comment_meta']['ap_object'] = \serialize( $object ); + + //apply_filters( 'wp_update_comment_data', $data, $comment, $commentarr ); + + // disable flood control + \remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); + + // do not require email for AP entries + \add_filter( 'pre_option_require_name_email', '__return_false' ); + + $state = \wp_update_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 ); + } + } + + /** + * Handles "Delete" requests + * + * @param array $object The activity-object + * @param int $user_id The id of the local blog-user + */ + public static function handle_delete( $object, $user_id ) { + $meta = \Activitypub\get_remote_metadata_by_actor( $object['actor'] ); + $avatar_url = null; + $audience = \Activitypub\get_audience( $object ); + + if ( ! isset( $object['object']['id'] ) ) { + return; + } + //Determine comment_ID + $object_comment_ID = \Activitypub\url_to_commentid( \esc_url_raw( $object['object']['id'] ) ); + if ( !is_null( $object_comment_ID ) ) { + + //found a local comment id + $commentdata = \get_comment( $object_comment_ID, ARRAY_A ); + + // disable flood control + \remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); + + // do not require email for AP entries + \add_filter( 'pre_option_require_name_email', '__return_false' ); + + // Should we trash or send back to moderation + $state = \wp_trash_comment( $commentdata['comment_ID'], 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 ) { $recipients = array(); $users = array(); diff --git a/templates/comment-json.php b/templates/comment-json.php new file mode 100644 index 0000000..13ba9cd --- /dev/null +++ b/templates/comment-json.php @@ -0,0 +1,36 @@ + \Activitypub\get_context() ), $activitypub_comment->to_array() ); + +// filter output +$json = \apply_filters( 'activitypub_json_comment_array', $json ); + +/* + * Action triggerd prior to the ActivityPub profile being created and sent to the client + */ +\do_action( 'activitypub_json_comment_pre' ); + +$options = 0; +// JSON_PRETTY_PRINT added in PHP 5.4 +if ( \get_query_var( 'pretty' ) ) { + $options |= \JSON_PRETTY_PRINT; // phpcs:ignore +} + +$options |= \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT; + +/* + * Options to be passed to json_encode() + * + * @param int $options The current options flags + */ +$options = \apply_filters( 'activitypub_json_comment_options', $options ); + +\header( 'Content-Type: application/activity+json' ); +echo \wp_json_encode( $json, $options ); + +/* + * Action triggerd after the ActivityPub profile has been created and sent to the client + */ +\do_action( 'activitypub_json_comment_comment' );