Activity-Type based handlers (#551)

* init

* save source id

* fix delete and add improve undo

* test new functions

* add support for threaded comments

* some formatting

* check if URL is no longer available

...and returns either status 410 or 404.

* improve delete handler

* improve update handler

* `object` and `actor` are already required by the inbox endpoint

* fix typo

* simplify queries

* cosmetics

* fix unit tests

* schedule delete comments of deleted actor (#575)

* schedule delete comments of deleted actor

* phpcs

---------

Co-authored-by: Django Doucet <django.doucet@webdevstudios.com>

* move `get_comments_by_actor` to interactions collection

* consistent wording

* implement Tombstone

* fix follow issue

* fix inbox-create

* added missing namespace

* check if field is set

* Fix namespacing issue

* update profile and update interaction

* fields are already validated by inbox

* optimize avatar handling

---------

Co-authored-by: Django <mediaformat.ux@gmail.com>
Co-authored-by: Django Doucet <django.doucet@webdevstudios.com>
This commit is contained in:
Matthias Pfefferle 2023-11-30 11:43:48 +01:00 committed by GitHub
parent 4a72329bbe
commit 6e7f82bf42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1290 additions and 354 deletions

View file

@ -66,7 +66,7 @@ function plugin_init() {
\add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Activity_Dispatcher', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Activity_Dispatcher', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Collection\Followers', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Handler', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Admin', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Admin', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Hashtag', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Hashtag', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) );

View file

@ -15,7 +15,8 @@
"yoast/phpunit-polyfills": "^2.0", "yoast/phpunit-polyfills": "^2.0",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0",
"sirbrillig/phpcs-variable-analysis": "^2.11", "sirbrillig/phpcs-variable-analysis": "^2.11",
"phpcsstandards/phpcsextra": "^1.1.0" "phpcsstandards/phpcsextra": "^1.1.0",
"dms/phpunit-arraysubset-asserts": "^0.4.0"
}, },
"config": { "config": {
"allow-plugins": true "allow-plugins": true

View file

@ -194,6 +194,12 @@ class Activity extends Base_Object {
* @return void * @return void
*/ */
public function set_object( $object ) { public function set_object( $object ) {
// convert array to object
if ( is_array( $object ) ) {
$object = Base_Object::init_from_array( $object );
}
// set object
$this->set( 'object', $object ); $this->set( 'object', $object );
if ( ! is_object( $object ) ) { if ( ! is_object( $object ) ) {

View file

@ -585,7 +585,7 @@ class Base_Object {
foreach ( $array as $key => $value ) { foreach ( $array as $key => $value ) {
$key = camel_to_snake_case( $key ); $key = camel_to_snake_case( $key );
$object->set( $key, $value ); call_user_func( array( $object, 'set_' . $key ), $value );
} }
return $object; return $object;
@ -611,7 +611,7 @@ class Base_Object {
foreach ( $array as $key => $value ) { foreach ( $array as $key => $value ) {
if ( $value ) { if ( $value ) {
$key = camel_to_snake_case( $key ); $key = camel_to_snake_case( $key );
$this->set( $key, $value ); call_user_func( array( $this, 'set_' . $key ), $value );
} }
} }
} }

View file

@ -1,8 +1,12 @@
<?php <?php
namespace Activitypub; namespace Activitypub;
use Exception;
use Activitypub\Signature; use Activitypub\Signature;
use Activitypub\Collection\Users; use Activitypub\Collection\Users;
use Activitypub\Collection\Followers;
use function Activitypub\sanitize_url;
/** /**
* ActivityPub Class * ActivityPub Class
@ -34,6 +38,9 @@ class Activitypub {
\add_action( 'after_setup_theme', array( self::class, 'theme_compat' ), 99 ); \add_action( 'after_setup_theme', array( self::class, 'theme_compat' ), 99 );
\add_action( 'in_plugin_update_message-' . ACTIVITYPUB_PLUGIN_BASENAME, array( self::class, 'plugin_update_message' ) ); \add_action( 'in_plugin_update_message-' . ACTIVITYPUB_PLUGIN_BASENAME, array( self::class, 'plugin_update_message' ) );
// register several post_types
self::register_post_types();
} }
/** /**
@ -54,7 +61,6 @@ class Activitypub {
*/ */
public static function deactivate() { public static function deactivate() {
self::flush_rewrite_rules(); self::flush_rewrite_rules();
Scheduler::deregister_schedules(); Scheduler::deregister_schedules();
} }
@ -328,4 +334,80 @@ class Activitypub {
) )
); );
} }
/**
* Register the "Followers" Taxonomy
*
* @return void
*/
private static function register_post_types() {
register_post_type(
Followers::POST_TYPE,
array(
'labels' => array(
'name' => _x( 'Followers', 'post_type plural name', 'activitypub' ),
'singular_name' => _x( 'Follower', 'post_type single name', 'activitypub' ),
),
'public' => false,
'hierarchical' => false,
'rewrite' => false,
'query_var' => false,
'delete_with_user' => false,
'can_export' => true,
'supports' => array(),
)
);
register_post_meta(
Followers::POST_TYPE,
'activitypub_inbox',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_url',
)
);
register_post_meta(
Followers::POST_TYPE,
'activitypub_errors',
array(
'type' => 'string',
'single' => false,
'sanitize_callback' => function( $value ) {
if ( ! is_string( $value ) ) {
throw new Exception( 'Error message is no valid string' );
}
return esc_sql( $value );
},
)
);
register_post_meta(
Followers::POST_TYPE,
'activitypub_user_id',
array(
'type' => 'string',
'single' => false,
'sanitize_callback' => function( $value ) {
return esc_sql( $value );
},
)
);
register_post_meta(
Followers::POST_TYPE,
'activitypub_actor_json',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => function( $value ) {
return sanitize_text_field( $value );
},
)
);
do_action( 'activitypub_after_register_post_type' );
}
} }

View file

@ -0,0 +1,33 @@
<?php
namespace Activitypub;
use Activitypub\Handler\Create;
use Activitypub\Handler\Delete;
use Activitypub\Handler\Follow;
use Activitypub\Handler\Undo;
use Activitypub\Handler\Update;
/**
* Handler class.
*/
class Handler {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
self::register_handlers();
}
/**
* Register handlers.
*/
public static function register_handlers() {
Create::init();
Delete::init();
Follow::init();
Undo::init();
Update::init();
do_action( 'activitypub_register_handlers' );
}
}

View file

