From b744dc551dc9ca34e2ca805ca99755fcc86f4013 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 22 Dec 2023 10:12:26 +0100 Subject: [PATCH] Comment Federation (#550) * Comments 1 * Delete FUNDING.yml * Add basic BuddyPress support fix #122 thanks and props @skysarwer * change URL to `bp_core_get_user_domain` * fix "Follow" issue fix #133 * fix #135 * version bump * Create phpunit.yml * Update composer.json * Update composer.json * Update phpunit.yml * Update composer.json * Create phpcs.yml * Update phpcs.xml * Update composer.json * phpcs fixes * fix typo * Comments update * webfinger_extract remove extra param * coding standards * Replies Collection, settings, other fixes * Create stale.yml * move stale file * code standards cleanup * Migrate / Update script * bugfix * add settings link to plugin page * fix code standards * fix cs * fix PHPCS * PHPCS fixes * change background image for wp.org * fix docker * fix webfinger for email identifiers fix #152 * version bump * update composer file to fix unit testing * allow plugins * fix dependencies * Migrate tools * code cleanup * regression fix * Fix announce, clarified language * update included filename * code cleanup * Improve migration UX * Add comments view, warnings to migrate page * style fix * more style fixes * Fix send_delete_activity * replace ap_comment_id to reuse replytocom var * Comments class missing attributes * Post class fix attributes * move js file to assets/js * Separate file for Comment processing hooks * fix file path * associate comments to back compat post * Fix js assets enqueue * change regex matching potential hashtags Matches any string starting with '#' and consisting of any number and combination of [A-Za-z0-9_] that is directly followed by whitespace or punctuation. Groups everything after '#' for access in functions using this regex. This fixes #183 (incomplete links on hashtags containing special characters) by not matching these at all. * also detect hashtags at the start of a paragraph * restrict html tags after which to detect a hashtag Hashtags should not be detected after just any html tag - for example not after an opening a or div. To still allow detection at the start of a line, allow specifically p and br to directly precede a hashtag. * fix pagination * Add Custom Post Type support to outbox API * remove comment_type * fix comparison * remove trailing spaces * fix phpcs issues * fix phpcs issues * run phpcs also on pull_requests * fix phpcs issues * support threaded comments from ActivityPub * refactor support for threaded comments from ActivityPub * remove debugging log line * add first unit tests for class inbox * fix code smells * make filter function static * attempt to resolve backwards compatibility issues * update js to new file * delete old js * Remove migrate code * update post meta canonical * remove type and mention meta from comment filters * extract mentions from comment_content * phpcbf * remove extra curly bracket * Remove migrate code * remove version_check() * Update enqueue scripts * Remove remote comments from preprocessing * Reply to comments from Dashboard * rename function, inserts users into reply text * Update dispatch comments * update comment model * fix comment model replies property * fix preprocess_comment cap check * Add webfinger filter to comments * Add comment edit datetime * cleanup * fix var name * cleanup * phpcbf * better actual translation support * Separate comment reply script * migrate dispatch, migrate comment model to transform * ignore WP_Comment type for now * Adds new helpers for resolving inReplyTo url * Update activitypub_send_comment_activity to include type * remove redundant id check * reinclude user_id in saved ap_object meta * update post field meta * Fix comment updated datetime * front-end reply inserts @mentions * enqueue reply script on front end * use const instead of dirname * some simplifications * move some functions * fixes * some more fixes * fix namespace * fix unittests * fix testcase * fixed typo * fix tests * fix tests * fix PHPCS * move functions to transformer class * fix warnings * Link remote comments on frontend * Link to comment source as row action * Init Comments class * remove dead dispatch action * re-add extract mentions filter * Restore and tweak Comment transform * Schedule comments activities for non-admin users * lint * remove context property * rename get_id method to generate_id * fix locale * move functions * PHPDoc * this is never used * remove some edit methods * remove replies for now * remove JS calls * remove reply_recipients * never used * remove other query-vars * otherwise to_json would not work properly * small changes * use `c` for comment IDs * remove comments.php for now maybe re-add it later * wp_insert_post is an action * also parse comment_text * remove duplicate functions * add Base transformer * remove invalid test * update to new query var * update dispatcher to support comments and posts * fix transition * remove unused functions for now * schedule_comment_activity seems to ignore create and update * fix wrong use of functions! * not every platforms sends an URL * check source-id first * remove hashtags for now * fallback to ID * fix typo * move to_activity to Base class * remove unused function * add support for announce and like * also ping inboxes of other commenters in the thread * restructure WebFinger class * some small improvements * simplified to_object class props @Menrath for the feedback and the idea! * fix unit tests * make transformer filterable /cc @Menrath * use transformer factory, so that transformer can be overwritten * phpcs fixes * fix attachments * fix comment transformer * remove comments for now * update readme/changelog * simplify and unify json_encodes --------- Co-authored-by: Django Doucet Co-authored-by: Andreas Co-authored-by: Eana Hufwe Co-authored-by: Matthew Exon Co-authored-by: Django Doucet --- README.md | 1 + assets/js/activitypub-admin.js | 5 +- includes/activity/class-base-object.php | 47 ++-- includes/class-activity-dispatcher.php | 62 ++--- includes/class-activitypub.php | 52 +++- includes/class-hashtag.php | 6 +- includes/class-mention.php | 3 +- includes/class-scheduler.php | 89 +++++++ includes/class-shortcodes.php | 41 ++- includes/class-webfinger.php | 207 ++++++++------- includes/collection/class-interactions.php | 22 +- includes/functions.php | 45 +++- includes/rest/class-users.php | 3 +- includes/transformer/class-attachment.php | 49 ++++ includes/transformer/class-base.php | 105 ++++++++ includes/transformer/class-comment.php | 277 +++++++++++++++++++++ includes/transformer/class-factory.php | 61 +++++ includes/transformer/class-post.php | 96 +++---- readme.txt | 1 + templates/author-json.php | 17 +- templates/blog-json.php | 17 +- templates/comment-json.php | 36 +++ templates/post-json.php | 24 +- tests/test-class-activitypub-webfinger.php | 21 ++ 24 files changed, 1009 insertions(+), 278 deletions(-) create mode 100644 includes/transformer/class-attachment.php create mode 100644 includes/transformer/class-base.php create mode 100644 includes/transformer/class-comment.php create mode 100644 includes/transformer/class-factory.php create mode 100644 templates/comment-json.php create mode 100644 tests/test-class-activitypub-webfinger.php diff --git a/README.md b/README.md index 7947c21..656b282 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Project maintained on GitHub at [automattic/wordpress-activitypub](https://githu * Added: Make Post-Template filterable * Added: CSS class for ActivityPub comments to allow custom designs * Added: FEP-2677: Identifying the Application Actor +* Added: Basic Comment Federation * Improved: WebFinger endpoints ### 1.3.0 ### diff --git a/assets/js/activitypub-admin.js b/assets/js/activitypub-admin.js index f6a75af..37117ca 100644 --- a/assets/js/activitypub-admin.js +++ b/assets/js/activitypub-admin.js @@ -11,10 +11,11 @@ jQuery( function( $ ) { $( '#' + $( this ).attr( 'aria-controls' ) ).attr( 'hidden', false ); } } ); - + $(document).on( 'wp-plugin-install-success', function( event, response ) { setTimeout( function() { $( '.activate-now' ).removeClass( 'thickbox open-plugin-details-modal' ); }, 1200 ); - } ); + } ); + } ); diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index b73c621..1e256cb 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -254,19 +254,6 @@ class Base_Object { */ protected $published; - /** - * A Collection containing objects considered to be responses to - * this object. - * - * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies - * - * @var string - * | Collection - * | Link - * | null - */ - protected $replies; - /** * The date and time describing the actual or expected starting time * of the object. @@ -437,6 +424,19 @@ class Base_Object { */ protected $source; + /** + * A Collection containing objects considered to be responses to + * this object. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies + * + * @var string + * | Collection + * | Link + * | null + */ + protected $replies; + /** * Magic function to implement getter and setter * @@ -671,8 +671,25 @@ class Base_Object { * @return string The JSON string. */ public function to_json() { - $array = $this->to_array(); + $array = $this->to_array(); + $options = \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT; - return \wp_json_encode( $array, \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_encode_options', $options ); + + return \wp_json_encode( $array, $options ); + } + + /** + * Returns the keys of the object vars. + * + * @return array The keys of the object vars. + */ + public function get_object_var_keys() { + return \array_keys( \get_object_vars( $this ) ); } } diff --git a/includes/class-activity-dispatcher.php b/includes/class-activity-dispatcher.php index 462a75a..c9a1851 100644 --- a/includes/class-activity-dispatcher.php +++ b/includes/class-activity-dispatcher.php @@ -2,10 +2,13 @@ namespace Activitypub; use WP_Post; +use WP_Comment; use Activitypub\Activity\Activity; use Activitypub\Collection\Users; use Activitypub\Collection\Followers; +use Activitypub\Transformer\Factory; use Activitypub\Transformer\Post; +use Activitypub\Transformer\Comment; use function Activitypub\is_single_user; use function Activitypub\is_user_disabled; @@ -30,12 +33,12 @@ class Activity_Dispatcher { /** * Send Activities to followers and mentioned users or `Announce` (boost) a blog post. * - * @param WP_Post $wp_post The ActivityPub Post. - * @param string $type The Activity-Type. + * @param mixed $wp_object The ActivityPub Post. + * @param string $type The Activity-Type. * * @return void */ - public static function send_activity_or_announce( WP_Post $wp_post, $type ) { + public static function send_activity_or_announce( $wp_object, $type ) { // check if a migration is needed before sending new posts Migration::maybe_migrate(); @@ -43,35 +46,37 @@ class Activity_Dispatcher { return; } - $wp_post->post_author = Users::BLOG_USER_ID; - if ( is_single_user() ) { - self::send_activity( $wp_post, $type ); + self::send_activity( $wp_object, $type, Users::BLOG_USER_ID ); } else { - self::send_announce( $wp_post, $type ); + self::send_announce( $wp_object, $type ); } } /** * Send Activities to followers and mentioned users. * - * @param WP_Post $wp_post The ActivityPub Post. - * @param string $type The Activity-Type. + * @param mixed $wp_object The ActivityPub Post. + * @param string $type The Activity-Type. * * @return void */ - public static function send_activity( WP_Post $wp_post, $type ) { - if ( is_user_disabled( $wp_post->post_author ) ) { + public static function send_activity( $wp_object, $type, $user_id = null ) { + $transformer = Factory::get_transformer( $wp_object ); + + if ( null !== $user_id ) { + $transformer->change_wp_user_id( $user_id ); + } + + $user_id = $transformer->get_wp_user_id(); + + if ( is_user_disabled( $user_id ) ) { return; } - $object = Post::transform( $wp_post )->to_object(); + $activity = $transformer->to_activity( 'Create' ); - $activity = new Activity(); - $activity->set_type( $type ); - $activity->set_object( $object ); - - $follower_inboxes = Followers::get_inboxes( $wp_post->post_author ); + $follower_inboxes = Followers::get_inboxes( $user_id ); $mentioned_inboxes = Mention::get_inboxes( $activity->get_cc() ); $inboxes = array_merge( $follower_inboxes, $mentioned_inboxes ); @@ -80,19 +85,19 @@ class Activity_Dispatcher { $json = $activity->to_json(); foreach ( $inboxes as $inbox ) { - safe_remote_post( $inbox, $json, $wp_post->post_author ); + safe_remote_post( $inbox, $json, $user_id ); } } /** * Send Announces to followers and mentioned users. * - * @param WP_Post $wp_post The ActivityPub Post. - * @param string $type The Activity-Type. + * @param mixed $wp_object The ActivityPub Post. + * @param string $type The Activity-Type. * * @return void */ - public static function send_announce( WP_Post $wp_post, $type ) { + public static function send_announce( $wp_object, $type ) { if ( ! in_array( $type, array( 'Create', 'Update' ), true ) ) { return; } @@ -101,16 +106,13 @@ class Activity_Dispatcher { return; } - $object = Post::transform( $wp_post )->to_object(); + $transformer = Factory::get_transformer( $wp_object ); + $transformer->change_wp_user_id( Users::BLOG_USER_ID ); - $activity = new Activity(); - $activity->set_type( 'Announce' ); - // to pre-fill attributes like "published" and "id" - $activity->set_object( $object ); - // send only the id - $activity->set_object( $object->get_id() ); + $user_id = $transformer->get_wp_user_id(); + $activity = $transformer->to_activity( 'Announce' ); - $follower_inboxes = Followers::get_inboxes( $wp_post->post_author ); + $follower_inboxes = Followers::get_inboxes( $user_id ); $mentioned_inboxes = Mention::get_inboxes( $activity->get_cc() ); $inboxes = array_merge( $follower_inboxes, $mentioned_inboxes ); @@ -119,7 +121,7 @@ class Activity_Dispatcher { $json = $activity->to_json(); foreach ( $inboxes as $inbox ) { - safe_remote_post( $inbox, $json, $wp_post->post_author ); + safe_remote_post( $inbox, $json, $user_id ); } } } diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 0ae3c36..8b2a405 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -8,6 +8,9 @@ use Activitypub\Collection\Followers; use function Activitypub\sanitize_url; +use function Activitypub\is_comment; +use function Activitypub\is_activitypub_request; + /** * ActivityPub Class * @@ -19,6 +22,7 @@ class Activitypub { */ public static function init() { \add_filter( 'template_include', array( self::class, 'render_json_template' ), 99 ); + \add_action( 'template_redirect', array( self::class, 'template_redirect' ) ); \add_filter( 'query_vars', array( self::class, 'add_query_vars' ) ); \add_filter( 'pre_get_avatar_data', array( self::class, 'pre_get_avatar_data' ), 11, 2 ); \add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 3 ); @@ -100,6 +104,8 @@ class Activitypub { if ( \is_author() ) { $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/author-json.php'; + } elseif ( is_comment() ) { + $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/comment-json.php'; } elseif ( \is_singular() ) { $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/post-json.php'; } elseif ( \is_home() ) { @@ -117,11 +123,43 @@ class Activitypub { return $json_template; } + /** + * Custom redirects for ActivityPub requests. + * + * @return void + */ + public static function template_redirect() { + $comment_id = get_query_var( 'c', null ); + + // check if it seems to be a comment + if ( ! $comment_id ) { + return; + } + + $comment = get_comment( $comment_id ); + + // load a 404 page if `c` is set but not valid + if ( ! $comment ) { + global $wp_query; + $wp_query->set_404(); + return; + } + + // stop if it's not an ActivityPub comment + if ( is_activitypub_request() && $comment->user_id ) { + return; + } + + wp_safe_redirect( get_comment_link( $comment ) ); + } + /** * Add the 'activitypub' query variable so WordPress won't mangle it. */ public static function add_query_vars( $vars ) { $vars[] = 'activitypub'; + $vars[] = 'c'; + $vars[] = 'p'; return $vars; } @@ -197,10 +235,18 @@ class Activitypub { * @return string $url */ public static function remote_comment_link( $comment_link, $comment ) { - $remote_comment_link = get_comment_meta( $comment->comment_ID, 'source_url', true ); - if ( $remote_comment_link ) { - $comment_link = esc_url( $remote_comment_link ); + if ( ! $comment || is_admin() ) { + return $comment_link; } + + $comment_meta = \get_comment_meta( $comment->comment_ID ); + + if ( ! empty( $comment_meta['source_url'][0] ) ) { + return $comment_meta['source_url'][0]; + } elseif ( ! empty( $comment_meta['source_id'][0] ) ) { + return $comment_meta['source_id'][0]; + } + return $comment_link; } diff --git a/includes/class-hashtag.php b/includes/class-hashtag.php index 6031d1f..acd5a9a 100644 --- a/includes/class-hashtag.php +++ b/includes/class-hashtag.php @@ -12,8 +12,8 @@ class Hashtag { */ public static function init() { if ( '1' === \get_option( 'activitypub_use_hashtags', '1' ) ) { - \add_filter( 'wp_insert_post', array( self::class, 'insert_post' ), 10, 2 ); - \add_filter( 'the_content', array( self::class, 'the_content' ), 10, 2 ); + \add_action( 'wp_insert_post', array( self::class, 'insert_post' ), 10, 2 ); + \add_filter( 'the_content', array( self::class, 'the_content' ), 10, 1 ); } } @@ -67,7 +67,7 @@ class Hashtag { $tag = strtolower( $m[2] ); if ( '/' === $m[1] ) { // Closing tag. - $i = array_search( $tag, $tag_stack ); + $i = array_search( $tag, $tag_stack, true ); // We can only remove the tag from the stack if it is in the stack. if ( false !== $i ) { $tag_stack = array_slice( $tag_stack, 0, $i ); diff --git a/includes/class-mention.php b/includes/class-mention.php index beb6246..01bbeaf 100644 --- a/includes/class-mention.php +++ b/includes/class-mention.php @@ -14,7 +14,8 @@ class Mention { * Initialize the class, registering WordPress hooks */ public static function init() { - \add_filter( 'the_content', array( self::class, 'the_content' ), 99, 2 ); + \add_filter( 'the_content', array( self::class, 'the_content' ), 99, 1 ); + \add_filter( 'comment_text', array( self::class, 'the_content' ), 10, 1 ); \add_filter( 'activitypub_extract_mentions', array( self::class, 'extract_mentions' ), 99, 2 ); } diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 11f40da..61deab7 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -16,11 +16,47 @@ 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 ); + } + ); + // 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 ); + } + ); + + // Follower Cleanups \add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) ); \add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) ); + // Migration \add_action( 'admin_init', array( self::class, 'schedule_migration' ) ); } @@ -58,6 +94,8 @@ class Scheduler { * @param WP_Post $post Post object. */ public static function schedule_post_activity( $new_status, $old_status, $post ) { + $post = get_post( $post ); + // Do not send activities if post is password protected. if ( \post_password_required( $post ) ) { return; @@ -99,6 +137,57 @@ class Scheduler { ); } + /** + * Schedule Comment Activities + * + * 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 approved comments. + if ( ! $comment->user_id ) { + return; + } + + if ( + 'approved' === $new_status && + 'approved' !== $old_status + ) { + $type = 'Create'; + } elseif ( 'approved' === $new_status ) { + $type = 'Update'; + } elseif ( + 'trash' === $new_status || + 'spam' === $new_status + ) { + $type = 'Delete'; + } + + if ( ! $type ) { + return; + } + + \wp_schedule_single_event( + \time(), + 'activitypub_send_activity', + array( $comment, $type ) + ); + + \wp_schedule_single_event( + \time(), + sprintf( + 'activitypub_send_%s_activity', + \strtolower( $type ) + ), + array( $comment ) + ); + } + /** * Update followers * diff --git a/includes/class-shortcodes.php b/includes/class-shortcodes.php index 922c10d..664266d 100644 --- a/includes/class-shortcodes.php +++ b/includes/class-shortcodes.php @@ -110,8 +110,13 @@ class Shortcodes { $excerpt = \get_post_field( 'post_excerpt', $item ); - if ( '' === $excerpt ) { - + if ( 'attachment' === $item->post_type ) { + // get title of attachment with fallback to alt text. + $content = wp_get_attachment_caption( $item->ID ); + if ( empty( $content ) ) { + $content = get_post_meta( $item->ID, '_wp_attachment_image_alt', true ); + } + } elseif ( '' === $excerpt ) { $content = \get_post_field( 'post_content', $item ); // An empty string will make wp_trim_excerpt do stuff we do not want. @@ -207,20 +212,30 @@ class Shortcodes { $tag ); - $content = \get_post_field( 'post_content', $item ); + $content = ''; - if ( 'yes' === $atts['apply_filters'] ) { - $content = \apply_filters( 'the_content', $content ); + if ( 'attachment' === $item->post_type ) { + // get title of attachment with fallback to alt text. + $content = wp_get_attachment_caption( $item->ID ); + if ( empty( $content ) ) { + $content = get_post_meta( $item->ID, '_wp_attachment_image_alt', true ); + } } else { - $content = do_blocks( $content ); - $content = wptexturize( $content ); - $content = wp_filter_content_tags( $content ); - } + $content = \get_post_field( 'post_content', $item ); - // replace script and style elements - $content = \preg_replace( '@<(script|style)[^>]*?>.*?@si', '', $content ); - $content = strip_shortcodes( $content ); - $content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) ); + if ( 'yes' === $atts['apply_filters'] ) { + $content = \apply_filters( 'the_content', $content ); + } else { + $content = do_blocks( $content ); + $content = wptexturize( $content ); + $content = wp_filter_content_tags( $content ); + } + + // replace script and style elements + $content = \preg_replace( '@<(script|style)[^>]*?>.*?@si', '', $content ); + $content = strip_shortcodes( $content ); + $content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) ); + } add_shortcode( 'ap_content', array( 'Activitypub\Shortcodes', 'content' ) ); diff --git a/includes/class-webfinger.php b/includes/class-webfinger.php index 75f7ff6..b0fe20d 100644 --- a/includes/class-webfinger.php +++ b/includes/class-webfinger.php @@ -15,7 +15,7 @@ class Webfinger { /** * Returns a users WebFinger "resource" * - * @param int $user_id + * @param int $user_id The WordPress user id * * @return string The user-resource */ @@ -36,68 +36,65 @@ class Webfinger { /** * Resolve a WebFinger resource * - * @param string $resource The WebFinger resource + * @param string $uri The WebFinger Resource * * @return string|WP_Error The URL or WP_Error */ - public static function resolve( $resource ) { - if ( ! $resource ) { - return null; + public static function resolve( $uri ) { + $data = self::get_data( $uri ); + + if ( \is_wp_error( $data ) ) { + return $data; } - if ( ! preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $resource, $m ) ) { - return null; - } - - $transient_key = 'activitypub_resolve_' . ltrim( $resource, '@' ); - - $link = \get_transient( $transient_key ); - if ( $link ) { - return $link; - } - - $url = \add_query_arg( 'resource', 'acct:' . ltrim( $resource, '@' ), 'https://' . $m[2] . '/.well-known/webfinger' ); - if ( ! \wp_http_validate_url( $url ) ) { - $response = new WP_Error( 'invalid_webfinger_url', null, $url ); - \set_transient( $transient_key, $response, HOUR_IN_SECONDS ); // Cache the error for a shorter period. - return $response; - } - - // try to access author URL - $response = \wp_remote_get( - $url, - array( - 'headers' => array( 'Accept' => 'application/jrd+json' ), - 'redirection' => 2, - 'timeout' => 2, - ) - ); - - if ( \is_wp_error( $response ) ) { - $link = new WP_Error( 'webfinger_url_not_accessible', null, $url ); - \set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period. - return $link; - } - - $body = \wp_remote_retrieve_body( $response ); - $body = \json_decode( $body, true ); - - if ( empty( $body['links'] ) ) { - $link = new WP_Error( 'webfinger_url_invalid_response', null, $url ); - \set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period. - return $link; - } - - foreach ( $body['links'] as $link ) { - if ( 'self' === $link['rel'] && 'application/activity+json' === $link['type'] ) { - \set_transient( $transient_key, $link['href'], WEEK_IN_SECONDS ); + foreach ( $data['links'] as $link ) { + if ( + 'self' === $link['rel'] && + 'application/activity+json' === $link['type'] + ) { return $link['href']; } } - $link = new WP_Error( 'webfinger_url_no_activitypub', null, $body ); - \set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period. - return $link; + return new WP_Error( 'webfinger_url_no_activitypub', null, $data ); + } + + /** + * Transform a URI to an acct @ + * + * @param string $uri The URI (acct:, mailto:, http:, https:) + * + * @return string|WP_Error Error or acct URI + */ + public static function uri_to_acct( $uri ) { + $data = self::get_data( $uri ); + + if ( is_wp_error( $data ) ) { + return $data; + } + + // check if subject is an acct URI + if ( + isset( $data['subject'] ) && + \str_starts_with( $data['subject'], 'acct:' ) + ) { + return $data['subject']; + } + + // search for an acct URI in the aliases + if ( isset( $data['aliases'] ) ) { + foreach ( $data['aliases'] as $alias ) { + if ( \str_starts_with( $alias, 'acct:' ) ) { + return $alias; + } + } + } + + return new WP_Error( + 'webfinger_url_no_acct', + __( 'No acct URI found.', 'activitypub' ), + $data + ); } /** @@ -137,7 +134,7 @@ class Webfinger { } if ( empty( $host ) ) { - return new WP_Error( 'invalid_identifier', __( 'Invalid Identifier', 'activitypub' ) ); + return new WP_Error( 'webfinger_invalid_identifier', __( 'Invalid Identifier', 'activitypub' ) ); } return array( $identifier, $host ); @@ -146,55 +143,70 @@ class Webfinger { /** * Get the WebFinger data for a given URI * - * @param string $identifier The Identifier: @ - * @param string $host The Host: @ + * @param string $uri The Identifier: @ or URI * * @return WP_Error|array Error reaction or array with * identifier and host as values */ - public static function get_data( $identifier, $host ) { - $webfinger_url = 'https://' . $host . '/.well-known/webfinger?resource=' . rawurlencode( $identifier ); - - $response = wp_safe_remote_get( - $webfinger_url, - array( - 'headers' => array( 'Accept' => 'application/jrd+json' ), - 'redirection' => 0, - 'timeout' => 2, - ) - ); - - if ( is_wp_error( $response ) ) { - return new WP_Error( 'webfinger_url_not_accessible', null, $webfinger_url ); - } - - $body = wp_remote_retrieve_body( $response ); - - return json_decode( $body, true ); - } - - /** - * Undocumented function - * - * @return void - */ - public static function get_remote_follow_endpoint( $uri ) { + public static function get_data( $uri ) { $identifier_and_host = self::get_identifier_and_host( $uri ); if ( is_wp_error( $identifier_and_host ) ) { return $identifier_and_host; } + $transient_key = self::generate_cache_key( $uri ); + list( $identifier, $host ) = $identifier_and_host; - $data = self::get_data( $identifier, $host ); + $data = \get_transient( $transient_key ); + if ( $data ) { + return $data; + } + + $webfinger_url = 'https://' . $host . '/.well-known/webfinger?resource=' . rawurlencode( $identifier ); + + $response = wp_safe_remote_get( + $webfinger_url, + array( + 'headers' => array( 'Accept' => 'application/jrd+json' ), + ) + ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'webfinger_url_not_accessible', + __( 'The WebFinger Resource is not accessible.', 'activitypub' ), + $webfinger_url + ); + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + \set_transient( $transient_key, $data, WEEK_IN_SECONDS ); + + return $data; + } + + /** + * Get the Remote-Follow endpoint for a given URI + * + * @return string|WP_Error Error or the Remote-Follow endpoint URI. + */ + public static function get_remote_follow_endpoint( $uri ) { + $data = self::get_data( $uri ); if ( is_wp_error( $data ) ) { return $data; } if ( empty( $data['links'] ) ) { - return new WP_Error( 'webfinger_url_invalid_response', null, $data ); + return new WP_Error( + 'webfinger_missing_links', + __( 'No valid Link elements found.', 'activitypub' ), + $data + ); } foreach ( $data['links'] as $link ) { @@ -203,6 +215,27 @@ class Webfinger { } } - return new WP_Error( 'webfinger_remote_follow_endpoint_invalid', $data, array( 'status' => 417 ) ); + return new WP_Error( + 'webfinger_missing_remote_follow_endpoint', + __( 'No valid Remote-Follow endpoint found.', 'activitypub' ), + $data + ); + } + + /** + * Generate a cache key for a given URI + * + * @param string $uri A WebFinger Resource URI + * + * @return string The cache key + */ + public static function generate_cache_key( $uri ) { + $uri = ltrim( $uri, '@' ); + + if ( filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) { + $uri = 'acct:' . $uri; + } + + return 'webfinger_' . md5( $uri ); } } diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 87e4334..aeb706e 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -31,12 +31,13 @@ class Interactions { return false; } - $in_reply_to = \esc_url_raw( $activity['object']['inReplyTo'] ); - $comment_post_id = \url_to_postid( $in_reply_to ); - $parent_comment = object_id_to_comment( $in_reply_to ); + $in_reply_to = \esc_url_raw( $activity['object']['inReplyTo'] ); + $comment_post_id = \url_to_postid( $in_reply_to ); + $parent_comment_id = url_to_commentid( $in_reply_to ); // save only replys and reactions - if ( ! $comment_post_id && $parent_comment ) { + if ( ! $comment_post_id && $parent_comment_id ) { + $parent_comment = get_comment( $parent_comment_id ); $comment_post_id = $parent_comment->comment_post_ID; } @@ -58,10 +59,9 @@ class Interactions { 'comment_content' => \addslashes( $activity['object']['content'] ), 'comment_type' => 'comment', 'comment_author_email' => '', - 'comment_parent' => $parent_comment ? $parent_comment->comment_ID : 0, + 'comment_parent' => $parent_comment_id ? $parent_comment_id : 0, 'comment_meta' => array( 'source_id' => \esc_url_raw( $activity['object']['id'] ), - 'source_url' => \esc_url_raw( $activity['object']['url'] ), 'protocol' => 'activitypub', ), ); @@ -70,6 +70,10 @@ class Interactions { $commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] ); } + if ( isset( $activity['object']['url'] ) ) { + $commentdata['comment_meta']['source_url'] = \esc_url_raw( $activity['object']['url'] ); + } + // disable flood control \remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); // do not require email for AP entries @@ -104,14 +108,14 @@ class Interactions { $meta = get_remote_metadata_by_actor( $activity['actor'] ); //Determine comment_ID - $object_comment_id = url_to_commentid( \esc_url_raw( $activity['object']['id'] ) ); + $comment = object_id_to_comment( \esc_url_raw( $activity['object']['id'] ) ); + $commentdata = \get_comment( $comment, ARRAY_A ); - if ( ! $object_comment_id ) { + if ( ! $commentdata ) { return false; } //found a local comment id - $commentdata = \get_comment( $object_comment_id, ARRAY_A ); $commentdata['comment_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] ); $commentdata['comment_content'] = \addslashes( $activity['object']['content'] ); if ( isset( $meta['icon']['url'] ) ) { diff --git a/includes/functions.php b/includes/functions.php index 1140724..1f8190e 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -4,6 +4,7 @@ namespace Activitypub; use WP_Error; use WP_Comment_Query; use Activitypub\Http; +use Activitypub\Webfinger; use Activitypub\Activity\Activity; use Activitypub\Collection\Followers; use Activitypub\Collection\Users; @@ -168,6 +169,27 @@ function url_to_authorid( $url ) { return 0; } +/** + * Verify if url is a wp_ap_comment, + * Or if it is a previously received remote comment + * + * @return int comment_id + */ +function is_comment() { + $comment_id = get_query_var( 'c', null ); + + if ( ! is_null( $comment_id ) ) { + $comment = \get_comment( $comment_id ); + + // Only return local origin comments + if ( $comment && $comment->user_id ) { + return $comment_id; + } + } + + return false; +} + /** * Check for Tombstone Objects * @@ -579,7 +601,7 @@ function get_active_users( $duration = 1 ) { global $wpdb; $query = "SELECT COUNT( DISTINCT post_author ) FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND post_date <= DATE_SUB( NOW(), INTERVAL %d MONTH )"; $query = $wpdb->prepare( $query, $duration ); - $count = $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $count = $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching set_transient( $transient_key, $count, DAY_IN_SECONDS ); } @@ -674,16 +696,33 @@ function url_to_commentid( $url ) { return null; } + // check for local comment + if ( \wp_parse_url( \site_url(), \PHP_URL_HOST ) === \wp_parse_url( $url, \PHP_URL_HOST ) ) { + $query = \wp_parse_url( $url, PHP_URL_QUERY ); + + if ( $query ) { + parse_str( $query, $params ); + + if ( ! empty( $params['c'] ) ) { + $comment = \get_comment( $params['c'] ); + + if ( $comment ) { + return $comment->comment_ID; + } + } + } + } + $args = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( 'relation' => 'OR', array( - 'key' => 'source_url', + 'key' => 'source_url', 'value' => $url, ), array( - 'key' => 'source_id', + 'key' => 'source_id', 'value' => $url, ), ), diff --git a/includes/rest/class-users.php b/includes/rest/class-users.php index 9fb10ba..ce55227 100644 --- a/includes/rest/class-users.php +++ b/includes/rest/class-users.php @@ -50,14 +50,13 @@ class Users { array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( self::class, 'remote_follow_get' ), - + 'permission_callback' => '__return_true', 'args' => array( 'resource' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), ), - 'permission_callback' => '__return_true', ), ) ); diff --git a/includes/transformer/class-attachment.php b/includes/transformer/class-attachment.php new file mode 100644 index 0000000..684d2eb --- /dev/null +++ b/includes/transformer/class-attachment.php @@ -0,0 +1,49 @@ +object->ID ); + $media_type = preg_replace( '/(\/[a-zA-Z]+)/i', '', $mime_type ); + + switch ( $media_type ) { + case 'audio': + case 'video': + $type = 'Document'; + break; + case 'image': + $type = 'Image'; + break; + } + + $attachment = array( + 'type' => $type, + 'url' => wp_get_attachment_url( $this->object->ID ), + 'mediaType' => $mime_type, + ); + + $alt = \get_post_meta( $this->object->ID, '_wp_attachment_image_alt', true ); + if ( $alt ) { + $attachment['name'] = $alt; + } + + return $attachment; + } +} diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php new file mode 100644 index 0000000..2d2c59b --- /dev/null +++ b/includes/transformer/class-base.php @@ -0,0 +1,105 @@ +object = $object; + } + + /** + * Transform the WordPress Object into an ActivityPub Object. + * + * @return Activitypub\Activity\Base_Object + */ + public function to_object() { + $object = new Base_Object(); + + $vars = $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 ( isset( $value ) ) { + $setter = 'set_' . $var; + + call_user_func( array( $object, $setter ), $value ); + } + } + } + + return $object; + } + + /** + * Transforms the ActivityPub Object to an Activity + * + * @param string $type The Activity-Type. + * + * @return \Activitypub\Activity\Activity The Activity. + */ + public function to_activity( $type ) { + $object = $this->to_object(); + + $activity = new Activity(); + $activity->set_type( $type ); + $activity->set_object( $object ); + + // Use simple Object (only ID-URI) for Like and Announce + if ( in_array( $type, array( 'Like', 'Announce' ), true ) ) { + $activity->set_object( $object->get_id() ); + } + + return $activity; + } + + /** + * Returns the ID of the WordPress Object. + * + * @return int The ID of the WordPress Object + */ + abstract public function get_wp_user_id(); + + /** + * Change the User-ID of the WordPress Post. + * + * @return int The User-ID of the WordPress Post + */ + abstract public function change_wp_user_id( $user_id ); +} diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php new file mode 100644 index 0000000..bfb1b04 --- /dev/null +++ b/includes/transformer/class-comment.php @@ -0,0 +1,277 @@ +object->user_id; + } + + /** + * Change the User-ID of the WordPress Comment. + * + * @return int The User-ID of the WordPress Comment + */ + public function change_wp_user_id( $user_id ) { + $this->object->user_id = $user_id; + } + + /** + * Transforms the WP_Comment object to an ActivityPub Object + * + * @see \Activitypub\Activity\Base_Object + * + * @return \Activitypub\Activity\Base_Object The ActivityPub Object + */ + public function to_object() { + $comment = $this->object; + $object = parent::to_object(); + + $object->set_url( \get_comment_link( $comment->comment_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_last_modified', true ); + if ( $updated > $published ) { + $object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $updated ) ) ); + } + + $object->set_content_map( + array( + $this->get_locale() => $this->get_content(), + ) + ); + $path = sprintf( 'users/%d/followers', intval( $comment->comment_author ) ); + + $object->set_to( + array( + 'https://www.w3.org/ns/activitystreams#Public', + get_rest_url_by_path( $path ), + ) + ); + + return $object; + } + + /** + * Returns the User-URL of the Author of the Post. + * + * If `single_user` mode is enabled, the URL of the Blog-User is returned. + * + * @return string The User-URL. + */ + protected function get_attributed_to() { + if ( is_single_user() ) { + $user = new Blog_User(); + return $user->get_url(); + } + + return Users::get_by_id( $this->object->user_id )->get_url(); + } + + /** + * Returns the content for the ActivityPub Item. + * + * The content will be generated based on the user settings. + * + * @return string The content. + */ + protected function get_content() { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $comment = $this->object; + $content = $comment->comment_content; + + $content = \wpautop( $content ); + $content = \preg_replace( '/[\n\r\t]/', '', $content ); + $content = \trim( $content ); + $content = \apply_filters( 'the_content', $content, $comment ); + + return $content; + } + + /** + * Returns the in-reply-to for the ActivityPub Item. + * + * @return int The URL of the in-reply-to. + */ + protected function get_in_reply_to() { + $comment = $this->object; + + $parent_comment = \get_comment( $comment->comment_parent ); + + if ( $parent_comment ) { + $comment_meta = \get_comment_meta( $parent_comment->comment_ID ); + + if ( ! empty( $comment_meta['source_id'][0] ) ) { + $in_reply_to = $comment_meta['source_id'][0]; + } elseif ( ! empty( $comment_meta['source_url'][0] ) ) { + $in_reply_to = $comment_meta['source_url'][0]; + } else { + $in_reply_to = $this->generate_id( $parent_comment ); + } + } else { + $in_reply_to = \get_permalink( $comment->comment_post_ID ); + } + + return $in_reply_to; + } + + /** + * Returns the ID of the ActivityPub Object. + * + * @see https://www.w3.org/TR/activitypub/#obj-id + * @see https://github.com/tootsuite/mastodon/issues/13879 + * + * @return string ActivityPub URI for comment + */ + protected function get_id() { + $comment = $this->object; + return $this->generate_id( $comment ); + } + + /** + * Generates an ActivityPub URI for a comment + * + * @param WP_Comment|int $comment A comment object or comment ID + * + * @return string ActivityPub URI for comment + */ + protected function generate_id( $comment ) { + $comment = get_comment( $comment ); + + return \add_query_arg( + array( + 'c' => $comment->comment_ID, + ), + \trailingslashit( site_url() ) + ); + } + + /** + * Returns a list of Mentions, used in the Comment. + * + * @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 $mention => $url ) { + $cc[] = $url; + } + } + + $comment_query = new WP_Comment_Query( + array( + 'post_id' => $this->object->comment_post_ID, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => 'source_id', + 'compare' => 'EXISTS', + ), + ), + ) + ); + + if ( $comment_query->comments ) { + foreach ( $comment_query->comments as $comment ) { + if ( empty( $comment->comment_author_url ) ) { + continue; + } + $cc[] = \esc_url( $comment->comment_author_url ); + } + } + + $cc = \array_unique( $cc ); + + return $cc; + } + + /** + * Returns a list of Tags, used in the Comment. + * + * This includes Hash-Tags and Mentions. + * + * @return array The list of Tags. + */ + protected function get_tag() { + $tags = array(); + + $mentions = $this->get_mentions(); + if ( $mentions ) { + foreach ( $mentions as $mention => $url ) { + $tag = array( + 'type' => 'Mention', + 'href' => \esc_url( $url ), + 'name' => \esc_html( $mention ), + ); + $tags[] = $tag; + } + } + + return \array_unique( $tags, SORT_REGULAR ); + } + + /** + * Helper function to get the @-Mentions from the comment content. + * + * @return array The list of @-Mentions. + */ + protected function get_mentions() { + return apply_filters( 'activitypub_extract_mentions', array(), $this->object->comment_content, $this->object ); + } + + /** + * Returns the locale of the post. + * + * @return string The locale of the post. + */ + public function get_locale() { + $comment_id = $this->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->object ); + } +} diff --git a/includes/transformer/class-factory.php b/includes/transformer/class-factory.php new file mode 100644 index 0000000..7df7836 --- /dev/null +++ b/includes/transformer/class-factory.php @@ -0,0 +1,61 @@ +post_type === 'event' ) { + * return new My_Event_Transformer( $object ); + * } + * return $transformer; + * }, 10, 3 ); + * + * @param Activitypub\Transformer\Base $transformer The transformer to use. + * @param mixed $object The object to transform. + * @param string $object_class The class of the object to transform. + * + * @return mixed The transformer to use. + */ + $transformer = apply_filters( 'activitypub_transformer', null, $object, get_class( $object ) ); + + if ( $transformer ) { + return $transformer; + } + + // use default transformer + switch ( get_class( $object ) ) { + case 'WP_Post': + if ( 'attachment' === $object->post_type ) { + return new Attachment( $object ); + } + return new Post( $object ); + case 'WP_Comment': + return new Comment( $object ); + default: + return null; + } + } +} diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 061be51..7e631dd 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -2,10 +2,11 @@ namespace Activitypub\Transformer; use WP_Post; -use Activitypub\Collection\Users; -use Activitypub\Model\Blog_User; -use Activitypub\Activity\Base_Object; use Activitypub\Shortcodes; +use Activitypub\Model\Blog_User; +use Activitypub\Transformer\Base; +use Activitypub\Collection\Users; +use Activitypub\Activity\Base_Object; use function Activitypub\esc_hashtag; use function Activitypub\is_single_user; @@ -15,42 +16,32 @@ use function Activitypub\site_supports_blocks; /** * WordPress Post Transformer * - * The Post Transformer is responsible for transforming a WP_Post object into different othe + * The Post Transformer is responsible for transforming a WP_Post object into different other * Object-Types. * * Currently supported are: * * - Activitypub\Activity\Base_Object */ -class Post { - +class Post extends Base { /** - * The WP_Post object. + * Returns the ID of the WordPress Post. * - * @var WP_Post + * @return int The ID of the WordPress Post */ - protected $wp_post; - - /** - * Static function to Transform a WP_Post Object. - * - * This helps to chain the output of the Transformer. - * - * @param WP_Post $wp_post The WP_Post object - * - * @return void - */ - public static function transform( WP_Post $wp_post ) { - return new static( $wp_post ); + public function get_wp_user_id() { + return $this->object->post_author; } /** + * Change the User-ID of the WordPress Post. * - * - * @param WP_Post $wp_post + * @return int The User-ID of the WordPress Post */ - public function __construct( WP_Post $wp_post ) { - $this->wp_post = $wp_post; + public function change_wp_user_id( $user_id ) { + $this->object->post_author = $user_id; + + return $this; } /** @@ -61,31 +52,25 @@ class Post { * @return \Activitypub\Activity\Base_Object The ActivityPub Object */ public function to_object() { - $wp_post = $this->wp_post; - $object = new Base_Object(); + $post = $this->object; + $object = parent::to_object(); - $object->set_id( $this->get_id() ); - $object->set_url( $this->get_url() ); - $object->set_type( $this->get_object_type() ); - - $published = \strtotime( $wp_post->post_date_gmt ); + $published = \strtotime( $post->post_date_gmt ); $object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) ); - $updated = \strtotime( $wp_post->post_modified_gmt ); + $updated = \strtotime( $post->post_modified_gmt ); if ( $updated > $published ) { $object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) ); } - $object->set_attributed_to( $this->get_attributed_to() ); - $object->set_content( $this->get_content() ); $object->set_content_map( array( $this->get_locale() => $this->get_content(), ) ); - $path = sprintf( 'users/%d/followers', intval( $wp_post->post_author ) ); + $path = sprintf( 'users/%d/followers', intval( $post->post_author ) ); $object->set_to( array( @@ -93,9 +78,6 @@ class Post { get_rest_url_by_path( $path ), ) ); - $object->set_cc( $this->get_cc() ); - $object->set_attachment( $this->get_attachments() ); - $object->set_tag( $this->get_tags() ); return $object; } @@ -115,7 +97,7 @@ class Post { * @return string The Posts URL. */ public function get_url() { - $post = $this->wp_post; + $post = $this->object; if ( 'trash' === get_post_status( $post ) ) { $permalink = \get_post_meta( $post->ID, 'activitypub_canonical_url', true ); @@ -139,7 +121,7 @@ class Post { return $user->get_url(); } - return Users::get_by_id( $this->wp_post->post_author )->get_url(); + return Users::get_by_id( $this->object->post_author )->get_url(); } /** @@ -147,12 +129,12 @@ class Post { * * @return array The Attachments. */ - protected function get_attachments() { + protected function get_attachment() { // Once upon a time we only supported images, but we now support audio/video as well. // We maintain the image-centric naming for backwards compatibility. $max_media = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) ); - if ( site_supports_blocks() && \has_blocks( $this->wp_post->post_content ) ) { + if ( site_supports_blocks() && \has_blocks( $this->object->post_content ) ) { return $this->get_block_attachments( $max_media ); } @@ -172,7 +154,7 @@ class Post { return array(); } - $id = $this->wp_post->ID; + $id = $this->object->ID; $media_ids = array(); @@ -182,7 +164,7 @@ class Post { } if ( $max_media > 0 ) { - $blocks = \parse_blocks( $this->wp_post->post_content ); + $blocks = \parse_blocks( $this->object->post_content ); $media_ids = self::get_media_ids_from_blocks( $blocks, $media_ids, $max_media ); } @@ -203,7 +185,7 @@ class Post { return array(); } - $id = $this->wp_post->ID; + $id = $this->object->ID; $image_ids = array(); @@ -316,7 +298,7 @@ class Post { */ $thumbnail = apply_filters( 'activitypub_get_image', - self::get_image( $id, $image_size ), + self::get_wordpress_attachment( $id, $image_size ), $id, $image_size ); @@ -365,7 +347,7 @@ class Post { * * @return array|false Array of image data, or boolean false if no image is available. */ - protected static function get_image( $id, $image_size = 'full' ) { + protected static function get_wordpress_attachment( $id, $image_size = 'full' ) { /** * Hook into the image retrieval process. Before image retrieval. * @@ -402,10 +384,10 @@ class Post { // Default to Article. $object_type = 'Article'; - $post_type = \get_post_type( $this->wp_post ); + $post_type = \get_post_type( $this->object ); switch ( $post_type ) { case 'post': - $post_format = \get_post_format( $this->wp_post ); + $post_format = \get_post_format( $this->object ); switch ( $post_format ) { case 'aside': case 'status': @@ -481,10 +463,10 @@ class Post { * * @return array The list of Tags. */ - protected function get_tags() { + protected function get_tag() { $tags = array(); - $post_tags = \get_the_tags( $this->wp_post->ID ); + $post_tags = \get_the_tags( $this->object->ID ); if ( $post_tags ) { foreach ( $post_tags as $post_tag ) { $tag = array( @@ -531,7 +513,7 @@ class Post { do_action( 'activitypub_before_get_content', $post ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $post = $this->wp_post; + $post = $this->object; $content = $this->get_post_content_template(); // Register our shortcodes just in time. @@ -576,7 +558,7 @@ class Post { break; } - return apply_filters( 'activitypub_object_content_template', $template, $this->wp_post ); + return apply_filters( 'activitypub_object_content_template', $template, $this->object ); } /** @@ -585,7 +567,7 @@ class Post { * @return array The list of @-Mentions. */ protected function get_mentions() { - return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_post->post_content, $this->wp_post ); + return apply_filters( 'activitypub_extract_mentions', array(), $this->object->post_content, $this->object ); } /** @@ -594,7 +576,7 @@ class Post { * @return string The locale of the post. */ public function get_locale() { - $post_id = $this->wp_post->ID; + $post_id = $this->object->ID; $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); /** @@ -606,6 +588,6 @@ class Post { * * @return string The filtered locale of the post. */ - return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_post ); + return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->object ); } } diff --git a/readme.txt b/readme.txt index c15b43f..510a3ca 100644 --- a/readme.txt +++ b/readme.txt @@ -113,6 +113,7 @@ Project maintained on GitHub at [automattic/wordpress-activitypub](https://githu * Added: Make Post-Template filterable * Added: CSS class for ActivityPub comments to allow custom designs * Added: FEP-2677: Identifying the Application Actor +* Added: Basic Comment Federation * Improved: WebFinger endpoints = 1.3.0 = diff --git a/templates/author-json.php b/templates/author-json.php index 70fb43b..7043131 100644 --- a/templates/author-json.php +++ b/templates/author-json.php @@ -10,23 +10,8 @@ $user->set_context( */ \do_action( 'activitypub_json_author_pre', $user->get__id() ); -$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_author_options', $options, $user->get__id() ); - \header( 'Content-Type: application/activity+json' ); -echo \wp_json_encode( $user->to_array(), $options ); +echo $user->to_json(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped /* * Action triggerd after the ActivityPub profile has been created and sent to the client diff --git a/templates/blog-json.php b/templates/blog-json.php index 7ce6a27..3ca54dc 100644 --- a/templates/blog-json.php +++ b/templates/blog-json.php @@ -10,23 +10,8 @@ $user->set_context( */ \do_action( 'activitypub_json_author_pre', $user->get__id() ); -$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_author_options', $options, $user->get__id() ); - \header( 'Content-Type: application/activity+json' ); -echo \wp_json_encode( $user->to_array(), $options ); +echo $user->to_json(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped /* * Action triggerd after the ActivityPub profile has been created and sent to the client diff --git a/templates/comment-json.php b/templates/comment-json.php new file mode 100644 index 0000000..aea4268 --- /dev/null +++ b/templates/comment-json.php @@ -0,0 +1,36 @@ + \Activitypub\get_context() ), $object->to_object()->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' ); diff --git a/templates/post-json.php b/templates/post-json.php index 89467c4..1db29a6 100644 --- a/templates/post-json.php +++ b/templates/post-json.php @@ -2,34 +2,16 @@ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited $post = \get_post(); -$object = new \Activitypub\Transformer\Post( $post ); -$json = \array_merge( array( '@context' => \Activitypub\get_context() ), $object->to_object()->to_array() ); - -// filter output -$json = \apply_filters( 'activitypub_json_post_array', $json ); +$post_object = \Activitypub\Transformer\Factory::get_transformer( $post )->to_object(); +$post_object->set_context( \Activitypub\get_context() ); /* * Action triggerd prior to the ActivityPub profile being created and sent to the client */ \do_action( 'activitypub_json_post_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_post_options', $options ); - \header( 'Content-Type: application/activity+json' ); -echo \wp_json_encode( $json, $options ); +echo $post_object->to_json(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped /* * Action triggerd after the ActivityPub profile has been created and sent to the client diff --git a/tests/test-class-activitypub-webfinger.php b/tests/test-class-activitypub-webfinger.php new file mode 100644 index 0000000..5b1bdb9 --- /dev/null +++ b/tests/test-class-activitypub-webfinger.php @@ -0,0 +1,21 @@ +assertEquals( $cache_key, 'webfinger_' . $hash ); + } + + public function the_cache_key_provider() { + return array( + array( 'http://example.org/?author=1', md5( 'http://example.org/?author=1' ) ), + array( '@author@example.org', md5( 'acct:author@example.org' ) ), + array( 'author@example.org', md5( 'acct:author@example.org' ) ), + array( 'acct:author@example.org', md5( 'acct:author@example.org' ) ), + array( 'https://example.org', md5( 'https://example.org' ) ), + ); + } +}