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 <mediaformat.ux@gmail.com>
Co-authored-by: Andreas <andreas@bocops.de>
Co-authored-by: Eana Hufwe <eana@1a23.com>
Co-authored-by: Matthew Exon <git.mexon@spamgourmet.com>
Co-authored-by: Django Doucet <django.doucet@webdevstudios.com>
This commit is contained in:
Matthias Pfefferle 2023-12-22 10:12:26 +01:00 committed by GitHub
parent a3ea9955d9
commit b744dc551d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1009 additions and 278 deletions

View file

@ -113,6 +113,7 @@ Project maintained on GitHub at [automattic/wordpress-activitypub](https://githu
* Added: Make Post-Template filterable * Added: Make Post-Template filterable
* Added: CSS class for ActivityPub comments to allow custom designs * Added: CSS class for ActivityPub comments to allow custom designs
* Added: FEP-2677: Identifying the Application Actor * Added: FEP-2677: Identifying the Application Actor
* Added: Basic Comment Federation
* Improved: WebFinger endpoints * Improved: WebFinger endpoints
### 1.3.0 ### ### 1.3.0 ###

View file

@ -17,4 +17,5 @@ jQuery( function( $ ) {
$( '.activate-now' ).removeClass( 'thickbox open-plugin-details-modal' ); $( '.activate-now' ).removeClass( 'thickbox open-plugin-details-modal' );
}, 1200 ); }, 1200 );
} ); } );
} ); } );

View file

@ -254,19 +254,6 @@ class Base_Object {
*/ */
protected $published; 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 * The date and time describing the actual or expected starting time
* of the object. * of the object.
@ -437,6 +424,19 @@ class Base_Object {
*/ */
protected $source; 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 * Magic function to implement getter and setter
* *
@ -671,8 +671,25 @@ class Base_Object {
* @return string The JSON string. * @return string The JSON string.
*/ */
public function to_json() { 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 ) );
} }
} }

View file

@ -2,10 +2,13 @@
namespace Activitypub; namespace Activitypub;
use WP_Post; use WP_Post;
use WP_Comment;
use Activitypub\Activity\Activity; use Activitypub\Activity\Activity;
use Activitypub\Collection\Users; use Activitypub\Collection\Users;
use Activitypub\Collection\Followers; use Activitypub\Collection\Followers;
use Activitypub\Transformer\Factory;
use Activitypub\Transformer\Post; use Activitypub\Transformer\Post;
use Activitypub\Transformer\Comment;
use function Activitypub\is_single_user; use function Activitypub\is_single_user;
use function Activitypub\is_user_disabled; 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. * Send Activities to followers and mentioned users or `Announce` (boost) a blog post.
* *
* @param WP_Post $wp_post The ActivityPub Post. * @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type. * @param string $type The Activity-Type.
* *
* @return void * @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 // check if a migration is needed before sending new posts
Migration::maybe_migrate(); Migration::maybe_migrate();
@ -43,35 +46,37 @@ class Activity_Dispatcher {
return; return;
} }
$wp_post->post_author = Users::BLOG_USER_ID;
if ( is_single_user() ) { if ( is_single_user() ) {
self::send_activity( $wp_post, $type ); self::send_activity( $wp_object, $type, Users::BLOG_USER_ID );
} else { } else {
self::send_announce( $wp_post, $type ); self::send_announce( $wp_object, $type );
} }
} }
/** /**
* Send Activities to followers and mentioned users. * Send Activities to followers and mentioned users.
* *
* @param WP_Post $wp_post The ActivityPub Post. * @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type. * @param string $type The Activity-Type.
* *
* @return void * @return void
*/ */
public static function send_activity( WP_Post $wp_post, $type ) { public static function send_activity( $wp_object, $type, $user_id = null ) {
if ( is_user_disabled( $wp_post->post_author ) ) { $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; return;
} }
$object = Post::transform( $wp_post )->to_object(); $activity = $transformer->to_activity( 'Create' );
$activity = new Activity(); $follower_inboxes = Followers::get_inboxes( $user_id );
$activity->set_type( $type );
$activity->set_object( $object );
$follower_inboxes = Followers::get_inboxes( $wp_post->post_author );
$mentioned_inboxes = Mention::get_inboxes( $activity->get_cc() ); $mentioned_inboxes = Mention::get_inboxes( $activity->get_cc() );
$inboxes = array_merge( $follower_inboxes, $mentioned_inboxes ); $inboxes = array_merge( $follower_inboxes, $mentioned_inboxes );
@ -80,19 +85,19 @@ class Activity_Dispatcher {
$json = $activity->to_json(); $json = $activity->to_json();
foreach ( $inboxes as $inbox ) { 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. * Send Announces to followers and mentioned users.
* *
* @param WP_Post $wp_post The ActivityPub Post. * @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type. * @param string $type The Activity-Type.
* *
* @return void * @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 ) ) { if ( ! in_array( $type, array( 'Create', 'Update' ), true ) ) {
return; return;
} }
@ -101,16 +106,13 @@ class Activity_Dispatcher {
return; 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(); $user_id = $transformer->get_wp_user_id();
$activity->set_type( 'Announce' ); $activity = $transformer->to_activity( 'Announce' );
// to pre-fill attributes like "published" and "id"
$activity->set_object( $object );
// send only the id
$activity->set_object( $object->get_id() );
$follower_inboxes = Followers::get_inboxes( $wp_post->post_author ); $follower_inboxes = Followers::get_inboxes( $user_id );
$mentioned_inboxes = Mention::get_inboxes( $activity->get_cc() ); $mentioned_inboxes = Mention::get_inboxes( $activity->get_cc() );
$inboxes = array_merge( $follower_inboxes, $mentioned_inboxes ); $inboxes = array_merge( $follower_inboxes, $mentioned_inboxes );
@ -119,7 +121,7 @@ class Activity_Dispatcher {
$json = $activity->to_json(); $json = $activity->to_json();
foreach ( $inboxes as $inbox ) { foreach ( $inboxes as $inbox ) {
safe_remote_post( $inbox, $json, $wp_post->post_author ); safe_remote_post( $inbox, $json, $user_id );
} }
} }
} }

View file

@ -8,6 +8,9 @@ use Activitypub\Collection\Followers;
use function Activitypub\sanitize_url; use function Activitypub\sanitize_url;
use function Activitypub\is_comment;
use function Activitypub\is_activitypub_request;
/** /**
* ActivityPub Class * ActivityPub Class
* *
@ -19,6 +22,7 @@ class Activitypub {
*/ */
public static function init() { public static function init() {
\add_filter( 'template_include', array( self::class, 'render_json_template' ), 99 ); \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( '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( '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 ); \add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 3 );
@ -100,6 +104,8 @@ class Activitypub {
if ( \is_author() ) { if ( \is_author() ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/author-json.php'; $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/author-json.php';
} elseif ( is_comment() ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/comment-json.php';
} elseif ( \is_singular() ) { } elseif ( \is_singular() ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/post-json.php'; $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/post-json.php';
} elseif ( \is_home() ) { } elseif ( \is_home() ) {
@ -117,11 +123,43 @@ class Activitypub {
return $json_template; 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. * Add the 'activitypub' query variable so WordPress won't mangle it.
*/ */
public static function add_query_vars( $vars ) { public static function add_query_vars( $vars ) {
$vars[] = 'activitypub'; $vars[] = 'activitypub';
$vars[] = 'c';
$vars[] = 'p';
return $vars; return $vars;
} }
@ -197,10 +235,18 @@ class Activitypub {
* @return string $url * @return string $url
*/ */
public static function remote_comment_link( $comment_link, $comment ) { public static function remote_comment_link( $comment_link, $comment ) {
$remote_comment_link = get_comment_meta( $comment->comment_ID, 'source_url', true ); if ( ! $comment || is_admin() ) {
if ( $remote_comment_link ) { return $comment_link;
$comment_link = esc_url( $remote_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; return $comment_link;
} }

View file

@ -12,8 +12,8 @@ class Hashtag {
*/ */
public static function init() { public static function init() {
if ( '1' === \get_option( 'activitypub_use_hashtags', '1' ) ) { if ( '1' === \get_option( 'activitypub_use_hashtags', '1' ) ) {
\add_filter( 'wp_insert_post', array( self::class, 'insert_post' ), 10, 2 ); \add_action( 'wp_insert_post', array( self::class, 'insert_post' ), 10, 2 );
\add_filter( 'the_content', array( self::class, 'the_content' ), 10, 2 ); \add_filter( 'the_content', array( self::class, 'the_content' ), 10, 1 );
} }
} }
@ -67,7 +67,7 @@ class Hashtag {
$tag = strtolower( $m[2] ); $tag = strtolower( $m[2] );
if ( '/' === $m[1] ) { if ( '/' === $m[1] ) {
// Closing tag. // 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. // We can only remove the tag from the stack if it is in the stack.
if ( false !== $i ) { if ( false !== $i ) {
$tag_stack = array_slice( $tag_stack, 0, $i ); $tag_stack = array_slice( $tag_stack, 0, $i );

View file

@ -14,7 +14,8 @@ class Mention {
* Initialize the class, registering WordPress hooks * Initialize the class, registering WordPress hooks
*/ */
public static function init() { 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 ); \add_filter( 'activitypub_extract_mentions', array( self::class, 'extract_mentions' ), 99, 2 );
} }

View file

@ -16,11 +16,47 @@ class Scheduler {
* Initialize the class, registering WordPress hooks * Initialize the class, registering WordPress hooks
*/ */
public static function init() { public static function init() {
// Post transitions
\add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 ); \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_update_followers', array( self::class, 'update_followers' ) );
\add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) ); \add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) );
// Migration
\add_action( 'admin_init', array( self::class, 'schedule_migration' ) ); \add_action( 'admin_init', array( self::class, 'schedule_migration' ) );
} }
@ -58,6 +94,8 @@ class Scheduler {
* @param WP_Post $post Post object. * @param WP_Post $post Post object.
*/ */
public static function schedule_post_activity( $new_status, $old_status, $post ) { public static function schedule_post_activity( $new_status, $old_status, $post ) {
$post = get_post( $post );
// Do not send activities if post is password protected. // Do not send activities if post is password protected.
if ( \post_password_required( $post ) ) { if ( \post_password_required( $post ) ) {
return; 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 * Update followers
* *

View file

@ -110,8 +110,13 @@ class Shortcodes {
$excerpt = \get_post_field( 'post_excerpt', $item ); $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 ); $content = \get_post_field( 'post_content', $item );
// An empty string will make wp_trim_excerpt do stuff we do not want. // An empty string will make wp_trim_excerpt do stuff we do not want.
@ -207,20 +212,30 @@ class Shortcodes {
$tag $tag
); );
$content = \get_post_field( 'post_content', $item ); $content = '';
if ( 'yes' === $atts['apply_filters'] ) { if ( 'attachment' === $item->post_type ) {
$content = \apply_filters( 'the_content', $content ); // 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 { } else {
$content = do_blocks( $content ); $content = \get_post_field( 'post_content', $item );
$content = wptexturize( $content );
$content = wp_filter_content_tags( $content );
}
// replace script and style elements if ( 'yes' === $atts['apply_filters'] ) {
$content = \preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $content ); $content = \apply_filters( 'the_content', $content );
$content = strip_shortcodes( $content ); } else {
$content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) ); $content = do_blocks( $content );
$content = wptexturize( $content );
$content = wp_filter_content_tags( $content );
}
// replace script and style elements
$content = \preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $content );
$content = strip_shortcodes( $content );
$content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) );
}
add_shortcode( 'ap_content', array( 'Activitypub\Shortcodes', 'content' ) ); add_shortcode( 'ap_content', array( 'Activitypub\Shortcodes', 'content' ) );

View file

@ -15,7 +15,7 @@ class Webfinger {
/** /**
* Returns a users WebFinger "resource" * Returns a users WebFinger "resource"
* *
* @param int $user_id * @param int $user_id The WordPress user id
* *
* @return string The user-resource * @return string The user-resource
*/ */
@ -36,68 +36,65 @@ class Webfinger {
/** /**
* Resolve a WebFinger resource * 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 * @return string|WP_Error The URL or WP_Error
*/ */
public static function resolve( $resource ) { public static function resolve( $uri ) {
if ( ! $resource ) { $data = self::get_data( $uri );
return null;
if ( \is_wp_error( $data ) ) {
return $data;
} }
if ( ! preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $resource, $m ) ) { foreach ( $data['links'] as $link ) {
return null; if (
} 'self' === $link['rel'] &&
'application/activity+json' === $link['type']
$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 );
return $link['href']; return $link['href'];
} }
} }
$link = new WP_Error( 'webfinger_url_no_activitypub', null, $body ); return new WP_Error( 'webfinger_url_no_activitypub', null, $data );
\set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period. }
return $link;
/**
* Transform a URI to an acct <identifier>@<host>
*
* @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 ) ) { 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 ); return array( $identifier, $host );
@ -146,55 +143,70 @@ class Webfinger {
/** /**
* Get the WebFinger data for a given URI * Get the WebFinger data for a given URI
* *
* @param string $identifier The Identifier: <identifier>@<host> * @param string $uri The Identifier: <identifier>@<host> or URI
* @param string $host The Host: <identifier>@<host>
* *
* @return WP_Error|array Error reaction or array with * @return WP_Error|array Error reaction or array with
* identifier and host as values * identifier and host as values
*/ */
public static function get_data( $identifier, $host ) { public static function get_data( $uri ) {
$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 ) {
$identifier_and_host = self::get_identifier_and_host( $uri ); $identifier_and_host = self::get_identifier_and_host( $uri );
if ( is_wp_error( $identifier_and_host ) ) { if ( is_wp_error( $identifier_and_host ) ) {
return $identifier_and_host; return $identifier_and_host;
} }
$transient_key = self::generate_cache_key( $uri );
list( $identifier, $host ) = $identifier_and_host; 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 ) ) { if ( is_wp_error( $data ) ) {
return $data; return $data;
} }
if ( empty( $data['links'] ) ) { 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 ) { 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 );
} }
} }

View file

@ -31,12 +31,13 @@ class Interactions {
return false; return false;
} }
$in_reply_to = \esc_url_raw( $activity['object']['inReplyTo'] ); $in_reply_to = \esc_url_raw( $activity['object']['inReplyTo'] );
$comment_post_id = \url_to_postid( $in_reply_to ); $comment_post_id = \url_to_postid( $in_reply_to );
$parent_comment = object_id_to_comment( $in_reply_to ); $parent_comment_id = url_to_commentid( $in_reply_to );
// save only replys and reactions // 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; $comment_post_id = $parent_comment->comment_post_ID;
} }
@ -58,10 +59,9 @@ class Interactions {
'comment_content' => \addslashes( $activity['object']['content'] ), 'comment_content' => \addslashes( $activity['object']['content'] ),
'comment_type' => 'comment', 'comment_type' => 'comment',
'comment_author_email' => '', 'comment_author_email' => '',
'comment_parent' => $parent_comment ? $parent_comment->comment_ID : 0, 'comment_parent' => $parent_comment_id ? $parent_comment_id : 0,
'comment_meta' => array( 'comment_meta' => array(
'source_id' => \esc_url_raw( $activity['object']['id'] ), 'source_id' => \esc_url_raw( $activity['object']['id'] ),
'source_url' => \esc_url_raw( $activity['object']['url'] ),
'protocol' => 'activitypub', 'protocol' => 'activitypub',
), ),
); );
@ -70,6 +70,10 @@ class Interactions {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] ); $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 // 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 // do not require email for AP entries
@ -104,14 +108,14 @@ class Interactions {
$meta = get_remote_metadata_by_actor( $activity['actor'] ); $meta = get_remote_metadata_by_actor( $activity['actor'] );
//Determine comment_ID //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; return false;
} }
//found a local comment id //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_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] );
$commentdata['comment_content'] = \addslashes( $activity['object']['content'] ); $commentdata['comment_content'] = \addslashes( $activity['object']['content'] );
if ( isset( $meta['icon']['url'] ) ) { if ( isset( $meta['icon']['url'] ) ) {

View file

@ -4,6 +4,7 @@ namespace Activitypub;
use WP_Error; use WP_Error;
use WP_Comment_Query; use WP_Comment_Query;
use Activitypub\Http; use Activitypub\Http;
use Activitypub\Webfinger;
use Activitypub\Activity\Activity; use Activitypub\Activity\Activity;
use Activitypub\Collection\Followers; use Activitypub\Collection\Followers;
use Activitypub\Collection\Users; use Activitypub\Collection\Users;
@ -168,6 +169,27 @@ function url_to_authorid( $url ) {
return 0; 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 * Check for Tombstone Objects
* *
@ -579,7 +601,7 @@ function get_active_users( $duration = 1 ) {
global $wpdb; 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 = "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 ); $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 ); set_transient( $transient_key, $count, DAY_IN_SECONDS );
} }
@ -674,16 +696,33 @@ function url_to_commentid( $url ) {
return null; 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( $args = array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array( 'meta_query' => array(
'relation' => 'OR', 'relation' => 'OR',
array( array(
'key' => 'source_url', 'key' => 'source_url',
'value' => $url, 'value' => $url,
), ),
array( array(
'key' => 'source_id', 'key' => 'source_id',
'value' => $url, 'value' => $url,
), ),
), ),

View file

@ -50,14 +50,13 @@ class Users {
array( array(
'methods' => WP_REST_Server::READABLE, 'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'remote_follow_get' ), 'callback' => array( self::class, 'remote_follow_get' ),
'permission_callback' => '__return_true',
'args' => array( 'args' => array(
'resource' => array( 'resource' => array(
'required' => true, 'required' => true,
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
), ),
), ),
'permission_callback' => '__return_true',
), ),
) )
); );

View file

@ -0,0 +1,49 @@
<?php
namespace Activitypub\Transformer;
use Activitypub\Transformer\Post;
/**
* WordPress Attachment Transformer
*
* The Attachment Transformer is responsible for transforming a WP_Post object into different other
* Object-Types.
*
* Currently supported are:
*
* - Activitypub\Activity\Base_Object
*/
class Attachment extends Post {
/**
* Generates all Media Attachments for a Post.
*
* @return array The Attachments.
*/
protected function get_attachment() {
$mime_type = get_post_mime_type( $this->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;
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace Activitypub\Transformer;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
/**
* WordPress Base Transformer
*
* Transformers are responsible for transforming a WordPress objects into different ActivityPub
* Object-Types or Activities.
*/
abstract class Base {
/**
* The WP_Post object.
*
* @var
*/
protected $object;
/**
* Static function to Transform a WordPress Object.
*
* This helps to chain the output of the Transformer.
*
* @param stdClass $object The WP_Post object
*
* @return void
*/
public static function transform( $object ) {
return new static( $object );
}
/**
* Base constructor.
*
* @param stdClass $object
*/
public function __construct( $object ) {
$this->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 );
}

View file

@ -0,0 +1,277 @@
<?php
namespace Activitypub\Transformer;
use WP_Comment;
use WP_Comment_Query;
use Activitypub\Hashtag;
use Activitypub\Webfinger;
use Activitypub\Model\Blog_User;
use Activitypub\Collection\Users;
use Activitypub\Transformer\Base;
use Activitypub\Activity\Base_Object;
use function Activitypub\esc_hashtag;
use function Activitypub\is_single_user;
use function Activitypub\get_rest_url_by_path;
/**
* WordPress Comment Transformer
*
* The Comment Transformer is responsible for transforming a WP_Comment object into different
* Object-Types.
*
* Currently supported are:
*
* - Activitypub\Activity\Base_Object
*/
class Comment extends Base {
/**
* Returns the User-ID of the WordPress Comment.
*
* @return int The User-ID of the WordPress Comment
*/
public function get_wp_user_id() {
return $this->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 );
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace Activitypub\Transformer;
use Activitypub\Transformer\Post;
use Activitypub\Transformer\Comment;
use Activitypub\Transformer\Attachment;
/**
* Transformer Factory
*/
class Factory {
public static function get_transformer( $object ) {
/**
* Filter the transformer for a given object.
*
* Add your own transformer based on the object class or the object type.
*
* Example usage:
*
* // Filter be object class
* add_filter( 'activitypub_transformer', function( $transformer, $object, $object_class ) {
* if ( $object_class === 'WP_Post' ) {
* return new My_Post_Transformer( $object );
* }
* return $transformer;
* }, 10, 3 );
*
* // Filter be object type
* add_filter( 'activitypub_transformer', function( $transformer, $object, $object_class ) {
* if ( $object->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;
}
}
}

View file

@ -2,10 +2,11 @@
namespace Activitypub\Transformer; namespace Activitypub\Transformer;
use WP_Post; use WP_Post;
use Activitypub\Collection\Users;
use Activitypub\Model\Blog_User;
use Activitypub\Activity\Base_Object;
use Activitypub\Shortcodes; 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\esc_hashtag;
use function Activitypub\is_single_user; use function Activitypub\is_single_user;
@ -15,42 +16,32 @@ use function Activitypub\site_supports_blocks;
/** /**
* WordPress Post Transformer * 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. * Object-Types.
* *
* Currently supported are: * Currently supported are:
* *
* - Activitypub\Activity\Base_Object * - 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; public function get_wp_user_id() {
return $this->object->post_author;
/**
* 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 );
} }
/** /**
* Change the User-ID of the WordPress Post.
* *
* * @return int The User-ID of the WordPress Post
* @param WP_Post $wp_post
*/ */
public function __construct( WP_Post $wp_post ) { public function change_wp_user_id( $user_id ) {
$this->wp_post = $wp_post; $this->object->post_author = $user_id;
return $this;
} }
/** /**
@ -61,31 +52,25 @@ class Post {
* @return \Activitypub\Activity\Base_Object The ActivityPub Object * @return \Activitypub\Activity\Base_Object The ActivityPub Object
*/ */
public function to_object() { public function to_object() {
$wp_post = $this->wp_post; $post = $this->object;
$object = new Base_Object(); $object = parent::to_object();
$object->set_id( $this->get_id() ); $published = \strtotime( $post->post_date_gmt );
$object->set_url( $this->get_url() );
$object->set_type( $this->get_object_type() );
$published = \strtotime( $wp_post->post_date_gmt );
$object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) ); $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 ) { if ( $updated > $published ) {
$object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) ); $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( $object->set_content_map(
array( array(
$this->get_locale() => $this->get_content(), $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( $object->set_to(
array( array(
@ -93,9 +78,6 @@ class Post {
get_rest_url_by_path( $path ), 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; return $object;
} }
@ -115,7 +97,7 @@ class Post {
* @return string The Posts URL. * @return string The Posts URL.
*/ */
public function get_url() { public function get_url() {
$post = $this->wp_post; $post = $this->object;
if ( 'trash' === get_post_status( $post ) ) { if ( 'trash' === get_post_status( $post ) ) {
$permalink = \get_post_meta( $post->ID, 'activitypub_canonical_url', true ); $permalink = \get_post_meta( $post->ID, 'activitypub_canonical_url', true );
@ -139,7 +121,7 @@ class Post {
return $user->get_url(); 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. * @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. // 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. // 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 ) ) ); $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 ); return $this->get_block_attachments( $max_media );
} }
@ -172,7 +154,7 @@ class Post {
return array(); return array();
} }
$id = $this->wp_post->ID; $id = $this->object->ID;
$media_ids = array(); $media_ids = array();
@ -182,7 +164,7 @@ class Post {
} }
if ( $max_media > 0 ) { 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 ); $media_ids = self::get_media_ids_from_blocks( $blocks, $media_ids, $max_media );
} }
@ -203,7 +185,7 @@ class Post {
return array(); return array();
} }
$id = $this->wp_post->ID; $id = $this->object->ID;
$image_ids = array(); $image_ids = array();
@ -316,7 +298,7 @@ class Post {
*/ */
$thumbnail = apply_filters( $thumbnail = apply_filters(
'activitypub_get_image', 'activitypub_get_image',
self::get_image( $id, $image_size ), self::get_wordpress_attachment( $id, $image_size ),
$id, $id,
$image_size $image_size
); );
@ -365,7 +347,7 @@ class Post {
* *
* @return array|false Array of image data, or boolean false if no image is available. * @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. * Hook into the image retrieval process. Before image retrieval.
* *
@ -402,10 +384,10 @@ class Post {
// Default to Article. // Default to Article.
$object_type = 'Article'; $object_type = 'Article';
$post_type = \get_post_type( $this->wp_post ); $post_type = \get_post_type( $this->object );
switch ( $post_type ) { switch ( $post_type ) {
case 'post': case 'post':
$post_format = \get_post_format( $this->wp_post ); $post_format = \get_post_format( $this->object );
switch ( $post_format ) { switch ( $post_format ) {
case 'aside': case 'aside':
case 'status': case 'status':
@ -481,10 +463,10 @@ class Post {
* *
* @return array The list of Tags. * @return array The list of Tags.
*/ */
protected function get_tags() { protected function get_tag() {
$tags = array(); $tags = array();
$post_tags = \get_the_tags( $this->wp_post->ID ); $post_tags = \get_the_tags( $this->object->ID );
if ( $post_tags ) { if ( $post_tags ) {
foreach ( $post_tags as $post_tag ) { foreach ( $post_tags as $post_tag ) {
$tag = array( $tag = array(
@ -531,7 +513,7 @@ class Post {
do_action( 'activitypub_before_get_content', $post ); do_action( 'activitypub_before_get_content', $post );
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$post = $this->wp_post; $post = $this->object;
$content = $this->get_post_content_template(); $content = $this->get_post_content_template();
// Register our shortcodes just in time. // Register our shortcodes just in time.
@ -576,7 +558,7 @@ class Post {
break; 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. * @return array The list of @-Mentions.
*/ */
protected function get_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. * @return string The locale of the post.
*/ */
public function get_locale() { public function get_locale() {
$post_id = $this->wp_post->ID; $post_id = $this->object->ID;
$lang = \strtolower( \strtok( \get_locale(), '_-' ) ); $lang = \strtolower( \strtok( \get_locale(), '_-' ) );
/** /**
@ -606,6 +588,6 @@ class Post {
* *
* @return string The filtered locale of the 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 );
} }
} }

View file

@ -113,6 +113,7 @@ Project maintained on GitHub at [automattic/wordpress-activitypub](https://githu
* Added: Make Post-Template filterable * Added: Make Post-Template filterable
* Added: CSS class for ActivityPub comments to allow custom designs * Added: CSS class for ActivityPub comments to allow custom designs
* Added: FEP-2677: Identifying the Application Actor * Added: FEP-2677: Identifying the Application Actor
* Added: Basic Comment Federation
* Improved: WebFinger endpoints * Improved: WebFinger endpoints
= 1.3.0 = = 1.3.0 =

View file

@ -10,23 +10,8 @@ $user->set_context(
*/ */
\do_action( 'activitypub_json_author_pre', $user->get__id() ); \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' ); \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 * Action triggerd after the ActivityPub profile has been created and sent to the client

View file

@ -10,23 +10,8 @@ $user->set_context(
*/ */
\do_action( 'activitypub_json_author_pre', $user->get__id() ); \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' ); \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 * Action triggerd after the ActivityPub profile has been created and sent to the client

View file

@ -0,0 +1,36 @@
<?php
$comment = \get_comment( \get_query_var( 'c', null ) ); // phpcs:ignore
$object = \Activitypub\Transformer\Factory::get_transformer( $comment );
$json = \array_merge( array( '@context' => \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' );

View file

@ -2,34 +2,16 @@
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$post = \get_post(); $post = \get_post();
$object = new \Activitypub\Transformer\Post( $post ); $post_object = \Activitypub\Transformer\Factory::get_transformer( $post )->to_object();
$json = \array_merge( array( '@context' => \Activitypub\get_context() ), $object->to_object()->to_array() ); $post_object->set_context( \Activitypub\get_context() );
// filter output
$json = \apply_filters( 'activitypub_json_post_array', $json );
/* /*
* Action triggerd prior to the ActivityPub profile being created and sent to the client * Action triggerd prior to the ActivityPub profile being created and sent to the client
*/ */
\do_action( 'activitypub_json_post_pre' ); \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' ); \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 * Action triggerd after the ActivityPub profile has been created and sent to the client

View file

@ -0,0 +1,21 @@
<?php
class Test_Activitypub_Webfinger extends WP_UnitTestCase {
/**
* @dataProvider the_cache_key_provider
*/
public function test_generate_cache_key( $uri, $hash ) {
$cache_key = Activitypub\Webfinger::generate_cache_key( $uri );
$this->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' ) ),
);
}
}