@ -20,7 +20,7 @@ class Http {
* @return array|WP_Error The POST Response or an WP_ERROR * @return array|WP_Error The POST Response or an WP_ERROR
*/ */
public static function post( $url, $body, $user_id ) { public static function post( $url, $body, $user_id ) {
do_action( 'activitypub_pre_http_post', $url, $body, $user_id ); \do_action( 'activitypub_pre_http_post', $url, $body, $user_id );
$date = \gmdate( 'D, d M Y H:i:s T' ); $date = \gmdate( 'D, d M Y H:i:s T' );
$digest = Signature::generate_digest( $body ); $digest = Signature::generate_digest( $body );
@ -70,7 +70,7 @@ class Http {
* @return array|WP_Error The GET Response or an WP_ERROR * @return array|WP_Error The GET Response or an WP_ERROR
*/ */
public static function get( $url ) { public static function get( $url ) {
do_action( 'activitypub_pre_http_get', $url ); \do_action( 'activitypub_pre_http_get', $url );
$date = \gmdate( 'D, d M Y H:i:s T' ); $date = \gmdate( 'D, d M Y H:i:s T' );
$signature = Signature::generate_signature( Users::APPLICATION_USER_ID, 'get', $url, $date ); $signature = Signature::generate_signature( Users::APPLICATION_USER_ID, 'get', $url, $date );
@ -108,4 +108,24 @@ class Http {
return $response; return $response;
} }
/**
* Check for URL for Tombstone.
*
* @param string $url The URL to check.
*
* @return bool True if the URL is a tombstone.
*/
public static function is_tombstone( $url ) {
\do_action( 'activitypub_pre_http_is_tombstone', $url );
$response = \wp_safe_remote_get( $url );
$code = \wp_remote_retrieve_response_code( $response );
if ( in_array( (int) $code, array( 404, 410 ), true ) ) {
return true;
}
return false;
}
} }

View file

@ -2,14 +2,10 @@
namespace Activitypub\Collection; namespace Activitypub\Collection;
use WP_Error; use WP_Error;
use Exception;
use WP_Query; use WP_Query;
use Activitypub\Http; use Activitypub\Http;
use Activitypub\Webfinger; use Activitypub\Webfinger;
use Activitypub\Model\Follower; use Activitypub\Model\Follower;
use Activitypub\Collection\Users;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use function Activitypub\is_tombstone; use function Activitypub\is_tombstone;
use function Activitypub\get_remote_metadata_by_actor; use function Activitypub\get_remote_metadata_by_actor;
@ -24,136 +20,6 @@ class Followers {
const POST_TYPE = 'ap_follower'; const POST_TYPE = 'ap_follower';
const CACHE_KEY_INBOXES = 'follower_inboxes_%s'; const CACHE_KEY_INBOXES = 'follower_inboxes_%s';
/**
* Register WordPress hooks/actions and register Taxonomy
*
* @return void
*/
public static function init() {
// register "followers" post_type
self::register_post_type();
\add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow_request' ), 10, 2 );
\add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo_request' ), 10, 2 );
\add_action( 'activitypub_followers_post_follow', array( self::class, 'send_follow_response' ), 10, 4 );
}
/**
* Register the "Followers" Taxonomy
*
* @return void
*/
private static function register_post_type() {
register_post_type(
self::POST_TYPE,
array(
'labels' => array(
'name' => _x( 'Followers', 'post_type plural name', 'activitypub' ),
'singular_name' => _x( 'Follower', 'post_type single name', 'activitypub' ),
),
'public' => false,
'hierarchical' => false,
'rewrite' => false,
'query_var' => false,
'delete_with_user' => false,
'can_export' => true,
'supports' => array(),
)
);
register_post_meta(
self::POST_TYPE,
'activitypub_inbox',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => array( self::class, 'sanitize_url' ),
)
);
register_post_meta(
self::POST_TYPE,
'activitypub_errors',
array(
'type' => 'string',
'single' => false,
'sanitize_callback' => function( $value ) {
if ( ! is_string( $value ) ) {
throw new Exception( 'Error message is no valid string' );
}
return esc_sql( $value );
},
)
);
register_post_meta(
self::POST_TYPE,
'activitypub_user_id',
array(
'type' => 'string',
'single' => false,
'sanitize_callback' => function( $value ) {
return esc_sql( $value );
},
)
);
register_post_meta(
self::POST_TYPE,
'activitypub_actor_json',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => function( $value ) {
return sanitize_text_field( $value );
},
)
);
do_action( 'activitypub_after_register_post_type' );
}
public static function sanitize_url( $value ) {
if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) {
return null;
}
return esc_url_raw( $value );
}
/**
* Handle the "Follow" Request
*
* @param array $object The JSON "Follow" Activity
* @param int $user_id The ID of the ID of the WordPress User
*
* @return void
*/
public static function handle_follow_request( $object, $user_id ) {
// save follower
$follower = self::add_follower( $user_id, $object['actor'] );
do_action( 'activitypub_followers_post_follow', $object['actor'], $object, $user_id, $follower );
}
/**
* Handle "Unfollow" requests
*
* @param array $object The JSON "Undo" Activity
* @param int $user_id The ID of the ID of the WordPress User
*/
public static function handle_undo_request( $object, $user_id ) {
if (
isset( $object['object'] ) &&
isset( $object['object']['type'] ) &&
'Follow' === $object['object']['type']
) {
self::remove_follower( $user_id, $object['actor'] );
}
}
/** /**
* Add new Follower * Add new Follower
* *
@ -214,16 +80,17 @@ class Followers {
} }
/** /**
* Get a Follower * Get a Follower.
* *
* @param int $user_id The ID of the WordPress User * @param int $user_id The ID of the WordPress User
* @param string $actor The Actor URL * @param string $actor The Actor URL
* *
* @return \Activitypub\Model\Follower The Follower object * @return \Activitypub\Model\Follower|null The Follower object or null
*/ */
public static function get_follower( $user_id, $actor ) { public static function get_follower( $user_id, $actor ) {
global $wpdb; global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$post_id = $wpdb->get_var( $post_id = $wpdb->get_var(
$wpdb->prepare( $wpdb->prepare(
"SELECT DISTINCT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key = 'activitypub_user_id' AND pm.meta_value = %d AND p.guid = %s", "SELECT DISTINCT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key = 'activitypub_user_id' AND pm.meta_value = %d AND p.guid = %s",
@ -244,51 +111,29 @@ class Followers {
} }
/** /**
* Send Accept response * Get a Follower by Actor indepenent from the User.
* *
* @param string $actor The Actor URL * @param string $actor The Actor URL.
* @param array $object The Activity object
* @param int $user_id The ID of the WordPress User
* @param Activitypub\Model\Follower $follower The Follower object
* *
* @return void * @return \Activitypub\Model\Follower|null The Follower object or null
*/ */
public static function send_follow_response( $actor, $object, $user_id, $follower ) { public static function get_follower_by_actor( $actor ) {
if ( is_wp_error( $follower ) ) { global $wpdb;
// it is not even possible to send a "Reject" because
// we can not get the Remote-Inbox
return;
}
// only send minimal data // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$object = array_intersect_key( $post_id = $wpdb->get_var(
$object, $wpdb->prepare(
array_flip( "SELECT ID FROM $wpdb->posts WHERE guid=%s",
array( esc_sql( $actor )
'id',
'type',
'actor',
'object',
)
) )
); );
$user = Users::get_by_id( $user_id ); if ( $post_id ) {
$post = get_post( $post_id );
return Follower::init_from_cpt( $post );
}
// get inbox return null;
$inbox = $follower->get_shared_inbox();
// send "Accept" activity
$activity = new Activity();
$activity->set_type( 'Accept' );
$activity->set_object( $object );
$activity->set_actor( $user->get_id() );
$activity->set_to( $actor );
$activity->set_id( $user->get_id() . '#follow-' . \preg_replace( '~^https?://~', '', $actor ) . '-' . \time() );
$activity = $activity->to_json();
Http::post( $inbox, $activity, $user_id );
} }
/** /**

View file

@ -0,0 +1,236 @@
<?php
namespace Activitypub\Collection;
use WP_Error;
use WP_Comment_Query;
use function Activitypub\url_to_commentid;
use function Activitypub\object_id_to_comment;
use function Activitypub\get_remote_metadata_by_actor;
/**
* ActivityPub Interactions Collection
*/
class Interactions {
/**
* Add a comment to a post
*
* @param array $activity The activity-object
*
* @return array|false The commentdata or false on failure
*/
public static function add_comment( $activity ) {
if (
! isset( $activity['object'] ) ||
! isset( $activity['object']['id'] )
) {
return false;
}
if ( ! isset( $activity['object']['inReplyTo'] ) ) {
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 );
// save only replys and reactions
if ( ! $comment_post_id && $parent_comment ) {
$comment_post_id = $parent_comment->comment_post_ID;
}
// not a reply to a post or comment
if ( ! $comment_post_id ) {
return false;
}
$meta = get_remote_metadata_by_actor( $activity['actor'] );
if ( ! $meta || \is_wp_error( $meta ) ) {
return false;
}
$commentdata = array(
'comment_post_ID' => $comment_post_id,
'comment_author' => \esc_attr( $meta['name'] ),
'comment_author_url' => \esc_url_raw( $meta['url'] ),
'comment_content' => \addslashes( \wp_kses( $activity['object']['content'], 'pre_comment_content' ) ),
'comment_type' => 'comment',
'comment_author_email' => '',
'comment_parent' => $parent_comment ? $parent_comment->comment_ID : 0,
'comment_meta' => array(
'source_id' => \esc_url_raw( $activity['object']['id'] ),
'source_url' => \esc_url_raw( $activity['object']['url'] ),
'protocol' => 'activitypub',
),
);
if ( isset( $meta['icon']['url'] ) ) {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] );
}
// disable flood control
\remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 );
// do not require email for AP entries
\add_filter( 'pre_option_require_name_email', '__return_false' );
// No nonce possible for this submission route
\add_filter(
'akismet_comment_nonce',
function() {
return 'inactive';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
$comment = \wp_new_comment( $commentdata, true );
\remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10 );
\remove_filter( 'pre_option_require_name_email', '__return_false' );
// re-add flood control
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
return $comment;
}
/**
* Update a comment
*
* @param array $activity The activity-object
*
* @return array|false The commentdata or false on failure
*/
public static function update_comment( $activity ) {
$meta = get_remote_metadata_by_actor( $activity['actor'] );
//Determine comment_ID
$object_comment_id = url_to_commentid( \esc_url_raw( $activity['object']['id'] ) );
if ( ! $object_comment_id ) {
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( \wp_kses( $activity['object']['content'], 'pre_comment_content' ) );
if ( isset( $meta['icon']['url'] ) ) {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] );
}
// disable flood control
\remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 );
// do not require email for AP entries
\add_filter( 'pre_option_require_name_email', '__return_false' );
// No nonce possible for this submission route
\add_filter(
'akismet_comment_nonce',
function() {
return 'inactive';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
$comment = \wp_update_comment( $commentdata, true );
\remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10 );
\remove_filter( 'pre_option_require_name_email', '__return_false' );
// re-add flood control
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
return $comment;
}
/**
* Get interaction(s) for a given URL/ID.
*
* @param strin $url The URL/ID to get interactions for.
*
* @return array The interactions as WP_Comment objects.
*/
public static function get_interaction_by_id( $url ) {
$args = array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'protocol',
'value' => 'activitypub',
),
array(
'relation' => 'OR',
array(
'key' => 'source_url',
'value' => $url,
),
array(
'key' => 'source_id',
'value' => $url,
),
),
),
);
$query = new WP_Comment_Query( $args );
return $query->comments;
}
/**
* Get interaction(s) for a given actor.
*
* @param string $actor The Actor-URL.
*
* @return array The interactions as WP_Comment objects.
*/
public static function get_interactions_by_actor( $actor ) {
$meta = get_remote_metadata_by_actor( $actor );
// get URL, because $actor seems to be the ID
if ( $meta && ! is_wp_error( $meta ) && isset( $meta['url'] ) ) {
$actor = $meta['url'];
}
$args = array(
'author_url' => $actor,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'protocol',
'value' => 'activitypub',
'compare' => '=',
),
),
);
$comment_query = new WP_Comment_Query( $args );
return $comment_query->comments;
}
/**
* Adds line breaks to the list of allowed comment tags.
*
* @param array $allowedtags Allowed HTML tags.
* @param string $context Context.
* @return array Filtered tag list.
*/
public static function allowed_comment_html( $allowedtags, $context = '' ) {
if ( 'pre_comment_content' !== $context ) {
// Do nothing.
return $allowedtags;
}
// Add `p` and `br` to the list of allowed tags.
if ( ! array_key_exists( 'br', $allowedtags ) ) {
$allowedtags['br'] = array();
}
if ( ! array_key_exists( 'p', $allowedtags ) ) {
$allowedtags['p'] = array();
}
return $allowedtags;
}
}

