Merge branch 'master' into feature-guidance
This commit is contained in:
commit
d6b7cd0235
13 changed files with 844 additions and 53 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -6,3 +6,4 @@ composer.lock
|
|||
.idea/
|
||||
.php_cs.cache
|
||||
.vscode/settings.json
|
||||
.phpunit.result.cache
|
||||
|
|
|
@ -19,7 +19,7 @@ namespace Activitypub;
|
|||
* Initialize plugin
|
||||
*/
|
||||
function init() {
|
||||
\defined( 'ACTIVITYPUB_HASHTAGS_REGEXP' ) || \define( 'ACTIVITYPUB_HASHTAGS_REGEXP', '(?:(?<=\s)|^)#(\w*[A-Za-z_]+\w*)' );
|
||||
\defined( 'ACTIVITYPUB_HASHTAGS_REGEXP' ) || \define( 'ACTIVITYPUB_HASHTAGS_REGEXP', '(?:(?<=\s)|(?<=<p>)|(?<=<br>)|^)#([A-Za-z0-9_]+)(?:(?=\s|[[:punct:]]|$))' );
|
||||
\defined( 'ACTIVITYPUB_ALLOWED_HTML' ) || \define( 'ACTIVITYPUB_ALLOWED_HTML', '<strong><a><p><ul><ol><li><code><blockquote><pre><img>' );
|
||||
\defined( 'ACTIVITYPUB_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "<p><strong>%title%</strong></p>\n\n%content%\n\n<p>%hashtags%</p>\n\n<p>%shortlink%</p>" );
|
||||
\define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
|
@ -136,3 +136,11 @@ function enable_buddypress_features() {
|
|||
\Activitypub\Integration\Buddypress::init();
|
||||
}
|
||||
add_action( 'bp_include', '\Activitypub\enable_buddypress_features' );
|
||||
|
||||
add_action(
|
||||
'friends_load_parsers',
|
||||
function( \Friends\Feed $friends_feed ) {
|
||||
require_once __DIR__ . '/integration/class-friends-feed-parser-activitypub.php';
|
||||
$friends_feed->register_parser( Friends_Feed_Parser_ActivityPub::SLUG, new Friends_Feed_Parser_ActivityPub( $friends_feed ) );
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
namespace Activitypub;
|
||||
|
||||
use Activitypub\Rest\Webfinger;
|
||||
|
||||
/**
|
||||
* ActivityPub Health_Check Class
|
||||
*
|
||||
|
@ -199,58 +201,37 @@ class Health_Check {
|
|||
* @return boolean|WP_Error
|
||||
*/
|
||||
public static function is_webfinger_endpoint_accessible() {
|
||||
$user = \wp_get_current_user();
|
||||
$webfinger = \Activitypub\get_webfinger_resource( $user->ID );
|
||||
$user = \wp_get_current_user();
|
||||
$account = \Activitypub\get_webfinger_resource( $user->ID );
|
||||
|
||||
$url = \wp_parse_url( \home_url(), \PHP_URL_SCHEME ) . '://' . \wp_parse_url( \home_url(), \PHP_URL_HOST );
|
||||
|
||||
if ( \wp_parse_url( \home_url(), \PHP_URL_PORT ) ) {
|
||||
$url .= ':' . \wp_parse_url( \home_url(), \PHP_URL_PORT );
|
||||
}
|
||||
|
||||
$url = \trailingslashit( $url ) . '.well-known/webfinger';
|
||||
|
||||
$url = \add_query_arg( 'resource', 'acct:' . $webfinger, $url );
|
||||
|
||||
// try to access author URL
|
||||
$response = \wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'headers' => array( 'Accept' => 'application/activity+json' ),
|
||||
'redirection' => 0,
|
||||
)
|
||||
);
|
||||
|
||||
if ( \is_wp_error( $response ) ) {
|
||||
return new \WP_Error(
|
||||
'webfinger_url_not_accessible',
|
||||
\sprintf(
|
||||
$url = Webfinger::resolve( $account );
|
||||
if ( \is_wp_error( $url ) ) {
|
||||
$health_messages = array(
|
||||
'webfinger_url_not_accessible' => \sprintf(
|
||||
// translators: %s: Author URL
|
||||
\__(
|
||||
'<p>Your WebFinger endpoint <code>%s</code> is not accessible. Please check your WordPress setup or permalink structure.</p>',
|
||||
'activitypub'
|
||||
),
|
||||
$url
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$response_code = \wp_remote_retrieve_response_code( $response );
|
||||
|
||||
// check if response is JSON
|
||||
$body = \wp_remote_retrieve_body( $response );
|
||||
|
||||
if ( ! \is_string( $body ) || ! \is_array( \json_decode( $body, true ) ) ) {
|
||||
return new \WP_Error(
|
||||
'webfinger_url_not_accessible',
|
||||
\sprintf(
|
||||
$url->get_error_data()
|
||||
),
|
||||
'webfinger_url_invalid_response' => \sprintf(
|
||||
// translators: %s: Author URL
|
||||
\__(
|
||||
'<p>Your WebFinger endpoint <code>%s</code> does not return valid JSON for <code>application/jrd+json</code>.</p>',
|
||||
'activitypub'
|
||||
),
|
||||
$url
|
||||
)
|
||||
$url->get_error_data()
|
||||
),
|
||||
);
|
||||
$message = null;
|
||||
if ( isset( $health_messages[ $url->get_error_code() ] ) ) {
|
||||
$message = $health_messages[ $url->get_error_code() ];
|
||||
}
|
||||
return new \WP_Error(
|
||||
$url->get_error_code(),
|
||||
$message,
|
||||
$url->get_error_data()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@ class Signature {
|
|||
\update_user_meta( $user_id, 'magic_sig_public_key', $detail['key'] );
|
||||
}
|
||||
|
||||
public static function generate_signature( $user_id, $url, $date, $digest = null ) {
|
||||
public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) {
|
||||
$key = self::get_private_key( $user_id );
|
||||
|
||||
$url_parts = \wp_parse_url( $url );
|
||||
|
@ -89,9 +89,9 @@ class Signature {
|
|||
}
|
||||
|
||||
if ( ! empty( $digest ) ) {
|
||||
$signed_string = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest";
|
||||
$signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest";
|
||||
} else {
|
||||
$signed_string = "(request-target): post $path\nhost: $host\ndate: $date";
|
||||
$signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date";
|
||||
}
|
||||
|
||||
$signature = null;
|
||||
|
|
|
@ -35,7 +35,7 @@ function get_context() {
|
|||
function safe_remote_post( $url, $body, $user_id ) {
|
||||
$date = \gmdate( 'D, d M Y H:i:s T' );
|
||||
$digest = \Activitypub\Signature::generate_digest( $body );
|
||||
$signature = \Activitypub\Signature::generate_signature( $user_id, $url, $date, $digest );
|
||||
$signature = \Activitypub\Signature::generate_signature( $user_id, 'post', $url, $date, $digest );
|
||||
|
||||
$wp_version = \get_bloginfo( 'version' );
|
||||
$user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
|
||||
|
@ -63,7 +63,7 @@ function safe_remote_post( $url, $body, $user_id ) {
|
|||
|
||||
function safe_remote_get( $url, $user_id ) {
|
||||
$date = \gmdate( 'D, d M Y H:i:s T' );
|
||||
$signature = \Activitypub\Signature::generate_signature( $user_id, $url, $date );
|
||||
$signature = \Activitypub\Signature::generate_signature( $user_id, 'get', $url, $date );
|
||||
|
||||
$wp_version = \get_bloginfo( 'version' );
|
||||
$user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
|
||||
|
@ -101,11 +101,23 @@ function get_webfinger_resource( $user_id ) {
|
|||
/**
|
||||
* [get_metadata_by_actor description]
|
||||
*
|
||||
* @param sting $actor
|
||||
* @param string $actor
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
function get_remote_metadata_by_actor( $actor ) {
|
||||
$pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
|
||||
if ( $pre ) {
|
||||
return $pre;
|
||||
}
|
||||
if ( preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $actor ) ) {
|
||||
$actor = Rest\Webfinger::resolve( $actor );
|
||||
}
|
||||
|
||||
if ( ! $actor ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$metadata = \get_transient( 'activitypub_' . $actor );
|
||||
|
||||
if ( $metadata ) {
|
||||
|
|
|
@ -75,7 +75,7 @@ class Activity {
|
|||
}
|
||||
|
||||
public function to_array() {
|
||||
$array = \get_object_vars( $this );
|
||||
$array = array_filter( \get_object_vars( $this ) );
|
||||
|
||||
if ( $this->context ) {
|
||||
$array = array( '@context' => $this->context ) + $array;
|
||||
|
|
|
@ -161,7 +161,6 @@ class Inbox {
|
|||
public static function shared_inbox_post( $request ) {
|
||||
$data = $request->get_params();
|
||||
$type = $request->get_param( 'type' );
|
||||
|
||||
$users = self::extract_recipients( $data );
|
||||
|
||||
if ( ! $users ) {
|
||||
|
@ -407,6 +406,10 @@ class Inbox {
|
|||
public static function handle_create( $object, $user_id ) {
|
||||
$meta = \Activitypub\get_remote_metadata_by_actor( $object['actor'] );
|
||||
|
||||
if ( ! isset( $object['object']['inReplyTo'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$comment_post_id = \url_to_postid( $object['object']['inReplyTo'] );
|
||||
|
||||
// save only replys and reactions
|
||||
|
|
|
@ -43,6 +43,7 @@ class Outbox {
|
|||
public static function user_outbox_get( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$author = \get_user_by( 'ID', $user_id );
|
||||
$post_types = \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) );
|
||||
|
||||
if ( ! $author ) {
|
||||
return new \WP_Error(
|
||||
|
@ -73,8 +74,11 @@ class Outbox {
|
|||
$json->type = 'OrderedCollectionPage';
|
||||
$json->partOf = \get_rest_url( null, "/activitypub/1.0/users/$user_id/outbox" ); // phpcs:ignore
|
||||
|
||||
$count_posts = \wp_count_posts();
|
||||
$json->totalItems = \intval( $count_posts->publish ); // phpcs:ignore
|
||||
$json->totalItems = 0;
|
||||
foreach ( $post_types as $post_type ) {
|
||||
$count_posts = \wp_count_posts( $post_type );
|
||||
$json->totalItems += \intval( $count_posts->publish ); // phpcs:ignore
|
||||
}
|
||||
|
||||
$json->first = \add_query_arg( 'page', 1, $json->partOf ); // phpcs:ignore
|
||||
$json->last = \add_query_arg( 'page', \ceil ( $json->totalItems / 10 ), $json->partOf ); // phpcs:ignore
|
||||
|
@ -89,7 +93,7 @@ class Outbox {
|
|||
'posts_per_page' => 10,
|
||||
'author' => $user_id,
|
||||
'offset' => ( $page - 1 ) * 10,
|
||||
'post_type' => 'post',
|
||||
'post_type' => $post_types,
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
@ -120,4 +120,44 @@ class Webfinger {
|
|||
|
||||
return $array;
|
||||
}
|
||||
|
||||
public static function resolve( $account ) {
|
||||
if ( ! preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $account, $m ) ) {
|
||||
return null;
|
||||
}
|
||||
$url = \add_query_arg( 'resource', 'acct:' . ltrim( $account, '@' ), 'https://' . $m[1] . '/.well-known/webfinger' );
|
||||
if ( ! \wp_http_validate_url( $url ) ) {
|
||||
return new \WP_Error( 'invalid_webfinger_url', null, $url );
|
||||
}
|
||||
|
||||
// try to access author URL
|
||||
$response = \wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'headers' => array( 'Accept' => 'application/activity+json' ),
|
||||
'redirection' => 0,
|
||||
)
|
||||
);
|
||||
|
||||
if ( \is_wp_error( $response ) ) {
|
||||
return new \WP_Error( 'webfinger_url_not_accessible', null, $url );
|
||||
}
|
||||
|
||||
$response_code = \wp_remote_retrieve_response_code( $response );
|
||||
|
||||
$body = \wp_remote_retrieve_body( $response );
|
||||
$body = \json_decode( $body, true );
|
||||
|
||||
if ( ! isset( $body['links'] ) ) {
|
||||
return new \WP_Error( 'webfinger_url_invalid_response', null, $url );
|
||||
}
|
||||
|
||||
foreach ( $body['links'] as $link ) {
|
||||
if ( 'self' === $link['rel'] && 'application/activity+json' === $link['type'] ) {
|
||||
return $link['href'];
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Error( 'webfinger_url_no_activity_pub', null, $body );
|
||||
}
|
||||
}
|
||||
|
|
409
integration/class-friends-feed-parser-activitypub.php
Normal file
409
integration/class-friends-feed-parser-activitypub.php
Normal file
|
@ -0,0 +1,409 @@
|
|||
<?php
|
||||
/**
|
||||
* This is the class for integrating ActivityPub into the Friends Plugin.
|
||||
*
|
||||
* @since 0.14
|
||||
*
|
||||
* @package ActivityPub
|
||||
* @author Alex Kirk
|
||||
*/
|
||||
namespace Activitypub;
|
||||
|
||||
class Friends_Feed_Parser_ActivityPub extends \Friends\Feed_Parser {
|
||||
const SLUG = 'activitypub';
|
||||
const NAME = 'ActivityPub';
|
||||
const URL = 'https://www.w3.org/TR/activitypub/';
|
||||
private $friends_feed;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param \Friends\Feed $friends_feed The friends feed
|
||||
*/
|
||||
public function __construct( \Friends\Feed $friends_feed ) {
|
||||
$this->friends_feed = $friends_feed;
|
||||
|
||||
\add_action( 'activitypub_inbox', array( $this, 'handle_received_activity' ), 10, 3 );
|
||||
\add_action( 'friends_user_feed_activated', array( $this, 'queue_follow_user' ), 10 );
|
||||
\add_action( 'friends_user_feed_deactivated', array( $this, 'queue_unfollow_user' ), 10 );
|
||||
\add_action( 'friends_feed_parser_activitypub_follow', array( $this, 'follow_user' ), 10, 2 );
|
||||
\add_action( 'friends_feed_parser_activitypub_unfollow', array( $this, 'unfollow_user' ), 10, 2 );
|
||||
\add_filter( 'friends_rewrite_incoming_url', array( $this, 'friends_rewrite_incoming_url' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow logging a message via an action.
|
||||
* @param string $message The message to log.
|
||||
* @param array $objects Optional objects as meta data.
|
||||
* @return void
|
||||
*/
|
||||
private function log( $message, $objects = array() ) {
|
||||
do_action( 'friends_activitypub_log', $message, $objects );
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if this is a supported feed and to what degree we feel it's supported.
|
||||
*
|
||||
* @param string $url The url.
|
||||
* @param string $mime_type The mime type.
|
||||
* @param string $title The title.
|
||||
* @param string|null $content The content, it can't be assumed that it's always available.
|
||||
*
|
||||
* @return int Return 0 if unsupported, a positive value representing the confidence for the feed, use 10 if you're reasonably confident.
|
||||
*/
|
||||
public function feed_support_confidence( $url, $mime_type, $title, $content = null ) {
|
||||
if ( preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $url ) ) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the feed title and autoselect the posts feed.
|
||||
*
|
||||
* @param array $feed_details The feed details.
|
||||
*
|
||||
* @return array The (potentially) modified feed details.
|
||||
*/
|
||||
public function update_feed_details( $feed_details ) {
|
||||
$meta = \Activitypub\get_remote_metadata_by_actor( $feed_details['url'] );
|
||||
if ( ! $meta && is_wp_error( $meta ) ) {
|
||||
return $meta;
|
||||
}
|
||||
|
||||
if ( isset( $meta['name'] ) ) {
|
||||
$feed_details['title'] = $meta['name'];
|
||||
} elseif ( isset( $meta['preferredUsername'] ) ) {
|
||||
$feed_details['title'] = $meta['preferredUsername'];
|
||||
}
|
||||
|
||||
if ( isset( $meta['id'] ) ) {
|
||||
$feed_details['url'] = $meta['id'];
|
||||
}
|
||||
|
||||
return $feed_details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a Mastodon style URL @username@server to a URL via webfinger.
|
||||
*
|
||||
* @param string $url The URL to filter.
|
||||
* @param string $incoming_url Potentially a mastodon identifier.
|
||||
*
|
||||
* @return <type> ( description_of_the_return_value )
|
||||
*/
|
||||
public function friends_rewrite_incoming_url( $url, $incoming_url ) {
|
||||
if ( preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $incoming_url ) ) {
|
||||
$resolved_url = \Activitypub\Rest\Webfinger::resolve( $incoming_url );
|
||||
if ( ! is_wp_error( $resolved_url ) ) {
|
||||
return $resolved_url;
|
||||
}
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the feeds available at the URL specified.
|
||||
*
|
||||
* @param string $content The content for the URL is already provided here.
|
||||
* @param string $url The url to search.
|
||||
*
|
||||
* @return array A list of supported feeds at the URL.
|
||||
*/
|
||||
public function discover_available_feeds( $content, $url ) {
|
||||
$discovered_feeds = array();
|
||||
|
||||
$meta = \Activitypub\get_remote_metadata_by_actor( $url );
|
||||
if ( $meta && ! is_wp_error( $meta ) ) {
|
||||
$discovered_feeds[ $meta['id'] ] = array(
|
||||
'type' => 'application/activity+json',
|
||||
'rel' => 'self',
|
||||
'post-format' => 'status',
|
||||
'parser' => self::SLUG,
|
||||
'autoselect' => true,
|
||||
);
|
||||
}
|
||||
return $discovered_feeds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a feed and returns the processed items.
|
||||
*
|
||||
* @param string $url The url.
|
||||
*
|
||||
* @return array An array of feed items.
|
||||
*/
|
||||
public function fetch_feed( $url ) {
|
||||
// There is no feed to fetch, we'll receive items via ActivityPub.
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles "Create" requests
|
||||
*
|
||||
* @param array $object The activity-object
|
||||
* @param int $user_id The id of the local blog-user
|
||||
* @param string $type The type of the activity.
|
||||
*/
|
||||
public function handle_received_activity( $object, $user_id, $type ) {
|
||||
if ( ! in_array(
|
||||
$type,
|
||||
array(
|
||||
// We don't need to handle 'Accept' types since it's handled by the ActivityPub plugin itself.
|
||||
'create',
|
||||
'announce',
|
||||
),
|
||||
true
|
||||
) ) {
|
||||
return false;
|
||||
}
|
||||
$actor_url = $object['actor'];
|
||||
$user_feed = false;
|
||||
if ( \wp_http_validate_url( $actor_url ) ) {
|
||||
// Let's check if we follow this actor. If not it might be a different URL representation.
|
||||
$user_feed = $this->friends_feed->get_user_feed_by_url( $actor_url );
|
||||
}
|
||||
|
||||
if ( is_wp_error( $user_feed ) || ! \wp_http_validate_url( $actor_url ) ) {
|
||||
$meta = \Activitypub\get_remote_metadata_by_actor( $actor_url );
|
||||
if ( ! $meta || ! isset( $meta['url'] ) ) {
|
||||
$this->log( 'Received invalid meta for ' . $actor_url );
|
||||
return false;
|
||||
}
|
||||
|
||||
$actor_url = $meta['url'];
|
||||
if ( ! \wp_http_validate_url( $actor_url ) ) {
|
||||
$this->log( 'Received invalid meta url for ' . $actor_url );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$user_feed = $this->friends_feed->get_user_feed_by_url( $actor_url );
|
||||
if ( ! $user_feed || is_wp_error( $user_feed ) ) {
|
||||
$this->log( 'We\'re not following ' . $actor_url );
|
||||
// We're not following this user.
|
||||
return false;
|
||||
}
|
||||
|
||||
switch ( $type ) {
|
||||
case 'create':
|
||||
return $this->handle_incoming_post( $object['object'], $user_feed );
|
||||
case 'announce':
|
||||
return $this->handle_incoming_announce( $object['object'], $user_feed, $user_id );
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the Activity type to a post fomat.
|
||||
*
|
||||
* @param string $type The type.
|
||||
*
|
||||
* @return string The determined post format.
|
||||
*/
|
||||
private function map_type_to_post_format( $type ) {
|
||||
return 'status';
|
||||
}
|
||||
|
||||
/**
|
||||
* We received a post for a feed, handle it.
|
||||
*
|
||||
* @param array $object The object from ActivityPub.
|
||||
* @param \Friends\User_Feed $user_feed The user feed.
|
||||
*/
|
||||
private function handle_incoming_post( $object, \Friends\User_Feed $user_feed ) {
|
||||
$permalink = $object['id'];
|
||||
if ( isset( $object['url'] ) ) {
|
||||
$permalink = $object['url'];
|
||||
}
|
||||
|
||||
$data = array(
|
||||
'permalink' => $permalink,
|
||||
'content' => $object['content'],
|
||||
'post_format' => $this->map_type_to_post_format( $object['type'] ),
|
||||
'date' => $object['published'],
|
||||
);
|
||||
|
||||
if ( isset( $object['attributedTo'] ) ) {
|
||||
$meta = \Activitypub\get_remote_metadata_by_actor( $object['attributedTo'] );
|
||||
$this->log( 'Attributed to ' . $object['attributedTo'], compact( 'meta' ) );
|
||||
if ( isset( $meta['name'] ) ) {
|
||||
$data['author'] = $meta['name'];
|
||||
} elseif ( isset( $meta['preferredUsername'] ) ) {
|
||||
$data['author'] = $meta['preferredUsername'];
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $object['attachment'] ) ) {
|
||||
foreach ( $object['attachment'] as $attachment ) {
|
||||
if ( ! isset( $attachment['type'] ) || ! isset( $attachment['mediaType'] ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( 'Document' !== $attachment['type'] || strpos( $attachment['mediaType'], 'image/' ) !== 0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data['content'] .= PHP_EOL;
|
||||
$data['content'] .= '<!-- wp:image -->';
|
||||
$data['content'] .= '<p><img src="' . esc_url( $attachment['url'] ) . '" width="' . esc_attr( $attachment['width'] ) . '" height="' . esc_attr( $attachment['height'] ) . '" class="size-full" /></p>';
|
||||
$data['content'] .= '<!-- /wp:image -->';
|
||||
}
|
||||
$meta = \Activitypub\get_remote_metadata_by_actor( $object['attributedTo'] );
|
||||
$this->log( 'Attributed to ' . $object['attributedTo'], compact( 'meta' ) );
|
||||
if ( isset( $meta['name'] ) ) {
|
||||
$data['author'] = $meta['name'];
|
||||
} elseif ( isset( $meta['preferredUsername'] ) ) {
|
||||
$data['author'] = $meta['preferredUsername'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->log(
|
||||
'Received feed item',
|
||||
array(
|
||||
'url' => $permalink,
|
||||
'data' => $data,
|
||||
)
|
||||
);
|
||||
$item = new \Friends\Feed_Item( $data );
|
||||
|
||||
$this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* We received an announced URL (boost) for a feed, handle it.
|
||||
*
|
||||
* @param array $url The announced URL.
|
||||
* @param \Friends\User_Feed $user_feed The user feed.
|
||||
*/
|
||||
private function handle_incoming_announce( $url, \Friends\User_Feed $user_feed, $user_id ) {
|
||||
if ( ! \wp_http_validate_url( $url ) ) {
|
||||
$this->log( 'Received invalid announce', compact( 'url' ) );
|
||||
return false;
|
||||
}
|
||||
$this->log( 'Received announce for ' . $url );
|
||||
|
||||
$response = \Activitypub\safe_remote_get( $url, $user_id );
|
||||
if ( \is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
$json = \wp_remote_retrieve_body( $response );
|
||||
$object = \json_decode( $json, true );
|
||||
if ( ! $object ) {
|
||||
$this->log( 'Received invalid json', compact( 'json' ) );
|
||||
return false;
|
||||
}
|
||||
$this->log( 'Received response', compact( 'url', 'object' ) );
|
||||
|
||||
return $this->handle_incoming_post( $object, $user_feed );
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare to follow the user via a scheduled event.
|
||||
*
|
||||
* @param \Friends\User_Feed $user_feed The user feed.
|
||||
*
|
||||
* @return bool|WP_Error Whether the event was queued.
|
||||
*/
|
||||
public function queue_follow_user( \Friends\User_Feed $user_feed ) {
|
||||
if ( self::SLUG !== $user_feed->get_parser() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$args = array( $user_feed->get_url(), get_current_user_id() );
|
||||
|
||||
$unfollow_timestamp = wp_next_scheduled( 'friends_feed_parser_activitypub_unfollow', $args );
|
||||
if ( $unfollow_timestamp ) {
|
||||
// If we just unfollowed, we don't want the event to potentially be executed after our follow event.
|
||||
wp_unschedule_event( $unfollow_timestamp, $args );
|
||||
}
|
||||
|
||||
if ( wp_next_scheduled( 'friends_feed_parser_activitypub_follow', $args ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
return \wp_schedule_single_event( \time(), 'friends_feed_parser_activitypub_follow', $args );
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a user via ActivityPub at a URL.
|
||||
*
|
||||
* @param string $url The url.
|
||||
* @param int $user_id The current user id.
|
||||
*/
|
||||
public function follow_user( $url, $user_id ) {
|
||||
$meta = \Activitypub\get_remote_metadata_by_actor( $url );
|
||||
$to = $meta['id'];
|
||||
$inbox = \Activitypub\get_inbox_by_actor( $to );
|
||||
$actor = \get_author_posts_url( $user_id );
|
||||
|
||||
$activity = new \Activitypub\Model\Activity( 'Follow', \Activitypub\Model\Activity::TYPE_SIMPLE );
|
||||
$activity->set_to( null );
|
||||
$activity->set_cc( null );
|
||||
$activity->set_actor( $actor );
|
||||
$activity->set_object( $to );
|
||||
$activity->set_id( $actor . '#follow-' . \preg_replace( '~^https?://~', '', $to ) );
|
||||
$activity = $activity->to_json();
|
||||
\Activitypub\safe_remote_post( $inbox, $activity, $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare to unfollow the user via a scheduled event.
|
||||
*
|
||||
* @param \Friends\User_Feed $user_feed The user feed.
|
||||
*
|
||||
* @return bool|WP_Error Whether the event was queued.
|
||||
*/
|
||||
public function queue_unfollow_user( \Friends\User_Feed $user_feed ) {
|
||||
if ( self::SLUG !== $user_feed->get_parser() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$args = array( $user_feed->get_url(), get_current_user_id() );
|
||||
|
||||
$follow_timestamp = wp_next_scheduled( 'friends_feed_parser_activitypub_follow', $args );
|
||||
if ( $follow_timestamp ) {
|
||||
// If we just followed, we don't want the event to potentially be executed after our unfollow event.
|
||||
wp_unschedule_event( $follow_timestamp, $args );
|
||||
}
|
||||
|
||||
if ( wp_next_scheduled( 'friends_feed_parser_activitypub_unfollow', $args ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return \wp_schedule_single_event( \time(), 'friends_feed_parser_activitypub_unfollow', $args );
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfllow a user via ActivityPub at a URL.
|
||||
*
|
||||
* @param string $url The url.
|
||||
* @param int $user_id The current user id.
|
||||
*/
|
||||
public function unfollow_user( $url, $user_id ) {
|
||||
$meta = \Activitypub\get_remote_metadata_by_actor( $url );
|
||||
$to = $meta['id'];
|
||||
$inbox = \Activitypub\get_inbox_by_actor( $to );
|
||||
$actor = \get_author_posts_url( $user_id );
|
||||
|
||||
$activity = new \Activitypub\Model\Activity( 'Undo', \Activitypub\Model\Activity::TYPE_SIMPLE );
|
||||
$activity->set_to( null );
|
||||
$activity->set_cc( null );
|
||||
$activity->set_actor( $actor );
|
||||
$activity->set_object(
|
||||
array(
|
||||
'type' => 'Follow',
|
||||
'actor' => $actor,
|
||||
'object' => $to,
|
||||
'id' => $to,
|
||||
)
|
||||
);
|
||||
$activity->set_id( $actor . '#unfollow-' . \preg_replace( '~^https?://~', '', $to ) );
|
||||
$activity = $activity->to_json();
|
||||
\Activitypub\safe_remote_post( $inbox, $activity, $user_id );
|
||||
}
|
||||
}
|
|
@ -19,6 +19,12 @@ require_once $_tests_dir . '/includes/functions.php';
|
|||
*/
|
||||
function _manually_load_plugin() {
|
||||
require \dirname( \dirname( __FILE__ ) ) . '/activitypub.php';
|
||||
|
||||
// Load the Friends plugin if available to test the integrations.
|
||||
$friends_plugin = \dirname( \dirname( \dirname( __FILE__ ) ) ) . '/friends/friends.php';
|
||||
if ( file_exists( $friends_plugin ) ) {
|
||||
require $friends_plugin;
|
||||
}
|
||||
}
|
||||
\tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' );
|
||||
|
||||
|
|
1
tests/fixtures/notiz-blog-2022-11-14-the-at-protocol.response
vendored
Normal file
1
tests/fixtures/notiz-blog-2022-11-14-the-at-protocol.response
vendored
Normal file
File diff suppressed because one or more lines are too long
326
tests/test-class-friends-feed-parser-activitypub.php
Normal file
326
tests/test-class-friends-feed-parser-activitypub.php
Normal file
|
@ -0,0 +1,326 @@
|
|||
<?php
|
||||
|
||||
class Test_Friends_Feed_Parser_ActivityPub extends \WP_UnitTestCase {
|
||||
public static $users = array();
|
||||
private $friend_id;
|
||||
private $friend_name;
|
||||
private $actor;
|
||||
|
||||
public function test_incoming_post() {
|
||||
$now = time() - 10;
|
||||
$status_id = 123;
|
||||
|
||||
$posts = get_posts(
|
||||
array(
|
||||
'post_type' => \Friends\Friends::CPT,
|
||||
'author' => $this->friend_id,
|
||||
)
|
||||
);
|
||||
|
||||
$post_count = count( $posts );
|
||||
|
||||
// Let's post a new Note through the REST API.
|
||||
$date = gmdate( \DATE_W3C, $now++ );
|
||||
$id = 'test' . $status_id;
|
||||
$content = 'Test ' . $date . ' ' . rand();
|
||||
|
||||
$request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' );
|
||||
$request->set_param( 'type', 'Create' );
|
||||
$request->set_param( 'id', $id );
|
||||
$request->set_param( 'actor', $this->actor );
|
||||
|
||||
$attachment_url = 'https://mastodon.local/files/original/1234.png';
|
||||
$attachment_width = 400;
|
||||
$attachment_height = 600;
|
||||
$request->set_param(
|
||||
'object',
|
||||
array(
|
||||
'type' => 'Note',
|
||||
'id' => $id,
|
||||
'attributedTo' => $this->actor,
|
||||
'content' => $content,
|
||||
'url' => 'https://mastodon.local/users/akirk/statuses/' . ( $status_id++ ),
|
||||
'published' => $date,
|
||||
'attachment' => array(
|
||||
array(
|
||||
'type' => 'Document',
|
||||
'mediaType' => 'image/png',
|
||||
'url' => $attachment_url,
|
||||
'name' => '',
|
||||
'blurhash' => '',
|
||||
'width' => $attachment_width,
|
||||
'height' => $attachment_height,
|
||||
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$response = $this->server->dispatch( $request );
|
||||
$this->assertEquals( 202, $response->get_status() );
|
||||
|
||||
$posts = get_posts(
|
||||
array(
|
||||
'post_type' => \Friends\Friends::CPT,
|
||||
'author' => $this->friend_id,
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertEquals( $post_count + 1, count( $posts ) );
|
||||
$this->assertStringStartsWith( $content, $posts[0]->post_content );
|
||||
$this->assertStringContainsString( '<img src="' . esc_url( $attachment_url ) . '" width="' . esc_attr( $attachment_width ) . '" height="' . esc_attr( $attachment_height ) . '"', $posts[0]->post_content );
|
||||
$this->assertEquals( $this->friend_id, $posts[0]->post_author );
|
||||
|
||||
// Do another test post, this time with a URL that has an @-id.
|
||||
$date = gmdate( \DATE_W3C, $now++ );
|
||||
$id = 'test' . $status_id;
|
||||
$content = 'Test ' . $date . ' ' . rand();
|
||||
|
||||
$request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' );
|
||||
$request->set_param( 'type', 'Create' );
|
||||
$request->set_param( 'id', $id );
|
||||
$request->set_param( 'actor', 'https://mastodon.local/@akirk' );
|
||||
$request->set_param(
|
||||
'object',
|
||||
array(
|
||||
'type' => 'Note',
|
||||
'id' => $id,
|
||||
'attributedTo' => 'https://mastodon.local/@akirk',
|
||||
'content' => $content,
|
||||
'url' => 'https://mastodon.local/users/akirk/statuses/' . ( $status_id++ ),
|
||||
'published' => $date,
|
||||
)
|
||||
);
|
||||
|
||||
$response = $this->server->dispatch( $request );
|
||||
$this->assertEquals( 202, $response->get_status() );
|
||||
|
||||
$posts = get_posts(
|
||||
array(
|
||||
'post_type' => \Friends\Friends::CPT,
|
||||
'author' => $this->friend_id,
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertEquals( $post_count + 2, count( $posts ) );
|
||||
$this->assertEquals( $content, $posts[0]->post_content );
|
||||
$this->assertEquals( $this->friend_id, $posts[0]->post_author );
|
||||
$this->assertEquals( $this->friend_name, get_post_meta( $posts[0]->ID, 'author', true ) );
|
||||
}
|
||||
|
||||
public function test_incoming_announce() {
|
||||
$now = time() - 10;
|
||||
$status_id = 123;
|
||||
|
||||
self::$users['https://notiz.blog/author/matthias-pfefferle/'] = array(
|
||||
'url' => 'https://notiz.blog/author/matthias-pfefferle/',
|
||||
'name' => 'Matthias Pfefferle',
|
||||
);
|
||||
|
||||
$posts = get_posts(
|
||||
array(
|
||||
'post_type' => \Friends\Friends::CPT,
|
||||
'author' => $this->friend_id,
|
||||
)
|
||||
);
|
||||
|
||||
$post_count = count( $posts );
|
||||
|
||||
$date = gmdate( \DATE_W3C, $now++ );
|
||||
$id = 'test' . $status_id;
|
||||
|
||||
$object = 'https://notiz.blog/2022/11/14/the-at-protocol/';
|
||||
|
||||
$request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/' . get_current_user_id() . '/inbox' );
|
||||
$request->set_param( 'type', 'Announce' );
|
||||
$request->set_param( 'id', $id );
|
||||
$request->set_param( 'actor', $this->actor );
|
||||
$request->set_param( 'published', $date );
|
||||
$request->set_param( 'object', $object );
|
||||
|
||||
$response = $this->server->dispatch( $request );
|
||||
$this->assertEquals( 202, $response->get_status() );
|
||||
|
||||
$p = wp_parse_url( $object );
|
||||
$cache = __DIR__ . '/fixtures/' . sanitize_title( $p['host'] . '-' . $p['path'] ) . '.response';
|
||||
$this->assertFileExists( $cache );
|
||||
|
||||
$object = json_decode( wp_remote_retrieve_body( unserialize( file_get_contents( $cache ) ) ) );
|
||||
|
||||
$posts = get_posts(
|
||||
array(
|
||||
'post_type' => \Friends\Friends::CPT,
|
||||
'author' => $this->friend_id,
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertEquals( $post_count + 1, count( $posts ) );
|
||||
$this->assertStringContainsString( 'Dezentrale Netzwerke', $posts[0]->post_content );
|
||||
$this->assertEquals( $this->friend_id, $posts[0]->post_author );
|
||||
$this->assertEquals( 'Matthias Pfefferle', get_post_meta( $posts[0]->ID, 'author', true ) );
|
||||
|
||||
}
|
||||
public function set_up() {
|
||||
if ( ! class_exists( '\Friends\Friends' ) ) {
|
||||
return $this->markTestSkipped( 'The Friends plugin is not loaded.' );
|
||||
}
|
||||
parent::set_up();
|
||||
|
||||
// Manually activate the REST server.
|
||||
global $wp_rest_server;
|
||||
$wp_rest_server = new \Spy_REST_Server();
|
||||
$this->server = $wp_rest_server;
|
||||
do_action( 'rest_api_init' );
|
||||
|
||||
add_filter(
|
||||
'rest_url',
|
||||
function() {
|
||||
return get_option( 'home' ) . '/wp-json/';
|
||||
}
|
||||
);
|
||||
|
||||
add_filter( 'pre_http_request', array( get_called_class(), 'pre_http_request' ), 10, 3 );
|
||||
add_filter( 'http_response', array( get_called_class(), 'http_response' ), 10, 3 );
|
||||
add_filter( 'http_request_host_is_external', array( get_called_class(), 'http_request_host_is_external' ), 10, 2 );
|
||||
add_filter( 'http_request_args', array( get_called_class(), 'http_request_args' ), 10, 2 );
|
||||
add_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ), 10, 2 );
|
||||
|
||||
$user_id = $this->factory->user->create(
|
||||
array(
|
||||
'role' => 'administrator',
|
||||
)
|
||||
);
|
||||
wp_set_current_user( $user_id );
|
||||
|
||||
$this->friend_name = 'Alex Kirk';
|
||||
$this->actor = 'https://mastodon.local/users/akirk';
|
||||
|
||||
$user_feed = \Friends\User_Feed::get_by_url( $this->actor );
|
||||
if ( is_wp_error( $user_feed ) ) {
|
||||
$this->friend_id = $this->factory->user->create(
|
||||
array(
|
||||
'display_name' => $this->friend_name,
|
||||
'role' => 'friend',
|
||||
)
|
||||
);
|
||||
\Friends\User_Feed::save(
|
||||
new \Friends\User( $this->friend_id ),
|
||||
$this->actor,
|
||||
array(
|
||||
'parser' => 'activitypub',
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$this->friend_id = $user_feed->get_friend_user()->ID;
|
||||
}
|
||||
|
||||
self::$users[ $this->actor ] = array(
|
||||
'url' => $this->actor,
|
||||
'name' => $this->friend_name,
|
||||
);
|
||||
self::$users['https://mastodon.local/@akirk'] = self::$users[ $this->actor ];
|
||||
|
||||
_delete_all_posts();
|
||||
}
|
||||
|
||||
public function tear_down() {
|
||||
remove_filter( 'pre_http_request', array( get_called_class(), 'pre_http_request' ) );
|
||||
remove_filter( 'http_response', array( get_called_class(), 'http_response' ) );
|
||||
remove_filter( 'http_request_host_is_external', array( get_called_class(), 'http_request_host_is_external' ) );
|
||||
remove_filter( 'http_request_args', array( get_called_class(), 'http_request_args' ) );
|
||||
remove_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ) );
|
||||
}
|
||||
|
||||
public static function pre_get_remote_metadata_by_actor( $pre, $actor ) {
|
||||
if ( isset( self::$users[ $actor ] ) ) {
|
||||
return self::$users[ $actor ];
|
||||
}
|
||||
return $pre;
|
||||
}
|
||||
public static function http_request_host_is_external( $in, $host ) {
|
||||
if ( in_array( $host, array( 'mastodon.local' ), true ) ) {
|
||||
return true;
|
||||
}
|
||||
return $in;
|
||||
}
|
||||
public static function http_request_args( $args, $url ) {
|
||||
if ( in_array( parse_url( $url, PHP_URL_HOST ), array( 'mastodon.local' ), true ) ) {
|
||||
$args['reject_unsafe_urls'] = false;
|
||||
}
|
||||
return $args;
|
||||
}
|
||||
public static function pre_http_request( $preempt, $request, $url ) {
|
||||
$p = wp_parse_url( $url );
|
||||
$cache = __DIR__ . '/fixtures/' . sanitize_title( $p['host'] . '-' . $p['path'] ) . '.response';
|
||||
if ( file_exists( $cache ) ) {
|
||||
return apply_filters(
|
||||
'fake_http_response',
|
||||
unserialize( file_get_contents( $cache ) ),
|
||||
$p['scheme'] . '://' . $p['host'],
|
||||
$url,
|
||||
$request
|
||||
);
|
||||
}
|
||||
|
||||
$home_url = home_url();
|
||||
|
||||
// Pretend the url now is the requested one.
|
||||
update_option( 'home', $p['scheme'] . '://' . $p['host'] );
|
||||
$rest_prefix = home_url() . '/wp-json';
|
||||
|
||||
if ( false === strpos( $url, $rest_prefix ) ) {
|
||||
// Restore the old home_url.
|
||||
update_option( 'home', $home_url );
|
||||
return $preempt;
|
||||
}
|
||||
|
||||
$url = substr( $url, strlen( $rest_prefix ) );
|
||||
$r = new \WP_REST_Request( $request['method'], $url );
|
||||
if ( ! empty( $request['body'] ) ) {
|
||||
foreach ( $request['body'] as $key => $value ) {
|
||||
$r->set_param( $key, $value );
|
||||
}
|
||||
}
|
||||
global $wp_rest_server;
|
||||
$response = $wp_rest_server->dispatch( $r );
|
||||
// Restore the old url.
|
||||
update_option( 'home', $home_url );
|
||||
|
||||
return apply_filters(
|
||||
'fake_http_response',
|
||||
array(
|
||||
'headers' => array(
|
||||
'content-type' => 'text/json',
|
||||
),
|
||||
'body' => wp_json_encode( $response->data ),
|
||||
'response' => array(
|
||||
'code' => $response->status,
|
||||
),
|
||||
),
|
||||
$p['scheme'] . '://' . $p['host'],
|
||||
$url,
|
||||
$request
|
||||
);
|
||||
}
|
||||
|
||||
public static function http_response( $response, $args, $url ) {
|
||||
$p = wp_parse_url( $url );
|
||||
$cache = __DIR__ . '/fixtures/' . sanitize_title( $p['host'] . '-' . $p['path'] ) . '.response';
|
||||
if ( ! file_exists( $cache ) ) {
|
||||
$headers = wp_remote_retrieve_headers( $response );
|
||||
file_put_contents(
|
||||
$cache,
|
||||
serialize(
|
||||
array(
|
||||
'headers' => $headers->getAll(),
|
||||
'body' => wp_remote_retrieve_body( $response ),
|
||||
'response' => array(
|
||||
'code' => wp_remote_retrieve_response_code( $response ),
|
||||
),
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue