updated plugin ActivityPub version 2.0.1

This commit is contained in:
2024-02-08 12:31:25 +00:00
committed by Gitium
parent 6e1c54f7ba
commit 50bf15833c
58 changed files with 1772 additions and 543 deletions

View File

@ -20,13 +20,13 @@ class Activity extends Base_Object {
const CONTEXT = array(
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
'https://purl.archive.org/socialweb/webfinger',
array(
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
'PropertyValue' => 'schema:PropertyValue',
'schema' => 'http://schema.org#',
'pt' => 'https://joinpeertube.org/ns#',
'toot' => 'http://joinmastodon.org/ns#',
'webfinger' => 'https://webfinger.net/#',
'litepub' => 'http://litepub.social/ns#',
'lemmy' => 'https://join-lemmy.org/ns#',
'value' => 'schema:value',
@ -51,7 +51,6 @@ class Activity extends Base_Object {
'discoverable' => 'toot:discoverable',
'indexable' => 'toot:indexable',
'sensitive' => 'as:sensitive',
'resource' => 'webfinger:resource',
),
);
@ -196,7 +195,7 @@ class Activity extends Base_Object {
public function set_object( $object ) {
// convert array to object
if ( is_array( $object ) ) {
$object = Base_Object::init_from_array( $object );
$object = self::init_from_array( $object );
}
// set object

View File

@ -254,19 +254,6 @@ class Base_Object {
*/
protected $published;
/**
* A Collection containing objects considered to be responses to
* this object.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies
*
* @var string
* | Collection
* | Link
* | null
*/
protected $replies;
/**
* The date and time describing the actual or expected starting time
* of the object.
@ -437,6 +424,19 @@ class Base_Object {
*/
protected $source;
/**
* A Collection containing objects considered to be responses to
* this object.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies
*
* @var string
* | Collection
* | Link
* | null
*/
protected $replies;
/**
* Magic function to implement getter and setter
*
@ -671,8 +671,25 @@ class Base_Object {
* @return string The JSON string.
*/
public function to_json() {
$array = $this->to_array();
$array = $this->to_array();
$options = \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT;
return \wp_json_encode( $array, \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT );
/*
* Options to be passed to json_encode()
*
* @param int $options The current options flags
*/
$options = \apply_filters( 'activitypub_json_encode_options', $options );
return \wp_json_encode( $array, $options );
}
/**
* Returns the keys of the object vars.
*
* @return array The keys of the object vars.
*/
public function get_object_var_keys() {
return \array_keys( \get_object_vars( $this ) );
}
}

View File

@ -2,10 +2,13 @@
namespace Activitypub;
use WP_Post;
use WP_Comment;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users;
use Activitypub\Collection\Followers;
use Activitypub\Transformer\Factory;
use Activitypub\Transformer\Post;
use Activitypub\Transformer\Comment;
use function Activitypub\is_single_user;
use function Activitypub\is_user_disabled;
@ -25,17 +28,18 @@ class Activity_Dispatcher {
public static function init() {
\add_action( 'activitypub_send_activity', array( self::class, 'send_activity' ), 10, 2 );
\add_action( 'activitypub_send_activity', array( self::class, 'send_activity_or_announce' ), 10, 2 );
\add_action( 'activitypub_send_update_profile_activity', array( self::class, 'send_profile_update' ), 10, 1 );
}
/**
* Send Activities to followers and mentioned users or `Announce` (boost) a blog post.
*
* @param WP_Post $wp_post The ActivityPub Post.
* @param string $type The Activity-Type.
* @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_activity_or_announce( WP_Post $wp_post, $type ) {
public static function send_activity_or_announce( $wp_object, $type ) {
// check if a migration is needed before sending new posts
Migration::maybe_migrate();
@ -43,56 +47,48 @@ class Activity_Dispatcher {
return;
}
$wp_post->post_author = Users::BLOG_USER_ID;
if ( is_single_user() ) {
self::send_activity( $wp_post, $type );
self::send_activity( $wp_object, $type, Users::BLOG_USER_ID );
} else {
self::send_announce( $wp_post, $type );
self::send_announce( $wp_object, $type );
}
}
/**
* Send Activities to followers and mentioned users.
*
* @param WP_Post $wp_post The ActivityPub Post.
* @param string $type The Activity-Type.
* @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_activity( WP_Post $wp_post, $type ) {
if ( is_user_disabled( $wp_post->post_author ) ) {
public static function send_activity( $wp_object, $type, $user_id = null ) {
$transformer = Factory::get_transformer( $wp_object );
if ( null !== $user_id ) {
$transformer->change_wp_user_id( $user_id );
}
$user_id = $transformer->get_wp_user_id();
if ( is_user_disabled( $user_id ) ) {
return;
}
$object = Post::transform( $wp_post )->to_object();
$activity = $transformer->to_activity( $type );
$activity = new Activity();
$activity->set_type( $type );
$activity->set_object( $object );
$follower_inboxes = Followers::get_inboxes( $wp_post->post_author );
$mentioned_inboxes = Mention::get_inboxes( $activity->get_cc() );
$inboxes = array_merge( $follower_inboxes, $mentioned_inboxes );
$inboxes = array_unique( $inboxes );
$json = $activity->to_json();
foreach ( $inboxes as $inbox ) {
safe_remote_post( $inbox, $json, $wp_post->post_author );
}
self::send_activity_to_inboxes( $activity, $user_id );
}
/**
* Send Announces to followers and mentioned users.
*
* @param WP_Post $wp_post The ActivityPub Post.
* @param string $type The Activity-Type.
* @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_announce( WP_Post $wp_post, $type ) {
public static function send_announce( $wp_object, $type ) {
if ( ! in_array( $type, array( 'Create', 'Update' ), true ) ) {
return;
}
@ -101,25 +97,70 @@ class Activity_Dispatcher {
return;
}
$object = Post::transform( $wp_post )->to_object();
$transformer = Factory::get_transformer( $wp_object );
$transformer->change_wp_user_id( Users::BLOG_USER_ID );
$user_id = $transformer->get_wp_user_id();
$activity = $transformer->to_activity( 'Announce' );
self::send_activity_to_inboxes( $activity, $user_id );
}
/**
* Send a "Update" Activity when a user updates their profile.
*
* @param int $user_id The user ID to send an update for.
*
* @return void
*/
public static function send_profile_update( $user_id ) {
$user = Users::get_by_various( $user_id );
// bail if that's not a good user
if ( is_wp_error( $user ) ) {
return;
}
// build the update
$activity = new Activity();
$activity->set_type( 'Announce' );
// to pre-fill attributes like "published" and "id"
$activity->set_object( $object );
// send only the id
$activity->set_object( $object->get_id() );
$activity->set_id( $user->get_url() . '#update' );
$activity->set_type( 'Update' );
$activity->set_actor( $user->get_url() );
$activity->set_object( $user->get_url() );
$activity->set_to( 'https://www.w3.org/ns/activitystreams#Public' );
$follower_inboxes = Followers::get_inboxes( $wp_post->post_author );
$mentioned_inboxes = Mention::get_inboxes( $activity->get_cc() );
// send the update
self::send_activity_to_inboxes( $activity, $user_id );
}
/**
* Send an Activity to all followers and mentioned users.
*
* @param Activity $activity The ActivityPub Activity.
* @param int $user_id The user ID.
*
* @return void
*/
private static function send_activity_to_inboxes( $activity, $user_id ) {
$follower_inboxes = Followers::get_inboxes( $user_id );
$mentioned_inboxes = array();
$cc = $activity->get_cc();
if ( $cc ) {
$mentioned_inboxes = Mention::get_inboxes( $cc );
}
$inboxes = array_merge( $follower_inboxes, $mentioned_inboxes );
$inboxes = array_unique( $inboxes );
if ( empty( $inboxes ) ) {
return;
}
$json = $activity->to_json();
foreach ( $inboxes as $inbox ) {
safe_remote_post( $inbox, $json, $wp_post->post_author );
safe_remote_post( $inbox, $json, $user_id );
}
}
}

View File

@ -7,6 +7,8 @@ use Activitypub\Collection\Users;
use Activitypub\Collection\Followers;
use function Activitypub\sanitize_url;
use function Activitypub\is_comment;
use function Activitypub\is_activitypub_request;
/**
* ActivityPub Class
@ -19,6 +21,7 @@ class Activitypub {
*/
public static function init() {
\add_filter( 'template_include', array( self::class, 'render_json_template' ), 99 );
\add_action( 'template_redirect', array( self::class, 'template_redirect' ) );
\add_filter( 'query_vars', array( self::class, 'add_query_vars' ) );
\add_filter( 'pre_get_avatar_data', array( self::class, 'pre_get_avatar_data' ), 11, 2 );
\add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 3 );
@ -39,6 +42,8 @@ class Activitypub {
\add_action( 'in_plugin_update_message-' . ACTIVITYPUB_PLUGIN_BASENAME, array( self::class, 'plugin_update_message' ) );
\add_filter( 'comment_class', array( self::class, 'comment_class' ), 10, 3 );
// register several post_types
self::register_post_types();
}
@ -50,7 +55,6 @@ class Activitypub {
*/
public static function activate() {
self::flush_rewrite_rules();
Scheduler::register_schedules();
}
@ -98,6 +102,8 @@ class Activitypub {
if ( \is_author() ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/author-json.php';
} elseif ( is_comment() ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/comment-json.php';
} elseif ( \is_singular() ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/post-json.php';
} elseif ( \is_home() ) {
@ -115,11 +121,44 @@ class Activitypub {
return $json_template;
}
/**
* Custom redirects for ActivityPub requests.
*
* @return void
*/
public static function template_redirect() {
$comment_id = get_query_var( 'c', null );
// check if it seems to be a comment
if ( ! $comment_id ) {
return;
}
$comment = get_comment( $comment_id );
// load a 404 page if `c` is set but not valid
if ( ! $comment ) {
global $wp_query;
$wp_query->set_404();
return;
}
// stop if it's not an ActivityPub comment
if ( is_activitypub_request() && $comment->user_id ) {
return;
}
wp_safe_redirect( get_comment_link( $comment ) );
exit;
}
/**
* Add the 'activitypub' query variable so WordPress won't mangle it.
*/
public static function add_query_vars( $vars ) {
$vars[] = 'activitypub';
$vars[] = 'c';
$vars[] = 'p';
return $vars;
}
@ -195,10 +234,18 @@ class Activitypub {
* @return string $url
*/
public static function remote_comment_link( $comment_link, $comment ) {
$remote_comment_link = get_comment_meta( $comment->comment_ID, 'source_url', true );
if ( $remote_comment_link ) {
$comment_link = esc_url( $remote_comment_link );
if ( ! $comment || is_admin() ) {
return $comment_link;
}
$comment_meta = \get_comment_meta( $comment->comment_ID );
if ( ! empty( $comment_meta['source_url'][0] ) ) {
return $comment_meta['source_url'][0];
} elseif ( ! empty( $comment_meta['source_id'][0] ) ) {
return $comment_meta['source_id'][0];
}
return $comment_link;
}
@ -341,7 +388,7 @@ class Activitypub {
* @return void
*/
private static function register_post_types() {
register_post_type(
\register_post_type(
Followers::POST_TYPE,
array(
'labels' => array(
@ -358,7 +405,7 @@ class Activitypub {
)
);
register_post_meta(
\register_post_meta(
Followers::POST_TYPE,
'activitypub_inbox',
array(
@ -368,13 +415,13 @@ class Activitypub {
)
);
register_post_meta(
\register_post_meta(
Followers::POST_TYPE,
'activitypub_errors',
array(
'type' => 'string',
'single' => false,
'sanitize_callback' => function( $value ) {
'sanitize_callback' => function ( $value ) {
if ( ! is_string( $value ) ) {
throw new Exception( 'Error message is no valid string' );
}
@ -384,30 +431,48 @@ class Activitypub {
)
);
register_post_meta(
\register_post_meta(
Followers::POST_TYPE,
'activitypub_user_id',
array(
'type' => 'string',
'single' => false,
'sanitize_callback' => function( $value ) {
'sanitize_callback' => function ( $value ) {
return esc_sql( $value );
},
)
);
register_post_meta(
\register_post_meta(
Followers::POST_TYPE,
'activitypub_actor_json',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => function( $value ) {
'sanitize_callback' => function ( $value ) {
return sanitize_text_field( $value );
},
)
);
do_action( 'activitypub_after_register_post_type' );
\do_action( 'activitypub_after_register_post_type' );
}
/**
* Filters the CSS classes to add an ActivityPub class.
*
* @param string[] $classes An array of comment classes.
* @param string[] $css_class An array of additional classes added to the list.
* @param string $comment_id The comment ID as a numeric string.
*
* @return string[] An array of classes.
*/
public static function comment_class( $classes, $css_class, $comment_id ) {
// check if ActivityPub comment
if ( 'activitypub' === get_comment_meta( $comment_id, 'protocol', true ) ) {
$classes[] = 'activitypub-comment';
}
return $classes;
}
}

View File

@ -18,6 +18,7 @@ class Admin {
\add_action( 'admin_init', array( self::class, 'register_settings' ) );
\add_action( 'personal_options_update', array( self::class, 'save_user_description' ) );
\add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_scripts' ) );
\add_action( 'admin_notices', array( self::class, 'admin_notices' ) );
if ( ! is_user_disabled( get_current_user_id() ) ) {
\add_action( 'show_user_profile', array( self::class, 'add_profile' ) );
@ -46,6 +47,37 @@ class Admin {
}
}
/**
* Display admin menu notices about configuration problems or conflicts.
*
* @return void
*/
public static function admin_notices() {
$permalink_structure = \get_option( 'permalink_structure' );
if ( empty( $permalink_structure ) ) {
$admin_notice = \__( 'You are using the ActivityPub plugin without setting a permalink structure. This will prevent ActivityPub from working. Please set a permalink structure.', 'activitypub' );
self::show_admin_notice( $admin_notice, 'error' );
}
}
/**
* Display one admin menu notice about configuration problems or conflicts.
*
* @param string $admin_notice The notice to display.
* @param string $level The level of the notice (error, warning, success, info).
*
* @return void
*/
private static function show_admin_notice( $admin_notice, $level ) {
?>
<div class="notice notice-<?php echo esc_attr( $level ); ?>">
<p><?php echo wp_kses( $admin_notice, 'data' ); ?></p>
</div>
<?php
}
/**
* Load settings page
*/
@ -172,7 +204,7 @@ class Admin {
'description' => \esc_html__( 'The Identifier of the Blog-User', 'activitypub' ),
'show_in_rest' => true,
'default' => Blog_User::get_default_username(),
'sanitize_callback' => function( $value ) {
'sanitize_callback' => function ( $value ) {
// hack to allow dots in the username
$parts = explode( '.', $value );
$sanitized = array();

View File

@ -3,7 +3,9 @@ namespace Activitypub;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Users as User_Collection;
use Activitypub\is_user_type_disabled;
use function Activitypub\object_to_uri;
use function Activitypub\is_user_type_disabled;
class Blocks {
public static function init() {
@ -73,7 +75,7 @@ class Blocks {
if ( ! is_wp_error( $user ) ) {
$attrs['profileData'] = self::filter_array_by_keys(
$user->to_array(),
array( 'icon', 'name', 'resource' )
array( 'icon', 'name', 'webfinger' )
);
}
$wrapper_attributes = get_block_wrapper_attributes(
@ -94,7 +96,7 @@ class Blocks {
$attrs['followerData']['total'] = $follower_data['total'];
$attrs['followerData']['followers'] = array_map(
function( $follower ) {
function ( $follower ) {
return self::filter_array_by_keys(
$follower->to_array(),
array( 'icon', 'name', 'preferredUsername', 'url' )
@ -140,7 +142,7 @@ class Blocks {
return sprintf(
$template,
esc_url( $data['url'] ),
esc_url( object_to_uri( $data['url'] ) ),
esc_attr( $data['name'] ),
esc_attr( $data['icon']['url'] ),
esc_html( $data['name'] ),

View File

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

View File

@ -39,11 +39,6 @@ class Health_Check {
'test' => array( self::class, 'test_webfinger' ),
);
$tests['direct']['activitypub_test_system_cron'] = array(
'label' => __( 'System Cron Test', 'activitypub' ),
'test' => array( self::class, 'test_system_cron' ),
);
return $tests;
}
@ -257,17 +252,10 @@ class Health_Check {
* @return boolean|WP_Error
*/
public static function is_webfinger_endpoint_accessible() {
$user = \wp_get_current_user();
$user = Users::get_by_id( Users::APPLICATION_USER_ID );
$resource = $user->get_webfinger();
if ( ! is_user_type_disabled( 'blog' ) ) {
$account = get_webfinger_resource( $user->ID );
} elseif ( ! is_user_type_disabled( 'user' ) ) {
$account = get_webfinger_resource( Users::BLOG_USER_ID );
} else {
$account = '';
}
$url = Webfinger::resolve( $account );
$url = Webfinger::resolve( $resource );
if ( \is_wp_error( $url ) ) {
$allowed = array( 'code' => array() );
$not_accessible = wp_kses(

View File

@ -14,7 +14,8 @@ class Mention {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_filter( 'the_content', array( self::class, 'the_content' ), 99, 2 );
\add_filter( 'the_content', array( self::class, 'the_content' ), 99, 1 );
\add_filter( 'comment_text', array( self::class, 'the_content' ), 10, 1 );
\add_filter( 'activitypub_extract_mentions', array( self::class, 'extract_mentions' ), 99, 2 );
}
@ -100,7 +101,14 @@ class Mention {
if ( ! empty( $metadata['preferredUsername'] ) ) {
$username = $metadata['preferredUsername'];
}
return \sprintf( '<a rel="mention" class="u-url mention" href="%s">@<span>%s</span></a>', esc_url( $metadata['url'] ), esc_html( $username ) );
$url = isset( $metadata['url'] ) ? $metadata['url'] : $metadata['url'];
if ( \is_array( $url ) ) {
$url = $url[0];
}
return \sprintf( '<a rel="mention" class="u-url mention" href="%s">@<span>%s</span></a>', esc_url( $url ), esc_html( $username ) );
}
return $result[0];

View File

@ -6,22 +6,77 @@ use Activitypub\Collection\Users;
use Activitypub\Collection\Followers;
use Activitypub\Transformer\Post;
use function Activitypub\is_user_type_disabled;
/**
* ActivityPub Scheduler Class
*
* @author Matthias Pfefferle
*/
class Scheduler {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
// Post transitions
\add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 );
\add_action(
'edit_attachment',
function ( $post_id ) {
self::schedule_post_activity( 'publish', 'publish', $post_id );
}
);
\add_action(
'add_attachment',
function ( $post_id ) {
self::schedule_post_activity( 'publish', '', $post_id );
}
);
\add_action(
'delete_attachment',
function ( $post_id ) {
self::schedule_post_activity( 'trash', '', $post_id );
}
);
// Comment transitions
\add_action( 'transition_comment_status', array( self::class, 'schedule_comment_activity' ), 20, 3 );
\add_action(
'edit_comment',
function ( $comment_id ) {
self::schedule_comment_activity( 'approved', 'approved', $comment_id );
}
);
\add_action(
'wp_insert_comment',
function ( $comment_id ) {
self::schedule_comment_activity( 'approved', '', $comment_id );
}
);
// Follower Cleanups
\add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) );
\add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) );
// Migration
\add_action( 'admin_init', array( self::class, 'schedule_migration' ) );
// profile updates for blog options
if ( ! is_user_type_disabled( 'blog' ) ) {
\add_action( 'update_option_site_icon', array( self::class, 'blog_user_update' ) );
\add_action( 'update_option_blogdescription', array( self::class, 'blog_user_update' ) );
\add_action( 'update_option_blogname', array( self::class, 'blog_user_update' ) );
\add_filter( 'pre_set_theme_mod_custom_logo', array( self::class, 'blog_user_update' ) );
\add_filter( 'pre_set_theme_mod_header_image', array( self::class, 'blog_user_update' ) );
}
// profile updates for user options
if ( ! is_user_type_disabled( 'user' ) ) {
\add_action( 'wp_update_user', array( self::class, 'user_update' ) );
\add_action( 'updated_user_meta', array( self::class, 'user_meta_update' ), 10, 3 );
// @todo figure out a feasible way of updating the header image since it's not unique to any user.
}
}
/**
@ -58,6 +113,8 @@ class Scheduler {
* @param WP_Post $post Post object.
*/
public static function schedule_post_activity( $new_status, $old_status, $post ) {
$post = get_post( $post );
// Do not send activities if post is password protected.
if ( \post_password_required( $post ) ) {
return;
@ -99,6 +156,58 @@ class Scheduler {
);
}
/**
* Schedule Comment Activities
*
* transition_comment_status()
*
* @param string $new_status New comment status.
* @param string $old_status Old comment status.
* @param WP_Comment $comment Comment object.
*/
public static function schedule_comment_activity( $new_status, $old_status, $comment ) {
$comment = get_comment( $comment );
// Federate only approved comments.
if ( ! $comment->user_id ) {
return;
}
if (
'approved' === $new_status &&
'approved' !== $old_status
) {
$type = 'Create';
} elseif ( 'approved' === $new_status ) {
$type = 'Update';
\update_comment_meta( $comment->comment_ID, 'activitypub_comment_modified', time(), true );
} elseif (
'trash' === $new_status ||
'spam' === $new_status
) {
$type = 'Delete';
}
if ( ! $type ) {
return;
}
\wp_schedule_single_event(
\time(),
'activitypub_send_activity',
array( $comment, $type )
);
\wp_schedule_single_event(
\time(),
sprintf(
'activitypub_send_%s_activity',
\strtolower( $type )
),
array( $comment )
);
}
/**
* Update followers
*
@ -166,4 +275,68 @@ class Scheduler {
\wp_schedule_single_event( \time(), 'activitypub_schedule_migration' );
}
}
/**
* Send a profile update when relevant user meta is updated.
*
* @param int $meta_id Meta ID being updated.
* @param int $user_id User ID being updated.
* @param string $meta_key Meta key being updated.
*
* @return void
*/
public static function user_meta_update( $meta_id, $user_id, $meta_key ) {
// don't bother if the user can't publish
if ( ! \user_can( $user_id, 'publish_posts' ) ) {
return;
}
// the user meta fields that affect a profile.
$fields = array(
'activitypub_user_description',
'description',
'user_url',
'display_name',
);
if ( in_array( $meta_key, $fields, true ) ) {
self::schedule_profile_update( $user_id );
}
}
/**
* Send a profile update when a user is updated.
*
* @param int $user_id User ID being updated.
*
* @return void
*/
public static function user_update( $user_id ) {
// don't bother if the user can't publish
if ( ! \user_can( $user_id, 'publish_posts' ) ) {
return;
}
self::schedule_profile_update( $user_id );
}
/**
* Theme mods only have a dynamic filter so we fudge it like this.
* @param mixed $value
* @return mixed
*/
public static function blog_user_update( $value = null ) {
self::schedule_profile_update( 0 );
return $value;
}
/**
* Send a profile update to all followers. Gets hooked into all relevant options/meta etc.
* @param int $user_id The user ID to update (Could be 0 for Blog-User).
*/
public static function schedule_profile_update( $user_id ) {
\wp_schedule_single_event(
\time(),
'activitypub_send_update_profile_activity',
array( $user_id )
);
}
}

View File

@ -110,8 +110,13 @@ class Shortcodes {
$excerpt = \get_post_field( 'post_excerpt', $item );
if ( '' === $excerpt ) {
if ( 'attachment' === $item->post_type ) {
// get title of attachment with fallback to alt text.
$content = wp_get_attachment_caption( $item->ID );
if ( empty( $content ) ) {
$content = get_post_meta( $item->ID, '_wp_attachment_image_alt', true );
}
} elseif ( '' === $excerpt ) {
$content = \get_post_field( 'post_content', $item );
// An empty string will make wp_trim_excerpt do stuff we do not want.
@ -127,8 +132,7 @@ class Shortcodes {
// Strip out any remaining tags.
$excerpt = \wp_strip_all_tags( $excerpt );
/** This filter is documented in wp-includes/formatting.php */
$excerpt_more = \apply_filters( 'excerpt_more', ' [...]' );
$excerpt_more = \apply_filters( 'activitypub_excerpt_more', ' [&hellip;]' );
$excerpt_more_len = strlen( $excerpt_more );
// We now have a excerpt, but we need to check it's length, it may be longer than we want for two reasons:
@ -208,20 +212,30 @@ class Shortcodes {
$tag
);
$content = \get_post_field( 'post_content', $item );
$content = '';
if ( 'yes' === $atts['apply_filters'] ) {
$content = \apply_filters( 'the_content', $content );
if ( 'attachment' === $item->post_type ) {
// get title of attachment with fallback to alt text.
$content = wp_get_attachment_caption( $item->ID );
if ( empty( $content ) ) {
$content = get_post_meta( $item->ID, '_wp_attachment_image_alt', true );
}
} else {
$content = do_blocks( $content );
$content = wptexturize( $content );
$content = wp_filter_content_tags( $content );
}
$content = \get_post_field( 'post_content', $item );
// replace script and style elements
$content = \preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $content );
$content = strip_shortcodes( $content );
$content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) );
if ( 'yes' === $atts['apply_filters'] ) {
$content = \apply_filters( 'the_content', $content );
} else {
$content = do_blocks( $content );
$content = wptexturize( $content );
$content = wp_filter_content_tags( $content );
}
// replace script and style elements
$content = \preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $content );
$content = strip_shortcodes( $content );
$content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) );
}
add_shortcode( 'ap_content', array( 'Activitypub\Shortcodes', 'content' ) );

View File

@ -292,7 +292,8 @@ class Signature {
if ( is_array( $headers['digest'] ) ) {
$headers['digest'] = $headers['digest'][0];
}
$digest = explode( '=', $headers['digest'], 2 );
$hashalg = 'sha256';
$digest = explode( '=', $headers['digest'], 2 );
if ( 'SHA-256' === $digest[0] ) {
$hashalg = 'sha256';
}

View File

@ -15,89 +15,81 @@ class Webfinger {
/**
* Returns a users WebFinger "resource"
*
* @param int $user_id
* @param int $user_id The WordPress user id
*
* @return string The user-resource
*/
public static function get_user_resource( $user_id ) {
// use WebFinger plugin if installed
if ( \function_exists( '\get_webfinger_resource' ) ) {
return \get_webfinger_resource( $user_id, false );
}
$user = Users::get_by_id( $user_id );
if ( ! $user || is_wp_error( $user ) ) {
return '';
}
return $user->get_resource();
return $user->get_webfinger();
}
/**
* Resolve a WebFinger resource
*
* @param string $resource The WebFinger resource
* @param string $uri The WebFinger Resource
*
* @return string|WP_Error The URL or WP_Error
*/
public static function resolve( $resource ) {
if ( ! $resource ) {
return null;
public static function resolve( $uri ) {
$data = self::get_data( $uri );
if ( \is_wp_error( $data ) ) {
return $data;
}
if ( ! preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $resource, $m ) ) {
return null;
}
$transient_key = 'activitypub_resolve_' . ltrim( $resource, '@' );
$link = \get_transient( $transient_key );
if ( $link ) {
return $link;
}
$url = \add_query_arg( 'resource', 'acct:' . ltrim( $resource, '@' ), 'https://' . $m[2] . '/.well-known/webfinger' );
if ( ! \wp_http_validate_url( $url ) ) {
$response = new WP_Error( 'invalid_webfinger_url', null, $url );
\set_transient( $transient_key, $response, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
return $response;
}
// try to access author URL
$response = \wp_remote_get(
$url,
array(
'headers' => array( 'Accept' => 'application/jrd+json' ),
'redirection' => 2,
'timeout' => 2,
)
);
if ( \is_wp_error( $response ) ) {
$link = new WP_Error( 'webfinger_url_not_accessible', null, $url );
\set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
return $link;
}
$body = \wp_remote_retrieve_body( $response );
$body = \json_decode( $body, true );
if ( empty( $body['links'] ) ) {
$link = new WP_Error( 'webfinger_url_invalid_response', null, $url );
\set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
return $link;
}
foreach ( $body['links'] as $link ) {
if ( 'self' === $link['rel'] && 'application/activity+json' === $link['type'] ) {
\set_transient( $transient_key, $link['href'], WEEK_IN_SECONDS );
foreach ( $data['links'] as $link ) {
if (
'self' === $link['rel'] &&
'application/activity+json' === $link['type']
) {
return $link['href'];
}
}
$link = new WP_Error( 'webfinger_url_no_activitypub', null, $body );
\set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
return $link;
return new WP_Error( 'webfinger_url_no_activitypub', null, $data );
}
/**
* Transform a URI to an acct <identifier>@<host>
*
* @param string $uri The URI (acct:, mailto:, http:, https:)
*
* @return string|WP_Error Error or acct URI
*/
public static function uri_to_acct( $uri ) {
$data = self::get_data( $uri );
if ( is_wp_error( $data ) ) {
return $data;
}
// check if subject is an acct URI
if (
isset( $data['subject'] ) &&
\str_starts_with( $data['subject'], 'acct:' )
) {
return $data['subject'];
}
// search for an acct URI in the aliases
if ( isset( $data['aliases'] ) ) {
foreach ( $data['aliases'] as $alias ) {
if ( \str_starts_with( $alias, 'acct:' ) ) {
return $alias;
}
}
}
return new WP_Error(
'webfinger_url_no_acct',
__( 'No acct URI found.', 'activitypub' ),
$data
);
}
/**
@ -137,7 +129,7 @@ class Webfinger {
}
if ( empty( $host ) ) {
return new WP_Error( 'invalid_identifier', __( 'Invalid Identifier', 'activitypub' ) );
return new WP_Error( 'webfinger_invalid_identifier', __( 'Invalid Identifier', 'activitypub' ) );
}
return array( $identifier, $host );
@ -146,55 +138,70 @@ class Webfinger {
/**
* Get the WebFinger data for a given URI
*
* @param string $identifier The Identifier: <identifier>@<host>
* @param string $host The Host: <identifier>@<host>
* @param string $uri The Identifier: <identifier>@<host> or URI
*
* @return WP_Error|array Error reaction or array with
* identifier and host as values
*/
public static function get_data( $identifier, $host ) {
$webfinger_url = 'https://' . $host . '/.well-known/webfinger?resource=' . rawurlencode( $identifier );
$response = wp_safe_remote_get(
$webfinger_url,
array(
'headers' => array( 'Accept' => 'application/jrd+json' ),
'redirection' => 0,
'timeout' => 2,
)
);
if ( is_wp_error( $response ) ) {
return new WP_Error( 'webfinger_url_not_accessible', null, $webfinger_url );
}
$body = wp_remote_retrieve_body( $response );
return json_decode( $body, true );
}
/**
* Undocumented function
*
* @return void
*/
public static function get_remote_follow_endpoint( $uri ) {
public static function get_data( $uri ) {
$identifier_and_host = self::get_identifier_and_host( $uri );
if ( is_wp_error( $identifier_and_host ) ) {
return $identifier_and_host;
}
$transient_key = self::generate_cache_key( $uri );
list( $identifier, $host ) = $identifier_and_host;
$data = self::get_data( $identifier, $host );
$data = \get_transient( $transient_key );
if ( $data ) {
return $data;
}
$webfinger_url = 'https://' . $host . '/.well-known/webfinger?resource=' . rawurlencode( $identifier );
$response = wp_safe_remote_get(
$webfinger_url,
array(
'headers' => array( 'Accept' => 'application/jrd+json' ),
)
);
if ( is_wp_error( $response ) ) {
return new WP_Error(
'webfinger_url_not_accessible',
__( 'The WebFinger Resource is not accessible.', 'activitypub' ),
$webfinger_url
);
}
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
\set_transient( $transient_key, $data, WEEK_IN_SECONDS );
return $data;
}
/**
* Get the Remote-Follow endpoint for a given URI
*
* @return string|WP_Error Error or the Remote-Follow endpoint URI.
*/
public static function get_remote_follow_endpoint( $uri ) {
$data = self::get_data( $uri );
if ( is_wp_error( $data ) ) {
return $data;
}
if ( empty( $data['links'] ) ) {
return new WP_Error( 'webfinger_url_invalid_response', null, $data );
return new WP_Error(
'webfinger_missing_links',
__( 'No valid Link elements found.', 'activitypub' ),
$data
);
}
foreach ( $data['links'] as $link ) {
@ -203,6 +210,27 @@ class Webfinger {
}
}
return new WP_Error( 'webfinger_remote_follow_endpoint_invalid', $data, array( 'status' => 417 ) );
return new WP_Error(
'webfinger_missing_remote_follow_endpoint',
__( 'No valid Remote-Follow endpoint found.', 'activitypub' ),
$data
);
}
/**
* Generate a cache key for a given URI
*
* @param string $uri A WebFinger Resource URI
*
* @return string The cache key
*/
public static function generate_cache_key( $uri ) {
$uri = ltrim( $uri, '@' );
if ( filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) {
$uri = 'acct:' . $uri;
}
return 'webfinger_' . md5( $uri );
}
}

View File

@ -182,7 +182,7 @@ class Followers {
$query = new WP_Query( $args );
$total = $query->found_posts;
$followers = array_map(
function( $post ) {
function ( $post ) {
return Follower::init_from_cpt( $post );
},
$query->get_posts()

View File

@ -31,12 +31,13 @@ class Interactions {
return false;
}
$in_reply_to = \esc_url_raw( $activity['object']['inReplyTo'] );
$comment_post_id = \url_to_postid( $in_reply_to );
$parent_comment = object_id_to_comment( $in_reply_to );
$in_reply_to = \esc_url_raw( $activity['object']['inReplyTo'] );
$comment_post_id = \url_to_postid( $in_reply_to );
$parent_comment_id = url_to_commentid( $in_reply_to );
// save only replys and reactions
if ( ! $comment_post_id && $parent_comment ) {
if ( ! $comment_post_id && $parent_comment_id ) {
$parent_comment = get_comment( $parent_comment_id );
$comment_post_id = $parent_comment->comment_post_ID;
}
@ -53,15 +54,14 @@ class Interactions {
$commentdata = array(
'comment_post_ID' => $comment_post_id,
'comment_author' => \esc_attr( $meta['name'] ),
'comment_author' => isset( $meta['name'] ) ? \esc_attr( $meta['name'] ) : \esc_attr( $meta['preferredUsername'] ),
'comment_author_url' => \esc_url_raw( $meta['url'] ),
'comment_content' => \addslashes( $activity['object']['content'] ),
'comment_type' => 'comment',
'comment_author_email' => '',
'comment_parent' => $parent_comment ? $parent_comment->comment_ID : 0,
'comment_parent' => $parent_comment_id ? $parent_comment_id : 0,
'comment_meta' => array(
'source_id' => \esc_url_raw( $activity['object']['id'] ),
'source_url' => \esc_url_raw( $activity['object']['url'] ),
'protocol' => 'activitypub',
),
);
@ -70,6 +70,10 @@ class Interactions {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] );
}
if ( isset( $activity['object']['url'] ) ) {
$commentdata['comment_meta']['source_url'] = \esc_url_raw( $activity['object']['url'] );
}
// disable flood control
\remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 );
// do not require email for AP entries
@ -77,7 +81,7 @@ class Interactions {
// No nonce possible for this submission route
\add_filter(
'akismet_comment_nonce',
function() {
function () {
return 'inactive';
}
);
@ -98,20 +102,20 @@ class Interactions {
*
* @param array $activity The activity-object
*
* @return array|false The commentdata or false on failure
* @return array|string|int|\WP_Error|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'] ) );
$comment = object_id_to_comment( \esc_url_raw( $activity['object']['id'] ) );
$commentdata = \get_comment( $comment, ARRAY_A );
if ( ! $object_comment_id ) {
if ( ! $commentdata ) {
return false;
}
//found a local comment id
$commentdata = \get_comment( $object_comment_id, ARRAY_A );
$commentdata['comment_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] );
$commentdata['comment_content'] = \addslashes( $activity['object']['content'] );
if ( isset( $meta['icon']['url'] ) ) {
@ -125,20 +129,24 @@ class Interactions {
// No nonce possible for this submission route
\add_filter(
'akismet_comment_nonce',
function() {
function () {
return 'inactive';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
$comment = \wp_update_comment( $commentdata, true );
$state = \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;
if ( 1 === $state ) {
return $commentdata;
} else {
return $state; // Either `false` or a `WP_Error` instance or `0` or `1`!
}
}
/**

View File

@ -7,6 +7,7 @@ use Activitypub\Model\User;
use Activitypub\Model\Blog_User;
use Activitypub\Model\Application_User;
use function Activitypub\url_to_authorid;
use function Activitypub\is_user_disabled;
class Users {
@ -103,6 +104,8 @@ class Users {
return self::get_by_id( $user->results[0] );
}
$username = str_replace( array( '*', '%' ), '', $username );
// check for login or nicename.
$user = new WP_User_Query(
array(
@ -133,29 +136,79 @@ class Users {
* @return \Acitvitypub\Model\User The User.
*/
public static function get_by_resource( $resource ) {
if ( \strpos( $resource, '@' ) === false ) {
return new WP_Error(
'activitypub_unsupported_resource',
\__( 'Resource is invalid', 'activitypub' ),
array( 'status' => 400 )
);
$scheme = 'acct';
$match = array();
// try to extract the scheme and the host
if ( preg_match( '/^([a-zA-Z^:]+):(.*)$/i', $resource, $match ) ) {
// extract the scheme
$scheme = esc_attr( $match[1] );
}
$resource = \str_replace( 'acct:', '', $resource );
switch ( $scheme ) {
// check for http(s) URIs
case 'http':
case 'https':
$url_parts = wp_parse_url( $resource );
$resource_identifier = \substr( $resource, 0, \strrpos( $resource, '@' ) );
$resource_host = self::normalize_host( \substr( \strrchr( $resource, '@' ), 1 ) );
$blog_host = self::normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) );
// check for http(s)://blog.example.com/@username
if (
isset( $url_parts['path'] ) &&
str_starts_with( $url_parts['path'], '/@' )
) {
$identifier = str_replace( '/@', '', $url_parts['path'] );
$identifier = untrailingslashit( $identifier );
if ( $blog_host !== $resource_host ) {
return new WP_Error(
'activitypub_wrong_host',
\__( 'Resource host does not match blog host', 'activitypub' ),
array( 'status' => 404 )
);
return self::get_by_username( $identifier );
}
// check for http(s)://blog.example.com/author/username
$user_id = url_to_authorid( $resource );
if ( $user_id ) {
return self::get_by_id( $user_id );
}
// check for http(s)://blog.example.com/
if (
self::normalize_url( site_url() ) === self::normalize_url( $resource ) ||
self::normalize_url( home_url() ) === self::normalize_url( $resource )
) {
return self::get_by_id( self::BLOG_USER_ID );
}
return new WP_Error(
'activitypub_no_user_found',
\__( 'User not found', 'activitypub' ),
array( 'status' => 404 )
);
// check for acct URIs
case 'acct':
$resource = \str_replace( 'acct:', '', $resource );
$identifier = \substr( $resource, 0, \strrpos( $resource, '@' ) );
$host = self::normalize_host( \substr( \strrchr( $resource, '@' ), 1 ) );
$blog_host = self::normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) );
if ( $blog_host !== $host ) {
return new WP_Error(
'activitypub_wrong_host',
\__( 'Resource host does not match blog host', 'activitypub' ),
array( 'status' => 404 )
);
}
// prepare wildcards https://github.com/mastodon/mastodon/issues/22213
if ( in_array( $identifier, array( '_', '*', '' ), true ) ) {
return self::get_by_id( self::BLOG_USER_ID );
}
return self::get_by_username( $identifier );
default:
return new WP_Error(
'activitypub_wrong_scheme',
\__( 'Wrong scheme', 'activitypub' ),
array( 'status' => 404 )
);
}
return self::get_by_username( $resource_identifier );
}
/**
@ -168,7 +221,12 @@ class Users {
public static function get_by_various( $id ) {
if ( is_numeric( $id ) ) {
return self::get_by_id( $id );
} elseif ( filter_var( $id, FILTER_VALIDATE_URL ) ) {
} elseif (
// is URL
filter_var( $id, FILTER_VALIDATE_URL ) ||
// is acct
str_starts_with( $id, 'acct:' )
) {
return self::get_by_resource( $id );
} else {
return self::get_by_username( $id );
@ -176,7 +234,7 @@ class Users {
}
/**
* Normalize the host.
* Normalize a host.
*
* @param string $host The host.
*
@ -186,6 +244,22 @@ class Users {
return \str_replace( 'www.', '', $host );
}
/**
* Normalize a URL.
*
* @param string $url The URL.
*
* @return string The normalized URL.
*/
public static function normalize_url( $url ) {
$url = \untrailingslashit( $url );
$url = \str_replace( 'https://', '', $url );
$url = \str_replace( 'http://', '', $url );
$url = \str_replace( 'www.', '', $url );
return $url;
}
/**
* Get the User collection.
*

View File

@ -47,3 +47,53 @@ if ( ! function_exists( 'is_countable' ) ) {
return is_array( $value ) || $value instanceof \Countable;
}
}
/**
* Polyfill for `array_is_list()` function added in PHP 7.3.
*
* @param array $array The array to check.
*
* @return bool True if `$array` is a list, otherwise false.
*/
if ( ! function_exists( 'array_is_list' ) ) {
function array_is_list( $array ) {
if ( ! is_array( $array ) ) {
return false;
}
if ( array_values( $array ) === $array ) {
return true;
}
$next_key = -1;
foreach ( $array as $k => $v ) {
if ( ++$next_key !== $k ) {
return false;
}
}
return true;
}
}
if ( ! function_exists( 'str_contains' ) ) {
/**
* Polyfill for `str_contains()` function added in PHP 8.0.
*
* Performs a case-sensitive check indicating if needle is
* contained in haystack.
*
* @param string $haystack The string to search in.
* @param string $needle The substring to search for in the `$haystack`.
*
* @return bool True if `$needle` is in `$haystack`, otherwise false.
*/
function str_contains( $haystack, $needle ) {
if ( '' === $needle ) {
return true;
}
return false !== strpos( $haystack, $needle );
}
}

View File

@ -4,6 +4,7 @@ namespace Activitypub;
use WP_Error;
use WP_Comment_Query;
use Activitypub\Http;
use Activitypub\Webfinger;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Users;
@ -168,6 +169,27 @@ function url_to_authorid( $url ) {
return 0;
}
/**
* Verify if url is a wp_ap_comment,
* Or if it is a previously received remote comment
*
* @return int comment_id
*/
function is_comment() {
$comment_id = get_query_var( 'c', null );
if ( ! is_null( $comment_id ) ) {
$comment = \get_comment( $comment_id );
// Only return local origin comments
if ( $comment && $comment->user_id ) {
return $comment_id;
}
}
return false;
}
/**
* Check for Tombstone Objects
*
@ -579,7 +601,7 @@ function get_active_users( $duration = 1 ) {
global $wpdb;
$query = "SELECT COUNT( DISTINCT post_author ) FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND post_date <= DATE_SUB( NOW(), INTERVAL %d MONTH )";
$query = $wpdb->prepare( $query, $duration );
$count = $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$count = $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
set_transient( $transient_key, $count, DAY_IN_SECONDS );
}
@ -674,16 +696,33 @@ function url_to_commentid( $url ) {
return null;
}
// check for local comment
if ( \wp_parse_url( \site_url(), \PHP_URL_HOST ) === \wp_parse_url( $url, \PHP_URL_HOST ) ) {
$query = \wp_parse_url( $url, PHP_URL_QUERY );
if ( $query ) {
parse_str( $query, $params );
if ( ! empty( $params['c'] ) ) {
$comment = \get_comment( $params['c'] );
if ( $comment ) {
return $comment->comment_ID;
}
}
}
}
$args = array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'OR',
array(
'key' => 'source_url',
'key' => 'source_url',
'value' => $url,
),
array(
'key' => 'source_id',
'key' => 'source_id',
'value' => $url,
),
),
@ -698,3 +737,40 @@ function url_to_commentid( $url ) {
return null;
}
/**
* Get the URI of an ActivityPub object
*
* @param array $object The ActivityPub object
*
* @return string The URI of the ActivityPub object
*/
function object_to_uri( $object ) {
// check if it is already simple
if ( ! $object || is_string( $object ) ) {
return $object;
}
// check if it is a list, then take first item
// this plugin does not support collections
if ( array_is_list( $object ) ) {
$object = $object[0];
}
// check if it is simplified now
if ( is_string( $object ) ) {
return $object;
}
// return part of Object that makes most sense
switch ( $object['type'] ) {
case 'Link':
$object = $object['href'];
break;
default:
$object = $object['id'];
break;
}
return $object;
}

View File

@ -15,7 +15,12 @@ class Create {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_inbox_create', array( self::class, 'handle_create' ), 10, 3 );
\add_action(
'activitypub_inbox_create',
array( self::class, 'handle_create' ),
10,
3
);
}
/**

View File

@ -15,11 +15,24 @@ class Delete {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ), 10, 2 );
\add_action(
'activitypub_inbox_delete',
array( self::class, 'handle_delete' )
);
// defer signature verification for `Delete` requests.
\add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 );
\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 );
\add_action(
'activitypub_delete_actor_interactions',
array( self::class, 'delete_interactions' )
);
}
/**
@ -28,7 +41,7 @@ class Delete {
* @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 ) {
public static function handle_delete( $activity ) {
$object_type = isset( $activity['object']['type'] ) ? $activity['object']['type'] : '';
switch ( $object_type ) {
@ -39,7 +52,7 @@ class Delete {
case 'Organization':
case 'Service':
case 'Application':
self::maybe_delete_follower( $user_id, $activity );
self::maybe_delete_follower( $activity );
break;
// Object and Link Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types

View File

@ -14,8 +14,17 @@ 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 );
\add_action(
'activitypub_inbox_follow',
array( self::class, 'handle_follow' )
);
\add_action(
'activitypub_followers_post_follow',
array( self::class, 'send_follow_response' ),
10,
4
);
}
/**
@ -24,11 +33,30 @@ class Follow {
* @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'] );
public static function handle_follow( $activity ) {
$user = Users::get_by_resource( $activity['object'] );
do_action( 'activitypub_followers_post_follow', $activity['actor'], $activity, $user_id, $follower );
if ( ! $user || is_wp_error( $user ) ) {
// If we can not find a user,
// we can not initiate a follow process
return;
}
$user_id = $user->get__id();
// save follower
$follower = Followers::add_follower(
$user_id,
$activity['actor']
);
do_action(
'activitypub_followers_post_follow',
$activity['actor'],
$activity,
$user_id,
$follower
);
}
/**

View File

@ -1,6 +1,7 @@
<?php
namespace Activitypub\Handler;
use Activitypub\Collection\Users;
use Activitypub\Collection\Followers;
/**
@ -11,7 +12,10 @@ class Undo {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo' ), 10, 2 );
\add_action(
'activitypub_inbox_undo',
array( self::class, 'handle_undo' )
);
}
/**
@ -20,11 +24,23 @@ class Undo {
* @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 ) {
public static function handle_undo( $activity ) {
if (
isset( $activity['object']['type'] ) &&
'Follow' === $activity['object']['type']
'Follow' === $activity['object']['type'] &&
isset( $activity['object']['object'] ) &&
filter_var( $activity['object']['object'], FILTER_VALIDATE_URL )
) {
$user = Users::get_by_resource( $activity['object']['object'] );
if ( ! $user || is_wp_error( $user ) ) {
// If we can not find a user,
// we can not initiate a follow process
return;
}
$user_id = $user->get__id();
Followers::remove_follower( $user_id, $activity['actor'] );
}
}

View File

@ -14,7 +14,10 @@ class Update {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_inbox_update', array( self::class, 'handle_update' ), 10, 2 );
\add_action(
'activitypub_inbox_update',
array( self::class, 'handle_update' )
);
}
/**
@ -23,7 +26,7 @@ class Update {
* @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 ) {
public static function handle_update( $array ) {
$object_type = isset( $array['object']['type'] ) ? $array['object']['type'] : '';
switch ( $object_type ) {
@ -45,7 +48,7 @@ class Update {
case 'Video':
case 'Event':
case 'Document':
self::update_interaction( $array, $user_id );
self::update_interaction( $array );
break;
// Minimal Activity
// @see https://www.w3.org/TR/activitystreams-core/#example-1
@ -62,15 +65,18 @@ class Update {
*
* @return void
*/
public static function update_interaction( $activity, $user_id ) {
$state = Interactions::update_comment( $activity );
$reaction = null;
public static function update_interaction( $activity ) {
$commentdata = Interactions::update_comment( $activity );
$reaction = null;
if ( $state && ! \is_wp_error( $reaction ) ) {
$reaction = \get_comment( $state );
if ( ! empty( $commentdata['comment_ID'] ) ) {
$state = 1;
$reaction = \get_comment( $commentdata['comment_ID'] );
} else {
$state = $commentdata;
}
\do_action( 'activitypub_handled_update', $activity, $user_id, $state, $reaction );
\do_action( 'activitypub_handled_update', $activity, null, $state, $reaction );
}
/**

View File

@ -38,6 +38,15 @@ class Application_User extends Blog_User {
return get_rest_url_by_path( 'application' );
}
/**
* Returns the User-URL with @-Prefix for the username.
*
* @return string The User-URL with @-Prefix for the username.
*/
public function get_alternate_url() {
return \esc_url( \trailingslashit( get_home_url() ) . '@' . $this->get_preferred_username() );
}
public function get_name() {
return 'application';
}
@ -69,4 +78,8 @@ class Application_User extends Blog_User {
public function get_indexable() {
return false;
}
public function get_type() {
return $this->type;
}
}

View File

@ -100,12 +100,12 @@ class Blog_User extends User {
}
/**
* Returns the User-URL with @-Prefix for the username.
* Get blog's homepage URL.
*
* @return string The User-URL with @-Prefix for the username.
* @return string The User-Url.
*/
public function get_at_url() {
return \esc_url( \trailingslashit( get_home_url() ) . '@' . $this->get_preferred_username() );
public function get_alternate_url() {
return \esc_url( \trailingslashit( get_home_url() ) );
}
/**

View File

@ -1,6 +1,7 @@
<?php
namespace Activitypub\Model;
use Activitypub\Collection\Users;
use Activitypub\Transformer\Post as Post_Transformer;
/**
@ -61,7 +62,7 @@ class Post {
* @return string The URL of the Actor.
*/
public function get_actor() {
$user = User_Factory::get_by_id( $this->get_user_id() );
$user = Users::get_by_id( $this->get_user_id() );
return $user->get_url();
}

View File

@ -64,7 +64,7 @@ class User extends Actor {
*
* @var string<url>
*/
protected $resource;
protected $webfinger;
/**
* Restrict posting to mods
@ -135,8 +135,8 @@ class User extends Actor {
*
* @return string The User-URL with @-Prefix for the username.
*/
public function get_at_url() {
return \esc_url( \trailingslashit( get_home_url() ) . '@' . $this->get_username() );
public function get_alternate_url() {
return \esc_url( \trailingslashit( get_home_url() ) . '@' . $this->get_preferred_username() );
}
public function get_preferred_username() {
@ -226,6 +226,18 @@ class User extends Actor {
return get_rest_url_by_path( sprintf( 'users/%d/collections/featured', $this->get__id() ) );
}
public function get_endpoints() {
$endpoints = null;
if ( ACTIVITYPUB_SHARED_INBOX_FEATURE ) {
$endpoints = array(
'sharedInbox' => get_rest_url_by_path( 'inbox' ),
);
}
return $endpoints;
}
/**
* Extend the User-Output with Attachments.
*
@ -274,10 +286,14 @@ class User extends Actor {
*
* @return string The Webfinger-Identifier.
*/
public function get_resource() {
public function get_webfinger() {
return $this->get_preferred_username() . '@' . \wp_parse_url( \home_url(), \PHP_URL_HOST );
}
public function get_resource() {
return $this->get_webfinger();
}
public function get_canonical_url() {
return $this->get_url();
}

View File

@ -1,34 +0,0 @@
<?php
namespace Activitypub\Peer;
/**
* ActivityPub Followers DB-Class
*
* @author Matthias Pfefferle
*/
class Followers {
public static function get_followers( $author_id ) {
_deprecated_function( __METHOD__, '1.0.0', '\Activitypub\Collection\Followers::get_followers' );
return \Activitypub\Collection\Followers::get_followers( $author_id );
}
public static function count_followers( $author_id ) {
_deprecated_function( __METHOD__, '1.0.0', '\Activitypub\Collection\Followers::count_followers' );
return \Activitypub\Collection\Followers::count_followers( $author_id );
}
public static function add_follower( $actor, $author_id ) {
_deprecated_function( __METHOD__, '1.0.0', '\Activitypub\Collection\Followers::add_follower' );
return \Activitypub\Collection\Followers::add_follower( $author_id, $actor );
}
public static function remove_follower( $actor, $author_id ) {
_deprecated_function( __METHOD__, '1.0.0', '\Activitypub\Collection\Followers::remove_follower' );
return \Activitypub\Collection\Followers::remove_follower( $author_id, $actor );
}
}

View File

@ -94,7 +94,7 @@ class Followers {
// phpcs:ignore
$json->orderedItems = array_map(
function( $item ) use ( $context ) {
function ( $item ) use ( $context ) {
if ( 'full' === $context ) {
return $item->to_array();
}

View File

@ -8,6 +8,7 @@ use Activitypub\Activity\Activity;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\get_context;
use function Activitypub\object_to_uri;
use function Activitypub\url_to_authorid;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_remote_metadata_by_actor;
@ -93,11 +94,8 @@ class Inbox {
$json->generator = 'http://wordpress.org/?v=' . \get_bloginfo_rss( 'version' );
$json->type = 'OrderedCollectionPage';
$json->partOf = get_rest_url_by_path( sprintf( 'users/%d/inbox', $user->get__id() ) ); // phpcs:ignore
$json->totalItems = 0; // phpcs:ignore
$json->orderedItems = array(); // phpcs:ignore
$json->first = $json->partOf; // phpcs:ignore
// filter output
@ -154,37 +152,10 @@ class Inbox {
$data = $request->get_json_params();
$activity = Activity::init_from_array( $data );
$type = $request->get_param( 'type' );
$users = self::get_recipients( $data );
$type = \strtolower( $type );
if ( ! $users ) {
return new WP_Error(
'rest_invalid_param',
\__( 'No recipients found', 'activitypub' ),
array(
'status' => 400,
'params' => array(
'to' => \__( 'Please check/validate "to" field', 'activitypub' ),
'bto' => \__( 'Please check/validate "bto" field', 'activitypub' ),
'cc' => \__( 'Please check/validate "cc" field', 'activitypub' ),
'bcc' => \__( 'Please check/validate "bcc" field', 'activitypub' ),
'audience' => \__( 'Please check/validate "audience" field', 'activitypub' ),
),
)
);
}
foreach ( $users as $user ) {
$user = User_Collection::get_by_various( $user );
if ( is_wp_error( $user ) ) {
continue;
}
$type = \strtolower( $type );
\do_action( 'activitypub_inbox', $data, $user->ID, $type, $activity );
\do_action( "activitypub_inbox_{$type}", $data, $user->ID, $activity );
}
\do_action( 'activitypub_inbox', $data, null, $type, $activity );
\do_action( "activitypub_inbox_{$type}", $data, null, $activity );
$rest_response = new WP_REST_Response( array(), 202 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
@ -236,15 +207,8 @@ class Inbox {
$params['actor'] = array(
'required' => true,
'sanitize_callback' => function( $param, $request, $key ) {
if ( \is_array( $param ) ) {
if ( isset( $param['id'] ) ) {
$param = $param['id'];
} else {
$param = $param['url'];
}
}
return \esc_url_raw( $param );
'sanitize_callback' => function ( $param, $request, $key ) {
return object_to_uri( $param );
},
);
@ -252,7 +216,7 @@ class Inbox {
'required' => true,
//'type' => 'enum',
//'enum' => array( 'Create' ),
//'sanitize_callback' => function( $param, $request, $key ) {
//'sanitize_callback' => function ( $param, $request, $key ) {
// return \strtolower( $param );
//},
);
@ -285,11 +249,8 @@ class Inbox {
$params['actor'] = array(
'required' => true,
//'type' => array( 'object', 'string' ),
'sanitize_callback' => function( $param, $request, $key ) {
if ( ! \is_string( $param ) ) {
$param = $param['id'];
}
return \esc_url_raw( $param );
'sanitize_callback' => function ( $param, $request, $key ) {
return object_to_uri( $param );
},
);
@ -297,7 +258,7 @@ class Inbox {
'required' => true,
//'type' => 'enum',
//'enum' => array( 'Create' ),
//'sanitize_callback' => function( $param, $request, $key ) {
//'sanitize_callback' => function ( $param, $request, $key ) {
// return \strtolower( $param );
//},
);
@ -309,7 +270,7 @@ class Inbox {
$params['to'] = array(
'required' => false,
'sanitize_callback' => function( $param, $request, $key ) {
'sanitize_callback' => function ( $param, $request, $key ) {
if ( \is_string( $param ) ) {
$param = array( $param );
}
@ -319,7 +280,7 @@ class Inbox {
);
$params['cc'] = array(
'sanitize_callback' => function( $param, $request, $key ) {
'sanitize_callback' => function ( $param, $request, $key ) {
if ( \is_string( $param ) ) {
$param = array( $param );
}
@ -329,7 +290,7 @@ class Inbox {
);
$params['bcc'] = array(
'sanitize_callback' => function( $param, $request, $key ) {
'sanitize_callback' => function ( $param, $request, $key ) {
if ( \is_string( $param ) ) {
$param = array( $param );
}

View File

@ -105,6 +105,12 @@ class Nodeinfo {
'outbound' => array(),
);
$nodeinfo['metadata'] = array(
'nodeName' => \get_bloginfo( 'name' ),
'nodeDescription' => \get_bloginfo( 'description' ),
'nodeIcon' => \get_site_icon_url(),
);
return new WP_REST_Response( $nodeinfo, 200 );
}
@ -169,6 +175,10 @@ class Nodeinfo {
'rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0',
'href' => get_rest_url_by_path( 'nodeinfo' ),
),
array(
'rel' => 'https://www.w3.org/ns/activitystreams#Application',
'href' => get_rest_url_by_path( 'application' ),
),
);
return new \WP_REST_Response( $discovery, 200 );

View File

@ -50,14 +50,13 @@ class Users {
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'remote_follow_get' ),
'permission_callback' => '__return_true',
'args' => array(
'resource' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
'permission_callback' => '__return_true',
),
)
);
@ -124,7 +123,7 @@ class Users {
return $template;
}
$resource = $user->get_resource();
$resource = $user->get_webfinger();
$url = str_replace( '{uri}', $resource, $template );
return new WP_REST_Response(

View File

@ -72,7 +72,7 @@ class Webfinger {
$params['resource'] = array(
'required' => true,
'type' => 'string',
'pattern' => '^acct:(.+)@(.+)$',
'pattern' => '^(acct:)|^(https?://)(.+)$',
);
return $params;
@ -94,10 +94,13 @@ class Webfinger {
$aliases = array(
$user->get_url(),
$user->get_alternate_url(),
);
$aliases = array_unique( $aliases );
$profile = array(
'subject' => $resource,
'subject' => sprintf( 'acct:%s', $user->get_webfinger() ),
'aliases' => array_values( array_unique( $aliases ) ),
'links' => array(
array(
@ -113,9 +116,9 @@ class Webfinger {
),
);
if ( 'Group' === $user->get_type() ) {
if ( 'Person' !== $user->get_type() ) {
$profile['links'][0]['properties'] = array(
'https://www.w3.org/ns/activitystreams#type' => 'Group',
'https://www.w3.org/ns/activitystreams#type' => $user->get_type(),
);
}

View File

@ -5,6 +5,8 @@ use WP_List_Table;
use Activitypub\Collection\Users;
use Activitypub\Collection\Followers as FollowerCollection;
use function Activitypub\object_to_uri;
if ( ! \class_exists( '\WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
@ -101,7 +103,7 @@ class Followers extends WP_List_Table {
'icon' => esc_attr( $follower->get_icon_url() ),
'post_title' => esc_attr( $follower->get_name() ),
'username' => esc_attr( $follower->get_preferred_username() ),
'url' => esc_attr( $follower->get_url() ),
'url' => esc_attr( object_to_uri( $follower->get_url() ) ),
'identifier' => esc_attr( $follower->get_id() ),
'published' => esc_attr( $follower->get_published() ),
'modified' => esc_attr( $follower->get_updated() ),

View File

@ -0,0 +1,49 @@
<?php
namespace Activitypub\Transformer;
use Activitypub\Transformer\Post;
/**
* WordPress Attachment Transformer
*
* The Attachment Transformer is responsible for transforming a WP_Post object into different other
* Object-Types.
*
* Currently supported are:
*
* - Activitypub\Activity\Base_Object
*/
class Attachment extends Post {
/**
* Generates all Media Attachments for a Post.
*
* @return array The Attachments.
*/
protected function get_attachment() {
$mime_type = get_post_mime_type( $this->wp_object->ID );
$media_type = preg_replace( '/(\/[a-zA-Z]+)/i', '', $mime_type );
switch ( $media_type ) {
case 'audio':
case 'video':
$type = 'Document';
break;
case 'image':
$type = 'Image';
break;
}
$attachment = array(
'type' => $type,
'url' => wp_get_attachment_url( $this->wp_object->ID ),
'mediaType' => $mime_type,
);
$alt = \get_post_meta( $this->wp_object->ID, '_wp_attachment_image_alt', true );
if ( $alt ) {
$attachment['name'] = $alt;
}
return $attachment;
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace Activitypub\Transformer;
use WP_Post;
use WP_Comment;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
/**
* WordPress Base Transformer
*
* Transformers are responsible for transforming a WordPress objects into different ActivityPub
* Object-Types or Activities.
*/
abstract class Base {
/**
* The WP_Post or WP_Comment object.
*
* This is the source object of the transformer.
*
* @var WP_Post|WP_Comment
*/
protected $wp_object;
/**
* Static function to Transform a WordPress Object.
*
* This helps to chain the output of the Transformer.
*
* @param WP_Post|WP_Comment $wp_object The WordPress object
*
* @return void
*/
public static function transform( $object ) {
return new static( $object );
}
/**
* Base constructor.
*
* @param WP_Post|WP_Comment $wp_object The WordPress object
*/
public function __construct( $wp_object ) {
$this->wp_object = $wp_object;
}
/**
* Transform the WordPress Object into an ActivityPub Object.
*
* @return Activitypub\Activity\Base_Object
*/
public function to_object() {
$activitypub_object = new Base_Object();
$vars = $activitypub_object->get_object_var_keys();
foreach ( $vars as $var ) {
$getter = 'get_' . $var;
if ( method_exists( $this, $getter ) ) {
$value = call_user_func( array( $this, $getter ) );
if ( isset( $value ) ) {
$setter = 'set_' . $var;
call_user_func( array( $activitypub_object, $setter ), $value );
}
}
}
return $activitypub_object;
}
/**
* Transforms the ActivityPub Object to an Activity
*
* @param string $type The Activity-Type.
*
* @return \Activitypub\Activity\Activity The Activity.
*/
public function to_activity( $type ) {
$object = $this->to_object();
$activity = new Activity();
$activity->set_type( $type );
$activity->set_object( $object );
// Use simple Object (only ID-URI) for Like and Announce
if ( in_array( $type, array( 'Like', 'Announce' ), true ) ) {
$activity->set_object( $object->get_id() );
}
return $activity;
}
/**
* Returns the ID of the WordPress Object.
*
* @return int The ID of the WordPress Object
*/
abstract public function get_wp_user_id();
/**
* Change the User-ID of the WordPress Post.
*
* @return int The User-ID of the WordPress Post
*/
abstract public function change_wp_user_id( $user_id );
}

View File

@ -0,0 +1,274 @@
<?php
namespace Activitypub\Transformer;
use WP_Comment;
use WP_Comment_Query;
use Activitypub\Model\Blog_User;
use Activitypub\Collection\Users;
use Activitypub\Transformer\Base;
use function Activitypub\is_single_user;
use function Activitypub\get_rest_url_by_path;
/**
* WordPress Comment Transformer
*
* The Comment Transformer is responsible for transforming a WP_Comment object into different
* Object-Types.
*
* Currently supported are:
*
* - Activitypub\Activity\Base_Object
*/
class Comment extends Base {
/**
* Returns the User-ID of the WordPress Comment.
*
* @return int The User-ID of the WordPress Comment
*/
public function get_wp_user_id() {
return $this->wp_object->user_id;
}
/**
* Change the User-ID of the WordPress Comment.
*
* @return int The User-ID of the WordPress Comment
*/
public function change_wp_user_id( $user_id ) {
$this->wp_object->user_id = $user_id;
}
/**
* Transforms the WP_Comment object to an ActivityPub Object
*
* @see \Activitypub\Activity\Base_Object
*
* @return \Activitypub\Activity\Base_Object The ActivityPub Object
*/
public function to_object() {
$comment = $this->wp_object;
$object = parent::to_object();
$object->set_url( \get_comment_link( $comment->comment_ID ) );
$object->set_type( 'Note' );
$published = \strtotime( $comment->comment_date_gmt );
$object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) );
$updated = \get_comment_meta( $comment->comment_ID, 'activitypub_comment_modified', true );
if ( $updated > $published ) {
$object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) );
}
$object->set_content_map(
array(
$this->get_locale() => $this->get_content(),
)
);
$path = sprintf( 'users/%d/followers', intval( $comment->comment_author ) );
$object->set_to(
array(
'https://www.w3.org/ns/activitystreams#Public',
get_rest_url_by_path( $path ),
)
);
return $object;
}
/**
* Returns the User-URL of the Author of the Post.
*
* If `single_user` mode is enabled, the URL of the Blog-User is returned.
*
* @return string The User-URL.
*/
protected function get_attributed_to() {
if ( is_single_user() ) {
$user = new Blog_User();
return $user->get_url();
}
return Users::get_by_id( $this->wp_object->user_id )->get_url();
}
/**
* Returns the content for the ActivityPub Item.
*
* The content will be generated based on the user settings.
*
* @return string The content.
*/
protected function get_content() {
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$comment = $this->wp_object;
$content = $comment->comment_content;
$content = \wpautop( $content );
$content = \preg_replace( '/[\n\r\t]/', '', $content );
$content = \trim( $content );
$content = \apply_filters( 'the_content', $content, $comment );
return $content;
}
/**
* Returns the in-reply-to for the ActivityPub Item.
*
* @return int The URL of the in-reply-to.
*/
protected function get_in_reply_to() {
$comment = $this->wp_object;
$parent_comment = \get_comment( $comment->comment_parent );
if ( $parent_comment ) {
$comment_meta = \get_comment_meta( $parent_comment->comment_ID );
if ( ! empty( $comment_meta['source_id'][0] ) ) {
$in_reply_to = $comment_meta['source_id'][0];
} elseif ( ! empty( $comment_meta['source_url'][0] ) ) {
$in_reply_to = $comment_meta['source_url'][0];
} else {
$in_reply_to = $this->generate_id( $parent_comment );
}
} else {
$in_reply_to = \get_permalink( $comment->comment_post_ID );
}
return $in_reply_to;
}
/**
* Returns the ID of the ActivityPub Object.
*
* @see https://www.w3.org/TR/activitypub/#obj-id
* @see https://github.com/tootsuite/mastodon/issues/13879
*
* @return string ActivityPub URI for comment
*/
protected function get_id() {
$comment = $this->wp_object;
return $this->generate_id( $comment );
}
/**
* Generates an ActivityPub URI for a comment
*
* @param WP_Comment|int $comment A comment object or comment ID
*
* @return string ActivityPub URI for comment
*/
protected function generate_id( $comment ) {
$comment = get_comment( $comment );
return \add_query_arg(
array(
'c' => $comment->comment_ID,
),
\trailingslashit( site_url() )
);
}
/**
* Returns a list of Mentions, used in the Comment.
*
* @see https://docs.joinmastodon.org/spec/activitypub/#Mention
*
* @return array The list of Mentions.
*/
protected function get_cc() {
$cc = array();
$mentions = $this->get_mentions();
if ( $mentions ) {
foreach ( $mentions as $mention => $url ) {
$cc[] = $url;
}
}
$comment_query = new WP_Comment_Query(
array(
'post_id' => $this->wp_object->comment_post_ID,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'source_id',
'compare' => 'EXISTS',
),
),
)
);
if ( $comment_query->comments ) {
foreach ( $comment_query->comments as $comment ) {
if ( empty( $comment->comment_author_url ) ) {
continue;
}
$cc[] = \esc_url( $comment->comment_author_url );
}
}
$cc = \array_unique( $cc );
return $cc;
}
/**
* Returns a list of Tags, used in the Comment.
*
* This includes Hash-Tags and Mentions.
*
* @return array The list of Tags.
*/
protected function get_tag() {
$tags = array();
$mentions = $this->get_mentions();
if ( $mentions ) {
foreach ( $mentions as $mention => $url ) {
$tag = array(
'type' => 'Mention',
'href' => \esc_url( $url ),
'name' => \esc_html( $mention ),
);
$tags[] = $tag;
}
}
return \array_unique( $tags, SORT_REGULAR );
}
/**
* Helper function to get the @-Mentions from the comment content.
*
* @return array The list of @-Mentions.
*/
protected function get_mentions() {
return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_object->comment_content, $this->wp_object );
}
/**
* Returns the locale of the post.
*
* @return string The locale of the post.
*/
public function get_locale() {
$comment_id = $this->wp_object->ID;
$lang = \strtolower( \strtok( \get_locale(), '_-' ) );
/**
* Filter the locale of the comment.
*
* @param string $lang The locale of the comment.
* @param int $comment_id The comment ID.
* @param WP_Post $post The comment object.
*
* @return string The filtered locale of the comment.
*/
return apply_filters( 'activitypub_comment_locale', $lang, $comment_id, $this->wp_object );
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace Activitypub\Transformer;
use Activitypub\Transformer\Post;
use Activitypub\Transformer\Comment;
use Activitypub\Transformer\Attachment;
/**
* Transformer Factory
*/
class Factory {
public static function get_transformer( $object ) {
/**
* Filter the transformer for a given object.
*
* Add your own transformer based on the object class or the object type.
*
* Example usage:
*
* // Filter be object class
* add_filter( 'activitypub_transformer', function( $transformer, $object, $object_class ) {
* if ( $object_class === 'WP_Post' ) {
* return new My_Post_Transformer( $object );
* }
* return $transformer;
* }, 10, 3 );
*
* // Filter be object type
* add_filter( 'activitypub_transformer', function( $transformer, $object, $object_class ) {
* if ( $object->post_type === 'event' ) {
* return new My_Event_Transformer( $object );
* }
* return $transformer;
* }, 10, 3 );
*
* @param Activitypub\Transformer\Base $transformer The transformer to use.
* @param mixed $object The object to transform.
* @param string $object_class The class of the object to transform.
*
* @return mixed The transformer to use.
*/
$transformer = apply_filters( 'activitypub_transformer', null, $object, get_class( $object ) );
if ( $transformer ) {
return $transformer;
}
// use default transformer
switch ( get_class( $object ) ) {
case 'WP_Post':
if ( 'attachment' === $object->post_type ) {
return new Attachment( $object );
}
return new Post( $object );
case 'WP_Comment':
return new Comment( $object );
default:
return null;
}
}
}

View File

@ -2,10 +2,11 @@
namespace Activitypub\Transformer;
use WP_Post;
use Activitypub\Collection\Users;
use Activitypub\Model\Blog_User;
use Activitypub\Activity\Base_Object;
use Activitypub\Shortcodes;
use Activitypub\Model\Blog_User;
use Activitypub\Transformer\Base;
use Activitypub\Collection\Users;
use Activitypub\Activity\Base_Object;
use function Activitypub\esc_hashtag;
use function Activitypub\is_single_user;
@ -15,42 +16,32 @@ use function Activitypub\site_supports_blocks;
/**
* WordPress Post Transformer
*
* The Post Transformer is responsible for transforming a WP_Post object into different othe
* The Post Transformer is responsible for transforming a WP_Post object into different other
* Object-Types.
*
* Currently supported are:
*
* - Activitypub\Activity\Base_Object
*/
class Post {
class Post extends Base {
/**
* The WP_Post object.
* Returns the ID of the WordPress Post.
*
* @var WP_Post
* @return int The ID of the WordPress Post
*/
protected $wp_post;
/**
* Static function to Transform a WP_Post Object.
*
* This helps to chain the output of the Transformer.
*
* @param WP_Post $wp_post The WP_Post object
*
* @return void
*/
public static function transform( WP_Post $wp_post ) {
return new static( $wp_post );
public function get_wp_user_id() {
return $this->wp_object->post_author;
}
/**
* Change the User-ID of the WordPress Post.
*
*
* @param WP_Post $wp_post
* @return int The User-ID of the WordPress Post
*/
public function __construct( WP_Post $wp_post ) {
$this->wp_post = $wp_post;
public function change_wp_user_id( $user_id ) {
$this->wp_object->post_author = $user_id;
return $this;
}
/**
@ -61,31 +52,25 @@ class Post {
* @return \Activitypub\Activity\Base_Object The ActivityPub Object
*/
public function to_object() {
$wp_post = $this->wp_post;
$object = new Base_Object();
$post = $this->wp_object;
$object = parent::to_object();
$object->set_id( $this->get_id() );
$object->set_url( $this->get_url() );
$object->set_type( $this->get_object_type() );
$published = \strtotime( $wp_post->post_date_gmt );
$published = \strtotime( $post->post_date_gmt );
$object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) );
$updated = \strtotime( $wp_post->post_modified_gmt );
$updated = \strtotime( $post->post_modified_gmt );
if ( $updated > $published ) {
$object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) );
}
$object->set_attributed_to( $this->get_attributed_to() );
$object->set_content( $this->get_content() );
$object->set_content_map(
array(
$this->get_locale() => $this->get_content(),
)
);
$path = sprintf( 'users/%d/followers', intval( $wp_post->post_author ) );
$path = sprintf( 'users/%d/followers', intval( $post->post_author ) );
$object->set_to(
array(
@ -93,9 +78,6 @@ class Post {
get_rest_url_by_path( $path ),
)
);
$object->set_cc( $this->get_cc() );
$object->set_attachment( $this->get_attachments() );
$object->set_tag( $this->get_tags() );
return $object;
}
@ -115,7 +97,7 @@ class Post {
* @return string The Posts URL.
*/
public function get_url() {
$post = $this->wp_post;
$post = $this->wp_object;
if ( 'trash' === get_post_status( $post ) ) {
$permalink = \get_post_meta( $post->ID, 'activitypub_canonical_url', true );
@ -134,12 +116,19 @@ class Post {
* @return string The User-URL.
*/
protected function get_attributed_to() {
$blog_user = new Blog_User();
if ( is_single_user() ) {
$user = new Blog_User();
return $blog_user->get_url();
}
$user = Users::get_by_id( $this->wp_object->post_author );
if ( $user && ! is_wp_error( $user ) ) {
return $user->get_url();
}
return Users::get_by_id( $this->wp_post->post_author )->get_url();
return $blog_user->get_url();
}
/**
@ -147,12 +136,12 @@ class Post {
*
* @return array The Attachments.
*/
protected function get_attachments() {
protected function get_attachment() {
// Once upon a time we only supported images, but we now support audio/video as well.
// We maintain the image-centric naming for backwards compatibility.
$max_media = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) );
if ( site_supports_blocks() && \has_blocks( $this->wp_post->post_content ) ) {
if ( site_supports_blocks() && \has_blocks( $this->wp_object->post_content ) ) {
return $this->get_block_attachments( $max_media );
}
@ -172,7 +161,7 @@ class Post {
return array();
}
$id = $this->wp_post->ID;
$id = $this->wp_object->ID;
$media_ids = array();
@ -182,7 +171,7 @@ class Post {
}
if ( $max_media > 0 ) {
$blocks = \parse_blocks( $this->wp_post->post_content );
$blocks = \parse_blocks( $this->wp_object->post_content );
$media_ids = self::get_media_ids_from_blocks( $blocks, $media_ids, $max_media );
}
@ -191,6 +180,103 @@ class Post {
/**
* Get image attachments from the classic editor.
* This is imperfect as the contained images aren't necessarily the
* same as the attachments.
*
* @param int $max_images The maximum number of images to return.
*
* @return array The attachment IDs.
*/
protected function get_classic_editor_image_attachments( $max_images ) {
// max images can't be negative or zero
if ( $max_images <= 0 ) {
return array();
}
$image_ids = array();
$query = new \WP_Query(
array(
'post_parent' => $this->wp_object->ID,
'post_status' => 'inherit',
'post_type' => 'attachment',
'post_mime_type' => 'image',
'order' => 'ASC',
'orderby' => 'menu_order ID',
'posts_per_page' => $max_images,
)
);
foreach ( $query->get_posts() as $attachment ) {
if ( ! \in_array( $attachment->ID, $image_ids, true ) ) {
$image_ids[] = $attachment->ID;
}
}
return $image_ids;
}
/**
* Get image embeds from the classic editor by parsing HTML.
*
* @param int $max_images The maximum number of images to return.
*
* @return array The attachment IDs.
*/
protected function get_classic_editor_image_embeds( $max_images ) {
// if someone calls that function directly, bail
if ( ! \class_exists( '\WP_HTML_Tag_Processor' ) ) {
return array();
}
// max images can't be negative or zero
if ( $max_images <= 0 ) {
return array();
}
$image_ids = array();
$base = \wp_get_upload_dir()['baseurl'];
$content = \get_post_field( 'post_content', $this->wp_object );
$tags = new \WP_HTML_Tag_Processor( $content );
// This linter warning is a false positive - we have to
// re-count each time here as we modify $image_ids.
// phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
while ( $tags->next_tag( 'img' ) && ( \count( $image_ids ) < $max_images ) ) {
$src = $tags->get_attribute( 'src' );
// If the img source is in our uploads dir, get the
// associated ID. Note: if there's a -500x500
// type suffix, we remove it, but we try the original
// first in case the original image is actually called
// that. Likewise, we try adding the -scaled suffix for
// the case that this is a small version of an image
// that was big enough to get scaled down on upload:
// https://make.wordpress.org/core/2019/10/09/introducing-handling-of-big-images-in-wordpress-5-3/
if ( null !== $src && \str_starts_with( $src, $base ) ) {
$img_id = \attachment_url_to_postid( $src );
if ( 0 === $img_id ) {
$count = 0;
$src = preg_replace( '/-(?:\d+x\d+)(\.[a-zA-Z]+)$/', '$1', $src, 1, $count );
if ( $count > 0 ) {
$img_id = \attachment_url_to_postid( $src );
}
}
if ( 0 === $img_id ) {
$src = preg_replace( '/(\.[a-zA-Z]+)$/', '-scaled$1', $src );
$img_id = \attachment_url_to_postid( $src );
}
if ( 0 !== $img_id ) {
if ( ! \in_array( $img_id, $image_ids, true ) ) {
$image_ids[] = $img_id;
}
}
}
}
return $image_ids;
}
/**
* Get post images from the classic editor.
* Note that audio/video attachments are only supported in the block editor.
*
* @param int $max_images The maximum number of images to return.
@ -203,35 +289,24 @@ class Post {
return array();
}
$id = $this->wp_post->ID;
$id = $this->wp_object->ID;
$image_ids = array();
// list post thumbnail first if this post has one
if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) {
$image_ids[] = \get_post_thumbnail_id( $id );
--$max_images;
}
if ( $max_images > 0 ) {
$query = new \WP_Query(
array(
'post_parent' => $id,
'post_status' => 'inherit',
'post_type' => 'attachment',
'post_mime_type' => 'image',
'order' => 'ASC',
'orderby' => 'menu_order ID',
'posts_per_page' => $max_images,
)
);
foreach ( $query->get_posts() as $attachment ) {
if ( ! \in_array( $attachment->ID, $image_ids, true ) ) {
$image_ids[] = $attachment->ID;
}
if ( \count( $image_ids ) < $max_images ) {
if ( \class_exists( '\WP_HTML_Tag_Processor' ) ) {
$image_ids = \array_merge( $image_ids, $this->get_classic_editor_image_embeds( $max_images ) );
} else {
$image_ids = \array_merge( $image_ids, $this->get_classic_editor_image_attachments( $max_images ) );
}
}
$image_ids = \array_unique( $image_ids );
// unique then slice as the thumbnail may duplicate another image
$image_ids = \array_slice( \array_unique( $image_ids ), 0, $max_images );
return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $image_ids ) );
}
@ -316,7 +391,7 @@ class Post {
*/
$thumbnail = apply_filters(
'activitypub_get_image',
self::get_image( $id, $image_size ),
self::get_wordpress_attachment( $id, $image_size ),
$id,
$image_size
);
@ -365,7 +440,7 @@ class Post {
*
* @return array|false Array of image data, or boolean false if no image is available.
*/
protected static function get_image( $id, $image_size = 'full' ) {
protected static function get_wordpress_attachment( $id, $image_size = 'full' ) {
/**
* Hook into the image retrieval process. Before image retrieval.
*
@ -395,17 +470,17 @@ class Post {
*
* @return string The Object-Type.
*/
protected function get_object_type() {
protected function get_type() {
if ( 'wordpress-post-format' !== \get_option( 'activitypub_object_type', 'note' ) ) {
return \ucfirst( \get_option( 'activitypub_object_type', 'note' ) );
}
// Default to Article.
$object_type = 'Article';
$post_type = \get_post_type( $this->wp_post );
$post_type = \get_post_type( $this->wp_object );
switch ( $post_type ) {
case 'post':
$post_format = \get_post_format( $this->wp_post );
$post_format = \get_post_format( $this->wp_object );
switch ( $post_format ) {
case 'aside':
case 'status':
@ -481,10 +556,10 @@ class Post {
*
* @return array The list of Tags.
*/
protected function get_tags() {
protected function get_tag() {
$tags = array();
$post_tags = \get_the_tags( $this->wp_post->ID );
$post_tags = \get_the_tags( $this->wp_object->ID );
if ( $post_tags ) {
foreach ( $post_tags as $post_tag ) {
$tag = array(
@ -531,7 +606,7 @@ class Post {
do_action( 'activitypub_before_get_content', $post );
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$post = $this->wp_post;
$post = $this->wp_object;
$content = $this->get_post_content_template();
// Register our shortcodes just in time.
@ -559,19 +634,24 @@ class Post {
* @return string The Template.
*/
protected function get_post_content_template() {
if ( 'excerpt' === \get_option( 'activitypub_post_content_type', 'content' ) ) {
return "[ap_excerpt]\n\n[ap_permalink type=\"html\"]";
$type = \get_option( 'activitypub_post_content_type', 'content' );
switch ( $type ) {
case 'excerpt':
$template = "[ap_excerpt]\n\n[ap_permalink type=\"html\"]";
break;
case 'title':
$template = "[ap_title]\n\n[ap_permalink type=\"html\"]";
break;
case 'content':
$template = "[ap_content]\n\n[ap_permalink type=\"html\"]\n\n[ap_hashtags]";
break;
default:
$template = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
break;
}
if ( 'title' === \get_option( 'activitypub_post_content_type', 'content' ) ) {
return "[ap_title]\n\n[ap_permalink type=\"html\"]";
}
if ( 'content' === \get_option( 'activitypub_post_content_type', 'content' ) ) {
return "[ap_content]\n\n[ap_permalink type=\"html\"]\n\n[ap_hashtags]";
}
return \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
return apply_filters( 'activitypub_object_content_template', $template, $this->wp_object );
}
/**
@ -580,7 +660,7 @@ class Post {
* @return array The list of @-Mentions.
*/
protected function get_mentions() {
return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_post->post_content, $this->wp_post );
return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_object->post_content, $this->wp_object );
}
/**
@ -589,7 +669,7 @@ class Post {
* @return string The locale of the post.
*/
public function get_locale() {
$post_id = $this->wp_post->ID;
$post_id = $this->wp_object->ID;
$lang = \strtolower( \strtok( \get_locale(), '_-' ) );
/**
@ -601,6 +681,6 @@ class Post {
*
* @return string The filtered locale of the post.
*/
return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_post );
return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_object );
}
}