View file

@ -2,6 +2,7 @@
namespace Activitypub; namespace Activitypub;
use WP_Error; use WP_Error;
use WP_Comment_Query;
use Activitypub\Http; use Activitypub\Http;
use Activitypub\Activity\Activity; use Activitypub\Activity\Activity;
use Activitypub\Collection\Followers; use Activitypub\Collection\Followers;
@ -486,6 +487,81 @@ function is_blog_public() {
return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) ); return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) );
} }
/**
* Sanitize a URL
*
* @param string $value The URL to sanitize
*
* @return string|null The sanitized URL or null if invalid
*/
function sanitize_url( $value ) {
if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) {
return null;
}
return esc_url_raw( $value );
}
/**
* Extract recipient URLs from Activity object
*
* @param array $data
*
* @return array The list of user URLs
*/
function extract_recipients_from_activity( $data ) {
$recipient_items = array();
foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) {
if ( array_key_exists( $i, $data ) ) {
if ( is_array( $data[ $i ] ) ) {
$recipient = $data[ $i ];
} else {
$recipient = array( $data[ $i ] );
}
$recipient_items = array_merge( $recipient_items, $recipient );
}
if ( is_array( $data['object'] ) && array_key_exists( $i, $data['object'] ) ) {
if ( is_array( $data['object'][ $i ] ) ) {
$recipient = $data['object'][ $i ];
} else {
$recipient = array( $data['object'][ $i ] );
}
$recipient_items = array_merge( $recipient_items, $recipient );
}
}
$recipients = array();
// flatten array
foreach ( $recipient_items as $recipient ) {
if ( is_array( $recipient ) ) {
// check if recipient is an object
if ( array_key_exists( 'id', $recipient ) ) {
$recipients[] = $recipient['id'];
}
} else {
$recipients[] = $recipient;
}
}
return array_unique( $recipients );
}
/**
* Check if passed Activity is Public
*
* @param array $data The Activity object as array
*
* @return boolean True if public, false if not
*/
function is_activity_public( $data ) {
$recipients = extract_recipients_from_activity( $data );
return in_array( 'https://www.w3.org/ns/activitystreams#Public', $recipients, true );
}
/** /**
* Get active users based on a given duration * Get active users based on a given duration
* *
@ -557,3 +633,68 @@ function get_total_users() {
return $users + 1; return $users + 1;
} }
/**
* Examine a comment ID and look up an existing comment it represents.
*
* @param string $id ActivityPub object ID (usually a URL) to check.
*
* @return int|boolean Comment ID, or false on failure.
*/
function object_id_to_comment( $id ) {
$comment_query = new WP_Comment_Query(
array(
'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
)
);
if ( ! $comment_query->comments ) {
return false;
}
if ( count( $comment_query->comments ) > 1 ) {
return false;
}
return $comment_query->comments[0];
}
/**
* Verify if URL is a local comment,
* Or if it is a previously received remote comment
* (For threading comments locally)
*
* @param string $url The URL to check.
*
* @return int comment_ID or null if not found
*/
function url_to_commentid( $url ) {
if ( ! $url || ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
return null;
}
$args = array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'OR',
array(
'key' => 'source_url',
'value' => $url,
),
array(
'key' => 'source_id',
'value' => $url,
),
),
);
$query = new \WP_Comment_Query();
$comments = $query->query( $args );
if ( $comments && is_array( $comments ) ) {
return $comments[0]->comment_ID;
}
return null;
}

