updated plugin ActivityPub version 2.3.0

This commit is contained in:
2024-04-19 10:49:31 +00:00
committed by Gitium
parent 34dbbce9b4
commit 620280b550
39 changed files with 797 additions and 663 deletions

View File

@ -52,7 +52,7 @@ class Actor extends Base_Object {
/**
* @var string
*/
protected $type = 'Person';
protected $type;
/**
* A reference to an ActivityStreams OrderedCollection comprised of

View File

@ -44,9 +44,6 @@ class Activity_Dispatcher {
* @return void
*/
public static function send_activity_or_announce( $wp_object, $type ) {
// check if a migration is needed before sending new posts
Migration::maybe_migrate();
if ( is_user_type_disabled( 'blog' ) ) {
return;
}

View File

@ -38,8 +38,9 @@ class Activitypub {
\add_action( 'untrash_post', array( self::class, 'untrash_post' ), 1 );
\add_action( 'init', array( self::class, 'add_rewrite_rules' ), 11 );
\add_action( 'init', array( self::class, 'theme_compat' ), 11 );
\add_action( 'after_setup_theme', array( self::class, 'theme_compat' ), 99 );
\add_action( 'user_register', array( self::class, 'user_register' ) );
\add_action( 'in_plugin_update_message-' . ACTIVITYPUB_PLUGIN_BASENAME, array( self::class, 'plugin_update_message' ) );
@ -202,14 +203,16 @@ class Activitypub {
$avatar = self::get_avatar_url( $id_or_email->comment_ID );
if ( $avatar ) {
if ( ! isset( $args['class'] ) || ! \is_array( $args['class'] ) ) {
$args['class'] = array( 'u-photo' );
} else {
$args['class'][] = 'u-photo';
$args['class'] = \array_unique( $args['class'] );
if ( empty( $args['class'] ) ) {
$args['class'] = array();
} elseif ( \is_string( $args['class'] ) ) {
$args['class'] = \explode( ' ', $args['class'] );
}
$args['url'] = $avatar;
$args['class'][] = 'avatar-activitypub';
$args['class'][] = 'u-photo';
$args['class'] = \array_unique( $args['class'] );
}
return $args;
@ -334,6 +337,23 @@ class Activitypub {
);
add_theme_support( 'custom-header', $custom_header_args );
}
// We assume that you want to use Post-Formats when enabling the setting
if ( 'wordpress-post-format' === \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ) ) {
if ( ! get_theme_support( 'post-formats' ) ) {
// Add support for the Aside, Gallery Post Formats...
add_theme_support(
'post-formats',
array(
'gallery',
'status',
'image',
'video',
'audio',
)
);
}
}
}
/**
@ -437,4 +457,17 @@ class Activitypub {
\do_action( 'activitypub_after_register_post_type' );
}
/**
* Add the 'activitypub' query variable so WordPress won't mangle it.
*
* @param int $user_id User ID.
* @param array $userdata The raw array of data passed to wp_insert_user().
*/
public static function user_register( $user_id ) {
if ( \user_can( $user_id, 'publish_posts' ) ) {
$user = \get_user_by( 'id', $user_id );
$user->add_cap( 'activitypub' );
}
}
}

View File

@ -24,7 +24,15 @@ class Admin {
\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' ) );
\add_filter( 'comment_row_actions', array( self::class, 'comment_row_actions' ), 10, 2 );
\add_filter( 'manage_edit-comments_columns', array( static::class, 'manage_comment_columns' ) );
\add_filter( 'manage_comments_custom_column', array( static::class, 'manage_comments_custom_column' ), 9, 2 );
\add_filter( 'manage_users_columns', array( self::class, 'manage_users_columns' ), 10, 1 );
\add_filter( 'manage_users_custom_column', array( self::class, 'manage_users_custom_column' ), 10, 3 );
\add_filter( 'bulk_actions-users', array( self::class, 'user_bulk_options' ) );
\add_filter( 'handle_bulk_actions-users', array( self::class, 'handle_bulk_request' ), 10, 3 );
if ( ! is_user_disabled( get_current_user_id() ) ) {
\add_action( 'show_user_profile', array( self::class, 'add_profile' ) );
@ -61,7 +69,7 @@ class Admin {
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' );
$admin_notice = \__( 'You are using the ActivityPub plugin with a permalink structure of "plain". This will prevent ActivityPub from working. Please go to "Settings" / "Permalinks" and choose a permalink structure other than "plain".', 'activitypub' );
self::show_admin_notice( $admin_notice, 'error' );
}
}
@ -175,7 +183,6 @@ class Admin {
'schema' => array(
'enum' => array(
'note',
'article',
'wordpress-post-format',
),
),
@ -346,4 +353,120 @@ class Admin {
return $actions;
}
/**
* Add a column "activitypub"
*
* This column shows if the user has the capability to use ActivityPub.
*
* @param array $columns The columns.
*
* @return array The columns extended by the activitypub.
*/
public static function manage_users_columns( $columns ) {
$columns['activitypub'] = __( 'ActivityPub', 'activitypub' );
return $columns;
}
/**
* Add "comment-type" and "protocol" as column in WP-Admin
*
* @param array $columns the list of column names
*/
public static function manage_comment_columns( $columns ) {
$columns['comment_type'] = esc_attr__( 'Comment-Type', 'activitypub' );
$columns['comment_protocol'] = esc_attr__( 'Protocol', 'activitypub' );
return $columns;
}
/**
* Add "comment-type" and "protocol" as column in WP-Admin
*
* @param array $column The column to implement
* @param int $comment_id The comment id
*/
public static function manage_comments_custom_column( $column, $comment_id ) {
if ( 'comment_type' === $column && ! defined( 'WEBMENTION_PLUGIN_DIR' ) ) {
echo esc_attr( ucfirst( get_comment_type( $comment_id ) ) );
} elseif ( 'comment_protocol' === $column ) {
$protocol = get_comment_meta( $comment_id, 'protocol', true );
if ( $protocol ) {
echo esc_attr( ucfirst( str_replace( 'activitypub', 'ActivityPub', $protocol ) ) );
} else {
esc_attr_e( 'Local', 'activitypub' );
}
}
}
/**
* Return the results for the activitypub column.
*
* @param string $output Custom column output. Default empty.
* @param string $column_name Column name.
* @param int $user_id ID of the currently-listed user.
*
* @return string The column contents.
*/
public static function manage_users_custom_column( $output, $column_name, $user_id ) {
if ( 'activitypub' !== $column_name ) {
return $output;
}
if ( \user_can( $user_id, 'activitypub' ) ) {
return '✓';
} else {
return '✗';
}
}
/**
* Add options to the Bulk dropdown on the users page
*
* @param array $actions The existing bulk options.
*
* @return array The extended bulk options.
*/
public static function user_bulk_options( $actions ) {
$actions['add_activitypub_cap'] = __( 'Enable for ActivityPub', 'activitypub' );
$actions['remove_activitypub_cap'] = __( 'Disable for ActivityPub', 'activitypub' );
return $actions;
}
/**
* Handle bulk activitypub requests
*
* * `add_activitypub_cap` - Add the activitypub capability to the selected users.
* * `remove_activitypub_cap` - Remove the activitypub capability from the selected users.
*
* @param string $sendback The URL to send the user back to.
* @param string $action The requested action.
* @param array $users The selected users.
*
* @return string The URL to send the user back to.
*/
public static function handle_bulk_request( $sendback, $action, $users ) {
if (
'remove_activitypub_cap' !== $action &&
'add_activitypub_cap' !== $action
) {
return $sendback;
}
foreach ( $users as $user_id ) {
$user = new \WP_User( $user_id );
if (
'add_activitypub_cap' === $action &&
user_can( $user_id, 'publish_posts' )
) {
$user->add_cap( 'activitypub' );
} elseif ( 'remove_activitypub_cap' === $action ) {
$user->remove_cap( 'activitypub' );
}
}
return $sendback;
}
}

View File

@ -2,9 +2,11 @@
namespace Activitypub;
use Activitypub\Collection\Users;
use WP_Comment_Query;
use function Activitypub\is_user_disabled;
use function Activitypub\is_single_user;
/**
* ActivityPub Comment Class
@ -37,6 +39,11 @@ class Comment {
*/
public static function comment_reply_link( $link, $args, $comment ) {
if ( self::are_comments_allowed( $comment ) ) {
$user_id = get_current_user_id();
if ( $user_id && self::was_received( $comment ) && \user_can( $user_id, 'activitypub' ) ) {
return self::create_fediverse_reply_link( $link, $args );
}
return $link;
}
@ -53,6 +60,27 @@ class Comment {
return apply_filters( 'activitypub_comment_reply_link', $div );
}
/**
* Create a link to reply to a federated comment.
* This function adds a title attribute to the reply link to inform the user
* that the comment was received from the fediverse and the reply will be sent
* to the original author.
*
* @param string $link The HTML markup for the comment reply link.
* @param array $args The args provided by the `comment_reply_link` filter.
*
* @return string The modified HTML markup for the comment reply link.
*/
private static function create_fediverse_reply_link( $link, $args ) {
$str_to_replace = sprintf( '>%s<', $args['reply_text'] );
$replace_with = sprintf(
' title="%s">%s<',
esc_attr__( 'This comment was received from the fediverse and your reply will be sent to the original author', 'activitypub' ),
esc_html__( 'Reply with federation', 'activitypub' )
);
return str_replace( $str_to_replace, $replace_with, $link );
}
/**
* Check if it is allowed to comment to a comment.
*
@ -75,6 +103,11 @@ class Comment {
return false;
}
if ( is_single_user() && \user_can( $current_user, 'publish_posts' ) ) {
// On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user
$current_user = Users::BLOG_USER_ID;
}
$is_user_disabled = is_user_disabled( $current_user );
if ( $is_user_disabled ) {
@ -180,6 +213,11 @@ class Comment {
return false;
}
if ( is_single_user() && \user_can( $user_id, 'publish_posts' ) ) {
// On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user
$user_id = Users::BLOG_USER_ID;
}
$is_user_disabled = is_user_disabled( $user_id );
// user is disabled for federation
@ -330,12 +368,14 @@ class Comment {
* @return string ActivityPub URI for comment
*/
public static function generate_id( $comment ) {
$comment = get_comment( $comment );
$comment = \get_comment( $comment );
$comment_meta = \get_comment_meta( $comment->comment_ID );
// show external comment ID if it exists
$source_id = get_comment_meta( $comment->comment_ID, 'source_id', true );
if ( ! empty( $source_id ) ) {
return $source_id;
if ( ! empty( $comment_meta['source_id'][0] ) ) {
return $comment_meta['source_id'][0];
} elseif ( ! empty( $comment_meta['source_url'][0] ) ) {
return $comment_meta['source_url'][0];
}
// generate URI based on comment ID
@ -347,13 +387,59 @@ class Comment {
);
}
/**
* Check if a post has remote comments
*
* @param int $post_id The post ID.
*
* @return bool True if the post has remote comments, false otherwise.
*/
private static function post_has_remote_comments( $post_id ) {
$comments = \get_comments(
array(
'post_id' => $post_id,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'protocol',
'value' => 'activitypub',
'compare' => '=',
),
array(
'key' => 'source_id',
'compare' => 'EXISTS',
),
),
)
);
return ! empty( $comments );
}
/**
* Enqueue scripts for remote comments
*/
public static function enqueue_scripts() {
if ( ! is_singular() ) {
if ( ! \is_singular() || \is_user_logged_in() ) {
// only on single pages, only for logged out users
return;
}
if ( ! \post_type_supports( \get_post_type(), 'activitypub' ) ) {
// post type does not support ActivityPub
return;
}
if ( ! \comments_open() || ! \get_comments_number() ) {
// no comments, no need to load the script
return;
}
if ( ! self::post_has_remote_comments( \get_the_ID() ) ) {
// no remote comments, no need to load the script
return;
}
$handle = 'activitypub-remote-reply';
$data = array(
'namespace' => ACTIVITYPUB_REST_NAMESPACE,

View File

@ -4,6 +4,8 @@ namespace Activitypub;
use WP_Error;
use Activitypub\Collection\Users;
use function Activitypub\get_masked_wp_version;
/**
* ActivityPub HTTP Class
*
@ -26,7 +28,7 @@ class Http {
$digest = Signature::generate_digest( $body );
$signature = Signature::generate_signature( $user_id, 'post', $url, $date, $digest );
$wp_version = \get_bloginfo( 'version' );
$wp_version = get_masked_wp_version();
/**
* Filter the HTTP headers user agent.
@ -75,7 +77,7 @@ class Http {
$date = \gmdate( 'D, d M Y H:i:s T' );
$signature = Signature::generate_signature( Users::APPLICATION_USER_ID, 'get', $url, $date );
$wp_version = \get_bloginfo( 'version' );
$wp_version = get_masked_wp_version();
/**
* Filter the HTTP headers user agent.

View File

@ -15,7 +15,9 @@ class Migration {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_schedule_migration', array( self::class, 'maybe_migrate' ) );
\add_action( 'activitypub_migrate', array( self::class, 'async_migration' ) );
self::maybe_migrate();
}
/**
@ -108,18 +110,28 @@ class Migration {
$version_from_db = self::get_version();
// check for inital migration
if ( ! $version_from_db ) {
self::add_default_settings();
$version_from_db = self::get_target_version();
}
// schedule the async migration
if ( ! \wp_next_scheduled( 'activitypub_migrate', $version_from_db ) ) {
\wp_schedule_single_event( \time(), 'activitypub_migrate', $version_from_db );
}
if ( version_compare( $version_from_db, '0.17.0', '<' ) ) {
self::migrate_from_0_16();
}
if ( version_compare( $version_from_db, '1.0.0', '<' ) ) {
self::migrate_from_0_17();
}
if ( version_compare( $version_from_db, '1.3.0', '<' ) ) {
self::migrate_from_1_2_0();
}
if ( version_compare( $version_from_db, '2.1.0', '<' ) ) {
self::migrate_from_2_0_0();
}
if ( version_compare( $version_from_db, '2.3.0', '<' ) ) {
self::migrate_from_2_2_0();
}
update_option( 'activitypub_db_version', self::get_target_version() );
@ -127,23 +139,14 @@ class Migration {
}
/**
* Updates the DB-schema of the followers-list
* Asynchronously migrates the database structure.
*
* @return void
* @param string $version_from_db The version from which to migrate.
*/
private static function migrate_from_0_17() {
// migrate followers
foreach ( get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
$followers = get_user_meta( $user_id, 'activitypub_followers', true );
if ( $followers ) {
foreach ( $followers as $actor ) {
Followers::add_follower( $user_id, $actor );
}
}
public static function async_migration( $version_from_db ) {
if ( version_compare( $version_from_db, '1.0.0', '<' ) ) {
self::migrate_from_0_17();
}
Activitypub::flush_rewrite_rules();
}
/**
@ -183,13 +186,33 @@ class Migration {
}
}
/**
* Updates the DB-schema of the followers-list
*
* @return void
*/
public static function migrate_from_0_17() {
// migrate followers
foreach ( get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
$followers = get_user_meta( $user_id, 'activitypub_followers', true );
if ( $followers ) {
foreach ( $followers as $actor ) {
Followers::add_follower( $user_id, $actor );
}
}
}
Activitypub::flush_rewrite_rules();
}
/**
* Clear the cache after updating to 1.3.0
*
* @return void
*/
private static function migrate_from_1_2_0() {
$user_ids = get_users(
$user_ids = \get_users(
array(
'fields' => 'ID',
'capability__in' => array( 'publish_posts' ),
@ -214,5 +237,51 @@ class Migration {
wp_unschedule_hook( 'activitypub_send_post_activity' );
wp_unschedule_hook( 'activitypub_send_update_activity' );
wp_unschedule_hook( 'activitypub_send_delete_activity' );
$object_type = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE );
if ( 'article' === $object_type ) {
\update_option( 'activitypub_object_type', 'wordpress-post-format' );
}
}
/**
* Add the ActivityPub capability to all users that can publish posts
* Delete old meta to store followers
*
* @return void
*/
private static function migrate_from_2_2_0() {
// add the ActivityPub capability to all users that can publish posts
self::add_activitypub_capability();
}
/**
* Set the defaults needed for the plugin to work
*
* * Add the ActivityPub capability to all users that can publish posts
*
* @return void
*/
public static function add_default_settings() {
self::add_activitypub_capability();
}
/**
* Add the ActivityPub capability to all users that can publish posts
*
* @return void
*/
private static function add_activitypub_capability() {
// get all WP_User objects that can publish posts
$users = \get_users(
array(
'capability__in' => array( 'publish_posts' ),
)
);
// add ActivityPub capability to all users that can publish posts
foreach ( $users as $user ) {
$user->add_cap( 'activitypub' );
}
}
}

View File

@ -64,9 +64,6 @@ class Scheduler {
\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' ) );
@ -265,17 +262,6 @@ class Scheduler {
}
}
/**
* Schedule migration if DB-Version is not up to date.
*
* @return void
*/
public static function schedule_migration() {
if ( ! \wp_next_scheduled( 'activitypub_schedule_migration' ) && ! Migration::is_latest_version() ) {
\wp_schedule_single_event( \time(), 'activitypub_schedule_migration' );
}
}
/**
* Send a profile update when relevant user meta is updated.
*
@ -287,7 +273,7 @@ class Scheduler {
*/
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' ) ) {
if ( ! \user_can( $user_id, 'activitypub' ) ) {
return;
}
// the user meta fields that affect a profile.
@ -311,7 +297,7 @@ class Scheduler {
*/
public static function user_update( $user_id ) {
// don't bother if the user can't publish
if ( ! \user_can( $user_id, 'publish_posts' ) ) {
if ( ! \user_can( $user_id, 'activitypub' ) ) {
return;
}

View File

@ -271,7 +271,7 @@ class Shortcodes {
}
return \sprintf(
'<a href="%1$s">%1$s</a>',
'<a href="%1$s" class="status-link unhandled-link">%1$s</a>',
\esc_url( \get_permalink( $item->ID ) )
);
}
@ -305,7 +305,7 @@ class Shortcodes {
}
return \sprintf(
'<a href="%1$s">%1$s</a>',
'<a href="%1$s" class="status-link unhandled-link">%1$s</a>',
\esc_url( \wp_get_shortlink( $item->ID ) )
);
}

View File

@ -268,7 +268,7 @@ class Users {
public static function get_collection() {
$users = \get_users(
array(
'capability__in' => array( 'publish_posts' ),
'capability__in' => array( 'activitypub' ),
)
);

View File

@ -384,7 +384,7 @@ function is_user_disabled( $user_id ) {
break;
}
if ( ! \user_can( $user_id, 'publish_posts' ) ) {
if ( ! \user_can( $user_id, 'activitypub' ) ) {
$return = true;
break;
}
@ -644,7 +644,7 @@ function get_total_users() {
$users = \get_users(
array(
'capability__in' => array( 'publish_posts' ),
'capability__in' => array( 'activitypub' ),
)
);
@ -827,3 +827,19 @@ function get_post_type_description( $post_type ) {
return apply_filters( 'activitypub_post_type_description', $description, $post_type->name, $post_type );
}
/**
* Get the masked WordPress version to only show the major and minor version.
*
* @return string The masked version.
*/
function get_masked_wp_version() {
// only show the major and minor version
$version = get_bloginfo( 'version' );
// strip the RC or beta part
$version = preg_replace( '/-.*$/', '', $version );
$version = explode( '.', $version );
$version = array_slice( $version, 0, 2 );
return implode( '.', $version );
}

View File

@ -75,9 +75,13 @@ class Comment {
return $template;
}
$resource = \get_comment_meta( $comment_id, 'source_id', true );
$comment_meta = \get_comment_meta( $comment_id );
if ( ! $resource ) {
if ( ! empty( $comment_meta['source_id'][0] ) ) {
$resource = $comment_meta['source_id'][0];
} elseif ( ! empty( $comment_meta['source_url'][0] ) ) {
$resource = $comment_meta['source_url'][0];
} else {
$resource = Comment_Utils::generate_id( $comment );
}

View File

@ -9,6 +9,7 @@ use Activitypub\Collection\Users as User_Collection;
use Activitypub\Collection\Followers as Follower_Collection;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
/**
* ActivityPub Followers REST-Class
@ -74,7 +75,7 @@ class Followers {
$json->{'@context'} = \Activitypub\get_context();
$json->id = get_rest_url_by_path( sprintf( 'users/%d/followers', $user->get__id() ) );
$json->generator = 'http://wordpress.org/?v=' . \get_bloginfo_rss( 'version' );
$json->generator = 'http://wordpress.org/?v=' . get_masked_wp_version();
$json->actor = $user->get_id();
$json->type = 'OrderedCollectionPage';

View File

@ -6,6 +6,7 @@ use Activitypub\Collection\Users as User_Collection;
use function Activitypub\is_single_user;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
/**
* ActivityPub Following REST-Class
@ -67,7 +68,7 @@ class Following {
$json->{'@context'} = \Activitypub\get_context();
$json->id = get_rest_url_by_path( sprintf( 'users/%d/following', $user->get__id() ) );
$json->generator = 'http://wordpress.org/?v=' . \get_bloginfo_rss( 'version' );
$json->generator = 'http://wordpress.org/?v=' . get_masked_wp_version();
$json->actor = $user->get_id();
$json->type = 'OrderedCollectionPage';

View File

@ -11,6 +11,7 @@ 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_masked_wp_version;
use function Activitypub\extract_recipients_from_activity;
/**
@ -90,7 +91,7 @@ class Inbox {
$json->{'@context'} = get_context();
$json->id = get_rest_url_by_path( sprintf( 'users/%d/inbox', $user->get__id() ) );
$json->generator = 'http://wordpress.org/?v=' . \get_bloginfo_rss( 'version' );
$json->generator = 'http://wordpress.org/?v=' . get_masked_wp_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

View File

@ -6,6 +6,7 @@ use WP_REST_Response;
use function Activitypub\get_total_users;
use function Activitypub\get_active_users;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
/**
* ActivityPub NodeInfo REST-Class
@ -81,7 +82,7 @@ class Nodeinfo {
$nodeinfo['version'] = '2.0';
$nodeinfo['software'] = array(
'name' => 'wordpress',
'version' => \get_bloginfo( 'version' ),
'version' => get_masked_wp_version(),
);
$posts = \wp_count_posts();
@ -134,7 +135,7 @@ class Nodeinfo {
'baseUrl' => \home_url( '/' ),
'name' => \get_bloginfo( 'name' ),
'software' => 'wordpress',
'version' => \get_bloginfo( 'version' ),
'version' => get_masked_wp_version(),
);
$posts = \wp_count_posts();

View File

@ -11,6 +11,7 @@ use Activitypub\Collection\Users as User_Collection;
use function Activitypub\get_context;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
/**
* ActivityPub Outbox REST-Class
@ -72,15 +73,20 @@ class Outbox {
$json->{'@context'} = get_context();
$json->id = get_rest_url_by_path( sprintf( 'users/%d/outbox', $user_id ) );
$json->generator = 'http://wordpress.org/?v=' . \get_bloginfo_rss( 'version' );
$json->generator = 'http://wordpress.org/?v=' . get_masked_wp_version();
$json->actor = $user->get_id();
$json->type = 'OrderedCollectionPage';
$json->partOf = get_rest_url_by_path( sprintf( 'users/%d/outbox', $user_id ) ); // phpcs:ignore
$json->totalItems = 0; // phpcs:ignore
foreach ( $post_types as $post_type ) {
$count_posts = \wp_count_posts( $post_type );
$json->totalItems += \intval( $count_posts->publish ); // phpcs:ignore
if ( $user_id > 0 ) {
$count_posts = \count_user_posts( $user_id, $post_types, true );
$json->totalItems = \intval( $count_posts ); // phpcs:ignore
} else {
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
@ -98,7 +104,7 @@ class Outbox {
$posts = \get_posts(
array(
'posts_per_page' => 10,
'author' => $user_id,
'author' => $user_id > 0 ? $user_id : null,
'paged' => $page,
'post_type' => $post_types,
)

View File

@ -46,4 +46,16 @@ class Attachment extends Post {
return $attachment;
}
/**
* Returns the ActivityStreams 2.0 Object-Type for a Post based on the
* settings and the Post-Type.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
*
* @return string The Object-Type.
*/
protected function get_type() {
return 'Note';
}
}

View File

@ -174,7 +174,7 @@ class Comment extends Base {
$mentions = $this->get_mentions();
if ( $mentions ) {
foreach ( $mentions as $mention => $url ) {
foreach ( $mentions as $url ) {
$cc[] = $url;
}
}

View File

@ -1,6 +1,8 @@
<?php
namespace Activitypub\Transformer;
use WP_Error;
use Activitypub\Transformer\Base;
use Activitypub\Transformer\Post;
use Activitypub\Transformer\Comment;
use Activitypub\Transformer\Attachment;
@ -10,6 +12,12 @@ use Activitypub\Transformer\Attachment;
*/
class Factory {
public static function get_transformer( $object ) {
if ( ! \is_object( $object ) ) {
return new WP_Error( 'invalid_object', __( 'Invalid object', 'activitypub' ) );
}
$class = \get_class( $object );
/**
* Filter the transformer for a given object.
*
@ -39,14 +47,21 @@ class Factory {
*
* @return mixed The transformer to use.
*/
$transformer = apply_filters( 'activitypub_transformer', null, $object, get_class( $object ) );
$transformer = \apply_filters( 'activitypub_transformer', null, $object, $class );
if ( $transformer ) {
if (
! \is_object( $transformer ) ||
! $transformer instanceof Base
) {
return new WP_Error( 'invalid_transformer', __( 'Invalid transformer', 'activitypub' ) );
}
return $transformer;
}
// use default transformer
switch ( get_class( $object ) ) {
switch ( $class ) {
case 'WP_Post':
if ( 'attachment' === $object->post_type ) {
return new Attachment( $object );

View File

@ -139,13 +139,19 @@ class Post extends Base {
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 ) ) );
$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_object->post_content ) ) {
return $this->get_block_attachments( $max_media );
$media = $this->get_block_attachments( $max_media );
} else {
$media = $this->get_classic_editor_images( $max_media );
}
return $this->get_classic_editor_images( $max_media );
$unique_ids = \array_unique( \array_column( $media, 'id' ) );
$media = \array_intersect_key( $media, $unique_ids );
$media = \array_slice( $media, 0, $max_media );
return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $media ) );
}
/**
@ -163,19 +169,23 @@ class Post extends Base {
$id = $this->wp_object->ID;
$media_ids = array();
$media = array(
'image' => array(),
'audio' => array(),
'video' => array(),
);
// list post thumbnail first if this post has one
if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) {
$media_ids[] = \get_post_thumbnail_id( $id );
$media['image'][] = array( 'id' => \get_post_thumbnail_id( $id ) );
}
if ( $max_media > 0 ) {
$blocks = \parse_blocks( $this->wp_object->post_content );
$media_ids = self::get_media_ids_from_blocks( $blocks, $media_ids, $max_media );
$media = self::get_media_from_blocks( $blocks, $media );
}
return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $media_ids ) );
return self::filter_media_by_object_type( $media, \get_post_format( $this->wp_object ) );
}
/**
@ -192,7 +202,7 @@ class Post extends Base {
if ( $max_images <= 0 ) {
return array();
}
$image_ids = array();
$images = array();
$query = new \WP_Query(
array(
'post_parent' => $this->wp_object->ID,
@ -205,11 +215,12 @@ class Post extends Base {
)
);
foreach ( $query->get_posts() as $attachment ) {
if ( ! \in_array( $attachment->ID, $image_ids, true ) ) {
$image_ids[] = $attachment->ID;
if ( ! \in_array( $attachment->ID, $images, true ) ) {
$images[] = array( 'id' => $attachment->ID );
}
}
return $image_ids;
return $images;
}
/**
@ -217,7 +228,7 @@ class Post extends Base {
*
* @param int $max_images The maximum number of images to return.
*
* @return array The attachment IDs.
* @return array The attachments.
*/
protected function get_classic_editor_image_embeds( $max_images ) {
// if someone calls that function directly, bail
@ -230,15 +241,15 @@ class Post extends Base {
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 );
$images = 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.
// re-count each time here as we modify $images.
// phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
while ( $tags->next_tag( 'img' ) && ( \count( $image_ids ) < $max_images ) ) {
while ( $tags->next_tag( 'img' ) && ( \count( $images ) < $max_images ) ) {
$src = $tags->get_attribute( 'src' );
// If the img source is in our uploads dir, get the
@ -266,13 +277,15 @@ class Post extends Base {
}
if ( 0 !== $img_id ) {
if ( ! \in_array( $img_id, $image_ids, true ) ) {
$image_ids[] = $img_id;
}
$images[] = array(
'id' => $img_id,
'alt' => $tags->get_attribute( 'alt' ),
);
}
}
}
return $image_ids;
return $images;
}
/**
@ -291,91 +304,125 @@ class Post extends Base {
$id = $this->wp_object->ID;
$image_ids = array();
$images = 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 );
$images[] = \get_post_thumbnail_id( $id );
}
if ( \count( $image_ids ) < $max_images ) {
if ( \count( $images ) < $max_images ) {
if ( \class_exists( '\WP_HTML_Tag_Processor' ) ) {
$image_ids = \array_merge( $image_ids, $this->get_classic_editor_image_embeds( $max_images ) );
$images = \array_merge( $images, $this->get_classic_editor_image_embeds( $max_images ) );
} else {
$image_ids = \array_merge( $image_ids, $this->get_classic_editor_image_attachments( $max_images ) );
$images = \array_merge( $images, $this->get_classic_editor_image_attachments( $max_images ) );
}
}
// 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 ) );
return $images;
}
/**
* Recursively get media IDs from blocks.
* @param array $blocks The blocks to search for media IDs
* @param array $media_ids The media IDs to append new IDs to
* @param array $media The media IDs to append new IDs to
* @param int $max_media The maximum number of media to return.
*
* @return array The image IDs.
*/
protected static function get_media_ids_from_blocks( $blocks, $media_ids, $max_media ) {
protected static function get_media_from_blocks( $blocks, $media ) {
foreach ( $blocks as $block ) {
// recurse into inner blocks
if ( ! empty( $block['innerBlocks'] ) ) {
$media_ids = self::get_media_ids_from_blocks( $block['innerBlocks'], $media_ids, $max_media );
$media = self::get_media_from_blocks( $block['innerBlocks'], $media );
}
switch ( $block['blockName'] ) {
case 'core/image':
case 'core/cover':
if ( ! empty( $block['attrs']['id'] ) ) {
$alt = '';
$check = preg_match( '/<img.*?alt=[\"\'](.*?)[\"\'].*>/i', $block['innerHTML'], $match );
if ( $check ) {
$alt = $match[1];
}
$media['image'][] = array(
'id' => $block['attrs']['id'],
'alt' => $alt,
);
}
break;
case 'core/audio':
if ( ! empty( $block['attrs']['id'] ) ) {
$media['audio'][] = array( 'id' => $block['attrs']['id'] );
}
break;
case 'core/video':
case 'videopress/video':
if ( ! empty( $block['attrs']['id'] ) ) {
$media_ids[] = $block['attrs']['id'];
$media['video'][] = array( 'id' => $block['attrs']['id'] );
}
break;
case 'jetpack/slideshow':
case 'jetpack/tiled-gallery':
if ( ! empty( $block['attrs']['ids'] ) ) {
$media_ids = array_merge( $media_ids, $block['attrs']['ids'] );
$media['image'] = array_merge(
$media['image'],
array_map(
function ( $id ) {
return array( 'id' => $id );
},
$block['attrs']['ids']
)
);
}
break;
case 'jetpack/image-compare':
if ( ! empty( $block['attrs']['beforeImageId'] ) ) {
$media_ids[] = $block['attrs']['beforeImageId'];
$media['image'][] = array( 'id' => $block['attrs']['beforeImageId'] );
}
if ( ! empty( $block['attrs']['afterImageId'] ) ) {
$media_ids[] = $block['attrs']['afterImageId'];
$media['image'][] = array( 'id' => $block['attrs']['afterImageId'] );
}
break;
}
// depupe
$media_ids = \array_unique( $media_ids );
// stop doing unneeded work
if ( count( $media_ids ) >= $max_media ) {
break;
}
}
// still need to slice it because one gallery could knock us over the limit
return array_slice( $media_ids, 0, $max_media );
return $media;
}
/**
* Filter media IDs by object type.
*
* @param array $media The media array grouped by type.
* @param array $type The object type.
*
* @return array The filtered media IDs.
*/
protected static function filter_media_by_object_type( $media, $type ) {
$type = \apply_filters( 'filter_media_by_object_type', \strtolower( $type ) );
if ( ! empty( $media[ $type ] ) ) {
return $media[ $type ];
}
return array_merge( array(), ...array_values( $media ) );
}
/**
* Converts a WordPress Attachment to an ActivityPub Attachment.
*
* @param int $id The Attachment ID.
* @param array $media The Attachment array.
*
* @return array The ActivityPub Attachment.
*/
public static function wp_attachment_to_activity_attachment( $id ) {
$attachment = array();
$mime_type = \get_post_mime_type( $id );
public static function wp_attachment_to_activity_attachment( $media ) {
$id = $media['id'];
$attachment = array();
$mime_type = \get_post_mime_type( $id );
$mime_type_parts = \explode( '/', $mime_type );
// switching on image/audio/video
switch ( $mime_type_parts[0] ) {
@ -397,16 +444,21 @@ class Post extends Base {
);
if ( $thumbnail ) {
$alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
$image = array(
'type' => 'Image',
'url' => $thumbnail[0],
'mediaType' => $mime_type,
'url' => \esc_url( $thumbnail[0] ),
'mediaType' => \esc_attr( $mime_type ),
);
if ( $alt ) {
$image['name'] = $alt;
if ( ! empty( $media['alt'] ) ) {
$image['name'] = \esc_attr( $media['alt'] );
} else {
$alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
if ( $alt ) {
$image['name'] = \esc_attr( $alt );
}
}
$attachment = $image;
}
break;
@ -415,15 +467,15 @@ class Post extends Base {
case 'video':
$attachment = array(
'type' => 'Document',
'mediaType' => $mime_type,
'url' => \wp_get_attachment_url( $id ),
'name' => \get_the_title( $id ),
'mediaType' => \esc_attr( $mime_type ),
'url' => \esc_url( \wp_get_attachment_url( $id ) ),
'name' => \esc_attr( \get_the_title( $id ) ),
);
$meta = wp_get_attachment_metadata( $id );
// height and width for videos
if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) {
$attachment['width'] = $meta['width'];
$attachment['height'] = $meta['height'];
$attachment['width'] = \esc_attr( $meta['width'] );
$attachment['height'] = \esc_attr( $meta['height'] );
}
// @todo: add `icon` support for audio/video attachments. Maybe use post thumbnail?
break;
@ -471,32 +523,39 @@ class Post extends Base {
* @return string The Object-Type.
*/
protected function get_type() {
if ( 'wordpress-post-format' !== \get_option( 'activitypub_object_type', 'note' ) ) {
return \ucfirst( \get_option( 'activitypub_object_type', 'note' ) );
$post_format_setting = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE );
if ( 'wordpress-post-format' !== $post_format_setting ) {
return \ucfirst( $post_format_setting );
}
$has_title = post_type_supports( $this->wp_object->post_type, 'title' );
if ( ! $has_title ) {
return 'Note';
}
// Default to Article.
$object_type = 'Article';
$post_format = 'standard';
if ( \get_theme_support( 'post-formats' ) ) {
$post_format = \get_post_format( $this->wp_object );
}
$post_type = \get_post_type( $this->wp_object );
switch ( $post_type ) {
case 'post':
$post_format = \get_post_format( $this->wp_object );
switch ( $post_format ) {
case 'aside':
case 'status':
case 'quote':
case 'note':
$object_type = 'Note';
break;
case 'gallery':
case 'image':
$object_type = 'Image';
break;
case 'video':
$object_type = 'Video';
break;
case 'audio':
$object_type = 'Audio';
$object_type = 'Note';
break;
default:
$object_type = 'Article';
@ -506,21 +565,6 @@ class Post extends Base {
case 'page':
$object_type = 'Page';
break;
case 'attachment':
$mime_type = \get_post_mime_type();
$media_type = \preg_replace( '/(\/[a-zA-Z]+)/i', '', $mime_type );
switch ( $media_type ) {
case 'audio':
$object_type = 'Audio';
break;
case 'video':
$object_type = 'Video';
break;
case 'image':
$object_type = 'Image';
break;
}
break;
default:
$object_type = 'Article';
break;
@ -586,6 +630,65 @@ class Post extends Base {
return $tags;
}
/**
* Returns the summary for the ActivityPub Item.
*
* The summary will be generated based on the user settings and only if the
* object type is not set to `note`.
*
* @return string|null The summary or null if the object type is `note`.
*/
protected function get_summary() {
if ( 'Note' === $this->get_type() ) {
return null;
}
$content = \get_post_field( 'post_content', $this->wp_object->ID );
$content = \html_entity_decode( $content );
$content = \wp_strip_all_tags( $content );
$content = \trim( $content );
$content = \preg_replace( '/\R+/m', "\n\n", $content );
$content = \preg_replace( '/[\r\t]/', '', $content );
$excerpt_more = \apply_filters( 'activitypub_excerpt_more', '[...]' );
$length = 500;
$length = $length - strlen( $excerpt_more );
if ( \strlen( $content ) > $length ) {
$content = \wordwrap( $content, $length, '</activitypub-summary>' );
$content = \explode( '</activitypub-summary>', $content, 2 );
$content = $content[0];
}
return $content . ' ' . $excerpt_more;
}
/**
* Returns the title for the ActivityPub Item.
*
* The title will be generated based on the user settings and only if the
* object type is not set to `note`.
*
* @return string|null The title or null if the object type is `note`.
*/
protected function get_name() {
if ( 'Note' === $this->get_type() ) {
return null;
}
$title = \get_the_title( $this->wp_object->ID );
if ( $title ) {
return \wp_strip_all_tags(
\html_entity_decode(
$title
)
);
}
return null;
}
/**
* Returns the content for the ActivityPub Item.
*
@ -641,7 +744,7 @@ class Post extends Base {
$template = "[ap_excerpt]\n\n[ap_permalink type=\"html\"]";
break;
case 'title':
$template = "[ap_title]\n\n[ap_permalink type=\"html\"]";
$template = "<h2>[ap_title]</h2>\n\n[ap_permalink type=\"html\"]";
break;
case 'content':
$template = "[ap_content]\n\n[ap_permalink type=\"html\"]\n\n[ap_hashtags]";
@ -651,6 +754,12 @@ class Post extends Base {
break;
}
$post_format_setting = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE );
if ( 'wordpress-post-format' === $post_format_setting ) {
$template = '[ap_content]';
}
return apply_filters( 'activitypub_object_content_template', $template, $this->wp_object );
}