View file

@ -0,0 +1,61 @@
<?php
namespace Activitypub\Handler;
use WP_Error;
use Activitypub\Collection\Interactions;
use function Activitypub\is_activity_public;
use function Activitypub\object_id_to_comment;
/**
* Handle Create requests
*/
class Create {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_inbox_create', array( self::class, 'handle_create' ), 10, 3 );
}
/**
* Handles "Create" requests
*
* @param array $array The activity-object
* @param int $user_id The id of the local blog-user
* @param Activitypub\Activity $object The activity object
*
* @return void
*/
public static function handle_create( $array, $user_id, $object = null ) {
if (
! isset( $array['object'] ) ||
! isset( $array['object']['id'] )
) {
return;
}
// check if Activity is public or not
if ( ! is_activity_public( $array ) ) {
// @todo maybe send email
return;
}
$check_dupe = object_id_to_comment( $array['object']['id'] );
// if comment exists, call update action
if ( $check_dupe ) {
\do_action( 'activitypub_inbox_update', $array, $user_id, $object );
return;
}
$state = Interactions::add_comment( $array );
$reaction = null;
if ( $state && ! \is_wp_error( $reaction ) ) {
$reaction = \get_comment( $state );
}
\do_action( 'activitypub_handled_create', $array, $user_id, $state, $reaction );
}
}

View file

@ -0,0 +1,165 @@
<?php
namespace Activitypub\Handler;
use WP_Error;
use WP_REST_Request;
use Activitypub\Http;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Interactions;
/**
* Handles Delete requests.
*/
class Delete {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ), 10, 2 );
// defer signature verification for `Delete` requests.
\add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 );
// side effect
\add_action( 'activitypub_delete_actor_interactions', array( self::class, 'delete_interactions' ), 10, 1 );
}
/**
* Handles "Delete" requests.
*
* @param array $activity The delete activity.
* @param int $user_id The ID of the user performing the delete activity.
*/
public static function handle_delete( $activity, $user_id ) {
$object_type = isset( $activity['object']['type'] ) ? $activity['object']['type'] : '';
switch ( $object_type ) {
// Actor Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
case 'Person':
case 'Group':
case 'Organization':
case 'Service':
case 'Application':
self::maybe_delete_follower( $user_id, $activity );
break;
// Object and Link Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
case 'Note':
case 'Article':
case 'Image':
case 'Audio':
case 'Video':
case 'Event':
case 'Document':
self::maybe_delete_interaction( $activity );
break;
// Tombstone Type
// @see: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
case 'Tombstone':
self::maybe_delete_interaction( $activity );
break;
// Minimal Activity
// @see https://www.w3.org/TR/activitystreams-core/#example-1
default:
// ignore non Minimal Activities.
if ( ! is_string( $activity['object'] ) ) {
return;
}
// check if Object is an Actor.
if ( $activity['actor'] === $activity['object'] ) {
self::maybe_delete_follower( $activity );
self::maybe_delete_interactions( $activity );
} else { // assume a interaction otherwise.
self::maybe_delete_interaction( $activity );
}
// maybe handle Delete Activity for other Object Types.
break;
}
}
/**
* Delete a Follower if Actor-URL is a Tombstone.
*
* @param array $activity The delete activity.
*/
public static function maybe_delete_follower( $activity ) {
$follower = Followers::get_follower_by_actor( $activity['actor'] );
// verify if Actor is deleted.
if ( $follower && Http::is_tombstone( $activity['actor'] ) ) {
$follower->delete();
}
}
/**
* Delete Reactions if Actor-URL is a Tombstone.
*
* @param array $activity The delete activity.
*/
public static function maybe_delete_interactions( $activity ) {
// verify if Actor is deleted.
if ( Http::is_tombstone( $activity['actor'] ) ) {
\wp_schedule_single_event(
\time(),
'activitypub_delete_actor_interactions',
array( $activity['actor'] )
);
}
}
/**
* Delete comments from an Actor.
*
* @param array $comments The comments to delete.
*/
public static function delete_interactions( $actor ) {
$comments = Interactions::get_interactions_by_actor( $actor );
if ( is_array( $comments ) ) {
foreach ( $comments as $comment ) {
wp_delete_comment( $comment->comment_ID );
}
}
}
/**
* Delete a Reaction if URL is a Tombstone.
*
* @param array $activity The delete activity.
*
* @return void
*/
public static function maybe_delete_interaction( $activity ) {
if ( is_array( $activity['object'] ) ) {
$id = $activity['object']['id'];
} else {
$id = $activity['object'];
}
$comments = Interactions::get_interaction_by_id( $id );
if ( $comments && Http::is_tombstone( $id ) ) {
foreach ( $comments as $comment ) {
wp_delete_comment( $comment->comment_ID, true );
}
}
}
/**
* Defer signature verification for `Delete` requests.
*
* @param bool $defer Whether to defer signature verification.
* @param WP_REST_Request $request The request object.
*
* @return bool Whether to defer signature verification.
*/
public static function defer_signature_verification( $defer, $request ) {
$json = $request->get_json_params();
if ( isset( $json['type'] ) && 'Delete' === $json['type'] ) {
return true;
}
return false;
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace Activitypub\Handler;
use Activitypub\Http;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users;
use Activitypub\Collection\Followers;
/**
* Handle Follow requests
*/
class Follow {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow' ), 10, 2 );
\add_action( 'activitypub_followers_post_follow', array( self::class, 'send_follow_response' ), 10, 4 );
}
/**
* Handle "Follow" requests
*
* @param array $activity The activity object
* @param int $user_id The user ID
*/
public static function handle_follow( $activity, $user_id ) {
// save follower
$follower = Followers::add_follower( $user_id, $activity['actor'] );
do_action( 'activitypub_followers_post_follow', $activity['actor'], $activity, $user_id, $follower );
}
/**
* Send Accept response
*
* @param string $actor The Actor URL
* @param array $object The Activity object
* @param int $user_id The ID of the WordPress User
* @param Activitypub\Model\Follower $follower The Follower object
*
* @return void
*/
public static function send_follow_response( $actor, $object, $user_id, $follower ) {
if ( \is_wp_error( $follower ) ) {
// it is not even possible to send a "Reject" because
// we can not get the Remote-Inbox
return;
}
// only send minimal data
$object = array_intersect_key(
$object,
array_flip(
array(
'id',
'type',
'actor',
'object',
)
)
);
$user = Users::get_by_id( $user_id );
// get inbox
$inbox = $follower->get_shared_inbox();
// send "Accept" activity
$activity = new Activity();
$activity->set_type( 'Accept' );
$activity->set_object( $object );
$activity->set_actor( $user->get_id() );
$activity->set_to( $actor );
$activity->set_id( $user->get_id() . '#follow-' . \preg_replace( '~^https?://~', '', $actor ) . '-' . \time() );
$activity = $activity->to_json();
Http::post( $inbox, $activity, $user_id );
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Activitypub\Handler;
use Activitypub\Collection\Followers;
/**
* Handle Undo requests
*/
class Undo {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo' ), 10, 2 );
}
/**
* Handle "Unfollow" requests
*
* @param array $activity The JSON "Undo" Activity
* @param int $user_id The ID of the ID of the WordPress User
*/
public static function handle_undo( $activity, $user_id ) {
if (
isset( $activity['object']['type'] ) &&
'Follow' === $activity['object']['type']
) {
Followers::remove_follower( $user_id, $activity['actor'] );
}
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace Activitypub\Handler;
use WP_Error;
use Activitypub\Collection\Interactions;
use function Activitypub\get_remote_metadata_by_actor;
/**
* Handle Update requests.
*/
class Update {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_inbox_update', array( self::class, 'handle_update' ), 10, 2 );
}
/**
* Handle "Update" requests
*
* @param array $array The activity-object
* @param int $user_id The id of the local blog-user
*/
public static function handle_update( $array, $user_id ) {
$object_type = isset( $array['object']['type'] ) ? $array['object']['type'] : '';
switch ( $object_type ) {
// Actor Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
case 'Person':
case 'Group':
case 'Organization':
case 'Service':
case 'Application':
self::update_actor( $array );
break;
// Object and Link Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
case 'Note':
case 'Article':
case 'Image':
case 'Audio':
case 'Video':
case 'Event':
case 'Document':
self::update_interaction( $array, $user_id );
break;
// Minimal Activity
// @see https://www.w3.org/TR/activitystreams-core/#example-1
default:
break;
}
}
/**
* Update an Interaction
*
* @param array $activity The activity-object
* @param int $user_id The id of the local blog-user
*
* @return void
*/
public static function update_interaction( $activity, $user_id ) {
$state = Interactions::update_comment( $activity );
$reaction = null;
if ( $state && ! \is_wp_error( $reaction ) ) {
$reaction = \get_comment( $state );
}
\do_action( 'activitypub_handled_update', $activity, $user_id, $state, $reaction );
}
/**
* Update an Actor
*
* @param array $activity The activity-object
*
* @return void
*/
public static function update_actor( $activity ) {
// update cache
get_remote_metadata_by_actor( $activity['actor'], false );
// @todo maybe also update all interactions
}
}

View file

@ -11,6 +11,7 @@ use function Activitypub\get_context;
use function Activitypub\url_to_authorid; use function Activitypub\url_to_authorid;
use function Activitypub\get_rest_url_by_path; use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_remote_metadata_by_actor; use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\extract_recipients_from_activity;
/** /**
* ActivityPub Inbox REST-Class * ActivityPub Inbox REST-Class
@ -25,8 +26,6 @@ class Inbox {
*/ */
public static function init() { public static function init() {
self::register_routes(); self::register_routes();
\add_action( 'activitypub_inbox_create', array( self::class, 'handle_create' ), 10, 2 );
} }
/** /**
@ -131,11 +130,12 @@ class Inbox {
} }
$data = $request->get_json_params(); $data = $request->get_json_params();
$activity = Activity::init_from_array( $data );
$type = $request->get_param( 'type' ); $type = $request->get_param( 'type' );
$type = \strtolower( $type ); $type = \strtolower( $type );
\do_action( 'activitypub_inbox', $data, $user->get__id(), $type ); \do_action( 'activitypub_inbox', $data, $user->get__id(), $type, $activity );
\do_action( "activitypub_inbox_{$type}", $data, $user->get__id() ); \do_action( "activitypub_inbox_{$type}", $data, $user->get__id(), $activity );
$rest_response = new WP_REST_Response( array(), 202 ); $rest_response = new WP_REST_Response( array(), 202 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
@ -152,8 +152,9 @@ class Inbox {
*/ */
public static function shared_inbox_post( $request ) { public static function shared_inbox_post( $request ) {
$data = $request->get_json_params(); $data = $request->get_json_params();
$activity = Activity::init_from_array( $data );
$type = $request->get_param( 'type' ); $type = $request->get_param( 'type' );
$users = self::extract_recipients( $data ); $users = self::get_recipients( $data );
if ( ! $users ) { if ( ! $users ) {
return new WP_Error( return new WP_Error(
@ -181,8 +182,8 @@ class Inbox {
$type = \strtolower( $type ); $type = \strtolower( $type );
\do_action( 'activitypub_inbox', $data, $user->ID, $type ); \do_action( 'activitypub_inbox', $data, $user->ID, $type, $activity );
\do_action( "activitypub_inbox_{$type}", $data, $user->ID ); \do_action( "activitypub_inbox_{$type}", $data, $user->ID, $activity );
} }
$rest_response = new WP_REST_Response( array(), 202 ); $rest_response = new WP_REST_Response( array(), 202 );
@ -340,121 +341,6 @@ class Inbox {
return $params; return $params;
} }
/**
* Handles "Create" requests
*
* @param array $object The activity-object
* @param int $user_id The id of the local blog-user
*/
public static function handle_create( $object, $user_id ) {
$meta = get_remote_metadata_by_actor( $object['actor'] );
if ( ! isset( $object['object']['inReplyTo'] ) ) {
return;
}
// check if Activity is public or not
if ( ! self::is_activity_public( $object ) ) {
// @todo maybe send email
return;
}
$comment_post_id = \url_to_postid( $object['object']['inReplyTo'] );
// save only replys and reactions
if ( ! $comment_post_id ) {
return false;
}
$commentdata = array(
'comment_post_ID' => $comment_post_id,
'comment_author' => \esc_attr( $meta['name'] ),
'comment_author_url' => \esc_url_raw( $object['actor'] ),
'comment_content' => addslashes( \wp_kses( $object['object']['content'], 'pre_comment_content' ) ),
'comment_type' => 'comment',
'comment_author_email' => '',
'comment_parent' => 0,
'comment_meta' => array(
'source_url' => \esc_url_raw( $object['object']['url'] ),
'avatar_url' => \esc_url_raw( $meta['icon']['url'] ),
'protocol' => 'activitypub',
),
);
// disable flood control
\remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 );
// do not require email for AP entries
\add_filter( 'pre_option_require_name_email', '__return_false' );
// No nonce possible for this submission route
\add_filter(
'akismet_comment_nonce',
function() {
return 'inactive';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
$state = \wp_new_comment( $commentdata, true );
\remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ) );
\remove_filter( 'pre_option_require_name_email', '__return_false' );
// re-add flood control
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
do_action( 'activitypub_handled_create', $object, $user_id, $state, $commentdata );
}
/**
* Extract recipient URLs from Activity object
*
* @param array $data
*
* @return array The list of user URLs
*/
public static function extract_recipients( $data ) {
$recipient_items = array();
foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) {
if ( array_key_exists( $i, $data ) ) {
if ( is_array( $data[ $i ] ) ) {
$recipient = $data[ $i ];
} else {
$recipient = array( $data[ $i ] );
}
$recipient_items = array_merge( $recipient_items, $recipient );
}
if ( is_array( $data['object'] ) && array_key_exists( $i, $data['object'] ) ) {
if ( is_array( $data['object'][ $i ] ) ) {
$recipient = $data['object'][ $i ];
} else {
$recipient = array( $data['object'][ $i ] );
}
$recipient_items = array_merge( $recipient_items, $recipient );
}
}
$recipients = array();
// flatten array
foreach ( $recipient_items as $recipient ) {
if ( is_array( $recipient ) ) {
// check if recipient is an object
if ( array_key_exists( 'id', $recipient ) ) {
$recipients[] = $recipient['id'];
}
} else {
$recipients[] = $recipient;
}
}
return array_unique( $recipients );
}
/** /**
* Get local user recipients * Get local user recipients
* *
@ -463,7 +349,7 @@ class Inbox {
* @return array The list of local users * @return array The list of local users
*/ */
public static function get_recipients( $data ) { public static function get_recipients( $data ) {
$recipients = self::extract_recipients( $data ); $recipients = extract_recipients_from_activity( $data );
$users = array(); $users = array();
foreach ( $recipients as $recipient ) { foreach ( $recipients as $recipient ) {
@ -478,41 +364,4 @@ class Inbox {
return $users; return $users;
} }
/**
* Check if passed Activity is Public
*
* @param array $data
* @return boolean
*/
public static function is_activity_public( $data ) {
$recipients = self::extract_recipients( $data );
return in_array( 'https://www.w3.org/ns/activitystreams#Public', $recipients, true );
}
/**
* Adds line breaks to the list of allowed comment tags.
*
* @param array $allowedtags Allowed HTML tags.
* @param string $context Context.
* @return array Filtered tag list.
*/
public static function allowed_comment_html( $allowedtags, $context = '' ) {
if ( 'pre_comment_content' !== $context ) {
// Do nothing.
return $allowedtags;
}
// Add `p` and `br` to the list of allowed tags.
if ( ! array_key_exists( 'br', $allowedtags ) ) {
$allowedtags['br'] = array();
}
if ( ! array_key_exists( 'p', $allowedtags ) ) {
$allowedtags['p'] = array();
}
return $allowedtags;
}
} }

View file

@ -110,14 +110,20 @@ class Server {
if ( 'GET' !== $request->get_method() ) { if ( 'GET' !== $request->get_method() ) {
$verified_request = Signature::verify_http_signature( $request ); $verified_request = Signature::verify_http_signature( $request );
if ( \is_wp_error( $verified_request ) ) { if ( \is_wp_error( $verified_request ) ) {
return new WP_Error( 'activitypub_signature_verification', $verified_request->get_error_message(), array( 'status' => 401 ) ); return new WP_Error(
'activitypub_signature_verification',
$verified_request->get_error_message(),
array( 'status' => 401 )
);
} }
} elseif ( 'GET' === $request->get_method() ) { // GET-Requests are only signed in secure mode } elseif ( 'GET' === $request->get_method() && ACTIVITYPUB_AUTHORIZED_FETCH ) { // GET-Requests are only signed in secure mode
if ( ACTIVITYPUB_AUTHORIZED_FETCH ) {
$verified_request = Signature::verify_http_signature( $request ); $verified_request = Signature::verify_http_signature( $request );
if ( \is_wp_error( $verified_request ) ) { if ( \is_wp_error( $verified_request ) ) {
return new WP_Error( 'activitypub_signature_verification', $verified_request->get_error_message(), array( 'status' => 401 ) ); return new WP_Error(
} 'activitypub_signature_verification',
$verified_request->get_error_message(),
array( 'status' => 401 )
);
} }
} }

View file

@ -1,4 +1,6 @@
<?php <?php
use DMS\PHPUnitExtensions\ArraySubset\Assert;
class Test_Activitypub_Activity extends WP_UnitTestCase { class Test_Activitypub_Activity extends WP_UnitTestCase {
public function test_activity_mentions() { public function test_activity_mentions() {
$post = \wp_insert_post( $post = \wp_insert_post(
@ -42,4 +44,21 @@ class Test_Activitypub_Activity extends WP_UnitTestCase {
$this->assertEquals( 'Hello world!', $object->get_content() ); $this->assertEquals( 'Hello world!', $object->get_content() );
$this->assertEquals( $test_array, $object->to_array() ); $this->assertEquals( $test_array, $object->to_array() );
} }
public function test_activity_object() {
$test_array = array(
'id' => 'https://example.com/post/123',
'type' => 'Create',
'object' => array(
'id' => 'https://example.com/post/123/activity',
'type' => 'Note',
'content' => 'Hello world!',
),
);
$activity = \Activitypub\Activity\Activity::init_from_array( $test_array );
$this->assertEquals( 'Hello world!', $activity->get_object()->get_content() );
Assert::assertArraySubset( $test_array, $activity->to_array() );
}
} }

View file

@ -0,0 +1,70 @@
<?php
class Test_Activitypub_Create_Handler extends WP_UnitTestCase {
public $user_id;
public $user_url;
public $post_id;
public $post_permalink;
public function set_up() {
$this->user_id = 1;
$authordata = \get_userdata( $this->user_id );
$this->user_url = $authordata->user_url;
$this->post_id = \wp_insert_post(
array(
'post_author' => $this->user_id,
'post_content' => 'test',
)
);
$this->post_permalink = \get_permalink( $this->post_id );
\add_filter( 'pre_get_remote_metadata_by_actor', array( '\Test_Activitypub_Create_Handler', 'get_remote_metadata_by_actor' ), 0, 2 );
}
public static function get_remote_metadata_by_actor( $value, $actor ) {
return array(
'name' => 'Example User',
'icon' => array(
'url' => 'https://example.com/icon',
),
'url' => $actor,
'id' => 'http://example.org/users/example',
);
}
public function create_test_object( $id = 'https://example.com/123' ) {
return array(
'actor' => $this->user_url,
'id' => 'https://example.com/id/' . microtime( true ),
'to' => [ $this->user_url ],
'cc' => [ 'https://www.w3.org/ns/activitystreams#Public' ],
'object' => array(
'id' => $id,
'url' => 'https://example.com/example',
'inReplyTo' => $this->post_permalink,
'content' => 'example',
),
);
}
public function test_handle_create_object_unset_rejected() {
$object = $this->create_test_object();
unset( $object['object'] );
$converted = Activitypub\Handler\Create::handle_create( $object, $this->user_id );
$this->assertNull( $converted );
}
public function test_handle_create_non_public_rejected() {
$object = $this->create_test_object();
$object['cc'] = [];
$converted = Activitypub\Handler\Create::handle_create( $object, $this->user_id );
$this->assertNull( $converted );
}
public function test_handle_create_no_id_rejected() {
$object = $this->create_test_object();
unset( $object['object']['id'] );
$converted = Activitypub\Handler\Create::handle_create( $object, $this->user_id );
$this->assertNull( $converted );
}
}

View file

@ -1,5 +1,5 @@
<?php <?php
class Test_Db_Activitypub_Followers extends WP_UnitTestCase { class Test_Activitypub_Followers extends WP_UnitTestCase {
public static $users = array( public static $users = array(
'username@example.org' => array( 'username@example.org' => array(
'id' => 'https://example.org/users/username', 'id' => 'https://example.org/users/username',

View file

@ -0,0 +1,130 @@
<?php
class Test_Activitypub_Interactions extends WP_UnitTestCase {
public $user_id;
public $user_url;
public $post_id;
public $post_permalink;
public function set_up() {
$this->user_id = 1;
$authordata = \get_userdata( $this->user_id );
$this->user_url = $authordata->user_url;
$this->post_id = \wp_insert_post(
array(
'post_author' => $this->user_id,
'post_content' => 'test',
)
);
$this->post_permalink = \get_permalink( $this->post_id );
\add_filter( 'pre_get_remote_metadata_by_actor', array( '\Test_Activitypub_Interactions', 'get_remote_metadata_by_actor' ), 0, 2 );
}
public static function get_remote_metadata_by_actor( $value, $actor ) {
return array(
'name' => 'Example User',
'icon' => array(
'url' => 'https://example.com/icon',
),
'url' => $actor,
'id' => 'http://example.org/users/example',
);
}
public function create_test_object( $id = 'https://example.com/123' ) {
return array(
'actor' => $this->user_url,
'id' => 'https://example.com/id/' . microtime( true ),
'to' => [ $this->user_url ],
'cc' => [ 'https://www.w3.org/ns/activitystreams#Public' ],
'object' => array(
'id' => $id,
'url' => 'https://example.com/example',
'inReplyTo' => $this->post_permalink,
'content' => 'example',
),
);
}
public function test_handle_create_basic() {
$comment_id = Activitypub\Collection\Interactions::add_comment( $this->create_test_object() );
$comment = get_comment( $comment_id, ARRAY_A );
$this->assertIsArray( $comment );
$this->assertEquals( $this->post_id, $comment['comment_post_ID'] );
$this->assertEquals( 'Example User', $comment['comment_author'] );
$this->assertEquals( $this->user_url, $comment['comment_author_url'] );
$this->assertEquals( 'example', $comment['comment_content'] );
$this->assertEquals( 'comment', $comment['comment_type'] );
$this->assertEquals( '', $comment['comment_author_email'] );
$this->assertEquals( 0, $comment['comment_parent'] );
$this->assertEquals( 'https://example.com/123', get_comment_meta( $comment_id, 'source_id', true ) );
$this->assertEquals( 'https://example.com/example', get_comment_meta( $comment_id, 'source_url', true ) );
$this->assertEquals( 'https://example.com/icon', get_comment_meta( $comment_id, 'avatar_url', true ) );
$this->assertEquals( 'activitypub', get_comment_meta( $comment_id, 'protocol', true ) );
}
public function test_convert_object_to_comment_not_reply_rejected() {
$object = $this->create_test_object();
unset( $object['object']['inReplyTo'] );
$converted = Activitypub\Collection\Interactions::add_comment( $object );
$this->assertFalse( $converted );
}
public function test_convert_object_to_comment_already_exists_rejected() {
$object = $this->create_test_object( 'https://example.com/test_convert_object_to_comment_already_exists_rejected' );
Activitypub\Collection\Interactions::add_comment( $object );
$converted = Activitypub\Collection\Interactions::add_comment( $object );
$this->assertEquals( $converted->get_error_code(), 'comment_duplicate' );
}
public function test_convert_object_to_comment_reply_to_comment() {
$id = 'https://example.com/test_convert_object_to_comment_reply_to_comment';
$object = $this->create_test_object( $id );
Activitypub\Collection\Interactions::add_comment( $object );
$comment = \Activitypub\object_id_to_comment( $id );
$object['object']['inReplyTo'] = $id;
$object['object']['id'] = 'https://example.com/234';
$id = Activitypub\Collection\Interactions::add_comment( $object );
$converted = get_comment( $id, ARRAY_A );
$this->assertIsArray( $converted );
$this->assertEquals( $this->post_id, $converted['comment_post_ID'] );
$this->assertEquals( $comment->comment_ID, $converted['comment_parent'] );
}
public function test_convert_object_to_comment_reply_to_non_existent_comment_rejected() {
$object = $this->create_test_object();
$object['object']['inReplyTo'] = 'https://example.com/not_found';
$converted = Activitypub\Collection\Interactions::add_comment( $object );
$this->assertFalse( $converted );
}
public function test_handle_create_basic2() {
$id = 'https://example.com/test_handle_create_basic';
$object = $this->create_test_object( $id );
Activitypub\Collection\Interactions::add_comment( $object );
$comment = \Activitypub\object_id_to_comment( $id );
$this->assertInstanceOf( WP_Comment::class, $comment );
}
public function test_get_interaction_by_id() {
$id = 'https://example.com/test_get_interaction_by_id';
$url = 'https://example.com/test_get_interaction_by_url';
$object = $this->create_test_object( $id );
$object['object']['url'] = $url;
Activitypub\Collection\Interactions::add_comment( $object );
$comment = \Activitypub\object_id_to_comment( $id );
$interactions = Activitypub\Collection\Interactions::get_interaction_by_id( $id );
$this->assertIsArray( $interactions );
$this->assertEquals( $comment->comment_ID, $interactions[0]->comment_ID );
$comment = \Activitypub\object_id_to_comment( $id );
$interactions = Activitypub\Collection\Interactions::get_interaction_by_id( $url );
$this->assertIsArray( $interactions );
$this->assertEquals( $comment->comment_ID, $interactions[0]->comment_ID );
}
}

View file

@ -5,7 +5,7 @@ class Test_Activitypub_Rest_Inbox extends WP_UnitTestCase {
*/ */
public function test_is_activity_public( $data, $check ) { public function test_is_activity_public( $data, $check ) {
$this->assertEquals( $check, Activitypub\Rest\Inbox::is_activity_public( $data ) ); $this->assertEquals( $check, Activitypub\is_activity_public( $data ) );
} }
public function the_data_provider() { public function the_data_provider() {

View file

@ -1,9 +1,80 @@
<?php <?php
class Test_Functions extends ActivityPub_TestCase_Cache_HTTP { class Test_Functions extends ActivityPub_TestCase_Cache_HTTP {
public $user_id;
public $post_id;
public function test_get_remote_metadata_by_actor() { public function test_get_remote_metadata_by_actor() {
$metadata = \ActivityPub\get_remote_metadata_by_actor( 'pfefferle@notiz.blog' ); $metadata = \ActivityPub\get_remote_metadata_by_actor( 'pfefferle@notiz.blog' );
$this->assertEquals( 'https://notiz.blog/author/matthias-pfefferle/', $metadata['url'] ); $this->assertEquals( 'https://notiz.blog/author/matthias-pfefferle/', $metadata['url'] );
$this->assertEquals( 'pfefferle', $metadata['preferredUsername'] ); $this->assertEquals( 'pfefferle', $metadata['preferredUsername'] );
$this->assertEquals( 'Matthias Pfefferle', $metadata['name'] ); $this->assertEquals( 'Matthias Pfefferle', $metadata['name'] );
} }
public function set_up() {
$this->post_id = \wp_insert_post(
array(
'post_author' => $this->user_id,
'post_content' => 'test',
)
);
}
public function test_object_id_to_comment_basic() {
$single_comment_source_id = 'https://example.com/single';
$content = 'example';
$comment_id = \wp_new_comment(
array(
'comment_post_ID' => $this->post_id,
'comment_author' => 'Example User',
'comment_author_url' => 'https://example.com/user',
'comment_content' => $content,
'comment_type' => '',
'comment_author_email' => '',
'comment_parent' => 0,
'comment_meta' => array(
'source_id' => $single_comment_source_id,
'source_url' => 'https://example.com/123',
'avatar_url' => 'https://example.com/icon',
'protocol' => 'activitypub',
),
),
true
);
$query_result = \Activitypub\object_id_to_comment( $single_comment_source_id );
$this->assertInstanceOf( WP_Comment::class, $query_result );
$this->assertEquals( $comment_id, $query_result->comment_ID );
$this->assertEquals( $content, $query_result->comment_content );
}
public function test_object_id_to_comment_none() {
$single_comment_source_id = 'https://example.com/none';
$query_result = \Activitypub\object_id_to_comment( $single_comment_source_id );
$this->assertFalse( $query_result );
}
public function test_object_id_to_comment_duplicate() {
$duplicate_comment_source_id = 'https://example.com/duplicate';
for ( $i = 0; $i < 2; ++$i ) {
\wp_new_comment(
array(
'comment_post_ID' => $this->post_id,
'comment_author' => 'Example User',
'comment_author_url' => 'https://example.com/user',
'comment_content' => 'example',
'comment_type' => '',
'comment_author_email' => '',
'comment_parent' => 0,
'comment_meta' => array(
'source_id' => $duplicate_comment_source_id,
'source_url' => 'https://example.com/123',
'avatar_url' => 'https://example.com/icon',
'protocol' => 'activitypub',
),
),
true
);
}
$query_result = \Activitypub\object_id_to_comment( $duplicate_comment_source_id );
$this->assertFalse( $query_result );
}
} }