Compare commits

...

38 Commits

Author SHA1 Message Date
e4b9b8235b modified file upgrade-temp-backup 2025-05-05 12:51:00 +00:00
f3c623d403 updated plugin SMTP Mailer version 1.1.18 2025-05-05 12:50:58 +00:00
37e74c1bea deleted plugin Infinite Uploads version 2.0.8 2025-05-02 12:05:07 +00:00
8fefb19ab4 installed plugin Infinite Uploads version 2.0.8 2025-05-02 12:03:22 +00:00
7ca941b591 deleted file discussion-meta.php 2025-04-29 21:32:09 +00:00
cf022e2628 installed plugin Event Bridge for ActivityPub version 1.1.0 2025-04-29 21:32:06 +00:00
fc3d7ab181 modified file themes 2025-04-29 21:21:24 +00:00
81e02d9aea deleted file user-edit.css 2025-04-29 21:21:24 +00:00
6b573f08f6 modified file bootstrap.php 2025-04-29 21:21:24 +00:00
51f6d193dd updated theme Twenty Nineteen version 3.1 2025-04-29 21:21:21 +00:00
5dc2981470 modified file upgrade-temp-backup 2025-04-29 21:20:07 +00:00
0bc27333c2 modified plugin Cloudron SSO version 1.0.0 2025-04-29 21:20:07 +00:00
a212704ec2 updated plugin Two Factor version 0.13.0 2025-04-29 21:20:04 +00:00
c950632407 updated plugin Simple Local Avatars version 2.8.3 2025-04-29 21:20:02 +00:00
fd76ba0cbe updated plugin Menu Icons version 0.13.17 2025-04-29 21:20:00 +00:00
ebd40ef928 updated plugin Jetpack Protect version 4.0.0 2025-04-29 21:19:56 +00:00
eb9181b250 updated plugin GP Premium version 2.5.2 2025-04-29 21:19:14 +00:00
c53f9e0e50 updated plugin Gitium version 1.2.1 2025-04-29 21:19:11 +00:00
d652fac5a4 updated plugin AudioIgniter version 2.0.1 2025-04-29 21:19:09 +00:00
fdfbf76539 updated plugin ActivityPub version 5.8.0 2025-04-29 21:19:06 +00:00
19dfd317cc deleted file style.css 2024-12-16 13:52:01 +00:00
e3858f0710 deleted plugin Companion Auto Update version 3.9.2 2024-12-16 13:52:01 +00:00
9cbc2cb832 modified file themes 2024-10-09 12:47:37 +00:00
db85936315 modified file jetpack-waf 2024-10-09 12:47:37 +00:00
dd95c943cb deleted file wwa-version.php 2024-10-09 12:47:36 +00:00
7dcace54d3 updated theme GeneratePress version 3.5.1 2024-10-09 12:47:31 +00:00
e13bab0b76 modified file plugins 2024-10-09 12:44:46 +00:00
cd379e1d95 modified plugin OpenID Connect Generic version 3.10.0 2024-10-09 12:44:45 +00:00
65c751c1d9 modified plugin Cloudron SSO version 1.0.0 2024-10-09 12:44:44 +00:00
db5f4b72eb deleted file discussion-meta.php 2024-10-09 12:44:44 +00:00
ef209dc569 deleted plugin info.php 2024-10-09 12:44:44 +00:00
e73c3de31d updated plugin WP-WebAuthn version 1.3.4 2024-10-09 12:44:39 +00:00
f970470c59 updated plugin Jetpack Protect version 3.0.2 2024-10-09 12:44:33 +00:00
a35dc419bc updated plugin GP Premium version 2.5.0 2024-10-09 12:44:25 +00:00
627ec103fe updated plugin Gitium version 1.0.7 2024-10-09 12:44:22 +00:00
c54fa007bd updated plugin ActivityPub version 3.3.3 2024-10-09 12:44:18 +00:00
fb4b27bbc6 modified file themes 2024-07-19 19:46:44 +00:00
b964c1846c deleted file simple-local-avatars.php 2024-07-19 19:46:44 +00:00
1402 changed files with 67802 additions and 103472 deletions

View File

@ -0,0 +1,11 @@
<?php
define( 'DISABLE_JETPACK_WAF', false );
if ( defined( 'DISABLE_JETPACK_WAF' ) && DISABLE_JETPACK_WAF ) return;
define( 'JETPACK_WAF_MODE', 'silent' );
define( 'JETPACK_WAF_SHARE_DATA', false );
define( 'JETPACK_WAF_SHARE_DEBUG_DATA', false );
define( 'JETPACK_WAF_DIR', '/app/data/wp-content/jetpack-waf' );
define( 'JETPACK_WAF_WPCONFIG', '/app/data/wp-content/../wp-config.php' );
define( 'JETPACK_WAF_ENTRYPOINT', 'rules/rules.php' );
require_once '/app/data/wp-content/plugins/jetpack-protect/vendor/autoload.php';
Automattic\Jetpack\Waf\Waf_Runner::initialize();

View File

@ -0,0 +1,4 @@
<?php
$waf_allow_list = array (
);
return $waf->is_ip_in_array( $waf_allow_list );

View File

@ -0,0 +1,4 @@
<?php
$waf_block_list = array (
);
return $waf->is_ip_in_array( $waf_block_list );

View File

@ -0,0 +1 @@
<?php

View File

@ -1,68 +1,64 @@
<?php
/**
* Plugin Name: ActivityPub
* Plugin URI: https://github.com/pfefferle/wordpress-activitypub/
* Plugin URI: https://github.com/Automattic/wordpress-activitypub
* Description: The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format.
* Version: 2.6.1
* Version: 5.8.0
* Author: Matthias Pfefferle & Automattic
* Author URI: https://automattic.com/
* License: MIT
* License URI: http://opensource.org/licenses/MIT
* Requires PHP: 7.0
* Requires PHP: 7.2
* Text Domain: activitypub
* Domain Path: /languages
*
* @package Activitypub
*/
namespace Activitypub;
use function Activitypub\is_blog_public;
use function Activitypub\site_supports_blocks;
use WP_CLI;
require_once __DIR__ . '/includes/compat.php';
require_once __DIR__ . '/includes/functions.php';
\define( 'ACTIVITYPUB_PLUGIN_VERSION', '2.6.1' );
/**
* Initialize the plugin constants.
*/
\defined( 'ACTIVITYPUB_REST_NAMESPACE' ) || \define( 'ACTIVITYPUB_REST_NAMESPACE', 'activitypub/1.0' );
\defined( 'ACTIVITYPUB_EXCERPT_LENGTH' ) || \define( 'ACTIVITYPUB_EXCERPT_LENGTH', 400 );
\defined( 'ACTIVITYPUB_SHOW_PLUGIN_RECOMMENDATIONS' ) || \define( 'ACTIVITYPUB_SHOW_PLUGIN_RECOMMENDATIONS', true );
\defined( 'ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS' ) || \define( 'ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS', 3 );
\defined( 'ACTIVITYPUB_HASHTAGS_REGEXP' ) || \define( 'ACTIVITYPUB_HASHTAGS_REGEXP', '(?:(?<=\s)|(?<=<p>)|(?<=<br>)|^)#([A-Za-z0-9_]+)(?:(?=\s|[[:punct:]]|$))' );
\defined( 'ACTIVITYPUB_USERNAME_REGEXP' ) || \define( 'ACTIVITYPUB_USERNAME_REGEXP', '(?:([A-Za-z0-9\._-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))' );
\defined( 'ACTIVITYPUB_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "<h2>[ap_title]</h2>\n\n[ap_content]\n\n[ap_hashtags]\n\n[ap_shortlink]" );
\defined( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) || \define( 'ACTIVITYPUB_AUTHORIZED_FETCH', false );
\defined( 'ACTIVITYPUB_DISABLE_REWRITES' ) || \define( 'ACTIVITYPUB_DISABLE_REWRITES', false );
\defined( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS', false );
\defined( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS', false );
\defined( 'ACTIVITYPUB_SHARED_INBOX_FEATURE' ) || \define( 'ACTIVITYPUB_SHARED_INBOX_FEATURE', false );
\defined( 'ACTIVITYPUB_SEND_VARY_HEADER' ) || \define( 'ACTIVITYPUB_SEND_VARY_HEADER', false );
\defined( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE' ) || \define( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE', 'note' );
\define( 'ACTIVITYPUB_PLUGIN_VERSION', '5.8.0' );
// Plugin related constants.
\define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
\define( 'ACTIVITYPUB_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
\define( 'ACTIVITYPUB_PLUGIN_FILE', plugin_dir_path( __FILE__ ) . '/' . basename( __FILE__ ) );
\define( 'ACTIVITYPUB_PLUGIN_FILE', ACTIVITYPUB_PLUGIN_DIR . basename( __FILE__ ) );
\define( 'ACTIVITYPUB_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
require_once __DIR__ . '/includes/class-autoloader.php';
require_once __DIR__ . '/includes/compat.php';
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/constants.php';
require_once __DIR__ . '/integration/load.php';
Autoloader::register_path( __NAMESPACE__, __DIR__ . '/includes' );
/**
* Initialize REST routes.
*/
function rest_init() {
Rest\Actors::init();
Rest\Outbox::init();
Rest\Inbox::init();
Rest\Followers::init();
Rest\Following::init();
Rest\Webfinger::init();
Rest\Comment::init();
Rest\Server::init();
Rest\Collection::init();
Rest\Post::init();
( new Rest\Actors_Controller() )->register_routes();
( new Rest\Actors_Inbox_Controller() )->register_routes();
( new Rest\Application_Controller() )->register_routes();
( new Rest\Collections_Controller() )->register_routes();
( new Rest\Comments_Controller() )->register_routes();
( new Rest\Followers_Controller() )->register_routes();
( new Rest\Following_Controller() )->register_routes();
( new Rest\Inbox_Controller() )->register_routes();
( new Rest\Interaction_Controller() )->register_routes();
( new Rest\Moderators_Controller() )->register_routes();
( new Rest\Outbox_Controller() )->register_routes();
( new Rest\Replies_Controller() )->register_routes();
( new Rest\URL_Validator_Controller() )->register_routes();
( new Rest\Webfinger_Controller() )->register_routes();
// load NodeInfo endpoints only if blog is public
// Load NodeInfo endpoints only if blog is public.
if ( is_blog_public() ) {
Rest\NodeInfo::init();
( new Rest\Nodeinfo_Controller() )->register_routes();
}
}
\add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' );
@ -71,16 +67,18 @@ function rest_init() {
* Initialize plugin.
*/
function plugin_init() {
\add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Activity_Dispatcher', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Handler', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Admin', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Hashtag', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Health_Check', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Comment', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Dispatcher', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Handler', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Hashtag', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Link', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Mailer', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ), 1 );
\add_action( 'init', array( __NAMESPACE__ . '\Move', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Options', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) );
if ( site_supports_blocks() ) {
\add_action( 'init', array( __NAMESPACE__ . '\Blocks', 'init' ) );
@ -91,77 +89,30 @@ function plugin_init() {
require_once $debug_file;
Debug::init();
}
require_once __DIR__ . '/integration/class-webfinger.php';
Integration\Webfinger::init();
require_once __DIR__ . '/integration/class-nodeinfo.php';
Integration\Nodeinfo::init();
require_once __DIR__ . '/integration/class-enable-mastodon-apps.php';
Integration\Enable_Mastodon_Apps::init();
require_once __DIR__ . '/integration/class-opengraph.php';
Integration\Opengraph::init();
if ( \defined( 'JETPACK__VERSION' ) && ! \defined( 'IS_WPCOM' ) ) {
require_once __DIR__ . '/integration/class-jetpack.php';
Integration\Jetpack::init();
}
}
\add_action( 'plugins_loaded', __NAMESPACE__ . '\plugin_init' );
/**
* Class Autoloader
* Initialize plugin admin.
*/
\spl_autoload_register(
function ( $full_class ) {
$base_dir = __DIR__ . '/includes/';
$base = 'Activitypub\\';
function plugin_admin_init() {
// Menus are registered before `admin_init`, because of course they are.
\add_action( 'admin_menu', array( __NAMESPACE__ . '\WP_Admin\Menu', 'admin_menu' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Admin', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Health_Check', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings_Fields', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Welcome_Fields', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Advanced_Settings_Fields', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Blog_Settings_Fields', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\User_Settings_Fields', 'init' ) );
if ( strncmp( $full_class, $base, strlen( $base ) ) === 0 ) {
$maybe_uppercase = str_replace( $base, '', $full_class );
$class = strtolower( $maybe_uppercase );
// All classes should be capitalized. If this is instead looking for a lowercase method, we ignore that.
if ( $maybe_uppercase === $class ) {
return;
}
if ( false !== strpos( $class, '\\' ) ) {
$parts = explode( '\\', $class );
$class = array_pop( $parts );
$sub_dir = strtr( implode( '/', $parts ), '_', '-' );
$base_dir = $base_dir . $sub_dir . '/';
}
$filename = 'class-' . strtr( $class, '_', '-' );
$file = $base_dir . $filename . '.php';
if ( file_exists( $file ) && is_readable( $file ) ) {
require_once $file;
} else {
// translators: %s is the class name
\wp_die( sprintf( esc_html__( 'Required class not found or not readable: %s', 'activitypub' ), esc_html( $full_class ) ) );
}
}
if ( defined( 'WP_LOAD_IMPORTERS' ) && WP_LOAD_IMPORTERS ) {
require_once __DIR__ . '/includes/wp-admin/import/load.php';
\add_action( 'admin_init', __NAMESPACE__ . '\WP_Admin\Import\load' );
}
);
/**
* Add plugin settings link
*/
function plugin_settings_link( $actions ) {
$settings_link = array();
$settings_link[] = \sprintf(
'<a href="%1s">%2s</a>',
\menu_page_url( 'activitypub', false ),
\__( 'Settings', 'activitypub' )
);
return \array_merge( $settings_link, $actions );
}
\add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), __NAMESPACE__ . '\plugin_settings_link' );
\add_action( 'plugins_loaded', __NAMESPACE__ . '\plugin_admin_init' );
\register_activation_hook(
__FILE__,
@ -171,6 +122,19 @@ function plugin_settings_link( $actions ) {
)
);
/**
* Redirect to the welcome page after plugin activation.
*
* @param string $plugin The plugin basename.
*/
function activation_redirect( $plugin ) {
if ( ACTIVITYPUB_PLUGIN_BASENAME === $plugin ) {
\wp_safe_redirect( \admin_url( 'options-general.php?page=activitypub' ) );
exit;
}
}
\add_action( 'activated_plugin', __NAMESPACE__ . '\activation_redirect' );
\register_deactivation_hook(
__FILE__,
array(
@ -187,24 +151,18 @@ function plugin_settings_link( $actions ) {
)
);
/**
* Only load code that needs BuddyPress to run once BP is loaded and initialized.
*/
add_action(
'bp_include',
function () {
require_once __DIR__ . '/integration/class-buddypress.php';
Integration\Buddypress::init();
},
0
);
/**
* `get_plugin_data` wrapper
* `get_plugin_data` wrapper.
*
* @return array The plugin metadata array
* @deprecated 4.2.0 Use `get_plugin_data` instead.
*
* @param array $default_headers Optional. The default plugin headers. Default empty array.
* @return array The plugin metadata array.
*/
function get_plugin_meta( $default_headers = array() ) {
_deprecated_function( __FUNCTION__, '4.2.0', 'get_plugin_data' );
if ( ! $default_headers ) {
$default_headers = array(
'Name' => 'Plugin Name',
@ -227,13 +185,22 @@ function get_plugin_meta( $default_headers = array() ) {
/**
* Plugin Version Number used for caching.
*
* @deprecated 4.2.0 Use constant ACTIVITYPUB_PLUGIN_VERSION directly.
*/
function get_plugin_version() {
if ( \defined( 'ACTIVITYPUB_PLUGIN_VERSION' ) ) {
return ACTIVITYPUB_PLUGIN_VERSION;
}
_deprecated_function( __FUNCTION__, '4.2.0', 'ACTIVITYPUB_PLUGIN_VERSION' );
$meta = get_plugin_meta( array( 'Version' => 'Version' ) );
return $meta['Version'];
return ACTIVITYPUB_PLUGIN_VERSION;
}
// Check for CLI env, to add the CLI commands.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command(
'activitypub',
'\Activitypub\Cli',
array(
'shortdesc' => 'ActivityPub related commands to manage plugin functionality and the federation of posts and comments.',
)
);
}

View File

@ -1,12 +1,16 @@
.activitypub-settings {
max-width: 800px;
margin: 0 auto;
position: relative;
}
.settings_page_activitypub .notice {
max-width: 800px;
margin: auto;
margin: 0px auto 30px;
margin: 0 auto 30px;
}
.settings_page_activitypub .update-nag {
margin: 25px 20px 15px 22px;
}
.settings_page_activitypub .wrap {
@ -20,6 +24,15 @@
border-bottom: 1px solid #dcdcde;
}
.activitypub-settings-header h1 {
display: inline-block;
font-weight: 600;
margin: 0 0.8rem 1rem;
font-size: 23px;
padding: 9px 0 4px;
line-height: 1.3;
}
.activitypub-settings-title-section {
display: flex;
align-items: center;
@ -33,11 +46,10 @@
}
.activitypub-settings-tabs-wrapper {
display: -ms-inline-grid;
-ms-grid-columns: auto auto auto;
display: inline-flex;
vertical-align: top;
display: inline-grid;
grid-template-columns: auto auto auto;
flex-wrap: nowrap;
gap: 0;
}
.activitypub-settings-tab.active {
@ -54,6 +66,20 @@
transition: box-shadow .5s ease-in-out;
}
.activitypub-settings .row {
margin-bottom: 16px;
}
.activitypub-settings .row > div {
max-width: calc(100% - 24px);
display: inline-flex;
flex-direction: column;
}
.activitypub-settings .row .description {
margin-top: 0;
}
.wp-header-end {
visibility: hidden;
margin: -2px 0 0;
@ -168,8 +194,7 @@ input.blog-user-identifier {
background-size: cover;
}
.activitypub-settings
.logo {
.activitypub-settings .logo {
height: 80px;
width: 80px;
position: relative;
@ -177,22 +202,6 @@ input.blog-user-identifier {
left: 40px;
}
.settings_page_activitypub .box {
border: 1px solid #c3c4c7;
background-color: #fff;
padding: 1em 1.5em;
margin-bottom: 1.5em;
}
.settings_page_activitypub .activitypub-welcome-page .box label {
font-weight: bold;
}
.settings_page_activitypub .activitypub-welcome-page input {
font-size: 20px;
width: 95%;
}
.settings_page_activitypub .plugin-recommendations {
border-bottom: none;
margin-bottom: 0;
@ -202,3 +211,62 @@ input.blog-user-identifier {
content: "\f307";
font-family: dashicons;
}
.repost .dashboard-comment-wrap,
.like .dashboard-comment-wrap {
padding-inline-start: 63px;
}
.repost .dashboard-comment-wrap .comment-author,
.like .dashboard-comment-wrap .comment-author {
margin-block: 0;
}
.activitypub-settings .welcome-tab-close {
position: absolute;
top: 0px;
right: 0px;
font-size: 13px;
padding: 0 5px 0 20px;
text-decoration: none;
z-index: 1;
}
.activitypub-settings .welcome-tab-close::before {
position: absolute;
top: 0px;
left: 0;
transition: all .1s ease-in-out;
font: normal 16px/20px dashicons;
content: '\f335';
font-size: 20px;
}
.activitypub-notice .count {
display: inline-block;
vertical-align: top;
box-sizing: border-box;
margin: 1px 0 -1px 2px;
padding: 0 5px;
min-width: 18px;
height: 18px;
border-radius: 9px;
background-color: #dba617;
color: #fff;
font-size: 11px;
line-height: 1.6;
text-align: center;
z-index: 26;
}
.activitypub-notice .dashicons-warning {
color: #dba617;
}
.extra-fields-nav a + a {
margin-left: 8px;
}
.rtl .extra-fields-nav a + a {
margin-left: auto;
margin-right: 8px;
}

View File

@ -0,0 +1,115 @@
/**
* ActivityPub embed styles.
*/
.activitypub-embed {
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 0;
max-width: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.activitypub-reply-block .activitypub-embed {
margin: 1em 0;
}
.activitypub-embed-header {
padding: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.activitypub-embed-header img {
width: 48px;
height: 48px;
border-radius: 50%;
}
.activitypub-embed-header-text {
flex-grow: 1;
}
.activitypub-embed-header-text h2 {
color: #000;
font-size: 15px;
font-weight: 600;
margin: 0;
padding: 0;
}
.activitypub-embed-header-text .ap-account {
color: #687684;
font-size: 14px;
text-decoration: none;
}
.activitypub-embed-content {
padding: 0 15px 15px;
}
.activitypub-embed-content .ap-title {
font-size: 23px;
font-weight: 600;
margin: 0 0 10px;
padding: 0;
color: #000;
}
.activitypub-embed-content .ap-subtitle {
font-size: 15px;
color: #000;
margin: 0 0 15px;
}
.activitypub-embed-content .ap-preview {
border: 1px solid #e6e6e6;
border-radius: 8px;
overflow: hidden;
}
.activitypub-embed-content .ap-preview img {
width: 100%;
height: auto;
display: block;
}
.activitypub-embed-content .ap-preview-text {
padding: 15px;
}
.activitypub-embed-meta {
padding: 15px;
border-top: 1px solid #e6e6e6;
color: #687684;
font-size: 13px;
display: flex;
gap: 15px;
}
.activitypub-embed-meta .ap-stat {
display: flex;
align-items: center;
gap: 5px;
}
@media only screen and (max-width: 399px) {
.activitypub-embed-meta span.ap-stat {
display: none !important;
}
}
.activitypub-embed-meta a.ap-stat {
color: inherit;
text-decoration: none;
}
.activitypub-embed-meta strong {
font-weight: 600;
color: #000;
}
.activitypub-embed-meta .ap-stat-label {
color: #687684;
}

View File

@ -0,0 +1,271 @@
/**
* Handle the header image setting in
*
* This is based on site-icon.js
*
* @see wp-admin/js/site-icon.js
*/
/* global jQuery, wp */
( function ( $ ) {
var $chooseButton = $( '#activitypub-choose-from-library-button' ),
$headerImagePreviewWrapper = $( '#activitypub-header-image-preview-wrapper' ),
$headerImagePreview = $( '#activitypub-header-image-preview' ),
$hiddenDataField = $( '#activitypub_header_image' ),
$removeButton = $( '#activitypub-remove-header-image' ),
frame,
ImageCropperNoCustomizer;
/**
* We register our own handler because the Core one invokes the Customizer, which fails the request unnecessarily
* for users who don't have the 'customize' capability.
* See https://github.com/Automattic/wordpress-activitypub/issues/846
*/
ImageCropperNoCustomizer = wp.media.controller.CustomizeImageCropper.extend( {
doCrop: function( attachment ) {
var cropDetails = attachment.get( 'cropDetails' ),
control = this.get( 'control' ),
ratio = cropDetails.width / cropDetails.height;
// Use crop measurements when flexible in both directions.
if ( control.params.flex_width && control.params.flex_height ) {
cropDetails.dst_width = cropDetails.width;
cropDetails.dst_height = cropDetails.height;
// Constrain flexible side based on image ratio and size of the fixed side.
} else {
cropDetails.dst_width = control.params.flex_width ? control.params.height * ratio : control.params.width;
cropDetails.dst_height = control.params.flex_height ? control.params.width / ratio : control.params.height;
}
return wp.ajax.post( 'crop-image', {
// where wp_customize: 'on' would be in Core, for no good reason I understand.
nonce: attachment.get( 'nonces' ).edit,
id: attachment.get( 'id' ),
context: control.id,
cropDetails: cropDetails
} );
}
} );
/**
* Calculate image selection options based on the attachment dimensions.
*
* @since 6.5.0
*
* @param {Object} attachment The attachment object representing the image.
* @return {Object} The image selection options.
*/
function calculateImageSelectOptions( attachment ) {
var realWidth = attachment.get( 'width' ),
realHeight = attachment.get( 'height' ),
xInit = 1500,
yInit = 500,
ratio = xInit / yInit,
xImg = xInit,
yImg = yInit,
x1,
y1,
imgSelectOptions;
if ( realWidth / realHeight > ratio ) {
yInit = realHeight;
xInit = yInit * ratio;
} else {
xInit = realWidth;
yInit = xInit / ratio;
}
x1 = ( realWidth - xInit ) / 2;
y1 = ( realHeight - yInit ) / 2;
imgSelectOptions = {
aspectRatio: xInit + ':' + yInit,
handles: true,
keys: true,
instance: true,
persistent: true,
imageWidth: realWidth,
imageHeight: realHeight,
minWidth: xImg > xInit ? xInit : xImg,
minHeight: yImg > yInit ? yInit : yImg,
x1: x1,
y1: y1,
x2: xInit + x1,
y2: yInit + y1,
};
return imgSelectOptions;
}
/**
* Initializes the media frame for selecting or cropping an image.
*
* @since 6.5.0
*/
$chooseButton.on( 'click', function () {
var $el = $( this );
var userId = $el.data( 'userId' );
var mediaQuery = { type: 'image' };
if ( userId ) {
mediaQuery.author = userId;
}
// Create the media frame.
frame = wp.media( {
button: {
// Set the text of the button.
text: $el.data( 'update' ),
// Don't close, we might need to crop.
close: false,
},
states: [
new wp.media.controller.Library( {
title: $el.data( 'choose-text' ),
library: wp.media.query( mediaQuery ),
date: false,
suggestedWidth: $el.data( 'width' ),
suggestedHeight: $el.data( 'height' ),
} ),
new ImageCropperNoCustomizer( {
control: {
id: 'activitypub-header-image',
params: {
width: $el.data( 'width' ),
height: $el.data( 'height' ),
},
},
imgSelectOptions: calculateImageSelectOptions,
} ),
],
} );
frame.on( 'cropped', function ( attachment ) {
$hiddenDataField.val( attachment.id );
switchToUpdate( attachment );
frame.close();
// Start over with a frame that is so fresh and so clean clean.
frame = null;
} );
// When an image is selected, run a callback.
frame.on( 'select', function () {
// Grab the selected attachment.
var attachment = frame.state().get( 'selection' ).first(),
targetRatio = $el.data( 'width' ) / $el.data( 'height' ),
currentRatio = attachment.attributes.width / attachment.attributes.height,
alreadyCropped = false;
// Check if the image already has the correct aspect ratio (with a small tolerance).
if ( Math.abs( currentRatio - targetRatio ) < 0.01 ) {
// Check if this is the same image that was already selected.
if ( attachment.id !== parseInt( $hiddenDataField.val(), 10 ) ) {
// This is a new image with the correct aspect ratio.
$hiddenDataField.val( attachment.id );
}
alreadyCropped = true;
}
if ( alreadyCropped ) {
// Skip cropping for already cropped images.
switchToUpdate( attachment.attributes );
frame.close();
} else {
frame.setState( 'cropper' );
}
} );
frame.open();
} );
/**
* Update the UI when a header is selected.
*
* @since 6.5.0
*
* @param {array} attributes The attributes for the attachment.
*/
function switchToUpdate( attributes ) {
var i18nAppAlternativeString, i18nBrowserAlternativeString;
if ( attributes.alt ) {
i18nBrowserAlternativeString = wp.i18n.sprintf(
/* translators: %s: The selected image alt text. */
wp.i18n.__( 'Header Image preview: Current image: %s' ),
attributes.alt
);
} else {
i18nAppAlternativeString = wp.i18n.sprintf(
/* translators: %s: The selected image filename. */
wp.i18n.__(
'Header Image preview: The current image has no alternative text. The file name is: %s'
),
attributes.filename
);
i18nBrowserAlternativeString = wp.i18n.sprintf(
/* translators: %s: The selected image filename. */
wp.i18n.__(
'Header Image preview: The current image has no alternative text. The file name is: %s'
),
attributes.filename
);
}
// Set activitypub-header-image-preview src.
$headerImagePreview.attr( {
src: attributes.url,
alt: i18nAppAlternativeString,
} );
// Remove hidden class from header image preview div and remove button.
$headerImagePreviewWrapper.removeClass( 'hidden' );
$removeButton.removeClass( 'hidden' );
// If the choose button is not in the update state, swap the classes.
if ( $chooseButton.attr( 'data-state' ) !== '1' ) {
$chooseButton.attr( {
class: $chooseButton.attr( 'data-alt-classes' ),
'data-alt-classes': $chooseButton.attr( 'class' ),
'data-state': '1',
} );
}
// Swap the text of the choose button.
$chooseButton.text( $chooseButton.attr( 'data-update-text' ) );
}
/**
* Handles the click event of the remove button.
*
* @since 6.5.0
*/
$removeButton.on( 'click', function () {
$hiddenDataField.val( 'false' );
$( this ).toggleClass( 'hidden' );
$headerImagePreviewWrapper.toggleClass( 'hidden' );
$headerImagePreview.attr( {
src: '',
alt: '',
} );
/**
* Resets state to the button, for correct visual style and state.
* Updates the text of the button.
* Sets focus state to the button.
*/
$chooseButton
.attr( {
class: $chooseButton.attr( 'data-alt-classes' ),
'data-alt-classes': $chooseButton.attr( 'class' ),
'data-state': '',
} )
.text( $chooseButton.attr( 'data-choose-text' ) )
.trigger( 'focus' );
} );
} )( jQuery );

View File

@ -0,0 +1,8 @@
{
"name": "editor-plugin",
"title": "Editor Plugin: not a block, but block.json is very useful.",
"category": "widgets",
"icon": "admin-comments",
"keywords": [],
"editorScript": "file:./plugin.js"
}

View File

@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-primitives', 'wp-url'), 'version' => '293b8e75ac7a589c5096');

File diff suppressed because one or more lines are too long

View File

@ -36,8 +36,29 @@
"selectedUser": {
"type": "string",
"default": "site"
},
"buttonOnly": {
"type": "boolean",
"default": false
},
"buttonText": {
"type": "string",
"default": "Follow"
},
"buttonSize": {
"type": "string",
"default": "default",
"enum": [
"small",
"default",
"compact"
]
}
},
"usesContext": [
"postType",
"postId"
],
"editorScript": "file:./index.js",
"viewScript": "file:./view.js",
"style": [

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '7be9f9b97d08a20bde26');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '8f1a6f7e5f76d58a3204');

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--wp--preset--color--white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-left:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:0 50px 50px 0;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:50px 0 0 50px;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-follow-me-block-wrapper{width:100%}.activitypub-follow-me-block-wrapper.has-background .activitypub-profile,.activitypub-follow-me-block-wrapper.has-border-color .activitypub-profile{padding-right:1rem;padding-left:1rem}.activitypub-follow-me-block-wrapper .activitypub-profile{align-items:center;display:flex;padding:1rem 0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__avatar{border-radius:50%;height:75px;margin-left:1rem;width:75px}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__content{flex:1;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__handle,.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{line-height:1.2;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{font-size:1.25em}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow{align-self:center;background-color:var(--wp--preset--color--black);color:var(--wp--preset--color--white);margin-right:1rem}
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--color-white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-left:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:0 50px 50px 0;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:50px 0 0 50px;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-follow-me-block-wrapper{width:100%}.activitypub-follow-me-block-wrapper.has-background .activitypub-profile,.activitypub-follow-me-block-wrapper.has-border-color .activitypub-profile{padding-right:1rem;padding-left:1rem}.activitypub-follow-me-block-wrapper .activitypub-profile{align-items:center;display:flex;padding:1rem 0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__avatar{border-radius:50%;height:75px;margin-left:1rem;width:75px}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__content{flex:1;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__handle,.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{line-height:1.2;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{font-size:1.25em}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow{align-self:center;background-color:var(--wp--preset--color--black);color:var(--wp--preset--color--white)}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow:not(:only-child){margin-right:1rem}

View File

@ -1 +1 @@
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--wp--preset--color--white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-right:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:50px 0 0 50px;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:0 50px 50px 0;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-follow-me-block-wrapper{width:100%}.activitypub-follow-me-block-wrapper.has-background .activitypub-profile,.activitypub-follow-me-block-wrapper.has-border-color .activitypub-profile{padding-left:1rem;padding-right:1rem}.activitypub-follow-me-block-wrapper .activitypub-profile{align-items:center;display:flex;padding:1rem 0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__avatar{border-radius:50%;height:75px;margin-right:1rem;width:75px}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__content{flex:1;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__handle,.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{line-height:1.2;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{font-size:1.25em}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow{align-self:center;background-color:var(--wp--preset--color--black);color:var(--wp--preset--color--white);margin-left:1rem}
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--color-white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-right:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:50px 0 0 50px;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:0 50px 50px 0;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-follow-me-block-wrapper{width:100%}.activitypub-follow-me-block-wrapper.has-background .activitypub-profile,.activitypub-follow-me-block-wrapper.has-border-color .activitypub-profile{padding-left:1rem;padding-right:1rem}.activitypub-follow-me-block-wrapper .activitypub-profile{align-items:center;display:flex;padding:1rem 0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__avatar{border-radius:50%;height:75px;margin-right:1rem;width:75px}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__content{flex:1;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__handle,.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{line-height:1.2;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{font-size:1.25em}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow{align-self:center;background-color:var(--wp--preset--color--black);color:var(--wp--preset--color--white)}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow:not(:only-child){margin-left:1rem}

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => 'ab8c0dad126bb0a61ed6');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '635ed3e6db3230ae865f');

File diff suppressed because one or more lines are too long

View File

@ -33,6 +33,10 @@
]
}
},
"usesContext": [
"postType",
"postId"
],
"styles": [
{
"name": "default",

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => '3d39b46b3415c2d57654');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => 'e98a40c18060cbb88187');

View File

@ -1,3 +1,4 @@
(()=>{var e={20:(e,t,a)=>{"use strict";var r=a(609),n=Symbol.for("react.element"),l=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),o=r.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,i={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,a){var r,c={},s=null,p=null;for(r in void 0!==a&&(s=""+a),void 0!==t.key&&(s=""+t.key),void 0!==t.ref&&(p=t.ref),t)l.call(t,r)&&!i.hasOwnProperty(r)&&(c[r]=t[r]);if(e&&e.defaultProps)for(r in t=e.defaultProps)void 0===c[r]&&(c[r]=t[r]);return{$$typeof:n,type:e,key:s,ref:p,props:c,_owner:o.current}}},848:(e,t,a)=>{"use strict";e.exports=a(20)},609:e=>{"use strict";e.exports=window.React},942:(e,t)=>{var a;!function(){"use strict";var r={}.hasOwnProperty;function n(){for(var e="",t=0;t<arguments.length;t++){var a=arguments[t];a&&(e=o(e,l(a)))}return e}function l(e){if("string"==typeof e||"number"==typeof e)return e;if("object"!=typeof e)return"";if(Array.isArray(e))return n.apply(null,e);if(e.toString!==Object.prototype.toString&&!e.toString.toString().includes("[native code]"))return e.toString();var t="";for(var a in e)r.call(e,a)&&e[a]&&(t=o(t,a));return t}function o(e,t){return t?e?e+" "+t:e+t:e}e.exports?(n.default=n,e.exports=n):void 0===(a=function(){return n}.apply(t,[]))||(e.exports=a)}()}},t={};function a(r){var n=t[r];if(void 0!==n)return n.exports;var l=t[r]={exports:{}};return e[r](l,l.exports,a),l.exports}a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{"use strict";const e=window.wp.blocks,t=window.wp.primitives;var r=a(848);const n=(0,r.jsx)(t.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,r.jsx)(t.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})});var l=a(609);const o=window.wp.components,i=window.wp.element,c=window.wp.blockEditor,s=window.wp.i18n,p=window.wp.apiFetch;var u=a.n(p);const v=window.wp.url;var m=a(942),w=a.n(m);function d({active:e,children:t,page:a,pageClick:r,className:n}){const o=w()("wp-block activitypub-pager",n,{current:e});return(0,l.createElement)("a",{className:o,onClick:t=>{t.preventDefault(),!e&&r(a)}},t)}const f={outlined:"outlined",minimal:"minimal"};function b({compact:e,nextLabel:t,page:a,pageClick:r,perPage:n,prevLabel:o,total:i,variant:c=f.outlined}){const s=((e,t)=>{let a=[1,e-2,e-1,e,e+1,e+2,t];a.sort(((e,t)=>e-t)),a=a.filter(((e,a,r)=>e>=1&&e<=t&&r.lastIndexOf(e)===a));for(let e=a.length-2;e>=0;e--)a[e]===a[e+1]&&a.splice(e+1,1);return a})(a,Math.ceil(i/n)),p=w()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${c}`,{"is-compact":e});return(0,l.createElement)("nav",{className:p},o&&(0,l.createElement)(d,{key:"prev",page:a-1,pageClick:r,active:1===a,"aria-label":o,className:"wp-block-query-pagination-previous block-editor-block-list__block"},o),!e&&(0,l.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},s.map((e=>(0,l.createElement)(d,{key:e,page:e,pageClick:r,active:e===a,className:"page-numbers"},e)))),t&&(0,l.createElement)(d,{key:"next",page:a+1,pageClick:r,active:a===Math.ceil(i/n),"aria-label":t,className:"wp-block-query-pagination-next block-editor-block-list__block"},t))}const{namespace:y}=window._activityPubOptions;function g({selectedUser:e,per_page:t,order:a,title:r,page:n,setPage:o,className:c="",followLinks:p=!0,followerData:m=!1}){const w="site"===e?0:e,[d,f]=(0,l.useState)([]),[g,k]=(0,l.useState)(0),[h,E]=(0,l.useState)(0),[x,N]=function(){const[e,t]=(0,l.useState)(1);return[e,t]}(),S=n||x,C=o||N,O=(0,i.createInterpolateElement)(/* translators: arrow for previous followers link */ /* translators: arrow for previous followers link */
(0,s.__)("<span>←</span> Less","activitypub"),{span:(0,l.createElement)("span",{className:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),P=(0,i.createInterpolateElement)(/* translators: arrow for next followers link */ /* translators: arrow for next followers link */
(0,s.__)("More <span>→</span>","activitypub"),{span:(0,l.createElement)("span",{className:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})}),L=(e,a)=>{f(e),E(a),k(Math.ceil(a/t))};return(0,l.useEffect)((()=>{if(m&&1===S)return L(m.followers,m.total);const e=function(e,t,a,r){const n=`/${y}/actors/${e}/followers`,l={per_page:t,order:a,page:r,context:"full"};return(0,v.addQueryArgs)(n,l)}(w,t,a,S);u()({path:e}).then((e=>L(e.orderedItems,e.totalItems))).catch((()=>{}))}),[w,t,a,S,m]),(0,l.createElement)("div",{className:"activitypub-follower-block "+c},(0,l.createElement)("h3",null,r),(0,l.createElement)("ul",null,d&&d.map((e=>(0,l.createElement)("li",{key:e.url},(0,l.createElement)(_,{...e,followLinks:p}))))),g>1&&(0,l.createElement)(b,{page:S,perPage:t,total:h,pageClick:C,nextLabel:P,prevLabel:O,compact:"is-style-compact"===c}))}function _({name:e,icon:t,url:a,preferredUsername:r,followLinks:n=!0}){const i=`@${r}`,c={};return n||(c.onClick=e=>e.preventDefault()),(0,l.createElement)(o.ExternalLink,{className:"activitypub-link",href:a,title:i,...c},(0,l.createElement)("img",{width:"40",height:"40",src:t.url,className:"avatar activitypub-avatar",alt:e}),(0,l.createElement)("span",{className:"activitypub-actor"},(0,l.createElement)("strong",{className:"activitypub-name"},e),(0,l.createElement)("span",{className:"sep"},"/"),(0,l.createElement)("span",{className:"activitypub-handle"},i)))}const k=window.wp.data,h=window._activityPubOptions?.enabled;(0,e.registerBlockType)("activitypub/followers",{edit:function({attributes:e,setAttributes:t}){const{order:a,per_page:r,selectedUser:n,title:p}=e,u=(0,c.useBlockProps)(),[v,m]=(0,i.useState)(1),w=[{label:(0,s.__)("New to old","activitypub"),value:"desc"},{label:(0,s.__)("Old to new","activitypub"),value:"asc"}],d=function(){const e=h?.users?(0,k.useSelect)((e=>e("core").getUsers({who:"authors"}))):[];return(0,i.useMemo)((()=>{if(!e)return[];const t=h?.site?[{label:(0,s.__)("Whole Site","activitypub"),value:"site"}]:[];return e.reduce(((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e)),t)}),[e])}(),f=e=>a=>{m(1),t({[e]:a})};return(0,i.useEffect)((()=>{d.length&&(d.find((({value:e})=>e===n))||t({selectedUser:d[0].value}))}),[n,d]),(0,l.createElement)("div",{...u},(0,l.createElement)(c.InspectorControls,{key:"setting"},(0,l.createElement)(o.PanelBody,{title:(0,s.__)("Followers Options","activitypub")},(0,l.createElement)(o.TextControl,{label:(0,s.__)("Title","activitypub"),help:(0,s.__)("Title to display above the list of followers. Blank for none.","activitypub"),value:p,onChange:e=>t({title:e})}),d.length>1&&(0,l.createElement)(o.SelectControl,{label:(0,s.__)("Select User","activitypub"),value:n,options:d,onChange:f("selectedUser")}),(0,l.createElement)(o.SelectControl,{label:(0,s.__)("Sort","activitypub"),value:a,options:w,onChange:f("order")}),(0,l.createElement)(o.RangeControl,{label:(0,s.__)("Number of Followers","activitypub"),value:r,onChange:f("per_page"),min:1,max:10}))),(0,l.createElement)(g,{...e,page:v,setPage:m,followLinks:!1}))},save:()=>null,icon:n})})()})();
(()=>{var e={20:(e,t,a)=>{"use strict";var r=a(609),n=Symbol.for("react.element"),l=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),o=r.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,i={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,a){var r,c={},s=null,p=null;for(r in void 0!==a&&(s=""+a),void 0!==t.key&&(s=""+t.key),void 0!==t.ref&&(p=t.ref),t)l.call(t,r)&&!i.hasOwnProperty(r)&&(c[r]=t[r]);if(e&&e.defaultProps)for(r in t=e.defaultProps)void 0===c[r]&&(c[r]=t[r]);return{$$typeof:n,type:e,key:s,ref:p,props:c,_owner:o.current}}},848:(e,t,a)=>{"use strict";e.exports=a(20)},609:e=>{"use strict";e.exports=window.React},942:(e,t)=>{var a;!function(){"use strict";var r={}.hasOwnProperty;function n(){for(var e="",t=0;t<arguments.length;t++){var a=arguments[t];a&&(e=o(e,l(a)))}return e}function l(e){if("string"==typeof e||"number"==typeof e)return e;if("object"!=typeof e)return"";if(Array.isArray(e))return n.apply(null,e);if(e.toString!==Object.prototype.toString&&!e.toString.toString().includes("[native code]"))return e.toString();var t="";for(var a in e)r.call(e,a)&&e[a]&&(t=o(t,a));return t}function o(e,t){return t?e?e+" "+t:e+t:e}e.exports?(n.default=n,e.exports=n):void 0===(a=function(){return n}.apply(t,[]))||(e.exports=a)}()}},t={};function a(r){var n=t[r];if(void 0!==n)return n.exports;var l=t[r]={exports:{}};return e[r](l,l.exports,a),l.exports}a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{"use strict";const e=window.wp.blocks,t=window.wp.primitives;var r=a(848);const n=(0,r.jsx)(t.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,r.jsx)(t.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})});var l=a(609);const o=window.wp.components,i=window.wp.element,c=window.wp.blockEditor,s=window.wp.data,p=window.wp.coreData,u=window.wp.i18n,m=window.wp.apiFetch;var v=a.n(m);const d=window.wp.url;var w=a(942),b=a.n(w);function f({active:e,children:t,page:a,pageClick:r,className:n}){const o=b()("wp-block activitypub-pager",n,{current:e});return(0,l.createElement)("a",{className:o,onClick:t=>{t.preventDefault(),!e&&r(a)}},t)}function y({compact:e,nextLabel:t,page:a,pageClick:r,perPage:n,prevLabel:o,total:i,variant:c="outlined"}){const s=((e,t)=>{let a=[1,e-2,e-1,e,e+1,e+2,t];a.sort(((e,t)=>e-t)),a=a.filter(((e,a,r)=>e>=1&&e<=t&&r.lastIndexOf(e)===a));for(let e=a.length-2;e>=0;e--)a[e]===a[e+1]&&a.splice(e+1,1);return a})(a,Math.ceil(i/n)),p=b()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${c}`,{"is-compact":e});return(0,l.createElement)("nav",{className:p},o&&(0,l.createElement)(f,{key:"prev",page:a-1,pageClick:r,active:1===a,"aria-label":o,className:"wp-block-query-pagination-previous block-editor-block-list__block"},o),!e&&(0,l.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},s.map((e=>(0,l.createElement)(f,{key:e,page:e,pageClick:r,active:e===a,className:"page-numbers"},e)))),t&&(0,l.createElement)(f,{key:"next",page:a+1,pageClick:r,active:a===Math.ceil(i/n),"aria-label":t,className:"wp-block-query-pagination-next block-editor-block-list__block"},t))}function g(){return window._activityPubOptions||{}}function h({selectedUser:e,per_page:t,order:a,title:r,page:n,setPage:o,className:c="",followLinks:s=!0,followerData:p=!1}){const m="site"===e?0:e,[w,b]=(0,l.useState)([]),[f,h]=(0,l.useState)(0),[k,E]=(0,l.useState)(0),[x,S]=function(){const[e,t]=(0,l.useState)(1);return[e,t]}(),N=n||x,C=o||S,O=(0,i.createInterpolateElement)(/* translators: arrow for previous followers link */ /* translators: arrow for previous followers link */
(0,u.__)("<span>←</span> Less","activitypub"),{span:(0,l.createElement)("span",{className:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),P=(0,i.createInterpolateElement)(/* translators: arrow for next followers link */ /* translators: arrow for next followers link */
(0,u.__)("More <span>→</span>","activitypub"),{span:(0,l.createElement)("span",{className:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})}),I=(e,a)=>{b(e),E(a),h(Math.ceil(a/t))};return(0,l.useEffect)((()=>{if(p&&1===N)return I(p.followers,p.total);const e=function(e,t,a,r){const{namespace:n}=g(),l=`/${n}/actors/${e}/followers`,o={per_page:t,order:a,page:r,context:"full"};return(0,d.addQueryArgs)(l,o)}(m,t,a,N);v()({path:e}).then((e=>I(e.orderedItems,e.totalItems))).catch((()=>{}))}),[m,t,a,N,p]),(0,l.createElement)("div",{className:"activitypub-follower-block "+c},(0,l.createElement)("h3",null,r),(0,l.createElement)("ul",null,w&&w.map((e=>(0,l.createElement)("li",{key:e.url},(0,l.createElement)(_,{...e,followLinks:s}))))),f>1&&(0,l.createElement)(y,{page:N,perPage:t,total:k,pageClick:C,nextLabel:P,prevLabel:O,compact:"is-style-compact"===c}))}function _({name:e,icon:t,url:a,preferredUsername:r,followLinks:n=!0}){const i=`@${r}`,c={};return n||(c.onClick=e=>e.preventDefault()),(0,l.createElement)(o.ExternalLink,{className:"activitypub-link",href:a,title:i,...c},(0,l.createElement)("img",{width:"40",height:"40",src:t.url,className:"avatar activitypub-avatar",alt:e}),(0,l.createElement)("span",{className:"activitypub-actor"},(0,l.createElement)("strong",{className:"activitypub-name"},e),(0,l.createElement)("span",{className:"sep"},"/"),(0,l.createElement)("span",{className:"activitypub-handle"},i)))}function k({name:e}){const{enabled:t}=g(),a=t?.site?"":(0,u.__)("It will be empty in other non-author contexts.","activitypub"),r=(0,u.sprintf)(/* translators: %1$s: block name, %2$s: extra information for non-author context */ /* translators: %1$s: block name, %2$s: extra information for non-author context */
(0,u.__)("This <strong>%1$s</strong> block will adapt to the page it is on, displaying the user profile associated with a post author (in a loop) or a user archive. %2$s","activitypub"),e,a).trim();return(0,l.createElement)(o.Card,null,(0,l.createElement)(o.CardBody,null,(0,i.createInterpolateElement)(r,{strong:(0,l.createElement)("strong",null)})))}(0,e.registerBlockType)("activitypub/followers",{edit:function({attributes:e,setAttributes:t,context:{postType:a,postId:r}}){const{order:n,per_page:m,selectedUser:v,title:d}=e,w=(0,c.useBlockProps)(),[b,f]=(0,i.useState)(1),y=[{label:(0,u.__)("New to old","activitypub"),value:"desc"},{label:(0,u.__)("Old to new","activitypub"),value:"asc"}],_=function({withInherit:e=!1}){const{enabled:t}=g(),a=t?.users?(0,s.useSelect)((e=>e("core").getUsers({who:"authors"}))):[];return(0,i.useMemo)((()=>{if(!a)return[];const r=[];return t?.site&&r.push({label:(0,u.__)("Site","activitypub"),value:"site"}),e&&t?.users&&r.push({label:(0,u.__)("Dynamic User","activitypub"),value:"inherit"}),a.reduce(((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e)),r)}),[a])}({withInherit:!0}),E=e=>a=>{f(1),t({[e]:a})},x=(0,s.useSelect)((e=>{const{getEditedEntityRecord:t}=e(p.store),n=t("postType",a,r)?.author;return null!=n?n:null}),[a,r]);return(0,i.useEffect)((()=>{_.length&&(_.find((({value:e})=>e===v))||t({selectedUser:_[0].value}))}),[v,_]),(0,l.createElement)("div",{...w},(0,l.createElement)(c.InspectorControls,{key:"setting"},(0,l.createElement)(o.PanelBody,{title:(0,u.__)("Followers Options","activitypub")},(0,l.createElement)(o.TextControl,{label:(0,u.__)("Title","activitypub"),help:(0,u.__)("Title to display above the list of followers. Blank for none.","activitypub"),value:d,onChange:e=>t({title:e})}),_.length>1&&(0,l.createElement)(o.SelectControl,{label:(0,u.__)("Select User","activitypub"),value:v,options:_,onChange:E("selectedUser")}),(0,l.createElement)(o.SelectControl,{label:(0,u.__)("Sort","activitypub"),value:n,options:y,onChange:E("order")}),(0,l.createElement)(o.RangeControl,{label:(0,u.__)("Number of Followers","activitypub"),value:m,onChange:E("per_page"),min:1,max:10}))),"inherit"===v?x?(0,l.createElement)(h,{...e,page:b,setPage:f,followLinks:!1,selectedUser:x}):(0,l.createElement)(k,{name:(0,u.__)("Followers","activitypub")}):(0,l.createElement)(h,{...e,page:b,setPage:f,followLinks:!1}))},save:()=>null,icon:n})})()})();

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-url'), 'version' => '111b88843c05346aadbf');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-url'), 'version' => '34299fc181d49292ada0');

View File

@ -1,3 +1,3 @@
(()=>{var e,t={250:(e,t,a)=>{"use strict";const r=window.React,n=window.wp.apiFetch;var l=a.n(n);const o=window.wp.url,i=window.wp.element,c=window.wp.i18n;var s=a(942),p=a.n(s);function u({active:e,children:t,page:a,pageClick:n,className:l}){const o=p()("wp-block activitypub-pager",l,{current:e});return(0,r.createElement)("a",{className:o,onClick:t=>{t.preventDefault(),!e&&n(a)}},t)}const m={outlined:"outlined",minimal:"minimal"};function f({compact:e,nextLabel:t,page:a,pageClick:n,perPage:l,prevLabel:o,total:i,variant:c=m.outlined}){const s=((e,t)=>{let a=[1,e-2,e-1,e,e+1,e+2,t];a.sort(((e,t)=>e-t)),a=a.filter(((e,a,r)=>e>=1&&e<=t&&r.lastIndexOf(e)===a));for(let e=a.length-2;e>=0;e--)a[e]===a[e+1]&&a.splice(e+1,1);return a})(a,Math.ceil(i/l)),f=p()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${c}`,{"is-compact":e});return(0,r.createElement)("nav",{className:f},o&&(0,r.createElement)(u,{key:"prev",page:a-1,pageClick:n,active:1===a,"aria-label":o,className:"wp-block-query-pagination-previous block-editor-block-list__block"},o),!e&&(0,r.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},s.map((e=>(0,r.createElement)(u,{key:e,page:e,pageClick:n,active:e===a,className:"page-numbers"},e)))),t&&(0,r.createElement)(u,{key:"next",page:a+1,pageClick:n,active:a===Math.ceil(i/l),"aria-label":t,className:"wp-block-query-pagination-next block-editor-block-list__block"},t))}const v=window.wp.components,{namespace:b}=window._activityPubOptions;function d({selectedUser:e,per_page:t,order:a,title:n,page:s,setPage:p,className:u="",followLinks:m=!0,followerData:v=!1}){const d="site"===e?0:e,[g,y]=(0,r.useState)([]),[k,h]=(0,r.useState)(0),[E,N]=(0,r.useState)(0),[x,_]=function(){const[e,t]=(0,r.useState)(1);return[e,t]}(),O=s||x,S=p||_,C=(0,i.createInterpolateElement)(/* translators: arrow for previous followers link */ /* translators: arrow for previous followers link */
(0,c.__)("<span>←</span> Less","activitypub"),{span:(0,r.createElement)("span",{className:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),L=(0,i.createInterpolateElement)(/* translators: arrow for next followers link */ /* translators: arrow for next followers link */
(0,c.__)("More <span>→</span>","activitypub"),{span:(0,r.createElement)("span",{className:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})}),q=(e,a)=>{y(e),N(a),h(Math.ceil(a/t))};return(0,r.useEffect)((()=>{if(v&&1===O)return q(v.followers,v.total);const e=function(e,t,a,r){const n=`/${b}/actors/${e}/followers`,l={per_page:t,order:a,page:r,context:"full"};return(0,o.addQueryArgs)(n,l)}(d,t,a,O);l()({path:e}).then((e=>q(e.orderedItems,e.totalItems))).catch((()=>{}))}),[d,t,a,O,v]),(0,r.createElement)("div",{className:"activitypub-follower-block "+u},(0,r.createElement)("h3",null,n),(0,r.createElement)("ul",null,g&&g.map((e=>(0,r.createElement)("li",{key:e.url},(0,r.createElement)(w,{...e,followLinks:m}))))),k>1&&(0,r.createElement)(f,{page:O,perPage:t,total:E,pageClick:S,nextLabel:L,prevLabel:C,compact:"is-style-compact"===u}))}function w({name:e,icon:t,url:a,preferredUsername:n,followLinks:l=!0}){const o=`@${n}`,i={};return l||(i.onClick=e=>e.preventDefault()),(0,r.createElement)(v.ExternalLink,{className:"activitypub-link",href:a,title:o,...i},(0,r.createElement)("img",{width:"40",height:"40",src:t.url,className:"avatar activitypub-avatar",alt:e}),(0,r.createElement)("span",{className:"activitypub-actor"},(0,r.createElement)("strong",{className:"activitypub-name"},e),(0,r.createElement)("span",{className:"sep"},"/"),(0,r.createElement)("span",{className:"activitypub-handle"},o)))}const g=window.wp.domReady;a.n(g)()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-follower-block"),(e=>{const t=JSON.parse(e.dataset.attrs);(0,i.createRoot)(e).render((0,r.createElement)(d,{...t}))}))}))},942:(e,t)=>{var a;!function(){"use strict";var r={}.hasOwnProperty;function n(){for(var e="",t=0;t<arguments.length;t++){var a=arguments[t];a&&(e=o(e,l(a)))}return e}function l(e){if("string"==typeof e||"number"==typeof e)return e;if("object"!=typeof e)return"";if(Array.isArray(e))return n.apply(null,e);if(e.toString!==Object.prototype.toString&&!e.toString.toString().includes("[native code]"))return e.toString();var t="";for(var a in e)r.call(e,a)&&e[a]&&(t=o(t,a));return t}function o(e,t){return t?e?e+" "+t:e+t:e}e.exports?(n.default=n,e.exports=n):void 0===(a=function(){return n}.apply(t,[]))||(e.exports=a)}()}},a={};function r(e){var n=a[e];if(void 0!==n)return n.exports;var l=a[e]={exports:{}};return t[e](l,l.exports,r),l.exports}r.m=t,e=[],r.O=(t,a,n,l)=>{if(!a){var o=1/0;for(p=0;p<e.length;p++){for(var[a,n,l]=e[p],i=!0,c=0;c<a.length;c++)(!1&l||o>=l)&&Object.keys(r.O).every((e=>r.O[e](a[c])))?a.splice(c--,1):(i=!1,l<o&&(o=l));if(i){e.splice(p--,1);var s=n();void 0!==s&&(t=s)}}return t}l=l||0;for(var p=e.length;p>0&&e[p-1][2]>l;p--)e[p]=e[p-1];e[p]=[a,n,l]},r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var a in t)r.o(t,a)&&!r.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={996:0,528:0};r.O.j=t=>0===e[t];var t=(t,a)=>{var n,l,[o,i,c]=a,s=0;if(o.some((t=>0!==e[t]))){for(n in i)r.o(i,n)&&(r.m[n]=i[n]);if(c)var p=c(r)}for(t&&t(a);s<o.length;s++)l=o[s],r.o(e,l)&&e[l]&&e[l][0](),e[l]=0;return r.O(p)},a=globalThis.webpackChunkwordpress_activitypub=globalThis.webpackChunkwordpress_activitypub||[];a.forEach(t.bind(null,0)),a.push=t.bind(null,a.push.bind(a))})();var n=r.O(void 0,[528],(()=>r(250)));n=r.O(n)})();
(()=>{var e,t={73:(e,t,a)=>{"use strict";const r=window.React,n=window.wp.apiFetch;var l=a.n(n);const o=window.wp.url,c=window.wp.element,i=window.wp.i18n;var s=a(942),p=a.n(s);function u({active:e,children:t,page:a,pageClick:n,className:l}){const o=p()("wp-block activitypub-pager",l,{current:e});return(0,r.createElement)("a",{className:o,onClick:t=>{t.preventDefault(),!e&&n(a)}},t)}function m({compact:e,nextLabel:t,page:a,pageClick:n,perPage:l,prevLabel:o,total:c,variant:i="outlined"}){const s=((e,t)=>{let a=[1,e-2,e-1,e,e+1,e+2,t];a.sort(((e,t)=>e-t)),a=a.filter(((e,a,r)=>e>=1&&e<=t&&r.lastIndexOf(e)===a));for(let e=a.length-2;e>=0;e--)a[e]===a[e+1]&&a.splice(e+1,1);return a})(a,Math.ceil(c/l)),m=p()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${i}`,{"is-compact":e});return(0,r.createElement)("nav",{className:m},o&&(0,r.createElement)(u,{key:"prev",page:a-1,pageClick:n,active:1===a,"aria-label":o,className:"wp-block-query-pagination-previous block-editor-block-list__block"},o),!e&&(0,r.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},s.map((e=>(0,r.createElement)(u,{key:e,page:e,pageClick:n,active:e===a,className:"page-numbers"},e)))),t&&(0,r.createElement)(u,{key:"next",page:a+1,pageClick:n,active:a===Math.ceil(c/l),"aria-label":t,className:"wp-block-query-pagination-next block-editor-block-list__block"},t))}const f=window.wp.components;function v({selectedUser:e,per_page:t,order:a,title:n,page:s,setPage:p,className:u="",followLinks:f=!0,followerData:v=!1}){const w="site"===e?0:e,[d,g]=(0,r.useState)([]),[y,k]=(0,r.useState)(0),[h,E]=(0,r.useState)(0),[N,x]=function(){const[e,t]=(0,r.useState)(1);return[e,t]}(),_=s||N,O=p||x,S=(0,c.createInterpolateElement)(/* translators: arrow for previous followers link */ /* translators: arrow for previous followers link */
(0,i.__)("<span>←</span> Less","activitypub"),{span:(0,r.createElement)("span",{className:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),C=(0,c.createInterpolateElement)(/* translators: arrow for next followers link */ /* translators: arrow for next followers link */
(0,i.__)("More <span>→</span>","activitypub"),{span:(0,r.createElement)("span",{className:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})}),L=(e,a)=>{g(e),E(a),k(Math.ceil(a/t))};return(0,r.useEffect)((()=>{if(v&&1===_)return L(v.followers,v.total);const e=function(e,t,a,r){const{namespace:n}=window._activityPubOptions||{},l=`/${n}/actors/${e}/followers`,c={per_page:t,order:a,page:r,context:"full"};return(0,o.addQueryArgs)(l,c)}(w,t,a,_);l()({path:e}).then((e=>L(e.orderedItems,e.totalItems))).catch((()=>{}))}),[w,t,a,_,v]),(0,r.createElement)("div",{className:"activitypub-follower-block "+u},(0,r.createElement)("h3",null,n),(0,r.createElement)("ul",null,d&&d.map((e=>(0,r.createElement)("li",{key:e.url},(0,r.createElement)(b,{...e,followLinks:f}))))),y>1&&(0,r.createElement)(m,{page:_,perPage:t,total:h,pageClick:O,nextLabel:C,prevLabel:S,compact:"is-style-compact"===u}))}function b({name:e,icon:t,url:a,preferredUsername:n,followLinks:l=!0}){const o=`@${n}`,c={};return l||(c.onClick=e=>e.preventDefault()),(0,r.createElement)(f.ExternalLink,{className:"activitypub-link",href:a,title:o,...c},(0,r.createElement)("img",{width:"40",height:"40",src:t.url,className:"avatar activitypub-avatar",alt:e}),(0,r.createElement)("span",{className:"activitypub-actor"},(0,r.createElement)("strong",{className:"activitypub-name"},e),(0,r.createElement)("span",{className:"sep"},"/"),(0,r.createElement)("span",{className:"activitypub-handle"},o)))}const w=window.wp.domReady;a.n(w)()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-follower-block"),(e=>{const t=JSON.parse(e.dataset.attrs);(0,c.createRoot)(e).render((0,r.createElement)(v,{...t}))}))}))},942:(e,t)=>{var a;!function(){"use strict";var r={}.hasOwnProperty;function n(){for(var e="",t=0;t<arguments.length;t++){var a=arguments[t];a&&(e=o(e,l(a)))}return e}function l(e){if("string"==typeof e||"number"==typeof e)return e;if("object"!=typeof e)return"";if(Array.isArray(e))return n.apply(null,e);if(e.toString!==Object.prototype.toString&&!e.toString.toString().includes("[native code]"))return e.toString();var t="";for(var a in e)r.call(e,a)&&e[a]&&(t=o(t,a));return t}function o(e,t){return t?e?e+" "+t:e+t:e}e.exports?(n.default=n,e.exports=n):void 0===(a=function(){return n}.apply(t,[]))||(e.exports=a)}()}},a={};function r(e){var n=a[e];if(void 0!==n)return n.exports;var l=a[e]={exports:{}};return t[e](l,l.exports,r),l.exports}r.m=t,e=[],r.O=(t,a,n,l)=>{if(!a){var o=1/0;for(p=0;p<e.length;p++){a=e[p][0],n=e[p][1],l=e[p][2];for(var c=!0,i=0;i<a.length;i++)(!1&l||o>=l)&&Object.keys(r.O).every((e=>r.O[e](a[i])))?a.splice(i--,1):(c=!1,l<o&&(o=l));if(c){e.splice(p--,1);var s=n();void 0!==s&&(t=s)}}return t}l=l||0;for(var p=e.length;p>0&&e[p-1][2]>l;p--)e[p]=e[p-1];e[p]=[a,n,l]},r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var a in t)r.o(t,a)&&!r.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={996:0,528:0};r.O.j=t=>0===e[t];var t=(t,a)=>{var n,l,o=a[0],c=a[1],i=a[2],s=0;if(o.some((t=>0!==e[t]))){for(n in c)r.o(c,n)&&(r.m[n]=c[n]);if(i)var p=i(r)}for(t&&t(a);s<o.length;s++)l=o[s],r.o(e,l)&&e[l]&&e[l][0](),e[l]=0;return r.O(p)},a=self.webpackChunkwordpress_activitypub=self.webpackChunkwordpress_activitypub||[];a.forEach(t.bind(null,0)),a.push=t.bind(null,a.push.bind(a))})();var n=r.O(void 0,[528],(()=>r(73)));n=r.O(n)})();

View File

@ -0,0 +1,37 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "activitypub/reactions",
"apiVersion": 2,
"version": "1.0.0",
"title": "Fediverse Reactions",
"category": "widgets",
"icon": "heart",
"description": "Display Fediverse likes and reposts",
"supports": {
"html": false,
"align": true,
"layout": {
"default": {
"type": "constrained",
"orientation": "vertical",
"justifyContent": "center"
}
}
},
"attributes": {
"title": {
"type": "string",
"default": "Fediverse reactions"
}
},
"blockHooks": {
"core/post-content": "after"
},
"textdomain": "activitypub",
"editorScript": "file:./index.js",
"style": [
"file:./style-index.css",
"wp-components"
],
"viewScript": "file:./view.js"
}

View File

@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '32631215c76c36b38e5e');

View File

@ -0,0 +1,3 @@
(()=>{"use strict";var e,t={373:(e,t,a)=>{const n=window.wp.blocks,r=window.React,l=window.wp.blockEditor,o=window.wp.element,s=window.wp.i18n,i=window.wp.components,c=window.wp.apiFetch;var u=a.n(c);function m(){return window._activityPubOptions||{}}const p=({reactions:e})=>{const{defaultAvatarUrl:t}=m(),[a,n]=(0,o.useState)(new Set),[l,s]=(0,o.useState)(new Map),i=(0,o.useRef)([]),c=()=>{i.current.forEach((e=>clearTimeout(e))),i.current=[]},u=(t,a)=>{c();const r=100,l=e.length;a&&s((e=>{const a=new Map(e);return a.set(t,"clockwise"),a}));const o=e=>{const o="right"===e,c=o?l-1:0,u=o?1:-1;for(let e=o?t:t-1;o?e<=c:e>=c;e+=u){const l=Math.abs(e-t),o=setTimeout((()=>{n((t=>{const n=new Set(t);return a?n.add(e):n.delete(e),n})),a&&e!==t&&s((t=>{const a=new Map(t),n=e-u,r=a.get(n);return a.set(e,"clockwise"===r?"counter":"clockwise"),a}))}),l*r);i.current.push(o)}};if(o("right"),o("left"),!a){const e=Math.max((l-t)*r,t*r),a=setTimeout((()=>{s(new Map)}),e+r);i.current.push(a)}};return(0,o.useEffect)((()=>()=>c()),[]),(0,r.createElement)("ul",{className:"reaction-avatars"},e.map(((e,n)=>{const o=l.get(n),s=["reaction-avatar",a.has(n)?"wave-active":"",o?`rotate-${o}`:""].filter(Boolean).join(" "),i=e.avatar||t;return(0,r.createElement)("li",{key:n},(0,r.createElement)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",onMouseEnter:()=>u(n,!0),onMouseLeave:()=>u(n,!1)},(0,r.createElement)("img",{src:i,alt:e.name,className:s,width:"32",height:"32"})))})))},f=({reactions:e,type:t})=>(0,r.createElement)("ul",{className:"activitypub-reaction-list"},e.map(((e,t)=>(0,r.createElement)("li",{key:t},(0,r.createElement)("a",{href:e.url,className:"reaction-item",target:"_blank",rel:"noopener noreferrer"},(0,r.createElement)("img",{src:e.avatar,alt:e.name,width:"32",height:"32"}),(0,r.createElement)("span",null,e.name)))))),h=({items:e,label:t})=>{const[a,n]=(0,o.useState)(!1),[l,s]=(0,o.useState)(null),[c,u]=(0,o.useState)(e.length),m=(0,o.useRef)(null);(0,o.useEffect)((()=>{if(!m.current)return;const t=()=>{const t=m.current;if(!t)return;const a=t.offsetWidth-(l?.offsetWidth||0)-12,n=Math.max(1,Math.floor((a-32)/22));u(Math.min(n,e.length))};t();const a=new ResizeObserver(t);return a.observe(m.current),()=>{a.disconnect()}}),[l,e.length]);const h=e.slice(0,c);return(0,r.createElement)("div",{className:"reaction-group",ref:m},(0,r.createElement)(p,{reactions:h}),(0,r.createElement)(i.Button,{ref:s,className:"reaction-label is-link",onClick:()=>n(!a),"aria-expanded":a},t),a&&l&&(0,r.createElement)(i.Popover,{anchor:l,onClose:()=>n(!1)},(0,r.createElement)(f,{reactions:e})))};function d({title:e="",postId:t=null,reactions:a=null,titleComponent:n=null}){const{namespace:l}=m(),[s,i]=(0,o.useState)(a),[c,p]=(0,o.useState)(!a);return(0,o.useEffect)((()=>{if(a)return i(a),void p(!1);t?(p(!0),u()({path:`/${l}/posts/${t}/reactions`}).then((e=>{i(e),p(!1)})).catch((()=>p(!1)))):p(!1)}),[t,a]),c?null:s&&Object.values(s).some((e=>e.items?.length>0))?(0,r.createElement)("div",{className:"activitypub-reactions"},n||e&&(0,r.createElement)("h6",null,e),Object.entries(s).map((([e,t])=>t.items?.length?(0,r.createElement)(h,{key:e,items:t.items,label:t.label}):null))):null}const v=e=>{const t=["#FF6B6B","#4ECDC4","#45B7D1","#96CEB4","#FFEEAD","#D4A5A5","#9B59B6","#3498DB","#E67E22"],a=(()=>{const e=["Bouncy","Cosmic","Dancing","Fluffy","Giggly","Hoppy","Jazzy","Magical","Nifty","Perky","Quirky","Sparkly","Twirly","Wiggly","Zippy"],t=["Badger","Capybara","Dolphin","Echidna","Flamingo","Giraffe","Hedgehog","Iguana","Jellyfish","Koala","Lemur","Manatee","Narwhal","Octopus","Penguin"];return`${e[Math.floor(Math.random()*e.length)]} ${t[Math.floor(Math.random()*t.length)]}`})(),n=t[Math.floor(Math.random()*t.length)],r=a.charAt(0),l=document.createElement("canvas");l.width=64,l.height=64;const o=l.getContext("2d");return o.fillStyle=n,o.beginPath(),o.arc(32,32,32,0,2*Math.PI),o.fill(),o.fillStyle="#FFFFFF",o.font="32px sans-serif",o.textAlign="center",o.textBaseline="middle",o.fillText(r,32,32),{name:a,url:"#",avatar:l.toDataURL()}},g=JSON.parse('{"UU":"activitypub/reactions"}');(0,n.registerBlockType)(g.UU,{edit:function({attributes:e,setAttributes:t,__unstableLayoutClassNames:a}){const n=(0,l.useBlockProps)({className:a}),[i]=(0,o.useState)({likes:{label:(0,s.sprintf)(/* translators: %d: Number of likes */ /* translators: %d: Number of likes */
(0,s._x)("%d likes","number of likes","activitypub"),9),items:Array.from({length:9},((e,t)=>v()))},reposts:{label:(0,s.sprintf)(/* translators: %d: Number of reposts */ /* translators: %d: Number of reposts */
(0,s._x)("%d reposts","number of reposts","activitypub"),6),items:Array.from({length:6},((e,t)=>v()))}}),c=(0,r.createElement)(l.RichText,{tagName:"h6",value:e.title,onChange:e=>t({title:e}),placeholder:(0,s.__)("Fediverse Reactions","activitypub"),disableLineBreaks:!0,allowedFormats:[]});return(0,r.createElement)("div",{...n},(0,r.createElement)(d,{titleComponent:c,reactions:i}))}})}},a={};function n(e){var r=a[e];if(void 0!==r)return r.exports;var l=a[e]={exports:{}};return t[e](l,l.exports,n),l.exports}n.m=t,e=[],n.O=(t,a,r,l)=>{if(!a){var o=1/0;for(u=0;u<e.length;u++){a=e[u][0],r=e[u][1],l=e[u][2];for(var s=!0,i=0;i<a.length;i++)(!1&l||o>=l)&&Object.keys(n.O).every((e=>n.O[e](a[i])))?a.splice(i--,1):(s=!1,l<o&&(o=l));if(s){e.splice(u--,1);var c=r();void 0!==c&&(t=c)}}return t}l=l||0;for(var u=e.length;u>0&&e[u-1][2]>l;u--)e[u]=e[u-1];e[u]=[a,r,l]},n.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{for(var a in t)n.o(t,a)&&!n.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={608:0,104:0};n.O.j=t=>0===e[t];var t=(t,a)=>{var r,l,o=a[0],s=a[1],i=a[2],c=0;if(o.some((t=>0!==e[t]))){for(r in s)n.o(s,r)&&(n.m[r]=s[r]);if(i)var u=i(n)}for(t&&t(a);c<o.length;c++)l=o[c],n.o(e,l)&&e[l]&&e[l][0](),e[l]=0;return n.O(u)},a=self.webpackChunkwordpress_activitypub=self.webpackChunkwordpress_activitypub||[];a.forEach(t.bind(null,0)),a.push=t.bind(null,a.push.bind(a))})();var r=n.O(void 0,[104],(()=>n(373)));r=n.O(r)})();

View File

@ -0,0 +1 @@
.activitypub-reactions h6{border-top:1px solid;border-top-color:var(--wp--preset--color--contrast-2);display:inline-block;padding-top:.5em}.activitypub-reactions .reaction-group{align-items:center;display:flex;gap:.75em;justify-content:flex-start;margin:.5em 0;position:relative;width:100%}@media(max-width:782px){.activitypub-reactions .reaction-group:has(.reaction-avatars:not(:empty)){justify-content:space-between}}.activitypub-reactions .reaction-avatars{align-items:center;display:flex;flex-direction:row;list-style:none;margin:0;padding:0}.activitypub-reactions .reaction-avatars li{margin:0 0 0 -10px;padding:0}.activitypub-reactions .reaction-avatars li:last-child{margin-left:0}.activitypub-reactions .reaction-avatars li a{display:block;text-decoration:none}.activitypub-reactions .reaction-avatars .reaction-avatar{border:.5px solid var(--wp--preset--color--contrast,hsla(0,0%,100%,.8));border-radius:50%;box-shadow:0 0 0 .5px hsla(0,0%,100%,.8),0 1px 3px rgba(0,0,0,.2);height:32px;transition:transform .6s cubic-bezier(.34,1.56,.64,1);width:32px;will-change:transform}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active{transform:translateY(-5px)}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-clockwise{transform:translateY(-5px) rotate(-30deg)}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-counter{transform:translateY(-5px) rotate(30deg)}.activitypub-reactions .reaction-avatars .reaction-avatar:hover{position:relative;z-index:1}.activitypub-reactions .reaction-label.components-button{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#2271b1);flex:0 0 auto;height:auto;padding:0;text-decoration:none;white-space:nowrap}.activitypub-reactions .reaction-label.components-button:hover{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#135e96);text-decoration:underline}.activitypub-reactions .reaction-label.components-button:focus:not(:disabled){box-shadow:none;outline:1px solid var(--wp--preset--color--contrast,#135e96);outline-offset:2px}.activitypub-reaction-list{background-color:var(--wp--preset--color--background,var(--wp--preset--color--custom-background,var(--wp--preset--color--base)));list-style:none;margin:0;max-width:300px;padding:.25em .7em .25em 1.3em;width:-moz-max-content;width:max-content}.activitypub-reaction-list ul{margin:0;padding:0}.activitypub-reaction-list li{font-size:var(--wp--preset--font-size--small);margin:0;padding:0}.activitypub-reaction-list a{align-items:center;color:var(--wp--preset--color--contrast,var(--wp--preset--color--secondary));display:flex;font-size:var(--wp--preset--font-size--small,.75rem);gap:.5em;justify-content:flex-start;padding:.5em;text-decoration:none}.activitypub-reaction-list a:hover{text-decoration:underline}.activitypub-reaction-list a img{border-radius:50%;flex:none;height:24px;width:24px}

View File

@ -0,0 +1 @@
.activitypub-reactions h6{border-top:1px solid;border-top-color:var(--wp--preset--color--contrast-2);display:inline-block;padding-top:.5em}.activitypub-reactions .reaction-group{align-items:center;display:flex;gap:.75em;justify-content:flex-start;margin:.5em 0;position:relative;width:100%}@media(max-width:782px){.activitypub-reactions .reaction-group:has(.reaction-avatars:not(:empty)){justify-content:space-between}}.activitypub-reactions .reaction-avatars{align-items:center;display:flex;flex-direction:row;list-style:none;margin:0;padding:0}.activitypub-reactions .reaction-avatars li{margin:0 -10px 0 0;padding:0}.activitypub-reactions .reaction-avatars li:last-child{margin-right:0}.activitypub-reactions .reaction-avatars li a{display:block;text-decoration:none}.activitypub-reactions .reaction-avatars .reaction-avatar{border:.5px solid var(--wp--preset--color--contrast,hsla(0,0%,100%,.8));border-radius:50%;box-shadow:0 0 0 .5px hsla(0,0%,100%,.8),0 1px 3px rgba(0,0,0,.2);height:32px;transition:transform .6s cubic-bezier(.34,1.56,.64,1);width:32px;will-change:transform}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active{transform:translateY(-5px)}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-clockwise{transform:translateY(-5px) rotate(30deg)}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-counter{transform:translateY(-5px) rotate(-30deg)}.activitypub-reactions .reaction-avatars .reaction-avatar:hover{position:relative;z-index:1}.activitypub-reactions .reaction-label.components-button{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#2271b1);flex:0 0 auto;height:auto;padding:0;text-decoration:none;white-space:nowrap}.activitypub-reactions .reaction-label.components-button:hover{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#135e96);text-decoration:underline}.activitypub-reactions .reaction-label.components-button:focus:not(:disabled){box-shadow:none;outline:1px solid var(--wp--preset--color--contrast,#135e96);outline-offset:2px}.activitypub-reaction-list{background-color:var(--wp--preset--color--background,var(--wp--preset--color--custom-background,var(--wp--preset--color--base)));list-style:none;margin:0;max-width:300px;padding:.25em 1.3em .25em .7em;width:-moz-max-content;width:max-content}.activitypub-reaction-list ul{margin:0;padding:0}.activitypub-reaction-list li{font-size:var(--wp--preset--font-size--small);margin:0;padding:0}.activitypub-reaction-list a{align-items:center;color:var(--wp--preset--color--contrast,var(--wp--preset--color--secondary));display:flex;font-size:var(--wp--preset--font-size--small,.75rem);gap:.5em;justify-content:flex-start;padding:.5em;text-decoration:none}.activitypub-reaction-list a:hover{text-decoration:underline}.activitypub-reaction-list a img{border-radius:50%;flex:none;height:24px;width:24px}

View File

@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-dom-ready', 'wp-element', 'wp-i18n'), 'version' => 'd5cb95d9bd6062974b3c');

View File

@ -0,0 +1 @@
(()=>{"use strict";var e={n:t=>{var n=t&&t.__esModule?()=>t.default:()=>t;return e.d(n,{a:n}),n},d:(t,n)=>{for(var a in n)e.o(n,a)&&!e.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:n[a]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.React,n=window.wp.element,a=window.wp.domReady;var r=e.n(a);const c=window.wp.components,o=window.wp.apiFetch;var l=e.n(o);function s(){return window._activityPubOptions||{}}window.wp.i18n;const i=({reactions:e})=>{const{defaultAvatarUrl:a}=s(),[r,c]=(0,n.useState)(new Set),[o,l]=(0,n.useState)(new Map),i=(0,n.useRef)([]),u=()=>{i.current.forEach((e=>clearTimeout(e))),i.current=[]},m=(t,n)=>{u();const a=100,r=e.length;n&&l((e=>{const n=new Map(e);return n.set(t,"clockwise"),n}));const o=e=>{const o="right"===e,s=o?r-1:0,u=o?1:-1;for(let e=o?t:t-1;o?e<=s:e>=s;e+=u){const r=Math.abs(e-t),o=setTimeout((()=>{c((t=>{const a=new Set(t);return n?a.add(e):a.delete(e),a})),n&&e!==t&&l((t=>{const n=new Map(t),a=e-u,r=n.get(a);return n.set(e,"clockwise"===r?"counter":"clockwise"),n}))}),r*a);i.current.push(o)}};if(o("right"),o("left"),!n){const e=Math.max((r-t)*a,t*a),n=setTimeout((()=>{l(new Map)}),e+a);i.current.push(n)}};return(0,n.useEffect)((()=>()=>u()),[]),(0,t.createElement)("ul",{className:"reaction-avatars"},e.map(((e,n)=>{const c=o.get(n),l=["reaction-avatar",r.has(n)?"wave-active":"",c?`rotate-${c}`:""].filter(Boolean).join(" "),s=e.avatar||a;return(0,t.createElement)("li",{key:n},(0,t.createElement)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",onMouseEnter:()=>m(n,!0),onMouseLeave:()=>m(n,!1)},(0,t.createElement)("img",{src:s,alt:e.name,className:l,width:"32",height:"32"})))})))},u=({reactions:e,type:n})=>(0,t.createElement)("ul",{className:"activitypub-reaction-list"},e.map(((e,n)=>(0,t.createElement)("li",{key:n},(0,t.createElement)("a",{href:e.url,className:"reaction-item",target:"_blank",rel:"noopener noreferrer"},(0,t.createElement)("img",{src:e.avatar,alt:e.name,width:"32",height:"32"}),(0,t.createElement)("span",null,e.name)))))),m=({items:e,label:a})=>{const[r,o]=(0,n.useState)(!1),[l,s]=(0,n.useState)(null),[m,p]=(0,n.useState)(e.length),h=(0,n.useRef)(null);(0,n.useEffect)((()=>{if(!h.current)return;const t=()=>{const t=h.current;if(!t)return;const n=t.offsetWidth-(l?.offsetWidth||0)-12,a=Math.max(1,Math.floor((n-32)/22));p(Math.min(a,e.length))};t();const n=new ResizeObserver(t);return n.observe(h.current),()=>{n.disconnect()}}),[l,e.length]);const f=e.slice(0,m);return(0,t.createElement)("div",{className:"reaction-group",ref:h},(0,t.createElement)(i,{reactions:f}),(0,t.createElement)(c.Button,{ref:s,className:"reaction-label is-link",onClick:()=>o(!r),"aria-expanded":r},a),r&&l&&(0,t.createElement)(c.Popover,{anchor:l,onClose:()=>o(!1)},(0,t.createElement)(u,{reactions:e})))};function p({title:e="",postId:a=null,reactions:r=null,titleComponent:c=null}){const{namespace:o}=s(),[i,u]=(0,n.useState)(r),[p,h]=(0,n.useState)(!r);return(0,n.useEffect)((()=>{if(r)return u(r),void h(!1);a?(h(!0),l()({path:`/${o}/posts/${a}/reactions`}).then((e=>{u(e),h(!1)})).catch((()=>h(!1)))):h(!1)}),[a,r]),p?null:i&&Object.values(i).some((e=>e.items?.length>0))?(0,t.createElement)("div",{className:"activitypub-reactions"},c||e&&(0,t.createElement)("h6",null,e),Object.entries(i).map((([e,n])=>n.items?.length?(0,t.createElement)(m,{key:e,items:n.items,label:n.label}):null))):null}r()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-reactions-block"),(e=>{const a=JSON.parse(e.dataset.attrs);(0,n.createRoot)(e).render((0,t.createElement)(p,{...a}))}))}))})();

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => 'ab787305c7ed07812b96');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '7160b6399cd924e1c7be');

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--wp--preset--color--white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-left:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:0 50px 50px 0;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:50px 0 0 50px;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-remote-profile-delete{align-self:center;color:inherit;font-size:inherit;height:inherit;padding:0 5px}.activitypub-remote-profile-delete:hover{background:inherit;border:inherit}.activitypub-remote-reply{display:flex}
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--color-white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-left:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:0 50px 50px 0;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:50px 0 0 50px;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-remote-profile-delete{align-self:center;color:inherit;font-size:inherit;height:inherit;padding:0 5px}.activitypub-remote-profile-delete:hover{background:inherit;border:inherit}.activitypub-remote-reply{display:flex}

View File

@ -1 +1 @@
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--wp--preset--color--white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-right:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:50px 0 0 50px;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:0 50px 50px 0;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-remote-profile-delete{align-self:center;color:inherit;font-size:inherit;height:inherit;padding:0 5px}.activitypub-remote-profile-delete:hover{background:inherit;border:inherit}.activitypub-remote-reply{display:flex}
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--color-white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-right:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:50px 0 0 50px;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:0 50px 50px 0;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-remote-profile-delete{align-self:center;color:inherit;font-size:inherit;height:inherit;padding:0 5px}.activitypub-remote-profile-delete:hover{background:inherit;border:inherit}.activitypub-remote-reply{display:flex}

View File

@ -0,0 +1,12 @@
{
"name": "reply-handler",
"title": "Reply Handler: not a block, but block.json is very useful.",
"category": "widgets",
"icon": "admin-comments",
"keywords": [
"reply",
"handler",
"comments"
],
"editorScript": "file:./plugin.js"
}

View File

@ -0,0 +1 @@
<?php return array('dependencies' => array('wp-block-editor', 'wp-blocks', 'wp-data', 'wp-element', 'wp-plugins'), 'version' => 'f65a7269b5abb57d3e73');

View File

@ -0,0 +1 @@
(()=>{"use strict";const e=window.wp.plugins,t=window.wp.blocks,i=window.wp.data,n=window.wp.blockEditor,o=window.wp.element;let r=!1;(0,e.registerPlugin)("activitypub-reply-intent",{render:()=>((0,o.useEffect)((()=>{if(r)return;const e=new URLSearchParams(window.location.search).get("in_reply_to");e&&!r&&setTimeout((()=>{const o=(0,t.createBlock)("activitypub/reply",{url:e,embedPost:!0}),r=(0,i.dispatch)(n.store);r.insertBlock(o),r.insertAfterBlock(o.clientId)}),200),r=!0})),null)})})();

View File

@ -0,0 +1,29 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "activitypub/reply",
"version": "0.1.0",
"title": "Federated Reply",
"category": "widgets",
"icon": "commentReplyLink",
"description": "Respond to posts, notes, videos, and other content on the fediverse. Ensure the URL originates from a federated social network like Mastodon, as other URLs might not function as expected.",
"supports": {
"html": false,
"inserter": true,
"reusable": false,
"lock": false
},
"textdomain": "activitypub",
"editorScript": "file:./index.js",
"editorStyle": "file:./style-index.css",
"style": "file:./index.css",
"attributes": {
"url": {
"type": "string"
},
"embedPost": {
"type": "boolean",
"default": null
}
}
}

View File

@ -0,0 +1 @@
.activitypub-embed{background:#fff;border:1px solid #e6e6e6;border-radius:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:100%;padding:0}.activitypub-reply-block .activitypub-embed{margin:1em 0}.activitypub-embed-header{align-items:center;display:flex;gap:10px;padding:15px}.activitypub-embed-header img{border-radius:50%;height:48px;width:48px}.activitypub-embed-header-text{flex-grow:1}.activitypub-embed-header-text h2{color:#000;font-size:15px;font-weight:600;margin:0;padding:0}.activitypub-embed-header-text .ap-account{color:#687684;font-size:14px;text-decoration:none}.activitypub-embed-content{padding:0 15px 15px}.activitypub-embed-content .ap-title{color:#000;font-size:23px;font-weight:600;margin:0 0 10px;padding:0}.activitypub-embed-content .ap-subtitle{color:#000;font-size:15px;margin:0 0 15px}.activitypub-embed-content .ap-preview{border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}.activitypub-embed-content .ap-preview img{display:block;height:auto;width:100%}.activitypub-embed-content .ap-preview-text{padding:15px}.activitypub-embed-meta{border-top:1px solid #e6e6e6;color:#687684;display:flex;font-size:13px;gap:15px;padding:15px}.activitypub-embed-meta .ap-stat{align-items:center;display:flex;gap:5px}@media only screen and (max-width:399px){.activitypub-embed-meta .ap-stat{display:none!important}}.activitypub-embed-meta a.ap-stat{color:inherit;text-decoration:none}.activitypub-embed-meta strong{color:#000;font-weight:600}.activitypub-embed-meta .ap-stat-label{color:#687684}.wp-block-activitypub-reply .components-spinner{height:12px;margin-bottom:0;margin-top:0;width:12px}

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '0708145714d72862bff0');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => 'fcd855ff6f64b21029be');

View File

@ -0,0 +1 @@
.activitypub-embed{background:#fff;border:1px solid #e6e6e6;border-radius:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:100%;padding:0}.activitypub-reply-block .activitypub-embed{margin:1em 0}.activitypub-embed-header{align-items:center;display:flex;gap:10px;padding:15px}.activitypub-embed-header img{border-radius:50%;height:48px;width:48px}.activitypub-embed-header-text{flex-grow:1}.activitypub-embed-header-text h2{color:#000;font-size:15px;font-weight:600;margin:0;padding:0}.activitypub-embed-header-text .ap-account{color:#687684;font-size:14px;text-decoration:none}.activitypub-embed-content{padding:0 15px 15px}.activitypub-embed-content .ap-title{color:#000;font-size:23px;font-weight:600;margin:0 0 10px;padding:0}.activitypub-embed-content .ap-subtitle{color:#000;font-size:15px;margin:0 0 15px}.activitypub-embed-content .ap-preview{border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}.activitypub-embed-content .ap-preview img{display:block;height:auto;width:100%}.activitypub-embed-content .ap-preview-text{padding:15px}.activitypub-embed-meta{border-top:1px solid #e6e6e6;color:#687684;display:flex;font-size:13px;gap:15px;padding:15px}.activitypub-embed-meta .ap-stat{align-items:center;display:flex;gap:5px}@media only screen and (max-width:399px){.activitypub-embed-meta .ap-stat{display:none!important}}.activitypub-embed-meta a.ap-stat{color:inherit;text-decoration:none}.activitypub-embed-meta strong{color:#000;font-weight:600}.activitypub-embed-meta .ap-stat-label{color:#687684}.wp-block-activitypub-reply .components-spinner{height:12px;margin-bottom:0;margin-top:0;width:12px}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.activitypub-embed-container{margin-top:1em;min-height:100px;pointer-events:none;position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none}.activitypub-embed-loading{align-items:center;display:flex;justify-content:center}.activitypub-embed-container .wp-block-embed{pointer-events:none!important}.activitypub-embed-preview,.activitypub-embed-preview iframe{pointer-events:none}.activitypub-reply-display{margin:1em 0}.activitypub-reply-display p{margin:0}.activitypub-reply-display a{color:#2271b1;text-decoration:none}.activitypub-reply-display a:hover{color:#135e96;text-decoration:underline}

View File

@ -0,0 +1 @@
.activitypub-embed-container{margin-top:1em;min-height:100px;pointer-events:none;position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none}.activitypub-embed-loading{align-items:center;display:flex;justify-content:center}.activitypub-embed-container .wp-block-embed{pointer-events:none!important}.activitypub-embed-preview,.activitypub-embed-preview iframe{pointer-events:none}.activitypub-reply-display{margin:1em 0}.activitypub-reply-display p{margin:0}.activitypub-reply-display a{color:#2271b1;text-decoration:none}.activitypub-reply-display a:hover{color:#135e96;text-decoration:underline}

View File

@ -3,11 +3,14 @@
* Inspired by the PHP ActivityPub Library by @Landrok
*
* @link https://github.com/landrok/activitypub
*
* @package Activitypub
*/
namespace Activitypub\Activity;
use Activitypub\Activity\Base_Object;
use Activitypub\Activity\Extended_Object\Event;
use Activitypub\Activity\Extended_Object\Place;
/**
* \Activitypub\Activity\Activity implements the common
@ -22,6 +25,45 @@ class Activity extends Base_Object {
);
/**
* The default types for Activities.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
*
* @var array
*/
const TYPES = array(
'Accept',
'Add',
'Announce',
'Arrive',
'Block',
'Create',
'Delete',
'Dislike',
'Follow',
'Flag',
'Ignore',
'Invite',
'Join',
'Leave',
'Like',
'Listen',
'Move',
'Offer',
'Read',
'Reject',
'Remove',
'TentativeAccept',
'TentativeReject',
'Travel',
'Undo',
'Update',
'View',
);
/**
* The type of the object.
*
* @var string
*/
protected $type = 'Activity';
@ -33,10 +75,7 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object-term
*
* @var string
* | Base_Object
* | Link
* | null
* @var string|Base_Object|null
*/
protected $object;
@ -48,11 +87,7 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-actor
*
* @var string
* | \ActivityPhp\Type\Extended\AbstractActor
* | array<Actor>
* | array<Link>
* | Link
* @var string|array
*/
protected $actor;
@ -67,11 +102,7 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-target
*
* @var string
* | ObjectType
* | array<ObjectType>
* | Link
* | array<Link>
* @var string|array
*/
protected $target;
@ -83,13 +114,22 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-result
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|Base_Object
*/
protected $result;
/**
* Identifies a Collection containing objects considered to be responses
* to this object.
* WordPress has a strong core system of approving replies. We only include
* approved replies here.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies
*
* @var array
*/
protected $replies;
/**
* An indirect object of the activity from which the
* activity is directed.
@ -100,10 +140,7 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-origin
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|array
*/
protected $origin;
@ -113,10 +150,7 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-instrument
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|array
*/
protected $instrument;
@ -124,29 +158,67 @@ class Activity extends Base_Object {
* Set the object and copy Object properties to the Activity.
*
* Any to, bto, cc, bcc, and audience properties specified on the object
* MUST be copied over to the new Create activity by the server.
* MUST be copied over to the new "Create" activity by the server.
*
* @see https://www.w3.org/TR/activitypub/#object-without-create
*
* @param string|Base_Objectr|Link|null $object
*
* @return void
* @param array|string|Base_Object|Activity|Actor|null $data Activity object.
*/
public function set_object( $object ) {
// convert array to object
if ( is_array( $object ) ) {
$object = self::init_from_array( $object );
public function set_object( $data ) {
$object = $data;
// Convert array to appropriate object type.
if ( is_array( $data ) ) {
$type = $data['type'] ?? null;
if ( in_array( $type, self::TYPES, true ) ) {
$object = self::init_from_array( $data );
} elseif ( in_array( $type, Actor::TYPES, true ) ) {
$object = Actor::init_from_array( $data );
} elseif ( in_array( $type, Base_Object::TYPES, true ) ) {
switch ( $type ) {
case 'Event':
$object = Event::init_from_array( $data );
break;
case 'Place':
$object = Place::init_from_array( $data );
break;
default:
$object = Base_Object::init_from_array( $data );
break;
}
} else {
$object = Generic_Object::init_from_array( $data );
}
}
// set object
$this->set( 'object', $object );
$this->pre_fill_activity_from_object();
}
/**
* Fills the Activity with the specified activity object.
*/
public function pre_fill_activity_from_object() {
$object = $this->get_object();
// Check if `$data` is a URL and use it to generate an ID then.
if ( is_string( $object ) && filter_var( $object, FILTER_VALIDATE_URL ) && ! $this->get_id() ) {
$this->set( 'id', $object . '#activity-' . strtolower( $this->get_type() ) . '-' . time() );
return;
}
// Check if `$data` is an object and copy some properties otherwise do nothing.
if ( ! is_object( $object ) ) {
return;
}
foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) {
$this->set( $i, $object->get( $i ) );
$value = $object->get( $i );
if ( $value && ! $this->get( $i ) ) {
$this->set( $i, $value );
}
}
if ( $object->get_published() && ! $this->get_published() ) {
@ -161,12 +233,18 @@ class Activity extends Base_Object {
$this->set( 'actor', $object->get_attributed_to() );
}
if ( $this->get_type() !== 'Announce' && $object->get_in_reply_to() && ! $this->get_in_reply_to() ) {
$this->set( 'in_reply_to', $object->get_in_reply_to() );
}
if ( $object->get_id() && ! $this->get_id() ) {
$id = strtok( $object->get_id(), '#' );
if ( $object->get_updated() ) {
$updated = $object->get_updated();
} else {
} elseif ( $object->get_published() ) {
$updated = $object->get_published();
} else {
$updated = time();
}
$this->set( 'id', $id . '#activity-' . strtolower( $this->get_type() ) . '-' . $updated );
}
@ -181,7 +259,7 @@ class Activity extends Base_Object {
if ( $this->object instanceof Base_Object ) {
$class = get_class( $this->object );
if ( $class && $class::JSON_LD_CONTEXT ) {
// Without php 5.6 support this could be just: 'return $this->object::JSON_LD_CONTEXT;'
// Without php 5.6 support this could be just: 'return $this->object::JSON_LD_CONTEXT;'.
return $class::JSON_LD_CONTEXT;
}
}

View File

@ -3,6 +3,8 @@
* Inspired by the PHP ActivityPub Library by @Landrok
*
* @link https://github.com/landrok/activitypub
*
* @package Activitypub
*/
namespace Activitypub\Activity;
@ -22,34 +24,61 @@ class Actor extends Base_Object {
'https://w3id.org/security/v1',
'https://purl.archive.org/socialweb/webfinger',
array(
'schema' => 'http://schema.org#',
'toot' => 'http://joinmastodon.org/ns#',
'webfinger' => 'https://webfinger.net/#',
'lemmy' => 'https://join-lemmy.org/ns#',
'schema' => 'http://schema.org#',
'toot' => 'http://joinmastodon.org/ns#',
'lemmy' => 'https://join-lemmy.org/ns#',
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
'PropertyValue' => 'schema:PropertyValue',
'value' => 'schema:value',
'Hashtag' => 'as:Hashtag',
'featured' => array(
'@id' => 'toot:featured',
'PropertyValue' => 'schema:PropertyValue',
'value' => 'schema:value',
'Hashtag' => 'as:Hashtag',
'featured' => array(
'@id' => 'toot:featured',
'@type' => '@id',
),
'featuredTags' => array(
'@id' => 'toot:featuredTags',
'featuredTags' => array(
'@id' => 'toot:featuredTags',
'@type' => '@id',
),
'moderators' => array(
'@id' => 'lemmy:moderators',
'moderators' => array(
'@id' => 'lemmy:moderators',
'@type' => '@id',
),
'postingRestrictedToMods' => 'lemmy:postingRestrictedToMods',
'discoverable' => 'toot:discoverable',
'indexable' => 'toot:indexable',
'resource' => 'webfinger:resource',
'alsoKnownAs' => array(
'@id' => 'as:alsoKnownAs',
'@type' => '@id',
),
'movedTo' => array(
'@id' => 'as:movedTo',
'@type' => '@id',
),
'attributionDomains' => array(
'@id' => 'toot:attributionDomains',
'@type' => '@id',
),
'postingRestrictedToMods' => 'lemmy:postingRestrictedToMods',
'discoverable' => 'toot:discoverable',
'indexable' => 'toot:indexable',
),
);
/**
* The default types for Actors.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
*
* @var array
*/
const TYPES = array(
'Application',
'Group',
'Organization',
'Person',
'Service',
);
/**
* The type of the object.
*
* @var string
*/
protected $type;
@ -60,8 +89,7 @@ class Actor extends Base_Object {
*
* @see https://www.w3.org/TR/activitypub/#inbox
*
* @var string
* | null
* @var string|null
*/
protected $inbox;
@ -71,8 +99,7 @@ class Actor extends Base_Object {
*
* @see https://www.w3.org/TR/activitypub/#outbox
*
* @var string
* | null
* @var string|null
*/
protected $outbox;
@ -171,4 +198,28 @@ class Actor extends Base_Object {
* @var boolean
*/
protected $manually_approves_followers = false;
/**
* Domains allowed to use `fediverse:creator` for this actor in
* published articles.
*
* @see https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/
*
* @var array
*/
protected $attribution_domains = null;
/**
* The target of the actor.
*
* @var string|null
*/
protected $moved_to;
/**
* The alsoKnownAs of the actor.
*
* @var array
*/
protected $also_known_as;
}

View File

@ -3,17 +3,12 @@
* Inspired by the PHP ActivityPub Library by @Landrok
*
* @link https://github.com/landrok/activitypub
*
* @package Activitypub
*/
namespace Activitypub\Activity;
use WP_Error;
use ReflectionClass;
use DateTime;
use function Activitypub\camel_to_snake_case;
use function Activitypub\snake_to_camel_case;
/**
* Base_Object is an implementation of one of the
* Activity Streams Core Types.
@ -25,25 +20,89 @@ use function Activitypub\snake_to_camel_case;
* 'Base_' for this reason.
*
* @see https://www.w3.org/TR/activitystreams-core/#object
*
* @method string|null get_actor() Gets one or more entities that performed or are expected to perform the activity.
* @method string|null get_attributed_to() Gets the entity attributed as the original author.
* @method array|null get_attachment() Gets the attachment property of the object.
* @method array|null get_cc() Gets the secondary recipients of the object.
* @method string|null get_content() Gets the content property of the object.
* @method array|null get_icon() Gets the icon property of the object.
* @method string|null get_id() Gets the object's unique global identifier.
* @method array|null get_image() Gets the image property of the object.
* @method array|string|null get_in_reply_to() Gets the objects this object is in reply to.
* @method string|null get_name() Gets the natural language name of the object.
* @method Base_Object|string|null get_object() Gets the direct object of the activity.
* @method string|null get_published() Gets the date and time the object was published in ISO 8601 format.
* @method string|null get_summary() Gets the natural language summary of the object.
* @method array|null get_tag() Gets the tag property of the object.
* @method array|string|null get_to() Gets the primary recipients of the object.
* @method string get_type() Gets the type of the object.
* @method string|null get_updated() Gets the date and time the object was updated in ISO 8601 format.
* @method string|null get_url() Gets the URL of the object.
*
* @method string|array add_cc( string|array $cc ) Adds one or more entities to the secondary audience of the object.
* @method string|array add_to( string|array $to ) Adds one or more entities to the primary audience of the object.
*
* @method Base_Object set_actor( string|array $actor ) Sets one or more entities that performed the activity.
* @method Base_Object set_attachment( array $attachment ) Sets the attachment property of the object.
* @method Base_Object set_attributed_to( string $attributed_to ) Sets the entity attributed as the original author.
* @method Base_Object set_cc( array|string $cc ) Sets the secondary recipients of the object.
* @method Base_Object set_content( string $content ) Sets the content property of the object.
* @method Base_Object set_content_map( array $content_map ) Sets the content property of the object.
* @method Base_Object set_icon( array $icon ) Sets the icon property of the object.
* @method Base_Object set_id( string $id ) Sets the object's unique global identifier.
* @method Base_Object set_image( array $image ) Sets the image property of the object.
* @method Base_Object set_name( string $name ) Sets the natural language name of the object.
* @method Base_Object set_origin( string $origin ) Sets the origin property of the object.
* @method Base_Object set_published( string $published ) Sets the date and time the object was published in ISO 8601 format.
* @method Base_Object set_sensitive( bool $sensitive ) Sets the sensitive property of the object.
* @method Base_Object set_summary( string $summary ) Sets the natural language summary of the object.
* @method Base_Object set_summary_map( array|null $summary_map ) Sets the summary property of the object.
* @method Base_Object set_target( string $target ) Sets the target property of the object.
* @method Base_Object set_to( array|string $to ) Sets the primary recipients of the object.
* @method Base_Object set_type( string $type ) Sets the type of the object.
* @method Base_Object set_updated( string $updated ) Sets the date and time the object was updated in ISO 8601 format.
* @method Base_Object set_url( string $url ) Sets the URL of the object.
*/
class Base_Object {
class Base_Object extends Generic_Object {
/**
* The JSON-LD context for the object.
*
* @var array
*/
const JSON_LD_CONTEXT = array(
'https://www.w3.org/ns/activitystreams',
array(
'Hashtag' => 'as:Hashtag',
'Hashtag' => 'as:Hashtag',
'sensitive' => 'as:sensitive',
),
);
/**
* The object's unique global identifier
* The default types for Objects.
*
* @see https://www.w3.org/TR/activitypub/#obj-id
* @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
*
* @var string
* @var array
*/
protected $id;
const TYPES = array(
'Article',
'Audio',
'Document',
'Event',
'Image',
'Note',
'Page',
'Place',
'Profile',
'Relationship',
'Tombstone',
'Video',
);
/**
* The type of the object.
*
* @var string
*/
protected $type = 'Object';
@ -56,12 +115,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-attachment
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $attachment;
@ -72,12 +126,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-attributedto
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $attributed_to;
@ -87,12 +136,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audience
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $audience;
@ -122,10 +166,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-context
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|null
*/
protected $context;
@ -186,12 +227,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
*
* @var string
* | Image
* | Link
* | array<Image>
* | array<Link>
* | null
* @var string|array|null
*/
protected $icon;
@ -202,12 +238,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image-term
*
* @var string
* | Image
* | Link
* | array<Image>
* | array<Link>
* | null
* @var string|array|null
*/
protected $image;
@ -217,12 +248,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $in_reply_to;
@ -232,12 +258,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-location
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $location;
@ -246,10 +267,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-preview
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|null
*/
protected $preview;
@ -281,10 +299,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|null
*/
protected $summary;
@ -294,7 +309,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary
*
* @var array<string>|null
* @var string[]|null
*/
protected $summary_map;
@ -307,12 +322,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $tag;
@ -328,11 +338,7 @@ class Base_Object {
/**
* One or more links to representations of the object.
*
* @var string
* | array<string>
* | Link
* | array<Link>
* | null
* @var string|null
*/
protected $url;
@ -342,12 +348,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|array|null
*/
protected $to;
@ -357,12 +358,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-bto
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|array|null
*/
protected $bto;
@ -372,12 +368,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-cc
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|array|null
*/
protected $cc;
@ -387,12 +378,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-bcc
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|array|null
*/
protected $bcc;
@ -428,7 +414,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitypub/#source-property
*
* @var ObjectType
* @var array
*/
protected $source;
@ -438,58 +424,40 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies
*
* @var string
* | Collection
* | Link
* | null
* @var string|array|null
*/
protected $replies;
/**
* Magic function to implement getter and setter
* A Collection containing objects considered to be likes for
* this object.
*
* @param string $method The method name.
* @param string $params The method params.
* @see https://www.w3.org/TR/activitypub/#likes
*
* @return void
* @var array
*/
public function __call( $method, $params ) {
$var = \strtolower( \substr( $method, 4 ) );
if ( \strncasecmp( $method, 'get', 3 ) === 0 ) {
if ( ! $this->has( $var ) ) {
return new WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
}
return $this->$var;
}
if ( \strncasecmp( $method, 'set', 3 ) === 0 ) {
return $this->set( $var, $params[0] );
}
if ( \strncasecmp( $method, 'add', 3 ) === 0 ) {
$this->add( $var, $params[0] );
}
}
protected $likes;
/**
* Magic function, to transform the object to string.
* A Collection containing objects considered to be shares for
* this object.
*
* @return string The object id.
* @see https://www.w3.org/TR/activitypub/#shares
*
* @var array
*/
public function __toString() {
return $this->to_string();
}
protected $shares;
/**
* Function to transform the object to string.
* Used to mark an object as containing sensitive content.
* Mastodon displays a content warning, requiring users to click
* through to view the content.
*
* @return string The object id.
* @see https://docs.joinmastodon.org/spec/activitypub/#sensitive
*
* @var boolean
*/
public function to_string() {
return $this->get_id();
}
protected $sensitive;
/**
* Generic getter.
@ -500,21 +468,10 @@ class Base_Object {
*/
public function get( $key ) {
if ( ! $this->has( $key ) ) {
return new WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
return new \WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
}
return call_user_func( array( $this, 'get_' . $key ) );
}
/**
* Check if the object has a key
*
* @param string $key The key to check.
*
* @return boolean True if the object has the key.
*/
public function has( $key ) {
return property_exists( $this, $key );
return parent::get( $key );
}
/**
@ -527,12 +484,10 @@ class Base_Object {
*/
public function set( $key, $value ) {
if ( ! $this->has( $key ) ) {
return new WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
return new \WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
}
$this->$key = $value;
return $this;
return parent::set( $key, $value );
}
/**
@ -545,170 +500,9 @@ class Base_Object {
*/
public function add( $key, $value ) {
if ( ! $this->has( $key ) ) {
return new WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
return new \WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
}
if ( ! isset( $this->$key ) ) {
$this->$key = array();
}
$attributes = $this->$key;
$attributes[] = $value;
$this->$key = $attributes;
return $this->$key;
}
/**
* Convert JSON input to an array.
*
* @return string The JSON string.
*
* @return \Activitypub\Activity\Base_Object An Object built from the JSON string.
*/
public static function init_from_json( $json ) {
$array = \json_decode( $json, true );
if ( ! is_array( $array ) ) {
$array = array();
}
return self::init_from_array( $array );
}
/**
* Convert JSON input to an array.
*
* @return string The object array.
*
* @return \Activitypub\Activity\Base_Object An Object built from the JSON string.
*/
public static function init_from_array( $array ) {
if ( ! is_array( $array ) ) {
return new WP_Error( 'invalid_array', __( 'Invalid array', 'activitypub' ), array( 'status' => 404 ) );
}
$object = new static();
foreach ( $array as $key => $value ) {
$key = camel_to_snake_case( $key );
call_user_func( array( $object, 'set_' . $key ), $value );
}
return $object;
}
/**
* Convert JSON input to an array and pre-fill the object.
*
* @param string $json The JSON string.
*/
public function from_json( $json ) {
$array = \json_decode( $json, true );
$this->from_array( $array );
}
/**
* Convert JSON input to an array and pre-fill the object.
*
* @param array $array The array.
*/
public function from_array( $array ) {
foreach ( $array as $key => $value ) {
if ( $value ) {
$key = camel_to_snake_case( $key );
call_user_func( array( $this, 'set_' . $key ), $value );
}
}
}
/**
* Convert Object to an array.
*
* It tries to get the object attributes if they exist
* and falls back to the getters. Empty values are ignored.
*
* @param bool $include_json_ld_context Whether to include the JSON-LD context. Default true.
*
* @return array An array built from the Object.
*/
public function to_array( $include_json_ld_context = true ) {
$array = array();
$vars = get_object_vars( $this );
foreach ( $vars as $key => $value ) {
// ignotre all _prefixed keys.
if ( '_' === substr( $key, 0, 1 ) ) {
continue;
}
// if value is empty, try to get it from a getter.
if ( ! $value ) {
$value = call_user_func( array( $this, 'get_' . $key ) );
}
if ( is_object( $value ) ) {
$value = $value->to_array( false );
}
// if value is still empty, ignore it for the array and continue.
if ( isset( $value ) ) {
$array[ snake_to_camel_case( $key ) ] = $value;
}
}
if ( $include_json_ld_context ) {
// Get JsonLD context and move it to '@context' at the top.
$array = array_merge( array( '@context' => $this->get_json_ld_context() ), $array );
}
$class = new ReflectionClass( $this );
$class = strtolower( $class->getShortName() );
$array = \apply_filters( 'activitypub_activity_object_array', $array, $class, $this->id, $this );
$array = \apply_filters( "activitypub_activity_{$class}_object_array", $array, $this->id, $this );
return $array;
}
/**
* Convert Object to JSON.
*
* @param bool $include_json_ld_context Whether to include the JSON-LD context. Default true.
*
* @return string The JSON string.
*/
public function to_json( $include_json_ld_context = true ) {
$array = $this->to_array( $include_json_ld_context );
$options = \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT;
/*
* Options to be passed to json_encode()
*
* @param int $options The current options flags
*/
$options = \apply_filters( 'activitypub_json_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 ) );
}
/**
* Returns the JSON-LD context of this object.
*
* @return array $context A compacted JSON-LD context for the ActivityPub object.
*/
public function get_json_ld_context() {
return static::JSON_LD_CONTEXT;
return parent::add( $key, $value );
}
}

View File

@ -0,0 +1,325 @@
<?php
/**
* Generic Object.
*
* @package Activitypub
*/
namespace Activitypub\Activity;
use function Activitypub\camel_to_snake_case;
use function Activitypub\snake_to_camel_case;
/**
* Generic Object.
*
* This class is used to create Generic Objects.
* It is used to create objects that might be unknown by the plugin but
* conform to the ActivityStreams vocabulary.
*
* @since 5.3.0
*/
#[\AllowDynamicProperties]
class Generic_Object {
/**
* The JSON-LD context for the object.
*
* @var array
*/
const JSON_LD_CONTEXT = array(
'https://www.w3.org/ns/activitystreams',
);
/**
* The object's unique global identifier
*
* @see https://www.w3.org/TR/activitypub/#obj-id
*
* @var string
*/
protected $id;
/**
* Magic function, to transform the object to string.
*
* @return string The object id.
*/
public function __toString() {
return $this->to_string();
}
/**
* Function to transform the object to string.
*
* @return string The object id.
*/
public function to_string() {
return $this->get_id();
}
/**
* Magic function to implement getter and setter.
*
* @param string $method The method name.
* @param string $params The method params.
*/
public function __call( $method, $params ) {
$var = \strtolower( \substr( $method, 4 ) );
if ( \strncasecmp( $method, 'get', 3 ) === 0 ) {
if ( ! $this->has( $var ) ) {
return null;
}
return $this->$var;
}
if ( \strncasecmp( $method, 'set', 3 ) === 0 ) {
return $this->set( $var, $params[0] );
}
if ( \strncasecmp( $method, 'add', 3 ) === 0 ) {
return $this->add( $var, $params[0] );
}
}
/**
* Generic getter.
*
* @param string $key The key to get.
*
* @return mixed The value.
*/
public function get( $key ) {
return call_user_func( array( $this, 'get_' . $key ) );
}
/**
* Generic setter.
*
* @param string $key The key to set.
* @param string $value The value to set.
*
* @return mixed The value.
*/
public function set( $key, $value ) {
$this->$key = $value;
return $this;
}
/**
* Generic adder.
*
* @param string $key The key to set.
* @param mixed $value The value to add.
*
* @return mixed The value.
*/
public function add( $key, $value ) {
if ( empty( $value ) ) {
return;
}
if ( ! isset( $this->$key ) ) {
$this->$key = array();
}
if ( is_string( $this->$key ) ) {
$this->$key = array( $this->$key );
}
$attributes = $this->$key;
if ( is_array( $value ) ) {
$attributes = array_merge( $attributes, $value );
} else {
$attributes[] = $value;
}
$this->$key = array_unique( $attributes );
return $this->$key;
}
/**
* Check if the object has a key
*
* @param string $key The key to check.
*
* @return boolean True if the object has the key.
*/
public function has( $key ) {
return property_exists( $this, $key );
}
/**
* Convert JSON input to an array.
*
* @param string $json The JSON string.
*
* @return Generic_Object|\WP_Error An Object built from the JSON string or WP_Error when it's not a JSON string.
*/
public static function init_from_json( $json ) {
$array = \json_decode( $json, true );
if ( ! is_array( $array ) ) {
return new \WP_Error( 'invalid_json', __( 'Invalid JSON', 'activitypub' ), array( 'status' => 400 ) );
}
return self::init_from_array( $array );
}
/**
* Convert input array to a Base_Object.
*
* @param array $data The object array.
*
* @return Generic_Object|\WP_Error An Object built from the input array or WP_Error when it's not an array.
*/
public static function init_from_array( $data ) {
if ( ! is_array( $data ) ) {
return new \WP_Error( 'invalid_array', __( 'Invalid array', 'activitypub' ), array( 'status' => 400 ) );
}
$object = new static();
$object->from_array( $data );
return $object;
}
/**
* Convert JSON input to an array and pre-fill the object.
*
* @param array $data The array.
*/
public function from_array( $data ) {
foreach ( $data as $key => $value ) {
if ( null !== $value ) {
$key = camel_to_snake_case( $key );
call_user_func( array( $this, 'set_' . $key ), $value );
}
}
}
/**
* Convert JSON input to an array and pre-fill the object.
*
* @param string $json The JSON string.
*/
public function from_json( $json ) {
$array = \json_decode( $json, true );
$this->from_array( $array );
}
/**
* Convert Object to an array.
*
* It tries to get the object attributes if they exist
* and falls back to the getters. Empty values are ignored.
*
* @param bool $include_json_ld_context Whether to include the JSON-LD context. Default true.
*
* @return array An array built from the Object.
*/
public function to_array( $include_json_ld_context = true ) {
$array = array();
$vars = get_object_vars( $this );
foreach ( $vars as $key => $value ) {
if ( \is_wp_error( $value ) ) {
continue;
}
// Ignore all _prefixed keys.
if ( '_' === substr( $key, 0, 1 ) ) {
continue;
}
// If value is empty, try to get it from a getter.
if ( ! $value ) {
$value = call_user_func( array( $this, 'get_' . $key ) );
}
if ( is_object( $value ) ) {
$value = $value->to_array( false );
}
// If value is still empty, ignore it for the array and continue.
if ( isset( $value ) ) {
$array[ snake_to_camel_case( $key ) ] = $value;
}
}
if ( $include_json_ld_context ) {
// Get JsonLD context and move it to '@context' at the top.
$array = array_merge( array( '@context' => $this->get_json_ld_context() ), $array );
}
$class = new \ReflectionClass( $this );
$class = strtolower( $class->getShortName() );
/**
* Filter the array of the ActivityPub object.
*
* @param array $array The array of the ActivityPub object.
* @param string $class The class of the ActivityPub object.
* @param string $id The ID of the ActivityPub object.
* @param Generic_Object $object The ActivityPub object.
*
* @return array The filtered array of the ActivityPub object.
*/
$array = \apply_filters( 'activitypub_activity_object_array', $array, $class, $this->id, $this );
/**
* Filter the array of the ActivityPub object by class.
*
* @param array $array The array of the ActivityPub object.
* @param string $id The ID of the ActivityPub object.
* @param Generic_Object $object The ActivityPub object.
*
* @return array The filtered array of the ActivityPub object.
*/
return \apply_filters( "activitypub_activity_{$class}_object_array", $array, $this->id, $this );
}
/**
* Convert Object to JSON.
*
* @param bool $include_json_ld_context Whether to include the JSON-LD context. Default true.
*
* @return string The JSON string.
*/
public function to_json( $include_json_ld_context = true ) {
$array = $this->to_array( $include_json_ld_context );
$options = \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT | \JSON_UNESCAPED_SLASHES;
/**
* 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 ) );
}
/**
* Returns the JSON-LD context of this object.
*
* @return array $context A compacted JSON-LD context for the ActivityPub object.
*/
public function get_json_ld_context() {
return static::JSON_LD_CONTEXT;
}
}

View File

@ -19,8 +19,8 @@ use Activitypub\Activity\Base_Object;
class Event extends Base_Object {
// Human friendly minimal context for full Mobilizon compatible ActivityPub events.
const JSON_LD_CONTEXT = array(
'https://schema.org/', // The base context is schema.org, cause it is used a lot.
'https://www.w3.org/ns/activitystreams', // The ActivityStreams context overrides everyting also defined in schema.org.
'https://schema.org/', // The base context is schema.org, because it is used a lot.
'https://www.w3.org/ns/activitystreams', // The ActivityStreams context overrides everything also defined in schema.org.
array( // The keys here override/extend the context even more.
'pt' => 'https://joinpeertube.org/ns#',
'mz' => 'https://joinmobilizon.org/ns#',
@ -50,7 +50,8 @@ class Event extends Base_Object {
);
/**
* Mobilizon compatible values for repliesModertaionOption.
* Mobilizon compatible values for repliesModerationOption.
*
* @var array
*/
const REPLIES_MODERATION_OPTION_TYPES = array( 'allow_all', 'closed' );
@ -58,10 +59,11 @@ class Event extends Base_Object {
/**
* Mobilizon compatible values for joinModeTypes.
*/
const JOIN_MODE_TYPES = array( 'free', 'restricted', 'external' ); // and 'invite', but not used by mobilizon atm
const JOIN_MODE_TYPES = array( 'free', 'restricted', 'external' ); // and 'invite', but not used by mobilizon atm.
/**
* Allowed values for ical VEVENT STATUS.
*
* @var array
*/
const ICAL_EVENT_STATUS_TYPES = array( 'TENTATIVE', 'CONFIRMED', 'CANCELLED' );
@ -70,6 +72,7 @@ class Event extends Base_Object {
* Default event categories.
*
* These values currently reflect the default set as proposed by Mobilizon to maximize interoperability.
*
* @var array
*/
const DEFAULT_EVENT_CATEGORIES = array(
@ -106,8 +109,7 @@ class Event extends Base_Object {
);
/**
* Event is an implementation of one of the
* Activity Streams
* Event is an implementation of one of the Activity Streams.
*
* @var string
*/
@ -115,11 +117,13 @@ class Event extends Base_Object {
/**
* The Title of the event.
*
* @var string
*/
protected $name;
/**
* The events contacts
* The events contacts.
*
* @context {
* '@id' => 'mz:contacts',
@ -142,12 +146,16 @@ class Event extends Base_Object {
protected $comments_enabled;
/**
* Timezone of the event.
*
* @context https://joinmobilizon.org/ns#timezone
* @var string
*/
protected $timezone;
/**
* Moderation option for replies.
*
* @context https://joinmobilizon.org/ns#repliesModerationOption
* @see https://docs.joinmobilizon.org/contribute/activity_pub/#repliesmoderation
* @var string
@ -155,6 +163,8 @@ class Event extends Base_Object {
protected $replies_moderation_option;
/**
* Whether anonymous participation is enabled.
*
* @context https://joinmobilizon.org/ns#anonymousParticipationEnabled
* @see https://docs.joinmobilizon.org/contribute/activity_pub/#anonymousparticipationenabled
* @var bool
@ -162,26 +172,34 @@ class Event extends Base_Object {
protected $anonymous_participation_enabled;
/**
* The event's category.
*
* @context https://schema.org/category
* @var enum
* @var string
*/
protected $category;
/**
* Language of the event.
*
* @context https://schema.org/inLanguage
* @var
* @var string
*/
protected $in_language;
/**
* Whether the event is online.
*
* @context https://joinmobilizon.org/ns#isOnline
* @var bool
*/
protected $is_online;
/**
* The event's status.
*
* @context https://www.w3.org/2002/12/cal/ical#status
* @var enum
* @var string
*/
protected $status;
@ -196,25 +214,33 @@ class Event extends Base_Object {
protected $actor;
/**
* The external participation URL.
*
* @context https://joinmobilizon.org/ns#externalParticipationUrl
* @var string
*/
protected $external_participation_url;
/**
* Indicator of how new members may be able to join.
*
* @context https://joinmobilizon.org/ns#joinMode
* @see https://docs.joinmobilizon.org/contribute/activity_pub/#joinmode
* @var
* @var string
*/
protected $join_mode;
/**
* The participant count of the event.
*
* @context https://joinmobilizon.org/ns#participantCount
* @var int
*/
protected $participant_count;
/**
* How many places there can be for an event.
*
* @context https://schema.org/maximumAttendeeCapacity
* @see https://docs.joinmobilizon.org/contribute/activity_pub/#maximumattendeecapacity
* @var int
@ -222,6 +248,8 @@ class Event extends Base_Object {
protected $maximum_attendee_capacity;
/**
* The number of attendee places for an event that remain unallocated.
*
* @context https://schema.org/remainingAttendeeCapacity
* @see https://docs.joinmobilizon.org/contribute/activity_pub/#remainignattendeecapacity
* @var int
@ -234,6 +262,7 @@ class Event extends Base_Object {
* The passed timezone is only set when it is a valid one, otherwise the site's timezone is used.
*
* @param string $timezone The timezone string to be set, e.g. 'Europe/Berlin'.
* @return Event
*/
public function set_timezone( $timezone ) {
if ( in_array( $timezone, timezone_identifiers_list(), true ) ) {
@ -246,14 +275,16 @@ class Event extends Base_Object {
}
/**
* Custom setter for repliesModerationOption which also directy sets commentsEnabled accordingly.
* Custom setter for repliesModerationOption which also directly sets commentsEnabled accordingly.
*
* @param string $type
* @param string $type The type of the replies moderation option.
*
* @return Event
*/
public function set_replies_moderation_option( $type ) {
if ( in_array( $type, self::REPLIES_MODERATION_OPTION_TYPES, true ) ) {
$this->replies_moderation_option = $type;
$this->comments_enabled = ( 'allow_all' === $type ) ? true : false;
$this->comments_enabled = ( 'allow_all' === $type ) ? true : false;
} else {
_doing_it_wrong(
__METHOD__,
@ -268,11 +299,13 @@ class Event extends Base_Object {
/**
* Custom setter for commentsEnabled which also directly sets repliesModerationOption accordingly.
*
* @param bool $comments_enabled
* @param bool $comments_enabled Whether comments are enabled.
*
* @return Event
*/
public function set_comments_enabled( $comments_enabled ) {
if ( is_bool( $comments_enabled ) ) {
$this->comments_enabled = $comments_enabled;
$this->comments_enabled = $comments_enabled;
$this->replies_moderation_option = $comments_enabled ? 'allow_all' : 'closed';
} else {
_doing_it_wrong(
@ -288,7 +321,9 @@ class Event extends Base_Object {
/**
* Custom setter for the ical status that checks whether the status is an ical event status.
*
* @param string $status
* @param string $status The status of the event.
*
* @return Event
*/
public function set_status( $status ) {
if ( in_array( $status, self::ICAL_EVENT_STATUS_TYPES, true ) ) {
@ -307,13 +342,15 @@ class Event extends Base_Object {
/**
* Custom setter for the event category.
*
* Falls back to Mobilizons default category.
* Falls back to Mobilizon's default category.
*
* @param string $category
* @param bool $mobilizon_compatibilty Whether the category must be compatibly with Mobilizon.
* @param string $category The category of the event.
* @param bool $mobilizon_compatibility Optional. Whether the category must be compatibly with Mobilizon. Default true.
*
* @return Event
*/
public function set_category( $category, $mobilizon_compatibilty = true ) {
if ( $mobilizon_compatibilty ) {
public function set_category( $category, $mobilizon_compatibility = true ) {
if ( $mobilizon_compatibility ) {
$this->category = in_array( $category, self::DEFAULT_EVENT_CATEGORIES, true ) ? $category : 'MEETING';
} else {
$this->category = $category;
@ -327,12 +364,14 @@ class Event extends Base_Object {
*
* Automatically sets the joinMode to true if called.
*
* @param string $url
* @param string $url The URL for external participation.
*
* @return Event
*/
public function set_external_participation_url( $url ) {
if ( preg_match( '/^https?:\/\/.*/i', $url ) ) {
$this->external_participation_url = $url;
$this->join_mode = 'external';
$this->join_mode = 'external';
}
return $this;

View File

@ -38,8 +38,8 @@ class Place extends Base_Object {
protected $accuracy;
/**
* Indicates the altitude of a place. The measurement units is indicated using the units property.
* If units is not specified, the default is assumed to be "m" indicating meters.
* Indicates the altitude of a place. The measurement unit is indicated using the unit's property.
* If unit is not specified, the default is assumed to be "m" indicating meters.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-altitude
* @var float xsd:float
@ -63,22 +63,34 @@ class Place extends Base_Object {
protected $longitude;
/**
* The radius from the given latitude and longitude for a Place.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-radius
* @var float
*/
protected $radius;
/**
* Specifies the measurement units for the `radius` and `altitude` properties.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-units
* @var string
*/
protected $units;
/**
* @var Postal_Address|string
* The address of the place.
*
* @see https://schema.org/PostalAddress
* @var array|string
*/
protected $address;
/**
* Set the address of the place.
*
* @param array|string $address The address of the place.
*/
public function set_address( $address ) {
if ( is_string( $address ) || is_array( $address ) ) {
$this->address = $address;

View File

@ -1,238 +0,0 @@
<?php
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;
use function Activitypub\safe_remote_post;
use function Activitypub\set_wp_object_state;
/**
* ActivityPub Activity_Dispatcher Class
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/
*/
class Activity_Dispatcher {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_send_post', array( self::class, 'send_post' ), 10, 2 );
\add_action( 'activitypub_send_comment', array( self::class, 'send_comment' ), 10, 2 );
\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 mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_activity_or_announce( $wp_object, $type ) {
if ( is_user_type_disabled( 'blog' ) ) {
return;
}
if ( is_single_user() ) {
self::send_activity( $wp_object, $type, Users::BLOG_USER_ID );
} else {
self::send_announce( $wp_object, $type );
}
}
/**
* Send Activities to followers and mentioned users.
*
* @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_activity( $wp_object, $type, $user_id = null ) {
$transformer = Factory::get_transformer( $wp_object ); // Could potentially return a `\WP_Error` instance.
if ( \is_wp_error( $transformer ) ) {
return;
}
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;
}
$activity = $transformer->to_activity( $type );
self::send_activity_to_followers( $activity, $user_id, $wp_object );
}
/**
* Send Announces to followers and mentioned users.
*
* @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_announce( $wp_object, $type ) {
if ( ! in_array( $type, array( 'Create', 'Update', 'Delete' ), true ) ) {
return;
}
if ( is_user_disabled( Users::BLOG_USER_ID ) ) {
return;
}
$transformer = Factory::get_transformer( $wp_object );
if ( \is_wp_error( $transformer ) ) {
return;
}
$user_id = Users::BLOG_USER_ID;
$activity = $transformer->to_activity( $type );
$user = Users::get_by_id( Users::BLOG_USER_ID );
$announce = new Activity();
$announce->set_type( 'Announce' );
$announce->set_object( $activity );
$announce->set_actor( $user->get_id() );
self::send_activity_to_followers( $announce, $user_id, $wp_object );
}
/**
* 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_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' );
// send the update
self::send_activity_to_followers( $activity, $user_id, $user );
}
/**
* Send an Activity to all followers and mentioned users.
*
* @param Activity $activity The ActivityPub Activity.
* @param int $user_id The user ID.
* @param WP_User|WP_Post|WP_Comment $wp_object The WordPress object.
*
* @return void
*/
private static function send_activity_to_followers( $activity, $user_id, $wp_object ) {
// check if the Activity should be send to the followers
if ( ! apply_filters( 'activitypub_send_activity_to_followers', true, $activity, $user_id, $wp_object ) ) {
return;
}
$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, $user_id );
}
set_wp_object_state( $wp_object, 'federated' );
}
/**
* Send a "Create" or "Update" Activity for a WordPress Post.
*
* @param int $id The WordPress Post ID.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_post( $id, $type ) {
$post = get_post( $id );
if ( ! $post ) {
return;
}
do_action( 'activitypub_send_activity', $post, $type );
do_action(
sprintf(
'activitypub_send_%s_activity',
\strtolower( $type )
),
$post
);
}
/**
* Send a "Create" or "Update" Activity for a WordPress Comment.
*
* @param int $id The WordPress Comment ID.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_comment( $id, $type ) {
$comment = get_comment( $id );
if ( ! $comment ) {
return;
}
do_action( 'activitypub_send_activity', $comment, $type );
do_action(
sprintf(
'activitypub_send_%s_activity',
\strtolower( $type )
),
$comment
);
}
}

View File

@ -1,20 +1,20 @@
<?php
/**
* ActivityPub Class.
*
* @package Activitypub
*/
namespace Activitypub;
use Exception;
use Activitypub\Signature;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
use Activitypub\Collection\Followers;
use function Activitypub\is_comment;
use function Activitypub\sanitize_url;
use function Activitypub\is_local_comment;
use function Activitypub\is_user_type_disabled;
use function Activitypub\is_activitypub_request;
use function Activitypub\should_comment_be_federated;
use Activitypub\Collection\Extra_Fields;
/**
* ActivityPub Class
* ActivityPub Class.
*
* @author Matthias Pfefferle
*/
@ -23,13 +23,15 @@ class Activitypub {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'template_include', array( self::class, 'render_json_template' ), 99 );
\add_filter( 'template_include', array( self::class, 'render_activitypub_template' ), 99 );
\add_action( 'template_redirect', array( self::class, 'template_redirect' ) );
\add_filter( 'redirect_canonical', array( self::class, 'redirect_canonical' ), 10, 2 );
\add_filter( 'redirect_canonical', array( self::class, 'no_trailing_redirect' ), 10, 2 );
\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 support for ActivityPub to custom post types
$post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) ) ? \get_option( 'activitypub_support_post_types', array( 'post' ) ) : array();
// Add support for ActivityPub to custom post types.
$post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) );
foreach ( $post_types as $post_type ) {
\add_post_type_support( $post_type, 'activitypub' );
@ -43,41 +45,84 @@ class Activitypub {
\add_action( 'user_register', array( self::class, 'user_register' ) );
\add_action( 'in_plugin_update_message-' . ACTIVITYPUB_PLUGIN_BASENAME, array( self::class, 'plugin_update_message' ) );
\add_filter( 'activitypub_get_actor_extra_fields', array( Extra_Fields::class, 'default_actor_extra_fields' ), 10, 2 );
\add_filter( 'activitypub_get_actor_extra_fields', array( self::class, 'default_actor_extra_fields' ), 10, 2 );
\add_action( 'updated_postmeta', array( self::class, 'updated_postmeta' ), 10, 4 );
\add_action( 'added_post_meta', array( self::class, 'updated_postmeta' ), 10, 4 );
// register several post_types
\add_action( 'init', array( self::class, 'register_user_meta' ), 11 );
// Register several post_types.
self::register_post_types();
self::register_oembed_providers();
Embed::init();
}
/**
* Activation Hook
*
* @return void
* Activation Hook.
*/
public static function activate() {
self::flush_rewrite_rules();
Scheduler::register_schedules();
\add_filter( 'pre_wp_update_comment_count_now', array( Comment::class, 'pre_wp_update_comment_count_now' ), 10, 3 );
Migration::update_comment_counts();
}
/**
* Deactivation Hook
*
* @return void
* Deactivation Hook.
*/
public static function deactivate() {
self::flush_rewrite_rules();
Scheduler::deregister_schedules();
\remove_filter( 'pre_wp_update_comment_count_now', array( Comment::class, 'pre_wp_update_comment_count_now' ) );
Migration::update_comment_counts( 2000 );
}
/**
* Uninstall Hook
*
* @return void
* Uninstall Hook.
*/
public static function uninstall() {
Scheduler::deregister_schedules();
\remove_filter( 'pre_wp_update_comment_count_now', array( Comment::class, 'pre_wp_update_comment_count_now' ) );
Migration::update_comment_counts( 2000 );
\delete_option( 'activitypub_actor_mode' );
\delete_option( 'activitypub_allow_likes' );
\delete_option( 'activitypub_allow_replies' );
\delete_option( 'activitypub_attribution_domains' );
\delete_option( 'activitypub_authorized_fetch' );
\delete_option( 'activitypub_application_user_private_key' );
\delete_option( 'activitypub_application_user_public_key' );
\delete_option( 'activitypub_blog_user_also_known_as' );
\delete_option( 'activitypub_blog_user_mailer_new_dm' );
\delete_option( 'activitypub_blog_user_mailer_new_follower' );
\delete_option( 'activitypub_blog_user_mailer_new_mention' );
\delete_option( 'activitypub_blog_user_moved_to' );
\delete_option( 'activitypub_blog_user_private_key' );
\delete_option( 'activitypub_blog_user_public_key' );
\delete_option( 'activitypub_blog_description' );
\delete_option( 'activitypub_blog_identifier' );
\delete_option( 'activitypub_custom_post_content' );
\delete_option( 'activitypub_db_version' );
\delete_option( 'activitypub_default_extra_fields' );
\delete_option( 'activitypub_enable_blog_user' );
\delete_option( 'activitypub_enable_users' );
\delete_option( 'activitypub_header_image' );
\delete_option( 'activitypub_last_post_with_permalink_as_id' );
\delete_option( 'activitypub_max_image_attachments' );
\delete_option( 'activitypub_migration_lock' );
\delete_option( 'activitypub_object_type' );
\delete_option( 'activitypub_outbox_purge_days' );
\delete_option( 'activitypub_shared_inbox' );
\delete_option( 'activitypub_support_post_types' );
\delete_option( 'activitypub_use_hashtags' );
\delete_option( 'activitypub_use_opengraph' );
\delete_option( 'activitypub_use_permalink_as_id_for_blog' );
\delete_option( 'activitypub_vary_header' );
}
/**
@ -87,25 +132,33 @@ class Activitypub {
*
* @return string The new path to the JSON template.
*/
public static function render_json_template( $template ) {
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
public static function render_activitypub_template( $template ) {
if ( \wp_is_serving_rest_request() || \wp_doing_ajax() ) {
return $template;
}
self::add_headers();
if ( ! is_activitypub_request() ) {
return $template;
}
$json_template = false;
$activitypub_template = false;
$activitypub_object = Query::get_instance()->get_activitypub_object();
if ( \is_author() && ! is_user_disabled( \get_the_author_meta( 'ID' ) ) ) {
$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() && ! is_user_type_disabled( 'blog' ) ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/blog-json.php';
if ( $activitypub_object ) {
if ( \get_query_var( 'preview' ) ) {
\define( 'ACTIVITYPUB_PREVIEW', true );
/**
* Filter the template used for the ActivityPub preview.
*
* @param string $activitypub_template Absolute path to the template file.
*/
$activitypub_template = apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
} else {
$activitypub_template = ACTIVITYPUB_PLUGIN_DIR . 'templates/activitypub-json.php';
}
}
/*
@ -114,59 +167,175 @@ class Activitypub {
* @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
* @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
*/
if ( $json_template && ACTIVITYPUB_AUTHORIZED_FETCH ) {
if ( $activitypub_template && use_authorized_fetch() ) {
$verification = Signature::verify_http_signature( $_SERVER );
if ( \is_wp_error( $verification ) ) {
header( 'HTTP/1.1 401 Unauthorized' );
// fallback as template_loader can't return http headers
// Fallback as template_loader can't return http headers.
return $template;
}
}
if ( $json_template ) {
return $json_template;
if ( $activitypub_template ) {
\set_query_var( 'is_404', false );
// Check if header already sent.
if ( ! \headers_sent() ) {
// Send 200 status header.
\status_header( 200 );
}
return $activitypub_template;
}
return $template;
}
/**
* Add the 'self' link to the header.
*/
public static function add_headers() {
$id = Query::get_instance()->get_activitypub_object_id();
if ( ! $id ) {
return;
}
if ( ! headers_sent() ) {
\header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
if ( \get_option( 'activitypub_vary_header' ) ) {
// Send Vary header for Accept header.
\header( 'Vary: Accept', false );
}
}
add_action(
'wp_head',
function () use ( $id ) {
echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . esc_url( $id ) . '" />' . PHP_EOL;
}
);
}
/**
* Remove trailing slash from ActivityPub @username requests.
*
* @param string $redirect_url The URL to redirect to.
* @param string $requested_url The requested URL.
*
* @return string $redirect_url The possibly-unslashed redirect URL.
*/
public static function no_trailing_redirect( $redirect_url, $requested_url ) {
if ( get_query_var( 'actor' ) ) {
return $requested_url;
}
return $redirect_url;
}
/**
* Add support for `p` and `author` query vars.
*
* @param string $redirect_url The URL to redirect to.
* @param string $requested_url The requested URL.
*
* @return string $redirect_url
*/
public static function redirect_canonical( $redirect_url, $requested_url ) {
if ( ! is_activitypub_request() ) {
return $redirect_url;
}
$query = \wp_parse_url( $requested_url, PHP_URL_QUERY );
if ( ! $query ) {
return $redirect_url;
}
$query_params = \wp_parse_args( $query );
unset( $query_params['activitypub'] );
if ( 1 !== count( $query_params ) ) {
return $redirect_url;
}
if ( isset( $query_params['p'] ) ) {
return null;
}
if ( isset( $query_params['author'] ) ) {
return null;
}
return $requested_url;
}
/**
* Custom redirects for ActivityPub requests.
*
* @return void
*/
public static function template_redirect() {
global $wp_query;
$comment_id = get_query_var( 'c', null );
// check if it seems to be a comment
if ( ! $comment_id ) {
return;
// Check if it seems to be a comment.
if ( $comment_id ) {
$comment = get_comment( $comment_id );
// Load a 404-page if `c` is set but not valid.
if ( ! $comment ) {
$wp_query->set_404();
return;
}
// Stop if it's not an ActivityPub comment.
if ( is_activitypub_request() && ! is_local_comment( $comment ) ) {
return;
}
wp_safe_redirect( get_comment_link( $comment ) );
exit;
}
$comment = get_comment( $comment_id );
$actor = get_query_var( 'actor', null );
if ( $actor ) {
$actor = Actors::get_by_username( $actor );
if ( ! $actor || \is_wp_error( $actor ) ) {
$wp_query->set_404();
return;
}
// load a 404 page if `c` is set but not valid
if ( ! $comment ) {
global $wp_query;
$wp_query->set_404();
return;
if ( is_activitypub_request() ) {
return;
}
if ( $actor->get__id() > 0 ) {
$redirect_url = $actor->get_url();
} else {
$redirect_url = get_bloginfo( 'url' );
}
wp_safe_redirect( $redirect_url, 301 );
exit;
}
// stop if it's not an ActivityPub comment
if ( is_activitypub_request() && ! is_local_comment( $comment ) ) {
return;
}
wp_safe_redirect( get_comment_link( $comment ) );
exit;
}
/**
* Add the 'activitypub' query variable so WordPress won't mangle it.
*
* @param array $vars The query variables.
*
* @return array The query variables.
*/
public static function add_query_vars( $vars ) {
$vars[] = 'activitypub';
$vars[] = 'preview';
$vars[] = 'author';
$vars[] = 'actor';
$vars[] = 'c';
$vars[] = 'p';
@ -205,7 +374,7 @@ class Activitypub {
}
// Check if comment has an avatar.
$avatar = self::get_avatar_url( $id_or_email->comment_ID );
$avatar = \get_comment_meta( $id_or_email->comment_ID, 'avatar_url', true );
if ( $avatar ) {
if ( empty( $args['class'] ) ) {
@ -223,53 +392,37 @@ class Activitypub {
return $args;
}
/**
* Function to retrieve Avatar URL if stored in meta.
*
* @param int|WP_Comment $comment
*
* @return string $url
*/
public static function get_avatar_url( $comment ) {
if ( \is_numeric( $comment ) ) {
$comment = \get_comment( $comment );
}
return \get_comment_meta( $comment->comment_ID, 'avatar_url', true );
}
/**
* Store permalink in meta, to send delete Activity.
*
* @param string $post_id The Post ID.
*
* @return void
*/
public static function trash_post( $post_id ) {
\add_post_meta(
$post_id,
'activitypub_canonical_url',
'_activitypub_canonical_url',
\get_permalink( $post_id ),
true
);
}
/**
* Delete permalink from meta
* Delete permalink from meta.
*
* @param string $post_id The Post ID
*
* @return void
* @param string $post_id The Post ID.
*/
public static function untrash_post( $post_id ) {
\delete_post_meta( $post_id, 'activitypub_canonical_url' );
\delete_post_meta( $post_id, '_activitypub_canonical_url' );
}
/**
* Add rewrite rules
* Add rewrite rules.
*/
public static function add_rewrite_rules() {
// If another system needs to take precedence over the ActivityPub rewrite rules,
// they can define their own and will manually call the appropriate functions as required.
/*
* If another system needs to take precedence over the ActivityPub rewrite rules,
* they can define their own and will manually call the appropriate functions as required.
*/
if ( ACTIVITYPUB_DISABLE_REWRITES ) {
return;
}
@ -285,27 +438,17 @@ class Activitypub {
if ( ! \class_exists( 'Nodeinfo_Endpoint' ) && true === (bool) \get_option( 'blog_public', 1 ) ) {
\add_rewrite_rule(
'^.well-known/nodeinfo',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/nodeinfo/discovery',
'top'
);
\add_rewrite_rule(
'^.well-known/x-nodeinfo2',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/nodeinfo2',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/nodeinfo',
'top'
);
}
\add_rewrite_rule(
'^@([\w\-\.]+)',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/$matches[1]',
'top'
);
\add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' );
\add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES );
}
/**
* Flush rewrite rules;
* Flush rewrite rules.
*/
public static function flush_rewrite_rules() {
self::add_rewrite_rules();
@ -313,37 +456,10 @@ class Activitypub {
}
/**
* Theme compatibility stuff
*
* @return void
* Theme compatibility stuff.
*/
public static function theme_compat() {
$site_icon = get_theme_support( 'custom-logo' );
if ( ! $site_icon ) {
// custom logo support
add_theme_support(
'custom-logo',
array(
'height' => 80,
'width' => 80,
)
);
}
$custom_header = get_theme_support( 'custom-header' );
if ( ! $custom_header ) {
// This theme supports a custom header
$custom_header_args = array(
'width' => 1250,
'height' => 600,
'header-text' => true,
);
add_theme_support( 'custom-header', $custom_header_args );
}
// We assume that you want to use Post-Formats when enabling the setting
// 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...
@ -362,35 +478,7 @@ class Activitypub {
}
/**
* Display plugin upgrade notice to users
*
* @param array $data The plugin data
*
* @return void
*/
public static function plugin_update_message( $data ) {
if ( ! isset( $data['upgrade_notice'] ) ) {
return;
}
printf(
'<div class="update-message">%s</div>',
wp_kses(
wpautop( $data['upgrade_notice '] ),
array(
'p' => array(),
'a' => array( 'href', 'title' ),
'strong' => array(),
'em' => array(),
)
)
);
}
/**
* Register the "Followers" Taxonomy
*
* @return void
* Register Custom Post Types.
*/
private static function register_post_types() {
\register_post_type(
@ -412,7 +500,7 @@ class Activitypub {
\register_post_meta(
Followers::POST_TYPE,
'activitypub_inbox',
'_activitypub_inbox',
array(
'type' => 'string',
'single' => true,
@ -422,7 +510,7 @@ class Activitypub {
\register_post_meta(
Followers::POST_TYPE,
'activitypub_errors',
'_activitypub_errors',
array(
'type' => 'string',
'single' => false,
@ -438,7 +526,7 @@ class Activitypub {
\register_post_meta(
Followers::POST_TYPE,
'activitypub_user_id',
'_activitypub_user_id',
array(
'type' => 'string',
'single' => false,
@ -450,7 +538,7 @@ class Activitypub {
\register_post_meta(
Followers::POST_TYPE,
'activitypub_actor_json',
'_activitypub_actor_json',
array(
'type' => 'string',
'single' => true,
@ -460,44 +548,168 @@ class Activitypub {
)
);
\register_post_type(
'ap_extrafield',
// Register Outbox Post-Type.
register_post_type(
Outbox::POST_TYPE,
array(
'labels' => array(
'name' => _x( 'Extra fields', 'post_type plural name', 'activitypub' ),
'singular_name' => _x( 'Extra field', 'post_type single name', 'activitypub' ),
'add_new' => __( 'Add new', 'activitypub' ),
'add_new_item' => __( 'Add new extra field', 'activitypub' ),
'new_item' => __( 'New extra field', 'activitypub' ),
'edit_item' => __( 'Edit extra field', 'activitypub' ),
'view_item' => __( 'View extra field', 'activitypub' ),
'all_items' => __( 'All extra fields', 'activitypub' ),
'labels' => array(
'name' => _x( 'Outbox', 'post_type plural name', 'activitypub' ),
'singular_name' => _x( 'Outbox Item', 'post_type single name', 'activitypub' ),
),
'capabilities' => array(
'create_posts' => false,
),
'map_meta_cap' => true,
'public' => false,
'hierarchical' => false,
'show_in_rest' => true,
'rewrite' => false,
'query_var' => false,
'has_archive' => false,
'publicly_queryable' => false,
'show_in_menu' => false,
'supports' => array( 'title', 'editor', 'author', 'custom-fields' ),
'delete_with_user' => true,
'can_export' => true,
'exclude_from_search' => true,
'show_in_rest' => true,
'map_meta_cap' => true,
'show_ui' => true,
'supports' => array( 'title', 'editor' ),
)
);
/**
* Register Activity Type meta for Outbox items.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
*/
\register_post_meta(
Outbox::POST_TYPE,
'_activitypub_activity_type',
array(
'type' => 'string',
'description' => 'The type of the activity',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => function ( $value ) {
$value = ucfirst( strtolower( $value ) );
$schema = array(
'type' => 'string',
'enum' => array( 'Accept', 'Add', 'Announce', 'Arrive', 'Block', 'Create', 'Delete', 'Dislike', 'Flag', 'Follow', 'Ignore', 'Invite', 'Join', 'Leave', 'Like', 'Listen', 'Move', 'Offer', 'Question', 'Reject', 'Read', 'Remove', 'TentativeReject', 'TentativeAccept', 'Travel', 'Undo', 'Update', 'View' ),
'default' => 'Announce',
);
if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) {
return $schema['default'];
}
return $value;
},
)
);
\register_post_meta(
Outbox::POST_TYPE,
'_activitypub_activity_actor',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => function ( $value ) {
$schema = array(
'type' => 'string',
'enum' => array( 'application', 'blog', 'user' ),
'default' => 'user',
);
if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) {
return $schema['default'];
}
return $value;
},
)
);
\register_post_meta(
Outbox::POST_TYPE,
'_activitypub_outbox_offset',
array(
'type' => 'integer',
'single' => true,
'description' => 'Keeps track of the followers offset when processing outbox items.',
'sanitize_callback' => 'absint',
'default' => 0,
)
);
\register_post_meta(
Outbox::POST_TYPE,
'_activitypub_object_id',
array(
'type' => 'string',
'single' => true,
'description' => 'The ID (ActivityPub URI) of the object that the outbox item is about.',
'sanitize_callback' => 'sanitize_url',
)
);
\register_post_meta(
Outbox::POST_TYPE,
'activitypub_content_visibility',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => function ( $value ) {
$schema = array(
'type' => 'string',
'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ),
'default' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
);
if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) {
return $schema['default'];
}
return $value;
},
)
);
// Both User and Blog Extra Fields types have the same args.
$args = array(
'labels' => array(
'name' => _x( 'Extra fields', 'post_type plural name', 'activitypub' ),
'singular_name' => _x( 'Extra field', 'post_type single name', 'activitypub' ),
'add_new' => __( 'Add new', 'activitypub' ),
'add_new_item' => __( 'Add new extra field', 'activitypub' ),
'new_item' => __( 'New extra field', 'activitypub' ),
'edit_item' => __( 'Edit extra field', 'activitypub' ),
'view_item' => __( 'View extra field', 'activitypub' ),
'all_items' => __( 'All extra fields', 'activitypub' ),
),
'public' => false,
'hierarchical' => false,
'query_var' => false,
'has_archive' => false,
'publicly_queryable' => false,
'show_in_menu' => false,
'delete_with_user' => true,
'can_export' => true,
'exclude_from_search' => true,
'show_in_rest' => true,
'map_meta_cap' => true,
'show_ui' => true,
'supports' => array( 'title', 'editor', 'page-attributes' ),
);
\register_post_type( Extra_Fields::USER_POST_TYPE, $args );
\register_post_type( Extra_Fields::BLOG_POST_TYPE, $args );
/**
* Fires after ActivityPub custom post types have been registered.
*/
\do_action( 'activitypub_after_register_post_type' );
}
/**
* Add the 'activitypub' capability to users who can publish posts.
*
* @param int $user_id User ID.
*
* @param array $userdata The raw array of data passed to wp_insert_user().
* @param int $user_id User ID.
*/
public static function user_register( $user_id ) {
if ( \user_can( $user_id, 'publish_posts' ) ) {
@ -507,55 +719,180 @@ class Activitypub {
}
/**
* Add default extra fields to an actor.
* Delete `activitypub_content_visibility` when updated to an empty value.
*
* @param array $extra_fields The extra fields.
* @param int $user_id The User-ID.
*
* @return array The extra fields.
* @param int $meta_id ID of updated metadata entry.
* @param int $object_id Post ID.
* @param string $meta_key Metadata key.
* @param mixed $meta_value Metadata value. This will be a PHP-serialized string representation of the value
* if the value is an array, an object, or itself a PHP-serialized string.
*/
public static function default_actor_extra_fields( $extra_fields, $user_id ) {
if ( $extra_fields || ! $user_id ) {
return $extra_fields;
public static function updated_postmeta( $meta_id, $object_id, $meta_key, $meta_value ) {
if ( 'activitypub_content_visibility' === $meta_key && empty( $meta_value ) ) {
\delete_post_meta( $object_id, 'activitypub_content_visibility' );
}
}
$already_migrated = \get_user_meta( $user_id, 'activitypub_default_extra_fields', true );
/**
* Register some Mastodon oEmbed providers.
*/
public static function register_oembed_providers() {
\wp_oembed_add_provider( '#https?://mastodon\.social/(@.+)/([0-9]+)#i', 'https://mastodon.social/api/oembed', true );
\wp_oembed_add_provider( '#https?://mastodon\.online/(@.+)/([0-9]+)#i', 'https://mastodon.online/api/oembed', true );
\wp_oembed_add_provider( '#https?://mastodon\.cloud/(@.+)/([0-9]+)#i', 'https://mastodon.cloud/api/oembed', true );
\wp_oembed_add_provider( '#https?://mstdn\.social/(@.+)/([0-9]+)#i', 'https://mstdn.social/api/oembed', true );
\wp_oembed_add_provider( '#https?://mastodon\.world/(@.+)/([0-9]+)#i', 'https://mastodon.world/api/oembed', true );
\wp_oembed_add_provider( '#https?://mas\.to/(@.+)/([0-9]+)#i', 'https://mas.to/api/oembed', true );
}
if ( $already_migrated ) {
return $extra_fields;
}
/**
* Register user meta.
*/
public static function register_user_meta() {
$blog_prefix = $GLOBALS['wpdb']->get_blog_prefix();
$defaults = array(
\__( 'Blog', 'activitypub' ) => \home_url( '/' ),
\__( 'Profile', 'activitypub' ) => \get_author_posts_url( $user_id ),
\__( 'Homepage', 'activitypub' ) => \get_the_author_meta( 'user_url', $user_id ),
\register_meta(
'user',
$blog_prefix . 'activitypub_also_known_as',
array(
'type' => 'array',
'description' => 'An array of URLs that the user is known by.',
'single' => true,
'default' => array(),
'sanitize_callback' => array( Sanitize::class, 'url_list' ),
)
);
foreach ( $defaults as $title => $url ) {
if ( ! $url ) {
continue;
}
\register_meta(
'user',
$blog_prefix . 'activitypub_old_host_data',
array(
'description' => 'Actor object for the user on the old host.',
'single' => true,
)
);
$extra_field = array(
'post_type' => 'ap_extrafield',
'post_title' => $title,
'post_status' => 'publish',
'post_author' => $user_id,
'post_content' => sprintf(
'<!-- wp:paragraph --><p><a rel="me" title="%s" target="_blank" href="%s">%s</a></p><!-- /wp:paragraph -->',
\esc_attr( $url ),
$url,
\wp_parse_url( $url, \PHP_URL_HOST )
),
'comment_status' => 'closed',
);
\register_meta(
'user',
$blog_prefix . 'activitypub_moved_to',
array(
'type' => 'string',
'description' => 'The new URL of the user.',
'single' => true,
'sanitize_callback' => 'sanitize_url',
)
);
$extra_field_id = wp_insert_post( $extra_field );
$extra_fields[] = get_post( $extra_field_id );
\register_meta(
'user',
$blog_prefix . 'activitypub_description',
array(
'type' => 'string',
'description' => 'The users description.',
'single' => true,
'default' => '',
'sanitize_callback' => function ( $value ) {
return wp_kses( $value, 'user_description' );
},
)
);
\register_meta(
'user',
$blog_prefix . 'activitypub_icon',
array(
'type' => 'integer',
'description' => 'The attachment ID for users profile image.',
'single' => true,
'default' => 0,
'sanitize_callback' => 'absint',
)
);
\register_meta(
'user',
$blog_prefix . 'activitypub_header_image',
array(
'type' => 'integer',
'description' => 'The attachment ID for the users header image.',
'single' => true,
'default' => 0,
'sanitize_callback' => 'absint',
)
);
\register_meta(
'user',
$blog_prefix . 'activitypub_mailer_new_dm',
array(
'type' => 'integer',
'description' => 'Send a notification when someone sends this user a direct message.',
'single' => true,
'sanitize_callback' => 'absint',
)
);
\add_filter( 'get_user_option_activitypub_mailer_new_dm', array( self::class, 'user_options_default' ) );
\register_meta(
'user',
$blog_prefix . 'activitypub_mailer_new_follower',
array(
'type' => 'integer',
'description' => 'Send a notification when someone starts to follow this user.',
'single' => true,
'sanitize_callback' => 'absint',
)
);
\add_filter( 'get_user_option_activitypub_mailer_new_follower', array( self::class, 'user_options_default' ) );
\register_meta(
'user',
$blog_prefix . 'activitypub_mailer_new_mention',
array(
'type' => 'integer',
'description' => 'Send a notification when someone mentions this user.',
'single' => true,
'sanitize_callback' => 'absint',
)
);
\add_filter( 'get_user_option_activitypub_mailer_new_mention', array( self::class, 'user_options_default' ) );
\register_meta(
'user',
'activitypub_show_welcome_tab',
array(
'type' => 'integer',
'description' => 'Whether to show the welcome tab.',
'single' => true,
'default' => 1,
'sanitize_callback' => 'absint',
)
);
\register_meta(
'user',
'activitypub_show_advanced_tab',
array(
'type' => 'integer',
'description' => 'Whether to show the advanced tab.',
'single' => true,
'default' => 0,
'sanitize_callback' => 'absint',
)
);
}
/**
* Set default values for user options.
*
* @param bool|string $value Option value.
* @return bool|string
*/
public static function user_options_default( $value ) {
if ( false === $value ) {
return '1';
}
\update_user_meta( $user_id, 'activitypub_default_extra_fields', true );
return $extra_fields;
return $value;
}
}

View File

@ -1,649 +0,0 @@
<?php
namespace Activitypub;
use WP_User_Query;
use Activitypub\Model\Blog;
use Activitypub\Activitypub;
use Activitypub\Collection\Users;
use function Activitypub\count_followers;
use function Activitypub\is_user_disabled;
use function Activitypub\was_comment_received;
use function Activitypub\is_comment_federatable;
use function Activitypub\add_default_actor_extra_fields;
/**
* ActivityPub Admin Class
*
* @author Matthias Pfefferle
*/
class Admin {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'admin_menu', array( self::class, 'admin_menu' ) );
\add_action( 'admin_init', array( self::class, 'register_settings' ) );
\add_action( 'load-comment.php', array( self::class, 'edit_comment' ) );
\add_action( 'load-post.php', array( self::class, 'edit_post' ) );
\add_action( 'load-edit.php', array( self::class, 'list_posts' ) );
\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_action( 'manage_comments_custom_column', array( static::class, 'manage_comments_custom_column' ), 9, 2 );
\add_filter( 'manage_posts_columns', array( static::class, 'manage_post_columns' ), 10, 2 );
\add_action( 'manage_posts_custom_column', array( self::class, 'manage_posts_custom_column' ), 10, 2 );
\add_filter( 'manage_users_columns', array( self::class, 'manage_users_columns' ), 10, 1 );
\add_action( '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' ) );
}
\add_filter( 'dashboard_glance_items', array( self::class, 'dashboard_glance_items' ) );
}
/**
* Add admin menu entry
*/
public static function admin_menu() {
$settings_page = \add_options_page(
'Welcome',
'ActivityPub',
'manage_options',
'activitypub',
array( self::class, 'settings_page' )
);
\add_action( 'load-' . $settings_page, array( self::class, 'add_settings_help_tab' ) );
// user has to be able to publish posts
if ( ! is_user_disabled( get_current_user_id() ) ) {
$followers_list_page = \add_users_page( \__( 'Followers', 'activitypub' ), \__( 'Followers', 'activitypub' ), 'read', 'activitypub-followers-list', array( self::class, 'followers_list_page' ) );
\add_action( 'load-' . $followers_list_page, array( self::class, 'add_followers_list_help_tab' ) );
\add_users_page( \__( 'Extra Fields', 'activitypub' ), \__( 'Extra Fields', 'activitypub' ), 'read', esc_url( admin_url( '/edit.php?post_type=ap_extrafield' ) ) );
}
}
/**
* 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 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' );
}
$current_screen = get_current_screen();
if ( isset( $current_screen->id ) && 'edit-ap_extrafield' === $current_screen->id ) {
?>
<div class="notice" style="margin: 0; background: none; border: none; box-shadow: none; padding: 15px 0 0 0; font-size: 14px;">
<?php esc_html_e( 'These are extra fields that are used for your ActivityPub profile. You can use your homepage, social profiles, pronouns, age, anything you want.', 'activitypub' ); ?>
</div>
<?php
}
}
/**
* 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
*/
public static function settings_page() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['tab'] ) ) {
$tab = 'welcome';
} else {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$tab = sanitize_key( $_GET['tab'] );
}
switch ( $tab ) {
case 'settings':
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/settings.php' );
break;
case 'followers':
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/blog-user-followers-list.php' );
break;
case 'welcome':
default:
wp_enqueue_script( 'plugin-install' );
add_thickbox();
wp_enqueue_script( 'updates' );
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/welcome.php' );
break;
}
}
/**
* Load user settings page
*/
public static function followers_list_page() {
// user has to be able to publish posts
if ( ! is_user_disabled( get_current_user_id() ) ) {
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/user-followers-list.php' );
}
}
/**
* Register ActivityPub settings
*/
public static function register_settings() {
\register_setting(
'activitypub',
'activitypub_post_content_type',
array(
'type' => 'string',
'description' => \__( 'Use title and link, summary, full or custom content', 'activitypub' ),
'show_in_rest' => array(
'schema' => array(
'enum' => array(
'title',
'excerpt',
'content',
),
),
),
'default' => 'content',
)
);
\register_setting(
'activitypub',
'activitypub_custom_post_content',
array(
'type' => 'string',
'description' => \__( 'Define your own custom post template', 'activitypub' ),
'show_in_rest' => true,
'default' => ACTIVITYPUB_CUSTOM_POST_CONTENT,
)
);
\register_setting(
'activitypub',
'activitypub_max_image_attachments',
array(
'type' => 'integer',
'description' => \__( 'Number of images to attach to posts.', 'activitypub' ),
'default' => ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS,
)
);
\register_setting(
'activitypub',
'activitypub_object_type',
array(
'type' => 'string',
'description' => \__( 'The Activity-Object-Type', 'activitypub' ),
'show_in_rest' => array(
'schema' => array(
'enum' => array(
'note',
'wordpress-post-format',
),
),
),
'default' => 'note',
)
);
\register_setting(
'activitypub',
'activitypub_use_hashtags',
array(
'type' => 'boolean',
'description' => \__( 'Add hashtags in the content as native tags and replace the #tag with the tag-link', 'activitypub' ),
'default' => '0',
)
);
\register_setting(
'activitypub',
'activitypub_support_post_types',
array(
'type' => 'string',
'description' => \esc_html__( 'Enable ActivityPub support for post types', 'activitypub' ),
'show_in_rest' => true,
'default' => array( 'post' ),
)
);
\register_setting(
'activitypub',
'activitypub_blog_user_identifier',
array(
'type' => 'string',
'description' => \esc_html__( 'The Identifier of the Blog-User', 'activitypub' ),
'show_in_rest' => true,
'default' => Blog::get_default_username(),
'sanitize_callback' => function ( $value ) {
// hack to allow dots in the username
$parts = explode( '.', $value );
$sanitized = array();
foreach ( $parts as $part ) {
$sanitized[] = \sanitize_title( $part );
}
$sanitized = implode( '.', $sanitized );
// check for login or nicename.
$user = new WP_User_Query(
array(
'search' => $sanitized,
'search_columns' => array( 'user_login', 'user_nicename' ),
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
)
);
if ( $user->results ) {
add_settings_error(
'activitypub_blog_user_identifier',
'activitypub_blog_user_identifier',
\esc_html__( 'You cannot use an existing author\'s name for the blog profile ID.', 'activitypub' ),
'error'
);
return Blog::get_default_username();
}
return $sanitized;
},
)
);
\register_setting(
'activitypub',
'activitypub_enable_users',
array(
'type' => 'boolean',
'description' => \__( 'Every Author on this Blog (with the publish_posts capability) gets his own ActivityPub enabled Profile.', 'activitypub' ),
'default' => '1',
)
);
\register_setting(
'activitypub',
'activitypub_enable_blog_user',
array(
'type' => 'boolean',
'description' => \__( 'Your Blog becomes an ActivityPub compatible Profile.', 'activitypub' ),
'default' => '0',
)
);
}
public static function add_settings_help_tab() {
require_once ACTIVITYPUB_PLUGIN_DIR . 'includes/help.php';
}
public static function add_followers_list_help_tab() {
// todo
}
public static function add_profile( $user ) {
$description = get_user_meta( $user->ID, 'activitypub_user_description', true );
\load_template(
ACTIVITYPUB_PLUGIN_DIR . 'templates/user-settings.php',
true,
array(
'description' => $description,
)
);
}
public static function save_user_description( $user_id ) {
if ( ! isset( $_REQUEST['_apnonce'] ) ) {
return false;
}
$nonce = sanitize_text_field( wp_unslash( $_REQUEST['_apnonce'] ) );
if (
! wp_verify_nonce( $nonce, 'activitypub-user-description' ) ||
! current_user_can( 'edit_user', $user_id )
) {
return false;
}
$description = ! empty( $_POST['activitypub-user-description'] ) ? sanitize_text_field( wp_unslash( $_POST['activitypub-user-description'] ) ) : false;
if ( $description ) {
update_user_meta( $user_id, 'activitypub_user_description', $description );
}
}
public static function enqueue_scripts( $hook_suffix ) {
if ( false !== strpos( $hook_suffix, 'activitypub' ) ) {
wp_enqueue_style( 'activitypub-admin-styles', plugins_url( 'assets/css/activitypub-admin.css', ACTIVITYPUB_PLUGIN_FILE ), array(), get_plugin_version() );
wp_enqueue_script( 'activitypub-admin-script', plugins_url( 'assets/js/activitypub-admin.js', ACTIVITYPUB_PLUGIN_FILE ), array( 'jquery' ), get_plugin_version(), false );
}
if ( 'index.php' === $hook_suffix ) {
wp_enqueue_style( 'activitypub-admin-styles', plugins_url( 'assets/css/activitypub-admin.css', ACTIVITYPUB_PLUGIN_FILE ), array(), get_plugin_version() );
}
}
/**
* Hook into the edit_comment functionality
*
* * Disable the edit_comment capability for federated comments.
*
* @return void
*/
public static function edit_comment() {
// Disable the edit_comment capability for federated comments.
\add_filter(
'user_has_cap',
function ( $allcaps, $caps, $arg ) {
if ( 'edit_comment' !== $arg[0] ) {
return $allcaps;
}
if ( was_comment_received( $arg[2] ) ) {
return false;
}
return $allcaps;
},
1,
3
);
}
public static function edit_post() {
// Disable the edit_post capability for federated posts.
\add_filter(
'user_has_cap',
function ( $allcaps, $caps, $arg ) {
if ( 'edit_post' !== $arg[0] ) {
return $allcaps;
}
$post = get_post( $arg[2] );
if ( 'ap_extrafield' !== $post->post_type ) {
return $allcaps;
}
if ( (int) get_current_user_id() !== (int) $post->post_author ) {
return false;
}
return $allcaps;
},
1,
3
);
}
/**
* Add ActivityPub specific actions/filters to the post list view
*
* @return void
*/
public static function list_posts() {
// Show only the user's extra fields.
\add_action(
'pre_get_posts',
function ( $query ) {
if ( $query->get( 'post_type' ) === 'ap_extrafield' ) {
$query->set( 'author', get_current_user_id() );
}
}
);
// Remove all views for the extra fields.
$screen_id = get_current_screen()->id;
add_filter(
"views_{$screen_id}",
function ( $views ) {
if ( 'ap_extrafield' === get_post_type() ) {
return array();
}
return $views;
}
);
// Set defaults for new extra fields.
if ( 'edit-ap_extrafield' === $screen_id ) {
Activitypub::default_actor_extra_fields( array(), get_current_user_id() );
}
}
public static function comment_row_actions( $actions, $comment ) {
if ( was_comment_received( $comment ) ) {
unset( $actions['edit'] );
unset( $actions['quickedit'] );
}
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 "post_content" as column for Extra-Fields in WP-Admin
*
* @param array $columns Tthe list of column names.
* @param string $post_type The post type.
*/
public static function manage_post_columns( $columns, $post_type ) {
if ( 'ap_extrafield' === $post_type ) {
$after_key = 'title';
$index = array_search( $after_key, array_keys( $columns ), true );
$columns = array_slice( $columns, 0, $index + 1 ) + array( 'extra_field_content' => esc_attr__( 'Content', 'activitypub' ) ) + $columns;
}
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 '<span aria-hidden="true">&#x2713;</span><span class="screen-reader-text">' . esc_html__( 'ActivityPub enabled for this author', 'activitypub' ) . '</span>';
} else {
return '<span aria-hidden="true">&#x2717;</span><span class="screen-reader-text">' . esc_html__( 'ActivityPub disabled for this author', 'activitypub' ) . '</span>';
}
}
/**
* Add a column "extra_field_content" to the post list view
*
* @param string $column_name The column name.
* @param int $post_id The post ID.
*
* @return void
*/
public static function manage_posts_custom_column( $column_name, $post_id ) {
$post = get_post( $post_id );
if ( 'extra_field_content' === $column_name ) {
$post = get_post( $post_id );
if ( 'ap_extrafield' === $post->post_type ) {
echo esc_attr( wp_strip_all_tags( $post->post_content ) );
}
}
}
/**
* 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;
}
/**
* Add ActivityPub infos to the dashboard glance items
*
* @param array $items The existing glance items.
*
* @return array The extended glance items.
*/
public static function dashboard_glance_items( $items ) {
\add_filter( 'number_format_i18n', '\Activitypub\custom_large_numbers', 10, 3 );
if ( ! is_user_disabled( get_current_user_id() ) ) {
$follower_count = sprintf(
// translators: %s: number of followers
_n(
'%s Follower',
'%s Followers',
count_followers( \get_current_user_id() ),
'activitypub'
),
\number_format_i18n( count_followers( \get_current_user_id() ) )
);
$items['activitypub-followers-user'] = sprintf(
'<a class="activitypub-followers" href="%1$s" title="%2$s">%3$s</a>',
\esc_url( \admin_url( 'users.php?page=activitypub-followers-list' ) ),
\esc_attr__( 'Your followers', 'activitypub' ),
\esc_html( $follower_count )
);
}
if ( ! is_user_type_disabled( 'blog' ) && current_user_can( 'manage_options' ) ) {
$follower_count = sprintf(
// translators: %s: number of followers
_n(
'%s Follower (Blog)',
'%s Followers (Blog)',
count_followers( Users::BLOG_USER_ID ),
'activitypub'
),
\number_format_i18n( count_followers( Users::BLOG_USER_ID ) )
);
$items['activitypub-followers-blog'] = sprintf(
'<a class="activitypub-followers" href="%1$s" title="%2$s">%3$s</a>',
\esc_url( \admin_url( 'options-general.php?page=activitypub&tab=followers' ) ),
\esc_attr__( 'The Blog\'s followers', 'activitypub' ),
\esc_html( $follower_count )
);
}
\remove_filter( 'number_format_i18n', '\Activitypub\custom_large_numbers', 10, 3 );
return $items;
}
}

View File

@ -0,0 +1,106 @@
<?php
/**
* Autoloader for Activitypub.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* An Autoloader that respects WordPress's filename standards.
*/
class Autoloader {
/**
* Namespace separator.
*/
const NS_SEPARATOR = '\\';
/**
* The prefix to compare classes against.
*
* @var string
* @access protected
*/
protected $prefix;
/**
* Length of the prefix string.
*
* @var int
* @access protected
*/
protected $prefix_length;
/**
* Path to the file to be loaded.
*
* @var string
* @access protected
*/
protected $path;
/**
* Constructor.
*
* @param string $prefix Namespace prefix all classes have in common.
* @param string $path Path to the files to be loaded.
*/
public function __construct( $prefix, $path ) {
$this->prefix = $prefix;
$this->prefix_length = \strlen( $prefix );
$this->path = \rtrim( $path . '/' );
}
/**
* Registers Autoloader's autoload function.
*
* @throws \Exception When autoload_function cannot be registered.
*
* @param string $prefix Namespace prefix all classes have in common.
* @param string $path Path to the files to be loaded.
*/
public static function register_path( $prefix, $path ) {
$loader = new self( $prefix, $path );
\spl_autoload_register( array( $loader, 'load' ) );
}
/**
* Loads a class if its namespace starts with `$this->prefix`.
*
* @param string $class_name The class to be loaded.
*/
public function load( $class_name ) {
if ( \strpos( $class_name, $this->prefix . self::NS_SEPARATOR ) !== 0 ) {
return;
}
// Strip prefix from the start (ala PSR-4).
$class_name = \substr( $class_name, $this->prefix_length + 1 );
$class_name = \strtolower( $class_name );
$dir = '';
$last_ns_pos = \strripos( $class_name, self::NS_SEPARATOR );
if ( false !== $last_ns_pos ) {
$namespace = \substr( $class_name, 0, $last_ns_pos );
$namespace = \str_replace( '_', '-', $namespace );
$class_name = \substr( $class_name, $last_ns_pos + 1 );
$dir = \str_replace( self::NS_SEPARATOR, DIRECTORY_SEPARATOR, $namespace ) . DIRECTORY_SEPARATOR;
}
$path = $this->path . $dir . 'class-' . \str_replace( '_', '-', $class_name ) . '.php';
if ( ! \file_exists( $path ) ) {
$path = $this->path . $dir . 'interface-' . \str_replace( '_', '-', $class_name ) . '.php';
}
if ( ! \file_exists( $path ) ) {
$path = $this->path . $dir . 'trait-' . \str_replace( '_', '-', $class_name ) . '.php';
}
if ( \file_exists( $path ) ) {
require_once $path;
}
}
}

View File

@ -1,36 +1,136 @@
<?php
/**
* Blocks file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\object_to_uri;
use function Activitypub\is_user_type_disabled;
/**
* Block class.
*/
class Blocks {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
// this is already being called on the init hook, so just add it.
// This is already being called on the init hook, so just add it.
self::register_blocks();
\add_action( 'wp_enqueue_scripts', array( self::class, 'add_data' ) );
\add_action( 'enqueue_block_editor_assets', array( self::class, 'add_data' ) );
\add_action( 'wp_head', array( self::class, 'inject_activitypub_options' ), 11 );
\add_action( 'admin_print_scripts', array( self::class, 'inject_activitypub_options' ) );
\add_action( 'load-post-new.php', array( self::class, 'handle_in_reply_to_get_param' ) );
// Add editor plugin.
\add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
\add_action( 'init', array( self::class, 'register_postmeta' ), 11 );
\add_filter( 'activitypub_import_mastodon_post_data', array( self::class, 'filter_import_mastodon_post_data' ), 10, 2 );
}
public static function add_data() {
$context = is_admin() ? 'editor' : 'view';
$followers_handle = 'activitypub-followers-' . $context . '-script';
$follow_me_handle = 'activitypub-follow-me-' . $context . '-script';
/**
* Register post meta for content warnings.
*/
public static function register_postmeta() {
$ap_post_types = \get_post_types_by_support( 'activitypub' );
foreach ( $ap_post_types as $post_type ) {
\register_post_meta(
$post_type,
'activitypub_content_warning',
array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => function ( $warning ) {
if ( $warning ) {
return \sanitize_text_field( $warning );
}
return null;
},
)
);
\register_post_meta(
$post_type,
'activitypub_content_visibility',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => function ( $value ) {
$schema = array(
'type' => 'string',
'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ),
'default' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
);
if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) {
return $schema['default'];
}
return $value;
},
)
);
}
}
/**
* Enqueue the block editor assets.
*/
public static function enqueue_editor_assets() {
// Check for our supported post types.
$current_screen = \get_current_screen();
$ap_post_types = \get_post_types_by_support( 'activitypub' );
if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) {
return;
}
$asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php';
$plugin_url = plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
}
/**
* Enqueue the reply handle script if the in_reply_to GET param is set.
*/
public static function handle_in_reply_to_get_param() {
// Only load the script if the in_reply_to GET param is set, action happens there, not here.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['in_reply_to'] ) ) {
return;
}
$asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/reply-intent/plugin.asset.php';
$plugin_url = plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
}
/**
* Output ActivityPub options as a script tag.
*/
public static function inject_activitypub_options() {
$data = array(
'namespace' => ACTIVITYPUB_REST_NAMESPACE,
'enabled' => array(
'site' => ! is_user_type_disabled( 'blog' ),
'namespace' => ACTIVITYPUB_REST_NAMESPACE,
'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
'enabled' => array(
'site' => ! is_user_type_disabled( 'blog' ),
'users' => ! is_user_type_disabled( 'user' ),
),
);
$js = sprintf( 'var _activityPubOptions = %s;', wp_json_encode( $data ) );
\wp_add_inline_script( $followers_handle, $js, 'before' );
\wp_add_inline_script( $follow_me_handle, $js, 'before' );
printf(
"\n<script>var _activityPubOptions = %s;</script>",
wp_json_encode( $data )
);
}
/**
* Register the blocks.
*/
public static function register_blocks() {
\register_block_type_from_metadata(
ACTIVITYPUB_PLUGIN_DIR . '/build/followers',
@ -44,49 +144,135 @@ class Blocks {
'render_callback' => array( self::class, 'render_follow_me_block' ),
)
);
\register_block_type_from_metadata(
ACTIVITYPUB_PLUGIN_DIR . '/build/reply',
array(
'render_callback' => array( self::class, 'render_reply_block' ),
)
);
\register_block_type_from_metadata(
ACTIVITYPUB_PLUGIN_DIR . '/build/reactions',
array(
'render_callback' => array( self::class, 'render_post_reactions_block' ),
)
);
}
/**
* Render the post reactions block.
*
* @param array $attrs The block attributes.
*
* @return string The HTML to render.
*/
public static function render_post_reactions_block( $attrs ) {
if ( ! isset( $attrs['postId'] ) ) {
$attrs['postId'] = get_the_ID();
}
$wrapper_attributes = get_block_wrapper_attributes(
array(
'class' => 'activitypub-reactions-block',
'data-attrs' => wp_json_encode( $attrs ),
)
);
return sprintf(
'<div %s></div>',
$wrapper_attributes
);
}
/**
* Get the user ID from a user string.
*
* @param string $user_string The user string. Can be a user ID, 'site', or 'inherit'.
* @return int|null The user ID, or null if the 'inherit' string is not supported in this context.
*/
private static function get_user_id( $user_string ) {
if ( is_numeric( $user_string ) ) {
return absint( $user_string );
}
// any other non-numeric falls back to 0, including the `site` string used in the UI
return 0;
// If the user string is 'site', return the Blog User ID.
if ( 'site' === $user_string ) {
return Actors::BLOG_USER_ID;
}
// The only other value should be 'inherit', which means to use the query context to determine the User.
if ( 'inherit' !== $user_string ) {
return null;
}
// For a homepage/front page, if the Blog User is active, use it.
if ( ( is_front_page() || is_home() ) && ! is_user_type_disabled( 'blog' ) ) {
return Actors::BLOG_USER_ID;
}
// If we're in a loop, use the post author.
$author_id = get_the_author_meta( 'ID' );
if ( $author_id ) {
return $author_id;
}
// For other pages, the queried object will clue us in.
$queried_object = get_queried_object();
if ( ! $queried_object ) {
return null;
}
// If we're on a user archive page, use that user's ID.
if ( is_a( $queried_object, 'WP_User' ) ) {
return $queried_object->ID;
}
// For a single post, use the post author's ID.
if ( is_a( $queried_object, 'WP_Post' ) ) {
return get_the_author_meta( 'ID' );
}
// We won't properly account for some conditions, like tag archives.
return null;
}
/**
* Filter an array by a list of keys.
* @param array $array The array to filter.
*
* @param array $data The array to filter.
* @param array $keys The keys to keep.
* @return array The filtered array.
*/
protected static function filter_array_by_keys( $array, $keys ) {
return array_intersect_key( $array, array_flip( $keys ) );
protected static function filter_array_by_keys( $data, $keys ) {
return array_intersect_key( $data, array_flip( $keys ) );
}
/**
* Render the follow me block.
*
* @param array $attrs The block attributes.
* @return string The HTML to render.
*/
public static function render_follow_me_block( $attrs ) {
$user_id = self::get_user_id( $attrs['selectedUser'] );
$user = User_Collection::get_by_id( $user_id );
if ( ! is_wp_error( $user ) ) {
$attrs['profileData'] = self::filter_array_by_keys(
$user->to_array(),
array( 'icon', 'name', 'webfinger' )
);
$user = Actors::get_by_id( $user_id );
if ( is_wp_error( $user ) ) {
if ( 'inherit' === $attrs['selectedUser'] ) {
// If the user is 'inherit' and we couldn't determine the user, don't render anything.
return '<!-- Follow Me block: `inherit` mode does not display on this type of page -->';
} else {
// If the user is a specific ID and we couldn't find it, render an error message.
return '<!-- Follow Me block: user not found -->';
}
}
// add `@` prefix if it's missing
if ( '@' !== substr( $attrs['profileData']['webfinger'], 0, 1 ) ) {
$attrs['profileData']['webfinger'] = '@' . $attrs['profileData']['webfinger'];
}
$attrs['profileData'] = self::filter_array_by_keys(
$user->to_array(),
array( 'icon', 'name', 'webfinger' )
);
$wrapper_attributes = get_block_wrapper_attributes(
array(
'aria-label' => __( 'Follow me on the Fediverse', 'activitypub' ),
'class' => 'activitypub-follow-me-block-wrapper',
'data-attrs' => wp_json_encode( $attrs ),
)
@ -95,12 +281,28 @@ class Blocks {
return '<div ' . $wrapper_attributes . '></div>';
}
/**
* Render the follower block.
*
* @param array $attrs The block attributes.
*
* @return string The HTML to render.
*/
public static function render_follower_block( $attrs ) {
$followee_user_id = self::get_user_id( $attrs['selectedUser'] );
$per_page = absint( $attrs['per_page'] );
if ( is_null( $followee_user_id ) ) {
return '<!-- Followers block: `inherit` mode does not display on this type of page -->';
}
$user = Actors::get_by_id( $followee_user_id );
if ( is_wp_error( $user ) ) {
return '<!-- Followers block: `' . $followee_user_id . '` not an active ActivityPub user -->';
}
$per_page = absint( $attrs['per_page'] );
$follower_data = Followers::get_followers_with_count( $followee_user_id, $per_page );
$attrs['followerData']['total'] = $follower_data['total'];
$attrs['followerData']['total'] = $follower_data['total'];
$attrs['followerData']['followers'] = array_map(
function ( $follower ) {
return self::filter_array_by_keys(
@ -110,7 +312,7 @@ class Blocks {
},
$follower_data['followers']
);
$wrapper_attributes = get_block_wrapper_attributes(
$wrapper_attributes = get_block_wrapper_attributes(
array(
'aria-label' => __( 'Fediverse Followers', 'activitypub' ),
'class' => 'activitypub-follower-block',
@ -131,9 +333,65 @@ class Blocks {
return $html;
}
/**
* Render the reply block.
*
* @param array $attrs The block attributes.
*
* @return string The HTML to render.
*/
public static function render_reply_block( $attrs ) {
// Return early if no URL is provided.
if ( empty( $attrs['url'] ) ) {
return null;
}
$show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
$wrapper_attrs = get_block_wrapper_attributes(
array(
'aria-label' => __( 'Reply', 'activitypub' ),
'class' => 'activitypub-reply-block',
'data-in-reply-to' => $attrs['url'],
)
);
$html = '<div ' . $wrapper_attrs . '>';
// Try to get and append the embed if requested.
if ( $show_embed ) {
$embed = wp_oembed_get( $attrs['url'] );
if ( $embed ) {
$html .= $embed;
}
}
// Only show the link if we're not showing the embed.
if ( ! $show_embed ) {
$html .= sprintf(
'<p><a title="%2$s" aria-label="%2$s" href="%1$s" class="u-in-reply-to" target="_blank">%3$s</a></p>',
esc_url( $attrs['url'] ),
esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
// translators: %s is the URL of the post being replied to.
sprintf( __( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', esc_url( $attrs['url'] ) ) )
);
}
$html .= '</div>';
return $html;
}
/**
* Render a follower.
*
* @param \Activitypub\Model\Follower $follower The follower to render.
*
* @return string The HTML to render.
*/
public static function render_follower( $follower ) {
$external_svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="components-external-link__icon css-rvs7bx esh4a730" aria-hidden="true" focusable="false"><path d="M18.2 17c0 .7-.6 1.2-1.2 1.2H7c-.7 0-1.2-.6-1.2-1.2V7c0-.7.6-1.2 1.2-1.2h3.2V4.2H7C5.5 4.2 4.2 5.5 4.2 7v10c0 1.5 1.2 2.8 2.8 2.8h10c1.5 0 2.8-1.2 2.8-2.8v-3.6h-1.5V17zM14.9 3v1.5h3.7l-6.4 6.4 1.1 1.1 6.4-6.4v3.7h1.5V3h-6.3z"></path></svg>';
$template =
$template =
'<a href="%s" title="%s" class="components-external-link activitypub-link" target="_blank" rel="external noreferrer noopener">
<img width="40" height="40" src="%s" class="avatar activitypub-avatar" />
<span class="activitypub-actor">
@ -156,4 +414,33 @@ class Blocks {
$external_svg
);
}
/**
* Converts content to blocks before saving to the database.
*
* @param array $data The post data to be inserted.
* @param object $post The Mastodon Create activity.
*
* @return array
*/
public static function filter_import_mastodon_post_data( $data, $post ) {
// Convert paragraphs to blocks.
\preg_match_all( '#<p>.*?</p>#is', $data['post_content'], $matches );
$blocks = \array_map(
function ( $paragraph ) {
return '<!-- wp:paragraph -->' . PHP_EOL . $paragraph . PHP_EOL . '<!-- /wp:paragraph -->' . PHP_EOL;
},
$matches[0] ?? array()
);
$data['post_content'] = \rtrim( \implode( PHP_EOL, $blocks ), PHP_EOL );
// Add reply block if it's a reply.
if ( null !== $post->object->inReplyTo ) {
$reply_block = \sprintf( '<!-- wp:activitypub/reply {"url":"%1$s","embedPost":true} /-->' . PHP_EOL, \esc_url( $post->object->inReplyTo ) );
$data['post_content'] = $reply_block . $data['post_content'];
}
return $data;
}
}

View File

@ -0,0 +1,230 @@
<?php
/**
* WP-CLI file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Collection\Outbox;
/**
* WP-CLI commands.
*
* @package Activitypub
*/
class Cli extends \WP_CLI_Command {
/**
* Remove the entire blog from the Fediverse.
*
* ## EXAMPLES
*
* $ wp activitypub self-destruct
*
* @param array|null $args The arguments.
* @param array|null $assoc_args The associative arguments.
*
* @return void
*/
public function self_destruct( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
\WP_CLI::warning( 'Self-Destructing is not implemented yet.' );
}
/**
* Delete or Update a Post, Page, Custom Post Type or Attachment.
*
* ## OPTIONS
*
* <action>
* : The action to perform. Either `delete` or `update`.
* ---
* options:
* - delete
* - update
* ---
*
* <id>
* : The id of the Post, Page, Custom Post Type or Attachment.
*
* ## EXAMPLES
*
* $ wp activitypub post delete 1
*
* @synopsis <action> <id>
*
* @param array $args The arguments.
*/
public function post( $args ) {
$post = get_post( $args[1] );
if ( ! $post ) {
\WP_CLI::error( 'Post not found.' );
}
switch ( $args[0] ) {
case 'delete':
\WP_CLI::confirm( 'Do you really want to delete the (Custom) Post with the ID: ' . $args[1] );
add_to_outbox( $post, 'Delete', $post->post_author );
\WP_CLI::success( '"Delete" activity is queued.' );
break;
case 'update':
add_to_outbox( $post, 'Update', $post->post_author );
\WP_CLI::success( '"Update" activity is queued.' );
break;
default:
\WP_CLI::error( 'Unknown action.' );
}
}
/**
* Delete or Update a Comment.
*
* ## OPTIONS
*
* <action>
* : The action to perform. Either `delete` or `update`.
* ---
* options:
* - delete
* - update
* ---
*
* <id>
* : The id of the Comment.
*
* ## EXAMPLES
*
* $ wp activitypub comment delete 1
*
* @synopsis <action> <id>
*
* @param array $args The arguments.
*/
public function comment( $args ) {
$comment = get_comment( $args[1] );
if ( ! $comment ) {
\WP_CLI::error( 'Comment not found.' );
}
if ( was_comment_received( $comment ) ) {
\WP_CLI::error( 'This comment was received via ActivityPub and cannot be deleted or updated.' );
}
switch ( $args[0] ) {
case 'delete':
\WP_CLI::confirm( 'Do you really want to delete the Comment with the ID: ' . $args[1] );
add_to_outbox( $comment, 'Delete', $comment->user_id );
\WP_CLI::success( '"Delete" activity is queued.' );
break;
case 'update':
add_to_outbox( $comment, 'Update', $comment->user_id );
\WP_CLI::success( '"Update" activity is queued.' );
break;
default:
\WP_CLI::error( 'Unknown action.' );
}
}
/**
* Undo an activity that was sent to the Fediverse.
*
* ## OPTIONS
*
* <outbox_item_id>
* The ID or URL of the outbox item to undo.
*
* ## EXAMPLES
*
* $ wp activitypub undo 123
* $ wp activitypub undo "https://example.com/?post_type=ap_outbox&p=123"
*
* @synopsis <outbox_item_id>
*
* @param array $args The arguments.
*/
public function undo( $args ) {
$outbox_item_id = $args[0];
if ( ! is_numeric( $outbox_item_id ) ) {
$outbox_item_id = url_to_postid( $outbox_item_id );
}
$outbox_item_id = get_post( $outbox_item_id );
if ( ! $outbox_item_id ) {
\WP_CLI::error( 'Activity not found.' );
}
$undo_id = Outbox::undo( $outbox_item_id );
if ( ! $undo_id ) {
\WP_CLI::error( 'Failed to undo activity.' );
}
\WP_CLI::success( 'Undo activity scheduled.' );
}
/**
* Re-Schedule an activity that was sent to the Fediverse before.
*
* ## OPTIONS
*
* <outbox_item_id>
* The ID or URL of the outbox item to reschedule.
*
* ## EXAMPLES
*
* $ wp activitypub reschedule 123
* $ wp activitypub reschedule "https://example.com/?post_type=ap_outbox&p=123"
*
* @synopsis <outbox_item_id>
*
* @param array $args The arguments.
*/
public function reschedule( $args ) {
$outbox_item_id = $args[0];
if ( ! is_numeric( $outbox_item_id ) ) {
$outbox_item_id = url_to_postid( $outbox_item_id );
}
$outbox_item_id = get_post( $outbox_item_id );
if ( ! $outbox_item_id ) {
\WP_CLI::error( 'Activity not found.' );
}
Outbox::reschedule( $outbox_item_id );
\WP_CLI::success( 'Rescheduled activity.' );
}
/**
* Move the blog to a new URL.
*
* ## OPTIONS
*
* <from>
* The current URL of the blog.
*
* <to>
* The new URL of the blog.
*
* ## EXAMPLES
*
* $ wp activitypub move https://example.com/ https://newsite.com/
*
* @synopsis <from> <to>
*
* @param array $args The arguments.
*/
public function move( $args ) {
$from = $args[0];
$to = $args[1];
$outbox_item_id = Move::account( $from, $to );
if ( is_wp_error( $outbox_item_id ) ) {
\WP_CLI::error( $outbox_item_id->get_error_message() );
} else {
\WP_CLI::success( 'Move Scheduled.' );
}
}
}

View File

@ -1,28 +1,38 @@
<?php
/**
* ActivityPub Comment Class
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
use WP_Comment_Query;
use function Activitypub\is_user_disabled;
use function Activitypub\is_single_user;
/**
* ActivityPub Comment Class
* ActivityPub Comment Class.
*
* This class is a helper/utils class that provides a collection of static
* methods that are used to handle comments.
*/
class Comment {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_comment_types();
\add_filter( 'comment_reply_link', array( self::class, 'comment_reply_link' ), 10, 3 );
\add_filter( 'comment_class', array( self::class, 'comment_class' ), 10, 3 );
\add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 3 );
\add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 2 );
\add_action( 'wp_enqueue_scripts', array( self::class, 'enqueue_scripts' ) );
\add_action( 'pre_get_comments', array( static::class, 'comment_query' ) );
\add_filter( 'pre_comment_approved', array( static::class, 'pre_comment_approved' ), 10, 2 );
\add_filter( 'get_avatar_comment_types', array( static::class, 'get_avatar_comment_types' ), 99 );
\add_action( 'update_option_activitypub_allow_likes', array( self::class, 'maybe_update_comment_counts' ), 10, 2 );
\add_action( 'update_option_activitypub_allow_reposts', array( self::class, 'maybe_update_comment_counts' ), 10, 2 );
\add_filter( 'pre_wp_update_comment_count_now', array( static::class, 'pre_wp_update_comment_count_now' ), 10, 3 );
}
/**
@ -31,16 +41,15 @@ class Comment {
* We don't want to show the comment reply link for federated comments
* if the user is disabled for federation.
*
* @param string $link The HTML markup for the comment reply link.
* @param array $args An array of arguments overriding the defaults.
* @param WP_Comment $comment The object of the comment being replied.
* @param string $link The HTML markup for the comment reply link.
* @param array $args An array of arguments overriding the defaults.
* @param \WP_Comment $comment The object of the comment being replied.
*
* @return string The filtered HTML markup for the comment reply link.
*/
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' ) ) {
if ( \current_user_can( 'activitypub' ) && self::was_received( $comment ) ) {
return self::create_fediverse_reply_link( $link, $args );
}
@ -49,19 +58,27 @@ class Comment {
$attrs = array(
'selectedComment' => self::generate_id( $comment ),
'commentId' => $comment->comment_ID,
'commentId' => $comment->comment_ID,
);
$div = sprintf(
'<div class="activitypub-remote-reply" data-attrs="%s"></div>',
'<div class="reply activitypub-remote-reply" data-attrs="%s"></div>',
esc_attr( wp_json_encode( $attrs ) )
);
/**
* Filters the HTML markup for the ActivityPub remote comment reply container.
*
* @param string $div The HTML markup for the remote reply container. Default is a div
* with class 'activitypub-remote-reply' and data attributes for
* the selected comment ID and internal comment ID.
*/
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.
@ -73,7 +90,7 @@ class Comment {
*/
private static function create_fediverse_reply_link( $link, $args ) {
$str_to_replace = sprintf( '>%s<', $args['reply_text'] );
$replace_with = sprintf(
$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' )
@ -104,17 +121,11 @@ class Comment {
}
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;
// On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user.
$current_user = Actors::BLOG_USER_ID;
}
$is_user_disabled = is_user_disabled( $current_user );
if ( $is_user_disabled ) {
return false;
}
return true;
return user_can_activitypub( $current_user );
}
/**
@ -200,7 +211,7 @@ class Comment {
* @return boolean True if the comment should be federated, false otherwise.
*/
public static function should_be_federated( $comment ) {
// we should not federate federated comments
// We should not federate federated comments.
if ( self::was_received( $comment ) ) {
return false;
}
@ -208,29 +219,27 @@ class Comment {
$comment = \get_comment( $comment );
$user_id = $comment->user_id;
// comments without user can't be federated
// Comments without user can't be federated.
if ( ! $user_id ) {
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;
if ( is_single_user() && \user_can( $user_id, 'activitypub' ) ) {
// On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user.
$user_id = Actors::BLOG_USER_ID;
}
$is_user_disabled = is_user_disabled( $user_id );
// user is disabled for federation
if ( $is_user_disabled ) {
// User is not allowed to federate comments.
if ( ! user_can_activitypub( $user_id ) ) {
return false;
}
// it is a comment to the post and can be federated
// It is a comment to the post and can be federated.
if ( empty( $comment->comment_parent ) ) {
return true;
}
// check if parent comment is federated
// Check if parent comment is federated.
$parent_comment = \get_comment( $comment->comment_parent );
return ! self::is_local( $parent_comment );
@ -248,6 +257,8 @@ class Comment {
array(
'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'orderby' => 'comment_date',
'order' => 'DESC',
)
);
@ -255,27 +266,23 @@ class Comment {
return false;
}
if ( count( $comment_query->comments ) > 1 ) {
return false;
}
return $comment_query->comments[0];
}
/**
* Verify if URL is a local comment, or if it is a previously received
* remote comment (For threading comments locally)
* remote comment (For threading comments locally).
*
* @param string $url The URL to check.
*
* @return int comment_ID or null if not found
* @return string|null Comment ID or null if not found.
*/
public static function url_to_commentid( $url ) {
if ( ! $url || ! filter_var( $url, \FILTER_VALIDATE_URL ) ) {
return null;
}
// check for local comment
// Check for local comment.
if ( \wp_parse_url( \home_url(), \PHP_URL_HOST ) === \wp_parse_url( $url, \PHP_URL_HOST ) ) {
$query = \wp_parse_url( $url, \PHP_URL_QUERY );
@ -327,7 +334,7 @@ class Comment {
* @return string[] An array of classes.
*/
public static function comment_class( $classes, $css_class, $comment_id ) {
// check if ActivityPub comment
// Check if ActivityPub comment.
if ( 'activitypub' === get_comment_meta( $comment_id, 'protocol', true ) ) {
$classes[] = 'activitypub-comment';
}
@ -335,11 +342,51 @@ class Comment {
return $classes;
}
/**
* Gets the public comment id via the WordPress comments meta.
*
* @param int $wp_comment_id The internal WordPress comment ID.
* @param bool $fallback Whether the code should fall back to `source_url` if `source_id` is not set.
*
* @return string|null The ActivityPub id/url of the comment.
*/
public static function get_source_id( $wp_comment_id, $fallback = true ) {
$comment_meta = \get_comment_meta( $wp_comment_id );
if ( ! empty( $comment_meta['source_id'][0] ) ) {
return $comment_meta['source_id'][0];
} elseif ( ! empty( $comment_meta['source_url'][0] ) && $fallback ) {
return $comment_meta['source_url'][0];
}
return null;
}
/**
* Gets the public comment url via the WordPress comments meta.
*
* @param int $wp_comment_id The internal WordPress comment ID.
* @param bool $fallback Whether the code should fall back to `source_id` if `source_url` is not set.
*
* @return string|null The ActivityPub id/url of the comment.
*/
public static function get_source_url( $wp_comment_id, $fallback = true ) {
$comment_meta = \get_comment_meta( $wp_comment_id );
if ( ! empty( $comment_meta['source_url'][0] ) ) {
return $comment_meta['source_url'][0];
} elseif ( ! empty( $comment_meta['source_id'][0] ) && $fallback ) {
return $comment_meta['source_id'][0];
}
return null;
}
/**
* Link remote comments to source url.
*
* @param string $comment_link
* @param object|WP_Comment $comment
* @param string $comment_link The comment link.
* @param object|\WP_Comment $comment The comment object.
*
* @return string $url
*/
@ -348,37 +395,30 @@ class Comment {
return $comment_link;
}
$comment_meta = \get_comment_meta( $comment->comment_ID );
$public_comment_link = self::get_source_url( $comment->comment_ID );
if ( ! empty( $comment_meta['source_url'][0] ) ) {
return $comment_meta['source_url'][0];
} elseif ( ! empty( $comment_meta['source_id'][0] ) ) {
return $comment_meta['source_id'][0];
}
return $comment_link;
return $public_comment_link ?? $comment_link;
}
/**
* Generates an ActivityPub URI for a comment
*
* @param WP_Comment|int $comment A comment object or comment ID
* @param \WP_Comment|int $comment A comment object or comment ID.
*
* @return string ActivityPub URI for comment
*/
public static function generate_id( $comment ) {
$comment = \get_comment( $comment );
$comment_meta = \get_comment_meta( $comment->comment_ID );
$comment = \get_comment( $comment );
// show external comment ID if it exists
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];
// Show external comment ID if it exists.
$public_comment_link = self::get_source_id( $comment->comment_ID );
if ( $public_comment_link ) {
return $public_comment_link;
}
// generate URI based on comment ID
// Generate URI based on comment ID.
return \add_query_arg( 'c', $comment->comment_ID, \trailingslashit( \home_url() ) );
}
@ -392,7 +432,8 @@ class Comment {
private static function post_has_remote_comments( $post_id ) {
$comments = \get_comments(
array(
'post_id' => $post_id,
'post_id' => $post_id,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'AND',
array(
@ -416,28 +457,29 @@ class Comment {
*/
public static function enqueue_scripts() {
if ( ! \is_singular() || \is_user_logged_in() ) {
// only on single pages, only for logged out users
// Only on single pages, only for logged-out users.
return;
}
if ( ! \post_type_supports( \get_post_type(), 'activitypub' ) ) {
// post type does not support ActivityPub
// Post type does not support ActivityPub.
return;
}
if ( ! \comments_open() || ! \get_comments_number() ) {
// no comments, no need to load the script
// 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
// No remote comments, no need to load the script.
return;
}
$handle = 'activitypub-remote-reply';
$data = array(
'namespace' => ACTIVITYPUB_REST_NAMESPACE,
'namespace' => ACTIVITYPUB_REST_NAMESPACE,
'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
);
$js = sprintf( 'var _activityPubOptions = %s;', wp_json_encode( $data ) );
$asset_file = ACTIVITYPUB_PLUGIN_DIR . 'build/remote-reply/index.asset.php';
@ -453,13 +495,323 @@ class Comment {
true
);
\wp_add_inline_script( $handle, $js, 'before' );
\wp_set_script_translations( $handle, 'activitypub' );
\wp_enqueue_style(
$handle,
\plugins_url( 'build/remote-reply/style-index.css', __DIR__ ),
[ 'wp-components' ],
array( 'wp-components' ),
$assets['version']
);
}
}
/**
* Get the comment type by activity type.
*
* @param string $activity_type The activity type.
*
* @return array|null The comment type.
*/
public static function get_comment_type_by_activity_type( $activity_type ) {
$activity_type = \strtolower( $activity_type );
$activity_type = \sanitize_key( $activity_type );
$comment_types = self::get_comment_types();
foreach ( $comment_types as $comment_type ) {
if ( in_array( $activity_type, $comment_type['activity_types'], true ) ) {
return $comment_type;
}
}
return null;
}
/**
* Return the registered custom comment types.
*
* @return array The registered custom comment types
*/
public static function get_comment_types() {
global $activitypub_comment_types;
return $activitypub_comment_types;
}
/**
* Is this a registered comment type.
*
* @param string $slug The slug of the type.
*
* @return boolean True if registered.
*/
public static function is_registered_comment_type( $slug ) {
$slug = \strtolower( $slug );
$slug = \sanitize_key( $slug );
$comment_types = self::get_comment_types();
return isset( $comment_types[ $slug ] );
}
/**
* Return the registered custom comment type slugs.
*
* @return array The registered custom comment type slugs.
*/
public static function get_comment_type_slugs() {
return array_keys( self::get_comment_types() );
}
/**
* Return the registered custom comment type slugs.
*
* @deprecated 4.5.0 Use get_comment_type_slugs instead.
*
* @return array The registered custom comment type slugs.
*/
public static function get_comment_type_names() {
_deprecated_function( __METHOD__, '4.5.0', 'get_comment_type_slugs' );
return self::get_comment_type_slugs();
}
/**
* Get the custom comment type.
*
* Check if the type is registered, if not, check if it is a custom type.
*
* It looks for the array key in the registered types and returns the array.
* If it is not found, it looks for the type in the custom types and returns the array.
*
* @param string $type The comment type.
*
* @return array The comment type.
*/
public static function get_comment_type( $type ) {
$type = strtolower( $type );
$type = sanitize_key( $type );
$comment_types = self::get_comment_types();
$type_array = array();
// Check array keys.
if ( in_array( $type, array_keys( $comment_types ), true ) ) {
$type_array = $comment_types[ $type ];
}
/**
* Filter the comment type.
*
* @param array $type_array The comment type.
*/
return apply_filters( "activitypub_comment_type_{$type}", $type_array );
}
/**
* Get a comment type attribute.
*
* @param string $type The comment type.
* @param string $attr The attribute to get.
*
* @return mixed The value of the attribute.
*/
public static function get_comment_type_attr( $type, $attr ) {
$type_array = self::get_comment_type( $type );
if ( $type_array && isset( $type_array[ $attr ] ) ) {
$value = $type_array[ $attr ];
} else {
$value = '';
}
/**
* Filter the comment type attribute.
*
* @param mixed $value The value of the attribute.
* @param string $type The comment type.
*/
return apply_filters( "activitypub_comment_type_{$attr}", $value, $type );
}
/**
* Register the comment types used by the ActivityPub plugin.
*/
public static function register_comment_types() {
register_comment_type(
'repost',
array(
'label' => __( 'Reposts', 'activitypub' ),
'singular' => __( 'Repost', 'activitypub' ),
'description' => __( 'A repost on the indieweb is a post that is purely a 100% re-publication of another (typically someone else\'s) post.', 'activitypub' ),
'icon' => '♻️',
'class' => 'p-repost',
'type' => 'repost',
'collection' => 'reposts',
'activity_types' => array( 'announce' ),
'excerpt' => html_entity_decode( \__( '&hellip; reposted this!', 'activitypub' ) ),
/* translators: %d: Number of reposts */
'count_single' => _x( '%d repost', 'number of reposts', 'activitypub' ),
/* translators: %d: Number of reposts */
'count_plural' => _x( '%d reposts', 'number of reposts', 'activitypub' ),
)
);
register_comment_type(
'like',
array(
'label' => __( 'Likes', 'activitypub' ),
'singular' => __( 'Like', 'activitypub' ),
'description' => __( 'A like is a popular webaction button and in some cases post type on various silos such as Facebook and Instagram.', 'activitypub' ),
'icon' => '👍',
'class' => 'p-like',
'type' => 'like',
'collection' => 'likes',
'activity_types' => array( 'like' ),
'excerpt' => html_entity_decode( \__( '&hellip; liked this!', 'activitypub' ) ),
/* translators: %d: Number of likes */
'count_single' => _x( '%d like', 'number of likes', 'activitypub' ),
/* translators: %d: Number of likes */
'count_plural' => _x( '%d likes', 'number of likes', 'activitypub' ),
)
);
}
/**
* Show avatars on Activities if set.
*
* @param array $types List of avatar enabled comment types.
*
* @return array show avatars on Activities
*/
public static function get_avatar_comment_types( $types ) {
$comment_types = self::get_comment_type_slugs();
$types = array_merge( $types, $comment_types );
return array_unique( $types );
}
/**
* Excludes likes and reposts from comment queries.
*
* @author Jan Boddez
*
* @see https://github.com/janboddez/indieblocks/blob/a2d59de358031056a649ee47a1332ce9e39d4ce2/includes/functions.php#L423-L432
*
* @param WP_Comment_Query $query Comment count.
*/
public static function comment_query( $query ) {
if ( ! $query instanceof WP_Comment_Query ) {
return;
}
// Do not exclude likes and reposts on ActivityPub requests.
if ( defined( 'ACTIVITYPUB_REQUEST' ) && ACTIVITYPUB_REQUEST ) {
return;
}
// Do not exclude likes and reposts on REST requests.
if ( \wp_is_serving_rest_request() ) {
return;
}
// Do not exclude likes and reposts on admin pages or on non-singular pages.
if ( is_admin() || ! is_singular() ) {
return;
}
// Do not exclude likes and reposts if the query is for comments.
if ( ! empty( $query->query_vars['type__in'] ) || ! empty( $query->query_vars['type'] ) ) {
return;
}
// Exclude likes and reposts by the ActivityPub plugin.
$query->query_vars['type__not_in'] = self::get_comment_type_slugs();
}
/**
* Filter the comment status before it is set.
*
* @param string $approved The approved comment status.
* @param array $commentdata The comment data.
*
* @return boolean `true` if the comment is approved, `false` otherwise.
*/
public static function pre_comment_approved( $approved, $commentdata ) {
if ( $approved || \is_wp_error( $approved ) ) {
return $approved;
}
if ( '1' !== \get_option( 'comment_previously_approved' ) ) {
return $approved;
}
if (
empty( $commentdata['comment_meta']['protocol'] ) ||
'activitypub' !== $commentdata['comment_meta']['protocol']
) {
return $approved;
}
global $wpdb;
$author = $commentdata['comment_author'];
$author_url = $commentdata['comment_author_url'];
// phpcs:ignore
$ok_to_comment = $wpdb->get_var( $wpdb->prepare( "SELECT comment_approved FROM $wpdb->comments WHERE comment_author = %s AND comment_author_url = %s and comment_approved = '1' LIMIT 1", $author, $author_url ) );
if ( 1 === (int) $ok_to_comment ) {
return 1;
}
return $approved;
}
/**
* Update comment counts when interaction settings are disabled.
*
* Triggers a recount when likes or reposts are disabled to ensure accurate comment counts.
*
* @param mixed $old_value The old option value.
* @param mixed $value The new option value.
*/
public static function maybe_update_comment_counts( $old_value, $value ) {
if ( '1' === $old_value && '1' !== $value ) {
Migration::update_comment_counts();
}
}
/**
* Filters the comment count to exclude ActivityPub comment types.
*
* @param int|null $new_count The new comment count. Default null.
* @param int $old_count The old comment count.
* @param int $post_id Post ID.
*
* @return int|null The updated comment count, or null to use the default query.
*/
public static function pre_wp_update_comment_count_now( $new_count, $old_count, $post_id ) {
if ( null === $new_count ) {
$excluded_types = array_filter( self::get_comment_type_slugs(), array( self::class, 'is_comment_type_enabled' ) );
if ( ! empty( $excluded_types ) ) {
global $wpdb;
// phpcs:ignore WordPress.DB
$new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type NOT IN ('" . implode( "','", $excluded_types ) . "')", $post_id ) );
}
}
return $new_count;
}
/**
* Check if a comment type is enabled.
*
* @param string $comment_type The comment type.
* @return bool True if the comment type is enabled.
*/
public static function is_comment_type_enabled( $comment_type ) {
return '1' === get_option( "activitypub_allow_{$comment_type}s", '1' );
}
}

View File

@ -1,37 +1,79 @@
<?php
/**
* Debug Class.
*
* @package Activitypub
*/
namespace Activitypub;
use WP_DEBUG;
use WP_DEBUG_LOG;
/**
* ActivityPub Debug Class
* ActivityPub Debug Class.
*
* @author Matthias Pfefferle
*/
class Debug {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
if ( WP_DEBUG_LOG ) {
\add_action( 'activitypub_safe_remote_post_response', array( self::class, 'log_remote_post_responses' ), 10, 4 );
if ( \WP_DEBUG && \WP_DEBUG_LOG ) {
\add_action( 'activitypub_safe_remote_post_response', array( self::class, 'log_remote_post_responses' ), 10, 2 );
\add_action( 'activitypub_inbox', array( self::class, 'log_inbox' ), 10, 3 );
\add_action( 'activitypub_sent_to_inbox', array( self::class, 'log_sent_to_inbox' ), 10, 2 );
}
}
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
public static function log_remote_post_responses( $response, $url, $body, $user_id ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, WordPress.PHP.DevelopmentFunctions.error_log_print_r
\error_log( "Request to: {$url} with response: " . \print_r( $response, true ) );
/**
* Log the responses of remote post requests.
*
* @param array $response The response from the remote server.
* @param string $url The URL of the remote server.
*/
public static function log_remote_post_responses( $response, $url ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
\error_log( "[OUTBOX] Request to: {$url} with Response: " . \print_r( $response, true ) );
}
/**
* Log the inbox requests.
*
* @param array $data The Activity array.
* @param int $user_id The ID of the local blog user.
* @param string $type The type of the request.
*/
public static function log_inbox( $data, $user_id, $type ) {
$type = strtolower( $type );
if ( 'delete' !== $type ) {
$actor = $data['actor'] ?? '';
$url = object_to_uri( $actor );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
\error_log( "[INBOX] Request From: {$url} with Activity: " . \print_r( $data, true ) );
}
}
/**
* Log the sent to follower action.
*
* @param array $result The result of the remote post request.
* @param string $inbox The inbox URL.
*/
public static function log_sent_to_inbox( $result, $inbox ) {
if ( \is_wp_error( $result ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
\error_log( "[DISPATCHER] Failed Request to: {$inbox} with Result: " . \print_r( $result, true ) );
}
}
/**
* Write a log entry.
*
* @param mixed $log The log entry.
*/
public static function write_log( $log ) {
if ( \is_array( $log ) || \is_object( $log ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, WordPress.PHP.DevelopmentFunctions.error_log_print_r
\error_log( \print_r( $log, true ) );
} else {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
\error_log( $log );
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
\error_log( \print_r( $log, true ) );
}
}

View File

@ -0,0 +1,466 @@
<?php
/**
* ActivityPub Dispatcher Class.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Outbox;
/**
* ActivityPub Dispatcher Class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/
*/
class Dispatcher {
/**
* Batch size.
*
* @var int
*/
public static $batch_size = ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE;
/**
* Callback for the async batch processing.
*
* @var array
*/
public static $callback = array( self::class, 'send_to_followers' );
/**
* Error codes that qualify for a retry.
*
* @see https://github.com/tfredrich/RestApiTutorial.com/blob/fd08b0f67f07450521d143b123cd6e1846cb2e3b/content/advanced/responses/retries.md
* @var int[]
*/
public static $retry_error_codes = array( 408, 429, 500, 502, 503, 504 );
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_process_outbox', array( self::class, 'process_outbox' ) );
// Default filters to add Inboxes to sent to.
\add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_by_mentioned_actors' ), 10, 3 );
\add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_of_replied_urls' ), 10, 3 );
\add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_of_relays' ), 10, 3 );
// Fallback for `activitypub_send_to_inboxes` filter.
\add_filter(
'activitypub_additional_inboxes',
function ( $inboxes, $actor_id, $activity ) {
/**
* Filters the list of interactees inboxes to send the Activity to.
*
* @param array $inboxes The list of inboxes to send to.
* @param int $actor_id The actor ID.
* @param Activity $activity The ActivityPub Activity.
*
* @deprecated 5.2.0 Use `activitypub_additional_inboxes` instead.
* @deprecated 5.4.0 Use `activitypub_additional_inboxes` instead.
*/
$inboxes = \apply_filters_deprecated( 'activitypub_send_to_inboxes', array( $inboxes, $actor_id, $activity ), '5.2.0', 'activitypub_additional_inboxes' );
$inboxes = \apply_filters_deprecated( 'activitypub_interactees_inboxes', array( $inboxes, $actor_id, $activity ), '5.4.0', 'activitypub_additional_inboxes' );
return $inboxes;
},
10,
3
);
}
/**
* Process the outbox.
*
* @param int $id The outbox ID.
*/
public static function process_outbox( $id ) {
$outbox_item = \get_post( $id );
// If the activity is not a post, return.
if ( ! $outbox_item ) {
return;
}
$actor = Outbox::get_actor( $outbox_item );
if ( \is_wp_error( $actor ) ) {
// If the actor is not found, publish the post and don't try again.
\wp_publish_post( $outbox_item );
return;
}
$activity = Outbox::get_activity( $outbox_item );
// Send to mentioned and replied-to users. Everyone other than followers.
self::send_to_additional_inboxes( $activity, $actor->get__id(), $outbox_item );
if ( self::should_send_to_followers( $activity, $actor, $outbox_item ) ) {
Scheduler::async_batch(
self::$callback,
$outbox_item->ID,
self::$batch_size,
\get_post_meta( $outbox_item->ID, '_activitypub_outbox_offset', true ) ?: 0 // phpcs:ignore
);
} else {
// No followers to process for this update. We're done.
\wp_publish_post( $outbox_item );
\delete_post_meta( $outbox_item->ID, '_activitypub_outbox_offset' );
}
}
/**
* Asynchronously runs batch processing routines.
*
* @param int $outbox_item_id The Outbox item ID.
* @param int $batch_size Optional. The batch size. Default ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE.
* @param int $offset Optional. The offset. Default 0.
*
* @return array|void The next batch of followers to process, or void if done.
*/
public static function send_to_followers( $outbox_item_id, $batch_size = ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE, $offset = 0 ) {
$json = Outbox::get_activity( $outbox_item_id )->to_json();
$actor = Outbox::get_actor( \get_post( $outbox_item_id ) );
$inboxes = Followers::get_inboxes_for_activity( $json, $actor->get__id(), $batch_size, $offset );
$retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
// Retry failed inboxes.
if ( ! empty( $retries ) ) {
self::schedule_retry( $retries, $outbox_item_id );
}
if ( is_countable( $inboxes ) && count( $inboxes ) < $batch_size ) {
\delete_post_meta( $outbox_item_id, '_activitypub_outbox_offset' );
/**
* Fires when the followers are complete.
*
* @param array $inboxes The inboxes.
* @param string $json The ActivityPub Activity JSON
* @param int $actor_id The actor ID.
* @param int $outbox_item_id The Outbox item ID.
* @param int $batch_size The batch size.
* @param int $offset The offset.
*/
\do_action( 'activitypub_outbox_processing_complete', $inboxes, $json, $actor->get__id(), $outbox_item_id, $batch_size, $offset );
// No more followers to process for this update.
\wp_publish_post( $outbox_item_id );
} else {
\update_post_meta( $outbox_item_id, '_activitypub_outbox_offset', $offset + $batch_size );
/**
* Fires when the batch of followers is complete.
*
* @param array $inboxes The inboxes.
* @param string $json The ActivityPub Activity JSON
* @param int $actor_id The actor ID.
* @param int $outbox_item_id The Outbox item ID.
* @param int $batch_size The batch size.
* @param int $offset The offset.
*/
\do_action( 'activitypub_outbox_processing_batch_complete', $inboxes, $json, $actor->get__id(), $outbox_item_id, $batch_size, $offset );
return array( $outbox_item_id, $batch_size, $offset + $batch_size );
}
}
/**
* Retry sending to followers.
*
* @param string $transient_key The key to retrieve retry inboxes.
* @param int $outbox_item_id The Outbox item ID.
* @param int $attempt The attempt number.
*/
public static function retry_send_to_followers( $transient_key, $outbox_item_id, $attempt = 1 ) {
$inboxes = \get_transient( $transient_key );
if ( false === $inboxes ) {
return;
}
// Delete the transient as we no longer need it.
\delete_transient( $transient_key );
$retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
// Retry failed inboxes.
if ( ++$attempt < 3 && ! empty( $retries ) ) {
self::schedule_retry( $retries, $outbox_item_id, $attempt );
}
}
/**
* Send to inboxes.
*
* @param array $inboxes The inboxes to notify.
* @param int $outbox_item_id The Outbox item ID.
* @return array The failed inboxes.
*/
private static function send_to_inboxes( $inboxes, $outbox_item_id ) {
$json = Outbox::get_activity( $outbox_item_id )->to_json();
$actor = Outbox::get_actor( \get_post( $outbox_item_id ) );
$retries = array();
/**
* Fires before sending an Activity to inboxes.
*
* @param string $json The ActivityPub Activity JSON.
* @param array $inboxes The inboxes to send to.
* @param int $outbox_item_id The Outbox item ID.
*/
\do_action( 'activitypub_pre_send_to_inboxes', $json, $inboxes, $outbox_item_id );
foreach ( $inboxes as $inbox ) {
$result = safe_remote_post( $inbox, $json, $actor->get__id() );
if ( is_wp_error( $result ) && in_array( $result->get_error_code(), self::$retry_error_codes, true ) ) {
$retries[] = $inbox;
}
/**
* Fires after an Activity has been sent to an inbox.
*
* @param array $result The result of the remote post request.
* @param string $inbox The inbox URL.
* @param string $json The ActivityPub Activity JSON.
* @param int $actor_id The actor ID.
* @param int $outbox_item_id The Outbox item ID.
*/
\do_action( 'activitypub_sent_to_inbox', $result, $inbox, $json, $actor->get__id(), $outbox_item_id );
}
return $retries;
}
/**
* Schedule a retry.
*
* @param array $retries The inboxes to retry.
* @param int $outbox_item_id The Outbox item ID.
* @param int $attempt Optional. The attempt number. Default 1.
*/
private static function schedule_retry( $retries, $outbox_item_id, $attempt = 1 ) {
$transient_key = 'activitypub_retry_' . \wp_generate_password( 12, false );
\set_transient( $transient_key, $retries, WEEK_IN_SECONDS );
\wp_schedule_single_event(
\time() + ( $attempt * $attempt * HOUR_IN_SECONDS ),
'activitypub_async_batch',
array(
array( self::class, 'retry_send_to_followers' ),
$transient_key,
$outbox_item_id,
$attempt,
)
);
}
/**
* Send an Activity to a custom list of inboxes, like mentioned users or replied-to posts.
*
* For all custom implementations, please use the `activitypub_additional_inboxes` filter.
*
* @param Activity $activity The ActivityPub Activity.
* @param int $actor_id The actor ID.
* @param \WP_Post $outbox_item The WordPress object.
*/
private static function send_to_additional_inboxes( $activity, $actor_id, $outbox_item = null ) {
/**
* Filters the list of inboxes to send the Activity to.
*
* @param array $inboxes The list of inboxes to send to.
* @param int $actor_id The actor ID.
* @param Activity $activity The ActivityPub Activity.
*/
$inboxes = apply_filters( 'activitypub_additional_inboxes', array(), $actor_id, $activity );
$inboxes = array_unique( $inboxes );
$retries = self::send_to_inboxes( $inboxes, $outbox_item->ID );
// Retry failed inboxes.
if ( ! empty( $retries ) ) {
self::schedule_retry( $retries, $outbox_item->ID );
}
}
/**
* Default filter to add Inboxes of Mentioned Actors
*
* @param array $inboxes The list of Inboxes.
* @param int $actor_id The WordPress Actor-ID.
* @param Activity $activity The ActivityPub Activity.
*
* @return array The filtered Inboxes.
*/
public static function add_inboxes_by_mentioned_actors( $inboxes, $actor_id, $activity ) {
$cc = $activity->get_cc() ?? array();
$to = $activity->get_to() ?? array();
$audience = array_merge( $cc, $to );
// Remove "public placeholder" and "same domain" from the audience.
$audience = array_filter(
$audience,
function ( $actor ) {
return 'https://www.w3.org/ns/activitystreams#Public' !== $actor && ! is_same_domain( $actor );
}
);
if ( $audience ) {
$mentioned_inboxes = Mention::get_inboxes( $audience );
return array_merge( $inboxes, $mentioned_inboxes );
}
return $inboxes;
}
/**
* Default filter to add Inboxes of Posts that are set as `in-reply-to`
*
* @param array $inboxes The list of Inboxes.
* @param int $actor_id The WordPress Actor-ID.
* @param Activity $activity The ActivityPub Activity.
*
* @return array The filtered Inboxes
*/
public static function add_inboxes_of_replied_urls( $inboxes, $actor_id, $activity ) {
$in_reply_to = $activity->get_in_reply_to();
if ( ! $in_reply_to ) {
return $inboxes;
}
if ( ! is_array( $in_reply_to ) ) {
$in_reply_to = array( $in_reply_to );
}
foreach ( $in_reply_to as $url ) {
// No need to self-notify.
if ( is_same_domain( $url ) ) {
continue;
}
$object = Http::get_remote_object( $url );
if (
! $object ||
\is_wp_error( $object ) ||
empty( $object['attributedTo'] )
) {
continue;
}
$actor = object_to_uri( $object['attributedTo'] );
$actor = Http::get_remote_object( $actor );
if ( ! $actor || \is_wp_error( $actor ) ) {
continue;
}
if ( ! empty( $actor['endpoints']['sharedInbox'] ) ) {
$inboxes[] = $actor['endpoints']['sharedInbox'];
} elseif ( ! empty( $actor['inbox'] ) ) {
$inboxes[] = $actor['inbox'];
}
}
return $inboxes;
}
/**
* Adds Blog Actor inboxes to Updates so the Blog User's followers are notified of edits.
*
* @deprecated 5.2.0 Use {@see Followers::maybe_add_inboxes_of_blog_user} instead.
*
* @param array $inboxes The list of Inboxes.
* @param int $actor_id The WordPress Actor-ID.
* @param Activity $activity The ActivityPub Activity.
*
* @return array The filtered Inboxes.
*/
public static function maybe_add_inboxes_of_blog_user( $inboxes, $actor_id, $activity ) { // phpcs:ignore
_deprecated_function( __METHOD__, '5.2.0', 'Followers::maybe_add_inboxes_of_blog_user' );
return $inboxes;
}
/**
* Check if passed Activity is public.
*
* @param Activity $activity The Activity object.
* @param \Activitypub\Model\User|\Activitypub\Model\Blog $actor The Actor object.
* @param \WP_Post $outbox_item The Outbox item.
*
* @return boolean True if public, false if not.
*/
protected static function should_send_to_followers( $activity, $actor, $outbox_item ) {
// Check if follower endpoint is set.
$cc = $activity->get_cc() ?? array();
$to = $activity->get_to() ?? array();
$audience = array_merge( $cc, $to );
$send = (
// Check if activity is public.
in_array( 'https://www.w3.org/ns/activitystreams#Public', $audience, true ) ||
// ...or check if follower endpoint is set.
in_array( $actor->get_followers(), $audience, true )
);
if ( $send ) {
$followers = Followers::get_inboxes_for_activity( $activity->to_json(), $actor->get__id() );
// Only send if there are followers to send to.
$send = ! is_countable( $followers ) || 0 < count( $followers );
}
/**
* Filters whether to send an Activity to followers.
*
* @param bool $send_activity_to_followers Whether to send the Activity to followers.
* @param Activity $activity The ActivityPub Activity.
* @param int $actor_id The actor ID.
* @param \WP_Post $outbox_item The WordPress object.
*/
return apply_filters( 'activitypub_send_activity_to_followers', $send, $activity, $actor->get__id(), $outbox_item );
}
/**
* Add Inboxes of Relays.
*
* @param array $inboxes The list of Inboxes.
* @param int $actor_id The Actor-ID.
* @param Activity $activity The ActivityPub Activity.
*
* @return array The filtered Inboxes.
*/
public static function add_inboxes_of_relays( $inboxes, $actor_id, $activity ) {
// Check if follower endpoint is set.
$cc = $activity->get_cc() ?? array();
$to = $activity->get_to() ?? array();
$audience = array_merge( $cc, $to );
// Check if activity is public.
if ( ! in_array( 'https://www.w3.org/ns/activitystreams#Public', $audience, true ) ) {
return $inboxes;
}
$relays = \get_option( 'activitypub_relays', array() );
if ( empty( $relays ) ) {
return $inboxes;
}
return array_merge( $inboxes, $relays );
}
}

View File

@ -0,0 +1,263 @@
<?php
/**
* ActivityPub Embed Handler.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* Class to handle embedding ActivityPub content
*/
class Embed {
/**
* Initialize the embed handler
*/
public static function init() {
\add_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 );
\add_filter( 'oembed_dataparse', array( self::class, 'handle_filtered_oembed_result' ), 11, 3 );
\add_filter( 'oembed_request_post_id', array( self::class, 'register_fallback_hook' ) );
}
/**
* Get an ActivityPub embed HTML for a URL.
*
* @param string $url The URL to get the embed for.
* @param boolean $inline_css Whether to inline CSS. Default true.
*
* @return string|false The embed HTML or false if not found.
*/
public static function get_html( $url, $inline_css = true ) {
// Try to get ActivityPub representation.
$object = Http::get_remote_object( $url );
if ( is_wp_error( $object ) ) {
return false;
}
return self::get_html_for_object( $object, $inline_css );
}
/**
* Get an ActivityPub embed HTML for an ActivityPub object.
*
* @param array $activity_object The ActivityPub object to build the embed for.
* @param boolean $inline_css Whether to inline CSS. Default true.
*
* @return string The embed HTML.
*/
public static function get_html_for_object( $activity_object, $inline_css = true ) {
$author_name = $activity_object['attributedTo'] ?? '';
$avatar_url = $activity_object['icon']['url'] ?? '';
$author_url = $author_name;
// If we don't have an avatar URL, but we have an author URL, try to fetch it.
if ( ! $avatar_url && $author_url ) {
$author = Http::get_remote_object( $author_url );
if ( ! is_wp_error( $author ) ) {
$avatar_url = $author['icon']['url'] ?? '';
$author_name = $author['name'] ?? $author_name;
}
}
// Create Webfinger where not found.
if ( empty( $author['webfinger'] ) ) {
if ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
// Construct webfinger-style identifier from username and domain.
$domain = wp_parse_url( $author['url'], PHP_URL_HOST );
$author['webfinger'] = '@' . $author['preferredUsername'] . '@' . $domain;
} else {
// Fallback to URL.
$author['webfinger'] = $author_url;
}
}
$title = $activity_object['name'] ?? '';
$content = $activity_object['content'] ?? '';
$published = isset( $activity_object['published'] ) ? gmdate( get_option( 'date_format' ) . ', ' . get_option( 'time_format' ), strtotime( $activity_object['published'] ) ) : '';
$boosts = isset( $activity_object['shares']['totalItems'] ) ? (int) $activity_object['shares']['totalItems'] : null;
$favorites = isset( $activity_object['likes']['totalItems'] ) ? (int) $activity_object['likes']['totalItems'] : null;
$image = '';
if ( isset( $activity_object['image']['url'] ) ) {
$image = $activity_object['image']['url'];
} elseif ( isset( $activity_object['attachment'] ) ) {
foreach ( $activity_object['attachment'] as $attachment ) {
if ( isset( $attachment['type'] ) && in_array( $attachment['type'], array( 'Image', 'Document' ), true ) ) {
$image = $attachment['url'];
break;
}
}
}
ob_start();
load_template(
ACTIVITYPUB_PLUGIN_DIR . 'templates/reply-embed.php',
false,
array(
'author_name' => $author_name,
'author_url' => $author_url,
'avatar_url' => $avatar_url,
'published' => $published,
'title' => $title,
'content' => $content,
'image' => $image,
'boosts' => $boosts,
'favorites' => $favorites,
'url' => $activity_object['id'],
'webfinger' => $author['webfinger'],
)
);
if ( $inline_css ) {
// Grab the CSS.
$css = \file_get_contents( ACTIVITYPUB_PLUGIN_DIR . 'assets/css/activitypub-embed.css' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
// We embed CSS directly because this may be in an iframe.
printf( '<style>%s</style>', $css ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
// A little light whitespace cleanup.
return preg_replace( '/\s+/', ' ', ob_get_clean() );
}
/**
* Check if a real oEmbed result exists for the given URL.
*
* @param string $url The URL to check.
* @param array $args Additional arguments passed to wp_oembed_get().
* @return bool True if a real oEmbed result exists, false otherwise.
*/
public static function has_real_oembed( $url, $args = array() ) {
// Temporarily remove our filter to avoid infinite loops.
\remove_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 );
// Try to get a "real" oEmbed result. If found, it'll be cached to avoid unnecessary HTTP requests in `wp_oembed_get`.
$oembed_result = \wp_oembed_get( $url, $args );
// Add our filter back.
\add_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 );
return false !== $oembed_result;
}
/**
* Filter the oembed result to handle ActivityPub content when no oEmbed is found.
* Implementation is a bit weird because there's no way to filter on a false result, we have to use `pre_oembed_result`.
*
* @param null|string $result The UNSANITIZED (and potentially unsafe) HTML that should be used to embed.
* @param string $url The URL to the content that should be attempted to be embedded.
* @param array $args Additional arguments passed to wp_oembed_get().
* @return null|string Return null to allow normal oEmbed processing, or string for ActivityPub embed.
*/
public static function maybe_use_activitypub_embed( $result, $url, $args ) {
// If we already have a result, return it.
if ( null !== $result ) {
return $result;
}
// If we found a real oEmbed, return null to allow normal processing.
if ( self::has_real_oembed( $url, $args ) ) {
return null;
}
// No oEmbed found, try to get ActivityPub representation.
$html = get_embed_html( $url );
// If we couldn't get an ActivityPub embed either, return null to allow normal processing.
if ( ! $html ) {
return null;
}
// Return the ActivityPub embed HTML.
return $html;
}
/**
* Handle cases where WordPress has filtered out the oEmbed result for security reasons,
* but we can provide a safe ActivityPub-specific markup.
*
* This runs after wp_filter_oembed_result has potentially nullified the result.
*
* @param string|false $html The returned oEmbed HTML.
* @param object $data A data object result from an oEmbed provider.
* @param string $url The URL of the content to be embedded.
* @return string|false The filtered oEmbed HTML or our ActivityPub embed.
*/
public static function handle_filtered_oembed_result( $html, $data, $url ) {
// If we already have valid HTML, return it.
if ( $html ) {
return $html;
}
// If this isn't a rich or video type, we can't help.
if ( ! isset( $data->type ) || ! \in_array( $data->type, array( 'rich', 'video' ), true ) ) {
return $html;
}
// If there's no HTML in the data, we can't help.
if ( empty( $data->html ) || ! \is_string( $data->html ) ) {
return $html;
}
// Try to get ActivityPub representation.
$activitypub_html = get_embed_html( $url );
if ( ! $activitypub_html ) {
return $html;
}
// Return our safer ActivityPub embed HTML.
return $activitypub_html;
}
/**
* Register the fallback hook for oEmbed requests.
*
* Avoids filtering every single API request.
*
* @param int $post_id The post ID.
* @return int The post ID.
*/
public static function register_fallback_hook( $post_id ) {
\add_filter( 'rest_request_after_callbacks', array( self::class, 'oembed_fediverse_fallback' ), 10, 3 );
return $post_id;
}
/**
* Fallback for oEmbed requests to the Fediverse.
*
* @param \WP_REST_Response|\WP_Error $response Result to send to the client.
* @param array $handler Route handler used for the request.
* @param \WP_REST_Request $request Request used to generate the response.
*
* @return \WP_REST_Response|\WP_Error The response to send to the client.
*/
public static function oembed_fediverse_fallback( $response, $handler, $request ) {
if ( is_wp_error( $response ) && 'oembed_invalid_url' === $response->get_error_code() ) {
$url = $request->get_param( 'url' );
$html = get_embed_html( $url );
if ( $html ) {
$args = $request->get_params();
$data = (object) array(
'provider_name' => 'Embed Handler',
'html' => $html,
'scripts' => array(),
);
/** This filter is documented in wp-includes/class-wp-oembed.php */
$data->html = apply_filters( 'oembed_result', $data->html, $url, $args );
/** This filter is documented in wp-includes/class-wp-oembed-controller.php */
$ttl = apply_filters( 'rest_oembed_ttl', DAY_IN_SECONDS, $url, $args );
set_transient( 'oembed_' . md5( serialize( $args ) ), $data, $ttl ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
$response = new \WP_REST_Response( $data );
}
}
return $response;
}
}

View File

@ -1,10 +1,18 @@
<?php
/**
* Handler class.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Handler\Announce;
use Activitypub\Handler\Create;
use Activitypub\Handler\Delete;
use Activitypub\Handler\Follow;
use Activitypub\Handler\Like;
use Activitypub\Handler\Move;
use Activitypub\Handler\Undo;
use Activitypub\Handler\Update;
@ -13,7 +21,7 @@ use Activitypub\Handler\Update;
*/
class Handler {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_handlers();
@ -29,7 +37,14 @@ class Handler {
Follow::init();
Undo::init();
Update::init();
Like::init();
Move::init();
/**
* Register additional handlers.
*
* @since 1.3.0
*/
do_action( 'activitypub_register_handlers' );
}
}

View File

@ -1,117 +1,108 @@
<?php
/**
* Hashtag Class.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* ActivityPub Hashtag Class
* ActivityPub Hashtag Class.
*
* @author Matthias Pfefferle
*/
class Hashtag {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
if ( '1' === \get_option( 'activitypub_use_hashtags', '1' ) ) {
\add_action( 'wp_insert_post', array( self::class, 'insert_post' ), 10, 2 );
\add_filter( 'the_content', array( self::class, 'the_content' ), 10, 1 );
\add_filter( 'the_content', array( self::class, 'the_content' ) );
\add_filter( 'activitypub_activity_object_array', array( self::class, 'filter_activity_object' ), 99 );
}
}
/**
* Filter to save #tags as real WordPress tags
* Filter only the activity object and replace summery it with URLs.
*
* @param int $id the rev-id
* @param WP_Post $post the post
* @param array $activity The activity object array.
*
* @return
* @return array The filtered activity object array.
*/
public static function insert_post( $id, $post ) {
if ( \preg_match_all( '/' . ACTIVITYPUB_HASHTAGS_REGEXP . '/i', $post->post_content, $match ) ) {
$tags = \implode( ', ', $match[1] );
\wp_add_post_tags( $post->post_parent, $tags );
public static function filter_activity_object( $activity ) {
/* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
Only changed it for Person and Group as long is not merged: https://github.com/mastodon/mastodon/pull/28629
*/
if ( ! empty( $activity['summary'] ) && in_array( $activity['type'], array( 'Person', 'Group' ), true ) ) {
$activity['summary'] = self::the_content( $activity['summary'] );
}
return $id;
if ( ! empty( $activity['content'] ) ) {
$activity['content'] = self::the_content( $activity['content'] );
}
return $activity;
}
/**
* Filter to replace the #tags in the content with links
* Filter to save #tags as real WordPress tags.
*
* @param string $the_content the post-content
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
*/
public static function insert_post( $post_id, $post ) {
// Check if the post supports ActivityPub.
if ( ! \post_type_supports( \get_post_type( $post ), 'activitypub' ) ) {
return;
}
// Check if the (custom) post supports tags.
$taxonomies = \get_object_taxonomies( $post );
if ( ! in_array( 'post_tag', $taxonomies, true ) ) {
return;
}
$tags = array();
// Skip hashtags in HTML attributes, like hex colors.
$content = wp_strip_all_tags( $post->post_content . "\n" . $post->post_excerpt );
if ( \preg_match_all( '/' . ACTIVITYPUB_HASHTAGS_REGEXP . '/i', $content, $match ) ) {
$tags = array_unique( $match[1] );
}
\wp_add_post_tags( $post->ID, \implode( ', ', $tags ) );
}
/**
* Filter to replace the #tags in the content with links.
*
* @return string the filtered post-content
* @param string $the_content The post content.
*
* @return string The filtered post content.
*/
public static function the_content( $the_content ) {
// small protection against execution timeouts: limit to 1 MB
if ( mb_strlen( $the_content ) > MB_IN_BYTES ) {
return $the_content;
}
$tag_stack = array();
$protected_tags = array(
'pre',
'code',
'textarea',
'style',
'a',
);
$content_with_links = '';
$in_protected_tag = false;
foreach ( wp_html_split( $the_content ) as $chunk ) {
if ( preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
$content_with_links .= $chunk;
continue;
}
if ( preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
$tag = strtolower( $m[2] );
if ( '/' === $m[1] ) {
// Closing tag.
$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 );
}
} else {
// Opening tag, add it to the stack.
$tag_stack[] = $tag;
}
// If we're in a protected tag, the tag_stack contains at least one protected tag string.
// The protected tag state can only change when we encounter a start or end tag.
$in_protected_tag = array_intersect( $tag_stack, $protected_tags );
// Never inspect tags.
$content_with_links .= $chunk;
continue;
}
if ( $in_protected_tag ) {
// Don't inspect a chunk inside an inspected tag.
$content_with_links .= $chunk;
continue;
}
// Only reachable when there is no protected tag in the stack.
$content_with_links .= \preg_replace_callback( '/' . ACTIVITYPUB_HASHTAGS_REGEXP . '/i', array( '\Activitypub\Hashtag', 'replace_with_links' ), $chunk );
}
return $content_with_links;
return enrich_content_data( $the_content, '/' . ACTIVITYPUB_HASHTAGS_REGEXP . '/i', array( self::class, 'replace_with_links' ) );
}
/**
* A callback for preg_replace to build the term links
* A callback for preg_replace to build the term links.
*
* @param array $result the preg_match results
* @param array $result The preg_match results.
* @return string the final string
*/
public static function replace_with_links( $result ) {
$tag = $result[1];
$tag = $result[1];
$tag_object = \get_term_by( 'name', $tag, 'post_tag' );
if ( ! $tag_object ) {
$tag_object = \get_term_by( 'name', $tag, 'category' );
}
if ( $tag_object ) {
$link = \get_term_link( $tag_object, 'post_tag' );
return \sprintf( '<a rel="tag" class="hashtag u-tag u-category" href="%s">#%s</a>', $link, $tag );
return \sprintf( '<a rel="tag" class="hashtag u-tag u-category" href="%s">#%s</a>', esc_url( $link ), $tag );
}
return '#' . $tag;

View File

@ -1,365 +0,0 @@
<?php
namespace Activitypub;
use WP_Error;
use Activitypub\Webfinger;
use Activitypub\Collection\Users;
use function Activitypub\get_plugin_version;
use function Activitypub\is_user_type_disabled;
use function Activitypub\get_webfinger_resource;
/**
* ActivityPub Health_Check Class
*
* @author Matthias Pfefferle
*/
class Health_Check {
/**
* Initialize health checks
*
* @return void
*/
public static function init() {
\add_filter( 'site_status_tests', array( self::class, 'add_tests' ) );
\add_filter( 'debug_information', array( self::class, 'debug_information' ) );
}
public static function add_tests( $tests ) {
if ( ! is_user_disabled( get_current_user_id() ) ) {
$tests['direct']['activitypub_test_author_url'] = array(
'label' => \__( 'Author URL test', 'activitypub' ),
'test' => array( self::class, 'test_author_url' ),
);
}
$tests['direct']['activitypub_test_webfinger'] = array(
'label' => __( 'WebFinger Test', 'activitypub' ),
'test' => array( self::class, 'test_webfinger' ),
);
return $tests;
}
/**
* Author URL tests
*
* @return array
*/
public static function test_author_url() {
$result = array(
'label' => \__( 'Author URL accessible', 'activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'ActivityPub', 'activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\__( 'Your author URL is accessible and supports the required "Accept" header.', 'activitypub' )
),
'actions' => '',
'test' => 'test_author_url',
);
$check = self::is_author_url_accessible();
if ( true === $check ) {
return $result;
}
$result['status'] = 'critical';
$result['label'] = \__( 'Author URL is not accessible', 'activitypub' );
$result['badge']['color'] = 'red';
$result['description'] = \sprintf(
'<p>%s</p>',
$check->get_error_message()
);
return $result;
}
/**
* System Cron tests
*
* @return array
*/
public static function test_system_cron() {
$result = array(
'label' => \__( 'System Task Scheduler configured', 'activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'ActivityPub', 'activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\esc_html__( 'You seem to use the System Task Scheduler to process WP_Cron tasks.', 'activitypub' )
),
'actions' => '',
'test' => 'test_system_cron',
);
if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
return $result;
}
$result['status'] = 'recommended';
$result['label'] = \__( 'System Task Scheduler not configured', 'activitypub' );
$result['badge']['color'] = 'orange';
$result['description'] = \sprintf(
'<p>%s</p>',
\__( 'Enhance your WordPress sites performance and mitigate potential heavy loads caused by plugins like ActivityPub by setting up a system cron job to run WP Cron. This ensures scheduled tasks are executed consistently and reduces the reliance on website traffic for trigger events.', 'activitypub' )
);
$result['actions'] .= sprintf(
'<p><a href="%s" target="_blank" rel="noopener">%s<span class="screen-reader-text"> %s</span><span aria-hidden="true" class="dashicons dashicons-external"></span></a></p>',
__( 'https://developer.wordpress.org/plugins/cron/hooking-wp-cron-into-the-system-task-scheduler/', 'activitypub' ),
__( 'Learn how to hook the WP-Cron into the System Task Scheduler.', 'activitypub' ),
/* translators: Hidden accessibility text. */
__( '(opens in a new tab)', 'activitypub' )
);
return $result;
}
/**
* WebFinger tests
*
* @return array
*/
public static function test_webfinger() {
$result = array(
'label' => \__( 'WebFinger endpoint', 'activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'ActivityPub', 'activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\__( 'Your WebFinger endpoint is accessible and returns the correct information.', 'activitypub' )
),
'actions' => '',
'test' => 'test_webfinger',
);
$check = self::is_webfinger_endpoint_accessible();
if ( true === $check ) {
return $result;
}
$result['status'] = 'critical';
$result['label'] = \__( 'WebFinger endpoint is not accessible', 'activitypub' );
$result['badge']['color'] = 'red';
$result['description'] = \sprintf(
'<p>%s</p>',
$check->get_error_message()
);
return $result;
}
/**
* Check if `author_posts_url` is accessible and that request returns correct JSON
*
* @return boolean|WP_Error
*/
public static function is_author_url_accessible() {
$user = \wp_get_current_user();
$author_url = \get_author_posts_url( $user->ID );
$reference_author_url = self::get_author_posts_url( $user->ID, $user->user_nicename );
// check for "author" in URL
if ( $author_url !== $reference_author_url ) {
return new WP_Error(
'author_url_not_accessible',
\sprintf(
// translators: %s: Author URL
\__(
'Your author URL <code>%s</code> was replaced, this is often done by plugins.',
'activitypub'
),
$author_url
)
);
}
// try to access author URL
$response = \wp_remote_get(
$author_url,
array(
'headers' => array( 'Accept' => 'application/activity+json' ),
'redirection' => 0,
)
);
if ( \is_wp_error( $response ) ) {
return new WP_Error(
'author_url_not_accessible',
\sprintf(
// translators: %s: Author URL
\__(
'Your author URL <code>%s</code> is not accessible. Please check your WordPress setup or permalink structure. If the setup seems fine, maybe check if a plugin might restrict the access.',
'activitypub'
),
$author_url
)
);
}
$response_code = \wp_remote_retrieve_response_code( $response );
// check for redirects
if ( \in_array( $response_code, array( 301, 302, 307, 308 ), true ) ) {
return new WP_Error(
'author_url_not_accessible',
\sprintf(
// translators: %s: Author URL
\__(
'Your author URL <code>%s</code> is redirecting to another page, this is often done by SEO plugins like "Yoast SEO".',
'activitypub'
),
$author_url
)
);
}
// check if response is JSON
$body = \wp_remote_retrieve_body( $response );
if ( ! \is_string( $body ) || ! \is_array( \json_decode( $body, true ) ) ) {
return new WP_Error(
'author_url_not_accessible',
\sprintf(
// translators: %s: Author URL
\__(
'Your author URL <code>%s</code> does not return valid JSON for <code>application/activity+json</code>. Please check if your hosting supports alternate <code>Accept</code> headers.',
'activitypub'
),
$author_url
)
);
}
return true;
}
/**
* Check if WebFinger endpoint is accessible and profile request returns correct JSON
*
* @return boolean|WP_Error
*/
public static function is_webfinger_endpoint_accessible() {
$user = Users::get_by_id( Users::APPLICATION_USER_ID );
$resource = $user->get_webfinger();
$url = Webfinger::resolve( $resource );
if ( \is_wp_error( $url ) ) {
$allowed = array( 'code' => array() );
$not_accessible = wp_kses(
// translators: %s: Author URL
\__(
'Your WebFinger endpoint <code>%s</code> is not accessible. Please check your WordPress setup or permalink structure.',
'activitypub'
),
$allowed
);
$invalid_response = wp_kses(
// translators: %s: Author URL
\__(
'Your WebFinger endpoint <code>%s</code> does not return valid JSON for <code>application/jrd+json</code>.',
'activitypub'
),
$allowed
);
$health_messages = array(
'webfinger_url_not_accessible' => \sprintf(
$not_accessible,
$url->get_error_data()
),
'webfinger_url_invalid_response' => \sprintf(
// translators: %s: Author URL
$invalid_response,
$url->get_error_data()
),
);
$message = null;
if ( isset( $health_messages[ $url->get_error_code() ] ) ) {
$message = $health_messages[ $url->get_error_code() ];
}
return new WP_Error(
$url->get_error_code(),
$message,
$url->get_error_data()
);
}
return true;
}
/**
* Retrieve the URL to the author page for the user with the ID provided.
*
* @global WP_Rewrite $wp_rewrite WordPress rewrite component.
*
* @param int $author_id Author ID.
* @param string $author_nicename Optional. The author's nicename (slug). Default empty.
*
* @return string The URL to the author's page.
*/
public static function get_author_posts_url( $author_id, $author_nicename = '' ) {
global $wp_rewrite;
$auth_id = (int) $author_id;
$link = $wp_rewrite->get_author_permastruct();
if ( empty( $link ) ) {
$file = home_url( '/' );
$link = $file . '?author=' . $auth_id;
} else {
if ( '' === $author_nicename ) {
$user = get_userdata( $author_id );
if ( ! empty( $user->user_nicename ) ) {
$author_nicename = $user->user_nicename;
}
}
$link = str_replace( '%author%', $author_nicename, $link );
$link = home_url( user_trailingslashit( $link ) );
}
return $link;
}
/**
* Static function for generating site debug data when required.
*
* @param array $info The debug information to be added to the core information page.
* @return array The filtered information
*/
public static function debug_information( $info ) {
$info['activitypub'] = array(
'label' => __( 'ActivityPub', 'activitypub' ),
'fields' => array(
'webfinger' => array(
'label' => __( 'WebFinger Resource', 'activitypub' ),
'value' => Webfinger::get_user_resource( wp_get_current_user()->ID ),
'private' => true,
),
'author_url' => array(
'label' => __( 'Author URL', 'activitypub' ),
'value' => get_author_posts_url( wp_get_current_user()->ID ),
'private' => true,
),
'plugin_version' => array(
'label' => __( 'Plugin Version', 'activitypub' ),
'value' => get_plugin_version(),
'private' => true,
),
),
);
return $info;
}
}

View File

@ -1,10 +1,14 @@
<?php
/**
* ActivityPub HTTP Class.
*
* @package Activitypub
*/
namespace Activitypub;
use WP_Error;
use Activitypub\Collection\Users;
use function Activitypub\get_masked_wp_version;
use Activitypub\Collection\Actors;
/**
* ActivityPub HTTP Class
@ -15,63 +19,90 @@ class Http {
/**
* Send a POST Request with the needed HTTP Headers
*
* @param string $url The URL endpoint
* @param string $body The Post Body
* @param int $user_id The WordPress User-ID
* @param string $url The URL endpoint.
* @param string $body The Post Body.
* @param int $user_id The WordPress User-ID.
*
* @return array|WP_Error The POST Response or an WP_ERROR
* @return array|WP_Error The POST Response or an WP_Error.
*/
public static function post( $url, $body, $user_id ) {
/**
* Fires before an HTTP POST request is made.
*
* @param string $url The URL endpoint.
* @param string $body The POST body.
* @param int $user_id The WordPress User ID.
*/
\do_action( 'activitypub_pre_http_post', $url, $body, $user_id );
$date = \gmdate( 'D, d M Y H:i:s T' );
$digest = Signature::generate_digest( $body );
$date = \gmdate( 'D, d M Y H:i:s T' );
$digest = Signature::generate_digest( $body );
$signature = Signature::generate_signature( $user_id, 'post', $url, $date, $digest );
$wp_version = get_masked_wp_version();
/**
* Filter the HTTP headers user agent.
* Filters the HTTP headers user agent string.
*
* @param string $user_agent The user agent string.
*/
$user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
$args = array(
'timeout' => 100,
$args = array(
'timeout' => 100,
'limit_response_size' => 1048576,
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'Content-Type' => 'application/activity+json',
'Digest' => $digest,
'Signature' => $signature,
'Date' => $date,
'Digest' => $digest,
'Signature' => $signature,
'Date' => $date,
),
'body' => $body,
'body' => $body,
);
$response = \wp_safe_remote_post( $url, $args );
$code = \wp_remote_retrieve_response_code( $response );
if ( $code >= 400 ) {
$response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
$response = new WP_Error(
$code,
__( 'Failed HTTP Request', 'activitypub' ),
array(
'status' => $code,
'response' => $response,
)
);
}
/**
* Action to save the response of the remote POST request.
*
* @param array|WP_Error $response The response of the remote POST request.
* @param string $url The URL endpoint.
* @param string $body The Post Body.
* @param int $user_id The WordPress User-ID.
*/
\do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id );
return $response;
}
/**
* Send a GET Request with the needed HTTP Headers
* Send a GET Request with the needed HTTP Headers.
*
* @param string $url The URL endpoint
* @param bool|int $cached If the result should be cached, or its duration. Default: 1hr.
* @param string $url The URL endpoint.
* @param bool|int $cached Optional. Whether the result should be cached, or its duration. Default false.
*
* @return array|WP_Error The GET Response or an WP_ERROR
* @return array|WP_Error The GET Response or a WP_Error.
*/
public static function get( $url, $cached = false ) {
/**
* Fires before an HTTP GET request is made.
*
* @param string $url The URL endpoint.
*/
\do_action( 'activitypub_pre_http_get', $url );
if ( $cached ) {
@ -80,34 +111,50 @@ class Http {
$response = \get_transient( $transient_key );
if ( $response ) {
/**
* Action to save the response of the remote GET request.
*
* @param array|WP_Error $response The response of the remote GET request.
* @param string $url The URL endpoint.
*/
\do_action( 'activitypub_safe_remote_get_response', $response, $url );
return $response;
}
}
$date = \gmdate( 'D, d M Y H:i:s T' );
$signature = Signature::generate_signature( Users::APPLICATION_USER_ID, 'get', $url, $date );
$date = \gmdate( 'D, d M Y H:i:s T' );
$signature = Signature::generate_signature( Actors::APPLICATION_USER_ID, 'get', $url, $date );
$wp_version = get_masked_wp_version();
/**
* Filter the HTTP headers user agent.
* Filters the HTTP headers user agent string.
*
* This filter allows developers to modify the user agent string that is
* sent with HTTP requests.
*
* @param string $user_agent The user agent string.
*/
$user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
/**
* Filters the timeout duration for remote GET requests in ActivityPub.
*
* @param int $timeout The timeout value in seconds. Default 100 seconds.
*/
$timeout = \apply_filters( 'activitypub_remote_get_timeout', 100 );
$args = array(
'timeout' => apply_filters( 'activitypub_remote_get_timeout', 100 ),
'timeout' => $timeout,
'limit_response_size' => 1048576,
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'Content-Type' => 'application/activity+json',
'Signature' => $signature,
'Date' => $date,
'Signature' => $signature,
'Date' => $date,
),
);
@ -118,6 +165,12 @@ class Http {
$response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
}
/**
* Action to save the response of the remote GET request.
*
* @param array|WP_Error $response The response of the remote GET request.
* @param string $url The URL endpoint.
*/
\do_action( 'activitypub_safe_remote_get_response', $response, $url );
if ( $cached ) {
@ -139,49 +192,50 @@ class Http {
* @return bool True if the URL is a tombstone.
*/
public static function is_tombstone( $url ) {
/**
* Fires before checking if the URL is a tombstone.
*
* @param string $url The URL to check.
*/
\do_action( 'activitypub_pre_http_is_tombstone', $url );
$response = \wp_safe_remote_get( $url );
$response = \wp_safe_remote_get( $url, array( 'headers' => array( 'Accept' => 'application/activity+json' ) ) );
$code = \wp_remote_retrieve_response_code( $response );
if ( in_array( (int) $code, array( 404, 410 ), true ) ) {
return true;
}
$data = \wp_remote_retrieve_body( $response );
$data = \json_decode( $data, true );
if ( $data && isset( $data['type'] ) && 'Tombstone' === $data['type'] ) {
return true;
}
return false;
}
/**
* Generate a cache key for the URL.
*
* @param string $url The URL to generate the cache key for.
*
* @return string The cache key.
*/
public static function generate_cache_key( $url ) {
return 'activitypub_http_' . \md5( $url );
}
/**
* Requests the Data from the Object-URL or Object-Array
* Requests the Data from the Object-URL or Object-Array.
*
* @param array|string $url_or_object The Object or the Object URL.
* @param bool $cached If the result should be cached.
* @param bool $cached Optional. Whether the result should be cached. Default true.
*
* @return array|WP_Error The Object data as array or WP_Error on failure.
*/
public static function get_remote_object( $url_or_object, $cached = true ) {
if ( is_array( $url_or_object ) ) {
if ( array_key_exists( 'id', $url_or_object ) ) {
$url = $url_or_object['id'];
} elseif ( array_key_exists( 'url', $url_or_object ) ) {
$url = $url_or_object['url'];
} else {
return new WP_Error(
'activitypub_no_valid_actor_identifier',
\__( 'The "actor" identifier is not valid', 'activitypub' ),
array(
'status' => 404,
'object' => $url_or_object,
)
);
}
} else {
$url = $url_or_object;
}
$url = object_to_uri( $url_or_object );
if ( preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $url ) ) {
$url = Webfinger::resolve( $url );
@ -204,7 +258,7 @@ class Http {
$transient_key = self::generate_cache_key( $url );
// only check the cache if needed.
// Only check the cache if needed.
if ( $cached ) {
$data = \get_transient( $transient_key );

View File

@ -0,0 +1,132 @@
<?php
/**
* Link class.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* ActivityPub Summery Links Class.
*/
class Link {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_extra_field_content', array( self::class, 'the_content' ) );
\add_filter( 'activitypub_activity_object_array', array( self::class, 'filter_activity_object' ), 99 );
}
/**
* Filter only the activity object and replace the summary with URLs.
*
* @param array $activity The activity object array.
*
* @return array Rhe activity object array.
*/
public static function filter_activity_object( $activity ) {
/* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
Only changed it for Person and Group as long is not merged: https://github.com/mastodon/mastodon/pull/28629
*/
if ( ! empty( $activity['summary'] ) && in_array( $activity['type'], array( 'Person', 'Group' ), true ) ) {
$activity['summary'] = self::the_content( $activity['summary'] );
}
if ( ! empty( $activity['content'] ) ) {
$activity['content'] = self::the_content( $activity['content'] );
}
return $activity;
}
/**
* Filter to replace the URLS in the content with links
*
* @param string $the_content The post content.
*
* @return string the filtered post content.
*/
public static function the_content( $the_content ) {
return enrich_content_data( $the_content, '/' . ACTIVITYPUB_URL_REGEXP . '/i', array( self::class, 'replace_with_links' ) );
}
/**
* A callback for preg_replace to build the links.
*
* Link shortening https://docs.joinmastodon.org/api/guidelines/#links
*
* @param array $result The preg_match results.
*
* @return string The final string.
*/
public static function replace_with_links( $result ) {
if ( 'www.' === substr( $result[0], 0, 4 ) ) {
$result[0] = 'https://' . $result[0];
}
$parsed_url = \wp_parse_url( html_entity_decode( $result[0] ) );
if ( ! $parsed_url || empty( $parsed_url['host'] ) ) {
return $result[0];
}
if ( empty( $parsed_url['scheme'] ) ) {
$invisible_prefix = 'https://';
} else {
$invisible_prefix = $parsed_url['scheme'] . '://';
}
if ( ! empty( $parsed_url['user'] ) ) {
$invisible_prefix .= $parsed_url['user'];
}
if ( ! empty( $parsed_url['pass'] ) ) {
$invisible_prefix .= ':' . $parsed_url['pass'];
}
if ( ! empty( $parsed_url['user'] ) ) {
$invisible_prefix .= '@';
}
$text_url = $parsed_url['host'];
if ( 'www.' === substr( $text_url, 0, 4 ) ) {
$text_url = substr( $text_url, 4 );
$invisible_prefix .= 'www.';
}
if ( ! empty( $parsed_url['port'] ) ) {
$text_url .= ':' . $parsed_url['port'];
}
if ( ! empty( $parsed_url['path'] ) ) {
$text_url .= $parsed_url['path'];
}
if ( ! empty( $parsed_url['query'] ) ) {
$text_url .= '?' . $parsed_url['query'];
}
if ( ! empty( $parsed_url['fragment'] ) ) {
$text_url .= '#' . $parsed_url['fragment'];
}
$display = \substr( $text_url, 0, 30 );
$invisible_suffix = \substr( $text_url, 30 );
$display_class = '';
if ( $invisible_suffix ) {
$display_class .= 'ellipsis';
}
/**
* Filters the rel attribute for ActivityPub links.
*
* @param string $rel The rel attribute string. Default 'nofollow noopener noreferrer'.
*/
$rel = apply_filters( 'activitypub_link_rel', 'nofollow noopener noreferrer' );
return \sprintf(
'<a href="%s" target="_blank" rel="%s" translate="no"><span class="invisible">%s</span><span class="%s">%s</span><span class="invisible">%s</span></a>',
esc_url( $result[0] ),
$rel,
esc_html( $invisible_prefix ),
$display_class,
esc_html( $display ),
esc_html( $invisible_suffix )
);
}
}

View File

@ -0,0 +1,337 @@
<?php
/**
* Mailer Class.
*
* @package ActivityPub
*/
namespace Activitypub;
use Activitypub\Collection\Actors;
/**
* Mailer Class.
*/
class Mailer {
/**
* Initialize the Mailer.
*/
public static function init() {
\add_filter( 'comment_notification_subject', array( self::class, 'comment_notification_subject' ), 10, 2 );
\add_filter( 'comment_notification_text', array( self::class, 'comment_notification_text' ), 10, 2 );
\add_action( 'activitypub_inbox_follow', array( self::class, 'new_follower' ), 10, 2 );
\add_action( 'activitypub_inbox_create', array( self::class, 'direct_message' ), 10, 2 );
\add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 10, 2 );
}
/**
* Filter the subject line for Like and Announce notifications.
*
* @param string $subject The default subject line.
* @param int|string $comment_id The comment ID.
*
* @return string The filtered subject line.
*/
public static function comment_notification_subject( $subject, $comment_id ) {
$comment = \get_comment( $comment_id );
if ( ! $comment ) {
return $subject;
}
$type = \get_comment_meta( $comment->comment_ID, 'protocol', true );
if ( 'activitypub' !== $type ) {
return $subject;
}
$singular = Comment::get_comment_type_attr( $comment->comment_type, 'singular' );
if ( ! $singular ) {
return $subject;
}
$post = \get_post( $comment->comment_post_ID );
/* translators: 1: Blog name, 2: Like or Repost, 3: Post title */
return \sprintf( \esc_html__( '[%1$s] %2$s: %3$s', 'activitypub' ), \esc_html( get_option( 'blogname' ) ), \esc_html( $singular ), \esc_html( $post->post_title ) );
}
/**
* Filter the notification text for Like and Announce notifications.
*
* @param string $message The default notification text.
* @param int|string $comment_id The comment ID.
*
* @return string The filtered notification text.
*/
public static function comment_notification_text( $message, $comment_id ) {
$comment = \get_comment( $comment_id );
if ( ! $comment ) {
return $message;
}
$type = \get_comment_meta( $comment->comment_ID, 'protocol', true );
if ( 'activitypub' !== $type ) {
return $message;
}
$comment_type = Comment::get_comment_type( $comment->comment_type );
if ( ! $comment_type ) {
return $message;
}
$post = \get_post( $comment->comment_post_ID );
$comment_author_domain = \gethostbyaddr( $comment->comment_author_IP );
/* translators: 1: Comment type, 2: Post title */
$notify_message = \sprintf( html_entity_decode( esc_html__( 'New %1$s on your post &#8220;%2$s&#8221;.', 'activitypub' ) ), \esc_html( $comment_type['singular'] ), \esc_html( $post->post_title ) ) . "\r\n\r\n";
/* translators: 1: Website name, 2: Website IP address, 3: Website hostname. */
$notify_message .= \sprintf( \esc_html__( 'From: %1$s (IP address: %2$s, %3$s)', 'activitypub' ), \esc_html( $comment->comment_author ), \esc_html( $comment->comment_author_IP ), \esc_html( $comment_author_domain ) ) . "\r\n";
/* translators: Reaction author URL. */
$notify_message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $comment->comment_author_url ) ) . "\r\n\r\n";
/* translators: Comment type label */
$notify_message .= \sprintf( \esc_html__( 'You can see all %s on this post here:', 'activitypub' ), \esc_html( $comment_type['label'] ) ) . "\r\n";
$notify_message .= \get_permalink( $comment->comment_post_ID ) . '#' . \esc_attr( $comment_type['type'] ) . "\r\n\r\n";
return $notify_message;
}
/**
* Send a notification email for every new follower.
*
* @param array $activity The activity object.
* @param int $user_id The id of the local blog-user.
*/
public static function new_follower( $activity, $user_id ) {
if ( $user_id > Actors::BLOG_USER_ID ) {
if ( ! \get_user_option( 'activitypub_mailer_new_follower', $user_id ) ) {
return;
}
$email = \get_userdata( $user_id )->user_email;
$admin_url = '/users.php?page=activitypub-followers-list';
} else {
if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_follower', '1' ) ) {
return;
}
$email = \get_option( 'admin_email' );
$admin_url = '/options-general.php?page=activitypub&tab=followers';
}
$actor = get_remote_metadata_by_actor( $activity['actor'] );
if ( ! $actor || \is_wp_error( $actor ) ) {
return;
}
if ( empty( $actor['webfinger'] ) ) {
$actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
}
$template_args = array_merge(
$actor,
array(
'admin_url' => $admin_url,
'user_id' => $user_id,
'stats' => array(
'outbox' => null,
'followers' => null,
'following' => null,
),
)
);
foreach ( $template_args['stats'] as $field => $value ) {
if ( empty( $actor[ $field ] ) ) {
continue;
}
$result = Http::get( $actor[ $field ], true );
if ( 200 === \wp_remote_retrieve_response_code( $result ) ) {
$body = \json_decode( \wp_remote_retrieve_body( $result ), true );
if ( isset( $body['totalItems'] ) ) {
$template_args['stats'][ $field ] = $body['totalItems'];
}
}
}
/* translators: 1: Blog name, 2: Follower name */
$subject = \sprintf( \__( '[%1$s] New Follower: %2$s', 'activitypub' ), \get_option( 'blogname' ), $actor['name'] );
\ob_start();
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-follower.php', false, $template_args );
$html_message = \ob_get_clean();
$alt_function = function ( $mailer ) use ( $actor, $admin_url ) {
/* translators: 1: Follower name */
$message = \sprintf( \__( 'New Follower: %1$s.', 'activitypub' ), $actor['name'] ) . "\r\n\r\n";
/* translators: Follower URL */
$message .= \sprintf( \__( 'URL: %s', 'activitypub' ), \esc_url( $actor['url'] ) ) . "\r\n\r\n";
$message .= \__( 'You can see all followers here:', 'activitypub' ) . "\r\n";
$message .= \esc_url( \admin_url( $admin_url ) ) . "\r\n\r\n";
$mailer->{'AltBody'} = $message;
};
\add_action( 'phpmailer_init', $alt_function );
\wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
\remove_action( 'phpmailer_init', $alt_function );
}
/**
* Send a direct message.
*
* @param array $activity The activity object.
* @param int $user_id The id of the local blog-user.
*/
public static function direct_message( $activity, $user_id ) {
if (
is_activity_public( $activity ) ||
// Only accept messages that have the user in the "to" field.
empty( $activity['to'] ) ||
! in_array( Actors::get_by_id( $user_id )->get_id(), (array) $activity['to'], true )
) {
return;
}
if ( $user_id > Actors::BLOG_USER_ID ) {
if ( ! \get_user_option( 'activitypub_mailer_new_dm', $user_id ) ) {
return;
}
$email = \get_userdata( $user_id )->user_email;
} else {
if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_dm', '1' ) ) {
return;
}
$email = \get_option( 'admin_email' );
}
$actor = get_remote_metadata_by_actor( $activity['actor'] );
if ( ! $actor || \is_wp_error( $actor ) || empty( $activity['object']['content'] ) ) {
return;
}
if ( empty( $actor['webfinger'] ) ) {
$actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
}
$template_args = array(
'activity' => $activity,
'actor' => $actor,
'user_id' => $user_id,
);
/* translators: 1: Blog name, 2 Actor name */
$subject = \sprintf( \esc_html__( '[%1$s] Direct Message from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
\ob_start();
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-dm.php', false, $template_args );
$html_message = \ob_get_clean();
$alt_function = function ( $mailer ) use ( $actor, $activity ) {
$content = \html_entity_decode(
\wp_strip_all_tags(
str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
),
ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
);
/* translators: Actor name */
$message = \sprintf( \esc_html__( 'New Direct Message: %s', 'activitypub' ), $content ) . "\r\n\r\n";
/* translators: Actor name */
$message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
/* translators: Message URL */
$message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
$mailer->{'AltBody'} = $message;
};
\add_action( 'phpmailer_init', $alt_function );
\wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
\remove_action( 'phpmailer_init', $alt_function );
}
/**
* Send a mention notification.
*
* @param array $activity The activity object.
* @param int $user_id The id of the local blog-user.
*/
public static function mention( $activity, $user_id ) {
if (
// Only accept messages that have the user in the "cc" field.
empty( $activity['cc'] ) ||
! in_array( Actors::get_by_id( $user_id )->get_id(), (array) $activity['cc'], true )
) {
return;
}
if ( $user_id > Actors::BLOG_USER_ID ) {
if ( ! \get_user_option( 'activitypub_mailer_new_mention', $user_id ) ) {
return;
}
$email = \get_userdata( $user_id )->user_email;
} else {
if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_mention', '1' ) ) {
return;
}
$email = \get_option( 'admin_email' );
}
$actor = get_remote_metadata_by_actor( $activity['actor'] );
if ( \is_wp_error( $actor ) ) {
return;
}
if ( empty( $actor['webfinger'] ) ) {
$actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
}
$template_args = array(
'activity' => $activity,
'actor' => $actor,
'user_id' => $user_id,
);
/* translators: 1: Blog name, 2 Actor name */
$subject = \sprintf( \esc_html__( '[%1$s] Mention from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
\ob_start();
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-mention.php', false, $template_args );
$html_message = \ob_get_clean();
$alt_function = function ( $mailer ) use ( $actor, $activity ) {
$content = \html_entity_decode(
\wp_strip_all_tags(
str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
),
ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
);
/* translators: Message content */
$message = \sprintf( \esc_html__( 'New Mention: %s', 'activitypub' ), $content ) . "\r\n\r\n";
/* translators: Actor name */
$message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
/* translators: Message URL */
$message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
$mailer->{'AltBody'} = $message;
};
\add_action( 'phpmailer_init', $alt_function );
\wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
\remove_action( 'phpmailer_init', $alt_function );
}
}

View File

@ -1,96 +1,68 @@
<?php
/**
* Mention class file.
*
* @package Activitypub
*/
namespace Activitypub;
use WP_Error;
use Activitypub\Webfinger;
use function Activitypub\object_to_uri;
/**
* ActivityPub Mention Class
* ActivityPub Mention Class.
*
* @author Alex Kirk
*/
class Mention {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\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_extra_field_content', array( self::class, 'the_content' ), 10, 1 );
\add_filter( 'activitypub_extract_mentions', array( self::class, 'extract_mentions' ), 99, 2 );
\add_filter( 'activitypub_activity_object_array', array( self::class, 'filter_activity_object' ), 99 );
}
/**
* Filter to replace the mentions in the content with links
* Filter only the activity object and replace summery it with URLs
* add tag to user.
*
* @param string $the_content the post-content
* @param array $object_array Array of activity.
*
* @return string the filtered post-content
* @return array The activity object array.
*/
public static function filter_activity_object( $object_array ) {
if ( ! empty( $object_array['summary'] ) ) {
$object_array['summary'] = self::the_content( $object_array['summary'] );
}
if ( ! empty( $object_array['content'] ) ) {
$object_array['content'] = self::the_content( $object_array['content'] );
}
return $object_array;
}
/**
* Filter to replace the mentions in the content with links.
*
* @param string $the_content The post content.
*
* @return string The filtered post-content.
*/
public static function the_content( $the_content ) {
// small protection against execution timeouts: limit to 1 MB
if ( mb_strlen( $the_content ) > MB_IN_BYTES ) {
return $the_content;
}
$tag_stack = array();
$protected_tags = array(
'pre',
'code',
'textarea',
'style',
'a',
);
$content_with_links = '';
$in_protected_tag = false;
foreach ( wp_html_split( $the_content ) as $chunk ) {
if ( preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
$content_with_links .= $chunk;
continue;
}
if ( preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
$tag = strtolower( $m[2] );
if ( '/' === $m[1] ) {
// Closing tag.
$i = array_search( $tag, $tag_stack );
// 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 );
}
} else {
// Opening tag, add it to the stack.
$tag_stack[] = $tag;
}
// If we're in a protected tag, the tag_stack contains at least one protected tag string.
// The protected tag state can only change when we encounter a start or end tag.
$in_protected_tag = array_intersect( $tag_stack, $protected_tags );
// Never inspect tags.
$content_with_links .= $chunk;
continue;
}
if ( $in_protected_tag ) {
// Don't inspect a chunk inside an inspected tag.
$content_with_links .= $chunk;
continue;
}
// Only reachable when there is no protected tag in the stack.
$content_with_links .= \preg_replace_callback( '/@' . ACTIVITYPUB_USERNAME_REGEXP . '/', array( self::class, 'replace_with_links' ), $chunk );
}
return $content_with_links;
return enrich_content_data( $the_content, '/@' . ACTIVITYPUB_USERNAME_REGEXP . '/', array( self::class, 'replace_with_links' ) );
}
/**
* A callback for preg_replace to build the user links
* A callback for preg_replace to build the user links.
*
* @param array $result the preg_match results
* @param array $result The preg_match results.
*
* @return string the final string
* @return string The final string.
*/
public static function replace_with_links( $result ) {
$metadata = get_remote_metadata_by_actor( $result[0] );
@ -110,18 +82,18 @@ class Mention {
$url = isset( $metadata['url'] ) ? object_to_uri( $metadata['url'] ) : object_to_uri( $metadata['id'] );
return \sprintf( '<a rel="mention" class="u-url mention" href="%s">@<span>%s</span></a>', esc_url( $url ), esc_html( $username ) );
return \sprintf( '<a rel="mention" class="u-url mention" href="%1$s">@%2$s</a>', esc_url( $url ), esc_html( $username ) );
}
return $result[0];
}
/**
* Get the Inboxes for the mentioned Actors
* Get the Inboxes for the mentioned Actors.
*
* @param array $mentioned The list of Actors that were mentioned
* @param array $mentioned The list of Actors that were mentioned.
*
* @return array The list of Inboxes
* @return array The list of Inboxes.
*/
public static function get_inboxes( $mentioned ) {
$inboxes = array();
@ -138,11 +110,11 @@ class Mention {
}
/**
* Get the inbox from the Remote-Profile of a mentioned Actor
* Get the inbox from the Remote-Profile of a mentioned Actor.
*
* @param string $actor The Actor-URL
* @param string $actor The Actor URL.
*
* @return string The Inbox-URL
* @return string|WP_Error The Inbox-URL or WP_Error if not found.
*/
public static function get_inbox_by_mentioned_actor( $actor ) {
$metadata = get_remote_metadata_by_actor( $actor );
@ -151,7 +123,7 @@ class Mention {
return $metadata;
}
if ( isset( $metadata['endpoints'] ) && isset( $metadata['endpoints']['sharedInbox'] ) ) {
if ( isset( $metadata['endpoints']['sharedInbox'] ) ) {
return $metadata['endpoints']['sharedInbox'];
}
@ -165,10 +137,10 @@ class Mention {
/**
* Extract the mentions from the post_content.
*
* @param array $mentions The already found mentions.
* @param array $mentions The already found mentions.
* @param string $post_content The post content.
*
* @return mixed The discovered mentions.
* @return array The discovered mentions.
*/
public static function extract_mentions( $mentions, $post_content ) {
\preg_match_all( '/@' . ACTIVITYPUB_USERNAME_REGEXP . '/i', $post_content, $matches );
@ -178,6 +150,6 @@ class Mention {
$mentions[ $match ] = $link;
}
}
return $mentions;
return \array_unique( $mentions );
}
}

View File

@ -1,9 +1,17 @@
<?php
/**
* Migration class file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Activitypub;
use Activitypub\Model\Blog;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Extra_Fields;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Outbox;
use Activitypub\Transformer\Factory;
/**
* ActivityPub Migration Class
@ -12,10 +20,12 @@ use Activitypub\Collection\Followers;
*/
class Migration {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_migrate', array( self::class, 'async_migration' ) );
\add_action( 'activitypub_upgrade', array( self::class, 'async_upgrade' ), 10, 99 );
\add_action( 'activitypub_update_comment_counts', array( self::class, 'update_comment_counts' ), 10, 2 );
self::maybe_migrate();
}
@ -26,10 +36,14 @@ class Migration {
* This is the version that the database structure will be updated to.
* It is the same as the plugin version.
*
* @deprecated 4.2.0 Use constant ACTIVITYPUB_PLUGIN_VERSION directly.
*
* @return string The target version.
*/
public static function get_target_version() {
return get_plugin_version();
_deprecated_function( __FUNCTION__, '4.2.0', 'ACTIVITYPUB_PLUGIN_VERSION' );
return ACTIVITYPUB_PLUGIN_VERSION;
}
/**
@ -44,16 +58,23 @@ class Migration {
/**
* Locks the database migration process to prevent simultaneous migrations.
*
* @return void
* @return bool|int True if the lock was successful, timestamp of existing lock otherwise.
*/
public static function lock() {
\update_option( 'activitypub_migration_lock', \time() );
global $wpdb;
// Try to lock.
$lock_result = (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'no') /* LOCK */", 'activitypub_migration_lock', \time() ) ); // phpcs:ignore WordPress.DB
if ( ! $lock_result ) {
$lock_result = \get_option( 'activitypub_migration_lock' );
}
return $lock_result;
}
/**
* Unlocks the database migration process.
*
* @return void
*/
public static function unlock() {
\delete_option( 'activitypub_migration_lock' );
@ -87,9 +108,9 @@ class Migration {
* @return bool True if the database structure is up to date, false otherwise.
*/
public static function is_latest_version() {
return (bool) version_compare(
return (bool) \version_compare(
self::get_version(),
self::get_target_version(),
ACTIVITYPUB_PLUGIN_VERSION,
'=='
);
}
@ -110,30 +131,91 @@ class Migration {
$version_from_db = self::get_version();
// check for inital migration
// Check for initial migration.
if ( ! $version_from_db ) {
self::add_default_settings();
$version_from_db = self::get_target_version();
$version_from_db = ACTIVITYPUB_PLUGIN_VERSION;
}
// schedule the async migration
// Schedule the async migration.
if ( ! \wp_next_scheduled( 'activitypub_migrate', $version_from_db ) ) {
\wp_schedule_single_event( \time(), 'activitypub_migrate', array( $version_from_db ) );
}
if ( version_compare( $version_from_db, '0.17.0', '<' ) ) {
if ( \version_compare( $version_from_db, '0.17.0', '<' ) ) {
self::migrate_from_0_16();
}
if ( version_compare( $version_from_db, '1.3.0', '<' ) ) {
if ( \version_compare( $version_from_db, '1.3.0', '<' ) ) {
self::migrate_from_1_2_0();
}
if ( version_compare( $version_from_db, '2.1.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', '<' ) ) {
if ( \version_compare( $version_from_db, '2.3.0', '<' ) ) {
self::migrate_from_2_2_0();
}
if ( \version_compare( $version_from_db, '3.0.0', '<' ) ) {
self::migrate_from_2_6_0();
}
if ( \version_compare( $version_from_db, '4.0.0', '<' ) ) {
self::migrate_to_4_0_0();
}
if ( \version_compare( $version_from_db, '4.1.0', '<' ) ) {
self::migrate_to_4_1_0();
}
if ( \version_compare( $version_from_db, '4.5.0', '<' ) ) {
\wp_schedule_single_event( \time() + MINUTE_IN_SECONDS, 'activitypub_update_comment_counts' );
}
if ( \version_compare( $version_from_db, '4.7.1', '<' ) ) {
self::migrate_to_4_7_1();
}
if ( \version_compare( $version_from_db, '4.7.2', '<' ) ) {
self::migrate_to_4_7_2();
}
if ( \version_compare( $version_from_db, '4.7.3', '<' ) ) {
add_action( 'init', 'flush_rewrite_rules', 20 );
}
if ( \version_compare( $version_from_db, '5.0.0', '<' ) ) {
Scheduler::register_schedules();
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'create_post_outbox_items' ) );
\wp_schedule_single_event( \time() + 15, 'activitypub_upgrade', array( 'create_comment_outbox_items' ) );
add_action( 'init', 'flush_rewrite_rules', 20 );
}
if ( \version_compare( $version_from_db, '5.2.0', '<' ) ) {
Scheduler::register_schedules();
}
if ( \version_compare( $version_from_db, '5.4.0', '<' ) ) {
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_slashing' ) );
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_comment_author_emails' ) );
\add_action( 'init', 'flush_rewrite_rules', 20 );
}
if ( \version_compare( $version_from_db, '5.7.0', '<' ) ) {
self::delete_mastodon_api_orphaned_extra_fields();
}
if ( \version_compare( $version_from_db, '5.8.0', '<' ) ) {
self::update_notification_options();
}
update_option( 'activitypub_db_version', self::get_target_version() );
/*
* Add new update routines above this comment. ^
*
* Use 'unreleased' as the version number for new migrations and add tests for the callback directly.
* The release script will automatically replace it with the actual version number.
* Example:
*
* if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) {
* // Update routine.
* }
*/
/**
* Fires when the system has to be migrated.
*
* @param string $version_from_db The version from which to migrate.
* @param string $target_version The target version to migrate to.
*/
\do_action( 'activitypub_migrate', $version_from_db, ACTIVITYPUB_PLUGIN_VERSION );
\update_option( 'activitypub_db_version', ACTIVITYPUB_PLUGIN_VERSION );
self::unlock();
}
@ -144,22 +226,54 @@ class Migration {
* @param string $version_from_db The version from which to migrate.
*/
public static function async_migration( $version_from_db ) {
if ( version_compare( $version_from_db, '1.0.0', '<' ) ) {
if ( \version_compare( $version_from_db, '1.0.0', '<' ) ) {
self::migrate_from_0_17();
}
}
/**
* Updates the custom template to use shortcodes instead of the deprecated templates.
* Asynchronously runs upgrade routines.
*
* @return void
* @param callable $callback Callable upgrade routine. Must be a method of this class.
* @params mixed ...$args Optional. Parameters that get passed to the callback.
*/
public static function async_upgrade( $callback ) {
$args = \func_get_args();
// Bail if the existing lock is still valid.
if ( self::is_locked() ) {
\wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'activitypub_upgrade', $args );
return;
}
self::lock();
$callback = array_shift( $args ); // Remove $callback from arguments.
$next = \call_user_func_array( array( self::class, $callback ), $args );
self::unlock();
if ( ! empty( $next ) ) {
// Schedule the next run, adding the result to the arguments.
\wp_schedule_single_event(
\time() + 30,
'activitypub_upgrade',
\array_merge( array( $callback ), \array_values( $next ) )
);
}
}
/**
* Updates the custom template to use shortcodes instead of the deprecated templates.
*/
private static function migrate_from_0_16() {
// Get the custom template.
$old_content = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
// If the old content exists but is a blank string, we're going to need a flag to updated it even
// after setting it to the default contents.
/*
* If the old content exists but is a blank string, we're going to need a flag to updated it even
* after setting it to the default contents.
*/
$need_update = false;
// If the old contents is blank, use the defaults.
@ -187,12 +301,10 @@ class Migration {
}
/**
* Updates the DB-schema of the followers-list
*
* @return void
* Updates the DB-schema of the followers-list.
*/
public static function migrate_from_0_17() {
// migrate followers
// Migrate followers.
foreach ( get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
$followers = get_user_meta( $user_id, 'activitypub_followers', true );
@ -207,9 +319,7 @@ class Migration {
}
/**
* Clear the cache after updating to 1.3.0
*
* @return void
* Clear the cache after updating to 1.3.0.
*/
private static function migrate_from_1_2_0() {
$user_ids = \get_users(
@ -225,9 +335,7 @@ class Migration {
}
/**
* Unschedule Hooks after updating to 2.0.0
*
* @return void
* Unschedule Hooks after updating to 2.0.0.
*/
private static function migrate_from_2_0_0() {
wp_clear_scheduled_hook( 'activitypub_send_post_activity' );
@ -246,42 +354,591 @@ class Migration {
/**
* Add the ActivityPub capability to all users that can publish posts
* Delete old meta to store followers
*
* @return void
* Delete old meta to store followers.
*/
private static function migrate_from_2_2_0() {
// add the ActivityPub capability to all users that can publish posts
// Add the ActivityPub capability to all users that can publish posts.
self::add_activitypub_capability();
}
/**
* Set the defaults needed for the plugin to work
* Rename DB fields.
*/
private static function migrate_from_2_6_0() {
wp_cache_flush();
self::update_usermeta_key( 'activitypub_user_description', 'activitypub_description' );
self::update_options_key( 'activitypub_blog_user_description', 'activitypub_blog_description' );
self::update_options_key( 'activitypub_blog_user_identifier', 'activitypub_blog_identifier' );
}
/**
* * Update actor-mode settings.
* * Get the ID of the latest blog post and save it to the options table.
*/
private static function migrate_to_4_0_0() {
$latest_post_id = 0;
// Get the ID of the latest blog post and save it to the options table.
$latest_post = get_posts(
array(
'numberposts' => 1,
'orderby' => 'ID',
'order' => 'DESC',
'post_type' => 'any',
'post_status' => 'publish',
)
);
if ( $latest_post ) {
$latest_post_id = $latest_post[0]->ID;
}
\update_option( 'activitypub_last_post_with_permalink_as_id', $latest_post_id );
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);
foreach ( $users as $user ) {
$followers = Followers::get_followers( $user->ID );
if ( $followers ) {
\update_user_option( $user->ID, 'activitypub_use_permalink_as_id', '1' );
}
}
$followers = Followers::get_followers( Actors::BLOG_USER_ID );
if ( $followers ) {
\update_option( 'activitypub_use_permalink_as_id_for_blog', '1' );
}
self::migrate_actor_mode();
}
/**
* Upate to 4.1.0
*
* * Add the ActivityPub capability to all users that can publish posts
* * Migrate the `activitypub_post_content_type` to only use `activitypub_custom_post_content`.
*/
public static function migrate_to_4_1_0() {
$content_type = \get_option( 'activitypub_post_content_type' );
switch ( $content_type ) {
case 'excerpt':
$template = "[ap_excerpt]\n\n[ap_permalink type=\"html\"]";
break;
case 'title':
$template = "[ap_title type=\"html\"]\n\n[ap_permalink type=\"html\"]";
break;
case 'content':
$template = "[ap_content]\n\n[ap_permalink type=\"html\"]\n\n[ap_hashtags]";
break;
case 'custom':
$template = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
break;
default:
$template = ACTIVITYPUB_CUSTOM_POST_CONTENT;
break;
}
\update_option( 'activitypub_custom_post_content', $template );
\delete_option( 'activitypub_post_content_type' );
$object_type = \get_option( 'activitypub_object_type', false );
if ( ! $object_type ) {
\update_option( 'activitypub_object_type', 'note' );
}
// Clean up empty visibility meta.
global $wpdb;
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"DELETE FROM $wpdb->postmeta
WHERE meta_key = 'activitypub_content_visibility'
AND (meta_value IS NULL OR meta_value = '')"
);
}
/**
* Updates post meta keys to be prefixed with an underscore.
*/
public static function migrate_to_4_7_1() {
global $wpdb;
$meta_keys = array(
'activitypub_actor_json',
'activitypub_canonical_url',
'activitypub_errors',
'activitypub_inbox',
'activitypub_user_id',
);
foreach ( $meta_keys as $meta_key ) {
// phpcs:ignore WordPress.DB
$wpdb->update( $wpdb->postmeta, array( 'meta_key' => '_' . $meta_key ), array( 'meta_key' => $meta_key ) );
}
}
/**
* Clears the post cache for Followers, we should have done this in 4.7.1 when we renamed those keys.
*/
public static function migrate_to_4_7_2() {
global $wpdb;
// phpcs:ignore WordPress.DB
$followers = $wpdb->get_col(
$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", Followers::POST_TYPE )
);
foreach ( $followers as $id ) {
clean_post_cache( $id );
}
}
/**
* Update comment counts for posts in batches.
*
* @return void
* @see Comment::pre_wp_update_comment_count_now()
* @param int $batch_size Optional. Number of posts to process per batch. Default 100.
* @param int $offset Optional. Number of posts to skip. Default 0.
*/
public static function update_comment_counts( $batch_size = 100, $offset = 0 ) {
global $wpdb;
// Bail if the existing lock is still valid.
if ( self::is_locked() ) {
\wp_schedule_single_event(
time() + ( 5 * MINUTE_IN_SECONDS ),
'activitypub_update_comment_counts',
array(
'batch_size' => $batch_size,
'offset' => $offset,
)
);
return;
}
self::lock();
Comment::register_comment_types();
$comment_types = Comment::get_comment_type_slugs();
$type_inclusion = "AND comment_type IN ('" . implode( "','", $comment_types ) . "')";
// Get and process this batch.
$post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT DISTINCT comment_post_ID FROM {$wpdb->comments} WHERE comment_approved = '1' {$type_inclusion} ORDER BY comment_post_ID LIMIT %d OFFSET %d",
$batch_size,
$offset
)
);
foreach ( $post_ids as $post_id ) {
\wp_update_comment_count_now( $post_id );
}
if ( count( $post_ids ) === $batch_size ) {
// Schedule next batch.
\wp_schedule_single_event(
time() + MINUTE_IN_SECONDS,
'activitypub_update_comment_counts',
array(
'batch_size' => $batch_size,
'offset' => $offset + $batch_size,
)
);
}
self::unlock();
}
/**
* Create outbox items for posts in batches.
*
* @param int $batch_size Optional. Number of posts to process per batch. Default 50.
* @param int $offset Optional. Number of posts to skip. Default 0.
* @return array|null Array with batch size and offset if there are more posts to process, null otherwise.
*/
public static function create_post_outbox_items( $batch_size = 50, $offset = 0 ) {
$posts = \get_posts(
array(
// our own `ap_outbox` will be excluded from `any` by virtue of its `exclude_from_search` arg.
'post_type' => 'any',
'posts_per_page' => $batch_size,
'offset' => $offset,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_status',
'value' => 'federated',
),
),
)
);
// Avoid multiple queries for post meta.
\update_postmeta_cache( \wp_list_pluck( $posts, 'ID' ) );
foreach ( $posts as $post ) {
$visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
self::add_to_outbox( $post, 'Create', $post->post_author, $visibility );
// Add Update activity when the post has been modified.
if ( $post->post_modified !== $post->post_date ) {
self::add_to_outbox( $post, 'Update', $post->post_author, $visibility );
}
}
if ( count( $posts ) === $batch_size ) {
return array(
'batch_size' => $batch_size,
'offset' => $offset + $batch_size,
);
}
return null;
}
/**
* Create outbox items for comments in batches.
*
* @param int $batch_size Optional. Number of posts to process per batch. Default 50.
* @param int $offset Optional. Number of posts to skip. Default 0.
* @return array|null Array with batch size and offset if there are more posts to process, null otherwise.
*/
public static function create_comment_outbox_items( $batch_size = 50, $offset = 0 ) {
$comments = \get_comments(
array(
'author__not_in' => array( 0 ), // Limit to comments by registered users.
'number' => $batch_size,
'offset' => $offset,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_status',
'value' => 'federated',
),
),
)
);
foreach ( $comments as $comment ) {
self::add_to_outbox( $comment, 'Create', $comment->user_id );
}
if ( count( $comments ) === $batch_size ) {
return array(
'batch_size' => $batch_size,
'offset' => $offset + $batch_size,
);
}
return null;
}
/**
* Update _activitypub_actor_json meta values to ensure they are properly slashed.
*
* @param int $batch_size Optional. Number of meta values to process per batch. Default 100.
* @param int $offset Optional. Number of meta values to skip. Default 0.
* @return array|null Array with batch size and offset if there are more meta values to process, null otherwise.
*/
public static function update_actor_json_slashing( $batch_size = 100, $offset = 0 ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$meta_values = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_actor_json' LIMIT %d OFFSET %d",
$batch_size,
$offset
)
);
foreach ( $meta_values as $meta ) {
$json = \json_decode( $meta->meta_value, true );
// If json_decode fails, try adding slashes.
if ( null === $json && \json_last_error() !== JSON_ERROR_NONE ) {
$escaped_value = \preg_replace( '#\\\\(?!["\\\\/bfnrtu])#', '\\\\\\\\', $meta->meta_value );
$json = \json_decode( $escaped_value, true );
// Update the meta if json_decode succeeds with slashes.
if ( null !== $json && \json_last_error() === JSON_ERROR_NONE ) {
\update_post_meta( $meta->post_id, '_activitypub_actor_json', \wp_slash( $escaped_value ) );
}
}
}
if ( \count( $meta_values ) === $batch_size ) {
return array(
'batch_size' => $batch_size,
'offset' => $offset + $batch_size,
);
}
return null;
}
/**
* Update comment author emails with webfinger addresses for ActivityPub comments.
*
* @param int $batch_size Optional. Number of comments to process per batch. Default 50.
* @param int $offset Optional. Number of comments to skip. Default 0.
* @return array|null Array with batch size and offset if there are more comments to process, null otherwise.
*/
public static function update_comment_author_emails( $batch_size = 50, $offset = 0 ) {
$comments = \get_comments(
array(
'number' => $batch_size,
'offset' => $offset,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'protocol',
'value' => 'activitypub',
),
),
)
);
foreach ( $comments as $comment ) {
$comment_author_url = $comment->comment_author_url;
if ( empty( $comment_author_url ) ) {
continue;
}
$webfinger = Webfinger::uri_to_acct( $comment_author_url );
if ( \is_wp_error( $webfinger ) ) {
continue;
}
\wp_update_comment(
array(
'comment_ID' => $comment->comment_ID,
'comment_author_email' => \str_replace( 'acct:', '', $webfinger ),
)
);
}
if ( count( $comments ) === $batch_size ) {
return array(
'batch_size' => $batch_size,
'offset' => $offset + $batch_size,
);
}
return null;
}
/**
* Set the defaults needed for the plugin to work.
*
* Add the ActivityPub capability to all users that can publish posts.
*/
public static function add_default_settings() {
self::add_activitypub_capability();
self::add_default_extra_field();
}
/**
* Add the ActivityPub capability to all users that can publish posts
* Add an activity to the outbox without federating it.
*
* @return void
* @param \WP_Post|\WP_Comment $comment The comment or post object.
* @param string $activity_type The type of activity.
* @param int $user_id The user ID.
* @param string $visibility Optional. The visibility of the content. Default 'public'.
*/
private static function add_to_outbox( $comment, $activity_type, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) {
$transformer = Factory::get_transformer( $comment );
if ( ! $transformer || \is_wp_error( $transformer ) ) {
return;
}
$activity = $transformer->to_activity( $activity_type );
if ( ! $activity || \is_wp_error( $activity ) ) {
return;
}
// If the user is disabled, fall back to the blog user when available.
if ( ! user_can_activitypub( $user_id ) ) {
if ( user_can_activitypub( Actors::BLOG_USER_ID ) ) {
$user_id = Actors::BLOG_USER_ID;
} else {
return;
}
}
$post_id = Outbox::add( $activity, $user_id, $visibility );
// Immediately set to publish, no federation needed.
\wp_publish_post( $post_id );
}
/**
* Add the ActivityPub capability to all users that can publish posts.
*/
private static function add_activitypub_capability() {
// get all WP_User objects that can publish posts
// 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
// Add ActivityPub capability to all users that can publish posts.
foreach ( $users as $user ) {
$user->add_cap( 'activitypub' );
}
}
/**
* Add a default extra field for the user.
*/
private static function add_default_extra_field() {
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);
$title = \__( 'Powered by', 'activitypub' );
$content = 'WordPress';
// Add a default extra field for each user.
foreach ( $users as $user ) {
\wp_insert_post(
array(
'post_type' => Extra_Fields::USER_POST_TYPE,
'post_author' => $user->ID,
'post_status' => 'publish',
'post_title' => $title,
'post_content' => $content,
)
);
}
\wp_insert_post(
array(
'post_type' => Extra_Fields::BLOG_POST_TYPE,
'post_author' => 0,
'post_status' => 'publish',
'post_title' => $title,
'post_content' => $content,
)
);
}
/**
* Rename meta keys.
*
* @param string $old_key The old comment meta key.
* @param string $new_key The new comment meta key.
*/
private static function update_usermeta_key( $old_key, $new_key ) {
global $wpdb;
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->usermeta,
array( 'meta_key' => $new_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( 'meta_key' => $old_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( '%s' ),
array( '%s' )
);
}
/**
* Rename option keys.
*
* @param string $old_key The old option key.
* @param string $new_key The new option key.
*/
private static function update_options_key( $old_key, $new_key ) {
global $wpdb;
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->options,
array( 'option_name' => $new_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( 'option_name' => $old_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( '%s' ),
array( '%s' )
);
}
/**
* Migrate the actor mode settings.
*/
public static function migrate_actor_mode() {
$blog_profile = \get_option( 'activitypub_enable_blog_user', '0' );
$author_profiles = \get_option( 'activitypub_enable_users', '1' );
if (
'1' === $blog_profile &&
'1' === $author_profiles
) {
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE );
} elseif (
'1' === $blog_profile &&
'1' !== $author_profiles
) {
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_BLOG_MODE );
} elseif (
'1' !== $blog_profile &&
'1' === $author_profiles
) {
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE );
}
}
/**
* Deletes user extra fields where the author is the blog user.
*
* These extra fields were created when the Enable Mastodon Apps integration passed
* an author_url instead of a user_id to the mastodon_api_account filter. This caused
* Extra_Fields::default_actor_extra_fields() to run but fail to cache the fact it ran
* for non-existent users. The result is a number of user extra fields with no author.
*
* @ticket https://github.com/Automattic/wordpress-activitypub/pull/1554
*/
public static function delete_mastodon_api_orphaned_extra_fields() {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->delete(
$wpdb->posts,
array(
'post_type' => Extra_Fields::USER_POST_TYPE,
'post_author' => Actors::BLOG_USER_ID,
)
);
}
/**
* Update notification options.
*/
public static function update_notification_options() {
$new_dm = \get_option( 'activitypub_mailer_new_dm', '1' );
$new_follower = \get_option( 'activitypub_mailer_new_follower', '1' );
// Add the blog user notification options.
\add_option( 'activitypub_blog_user_mailer_new_dm', $new_dm );
\add_option( 'activitypub_blog_user_mailer_new_follower', $new_follower );
\add_option( 'activitypub_blog_user_mailer_new_mention', '1' );
// Add the actor notification options.
foreach ( Actors::get_collection() as $actor ) {
\update_user_option( $actor->get__id(), 'activitypub_mailer_new_dm', $new_dm );
\update_user_option( $actor->get__id(), 'activitypub_mailer_new_follower', $new_follower );
\update_user_option( $actor->get__id(), 'activitypub_mailer_new_mention', '1' );
}
// Delete the old notification options.
\delete_option( 'activitypub_mailer_new_dm' );
\delete_option( 'activitypub_mailer_new_follower' );
}
}

View File

@ -0,0 +1,313 @@
<?php
/**
* Move class file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Activity\Actor;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Model\Blog;
use Activitypub\Model\User;
/**
* ActivityPub (Account) Move Class
*
* @author Matthias Pfefferle
*/
class Move {
/**
* Initialize the Move class.
*/
public static function init() {
/**
* Filter to enable automatically moving Fediverse accounts when the domain changes.
*
* @param bool $domain_moves_enabled Whether domain moves are enabled.
*/
$domain_moves_enabled = apply_filters( 'activitypub_enable_primary_domain_moves', false );
if ( $domain_moves_enabled ) {
// Add the filter to change the domain.
\add_filter( 'update_option_home', array( self::class, 'change_domain' ), 10, 2 );
if ( get_option( 'activitypub_old_host' ) ) {
\add_action( 'activitypub_construct_model_actor', array( self::class, 'maybe_initiate_old_user' ) );
\add_action( 'activitypub_pre_send_to_inboxes', array( self::class, 'pre_send_to_inboxes' ) );
if ( ! is_user_type_disabled( 'blog' ) ) {
\add_filter( 'activitypub_pre_get_by_username', array( self::class, 'old_blog_username' ), 10, 2 );
}
}
}
}
/**
* Move an ActivityPub account from one location to another.
*
* @param string $from The current account URL.
* @param string $to The new account URL.
*
* @return int|bool|\WP_Error The ID of the outbox item or false or WP_Error on failure.
*/
public static function account( $from, $to ) {
if ( is_same_domain( $from ) && is_same_domain( $to ) ) {
return self::internally( $from, $to );
}
return self::externally( $from, $to );
}
/**
* Move an ActivityPub Actor from one location (internal) to another (external).
*
* This helps migrating local profiles to a new external profile:
*
* `Move::externally( 'https://example.com/?author=123', 'https://mastodon.example/users/foo' );`
*
* @param string $from The current account URL.
* @param string $to The new account URL.
*
* @return int|bool|\WP_Error The ID of the outbox item or false or WP_Error on failure.
*/
public static function externally( $from, $to ) {
$user = Actors::get_by_various( $from );
if ( \is_wp_error( $user ) ) {
return $user;
}
// Update the movedTo property.
if ( $user->get__id() > 0 ) {
\update_user_option( $user->get__id(), 'activitypub_moved_to', $to );
} else {
\update_option( 'activitypub_blog_user_moved_to', $to );
}
$response = Http::get_remote_object( $to );
if ( \is_wp_error( $response ) ) {
return $response;
}
$target_actor = new Actor();
$target_actor->from_array( $response );
// Check if the `Move` Activity is valid.
$also_known_as = $target_actor->get_also_known_as() ?? array();
if ( ! in_array( $from, $also_known_as, true ) ) {
return new \WP_Error( 'invalid_target', __( 'Invalid target', 'activitypub' ) );
}
$activity = new Activity();
$activity->set_type( 'Move' );
$activity->set_actor( $user->get_id() );
$activity->set_origin( $user->get_id() );
$activity->set_object( $user->get_id() );
$activity->set_target( $target_actor->get_id() );
// Add to outbox.
return add_to_outbox( $activity, null, $user->get__id(), ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC );
}
/**
* Internal Move.
*
* Move an ActivityPub Actor from one location (internal) to another (internal).
*
* This helps migrating abandoned profiles to `Move` to other profiles:
*
* `Move::internally( 'https://example.com/?author=123', 'https://example.com/?author=321' );`
*
* ... or to change Actor-IDs like:
*
* `Move::internally( 'https://example.com/author/foo', 'https://example.com/?author=123' );`
*
* @param string $from The current account URL.
* @param string $to The new account URL.
*
* @return int|bool|\WP_Error The ID of the outbox item or false or WP_Error on failure.
*/
public static function internally( $from, $to ) {
$user = Actors::get_by_various( $from );
if ( \is_wp_error( $user ) ) {
return $user;
}
// Add the old account URL to alsoKnownAs.
if ( $user->get__id() > 0 ) {
self::update_user_also_known_as( $user->get__id(), $from );
\update_user_option( $user->get__id(), 'activitypub_moved_to', $to );
} else {
self::update_blog_also_known_as( $from );
\update_option( 'activitypub_blog_user_moved_to', $to );
}
// check if `$from` is a URL or an ID.
if ( \filter_var( $from, FILTER_VALIDATE_URL ) ) {
$actor = $from;
} else {
$actor = $user->get_id();
}
$activity = new Activity();
$activity->set_type( 'Move' );
$activity->set_actor( $actor );
$activity->set_origin( $actor );
$activity->set_object( $actor );
$activity->set_target( $to );
return add_to_outbox( $activity, null, $user->get__id(), ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC );
}
/**
* Update the alsoKnownAs property of a user.
*
* @param int $user_id The user ID.
* @param string $from The current account URL.
*/
private static function update_user_also_known_as( $user_id, $from ) {
// phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$also_known_as = \get_user_option( 'activitypub_also_known_as', $user_id ) ?: array();
$also_known_as[] = $from;
\update_user_option( $user_id, 'activitypub_also_known_as', $also_known_as );
}
/**
* Update the alsoKnownAs property of the blog.
*
* @param string $from The current account URL.
*/
private static function update_blog_also_known_as( $from ) {
$also_known_as = \get_option( 'activitypub_blog_user_also_known_as', array() );
$also_known_as[] = $from;
\update_option( 'activitypub_blog_user_also_known_as', $also_known_as );
}
/**
* Change domain for all ActivityPub Actors.
*
* This method handles domain migration according to the ActivityPub Data Portability spec.
* It stores the old host and calls Move::internally for each available profile.
* It also caches the JSON representation of the old Actor for future lookups.
*
* @param string $from The old domain.
* @param string $to The new domain.
*
* @return array Array of results from Move::internally calls.
*/
public static function change_domain( $from, $to ) {
// Get all actors that need to be migrated.
$actors = Actors::get_all();
$results = array();
$to_host = \wp_parse_url( $to, \PHP_URL_HOST );
$from_host = \wp_parse_url( $from, \PHP_URL_HOST );
// Store the old host for future reference.
\update_option( 'activitypub_old_host', $from_host );
// Process each actor.
foreach ( $actors as $actor ) {
$actor_id = $actor->get_id();
// Replace the new host with the old host in the actor ID.
$old_actor_id = str_replace( $to_host, $from_host, $actor_id );
// Call Move::internally for this actor.
$result = self::internally( $old_actor_id, $actor_id );
if ( \is_wp_error( $result ) ) {
// Log the error and continue with the next actor.
Debug::write_log( 'Error moving actor: ' . $actor_id . ' - ' . $result->get_error_message() );
continue;
}
$json = str_replace( $to_host, $from_host, $actor->to_json() );
// Save the current actor data after migration.
if ( $actor instanceof Blog ) {
\update_option( 'activitypub_blog_user_old_host_data', $json, false );
} else {
\update_user_option( $actor->get__id(), 'activitypub_old_host_data', $json, false );
}
$results[] = array(
'actor' => $actor_id,
'result' => $result,
);
}
return $results;
}
/**
* Maybe initiate old user.
*
* This method checks if the current request domain matches the old host.
* If it does, it retrieves the cached data for the user and populates the instance.
*
* @param Blog|User $instance The Blog or User instance to populate.
*/
public static function maybe_initiate_old_user( $instance ) {
if ( ! Query::get_instance()->is_old_host_request() ) {
return;
}
if ( $instance instanceof Blog ) {
$cached_data = \get_option( 'activitypub_blog_user_old_host_data' );
} elseif ( $instance instanceof User ) {
$cached_data = \get_user_option( 'activitypub_old_host_data', $instance->get__id() );
}
if ( ! empty( $cached_data ) ) {
$instance->from_json( $cached_data );
}
}
/**
* Pre-send to inboxes.
*
* @param string $json The ActivityPub Activity JSON.
*/
public static function pre_send_to_inboxes( $json ) {
$json = json_decode( $json, true );
if ( 'Move' !== $json['type'] ) {
return;
}
if ( is_same_domain( $json['object'] ) ) {
return;
}
Query::get_instance()->set_old_host_request();
}
/**
* Filter to return the old blog username.
*
* @param null $pre The pre-existing value.
* @param string $username The username to check.
*
* @return Blog|null The old blog instance or null.
*/
public static function old_blog_username( $pre, $username ) {
$old_host = \get_option( 'activitypub_old_host' );
// Special case for Blog Actor on old host.
if ( $old_host === $username && Query::get_instance()->is_old_host_request() ) {
// Return a new Blog instance which will load the cached data in its constructor.
$pre = new Blog();
}
return $pre;
}
}

View File

@ -1,4 +1,9 @@
<?php
/**
* Notification file.
*
* @package Activitypub
*/
namespace Activitypub;
@ -37,15 +42,15 @@ class Notification {
/**
* Notification constructor.
*
* @param string $type The type of the notification.
* @param string $actor The actor URL.
* @param array $object The Activity object.
* @param int $target The WordPress User-Id.
* @param string $type The type of the notification.
* @param string $actor The actor URL.
* @param array $activity The Activity object.
* @param int $target The WordPress User-Id.
*/
public function __construct( $type, $actor, $object, $target ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound
$this->type = $type;
$this->actor = $actor;
$this->object = $object;
public function __construct( $type, $actor, $activity, $target ) {
$this->type = $type;
$this->actor = $actor;
$this->object = $activity;
$this->target = $target;
}
@ -53,6 +58,20 @@ class Notification {
* Send the notification.
*/
public function send() {
$type = \strtolower( $this->type );
/**
* Action to send ActivityPub notifications.
*
* @param Notification $instance The notification object.
*/
do_action( 'activitypub_notification', $this );
/**
* Type-specific action to send ActivityPub notifications.
*
* @param Notification $instance The notification object.
*/
do_action( "activitypub_notification_{$type}", $this );
}
}

View File

@ -0,0 +1,124 @@
<?php
/**
* Options file.
*
* @package ActivityPub
*/
namespace ActivityPub;
/**
* Options class.
*
* @package ActivityPub
*/
class Options {
/**
* Initialize the options.
*/
public static function init() {
\add_filter( 'pre_option_activitypub_actor_mode', array( self::class, 'pre_option_activitypub_actor_mode' ) );
\add_filter( 'pre_option_activitypub_authorized_fetch', array( self::class, 'pre_option_activitypub_authorized_fetch' ) );
\add_filter( 'pre_option_activitypub_shared_inbox', array( self::class, 'pre_option_activitypub_shared_inbox' ) );
\add_filter( 'pre_option_activitypub_vary_header', array( self::class, 'pre_option_activitypub_vary_header' ) );
\add_filter( 'pre_option_activitypub_allow_likes', array( self::class, 'maybe_disable_interactions' ) );
\add_filter( 'pre_option_activitypub_allow_replies', array( self::class, 'maybe_disable_interactions' ) );
}
/**
* Pre-get option filter for the Actor-Mode.
*
* @param string|false $pre The pre-get option value.
*
* @return string|false The actor mode or false if it should not be filtered.
*/
public static function pre_option_activitypub_actor_mode( $pre ) {
if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) && ACTIVITYPUB_SINGLE_USER_MODE ) {
return ACTIVITYPUB_BLOG_MODE;
}
if ( \defined( 'ACTIVITYPUB_DISABLE_USER' ) && ACTIVITYPUB_DISABLE_USER ) {
return ACTIVITYPUB_BLOG_MODE;
}
if ( \defined( 'ACTIVITYPUB_DISABLE_BLOG_USER' ) && ACTIVITYPUB_DISABLE_BLOG_USER ) {
return ACTIVITYPUB_ACTOR_MODE;
}
return $pre;
}
/**
* Pre-get option filter for the Authorized Fetch.
*
* @param string $pre The pre-get option value.
*
* @return string If the constant is defined, return the value, otherwise return the pre-get option value.
*/
public static function pre_option_activitypub_authorized_fetch( $pre ) {
if ( ! \defined( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) ) {
return $pre;
}
if ( ACTIVITYPUB_AUTHORIZED_FETCH ) {
return '1';
}
return '0';
}
/**
* Pre-get option filter for the Shared Inbox.
*
* @param string $pre The pre-get option value.
*
* @return string If the constant is defined, return the value, otherwise return the pre-get option value.
*/
public static function pre_option_activitypub_shared_inbox( $pre ) {
if ( ! \defined( 'ACTIVITYPUB_SHARED_INBOX_FEATURE' ) ) {
return $pre;
}
if ( ACTIVITYPUB_SHARED_INBOX_FEATURE ) {
return '1';
}
return '0';
}
/**
* Pre-get option filter for the Vary Header.
*
* @param string $pre The pre-get option value.
*
* @return string If the constant is defined, return the value, otherwise return the pre-get option value.
*/
public static function pre_option_activitypub_vary_header( $pre ) {
if ( ! \defined( 'ACTIVITYPUB_SEND_VARY_HEADER' ) ) {
return $pre;
}
if ( ACTIVITYPUB_SEND_VARY_HEADER ) {
return '1';
}
return '0';
}
/**
* Disallow interactions if the constant is set.
*
* @param bool $pre_option The value of the option.
* @return bool|string The value of the option.
*/
public static function maybe_disable_interactions( $pre_option ) {
if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
return '0';
}
return $pre_option;
}
}

View File

@ -0,0 +1,351 @@
<?php
/**
* Query class.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
use Activitypub\Transformer\Factory;
/**
* Singleton class to handle and store the ActivityPub query.
*/
class Query {
/**
* The singleton instance.
*
* @var Query
*/
private static $instance;
/**
* The ActivityPub object.
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object
*
* @var object
*/
private $activitypub_object;
/**
* The ActivityPub object ID.
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-id
*
* @var string
*/
private $activitypub_object_id;
/**
* Whether the current request is an ActivityPub request.
*
* @var bool
*/
private $is_activitypub_request;
/**
* Whether the current request is from the old host.
*
* @var bool
*/
private $is_old_host_request;
/**
* The constructor.
*/
private function __construct() {
// Do nothing.
}
/**
* The destructor.
*/
public function __destruct() {
self::$instance = null;
}
/**
* Get the singleton instance.
*
* @return Query The singleton instance.
*/
public static function get_instance() {
if ( ! isset( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Get the ActivityPub object.
*
* @return object The ActivityPub object.
*/
public function get_activitypub_object() {
if ( $this->activitypub_object ) {
return $this->activitypub_object;
}
if ( $this->prepare_activitypub_data() ) {
return $this->activitypub_object;
}
$queried_object = $this->get_queried_object();
$transformer = Factory::get_transformer( $queried_object );
if ( $transformer && ! \is_wp_error( $transformer ) ) {
$this->activitypub_object = $transformer->to_object();
}
return $this->activitypub_object;
}
/**
* Get the ActivityPub object ID.
*
* @return string The ActivityPub object ID.
*/
public function get_activitypub_object_id() {
if ( $this->activitypub_object_id ) {
return $this->activitypub_object_id;
}
if ( $this->prepare_activitypub_data() ) {
return $this->activitypub_object_id;
}
$queried_object = $this->get_queried_object();
$transformer = Factory::get_transformer( $queried_object );
if ( $transformer && ! \is_wp_error( $transformer ) ) {
$this->activitypub_object_id = $transformer->to_id();
}
return $this->activitypub_object_id;
}
/**
* Prepare and set both ActivityPub object and ID for Outbox activities and virtual objects.
*
* @return bool True if an object was found and set, false otherwise.
*/
private function prepare_activitypub_data() {
$queried_object = $this->get_queried_object();
// Check for Outbox Activity.
if (
$queried_object instanceof \WP_Post &&
Outbox::POST_TYPE === $queried_object->post_type
) {
$activitypub_object = Outbox::maybe_get_activity( $queried_object );
// Check if the Outbox Activity is public.
if ( ! \is_wp_error( $activitypub_object ) ) {
$this->activitypub_object = $activitypub_object;
$this->activitypub_object_id = $this->activitypub_object->get_id();
return true;
}
}
if ( ! $queried_object ) {
// If the object is not a valid ActivityPub object, try to get a virtual object.
$activitypub_object = $this->maybe_get_virtual_object();
if ( $activitypub_object ) {
$this->activitypub_object = $activitypub_object;
$this->activitypub_object_id = $this->activitypub_object->get_id();
return true;
}
}
return false;
}
/**
* Get the queried object.
*
* This adds support for Comments by `?c=123` IDs and Users by `?author=123` and `@username` IDs.
*
* @return \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null The queried object.
*/
public function get_queried_object() {
$queried_object = \get_queried_object();
// Check Comment by ID.
if ( ! $queried_object ) {
$comment_id = \get_query_var( 'c' );
if ( $comment_id ) {
$queried_object = \get_comment( $comment_id );
}
}
// Check Post by ID (works for custom post types).
if ( ! $queried_object ) {
$post_id = \get_query_var( 'p' );
if ( $post_id ) {
$queried_object = \get_post( $post_id );
}
}
// Try to get Author by ID.
if ( ! $queried_object ) {
$url = $this->get_request_url();
$author_id = url_to_authorid( $url );
if ( $author_id ) {
$queried_object = \get_user_by( 'id', $author_id );
}
}
/**
* Filters the queried object.
*
* @param \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null $queried_object The queried object.
*/
return apply_filters( 'activitypub_queried_object', $queried_object );
}
/**
* Get the virtual object.
*
* Virtual objects are objects that are not stored in the database, but are created on the fly.
* The plugins currently supports two virtual objects: The Blog-Actor and the Application-Actor.
*
* @see \Activitypub\Model\Blog
* @see \Activitypub\Model\Application
*
* @return object|null The virtual object.
*/
protected function maybe_get_virtual_object() {
$url = $this->get_request_url();
if ( ! $url ) {
return null;
}
$author_id = url_to_authorid( $url );
if ( ! is_numeric( $author_id ) ) {
$author_id = $url;
}
$user = Actors::get_by_various( $author_id );
if ( \is_wp_error( $user ) || ! $user ) {
return null;
}
return $user;
}
/**
* Get the request URL.
*
* @return string|null The request URL.
*/
protected function get_request_url() {
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
return null;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$url = \wp_unslash( $_SERVER['REQUEST_URI'] );
$url = \WP_Http::make_absolute_url( $url, \home_url() );
$url = \sanitize_url( $url );
return $url;
}
/**
* Check if the current request is an ActivityPub request.
*
* @return bool True if the request is an ActivityPub request, false otherwise.
*/
public function is_activitypub_request() {
if ( isset( $this->is_activitypub_request ) ) {
return $this->is_activitypub_request;
}
global $wp_query;
// One can trigger an ActivityPub request by adding `?activitypub` to the URL.
if (
isset( $wp_query->query_vars['activitypub'] ) ||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
isset( $_GET['activitypub'] )
) {
\defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
$this->is_activitypub_request = true;
return true;
}
/*
* The other (more common) option to make an ActivityPub request
* is to send an Accept header.
*/
if ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
$accept = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
/*
* $accept can be a single value, or a comma separated list of values.
* We want to support both scenarios,
* and return true when the header includes at least one of the following:
* - application/activity+json
* - application/ld+json
* - application/json
*/
if ( \preg_match( '/(application\/(ld\+json|activity\+json|json))/i', $accept ) ) {
\defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
$this->is_activitypub_request = true;
return true;
}
}
$this->is_activitypub_request = false;
return false;
}
/**
* Check if the current request is from the old host.
*
* @return bool True if the request is from the old host, false otherwise.
*/
public function is_old_host_request() {
if ( isset( $this->is_old_host_request ) ) {
return $this->is_old_host_request;
}
$old_host = \get_option( 'activitypub_old_host' );
if ( ! $old_host ) {
$this->is_old_host_request = false;
return false;
}
$request_host = isset( $_SERVER['HTTP_HOST'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
$referer_host = isset( $_SERVER['HTTP_REFERER'] ) ? \wp_parse_url( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST ) : '';
// Check if the domain matches either the request domain or referer.
$check = $old_host === $request_host || $old_host === $referer_host;
$this->is_old_host_request = $check;
return $check;
}
/**
* Fake an old host request.
*
* @param bool $state Optional. The state to set. Default true.
*/
public function set_old_host_request( $state = true ) {
$this->is_old_host_request = $state;
}
}

View File

@ -0,0 +1,122 @@
<?php
/**
* Sanitization file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Model\Blog;
/**
* Sanitization class.
*/
class Sanitize {
/**
* Sanitize a list of URLs.
*
* @param string|array $value The value to sanitize.
* @return array The sanitized list of URLs.
*/
public static function url_list( $value ) {
if ( ! \is_array( $value ) ) {
$value = \explode( PHP_EOL, $value );
}
$value = \array_filter( $value );
$value = \array_map( 'trim', $value );
$value = \array_map( 'sanitize_url', $value );
$value = \array_unique( $value );
return \array_values( $value );
}
/**
* Sanitize a list of hosts.
*
* @param string $value The value to sanitize.
* @return string The sanitized list of hosts.
*/
public static function host_list( $value ) {
$value = \explode( PHP_EOL, $value );
$value = \array_map(
function ( $host ) {
$host = \trim( $host );
$host = \strtolower( $host );
$host = \set_url_scheme( $host );
$host = \sanitize_url( $host, array( 'http', 'https' ) );
// Remove protocol.
if ( \str_contains( $host, 'http' ) ) {
$host = \wp_parse_url( $host, PHP_URL_HOST );
}
return \filter_var( $host, FILTER_VALIDATE_DOMAIN );
},
$value
);
return \implode( PHP_EOL, \array_filter( $value ) );
}
/**
* Sanitize a blog identifier.
*
* @param string $value The value to sanitize.
* @return string The sanitized blog identifier.
*/
public static function blog_identifier( $value ) {
// Hack to allow dots in the username.
$parts = \explode( '.', $value );
$sanitized = \array_map( 'sanitize_title', $parts );
$sanitized = \implode( '.', $sanitized );
// Check for login or nicename.
$user = new \WP_User_Query(
array(
'search' => $sanitized,
'search_columns' => array( 'user_login', 'user_nicename' ),
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
)
);
if ( $user->get_results() ) {
\add_settings_error(
'activitypub_blog_identifier',
'activitypub_blog_identifier',
\esc_html__( 'You cannot use an existing author&#8217;s name for the blog profile ID.', 'activitypub' )
);
return Blog::get_default_username();
}
return $sanitized;
}
/**
* Get the sanitized value of a constant.
*
* @param mixed $value The constant value.
*
* @return string The sanitized value.
*/
public static function constant_value( $value ) {
if ( is_bool( $value ) ) {
return $value ? 'true' : 'false';
}
if ( is_string( $value ) ) {
return esc_attr( $value );
}
if ( is_array( $value ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
return print_r( $value, true );
}
return $value;
}
}

View File

@ -1,90 +1,80 @@
<?php
/**
* Scheduler class file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Transformer\Post;
use Activitypub\Collection\Users;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use Activitypub\Scheduler\Post;
use Activitypub\Scheduler\Actor;
use Activitypub\Scheduler\Comment;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
use Activitypub\Collection\Followers;
use function Activitypub\was_comment_sent;
use function Activitypub\is_user_type_disabled;
use function Activitypub\should_comment_be_federated;
use function Activitypub\get_remote_metadata_by_actor;
use Activitypub\Transformer\Factory;
/**
* ActivityPub Scheduler Class
* Scheduler class.
*
* @author Matthias Pfefferle
*/
class Scheduler {
/**
* Initialize the class, registering WordPress hooks
* Allowed batch callbacks.
*
* @var array
*/
private static $batch_callbacks = array();
/**
* 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 );
}
self::register_schedulers();
self::$batch_callbacks = array(
Dispatcher::$callback,
array( Dispatcher::class, 'retry_send_to_followers' ),
);
if ( ! ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS ) {
// 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
// Follower Cleanups.
\add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) );
\add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) );
// 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' ) );
}
// Event callbacks.
\add_action( 'activitypub_async_batch', array( self::class, 'async_batch' ), 10, 99 );
\add_action( 'activitypub_reprocess_outbox', array( self::class, 'reprocess_outbox' ) );
\add_action( 'activitypub_outbox_purge', array( self::class, 'purge_outbox' ) );
// 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.
}
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_outbox_activity_for_federation' ) );
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_announce_activity' ), 10, 4 );
\add_action( 'update_option_activitypub_outbox_purge_days', array( self::class, 'handle_outbox_purge_days_update' ), 10, 2 );
}
/**
* Register handlers.
*/
public static function register_schedulers() {
Post::init();
Actor::init();
Comment::init();
/**
* Register additional schedulers.
*
* @since 5.0.0
*/
do_action( 'activitypub_register_schedulers' );
}
/**
* Schedule all ActivityPub schedules.
*
* @return void
*/
public static function register_schedules() {
if ( ! \wp_next_scheduled( 'activitypub_update_followers' ) ) {
@ -94,131 +84,30 @@ class Scheduler {
if ( ! \wp_next_scheduled( 'activitypub_cleanup_followers' ) ) {
\wp_schedule_event( time(), 'daily', 'activitypub_cleanup_followers' );
}
if ( ! \wp_next_scheduled( 'activitypub_reprocess_outbox' ) ) {
\wp_schedule_event( time(), 'hourly', 'activitypub_reprocess_outbox' );
}
if ( ! wp_next_scheduled( 'activitypub_outbox_purge' ) ) {
wp_schedule_event( time(), 'daily', 'activitypub_outbox_purge' );
}
}
/**
* Unscedule all ActivityPub schedules.
* Un-schedule all ActivityPub schedules.
*
* @return void
*/
public static function deregister_schedules() {
wp_unschedule_hook( 'activitypub_update_followers' );
wp_unschedule_hook( 'activitypub_cleanup_followers' );
}
/**
* Schedule Activities.
*
* @param string $new_status New post status.
* @param string $old_status Old post status.
* @param WP_Post $post Post object.
*/
public static function schedule_post_activity( $new_status, $old_status, $post ) {
$post = get_post( $post );
if ( 'ap_extrafield' === $post->post_type ) {
self::schedule_profile_update( $post->post_author );
return;
}
// Do not send activities if post is password protected.
if ( \post_password_required( $post ) ) {
return;
}
// Check if post-type supports ActivityPub.
$post_types = \get_post_types_by_support( 'activitypub' );
if ( ! \in_array( $post->post_type, $post_types, true ) ) {
return;
}
$type = false;
if (
'publish' === $new_status &&
'publish' !== $old_status
) {
$type = 'Create';
} elseif (
'publish' === $new_status ||
( 'draft' === $new_status &&
'draft' !== $old_status )
) {
$type = 'Update';
} elseif ( 'trash' === $new_status ) {
$type = 'Delete';
}
if ( empty( $type ) ) {
return;
}
$hook = 'activitypub_send_post';
$args = array( $post->ID, $type );
if ( false === wp_next_scheduled( $hook, $args ) ) {
set_wp_object_state( $post, 'federate' );
\wp_schedule_single_event( \time(), $hook, $args );
}
wp_unschedule_hook( 'activitypub_reprocess_outbox' );
wp_unschedule_hook( 'activitypub_outbox_purge' );
}
/**
* 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 comments that are written by a registered user.
if ( ! $comment->user_id ) {
return;
}
$type = false;
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 ( empty( $type ) ) {
return;
}
// check if comment should be federated or not
if ( ! should_comment_be_federated( $comment ) ) {
return;
}
$hook = 'activitypub_send_comment';
$args = array( $comment->comment_ID, $type );
if ( false === wp_next_scheduled( $hook, $args ) ) {
set_wp_object_state( $comment, 'federate' );
\wp_schedule_single_event( \time(), $hook, $args );
}
}
/**
* Update followers
*
* @return void
* Update followers.
*/
public static function update_followers() {
$number = 5;
@ -227,6 +116,11 @@ class Scheduler {
$number = 50;
}
/**
* Filter the number of followers to update.
*
* @param int $number The number of followers to update.
*/
$number = apply_filters( 'activitypub_update_followers_number', $number );
$followers = Followers::get_outdated_followers( $number );
@ -243,9 +137,7 @@ class Scheduler {
}
/**
* Cleanup followers
*
* @return void
* Cleanup followers.
*/
public static function cleanup_followers() {
$number = 5;
@ -254,6 +146,11 @@ class Scheduler {
$number = 50;
}
/**
* Filter the number of followers to clean up.
*
* @param int $number The number of followers to clean up.
*/
$number = apply_filters( 'activitypub_update_followers_number', $number );
$followers = Followers::get_faulty_followers( $number );
@ -280,69 +177,268 @@ class Scheduler {
}
/**
* Send a profile update when relevant user meta is updated.
* Schedule the outbox item for federation.
*
* @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
* @param int $id The ID of the outbox item.
* @param int $offset The offset to add to the scheduled time.
*/
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, 'activitypub' ) ) {
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 );
public static function schedule_outbox_activity_for_federation( $id, $offset = 0 ) {
$hook = 'activitypub_process_outbox';
$args = array( $id );
if ( false === wp_next_scheduled( $hook, $args ) ) {
\wp_schedule_single_event(
\time() + $offset,
$hook,
$args
);
}
}
/**
* Send a profile update when a user is updated.
*
* @param int $user_id User ID being updated.
*
* @return void
* Reprocess the outbox.
*/
public static function user_update( $user_id ) {
// don't bother if the user can't publish
if ( ! \user_can( $user_id, 'activitypub' ) ) {
public static function reprocess_outbox() {
// Bail if there is a pending batch.
if ( self::next_scheduled_hook( 'activitypub_async_batch' ) ) {
return;
}
self::schedule_profile_update( $user_id );
}
// Bail if there is a batch in progress.
$key = \md5( \serialize( Dispatcher::$callback ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
if ( self::is_locked( $key ) ) {
return;
}
/**
* 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 )
$ids = \get_posts(
array(
'post_type' => Outbox::POST_TYPE,
'post_status' => 'pending',
'posts_per_page' => 10,
'fields' => 'ids',
)
);
foreach ( $ids as $id ) {
self::schedule_outbox_activity_for_federation( $id );
}
}
/**
* Purge outbox items based on a schedule.
*/
public static function purge_outbox() {
$total_posts = (int) wp_count_posts( Outbox::POST_TYPE )->publish;
if ( $total_posts <= 20 ) {
return;
}
$days = (int) get_option( 'activitypub_outbox_purge_days', 180 );
$timezone = new \DateTimeZone( 'UTC' );
$date = new \DateTime( 'now', $timezone );
$date->sub( \DateInterval::createFromDateString( "$days days" ) );
$post_ids = get_posts(
array(
'post_type' => Outbox::POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'numberposts' => -1,
'date_query' => array(
array(
'before' => $date->format( 'Y-m-d' ),
),
),
)
);
foreach ( $post_ids as $post_id ) {
\wp_delete_post( $post_id, true );
}
}
/**
* Update schedules when outbox purge days settings change.
*
* @param int $old_value The old value.
* @param int $value The new value.
*/
public static function handle_outbox_purge_days_update( $old_value, $value ) {
if ( 0 === (int) $value ) {
wp_clear_scheduled_hook( 'activitypub_outbox_purge' );
} elseif ( ! wp_next_scheduled( 'activitypub_outbox_purge' ) ) {
wp_schedule_event( time(), 'daily', 'activitypub_outbox_purge' );
}
}
/**
* Asynchronously runs batch processing routines.
*
* The batching part is optional and only comes into play if the callback returns anything.
* Beyond that it's a helper to run a callback asynchronously with locking to prevent simultaneous processing.
*
* @param callable $callback Callable processing routine.
* @params mixed ...$args Optional. Parameters that get passed to the callback.
*/
public static function async_batch( $callback ) {
if ( ! in_array( $callback, self::$batch_callbacks, true ) || ! \is_callable( $callback ) ) {
_doing_it_wrong( __METHOD__, 'The first argument must be a valid callback.', '5.2.0' );
return;
}
$args = \func_get_args(); // phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue
$key = \md5( \serialize( $callback ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
// Bail if the existing lock is still valid.
if ( self::is_locked( $key ) ) {
\wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'activitypub_async_batch', $args );
return;
}
self::lock( $key );
$callback = array_shift( $args ); // Remove $callback from arguments.
$next = \call_user_func_array( $callback, $args );
self::unlock( $key );
if ( ! empty( $next ) ) {
// Schedule the next run, adding the result to the arguments.
\wp_schedule_single_event(
\time() + 30,
'activitypub_async_batch',
\array_merge( array( $callback ), \array_values( $next ) )
);
}
}
/**
* Locks the async batch process for individual callbacks to prevent simultaneous processing.
*
* @param string $key Serialized callback name.
* @return bool|int True if the lock was successful, timestamp of existing lock otherwise.
*/
public static function lock( $key ) {
global $wpdb;
// Try to lock.
$lock_result = (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'no') /* LOCK */", 'activitypub_async_batch_' . $key, \time() ) ); // phpcs:ignore WordPress.DB
if ( ! $lock_result ) {
$lock_result = \get_option( 'activitypub_async_batch_' . $key );
}
return $lock_result;
}
/**
* Unlocks processing for the async batch callback.
*
* @param string $key Serialized callback name.
*/
public static function unlock( $key ) {
\delete_option( 'activitypub_async_batch_' . $key );
}
/**
* Whether the async batch callback is locked.
*
* @param string $key Serialized callback name.
* @return boolean
*/
public static function is_locked( $key ) {
$lock = \get_option( 'activitypub_async_batch_' . $key );
if ( ! $lock ) {
return false;
}
$lock = (int) $lock;
if ( $lock < \time() - 1800 ) {
self::unlock( $key );
return false;
}
return true;
}
/**
* Get the next scheduled hook.
*
* @param string $hook The hook name.
* @return int|bool The timestamp of the next scheduled hook, or false if none found.
*/
private static function next_scheduled_hook( $hook ) {
$crons = _get_cron_array();
if ( empty( $crons ) ) {
return false;
}
// Get next event.
$next = false;
foreach ( $crons as $timestamp => $cron ) {
if ( isset( $cron[ $hook ] ) ) {
$next = $timestamp;
break;
}
}
return $next;
}
/**
* Send announces.
*
* @param int $outbox_activity_id The outbox activity ID.
* @param \Activitypub\Activity\Activity $activity The activity object.
* @param int $actor_id The actor ID.
* @param int $content_visibility The content visibility.
*/
public static function schedule_announce_activity( $outbox_activity_id, $activity, $actor_id, $content_visibility ) {
// Only if we're in both Blog and User modes.
if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE !== \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
return;
}
// Only if this isn't the Blog Actor.
if ( Actors::BLOG_USER_ID === $actor_id ) {
return;
}
// Only if the content is public or quiet public.
if ( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC !== $content_visibility ) {
return;
}
// Only if the activity is a Create.
if ( 'Create' !== $activity->get_type() ) {
return;
}
if ( ! is_object( $activity->get_object() ) ) {
return;
}
// Check if the object is an article, image, audio, video, event, or document and ignore profile updates and other activities.
if ( ! in_array( $activity->get_object()->get_type(), Base_Object::TYPES, true ) ) {
return;
}
$announce = new Activity();
$announce->set_type( 'Announce' );
$announce->set_actor( Actors::get_by_id( Actors::BLOG_USER_ID )->get_id() );
$announce->set_object( $activity );
$outbox_activity_id = Outbox::add( $announce, Actors::BLOG_USER_ID );
if ( ! $outbox_activity_id ) {
return;
}
// Schedule the outbox item for federation.
self::schedule_outbox_activity_for_federation( $outbox_activity_id, 120 );
}
}

View File

@ -1,11 +1,18 @@
<?php
/**
* Shortcodes class file.
*
* @package Activitypub
*/
namespace Activitypub;
use function Activitypub\esc_hashtag;
/**
* Shortcodes class.
*/
class Shortcodes {
/**
* Register the shortcodes
* Register the shortcodes.
*/
public static function register() {
foreach ( get_class_methods( self::class ) as $shortcode ) {
@ -16,7 +23,7 @@ class Shortcodes {
}
/**
* Unregister the shortcodes
* Unregister the shortcodes.
*/
public static function unregister() {
foreach ( get_class_methods( self::class ) as $shortcode ) {
@ -27,15 +34,11 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_hashtags' shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_hashtags' shortcode.
*
* @return string The post tags as hashtags.
*/
public static function hashtags( $atts, $content, $tag ) {
public static function hashtags() {
$item = self::get_item();
if ( ! $item ) {
@ -51,6 +54,11 @@ class Shortcodes {
$hash_tags = array();
foreach ( $tags as $tag ) {
// Tag can be empty.
if ( ! $tag ) {
continue;
}
$hash_tags[] = \sprintf(
'<a rel="tag" class="hashtag u-tag u-category" href="%s">%s</a>',
\esc_url( \get_tag_link( $tag ) ),
@ -77,7 +85,23 @@ class Shortcodes {
return '';
}
return \wp_strip_all_tags( \get_the_title( $item->ID ), true );
$title = \wp_strip_all_tags( \get_the_title( $item->ID ), true );
if ( ! $title ) {
return '';
}
$atts = shortcode_atts(
array( 'type' => 'plain' ),
$atts,
$tag
);
if ( 'html' !== $atts['type'] ) {
return $title;
}
return sprintf( '<h2>%s</h2>', $title );
}
/**
@ -108,87 +132,14 @@ class Shortcodes {
$excerpt_length = ACTIVITYPUB_EXCERPT_LENGTH;
}
$excerpt = \get_post_field( 'post_excerpt', $item );
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.
if ( '' !== $content ) {
$excerpt = \strip_shortcodes( $content );
/** This filter is documented in wp-includes/post-template.php */
$excerpt = \apply_filters( 'the_content', $excerpt );
$excerpt = \str_replace( ']]>', ']]&gt;', $excerpt );
}
}
// Strip out any remaining tags.
$excerpt = \wp_strip_all_tags( $excerpt );
$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:
//
// * The user has entered a manual excerpt which is longer that what we want.
// * No manual excerpt exists so we've used the content which might be longer than we want.
//
// Either way, let's trim it up if we need too. Also, don't forget to take into account the more indicator
// as part of the total length.
//
// Setup a variable to hold the current excerpts length.
$current_excerpt_length = strlen( $excerpt );
// Setup a variable to keep track of our target length.
$target_excerpt_length = $excerpt_length - $excerpt_more_len;
// Setup a variable to keep track of the current max length.
$current_excerpt_max = $target_excerpt_length;
// This is a loop since we can't calculate word break the string after 'the_excpert' filter has run (we would break
// all kinds of html tags), so we have to cut the excerpt down a bit at a time until we hit our target length.
while ( $current_excerpt_length > $target_excerpt_length && $current_excerpt_max > 0 ) {
// Trim the excerpt based on wordwrap() positioning.
// Note: we're using <br> as the linebreak just in case there are any newlines existing in the excerpt from the user.
// There won't be any <br> left after we've run wp_strip_all_tags() in the code above, so they're
// safe to use here. It won't be included in the final excerpt as the substr() will trim it off.
$excerpt = substr( $excerpt, 0, strpos( wordwrap( $excerpt, $current_excerpt_max, '<br>' ), '<br>' ) );
// If something went wrong, or we're in a language that wordwrap() doesn't understand,
// just chop it off and don't worry about breaking in the middle of a word.
if ( strlen( $excerpt ) > $excerpt_length - $excerpt_more_len ) {
$excerpt = substr( $excerpt, 0, $current_excerpt_max );
}
// Add in the more indicator.
$excerpt = $excerpt . $excerpt_more;
// Run it through the excerpt filter which will add some html tags back in.
$excerpt_filtered = apply_filters( 'the_excerpt', $excerpt );
// Now set the current excerpt length to this new filtered length.
$current_excerpt_length = strlen( $excerpt_filtered );
// Check to see if we're over the target length.
if ( $current_excerpt_length > $target_excerpt_length ) {
// If so, remove 20 characters from the current max and run the loop again.
$current_excerpt_max = $current_excerpt_max - 20;
}
}
$excerpt = generate_post_summary( $item, $excerpt_length );
/** This filter is documented in wp-includes/post-template.php */
return \apply_filters( 'the_excerpt', $excerpt );
}
/**
* Generates output for the 'ap_content' Shortcode
* Generates output for the 'ap_content' Shortcode.
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
@ -203,7 +154,7 @@ class Shortcodes {
return '';
}
// prevent inception
// Prevent inception.
remove_shortcode( 'ap_content' );
$atts = shortcode_atts(
@ -215,35 +166,40 @@ class Shortcodes {
$content = '';
if ( 'attachment' === $item->post_type ) {
// get title of attachment with fallback to alt text.
// 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 = \get_post_field( 'post_content', $item );
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 ) );
}
if ( empty( $content ) ) {
$content = \get_post_field( 'post_content', $item );
}
if ( 'yes' === $atts['apply_filters'] ) {
/** This filter is documented in wp-includes/post-template.php */
$content = \apply_filters( 'the_content', $content );
} else {
if ( site_supports_blocks() ) {
$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' ) );
return $content;
}
/**
* Generates output for the 'ap_permalink' Shortcode
* Generates output for the 'ap_permalink' Shortcode.
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
@ -266,7 +222,7 @@ class Shortcodes {
$tag
);
if ( 'url' === $atts['type'] ) {
if ( 'html' !== $atts['type'] ) {
return \esc_url( \get_permalink( $item->ID ) );
}
@ -277,7 +233,7 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_shortlink' Shortcode
* Generates output for the 'ap_shortlink' Shortcode.
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
@ -300,7 +256,7 @@ class Shortcodes {
$tag
);
if ( 'url' === $atts['type'] ) {
if ( 'html' !== $atts['type'] ) {
return \esc_url( \wp_get_shortlink( $item->ID ) );
}
@ -311,7 +267,7 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_image' Shortcode
* Generates output for the 'ap_image' Shortcode.
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
@ -354,15 +310,11 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_hashcats' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_hashcats' Shortcode.
*
* @return string The post categories as hashtags.
*/
public static function hashcats( $atts, $content, $tag ) {
public static function hashcats() {
$item = self::get_item();
if ( ! $item ) {
@ -389,15 +341,11 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_author' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_author' Shortcode.
*
* @return string The author name.
*/
public static function author( $atts, $content, $tag ) {
public static function author() {
$item = self::get_item();
if ( ! $item ) {
@ -405,7 +353,7 @@ class Shortcodes {
}
$author_id = \get_post_field( 'post_author', $item->ID );
$name = \get_the_author_meta( 'display_name', $author_id );
$name = \get_the_author_meta( 'display_name', $author_id );
if ( ! $name ) {
return '';
@ -415,15 +363,11 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_authorurl' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_authorurl' Shortcode.
*
* @return string The author URL.
*/
public static function authorurl( $atts, $content, $tag ) {
public static function authorurl() {
$item = self::get_item();
if ( ! $item ) {
@ -431,7 +375,7 @@ class Shortcodes {
}
$author_id = \get_post_field( 'post_author', $item->ID );
$url = \get_the_author_meta( 'user_url', $author_id );
$url = \get_the_author_meta( 'user_url', $author_id );
if ( ! $url ) {
return '';
@ -441,63 +385,46 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_blogurl' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_blogurl' Shortcode.
*
* @return string The site URL.
*/
public static function blogurl( $atts, $content, $tag ) {
public static function blogurl() {
return \esc_url( \get_bloginfo( 'url' ) );
}
/**
* Generates output for the 'ap_blogname' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_blogname' Shortcode.
*
* @return string
*/
public static function blogname( $atts, $content, $tag ) {
public static function blogname() {
return \wp_strip_all_tags( \get_bloginfo( 'name' ) );
}
/**
* Generates output for the 'ap_blogdesc' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_blogdesc' Shortcode.
*
* @return string The site description.
*/
public static function blogdesc( $atts, $content, $tag ) {
public static function blogdesc() {
return \wp_strip_all_tags( \get_bloginfo( 'description' ) );
}
/**
* Generates output for the 'ap_date' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_date' Shortcode.
*
* @return string The post date.
*/
public static function date( $atts, $content, $tag ) {
public static function date() {
$item = self::get_item();
if ( ! $item ) {
return '';
}
$datetime = \get_post_datetime( $item );
$datetime = \get_post_datetime( $item );
$dateformat = \get_option( 'date_format' );
$timeformat = \get_option( 'time_format' );
$date = $datetime->format( $dateformat );
@ -509,23 +436,18 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_time' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_time' Shortcode.
*
* @return string The post time.
*/
public static function time( $atts, $content, $tag ) {
public static function time() {
$item = self::get_item();
if ( ! $item ) {
return '';
}
$datetime = \get_post_datetime( $item );
$dateformat = \get_option( 'date_format' );
$datetime = \get_post_datetime( $item );
$timeformat = \get_option( 'time_format' );
$date = $datetime->format( $timeformat );
@ -538,22 +460,18 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_datetime' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_datetime' Shortcode.
*
* @return string The post date/time.
*/
public static function datetime( $atts, $content, $tag ) {
public static function datetime() {
$item = self::get_item();
if ( ! $item ) {
return '';
}
$datetime = \get_post_datetime( $item );
$datetime = \get_post_datetime( $item );
$dateformat = \get_option( 'date_format' );
$timeformat = \get_option( 'time_format' );
@ -572,7 +490,7 @@ class Shortcodes {
* Checks if item (WP_Post) is "public", a supported post type
* and not password protected.
*
* @return null|WP_Post The WordPress item.
* @return null|\WP_Post The WordPress item.
*/
protected static function get_item() {
$post = \get_post();

View File

@ -1,14 +1,20 @@
<?php
/**
* Signature class file.
*
* @package Activitypub
*/
namespace Activitypub;
use WP_Error;
use DateTime;
use DateTimeZone;
use WP_REST_Request;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
/**
* ActivityPub Signature Class
* ActivityPub Signature Class.
*
* @author Matthias Pfefferle
* @author Django Doucet
@ -19,7 +25,7 @@ class Signature {
* Return the public key for a given user.
*
* @param int $user_id The WordPress User ID.
* @param bool $force Force the generation of a new key pair.
* @param bool $force Optional. Force the generation of a new key pair. Default false.
*
* @return mixed The public key.
*/
@ -37,7 +43,7 @@ class Signature {
* Return the private key for a given user.
*
* @param int $user_id The WordPress User ID.
* @param bool $force Force the generation of a new key pair.
* @param bool $force Optional. Force the generation of a new key pair. Default false.
*
* @return mixed The private key.
*/
@ -60,7 +66,7 @@ class Signature {
*/
public static function get_keypair_for( $user_id ) {
$option_key = self::get_signature_options_key_for( $user_id );
$key_pair = \get_option( $option_key );
$key_pair = \get_option( $option_key );
if ( ! $key_pair ) {
$key_pair = self::generate_key_pair_for( $user_id );
@ -78,7 +84,7 @@ class Signature {
*/
protected static function generate_key_pair_for( $user_id ) {
$option_key = self::get_signature_options_key_for( $user_id );
$key_pair = self::check_legacy_key_pair_for( $user_id );
$key_pair = self::check_legacy_key_pair_for( $user_id );
if ( $key_pair ) {
\add_option( $option_key, $key_pair );
@ -87,19 +93,21 @@ class Signature {
}
$config = array(
'digest_alg' => 'sha512',
'digest_alg' => 'sha512',
'private_key_bits' => 2048,
'private_key_type' => \OPENSSL_KEYTYPE_RSA,
);
$key = \openssl_pkey_new( $config );
$key = \openssl_pkey_new( $config );
$priv_key = null;
$detail = array();
if ( $key ) {
\openssl_pkey_export( $key, $priv_key );
\openssl_pkey_export( $key, $priv_key );
$detail = \openssl_pkey_get_details( $key );
}
$detail = \openssl_pkey_get_details( $key );
// check if keys are valid
// Check if keys are valid.
if (
empty( $priv_key ) || ! is_string( $priv_key ) ||
! isset( $detail['key'] ) || ! is_string( $detail['key'] )
@ -115,7 +123,7 @@ class Signature {
'public_key' => $detail['key'],
);
// persist keys
// Persist keys.
\add_option( $option_key, $key_pair );
return $key_pair;
@ -133,7 +141,7 @@ class Signature {
if ( $user_id > 0 ) {
$user = \get_userdata( $user_id );
// sanatize username because it could include spaces and special chars
// Sanitize username because it could include spaces and special chars.
$id = sanitize_title( $user->user_login );
}
@ -150,15 +158,15 @@ class Signature {
protected static function check_legacy_key_pair_for( $user_id ) {
switch ( $user_id ) {
case 0:
$public_key = \get_option( 'activitypub_blog_user_public_key' );
$public_key = \get_option( 'activitypub_blog_user_public_key' );
$private_key = \get_option( 'activitypub_blog_user_private_key' );
break;
case -1:
$public_key = \get_option( 'activitypub_application_user_public_key' );
$public_key = \get_option( 'activitypub_application_user_public_key' );
$private_key = \get_option( 'activitypub_application_user_private_key' );
break;
default:
$public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true );
$public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true );
$private_key = \get_user_meta( $user_id, 'magic_sig_private_key', true );
break;
}
@ -174,18 +182,18 @@ class Signature {
}
/**
* Generates the Signature for a HTTP Request
* Generates the Signature for an HTTP Request.
*
* @param int $user_id The WordPress User ID.
* @param string $http_method The HTTP method.
* @param string $url The URL to send the request to.
* @param string $date The date the request is sent.
* @param string $digest The digest of the request body.
* @param string $digest Optional. The digest of the request body. Default null.
*
* @return string The signature.
*/
public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) {
$user = Users::get_by_id( $user_id );
$user = Actors::get_by_id( $user_id );
$key = self::get_private_key_for( $user->get__id() );
$url_parts = \wp_parse_url( $url );
@ -193,12 +201,12 @@ class Signature {
$host = $url_parts['host'];
$path = '/';
// add path
// Add path.
if ( ! empty( $url_parts['path'] ) ) {
$path = $url_parts['path'];
}
// add query
// Add query.
if ( ! empty( $url_parts['query'] ) ) {
$path .= '?' . $url_parts['query'];
}
@ -213,9 +221,9 @@ class Signature {
$signature = null;
\openssl_sign( $signed_string, $signature, $key, \OPENSSL_ALGO_SHA256 );
$signature = \base64_encode( $signature ); // phpcs:ignore
$signature = \base64_encode( $signature ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$key_id = $user->get_url() . '#main-key';
$key_id = $user->get_id() . '#main-key';
if ( ! empty( $digest ) ) {
return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature );
@ -229,18 +237,18 @@ class Signature {
*
* @param WP_REST_Request|array $request The request object or $_SERVER array.
*
* @return mixed A boolean or WP_Error.
* @return bool|WP_Error A boolean or WP_Error.
*/
public static function verify_http_signature( $request ) {
if ( is_object( $request ) ) { // REST Request object
// check if route starts with "index.php"
if ( is_object( $request ) ) { // REST Request object.
// Check if route starts with "index.php".
if ( str_starts_with( $request->get_route(), '/index.php' ) || ! rest_get_url_prefix() ) {
$route = $request->get_route();
} else {
$route = '/' . rest_get_url_prefix() . '/' . ltrim( $request->get_route(), '/' );
}
// fix route for subdirectory installs
// Fix route for subdirectory installs.
$path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
if ( \is_string( $path ) ) {
@ -251,32 +259,23 @@ class Signature {
$route = '/' . $path . $route;
}
$headers = $request->get_headers();
$headers = $request->get_headers();
$headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' ' . $route;
} else {
$request = self::format_server_request( $request );
$headers = $request['headers']; // $_SERVER array
$request = self::format_server_request( $request );
$headers = $request['headers']; // $_SERVER array
$headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
}
if ( ! isset( $headers['signature'] ) ) {
return new WP_Error( 'activitypub_signature', __( 'Request not signed', 'activitypub' ), array( 'status' => 401 ) );
}
if ( array_key_exists( 'signature', $headers ) ) {
$signature_block = self::parse_signature_header( $headers['signature'][0] );
} elseif ( array_key_exists( 'authorization', $headers ) ) {
$signature_block = self::parse_signature_header( $headers['authorization'][0] );
}
if ( ! isset( $signature_block ) || ! $signature_block ) {
} else {
return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 401 ) );
}
$signed_headers = $signature_block['headers'];
if ( ! $signed_headers ) {
$signed_headers = array( 'date' );
}
$signed_data = self::get_signed_data( $signed_headers, $signature_block, $headers );
if ( ! $signed_data ) {
@ -313,7 +312,6 @@ class Signature {
}
$verified = \openssl_verify( $signed_data, $signature_block['signature'], $public_key, $algorithm ) > 0;
if ( ! $verified ) {
return new WP_Error( 'activitypub_signature', __( 'Invalid signature', 'activitypub' ), array( 'status' => 401 ) );
}
@ -321,14 +319,14 @@ class Signature {
}
/**
* Get public key from key_id
* Get public key from key_id.
*
* @param string $key_id The URL to the public key.
*
* @return WP_Error|string The public key or WP_Error.
* @return resource|WP_Error The public key resource or WP_Error.
*/
public static function get_remote_key( $key_id ) { // phpcs:ignore
$actor = get_remote_metadata_by_actor( strip_fragment_from_url( $key_id ) ); // phpcs:ignore
public static function get_remote_key( $key_id ) {
$actor = get_remote_metadata_by_actor( strip_fragment_from_url( $key_id ) );
if ( \is_wp_error( $actor ) ) {
return new WP_Error(
'activitypub_no_remote_profile_found',
@ -336,9 +334,14 @@ class Signature {
array( 'status' => 401 )
);
}
if ( isset( $actor['publicKey']['publicKeyPem'] ) ) {
return \rtrim( $actor['publicKey']['publicKeyPem'] ); // phpcs:ignore
$key_resource = \openssl_pkey_get_public( \rtrim( $actor['publicKey']['publicKeyPem'] ) );
if ( $key_resource ) {
return $key_resource;
}
}
return new WP_Error(
'activitypub_no_remote_key_found',
__( 'No Public-Key found', 'activitypub' ),
@ -347,9 +350,9 @@ class Signature {
}
/**
* Gets the signature algorithm from the signature header
* Gets the signature algorithm from the signature header.
*
* @param array $signature_block
* @param array $signature_block The signature block.
*
* @return string The signature algorithm.
*/
@ -357,7 +360,7 @@ class Signature {
if ( $signature_block['algorithm'] ) {
switch ( $signature_block['algorithm'] ) {
case 'rsa-sha-512':
return 'sha512'; //hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
return 'sha512'; // hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12.
default:
return 'sha256';
}
@ -366,15 +369,15 @@ class Signature {
}
/**
* Parses the Signature header
* Parses the Signature header.
*
* @param string $signature The signature header.
*
* @return array signature parts
* @return array Signature parts.
*/
public static function parse_signature_header( $signature ) {
$parsed_header = array();
$matches = array();
$parsed_header = array();
$matches = array();
if ( \preg_match( '/keyId="(.*?)"/ism', $signature, $matches ) ) {
$parsed_header['keyId'] = trim( $matches[1] );
@ -392,10 +395,10 @@ class Signature {
$parsed_header['headers'] = \explode( ' ', trim( $matches[1] ) );
}
if ( \preg_match( '/signature="(.*?)"/ism', $signature, $matches ) ) {
$parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', trim( $matches[1] ) ) ); // phpcs:ignore
$parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', trim( $matches[1] ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
}
if ( ( $parsed_header['signature'] ) && ( $parsed_header['algorithm'] ) && ( ! $parsed_header['headers'] ) ) {
if ( empty( $parsed_header['headers'] ) ) {
$parsed_header['headers'] = array( 'date' );
}
@ -403,16 +406,17 @@ class Signature {
}
/**
* Gets the header data from the included pseudo headers
* Gets the header data from the included pseudo headers.
*
* @param array $signed_headers The signed headers.
* @param array $signature_block (pseudo-headers)
* @param array $headers (http headers)
* @param array $signature_block The signature block.
* @param array $headers The HTTP headers.
*
* @return string signed headers for comparison
*/
public static function get_signed_data( $signed_headers, $signature_block, $headers ) {
$signed_data = '';
// This also verifies time-based values by returning false if any of these are out of range.
foreach ( $signed_headers as $header ) {
if ( 'host' === $header ) {
@ -431,7 +435,7 @@ class Signature {
}
if ( '(created)' === $header ) {
if ( ! empty( $signature_block['(created)'] ) && \intval( $signature_block['(created)'] ) > \time() ) {
// created in future
// Created in the future.
return false;
}
@ -442,7 +446,7 @@ class Signature {
}
if ( '(expires)' === $header ) {
if ( ! empty( $signature_block['(expires)'] ) && \intval( $signature_block['(expires)'] ) < \time() ) {
// expired in past
// Expired in the past.
return false;
}
@ -452,41 +456,48 @@ class Signature {
}
}
if ( 'date' === $header ) {
// allow a bit of leeway for misconfigured clocks.
if ( empty( $headers[ $header ][0] ) ) {
continue;
}
// Allow a bit of leeway for misconfigured clocks.
$d = new DateTime( $headers[ $header ][0] );
$d->setTimeZone( new DateTimeZone( 'UTC' ) );
$c = $d->format( 'U' );
$dplus = time() + ( 3 * HOUR_IN_SECONDS );
$dplus = time() + ( 3 * HOUR_IN_SECONDS );
$dminus = time() - ( 3 * HOUR_IN_SECONDS );
if ( $c > $dplus || $c < $dminus ) {
// time out of range
// Time out of range.
return false;
}
}
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
if ( ! empty( $headers[ $header ][0] ) ) {
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
}
}
return \rtrim( $signed_data, "\n" );
}
/**
* Generates the digest for a HTTP Request
* Generates the digest for an HTTP Request.
*
* @param string $body The body of the request.
*
* @return string The digest.
*/
public static function generate_digest( $body ) {
$digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore
$digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return "SHA-256=$digest";
}
/**
* Formats the $_SERVER to resemble the WP_REST_REQUEST array,
* for use with verify_http_signature()
* for use with verify_http_signature().
*
* @param array $_SERVER The $_SERVER array.
* @param array $server The $_SERVER array.
*
* @return array $request The formatted request array.
*/
@ -497,7 +508,7 @@ class Signature {
if ( 'REQUEST_URI' === $req_param ) {
$request['headers']['route'][] = $param_val;
} else {
$header_key = str_replace(
$header_key = str_replace(
'http_',
'',
$req_param

View File

@ -1,11 +1,17 @@
<?php
/**
* WebFinger class file.
*
* @package Activitypub
*/
namespace Activitypub;
use WP_Error;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
/**
* ActivityPub WebFinger Class
* ActivityPub WebFinger Class.
*
* @author Matthias Pfefferle
*
@ -13,14 +19,14 @@ use Activitypub\Collection\Users;
*/
class Webfinger {
/**
* Returns a users WebFinger "resource"
* Returns a users WebFinger "resource".
*
* @param int $user_id The WordPress user id
* @param int $user_id The WordPress user id.
*
* @return string The user-resource
* @return string The user-resource.
*/
public static function get_user_resource( $user_id ) {
$user = Users::get_by_id( $user_id );
$user = Actors::get_by_id( $user_id );
if ( ! $user || is_wp_error( $user ) ) {
return '';
}
@ -29,11 +35,11 @@ class Webfinger {
}
/**
* Resolve a WebFinger resource
* Resolve a WebFinger resource.
*
* @param string $uri The WebFinger Resource
* @param string $uri The WebFinger Resource.
*
* @return string|WP_Error The URL or WP_Error
* @return string|WP_Error The URL or WP_Error.
*/
public static function resolve( $uri ) {
$data = self::get_data( $uri );
@ -56,6 +62,7 @@ class Webfinger {
foreach ( $data['links'] as $link ) {
if (
'self' === $link['rel'] &&
isset( $link['type'] ) &&
(
'application/activity+json' === $link['type'] ||
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' === $link['type']
@ -76,11 +83,13 @@ class Webfinger {
}
/**
* Transform a URI to an acct <identifier>@<host>
* Transform a URI to an acct <identifier>@<host>.
*
* @param string $uri The URI (acct:, mailto:, http:, https:)
* @see https://swicg.github.io/activitypub-webfinger/#reverse-discovery
*
* @return string|WP_Error Error or acct URI
* @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 );
@ -89,7 +98,7 @@ class Webfinger {
return $data;
}
// check if subject is an acct URI
// Check if subject is an acct URI.
if (
isset( $data['subject'] ) &&
\str_starts_with( $data['subject'], 'acct:' )
@ -97,7 +106,7 @@ class Webfinger {
return $data['subject'];
}
// search for an acct URI in the aliases
// Search for an acct URI in the aliases.
if ( isset( $data['aliases'] ) ) {
foreach ( $data['aliases'] as $alias ) {
if ( \str_starts_with( $alias, 'acct:' ) ) {
@ -120,10 +129,9 @@ class Webfinger {
* Convert a URI string to an identifier and its host.
* Automatically adds acct: if it's missing.
*
* @param string $url The URI (acct:, mailto:, http:, https:)
* @param string $url The URI (acct:, mailto:, http:, https:).
*
* @return WP_Error|array Error reaction or array with
* identifier and host as values
* @return WP_Error|array Error reaction or array with identifier and host as values.
*/
public static function get_identifier_and_host( $url ) {
if ( ! $url ) {
@ -137,15 +145,15 @@ class Webfinger {
);
}
// remove leading @
// Remove leading @.
$url = ltrim( $url, '@' );
if ( ! preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
$identifier = 'acct:' . $url;
$scheme = 'acct';
$scheme = 'acct';
} else {
$identifier = $url;
$scheme = $match[1];
$scheme = $match[1];
}
$host = null;
@ -178,12 +186,11 @@ class Webfinger {
}
/**
* Get the WebFinger data for a given URI
* Get the WebFinger data for a given URI.
*
* @param string $uri The Identifier: <identifier>@<host> or URI
* @param string $uri The Identifier: <identifier>@<host> or URI.
*
* @return WP_Error|array Error reaction or array with
* identifier and host as values
* @return WP_Error|array Error reaction or array with identifier and host as values.
*/
public static function get_data( $uri ) {
$identifier_and_host = self::get_identifier_and_host( $uri );
@ -201,7 +208,11 @@ class Webfinger {
return $data;
}
$webfinger_url = sprintf( 'https://%s/.well-known/webfinger?resource=%s', $host, rawurlencode( $identifier ) );
$webfinger_url = sprintf(
'https://%s/.well-known/webfinger?resource=%s',
$host,
rawurlencode( $identifier )
);
$response = wp_safe_remote_get(
$webfinger_url,
@ -230,7 +241,9 @@ class Webfinger {
}
/**
* Get the Remote-Follow endpoint for a given URI
* Get the Remote-Follow endpoint for a given URI.
*
* @param string $uri The WebFinger Resource URI.
*
* @return string|WP_Error Error or the Remote-Follow endpoint URI.
*/
@ -269,11 +282,11 @@ class Webfinger {
}
/**
* Generate a cache key for a given URI
* Generate a cache key for a given URI.
*
* @param string $uri A WebFinger Resource URI
* @param string $uri A WebFinger Resource URI.
*
* @return string The cache key
* @return string The cache key.
*/
public static function generate_cache_key( $uri ) {
$uri = ltrim( $uri, '@' );

View File

@ -0,0 +1,378 @@
<?php
/**
* Actors collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use WP_Error;
use WP_User_Query;
use Activitypub\Model\User;
use Activitypub\Model\Blog;
use Activitypub\Model\Application;
use function Activitypub\object_to_uri;
use function Activitypub\normalize_url;
use function Activitypub\normalize_host;
use function Activitypub\url_to_authorid;
use function Activitypub\is_user_type_disabled;
use function Activitypub\user_can_activitypub;
/**
* Actors collection.
*/
class Actors {
/**
* The ID of the Blog User.
*
* @var int
*/
const BLOG_USER_ID = 0;
/**
* The ID of the Application User.
*
* @var int
*/
const APPLICATION_USER_ID = -1;
/**
* Get the Actor by ID.
*
* @param int $user_id The User-ID.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
*/
public static function get_by_id( $user_id ) {
if ( is_numeric( $user_id ) ) {
$user_id = (int) $user_id;
}
if ( ! user_can_activitypub( $user_id ) ) {
return new WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
switch ( $user_id ) {
case self::BLOG_USER_ID:
return new Blog();
case self::APPLICATION_USER_ID:
return new Application();
default:
return User::from_wp_user( $user_id );
}
}
/**
* Get the Actor by username.
*
* @param string $username Name of the Actor.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
*/
public static function get_by_username( $username ) {
/**
* Filter the username before we do anything else.
*
* @param null $pre The pre-existing value.
* @param string $username The username.
*/
$pre = apply_filters( 'activitypub_pre_get_by_username', null, $username );
if ( null !== $pre ) {
return $pre;
}
// Check for blog user.
if ( Blog::get_default_username() === $username ) {
return new Blog();
}
if ( get_option( 'activitypub_blog_identifier' ) === $username ) {
return new Blog();
}
// Check for application user.
if ( 'application' === $username ) {
return new Application();
}
// Check for 'activitypub_username' meta.
$user = new WP_User_Query(
array(
'count_total' => false,
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'OR',
array(
'key' => '_activitypub_user_identifier',
'value' => $username,
'compare' => 'LIKE',
),
),
)
);
if ( $user->results ) {
$actor = self::get_by_id( $user->results[0] );
if ( ! \is_wp_error( $actor ) ) {
return $actor;
}
}
$username = str_replace( array( '*', '%' ), '', $username );
// Check for login or nicename.
$user = new WP_User_Query(
array(
'count_total' => false,
'search' => $username,
'search_columns' => array( 'user_login', 'user_nicename' ),
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
)
);
if ( $user->results ) {
$actor = self::get_by_id( $user->results[0] );
if ( ! \is_wp_error( $actor ) ) {
return $actor;
}
}
return new WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
/**
* Get the Actor by resource.
*
* @param string $uri The Actor resource.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
*/
public static function get_by_resource( $uri ) {
$uri = object_to_uri( $uri );
if ( ! $uri ) {
return new WP_Error(
'activitypub_no_uri',
\__( 'No URI provided', 'activitypub' ),
array( 'status' => 404 )
);
}
$scheme = 'acct';
$match = array();
// Try to extract the scheme and the host.
if ( preg_match( '/^([a-zA-Z^:]+):(.*)$/i', $uri, $match ) ) {
// Extract the scheme.
$scheme = \esc_attr( $match[1] );
}
// @todo: handle old domain URIs here before we serve a new domain below when we shouldn't.
// Although maybe passing through to ::get_by_username() is enough?
switch ( $scheme ) {
// Check for http(s) URIs.
case 'http':
case 'https':
$resource_path = \wp_parse_url( $uri, PHP_URL_PATH );
if ( $resource_path ) {
$blog_path = \wp_parse_url( \home_url(), PHP_URL_PATH );
if ( $blog_path ) {
$resource_path = \str_replace( $blog_path, '', $resource_path );
}
$resource_path = \trim( $resource_path, '/' );
// Check for http(s)://blog.example.com/@username.
if ( str_starts_with( $resource_path, '@' ) ) {
$identifier = \str_replace( '@', '', $resource_path );
$identifier = \trim( $identifier, '/' );
return self::get_by_username( $identifier );
}
}
// Check for http(s)://blog.example.com/author/username.
$user_id = url_to_authorid( $uri );
if ( \is_int( $user_id ) ) {
return self::get_by_id( $user_id );
}
// Check for http(s)://blog.example.com/.
$normalized_uri = normalize_url( $uri );
if (
normalize_url( site_url() ) === $normalized_uri ||
normalize_url( home_url() ) === $normalized_uri
) {
return self::get_by_id( self::BLOG_USER_ID );
}
return new WP_Error(
'activitypub_no_user_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
// Check for acct URIs.
case 'acct':
$uri = \str_replace( 'acct:', '', $uri );
$identifier = \substr( $uri, 0, \strrpos( $uri, '@' ) );
$host = normalize_host( \substr( \strrchr( $uri, '@' ), 1 ) );
$blog_host = normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) );
if ( $blog_host !== $host && get_option( 'activitypub_old_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 )
);
}
}
/**
* Get the Actor by resource.
*
* @param string $id The Actor resource.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
*/
public static function get_by_various( $id ) {
$user = null;
if ( is_numeric( $id ) ) {
$user = self::get_by_id( $id );
} elseif (
// Is URL.
filter_var( $id, FILTER_VALIDATE_URL ) ||
// Is acct.
str_starts_with( $id, 'acct:' ) ||
// Is email.
filter_var( $id, FILTER_VALIDATE_EMAIL )
) {
$user = self::get_by_resource( $id );
} else {
$user = self::get_by_username( $id );
}
return $user;
}
/**
* Get the Actor collection.
*
* @return array The Actor collection.
*/
public static function get_collection() {
if ( is_user_type_disabled( 'user' ) ) {
return array();
}
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);
$return = array();
foreach ( $users as $user ) {
$actor = User::from_wp_user( $user->ID );
if ( \is_wp_error( $actor ) ) {
continue;
}
$return[] = $actor;
}
return $return;
}
/**
* Get all active Actors including the Blog Actor.
*
* @return array The actor collection.
*/
public static function get_all() {
$return = array();
if ( ! is_user_type_disabled( 'user' ) ) {
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);
foreach ( $users as $user ) {
$actor = User::from_wp_user( $user->ID );
if ( \is_wp_error( $actor ) ) {
continue;
}
$return[] = $actor;
}
}
// Also include the blog actor if active.
if ( ! is_user_type_disabled( 'blog' ) ) {
$blog_actor = self::get_by_id( self::BLOG_USER_ID );
if ( ! \is_wp_error( $blog_actor ) ) {
$return[] = $blog_actor;
}
}
return $return;
}
/**
* Returns the actor type based on the user ID.
*
* @param int $user_id The user ID to check.
* @return string The user type.
*/
public static function get_type_by_id( $user_id ) {
$user_id = (int) $user_id;
if ( self::APPLICATION_USER_ID === $user_id ) {
return 'application';
}
if ( self::BLOG_USER_ID === $user_id ) {
return 'blog';
}
return 'user';
}
}

View File

@ -0,0 +1,300 @@
<?php
/**
* Extra Fields collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Link;
use function Activitypub\site_supports_blocks;
/**
* Extra Fields collection.
*/
class Extra_Fields {
const USER_POST_TYPE = 'ap_extrafield';
const BLOG_POST_TYPE = 'ap_extrafield_blog';
/**
* Get the extra fields for a user.
*
* @param int $user_id The user ID.
*
* @return \WP_Post[] The extra fields.
*/
public static function get_actor_fields( $user_id ) {
$is_blog = self::is_blog( $user_id );
$post_type = $is_blog ? self::BLOG_POST_TYPE : self::USER_POST_TYPE;
$args = array(
'post_type' => $post_type,
'nopaging' => true,
'orderby' => 'menu_order',
'order' => 'ASC',
);
if ( ! $is_blog ) {
$args['author'] = $user_id;
}
$query = new \WP_Query( $args );
$fields = $query->posts ?? array();
/**
* Filters the extra fields for an ActivityPub actor.
*
* This filter allows developers to modify or add custom fields to an actor's
* profile.
*
* @param \WP_Post[] $fields Array of WP_Post objects representing the extra fields.
* @param int $user_id The ID of the user whose fields are being retrieved.
*/
return apply_filters( 'activitypub_get_actor_extra_fields', $fields, $user_id );
}
/**
* Get formatted content for an extra field.
*
* @param \WP_Post $post The post.
*
* @return string The formatted content.
*/
public static function get_formatted_content( $post ) {
$content = \get_the_content( null, false, $post );
$content = Link::the_content( $content );
if ( site_supports_blocks() ) {
$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 ) );
/**
* Filters the content of an extra field.
*
* @param string $content The content.
* @param \WP_Post $post The post.
*/
return \apply_filters( 'activitypub_extra_field_content', $content, $post );
}
/**
* Transforms the Extra Fields (Custom Post Types) to ActivityPub Actor-Attachments.
*
* @param \WP_Post[] $fields The extra fields.
*
* @return array ActivityPub attachments.
*/
public static function fields_to_attachments( $fields ) {
$attachments = array();
\add_filter( 'activitypub_link_rel', array( self::class, 'add_rel_me' ) );
foreach ( $fields as $post ) {
$content = self::get_formatted_content( $post );
$attachments[] = array(
'type' => 'PropertyValue',
'name' => \get_the_title( $post ),
'value' => \html_entity_decode(
$content,
\ENT_QUOTES,
'UTF-8'
),
);
$attachment = false;
// Add support for FEP-fb2a, for more information see FEDERATION.md.
$link_content = \trim( \strip_tags( $content, '<a>' ) );
if (
\stripos( $link_content, '<a' ) === 0 &&
\stripos( $link_content, '<a', 3 ) === false &&
\stripos( $link_content, '</a>', \strlen( $link_content ) - 4 ) !== false &&
\class_exists( '\WP_HTML_Tag_Processor' )
) {
$tags = new \WP_HTML_Tag_Processor( $link_content );
$tags->next_tag( 'A' );
if ( 'A' === $tags->get_tag() ) {
$attachment = array(
'type' => 'Link',
'name' => \get_the_title( $post ),
'href' => \esc_url( $tags->get_attribute( 'href' ) ),
);
$rel = $tags->get_attribute( 'rel' );
if ( $rel && \is_string( $rel ) ) {
$attachment['rel'] = \explode( ' ', $rel );
}
}
}
if ( ! $attachment ) {
$attachment = array(
'type' => 'Note',
'name' => \get_the_title( $post ),
'content' => \html_entity_decode(
$content,
\ENT_QUOTES,
'UTF-8'
),
);
}
$attachments[] = $attachment;
}
\remove_filter( 'activitypub_link_rel', array( self::class, 'add_rel_me' ) );
return $attachments;
}
/**
* Check if a post type is an extra fields post type.
*
* @param string $post_type The post type.
*
* @return bool True if the post type is an extra fields post type, otherwise false.
*/
public static function is_extra_fields_post_type( $post_type ) {
return \in_array( $post_type, array( self::USER_POST_TYPE, self::BLOG_POST_TYPE ), true );
}
/**
* Check if a post type is the `ap_extrafield` post type.
*
* @param string $post_type The post type.
*
* @return bool True if the post type is `ap_extrafield`, otherwise false.
*/
public static function is_extra_field_post_type( $post_type ) {
return self::USER_POST_TYPE === $post_type;
}
/**
* Check if a post type is the `ap_extrafield_blog` post type.
*
* @param string $post_type The post type.
*
* @return bool True if the post type is `ap_extrafield_blog`, otherwise false.
*/
public static function is_extra_field_blog_post_type( $post_type ) {
return self::BLOG_POST_TYPE === $post_type;
}
/**
* Add default extra fields to an actor.
*
* @param array $extra_fields The extra fields.
* @param int $user_id The User-ID.
*
* @return array The extra fields.
*/
public static function default_actor_extra_fields( $extra_fields, $user_id ) {
// We'll only take action when there are none yet.
if ( ! empty( $extra_fields ) ) {
return $extra_fields;
}
$is_blog = self::is_blog( $user_id );
$already_migrated = $is_blog
? \get_option( 'activitypub_default_extra_fields' )
: \get_user_meta( $user_id, 'activitypub_default_extra_fields', true );
if ( $already_migrated ) {
return $extra_fields;
}
\add_filter(
'activitypub_link_rel',
function ( $rel ) {
$rel .= ' me';
return $rel;
}
);
$defaults = array(
\__( 'Blog', 'activitypub' ) => \home_url( '/' ),
);
if ( ! $is_blog ) {
$author_url = \get_the_author_meta( 'user_url', $user_id );
$author_posts_url = \get_author_posts_url( $user_id );
$defaults[ \__( 'Profile', 'activitypub' ) ] = $author_posts_url;
if ( $author_url !== $author_posts_url ) {
$defaults[ \__( 'Homepage', 'activitypub' ) ] = $author_url;
}
}
$post_type = $is_blog ? self::BLOG_POST_TYPE : self::USER_POST_TYPE;
$menu_order = 10;
foreach ( $defaults as $title => $url ) {
if ( ! $url ) {
continue;
}
$extra_field = array(
'post_type' => $post_type,
'post_title' => $title,
'post_status' => 'publish',
'post_author' => $user_id,
'post_content' => self::make_paragraph_block( Link::the_content( $url ) ),
'comment_status' => 'closed',
'menu_order' => $menu_order,
);
$menu_order += 10;
$extra_field_id = wp_insert_post( $extra_field );
$extra_fields[] = get_post( $extra_field_id );
}
$is_blog
? \update_option( 'activitypub_default_extra_fields', true )
: \update_user_meta( $user_id, 'activitypub_default_extra_fields', true );
return $extra_fields;
}
/**
* Create a paragraph block.
*
* @param string $content The content.
*
* @return string The paragraph block.
*/
public static function make_paragraph_block( $content ) {
if ( ! site_supports_blocks() ) {
return $content;
}
return '<!-- wp:paragraph --><p>' . $content . '</p><!-- /wp:paragraph -->';
}
/**
* Add the 'me' rel to the link.
*
* @param string $rel The rel attribute.
* @return string The modified rel attribute.
*/
public static function add_rel_me( $rel ) {
return $rel . ' me';
}
/**
* Checks if the user is the blog user.
*
* @param int $user_id The user ID.
* @return bool True if the user is the blog user, otherwise false.
*/
private static function is_blog( $user_id ) {
return Actors::BLOG_USER_ID === $user_id;
}
}

View File

@ -1,32 +1,36 @@
<?php
/**
* Followers collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Model\Follower;
use WP_Error;
use WP_Query;
use Activitypub\Http;
use Activitypub\Webfinger;
use Activitypub\Model\Follower;
use function Activitypub\is_tombstone;
use function Activitypub\get_remote_metadata_by_actor;
/**
* ActivityPub Followers Collection
* ActivityPub Followers Collection.
*
* @author Matt Wiebe
* @author Matthias Pfefferle
*/
class Followers {
const POST_TYPE = 'ap_follower';
const POST_TYPE = 'ap_follower';
const CACHE_KEY_INBOXES = 'follower_inboxes_%s';
/**
* Add new Follower
* Add new Follower.
*
* @param int $user_id The ID of the WordPress User
* @param string $actor The Actor URL
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return array|WP_Error The Follower (WP_Post array) or an WP_Error
* @return Follower|WP_Error The Follower (WP_Post array) or an WP_Error.
*/
public static function add_follower( $user_id, $actor ) {
$meta = get_remote_metadata_by_actor( $actor );
@ -48,11 +52,11 @@ class Followers {
return $id;
}
$post_meta = get_post_meta( $id, 'activitypub_user_id' );
$post_meta = get_post_meta( $id, '_activitypub_user_id', false );
// phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
if ( is_array( $post_meta ) && ! in_array( $user_id, $post_meta ) ) {
add_post_meta( $id, 'activitypub_user_id', $user_id );
add_post_meta( $id, '_activitypub_user_id', $user_id );
wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
}
@ -60,12 +64,12 @@ class Followers {
}
/**
* Remove a Follower
* Remove a Follower.
*
* @param int $user_id The ID of the WordPress User
* @param string $actor The Actor URL
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return bool|WP_Error True on success, false or WP_Error on failure.
* @return bool True on success, false on failure.
*/
public static function remove_follower( $user_id, $actor ) {
wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
@ -76,24 +80,33 @@ class Followers {
return false;
}
return delete_post_meta( $follower->get__id(), 'activitypub_user_id', $user_id );
/**
* Fires before a Follower is removed.
*
* @param Follower $follower The Follower object.
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*/
do_action( 'activitypub_followers_pre_remove_follower', $follower, $user_id, $actor );
return delete_post_meta( $follower->get__id(), '_activitypub_user_id', $user_id );
}
/**
* Get a Follower.
*
* @param int $user_id The ID of the WordPress User
* @param string $actor The Actor URL
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return \Activitypub\Model\Follower|null The Follower object or null
* @return Follower|false|null The Follower object or null
*/
public static function get_follower( $user_id, $actor ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT DISTINCT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key = 'activitypub_user_id' AND pm.meta_value = %d AND p.guid = %s",
"SELECT DISTINCT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key = '_activitypub_user_id' AND pm.meta_value = %d AND p.guid = %s",
array(
esc_sql( self::POST_TYPE ),
esc_sql( $user_id ),
@ -111,16 +124,16 @@ class Followers {
}
/**
* Get a Follower by Actor indepenent from the User.
* Get a Follower by Actor independent of the User.
*
* @param string $actor The Actor URL.
*
* @return \Activitypub\Model\Follower|null The Follower object or null
* @return Follower|false|null The Follower object or false on failure.
*/
public static function get_follower_by_actor( $actor ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE guid=%s",
@ -137,13 +150,13 @@ class Followers {
}
/**
* Get the Followers of a given user
* Get the Followers of a given user.
*
* @param int $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
* @return array List of `Follower` objects.
* @param int $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
* @return Follower[] List of `Follower` objects.
*/
public static function get_followers( $user_id, $number = -1, $page = null, $args = array() ) {
$data = self::get_followers_with_count( $user_id, $number, $page, $args );
@ -153,14 +166,17 @@ class Followers {
/**
* Get the Followers of a given user, along with a total count for pagination purposes.
*
* @param int $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
* @param int $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array
* followers List of `Follower` objects.
* total Total number of followers.
* @return array {
* Data about the followers.
*
* @type Follower[] $followers List of `Follower` objects.
* @type int $total Total number of followers.
* }
*/
public static function get_followers_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
$defaults = array(
@ -172,30 +188,25 @@ class Followers {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_user_id',
'key' => '_activitypub_user_id',
'value' => $user_id,
),
),
);
$args = wp_parse_args( $args, $defaults );
$query = new WP_Query( $args );
$total = $query->found_posts;
$followers = array_map(
function ( $post ) {
return Follower::init_from_cpt( $post );
},
$query->get_posts()
);
$args = wp_parse_args( $args, $defaults );
$query = new WP_Query( $args );
$total = $query->found_posts;
$followers = array_map( array( Follower::class, 'init_from_cpt' ), $query->get_posts() );
$followers = array_filter( $followers );
return compact( 'followers', 'total' );
}
/**
* Get all Followers
* Get all Followers.
*
* @param array $args The WP_Query arguments.
*
* @return array The Term list of Followers.
* @return Follower[] The Term list of Followers.
*/
public static function get_all_followers() {
$args = array(
@ -204,11 +215,11 @@ class Followers {
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'compare' => 'EXISTS',
),
array(
'key' => 'activitypub_actor_json',
'key' => '_activitypub_actor_json',
'compare' => 'EXISTS',
),
),
@ -219,7 +230,7 @@ class Followers {
/**
* Count the total number of followers
*
* @param int $user_id The ID of the WordPress User
* @param int $user_id The ID of the WordPress User.
*
* @return int The number of Followers
*/
@ -232,15 +243,15 @@ class Followers {
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'activitypub_user_id',
'key' => '_activitypub_user_id',
'value' => $user_id,
),
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'compare' => 'EXISTS',
),
array(
'key' => 'activitypub_actor_json',
'key' => '_activitypub_actor_json',
'compare' => 'EXISTS',
),
),
@ -251,21 +262,21 @@ class Followers {
}
/**
* Returns all Inboxes fo a Users Followers
* Returns all Inboxes for an Actor's Followers.
*
* @param int $user_id The ID of the WordPress User
* @param int $user_id The ID of the WordPress User.
*
* @return array The list of Inboxes
* @return array The list of Inboxes.
*/
public static function get_inboxes( $user_id ) {
$cache_key = sprintf( self::CACHE_KEY_INBOXES, $user_id );
$inboxes = wp_cache_get( $cache_key, 'activitypub' );
$inboxes = wp_cache_get( $cache_key, 'activitypub' );
if ( $inboxes ) {
return $inboxes;
}
// get all Followers of a ID of the WordPress User
// Get all Followers of an ID of the WordPress User.
$posts = new WP_Query(
array(
'nopaging' => true,
@ -275,15 +286,15 @@ class Followers {
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'compare' => 'EXISTS',
),
array(
'key' => 'activitypub_user_id',
'key' => '_activitypub_user_id',
'value' => $user_id,
),
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'value' => '',
'compare' => '!=',
),
@ -303,7 +314,7 @@ class Followers {
$wpdb->prepare(
"SELECT DISTINCT meta_value FROM {$wpdb->postmeta}
WHERE post_id IN (" . implode( ', ', array_fill( 0, count( $posts ), '%d' ) ) . ")
AND meta_key = 'activitypub_inbox'
AND meta_key = '_activitypub_inbox'
AND meta_value IS NOT NULL",
$posts
)
@ -316,13 +327,62 @@ class Followers {
}
/**
* Get all Followers that have not been updated for a given time
* Get all Inboxes for a given Activity.
*
* @param enum $output The output format, supported ARRAY_N, OBJECT and ACTIVITYPUB_OBJECT.
* @param int $number Limits the result.
* @param int $older_than The time in seconds.
* @param string $json The ActivityPub Activity JSON.
* @param int $actor_id The WordPress Actor ID.
* @param int $batch_size Optional. The batch size. Default 50.
* @param int $offset Optional. The offset. Default 0.
*
* @return mixed The Term list of Followers, the format depends on $output.
* @return array The list of Inboxes.
*/
public static function get_inboxes_for_activity( $json, $actor_id, $batch_size = 50, $offset = 0 ) {
$inboxes = self::get_inboxes( $actor_id );
if ( self::maybe_add_inboxes_of_blog_user( $json, $actor_id ) ) {
$inboxes = array_fill_keys( $inboxes, 1 );
foreach ( self::get_inboxes( Actors::BLOG_USER_ID ) as $inbox ) {
$inboxes[ $inbox ] = 1;
}
$inboxes = array_keys( $inboxes );
}
return array_slice( $inboxes, $offset, $batch_size );
}
/**
* Maybe add Inboxes of the Blog User.
*
* @param string $json The ActivityPub Activity JSON.
* @param int $actor_id The WordPress Actor ID.
* @return bool True if the Inboxes of the Blog User should be added, false otherwise.
*/
public static function maybe_add_inboxes_of_blog_user( $json, $actor_id ) {
// Only if we're in both Blog and User modes.
if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE !== \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
return false;
}
// Only if this isn't the Blog Actor.
if ( Actors::BLOG_USER_ID === $actor_id ) {
return false;
}
$activity = json_decode( $json, true );
// Only if this is an Update or Delete. Create handles its own "Announce" in dual user mode.
if ( ! in_array( $activity['type'] ?? null, array( 'Update', 'Delete' ), true ) ) {
return false;
}
return true;
}
/**
* Get all Followers that have not been updated for a given time.
*
* @param int $number Optional. Limits the result. Default 50.
* @param int $older_than Optional. The time in seconds. Default 86400 (1 day).
*
* @return Follower[] The Term list of Followers.
*/
public static function get_outdated_followers( $number = 50, $older_than = 86400 ) {
$args = array(
@ -330,7 +390,7 @@ class Followers {
'posts_per_page' => $number,
'orderby' => 'modified',
'order' => 'ASC',
'post_status' => 'any', // 'any' includes 'trash
'post_status' => 'any', // 'any' includes 'trash'.
'date_query' => array(
array(
'column' => 'post_modified_gmt',
@ -340,22 +400,17 @@ class Followers {
);
$posts = new WP_Query( $args );
$items = array();
$items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() );
foreach ( $posts->get_posts() as $follower ) {
$items[] = Follower::init_from_cpt( $follower ); // phpcs:ignore
}
return $items;
return array_filter( $items );
}
/**
* Get all Followers that had errors
* Get all Followers that had errors.
*
* @param enum $output The output format, supported ARRAY_N, OBJECT and ACTIVITYPUB_OBJECT
* @param integer $number The number of Followers to return.
* @param int $number Optional. The number of Followers to return. Default 20.
*
* @return mixed The Term list of Followers, the format depends on $output.
* @return Follower[] The Term list of Followers.
*/
public static function get_faulty_followers( $number = 20 ) {
$args = array(
@ -365,24 +420,24 @@ class Followers {
'meta_query' => array(
'relation' => 'OR',
array(
'key' => 'activitypub_errors',
'key' => '_activitypub_errors',
'compare' => 'EXISTS',
),
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'compare' => 'NOT EXISTS',
),
array(
'key' => 'activitypub_actor_json',
'key' => '_activitypub_actor_json',
'compare' => 'NOT EXISTS',
),
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'value' => '',
'compare' => '=',
),
array(
'key' => 'activitypub_actor_json',
'key' => '_activitypub_actor_json',
'value' => '',
'compare' => '=',
),
@ -390,21 +445,16 @@ class Followers {
);
$posts = new WP_Query( $args );
$items = array();
$items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() );
foreach ( $posts->get_posts() as $follower ) {
$items[] = Follower::init_from_cpt( $follower ); // phpcs:ignore
}
return $items;
return array_filter( $items );
}
/**
* This function is used to store errors that occur when
* sending an ActivityPub message to a Follower.
*
* The error will be stored in the
* post meta.
* The error will be stored in post meta.
*
* @param int $post_id The ID of the WordPress Custom-Post-Type.
* @param mixed $error The error message. Can be a string or a WP_Error.
@ -425,7 +475,7 @@ class Followers {
return add_post_meta(
$post_id,
'activitypub_errors',
'_activitypub_errors',
$error_message
);
}

View File

@ -1,117 +1,75 @@
<?php
/**
* Interactions collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use WP_Error;
use Activitypub\Webfinger;
use WP_Comment_Query;
use Activitypub\Comment;
use function Activitypub\object_to_uri;
use function Activitypub\is_post_disabled;
use function Activitypub\url_to_commentid;
use function Activitypub\object_id_to_comment;
use function Activitypub\get_remote_metadata_by_actor;
/**
* ActivityPub Interactions Collection
* ActivityPub Interactions Collection.
*/
class Interactions {
const INSERT = 'insert';
const UPDATE = 'update';
/**
* Add a comment to a post
* Add a comment to a post.
*
* @param array $activity The activity-object
* @param array $activity The activity-object.
*
* @return array|false The commentdata or false on failure
* @return int|false|\WP_Error The comment ID or false or WP_Error on failure.
*/
public static function add_comment( $activity ) {
if (
! isset( $activity['object'] ) ||
! isset( $activity['object']['id'] )
) {
$commentdata = self::activity_to_comment( $activity );
if ( ! $commentdata || ! isset( $activity['object']['inReplyTo'] ) ) {
return false;
}
if ( ! isset( $activity['object']['inReplyTo'] ) ) {
return false;
}
$in_reply_to = object_to_uri( $activity['object']['inReplyTo'] );
$in_reply_to = \esc_url_raw( $in_reply_to );
$comment_post_id = \url_to_postid( $in_reply_to );
$parent_comment_id = url_to_commentid( $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
// Save only replies and reactions.
if ( ! $comment_post_id && $parent_comment_id ) {
$parent_comment = get_comment( $parent_comment_id );
$comment_post_id = $parent_comment->comment_post_ID;
}
// not a reply to a post or comment
if ( ! $comment_post_id ) {
if ( is_post_disabled( $comment_post_id ) ) {
return false;
}
$actor = object_to_uri( $activity['actor'] );
$meta = get_remote_metadata_by_actor( $actor );
$commentdata['comment_post_ID'] = $comment_post_id;
$commentdata['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0;
if ( ! $meta || \is_wp_error( $meta ) ) {
return false;
}
$url = object_to_uri( $meta['url'] );
$commentdata = array(
'comment_post_ID' => $comment_post_id,
'comment_author' => isset( $meta['name'] ) ? \esc_attr( $meta['name'] ) : \esc_attr( $meta['preferredUsername'] ),
'comment_author_url' => \esc_url_raw( $url ),
'comment_content' => \addslashes( $activity['object']['content'] ),
'comment_type' => 'comment',
'comment_author_email' => '',
'comment_parent' => $parent_comment_id ? $parent_comment_id : 0,
'comment_meta' => array(
'source_id' => \esc_url_raw( $activity['object']['id'] ),
'protocol' => 'activitypub',
),
);
if ( isset( $meta['icon']['url'] ) ) {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] );
}
if ( isset( $activity['object']['url'] ) ) {
$commentdata['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) );
}
// disable flood control
\remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 );
// do not require email for AP entries
\add_filter( 'pre_option_require_name_email', '__return_false' );
// No nonce possible for this submission route
\add_filter(
'akismet_comment_nonce',
function () {
return 'inactive';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
$comment = \wp_new_comment( $commentdata, true );
\remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10 );
\remove_filter( 'pre_option_require_name_email', '__return_false' );
// re-add flood control
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
return $comment;
return self::persist( $commentdata, self::INSERT );
}
/**
* Update a comment
* Update a comment.
*
* @param array $activity The activity-object
* @param array $activity The activity object.
*
* @return array|string|int|\WP_Error|false The commentdata or false on failure
* @return array|string|int|\WP_Error|false The comment data or false on failure.
*/
public static function update_comment( $activity ) {
$meta = get_remote_metadata_by_actor( $activity['actor'] );
//Determine comment_ID
// Determine comment_ID.
$comment = object_id_to_comment( \esc_url_raw( $activity['object']['id'] ) );
$commentdata = \get_comment( $comment, ARRAY_A );
@ -119,44 +77,62 @@ class Interactions {
return false;
}
//found a local comment id
$commentdata['comment_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] );
// Found a local comment id.
$commentdata['comment_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] );
$commentdata['comment_content'] = \addslashes( $activity['object']['content'] );
if ( isset( $meta['icon']['url'] ) ) {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] );
return self::persist( $commentdata, self::UPDATE );
}
/**
* Adds an incoming Like, Announce, ... as a comment to a post.
*
* @param array $activity Activity array.
*
* @return array|false Comment data or `false` on failure.
*/
public static function add_reaction( $activity ) {
$commentdata = self::activity_to_comment( $activity );
if ( ! $commentdata ) {
return false;
}
// disable flood control
\remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 );
// do not require email for AP entries
\add_filter( 'pre_option_require_name_email', '__return_false' );
// No nonce possible for this submission route
\add_filter(
'akismet_comment_nonce',
function () {
return 'inactive';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
$url = object_to_uri( $activity['object'] );
$comment_post_id = \url_to_postid( $url );
$parent_comment_id = url_to_commentid( $url );
$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 );
if ( 1 === $state ) {
return $commentdata;
} else {
return $state; // Either `false` or a `WP_Error` instance or `0` or `1`!
if ( ! $comment_post_id && $parent_comment_id ) {
$parent_comment = \get_comment( $parent_comment_id );
$comment_post_id = $parent_comment->comment_post_ID;
}
if ( ! $comment_post_id || is_post_disabled( $comment_post_id ) ) {
// Not a reply to a post or comment.
return false;
}
$comment_type = Comment::get_comment_type_by_activity_type( $activity['type'] );
if ( ! $comment_type ) {
// Not a valid comment type.
return false;
}
$comment_content = $comment_type['excerpt'];
$commentdata['comment_post_ID'] = $comment_post_id;
$commentdata['comment_content'] = \esc_html( $comment_content );
$commentdata['comment_type'] = \esc_attr( $comment_type['type'] );
$commentdata['comment_meta']['source_id'] = \esc_url_raw( $activity['id'] );
return self::persist( $commentdata, self::INSERT );
}
/**
* Get interaction(s) for a given URL/ID.
*
* @param strin $url The URL/ID to get interactions for.
* @param string $url The URL/ID to get interactions for.
*
* @return array The interactions as WP_Comment objects.
*/
@ -198,7 +174,7 @@ class Interactions {
public static function get_interactions_by_actor( $actor ) {
$meta = get_remote_metadata_by_actor( $actor );
// get URL, because $actor seems to be the ID
// Get URL, because $actor seems to be the ID.
if ( $meta && ! is_wp_error( $meta ) && isset( $meta['url'] ) ) {
$actor = object_to_uri( $meta['url'] );
}
@ -211,19 +187,18 @@ class Interactions {
array(
'key' => 'protocol',
'value' => 'activitypub',
'compare' => '=',
),
),
);
$comment_query = new WP_Comment_Query( $args );
return $comment_query->comments;
return get_comments( $args );
}
/**
* Adds line breaks to the list of allowed comment tags.
*
* @param array $allowed_tags Allowed HTML tags.
* @param string $context Context.
* @param string $context Optional. Context. Default empty.
*
* @return array Filtered tag list.
*/
@ -244,4 +219,131 @@ class Interactions {
return $allowed_tags;
}
/**
* Convert an Activity to a WP_Comment
*
* @param array $activity The Activity array.
*
* @return array|false The comment data or false on failure.
*/
public static function activity_to_comment( $activity ) {
$comment_content = null;
$actor = object_to_uri( $activity['actor'] ?? null );
$actor = get_remote_metadata_by_actor( $actor );
// Check Actor-Meta.
if ( ! $actor || is_wp_error( $actor ) ) {
return false;
}
// Check Actor-Name.
if ( isset( $actor['name'] ) ) {
$comment_author = $actor['name'];
} elseif ( isset( $actor['preferredUsername'] ) ) {
$comment_author = $actor['preferredUsername'];
} else {
return false;
}
$url = object_to_uri( $actor['url'] ?? $actor['id'] );
if ( ! $url ) {
$url = object_to_uri( $actor['id'] );
}
if ( isset( $activity['object']['content'] ) ) {
$comment_content = \addslashes( $activity['object']['content'] );
}
$webfinger = Webfinger::uri_to_acct( $url );
if ( is_wp_error( $webfinger ) ) {
$webfinger = '';
} else {
$webfinger = str_replace( 'acct:', '', $webfinger );
}
$commentdata = array(
'comment_author' => \esc_attr( $comment_author ),
'comment_author_url' => \esc_url_raw( $url ),
'comment_content' => $comment_content,
'comment_type' => 'comment',
'comment_author_email' => $webfinger,
'comment_meta' => array(
'source_id' => \esc_url_raw( object_to_uri( $activity['object'] ) ),
'protocol' => 'activitypub',
),
);
if ( isset( $actor['icon']['url'] ) ) {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $actor['icon']['url'] );
}
if ( isset( $activity['object']['url'] ) ) {
$commentdata['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) );
}
return $commentdata;
}
/**
* Persist a comment.
*
* @param array $commentdata The commentdata array.
* @param string $action Optional. Either 'insert' or 'update'. Default 'insert'.
*
* @return array|string|int|\WP_Error|false The comment data or false on failure
*/
public static function persist( $commentdata, $action = self::INSERT ) {
// Disable flood control.
\remove_action( 'check_comment_flood', 'check_comment_flood_db' );
// Do not require email for AP entries.
\add_filter( 'pre_option_require_name_email', '__return_false' );
// No nonce possible for this submission route.
\add_filter(
'akismet_comment_nonce',
function () {
return 'inactive';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
if ( self::INSERT === $action ) {
$state = \wp_new_comment( $commentdata, true );
} else {
$state = \wp_update_comment( $commentdata, true );
}
\remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ) );
\remove_filter( 'pre_option_require_name_email', '__return_false' );
// Restore flood control.
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
if ( 1 === $state ) {
return $commentdata;
} else {
return $state; // Either WP_Comment, false, a WP_Error, 0, or 1!
}
}
/**
* Get the total number of interactions by type for a given ID.
*
* @param int $post_id The post ID.
* @param string $type The type of interaction to count.
*
* @return int The total number of interactions.
*/
public static function count_by_type( $post_id, $type ) {
return \get_comments(
array(
'post_id' => $post_id,
'status' => 'approve',
'type' => $type,
'count' => true,
'paging' => false,
'fields' => 'ids',
)
);
}
}

View File

@ -0,0 +1,351 @@
<?php
/**
* Outbox collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Dispatcher;
use Activitypub\Scheduler;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use function Activitypub\add_to_outbox;
/**
* ActivityPub Outbox Collection
*
* @link https://www.w3.org/TR/activitypub/#outbox
*/
class Outbox {
const POST_TYPE = 'ap_outbox';
/**
* Add an Item to the outbox.
*
* @param Activity $activity Full Activity object that will be added to the outbox.
* @param int $user_id The real or imaginary user ID of the actor that published the activity that will be added to the outbox.
* @param string $visibility Optional. The visibility of the content. Default: `ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC`. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`.
*
* @return false|int|\WP_Error The added item or an error.
*/
public static function add( Activity $activity, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) {
$actor_type = Actors::get_type_by_id( $user_id );
$object_id = self::get_object_id( $activity );
$title = self::get_object_title( $activity->get_object() );
if ( ! $activity->get_actor() ) {
$activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
}
$outbox_item = array(
'post_type' => self::POST_TYPE,
'post_title' => sprintf(
/* translators: 1. Activity type, 2. Object Title or Excerpt */
__( '[%1$s] %2$s', 'activitypub' ),
$activity->get_type(),
\wp_trim_words( $title, 5 )
),
'post_content' => wp_slash( $activity->to_json() ),
// ensure that user ID is not below 0.
'post_author' => \max( $user_id, 0 ),
'post_status' => 'pending',
'meta_input' => array(
'_activitypub_object_id' => $object_id,
'_activitypub_activity_type' => $activity->get_type(),
'_activitypub_activity_actor' => $actor_type,
'activitypub_content_visibility' => $visibility,
),
);
$has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
if ( $has_kses ) {
// Prevent KSES from corrupting JSON in post_content.
\kses_remove_filters();
}
$id = \wp_insert_post( $outbox_item, true );
// Update the activity ID if the post was inserted successfully.
if ( $id && ! \is_wp_error( $id ) ) {
$activity->set_id( \get_the_guid( $id ) );
\wp_update_post(
array(
'ID' => $id,
'post_content' => \wp_slash( $activity->to_json() ),
)
);
}
if ( $has_kses ) {
\kses_init_filters();
}
if ( \is_wp_error( $id ) ) {
return $id;
}
if ( ! $id ) {
return false;
}
self::invalidate_existing_items( $object_id, $activity->get_type(), $id );
return $id;
}
/**
* Invalidate existing outbox items with the same activity type and object ID
* by setting their status to 'publish'.
*
* @param string $object_id The ID of the activity object.
* @param string $activity_type The type of the activity.
* @param int $current_id The ID of the current outbox item to exclude.
*
* @return void
*/
private static function invalidate_existing_items( $object_id, $activity_type, $current_id ) {
// Do not invalidate items for Announce activities.
if ( 'Announce' === $activity_type ) {
return;
}
$meta_query = array(
array(
'key' => '_activitypub_object_id',
'value' => $object_id,
),
);
// For non-Delete activities, only invalidate items of the same type.
if ( 'Delete' !== $activity_type ) {
$meta_query[] = array(
'key' => '_activitypub_activity_type',
'value' => $activity_type,
);
}
$existing_items = get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'pending',
'exclude' => array( $current_id ),
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => $meta_query,
'fields' => 'ids',
)
);
foreach ( $existing_items as $existing_item_id ) {
$event_args = array(
Dispatcher::$callback,
$existing_item_id,
Dispatcher::$batch_size,
\get_post_meta( $existing_item_id, '_activitypub_outbox_offset', true ) ?: 0, // phpcs:ignore
);
$timestamp = \wp_next_scheduled( 'activitypub_async_batch', $event_args );
\wp_unschedule_event( $timestamp, 'activitypub_async_batch', $event_args );
$timestamp = \wp_next_scheduled( 'activitypub_process_outbox', array( $existing_item_id ) );
\wp_unschedule_event( $timestamp, 'activitypub_process_outbox', array( $existing_item_id ) );
\wp_publish_post( $existing_item_id );
\delete_post_meta( $existing_item_id, '_activitypub_outbox_offset' );
}
}
/**
* Creates an Undo activity.
*
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
*
* @return int|bool The ID of the outbox item or false on failure.
*/
public static function undo( $outbox_item ) {
$outbox_item = get_post( $outbox_item );
$activity = self::get_activity( $outbox_item );
$type = 'Undo';
if ( 'Create' === $activity->get_type() ) {
$type = 'Delete';
} elseif ( 'Add' === $activity->get_type() ) {
$type = 'Remove';
}
return add_to_outbox( $activity, $type, $outbox_item->post_author );
}
/**
* Reschedule an activity.
*
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
*
* @return bool True if the activity was rescheduled, false otherwise.
*/
public static function reschedule( $outbox_item ) {
$outbox_item = get_post( $outbox_item );
$outbox_item->post_status = 'pending';
$outbox_item->post_date = current_time( 'mysql' );
wp_update_post( $outbox_item );
Scheduler::schedule_outbox_activity_for_federation( $outbox_item->ID );
return true;
}
/**
* Get the Activity object from the Outbox item.
*
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
* @return Activity|\WP_Error The Activity object or WP_Error.
*/
public static function get_activity( $outbox_item ) {
$outbox_item = get_post( $outbox_item );
$actor = self::get_actor( $outbox_item );
if ( is_wp_error( $actor ) ) {
return $actor;
}
$activity_object = \json_decode( $outbox_item->post_content, true );
$type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
if ( $activity_object['type'] === $type ) {
$activity = Activity::init_from_array( $activity_object );
if ( ! $activity->get_actor() ) {
$activity->set_actor( $actor->get_id() );
}
} else {
$activity = new Activity();
$activity->set_type( $type );
$activity->set_id( $outbox_item->guid );
$activity->set_actor( $actor->get_id() );
// Pre-fill the Activity with data (for example cc and to).
$activity->set_object( $activity_object );
}
if ( 'Update' === $type ) {
$activity->set_updated( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_modified ) ) );
}
/**
* Filters the Activity object before it is returned.
*
* @param Activity $activity The Activity object.
* @param \WP_Post $outbox_item The outbox item post object.
*/
return apply_filters( 'activitypub_get_outbox_activity', $activity, $outbox_item );
}
/**
* Get the Actor object from the Outbox item.
*
* @param \WP_Post $outbox_item The Outbox post.
*
* @return \Activitypub\Model\User|\Activitypub\Model\Blog|\WP_Error The Actor object or WP_Error.
*/
public static function get_actor( $outbox_item ) {
$actor_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_actor', true );
switch ( $actor_type ) {
case 'blog':
$actor_id = Actors::BLOG_USER_ID;
break;
case 'application':
$actor_id = Actors::APPLICATION_USER_ID;
break;
case 'user':
default:
$actor_id = $outbox_item->post_author;
break;
}
return Actors::get_by_id( $actor_id );
}
/**
* Get the Activity object from the Outbox item.
*
* @param \WP_Post $outbox_item The Outbox post.
*
* @return Activity|\WP_Error The Activity object or WP_Error.
*/
public static function maybe_get_activity( $outbox_item ) {
if ( ! $outbox_item instanceof \WP_Post ) {
return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
}
if ( 'ap_outbox' !== $outbox_item->post_type ) {
return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
}
// Check if Outbox Activity is public.
$visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
if ( ! in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ), true ) ) {
return new \WP_Error( 'private_outbox_item', 'Not a public Outbox item.' );
}
$activity_types = \apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) );
$activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
if ( ! in_array( $activity_type, $activity_types, true ) ) {
return new \WP_Error( 'private_outbox_item', 'Not public Outbox item type.' );
}
return self::get_activity( $outbox_item );
}
/**
* Get the object ID of an activity.
*
* @param Activity|Base_Object|string $data The activity object.
*
* @return string The object ID.
*/
private static function get_object_id( $data ) {
$object = $data->get_object();
if ( is_object( $object ) ) {
return self::get_object_id( $object );
}
if ( is_string( $object ) ) {
return $object;
}
return $data->get_id() ?? $data->get_actor();
}
/**
* Get the title of an activity recursively.
*
* @param Base_Object $activity_object The activity object.
*
* @return string The title.
*/
private static function get_object_title( $activity_object ) {
if ( ! $activity_object ) {
return '';
}
if ( is_string( $activity_object ) ) {
$post_id = url_to_postid( $activity_object );
return $post_id ? get_the_title( $post_id ) : '';
}
$title = $activity_object->get_name() ?? $activity_object->get_content();
if ( ! $title && $activity_object->get_object() instanceof Base_Object ) {
$title = $activity_object->get_object()->get_name() ?? $activity_object->get_object()->get_content();
}
return $title;
}
}

View File

@ -0,0 +1,226 @@
<?php
/**
* Replies collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use WP_Post;
use WP_Comment;
use WP_Error;
use Activitypub\Comment;
use Activitypub\Model\Blog;
use Activitypub\Transformer\Post as PostTransformer;
use Activitypub\Transformer\Comment as CommentTransformer;
use function Activitypub\is_post_disabled;
use function Activitypub\is_local_comment;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\is_user_type_disabled;
/**
* Class containing code for getting replies Collections and CollectionPages of posts and comments.
*/
class Replies {
/**
* Build base arguments for fetching the comments of either a WordPress post or comment.
*
* @param WP_Post|WP_Comment|WP_Error $wp_object The post or comment to fetch replies for on success.
*/
private static function build_args( $wp_object ) {
$args = array(
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
'type' => 'comment',
);
if ( $wp_object instanceof WP_Post ) {
$args['parent'] = 0; // TODO: maybe this is unnecessary.
$args['post_id'] = $wp_object->ID;
} elseif ( $wp_object instanceof WP_Comment ) {
$args['parent'] = $wp_object->comment_ID;
} else {
return new WP_Error();
}
return $args;
}
/**
* Get the replies collections ID.
*
* @param WP_Post|WP_Comment $wp_object The post or comment to fetch replies for.
*
* @return string|WP_Error The rest URL of the replies collection or WP_Error if the object is not a post or comment.
*/
private static function get_id( $wp_object ) {
if ( $wp_object instanceof WP_Post ) {
return get_rest_url_by_path( sprintf( 'posts/%d/replies', $wp_object->ID ) );
} elseif ( $wp_object instanceof WP_Comment ) {
return get_rest_url_by_path( sprintf( 'comments/%d/replies', $wp_object->comment_ID ) );
} else {
return new WP_Error( 'unsupported_object', 'The object is not a post or comment.' );
}
}
/**
* Get the Replies collection.
*
* @param WP_Post|WP_Comment $wp_object The post or comment to fetch replies for.
*
* @return array|\WP_Error|null An associative array containing the replies collection without JSON-LD context on success.
*/
public static function get_collection( $wp_object ) {
$id = self::get_id( $wp_object );
if ( is_wp_error( $id ) ) {
return \wp_is_serving_rest_request() ? $id : null;
}
$replies = array(
'id' => $id,
'type' => 'Collection',
);
$replies['first'] = self::get_collection_page( $wp_object, 1, $replies['id'] );
return $replies;
}
/**
* Returns a replies collection page as an associative array.
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage
*
* @param WP_Post|WP_Comment $wp_object The post of comment the replies are for.
* @param int $page The current pagination page.
* @param string $part_of Optional. The collection id/url the returned CollectionPage belongs to. Default null.
*
* @return array|WP_Error|null A CollectionPage as an associative array on success, WP_Error or null on failure.
*/
public static function get_collection_page( $wp_object, $page, $part_of = null ) {
// Build initial arguments for fetching approved comments.
$args = self::build_args( $wp_object );
if ( is_wp_error( $args ) ) {
return \wp_is_serving_rest_request() ? $args : null;
}
// Retrieve the partOf if not already given.
$part_of = $part_of ?? self::get_id( $wp_object );
// If the collection page does not exist.
if ( is_wp_error( $part_of ) ) {
return \wp_is_serving_rest_request() ? $part_of : null;
}
// Get to total replies count.
$total_replies = \get_comments( array_merge( $args, array( 'count' => true ) ) );
// If set to zero, we get errors below. You need at least one comment per page, here.
$args['number'] = max( (int) \get_option( 'comments_per_page' ), 1 );
$args['offset'] = intval( $page - 1 ) * $args['number'];
// Get the ActivityPub ID's of the comments, without local-only comments.
$comment_ids = self::get_reply_ids( \get_comments( $args ) );
// Build the associative CollectionPage array.
$collection_page = array(
'id' => \add_query_arg( 'page', $page, $part_of ),
'type' => 'CollectionPage',
'partOf' => $part_of,
'items' => $comment_ids,
);
if ( ( $total_replies / $args['number'] ) > $page ) {
$collection_page['next'] = \add_query_arg( 'page', $page + 1, $part_of );
}
if ( $page > 1 ) {
$collection_page['prev'] = \add_query_arg( 'page', $page - 1, $part_of );
}
return $collection_page;
}
/**
* Get the context collection for a post.
*
* @param int $post_id The post ID.
*
* @return array|false The context for the post or false if the post is not found or disabled.
*/
public static function get_context_collection( $post_id ) {
$post = \get_post( $post_id );
if ( ! $post || is_post_disabled( $post_id ) ) {
return false;
}
$comments = \get_comments(
array(
'post_id' => $post_id,
'type' => 'comment',
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
)
);
$ids = self::get_reply_ids( $comments, true );
$post_uri = ( new PostTransformer( $post ) )->to_id();
\array_unshift( $ids, $post_uri );
$author = Actors::get_by_id( $post->post_author );
if ( is_wp_error( $author ) ) {
if ( is_user_type_disabled( 'blog' ) ) {
return false;
}
$author = new Blog();
}
return array(
'type' => 'OrderedCollection',
'url' => \get_permalink( $post_id ),
'attributedTo' => $author->get_id(),
'totalItems' => count( $ids ),
'items' => $ids,
);
}
/**
* Get the ActivityPub ID's from a list of comments.
*
* It takes only federated/non-local comments into account, others also do not have an
* ActivityPub ID available.
*
* @param WP_Comment[] $comments The comments to retrieve the ActivityPub ids from.
* @param boolean $include_blog_comments Optional. Include blog comments in the returned array. Default false.
*
* @return string[] A list of the ActivityPub ID's.
*/
private static function get_reply_ids( $comments, $include_blog_comments = false ) {
$comment_ids = array();
foreach ( $comments as $comment ) {
if ( is_local_comment( $comment ) ) {
continue;
}
$public_comment_id = Comment::get_source_id( $comment->comment_ID );
if ( $public_comment_id ) {
$comment_ids[] = $public_comment_id;
continue;
}
if ( $include_blog_comments ) {
$comment_ids[] = ( new CommentTransformer( $comment ) )->to_id();
}
}
return \array_unique( $comment_ids );
}
}

View File

@ -1,66 +1,29 @@
<?php
/**
* Users collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use WP_Error;
use WP_User_Query;
use Activitypub\Model\User;
use Activitypub\Model\Blog;
use Activitypub\Model\Application;
use function Activitypub\object_to_uri;
use function Activitypub\normalize_url;
use function Activitypub\normalize_host;
use function Activitypub\url_to_authorid;
use function Activitypub\is_user_disabled;
class Users {
/**
* Users collection.
*
* @deprecated version 4.2.0
*/
class Users extends Actors {
/**
* The ID of the Blog User
*
* @var int
*/
const BLOG_USER_ID = 0;
/**
* The ID of the Application User
*
* @var int
*/
const APPLICATION_USER_ID = -1;
/**
* Get the User by ID
* Get the User by ID.
*
* @param int $user_id The User-ID.
*
* @return \Acitvitypub\Model\User The User.
* @return User|Blog|Application|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_id( $user_id ) {
if ( is_string( $user_id ) || is_numeric( $user_id ) ) {
$user_id = (int) $user_id;
}
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_id' );
if ( is_user_disabled( $user_id ) ) {
return new WP_Error(
'activitypub_user_not_found',
\__( 'User not found', 'activitypub' ),
array( 'status' => 404 )
);
}
if ( self::BLOG_USER_ID === $user_id ) {
return new Blog();
} elseif ( self::APPLICATION_USER_ID === $user_id ) {
return new Application();
} elseif ( $user_id > 0 ) {
return User::from_wp_user( $user_id );
}
return new WP_Error(
'activitypub_user_not_found',
\__( 'User not found', 'activitypub' ),
array( 'status' => 404 )
);
return parent::get_by_id( $user_id );
}
/**
@ -68,191 +31,38 @@ class Users {
*
* @param string $username The User-Name.
*
* @return \Acitvitypub\Model\User The User.
* @return User|Blog|Application|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_username( $username ) {
// check for blog user.
if ( Blog::get_default_username() === $username ) {
return new Blog();
}
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_username' );
if ( get_option( 'activitypub_blog_user_identifier' ) === $username ) {
return new Blog();
}
// check for application user.
if ( 'application' === $username ) {
return new Application();
}
// check for 'activitypub_username' meta
$user = new WP_User_Query(
array(
'count_total' => false,
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'OR',
array(
'key' => 'activitypub_user_identifier',
'value' => $username,
'compare' => 'LIKE',
),
),
)
);
if ( $user->results ) {
return self::get_by_id( $user->results[0] );
}
$username = str_replace( array( '*', '%' ), '', $username );
// check for login or nicename.
$user = new WP_User_Query(
array(
'count_total' => false,
'search' => $username,
'search_columns' => array( 'user_login', 'user_nicename' ),
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
)
);
if ( $user->results ) {
return self::get_by_id( $user->results[0] );
}
return new WP_Error(
'activitypub_user_not_found',
\__( 'User not found', 'activitypub' ),
array( 'status' => 404 )
);
return parent::get_by_username( $username );
}
/**
* Get the User by resource.
*
* @param string $resource The User-Resource.
* @param string $uri The User-Resource.
*
* @return \Acitvitypub\Model\User The User.
* @return User|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_resource( $resource ) {
$resource = object_to_uri( $resource );
public static function get_by_resource( $uri ) {
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_resource' );
$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] );
}
switch ( $scheme ) {
// check for http(s) URIs
case 'http':
case 'https':
$resource_path = \wp_parse_url( $resource, PHP_URL_PATH );
if ( $resource_path ) {
$blog_path = \wp_parse_url( \home_url(), PHP_URL_PATH );
if ( $blog_path ) {
$resource_path = \str_replace( $blog_path, '', $resource_path );
}
$resource_path = \trim( $resource_path, '/' );
// check for http(s)://blog.example.com/@username
if ( str_starts_with( $resource_path, '@' ) ) {
$identifier = \str_replace( '@', '', $resource_path );
$identifier = \trim( $identifier, '/' );
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 (
normalize_url( site_url() ) === normalize_url( $resource ) ||
normalize_url( home_url() ) === 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 = normalize_host( \substr( \strrchr( $resource, '@' ), 1 ) );
$blog_host = 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 parent::get_by_resource( $uri );
}
/**
* Get the User by resource.
*
* @param string $resource The User-Resource.
* @param string $id The User-Resource.
*
* @return \Acitvitypub\Model\User The User.
* @return User|Blog|Application|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_various( $id ) {
$user = null;
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_various' );
if ( is_numeric( $id ) ) {
$user = self::get_by_id( $id );
} elseif (
// is URL
filter_var( $id, FILTER_VALIDATE_URL ) ||
// is acct
str_starts_with( $id, 'acct:' ) ||
// is email
filter_var( $id, FILTER_VALIDATE_EMAIL )
) {
$user = self::get_by_resource( $id );
}
if ( $user && ! is_wp_error( $user ) ) {
return $user;
}
return self::get_by_username( $id );
return parent::get_by_various( $id );
}
/**
@ -261,18 +71,8 @@ class Users {
* @return array The User collection.
*/
public static function get_collection() {
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_collection' );
$return = array();
foreach ( $users as $user ) {
$return[] = User::from_wp_user( $user->ID );
}
return $return;
return parent::get_collection();
}
}

View File

@ -1,6 +1,8 @@
<?php
/**
* ActivityPub implementation for WordPress/PHP functions either missing from older WordPress/PHP versions or not included by default.
*
* @package Activitypub
*/
if ( ! function_exists( 'str_starts_with' ) ) {
@ -23,19 +25,6 @@ if ( ! function_exists( 'str_starts_with' ) ) {
}
}
if ( ! function_exists( 'get_self_link' ) ) {
/**
* Returns the link for the currently displayed feed.
*
* @return string Correct link for the atom:self element.
*/
function get_self_link() {
$host = wp_parse_url( home_url() );
$path = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
return esc_url( apply_filters( 'self_link', set_url_scheme( 'http://' . $host['host'] . $path ) ) );
}
}
if ( ! function_exists( 'is_countable' ) ) {
/**
* Polyfill for `is_countable()` function added in PHP 7.3.
@ -56,18 +45,28 @@ if ( ! function_exists( 'is_countable' ) ) {
* @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 ) ) {
/**
* Check if an array is a list.
*
* An array is considered a list if its keys are a range of numbers
* starting from 0 and ending at count( $array ) - 1.
*
* @param array $input The array to check.
*
* @return bool True if `$input` is a list, otherwise false.
*/
function array_is_list( $input ) {
if ( ! is_array( $input ) ) {
return false;
}
if ( array_values( $array ) === $array ) {
if ( array_values( $input ) === $input ) {
return true;
}
$next_key = -1;
foreach ( $array as $k => $v ) {
foreach ( $input as $k => $v ) {
if ( ++$next_key !== $k ) {
return false;
}
@ -97,3 +96,16 @@ if ( ! function_exists( 'str_contains' ) ) {
return false !== strpos( $haystack, $needle );
}
}
if ( ! function_exists( 'wp_is_serving_rest_request' ) ) {
/**
* Polyfill for `wp_is_serving_rest_request()` function added in WordPress 6.5.
*
* @see https://developer.wordpress.org/reference/functions/wp_is_serving_rest_request/
*
* @return bool True if it's a WordPress REST API request, false otherwise.
*/
function wp_is_serving_rest_request() {
return defined( 'REST_REQUEST' ) && REST_REQUEST;
}
}

View File

@ -0,0 +1,76 @@
<?php
/**
* Plugin constants.
*
* @package Activitypub
*/
// The following constants can be defined in your wp-config.php file to override the default values.
\defined( 'ACTIVITYPUB_REST_NAMESPACE' ) || \define( 'ACTIVITYPUB_REST_NAMESPACE', 'activitypub/1.0' );
\defined( 'ACTIVITYPUB_EXCERPT_LENGTH' ) || \define( 'ACTIVITYPUB_EXCERPT_LENGTH', 400 );
\defined( 'ACTIVITYPUB_NOTE_LENGTH' ) || \define( 'ACTIVITYPUB_NOTE_LENGTH', 400 );
\defined( 'ACTIVITYPUB_SHOW_PLUGIN_RECOMMENDATIONS' ) || \define( 'ACTIVITYPUB_SHOW_PLUGIN_RECOMMENDATIONS', true );
\defined( 'ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS' ) || \define( 'ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS', 3 );
\defined( 'ACTIVITYPUB_HASHTAGS_REGEXP' ) || \define( 'ACTIVITYPUB_HASHTAGS_REGEXP', '(?:(?<=\s)|(?<=<p>)|(?<=<br>)|^)#([A-Za-z0-9_]+)(?:(?=\s|[[:punct:]]|$))' );
\defined( 'ACTIVITYPUB_USERNAME_REGEXP' ) || \define( 'ACTIVITYPUB_USERNAME_REGEXP', '(?:([A-Za-z0-9\._-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))' );
\defined( 'ACTIVITYPUB_URL_REGEXP' ) || \define( 'ACTIVITYPUB_URL_REGEXP', '(https?:|www\.)\S+[\w\/]' );
\defined( 'ACTIVITYPUB_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "[ap_title type=\"html\"]\n\n[ap_content]\n\n[ap_hashtags]" );
\defined( 'ACTIVITYPUB_DISABLE_REWRITES' ) || \define( 'ACTIVITYPUB_DISABLE_REWRITES', false );
\defined( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS', false );
\defined( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS', false );
\defined( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE' ) || \define( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE', 'wordpress-post-format' );
\defined( 'ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE' ) || \define( 'ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE', 100 );
// The following constants are invariable and define values used throughout the plugin.
/*
* Mastodon HTML sanitizer.
*
* @see https://docs.joinmastodon.org/spec/activitypub/#sanitization
*/
\define(
'ACTIVITYPUB_MASTODON_HTML_SANITIZER',
array(
'p' => array(),
'span' => array( 'class' => true ),
'br' => array(),
'a' => array(
'href' => true,
'rel' => true,
'class' => true,
),
'del' => array(),
'pre' => array(),
'code' => array(),
'em' => array(),
'strong' => array(),
'b' => array(),
'i' => array(),
'u' => array(),
'ul' => array(),
'ol' => array(
'start' => true,
'reversed' => true,
),
'li' => array( 'value' => true ),
'blockquote' => array(),
'h1' => array(),
'h2' => array(),
'h3' => array(),
'h4' => array(),
)
);
\define( 'ACTIVITYPUB_DATE_TIME_RFC3339', 'Y-m-d\TH:i:s\Z' );
// Define Actor-Modes for the plugin.
\define( 'ACTIVITYPUB_ACTOR_MODE', 'actor' );
\define( 'ACTIVITYPUB_BLOG_MODE', 'blog' );
\define( 'ACTIVITYPUB_ACTOR_AND_BLOG_MODE', 'actor_blog' );
// Post visibility constants.
\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC', '' );
\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC', 'quiet_public' );
\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE', 'private' );
\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL', 'local' );

View File

@ -1,17 +1,79 @@
<?php
/**
* Debugging functions.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* Allow localhost URLs if WP_DEBUG is true.
*
* @param array $r Array of HTTP request args.
* @param string $url The request URL.
* @param array $parsed_args An array of HTTP request arguments.
*
* @return array Array or string of HTTP request arguments.
*/
function allow_localhost( $r, $url ) {
$r['reject_unsafe_urls'] = false;
function allow_localhost( $parsed_args ) {
$parsed_args['reject_unsafe_urls'] = false;
return $r;
return $parsed_args;
}
add_filter( 'http_request_args', '\Activitypub\allow_localhost', 10, 2 );
\add_filter( 'http_request_args', '\Activitypub\allow_localhost' );
/**
* Debug the outbox post type.
*
* @param array $args The arguments for the post type.
* @param string $post_type The post type.
*
* @return array The arguments for the post type.
*/
function debug_outbox_post_type( $args, $post_type ) {
if ( 'ap_outbox' !== $post_type ) {
return $args;
}
$args['show_ui'] = true;
$args['menu_icon'] = 'dashicons-upload';
return $args;
}
\add_filter( 'register_post_type_args', '\Activitypub\debug_outbox_post_type', 10, 2 );
/**
* Debug the outbox post type column.
*
* @param array $columns The columns.
* @param string $post_type The post type.
*
* @return array The updated columns.
*/
function debug_outbox_post_type_column( $columns, $post_type ) {
if ( 'ap_outbox' !== $post_type ) {
return $columns;
}
$columns['ap_outbox_meta'] = 'Meta';
return $columns;
}
\add_filter( 'manage_posts_columns', '\Activitypub\debug_outbox_post_type_column', 10, 2 );
/**
* Debug the outbox post type meta.
*
* @param string $column_name The column name.
* @param int $post_id The post ID.
*
* @return void
*/
function manage_posts_custom_column( $column_name, $post_id ) {
if ( 'ap_outbox_meta' === $column_name ) {
$meta = \get_post_meta( $post_id );
foreach ( $meta as $key => $value ) {
echo \esc_attr( $key ) . ': ' . \esc_html( $value[0] ) . '<br>';
}
}
}
\add_action( 'manage_posts_custom_column', '\Activitypub\manage_posts_custom_column', 10, 2 );

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,25 @@
<?php
/**
* Announce handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Http;
use Activitypub\Comment;
use Activitypub\Collection\Interactions;
use function Activitypub\object_to_uri;
use function Activitypub\is_activity_public;
/**
* Handle Create requests
* Handle Create requests.
*/
class Announce {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
@ -22,35 +31,30 @@ class Announce {
}
/**
* Handles "Announce" requests
* Handles "Announce" requests.
*
* @param array $array The activity-object
* @param int $user_id The id of the local blog-user
* @param Activitypub\Activity $activity The activity object
*
* @return void
* @param array $announcement The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \Activitypub\Activity\Activity $activity The activity object.
*/
public static function handle_announce( $array, $user_id, $activity = null ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.arrayFound
if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
return;
}
if ( ! isset( $array['object'] ) ) {
return;
}
// check if Activity is public or not
if ( ! is_activity_public( $array ) ) {
public static function handle_announce( $announcement, $user_id, $activity = null ) {
// Check if Activity is public or not.
if ( ! is_activity_public( $announcement ) ) {
// @todo maybe send email
return;
}
// @todo save the `Announce`-Activity itself
// Check if reposts are allowed.
if ( ! Comment::is_comment_type_enabled( 'repost' ) ) {
return;
}
if ( is_string( $array['object'] ) ) {
$object = Http::get_remote_object( $array['object'] );
self::maybe_save_announce( $announcement, $user_id );
if ( is_string( $announcement['object'] ) ) {
$object = Http::get_remote_object( $announcement['object'] );
} else {
$object = $array['object'];
$object = $announcement['object'];
}
if ( ! $object || is_wp_error( $object ) ) {
@ -63,7 +67,59 @@ class Announce {
$type = \strtolower( $object['type'] );
/**
* Fires after an Announce has been received.
*
* @param array $object The object.
* @param int $user_id The id of the local blog-user.
* @param string $type The type of the activity.
* @param \Activitypub\Activity\Activity|null $activity The activity object.
*/
\do_action( 'activitypub_inbox', $object, $user_id, $type, $activity );
/**
* Fires after an Announce of a specific type has been received.
*
* @param array $object The object.
* @param int $user_id The id of the local blog-user.
* @param \Activitypub\Activity\Activity|null $activity The activity object.
*/
\do_action( "activitypub_inbox_{$type}", $object, $user_id, $activity );
}
/**
* Try to save the Announce.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
*/
public static function maybe_save_announce( $activity, $user_id ) {
$url = object_to_uri( $activity['object'] );
if ( empty( $url ) ) {
return;
}
$exists = Comment::object_id_to_comment( esc_url_raw( $url ) );
if ( $exists ) {
return;
}
$state = Interactions::add_reaction( $activity );
$reaction = null;
if ( $state && ! is_wp_error( $state ) ) {
$reaction = get_comment( $state );
}
/**
* Fires after an Announce has been saved.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param mixed $state The state of the reaction.
* @param mixed $reaction The reaction.
*/
do_action( 'activitypub_handled_announce', $activity, $user_id, $state, $reaction );
}
}

View File

@ -1,18 +1,25 @@
<?php
/**
* Create handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use WP_Error;
use Activitypub\Collection\Interactions;
use function Activitypub\is_self_ping;
use function Activitypub\is_activity_reply;
use function Activitypub\is_activity_public;
use function Activitypub\object_id_to_comment;
/**
* Handle Create requests
* Handle Create requests.
*/
class Create {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
@ -21,50 +28,106 @@ class Create {
10,
3
);
\add_filter(
'activitypub_validate_object',
array( self::class, 'validate_object' ),
10,
3
);
}
/**
* Handles "Create" requests
* Handles "Create" requests.
*
* @param array $array The activity-object
* @param int $user_id The id of the local blog-user
* @param Activitypub\Activity $object The activity object
*
* @return void
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null.
*/
public static function handle_create( $array, $user_id, $object = null ) {
if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
return;
}
public static function handle_create( $activity, $user_id, $activity_object = null ) {
// Check if Activity is public or not.
if (
! isset( $array['object'] ) ||
! isset( $array['object']['id'] )
! is_activity_public( $activity ) ||
! is_activity_reply( $activity )
) {
return;
}
// check if Activity is public or not
if ( ! is_activity_public( $array ) ) {
// @todo maybe send email
return;
}
$check_dupe = object_id_to_comment( $activity['object']['id'] );
$check_dupe = object_id_to_comment( $array['object']['id'] );
// if comment exists, call update action
// If comment exists, call update action.
if ( $check_dupe ) {
\do_action( 'activitypub_inbox_update', $array, $user_id, $object );
/**
* Fires when a Create activity is received for an existing comment.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \Activitypub\Activity\Activity $activity_object The activity object.
*/
\do_action( 'activitypub_inbox_update', $activity, $user_id, $activity_object );
return;
}
$state = Interactions::add_comment( $array );
if ( is_self_ping( $activity['object']['id'] ) ) {
return;
}
$state = Interactions::add_comment( $activity );
$reaction = null;
if ( $state && ! \is_wp_error( $state ) ) {
$reaction = \get_comment( $state );
}
\do_action( 'activitypub_handled_create', $array, $user_id, $state, $reaction );
/**
* Fires after a Create activity has been handled.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \WP_Comment|\WP_Error $state The comment object or WP_Error.
* @param \WP_Comment|\WP_Error|null $reaction The reaction object or WP_Error.
*/
\do_action( 'activitypub_handled_create', $activity, $user_id, $state, $reaction );
}
/**
* Validate the object.
*
* @param bool $valid The validation state.
* @param string $param The object parameter.
* @param \WP_REST_Request $request The request object.
*
* @return bool The validation state: true if valid, false if not.
*/
public static function validate_object( $valid, $param, $request ) {
$json_params = $request->get_json_params();
if ( empty( $json_params['type'] ) ) {
return false;
}
if (
'Create' !== $json_params['type'] ||
is_wp_error( $request )
) {
return $valid;
}
$object = $json_params['object'];
if ( ! is_array( $object ) ) {
return false;
}
$required = array(
'id',
'content',
);
if ( array_intersect( $required, array_keys( $object ) ) !== $required ) {
return false;
}
return $valid;
}
}

View File

@ -1,52 +1,47 @@
<?php
/**
* Delete handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use WP_Error;
use WP_REST_Request;
use Activitypub\Http;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Interactions;
use function Activitypub\object_to_uri;
/**
* Handles Delete requests.
*/
class Delete {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\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
);
// side effect
\add_action(
'activitypub_delete_actor_interactions',
array( self::class, 'delete_interactions' )
);
\add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ) );
\add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 );
\add_action( 'activitypub_delete_actor_interactions', array( self::class, 'delete_interactions' ) );
\add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) );
}
/**
* Handles "Delete" requests.
*
* @param array $activity The delete activity.
* @param int $user_id The ID of the user performing the delete activity.
*/
public static function handle_delete( $activity ) {
$object_type = isset( $activity['object']['type'] ) ? $activity['object']['type'] : '';
switch ( $object_type ) {
// Actor Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
/*
* Actor Types.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
*/
case 'Person':
case 'Group':
case 'Organization':
@ -54,8 +49,12 @@ class Delete {
case 'Application':
self::maybe_delete_follower( $activity );
break;
// Object and Link Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
/*
* Object and Link Types.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
*/
case 'Note':
case 'Article':
case 'Image':
@ -65,26 +64,34 @@ class Delete {
case 'Document':
self::maybe_delete_interaction( $activity );
break;
// Tombstone Type
// @see: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
/*
* Tombstone Type.
*
* @see: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
*/
case 'Tombstone':
self::maybe_delete_interaction( $activity );
break;
// Minimal Activity
// @see https://www.w3.org/TR/activitystreams-core/#example-1
/*
* Minimal Activity.
*
* @see https://www.w3.org/TR/activitystreams-core/#example-1
*/
default:
// ignore non Minimal Activities.
// Ignore non Minimal Activities.
if ( ! is_string( $activity['object'] ) ) {
return;
}
// check if Object is an Actor.
// Check if Object is an Actor.
if ( $activity['actor'] === $activity['object'] ) {
self::maybe_delete_follower( $activity );
} else { // assume a interaction otherwise.
} else { // Assume an interaction otherwise.
self::maybe_delete_interaction( $activity );
}
// maybe handle Delete Activity for other Object Types.
// Maybe handle Delete Activity for other Object Types.
break;
}
}
@ -95,9 +102,10 @@ class Delete {
* @param array $activity The delete activity.
*/
public static function maybe_delete_follower( $activity ) {
/* @var \Activitypub\Model\Follower $follower Follower object. */
$follower = Followers::get_follower_by_actor( $activity['actor'] );
// verify if Actor is deleted.
// Verify that Actor is deleted.
if ( $follower && Http::is_tombstone( $activity['actor'] ) ) {
$follower->delete();
self::maybe_delete_interactions( $activity );
@ -110,7 +118,7 @@ class Delete {
* @param array $activity The delete activity.
*/
public static function maybe_delete_interactions( $activity ) {
// verify if Actor is deleted.
// Verify that Actor is deleted.
if ( Http::is_tombstone( $activity['actor'] ) ) {
\wp_schedule_single_event(
\time(),
@ -123,15 +131,13 @@ class Delete {
/**
* Delete comments from an Actor.
*
* @param array $comments The comments to delete.
* @param string $actor The URL of the actor whose comments to delete.
*/
public static function delete_interactions( $actor ) {
$comments = Interactions::get_interactions_by_actor( $actor );
if ( is_array( $comments ) ) {
foreach ( $comments as $comment ) {
wp_delete_comment( $comment->comment_ID );
}
foreach ( $comments as $comment ) {
wp_delete_comment( $comment, true );
}
}
@ -139,8 +145,6 @@ class Delete {
* Delete a Reaction if URL is a Tombstone.
*
* @param array $activity The delete activity.
*
* @return void
*/
public static function maybe_delete_interaction( $activity ) {
if ( is_array( $activity['object'] ) ) {
@ -175,4 +179,18 @@ class Delete {
return false;
}
/**
* Set the object to the object ID.
*
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @return \Activitypub\Activity\Activity The filtered Activity object.
*/
public static function outbox_activity( $activity ) {
if ( 'Delete' === $activity->get_type() ) {
$activity->set_object( object_to_uri( $activity->get_object() ) );
}
return $activity;
}
}

View File

@ -1,18 +1,25 @@
<?php
/**
* Follow handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Http;
use Activitypub\Notification;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use function Activitypub\add_to_outbox;
/**
* Handle Follow requests
* Handle Follow requests.
*/
class Follow {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
@ -22,44 +29,44 @@ class Follow {
\add_action(
'activitypub_followers_post_follow',
array( self::class, 'send_follow_response' ),
array( self::class, 'queue_accept' ),
10,
4
);
}
/**
* Handle "Follow" requests
* Handle "Follow" requests.
*
* @param array $activity The activity object
* @param int $user_id The user ID
* @param array $activity The activity object.
*/
public static function handle_follow( $activity ) {
$user = Users::get_by_resource( $activity['object'] );
$user = Actors::get_by_resource( $activity['object'] );
if ( ! $user || is_wp_error( $user ) ) {
// If we can not find a user,
// we can not initiate a follow process
// If we can not find a user, we can not initiate a follow process.
return;
}
$user_id = $user->get__id();
// save follower
// Save follower.
$follower = Followers::add_follower(
$user_id,
$activity['actor']
);
do_action(
'activitypub_followers_post_follow',
$activity['actor'],
$activity,
$user_id,
$follower
);
/**
* Fires after a new follower has been added.
*
* @param string $actor The URL of the actor (follower) who initiated the follow.
* @param array $activity The complete activity data of the follow request.
* @param int $user_id The ID of the WordPress user being followed.
* @param \Activitypub\Model\Follower|\WP_Error $follower The Follower object containing the new follower's data.
*/
do_action( 'activitypub_followers_post_follow', $activity['actor'], $activity, $user_id, $follower );
// send notification
// Send notification.
$notification = new Notification(
'follow',
$activity['actor'],
@ -70,25 +77,22 @@ class Follow {
}
/**
* Send Accept response
* Send Accept response.
*
* @param string $actor The Actor URL
* @param array $object The Activity object
* @param int $user_id The ID of the WordPress User
* @param Activitypub\Model\Follower $follower The Follower object
*
* @return void
* @param string $actor The Actor URL.
* @param array $activity_object The Activity object.
* @param int $user_id The ID of the WordPress User.
* @param \Activitypub\Model\Follower|\WP_Error $follower The Follower object.
*/
public static function send_follow_response( $actor, $object, $user_id, $follower ) {
public static function queue_accept( $actor, $activity_object, $user_id, $follower ) {
if ( \is_wp_error( $follower ) ) {
// it is not even possible to send a "Reject" because
// we can not get the Remote-Inbox
// Impossible to send a "Reject" because we can not get the Remote-Inbox.
return;
}
// only send minimal data
$object = array_intersect_key(
$object,
// Only send minimal data.
$activity_object = array_intersect_key(
$activity_object,
array_flip(
array(
'id',
@ -99,21 +103,12 @@ class Follow {
)
);
$user = Users::get_by_id( $user_id );
// get inbox
$inbox = $follower->get_shared_inbox();
// send "Accept" activity
$activity = new Activity();
$activity->set_type( 'Accept' );
$activity->set_object( $object );
$activity->set_actor( $user->get_id() );
$activity->set_to( $actor );
$activity->set_id( $user->get_id() . '#follow-' . \preg_replace( '~^https?://~', '', $actor ) . '-' . \time() );
$activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
$activity->set_object( $activity_object );
$activity->set_to( array( $actor ) );
$activity = $activity->to_json();
Http::post( $inbox, $activity, $user_id );
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
}
}

View File

@ -0,0 +1,80 @@
<?php
/**
* Like handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Comment;
use Activitypub\Collection\Interactions;
use function Activitypub\object_to_uri;
/**
* Handle Like requests.
*/
class Like {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_like', array( self::class, 'handle_like' ), 10, 2 );
\add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) );
}
/**
* Handles "Like" requests.
*
* @param array $like The Activity array.
* @param int $user_id The ID of the local blog user.
*/
public static function handle_like( $like, $user_id ) {
if ( ! Comment::is_comment_type_enabled( 'like' ) ) {
return;
}
$url = object_to_uri( $like['object'] );
if ( empty( $url ) ) {
return;
}
$exists = Comment::object_id_to_comment( esc_url_raw( $url ) );
if ( $exists ) {
return;
}
$state = Interactions::add_reaction( $like );
$reaction = null;
if ( $state && ! is_wp_error( $state ) ) {
$reaction = get_comment( $state );
}
/**
* Fires after a Like has been handled.
*
* @param array $like The Activity array.
* @param int $user_id The ID of the local blog user.
* @param mixed $state The state of the reaction.
* @param mixed $reaction The reaction object.
*/
do_action( 'activitypub_handled_like', $like, $user_id, $state, $reaction );
}
/**
* Set the object to the object ID.
*
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @return \Activitypub\Activity\Activity The filtered Activity object.
*/
public static function outbox_activity( $activity ) {
if ( 'Like' === $activity->get_type() ) {
$activity->set_object( object_to_uri( $activity->get_object() ) );
}
return $activity;
}
}

View File

@ -0,0 +1,213 @@
<?php
/**
* Move handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Http;
use Activitypub\Collection\Followers;
use function Activitypub\object_to_uri;
/**
* Handle Move requests.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-move
* @see https://docs.joinmastodon.org/user/moving/
* @see https://docs.joinmastodon.org/spec/activitypub/#Move
*/
class Move {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_move', array( self::class, 'handle_move' ) );
\add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) );
}
/**
* Handle Move requests.
*
* @param array $activity The JSON "Move" Activity.
*/
public static function handle_move( $activity ) {
$target = self::extract_target( $activity );
$origin = self::extract_origin( $activity );
if ( ! $target || ! $origin ) {
return;
}
$target_object = Http::get_remote_object( $target );
$origin_object = Http::get_remote_object( $origin );
$verified = self::verify_move( $target_object, $origin_object );
if ( ! $verified ) {
return;
}
$target_follower = Followers::get_follower_by_actor( $target );
$origin_follower = Followers::get_follower_by_actor( $origin );
/*
* If the new target is followed, but the origin is not,
* everything is fine, so we can return.
*/
if ( $target_follower && ! $origin_follower ) {
return;
}
/*
* If the new target is not followed, but the origin is,
* update the origin follower to the new target.
*/
if ( ! $target_follower && $origin_follower ) {
$origin_follower->from_array( $target_object );
$origin_follower->set_id( $target );
$origin_id = $origin_follower->upsert();
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$wpdb->posts,
array( 'guid' => sanitize_url( $target ) ),
array( 'ID' => sanitize_key( $origin_id ) )
);
// Clear the cache.
wp_cache_delete( $origin_id, 'posts' );
return;
}
/*
* If the new target is followed, and the origin is followed,
* move users and delete the origin follower.
*/
if ( $target_follower && $origin_follower ) {
$origin_users = \get_post_meta( $origin_follower->get__id(), '_activitypub_user_id', false );
$target_users = \get_post_meta( $target_follower->get__id(), '_activitypub_user_id', false );
// Get all user ids from $origin_users that are not in $target_users.
$users = \array_diff( $origin_users, $target_users );
foreach ( $users as $user_id ) {
\add_post_meta( $target_follower->get__id(), '_activitypub_user_id', $user_id );
}
$origin_follower->delete();
}
}
/**
* Convert the object and origin to the correct format.
*
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @return \Activitypub\Activity\Activity The filtered Activity object.
*/
public static function outbox_activity( $activity ) {
if ( 'Move' === $activity->get_type() ) {
$activity->set_object( object_to_uri( $activity->get_object() ) );
$activity->set_origin( $activity->get_actor() );
$activity->set_target( $activity->get_object() );
}
return $activity;
}
/**
* Extract the target from the activity.
*
* The ActivityStreams spec define the `target` attribute as the
* destination of the activity, but Mastodon uses the `object`
* attribute to move profiles.
*
* @param array $activity The JSON "Move" Activity.
*
* @return string|null The target URI or null if not found.
*/
private static function extract_target( $activity ) {
if ( ! empty( $activity['target'] ) ) {
return object_to_uri( $activity['target'] );
}
if ( ! empty( $activity['object'] ) ) {
return object_to_uri( $activity['object'] );
}
return null;
}
/**
* Extract the origin from the activity.
*
* The ActivityStreams spec define the `origin` attribute as source
* of the activity, but Mastodon uses the `actor` attribute as source
* to move profiles.
*
* @param array $activity The JSON "Move" Activity.
*
* @return string|null The origin URI or null if not found.
*/
private static function extract_origin( $activity ) {
if ( ! empty( $activity['origin'] ) ) {
return object_to_uri( $activity['origin'] );
}
if ( ! empty( $activity['actor'] ) ) {
return object_to_uri( $activity['actor'] );
}
return null;
}
/**
* Verify the move.
*
* @param array $target_object The target object.
* @param array $origin_object The origin object.
*
* @return bool True if the move is verified, false otherwise.
*/
private static function verify_move( $target_object, $origin_object ) {
// Check if both objects are valid.
if ( \is_wp_error( $target_object ) || \is_wp_error( $origin_object ) ) {
return false;
}
// Check if both objects are persons.
if ( 'Person' !== $target_object['type'] || 'Person' !== $origin_object['type'] ) {
return false;
}
// Check if the target and origin are not the same.
if ( $target_object['id'] === $origin_object['id'] ) {
return false;
}
// Check if the target has an alsoKnownAs property.
if ( empty( $target_object['also_known_as'] ) ) {
return false;
}
// Check if the origin is in the alsoKnownAs property of the target.
if ( ! in_array( $origin_object['id'], $target_object['also_known_as'], true ) ) {
return false;
}
// Check if the origin has a movedTo property.
if ( empty( $origin_object['movedTo'] ) ) {
return false;
}
// Check if the movedTo property of the origin is the target.
if ( $origin_object['movedTo'] !== $target_object['id'] ) {
return false;
}
return true;
}
}

View File

@ -1,50 +1,90 @@
<?php
/**
* Undo handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Comment;
use function Activitypub\object_to_uri;
/**
* Handle Undo requests
* Handle Undo requests.
*/
class Undo {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
'activitypub_inbox_undo',
array( self::class, 'handle_undo' )
array( self::class, 'handle_undo' ),
10,
2
);
}
/**
* Handle "Unfollow" requests
* Handle "Unfollow" requests.
*
* @param array $activity The JSON "Undo" Activity
* @param int $user_id The ID of the ID of the WordPress User
* @param array $activity The JSON "Undo" Activity.
* @param int|null $user_id The ID of the user who initiated the "Undo" activity.
*/
public static function handle_undo( $activity ) {
public static function handle_undo( $activity, $user_id ) {
if (
isset( $activity['object']['type'] ) &&
'Follow' === $activity['object']['type'] &&
isset( $activity['object']['object'] )
! isset( $activity['object']['type'] ) ||
! isset( $activity['object']['object'] )
) {
$user_id = object_to_uri( $activity['object']['object'] );
$user = Users::get_by_resource( $user_id );
return;
}
$type = $activity['object']['type'];
$state = false;
// Handle "Unfollow" requests.
if ( 'Follow' === $type ) {
$id = object_to_uri( $activity['object']['object'] );
$user = Actors::get_by_resource( $id );
if ( ! $user || is_wp_error( $user ) ) {
// If we can not find a user,
// we can not initiate a follow process
// If we can not find a user, we can not initiate a follow process.
return;
}
$user_id = $user->get__id();
$actor = object_to_uri( $activity['actor'] );
Followers::remove_follower( $user_id, $actor );
$state = Followers::remove_follower( $user_id, $actor );
}
// Handle "Undo" requests for "Like" and "Create" activities.
if ( in_array( $type, array( 'Like', 'Create', 'Announce' ), true ) ) {
if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
return;
}
$object_id = object_to_uri( $activity['object'] );
$comment = Comment::object_id_to_comment( esc_url_raw( $object_id ) );
if ( empty( $comment ) ) {
return;
}
$state = wp_delete_comment( $comment, true );
}
/**
* Fires after an "Undo" activity has been handled.
*
* @param array $activity The JSON "Undo" Activity.
* @param int|null $user_id The ID of the user who initiated the "Undo" activity otherwise null.
* @param mixed $state The state of the "Undo" activity.
*/
do_action( 'activitypub_handled_undo', $activity, $user_id, $state );
}
}

View File

@ -1,7 +1,13 @@
<?php
/**
* Update handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use WP_Error;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Interactions;
use function Activitypub\get_remote_metadata_by_actor;
@ -11,7 +17,7 @@ use function Activitypub\get_remote_metadata_by_actor;
*/
class Update {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
@ -21,26 +27,32 @@ class Update {
}
/**
* Handle "Update" requests
* Handle "Update" requests.
*
* @param array $array The activity-object
* @param int $user_id The id of the local blog-user
* @param array $activity The Activity object.
*/
public static function handle_update( $array ) {
$object_type = isset( $array['object']['type'] ) ? $array['object']['type'] : '';
public static function handle_update( $activity ) {
$object_type = isset( $activity['object']['type'] ) ? $activity['object']['type'] : '';
switch ( $object_type ) {
// Actor Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
/*
* Actor Types.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
*/
case 'Person':
case 'Group':
case 'Organization':
case 'Service':
case 'Application':
self::update_actor( $array );
self::update_actor( $activity );
break;
// Object and Link Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
/*
* Object and Link Types.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
*/
case 'Note':
case 'Article':
case 'Image':
@ -48,22 +60,23 @@ class Update {
case 'Video':
case 'Event':
case 'Document':
self::update_interaction( $array );
self::update_interaction( $activity );
break;
// Minimal Activity
// @see https://www.w3.org/TR/activitystreams-core/#example-1
/*
* Minimal Activity.
*
* @see https://www.w3.org/TR/activitystreams-core/#example-1
*/
default:
break;
}
}
/**
* Update an Interaction
* Update an Interaction.
*
* @param array $activity The activity-object
* @param int $user_id The id of the local blog-user
*
* @return void
* @param array $activity The Activity object.
*/
public static function update_interaction( $activity ) {
$commentdata = Interactions::update_comment( $activity );
@ -76,20 +89,37 @@ class Update {
$state = $commentdata;
}
/**
* Fires after an Update activity has been handled.
*
* @param array $activity The complete Update activity data.
* @param null $user Always null for Update activities.
* @param int|array $state 1 if comment was updated successfully, error data otherwise.
* @param \WP_Comment|null $reaction The updated comment object if successful, null otherwise.
*/
\do_action( 'activitypub_handled_update', $activity, null, $state, $reaction );
}
/**
* Update an Actor
* Update an Actor.
*
* @param array $activity The activity-object
*
* @return void
* @param array $activity The Activity object.
*/
public static function update_actor( $activity ) {
// update cache
get_remote_metadata_by_actor( $activity['actor'], false );
// Update cache.
$actor = get_remote_metadata_by_actor( $activity['actor'], false );
// @todo maybe also update all interactions
if ( ! $actor || \is_wp_error( $actor ) || ! isset( $actor['id'] ) ) {
return;
}
$follower = Followers::get_follower_by_actor( $actor['id'] );
if ( ! $follower ) {
return;
}
$follower->from_array( $actor );
$follower->upsert();
}
}

View File

@ -1,75 +0,0 @@
<?php
\get_current_screen()->add_help_tab(
array(
'id' => 'template-tags',
'title' => \__( 'Template Tags', 'activitypub' ),
'content' =>
'<p>' . __( 'The following Template Tags are available:', 'activitypub' ) . '</p>' .
'<dl>' .
'<dt><code>[ap_title]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s title.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_content apply_filters="yes"]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s content. With <code>apply_filters</code> you can decide if filters (<code>apply_filters( \'the_content\', $content )</code>) should be applied or not (default is <code>yes</code>). The values can be <code>yes</code> or <code>no</code>. <code>apply_filters</code> attribute is optional.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_excerpt length="400"]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s excerpt (uses <code>the_excerpt</code> if that is set). If no excerpt is provided, will truncate at <code>length</code> (optional, default = 400).', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_permalink type="url"]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s permalink. <code>type</code> can be either: <code>url</code> or <code>html</code> (an &lt;a /&gt; tag). <code>type</code> attribute is optional.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_shortlink type="url"]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s shortlink. <code>type</code> can be either <code>url</code> or <code>html</code> (an &lt;a /&gt; tag). I can recommend <a href="https://wordpress.org/plugins/hum/" target="_blank">Hum</a>, to prettify the Shortlinks. <code>type</code> attribute is optional.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_hashtags]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s tags as hashtags.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_hashcats]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s categories as hashtags.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_image type=full]</code></dt>' .
'<dd>' . \wp_kses( __( 'The URL for the post\'s featured image, defaults to full size. The type attribute can be any of the following: <code>thumbnail</code>, <code>medium</code>, <code>large</code>, <code>full</code>. <code>type</code> attribute is optional.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_author]</code></dt>' .
'<dd>' . \wp_kses( __( 'The author\'s name.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_authorurl]</code></dt>' .
'<dd>' . \wp_kses( __( 'The URL to the author\'s profile page.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_date]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s date.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_time]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s time.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_datetime]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s date/time formated as "date @ time".', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_blogurl]</code></dt>' .
'<dd>' . \wp_kses( __( 'The URL to the site.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_blogname]</code></dt>' .
'<dd>' . \wp_kses( __( 'The name of the site.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_blogdesc]</code></dt>' .
'<dd>' . \wp_kses( __( 'The description of the site.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'</dl>' .
'<p>' . __( 'You may also use any Shortcode normally available to you on your site, however be aware that Shortcodes may significantly increase the size of your content depending on what they do.', 'activitypub' ) . '</p>' .
'<p>' . __( 'Note: the old Template Tags are now deprecated and automatically converted to the new ones.', 'activitypub' ) . '</p>' .
'<p>' . \wp_kses( \__( '<a href="https://github.com/pfefferle/wordpress-activitypub/issues/new" target="_blank">Let me know</a> if you miss a Template Tag.', 'activitypub' ), 'activitypub' ) . '</p>',
)
);
\get_current_screen()->add_help_tab(
array(
'id' => 'glossary',
'title' => \__( 'Glossary', 'activitypub' ),
'content' =>
'<p><h2>' . \__( 'Fediverse', 'activitypub' ) . '</h2></p>' .
'<p>' . \__( 'The Fediverse is a new word made of two words: "federation" + "universe"', 'activitypub' ) . '</p>' .
'<p>' . \__( 'It is a federated social network running on free open software on a myriad of computers across the globe. Many independent servers are interconnected and allow people to interact with one another. There\'s no one central site: you choose a server to register. This ensures some decentralization and sovereignty of data. Fediverse (also called Fedi) has no built-in advertisements, no tricky algorithms, no one big corporation dictating the rules. Instead we have small cozy communities of like-minded people. Welcome!', 'activitypub' ) . '</p>' .
'<p>' . \__( 'For more informations please visit <a href="https://fediverse.party/" target="_blank">fediverse.party</a>', 'activitypub' ) . '</p>' .
'<p><h2>' . \__( 'ActivityPub', 'activitypub' ) . '</h2></p>' .
'<p>' . \__( 'ActivityPub is a decentralized social networking protocol based on the ActivityStreams 2.0 data format. ActivityPub is an official W3C recommended standard published by the W3C Social Web Working Group. It provides a client to server API for creating, updating and deleting content, as well as a federated server to server API for delivering notifications and subscribing to content.', 'activitypub' ) . '</p>' .
'<p><h2>' . \__( 'WebFinger', 'activitypub' ) . '</h2></p>' .
'<p>' . \__( 'WebFinger is used to discover information about people or other entities on the Internet that are identified by a URI using standard Hypertext Transfer Protocol (HTTP) methods over a secure transport. A WebFinger resource returns a JavaScript Object Notation (JSON) object describing the entity that is queried. The JSON object is referred to as the JSON Resource Descriptor (JRD).', 'activitypub' ) . '</p>' .
'<p>' . \__( 'For a person, the type of information that might be discoverable via WebFinger includes a personal profile address, identity service, telephone number, or preferred avatar. For other entities on the Internet, a WebFinger resource might return JRDs containing link relations that enable a client to discover, for example, that a printer can print in color on A4 paper, the physical location of a server, or other static information.', 'activitypub' ) . '</p>' .
'<p>' . \__( 'On Mastodon [and other Plattforms], user profiles can be hosted either locally on the same website as yours, or remotely on a completely different website. The same username may be used on a different domain. Therefore, a Mastodon user\'s full mention consists of both the username and the domain, in the form <code>@username@domain</code>. In practical terms, <code>@user@example.com</code> is not the same as <code>@user@example.org</code>. If the domain is not included, Mastodon will try to find a local user named <code>@username</code>. However, in order to deliver to someone over ActivityPub, the <code>@username@domain</code> mention is not enough mentions must be translated to an HTTPS URI first, so that the remote actor\'s inbox and outbox can be found. (This paragraph is copied from the <a href="https://docs.joinmastodon.org/spec/webfinger/" target="_blank">Mastodon Documentation</a>)', 'activitypub' ) . '</p>' .
'<p>' . \__( 'For more informations please visit <a href="https://webfinger.net/" target="_blank">webfinger.net</a>', 'activitypub' ) . '</p>' .
'<p><h2>' . \__( 'NodeInfo', 'activitypub' ) . '</h2></p>' .
'<p>' . \__( 'NodeInfo is an effort to create a standardized way of exposing metadata about a server running one of the distributed social networks. The two key goals are being able to get better insights into the user base of distributed social networking and the ability to build tools that allow users to choose the best fitting software and server for their needs.', 'activitypub' ) . '</p>' .
'<p>' . \__( 'For more informations please visit <a href="http://nodeinfo.diaspora.software/" target="_blank">nodeinfo.diaspora.software</a>', 'activitypub' ) . '</p>',
)
);
\get_current_screen()->set_help_sidebar(
'<p><strong>' . \__( 'For more information:', 'activitypub' ) . '</strong></p>' .
'<p>' . \__( '<a href="https://wordpress.org/support/plugin/activitypub/">Get support</a>', 'activitypub' ) . '</p>' .
'<p>' . \__( '<a href="https://github.com/automattic/wordpress-activitypub/issues">Report an issue</a>', 'activitypub' ) . '</p>'
);

View File

@ -1,23 +1,34 @@
<?php
/**
* Application model file.
*
* @package Activitypub
*/
namespace Activitypub\Model;
use WP_Query;
use Activitypub\Signature;
use Activitypub\Activity\Actor;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
use function Activitypub\get_rest_url_by_path;
/**
* Application class.
*
* @method int get__id() Gets the internal user ID for the application (always returns APPLICATION_USER_ID).
*/
class Application extends Actor {
/**
* The User-ID
*
* @var int
*/
protected $_id = Users::APPLICATION_USER_ID; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
protected $_id = Actors::APPLICATION_USER_ID; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
/**
* If the User is discoverable.
* Whether the Application is discoverable.
*
* @see https://docs.joinmastodon.org/spec/activitypub/#discoverable
*
@ -28,7 +39,7 @@ class Application extends Actor {
protected $discoverable = false;
/**
* If the User is indexable.
* Whether the Application is indexable.
*
* @context http://joinmastodon.org/ns#indexable
*
@ -39,18 +50,33 @@ class Application extends Actor {
/**
* The WebFinger Resource.
*
* @var string<url>
* @var string
*/
protected $webfinger;
/**
* Returns the type of the object.
*
* @return string The type of the object.
*/
public function get_type() {
return 'Application';
}
/**
* Returns whether the Application manually approves followers.
*
* @return true Whether the Application manually approves followers.
*/
public function get_manually_approves_followers() {
return true;
}
/**
* Returns the ID of the Application.
*
* @return string The ID of the Application.
*/
public function get_id() {
return get_rest_url_by_path( 'application' );
}
@ -70,27 +96,37 @@ class Application extends Actor {
* @return string The User-URL with @-Prefix for the username.
*/
public function get_alternate_url() {
return $this->get_url();
return $this->get_id();
}
/**
* Get the Username.
*
* @return string The Username.
*/
public function get_name() {
return 'application';
}
/**
* Get the preferred username.
*
* @return string The preferred username.
*/
public function get_preferred_username() {
return $this->get_name();
}
/**
/**
* Get the User-Icon.
*
* @return array The User-Icon.
* @return string[] The User-Icon.
*/
public function get_icon() {
// try site icon first
// Try site icon first.
$icon_id = get_option( 'site_icon' );
// try custom logo second
// Try custom logo second.
if ( ! $icon_id ) {
$icon_id = get_theme_mod( 'custom_logo' );
}
@ -105,7 +141,7 @@ class Application extends Actor {
}
if ( ! $icon_url ) {
// fallback to default icon
// Fallback to default icon.
$icon_url = plugins_url( '/assets/img/wp-logo.png', ACTIVITYPUB_PLUGIN_FILE );
}
@ -118,7 +154,7 @@ class Application extends Actor {
/**
* Get the User-Header-Image.
*
* @return array|null The User-Header-Image.
* @return string[]|null The User-Header-Image.
*/
public function get_header_image() {
if ( \has_header_image() ) {
@ -131,6 +167,11 @@ class Application extends Actor {
return null;
}
/**
* Get the first published date.
*
* @return string The published date.
*/
public function get_published() {
$first_post = new WP_Query(
array(
@ -146,7 +187,7 @@ class Application extends Actor {
$time = \time();
}
return \gmdate( 'Y-m-d\TH:i:s\Z', $time );
return \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, $time );
}
/**
@ -176,18 +217,23 @@ class Application extends Actor {
return $this->get_preferred_username() . '@' . \wp_parse_url( \home_url(), \PHP_URL_HOST );
}
/**
* Returns the public key.
*
* @return string[] The public key.
*/
public function get_public_key() {
return array(
'id' => $this->get_id() . '#main-key',
'owner' => $this->get_id(),
'publicKeyPem' => Signature::get_public_key_for( Users::APPLICATION_USER_ID ),
'id' => $this->get_id() . '#main-key',
'owner' => $this->get_id(),
'publicKeyPem' => Signature::get_public_key_for( Actors::APPLICATION_USER_ID ),
);
}
/**
* Get the User-Description.
* Get the User description.
*
* @return string The User-Description.
* @return string The User description.
*/
public function get_summary() {
return \wpautop(
@ -198,6 +244,11 @@ class Application extends Actor {
);
}
/**
* Returns the canonical URL of the object.
*
* @return string|null The canonical URL of the object.
*/
public function get_canonical_url() {
return \home_url();
}

View File

@ -1,17 +1,29 @@
<?php
/**
* Blog model file.
*
* @package Activitypub
*/
namespace Activitypub\Model;
use WP_Query;
use WP_Error;
use Activitypub\Signature;
use Activitypub\Activity\Actor;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Extra_Fields;
use Activitypub\Signature;
use WP_Query;
use function Activitypub\esc_hashtag;
use function Activitypub\is_single_user;
use function Activitypub\is_user_disabled;
use function Activitypub\is_blog_public;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_attribution_domains;
/**
* Blog class.
*
* @method int get__id() Gets the internal user ID for the blog (always returns BLOG_USER_ID).
*/
class Blog extends Actor {
/**
* The Featured-Posts.
@ -41,7 +53,7 @@ class Blog extends Actor {
*
* @var int
*/
protected $_id = Users::BLOG_USER_ID; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
protected $_id = Actors::BLOG_USER_ID; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
/**
* If the User is indexable.
@ -55,12 +67,12 @@ class Blog extends Actor {
/**
* The WebFinger Resource.
*
* @var string<url>
* @var string
*/
protected $webfinger;
/**
* If the User is discoverable.
* Whether the User is discoverable.
*
* @see https://docs.joinmastodon.org/spec/activitypub/#discoverable
*
@ -71,7 +83,7 @@ class Blog extends Actor {
protected $discoverable;
/**
* Restrict posting to mods
* Restrict posting to mods.
*
* @see https://join-lemmy.org/docs/contributors/05-federation.html
*
@ -79,27 +91,61 @@ class Blog extends Actor {
*/
protected $posting_restricted_to_mods;
/**
* Constructor.
*/
public function __construct() {
/**
* Fires when a model actor is constructed.
*
* @param Blog $this The Blog model.
*/
\do_action( 'activitypub_construct_model_actor', $this );
}
/**
* Whether the User manually approves followers.
*
* @return false
*/
public function get_manually_approves_followers() {
return false;
}
/**
* Whether the User is discoverable.
*
* @return boolean
*/
public function get_discoverable() {
return true;
}
/**
* Get the User-ID.
* Get the User ID.
*
* @return string The User-ID.
* @return string The User ID.
*/
public function get_id() {
return $this->get_url();
$id = parent::get_id();
if ( $id ) {
return $id;
}
$permalink = \get_option( 'activitypub_use_permalink_as_id_for_blog', false );
if ( $permalink ) {
return $this->get_url();
}
return \add_query_arg( 'author', $this->_id, \trailingslashit( \home_url() ) );
}
/**
* Get the type of the object.
*
* If the Blog is in "single user" mode, return "Person" insted of "Group".
* If the Blog is in "single user" mode, return "Person" instead of "Group".
*
* @return string The type of the object.
*/
@ -112,9 +158,9 @@ class Blog extends Actor {
}
/**
* Get the User-Name.
* Get the Username.
*
* @return string The User-Name.
* @return string The Username.
*/
public function get_name() {
return \wp_strip_all_tags(
@ -127,23 +173,29 @@ class Blog extends Actor {
}
/**
* Get the User-Description.
* Get the User description.
*
* @return string The User-Description.
* @return string The User description.
*/
public function get_summary() {
$summary = \get_option( 'activitypub_blog_description', null );
if ( ! $summary ) {
$summary = \get_bloginfo( 'description' );
}
return \wpautop(
\wp_kses(
\get_bloginfo( 'description' ),
$summary,
'default'
)
);
}
/**
* Get the User-Url.
* Get the User url.
*
* @return string The User-Url.
* @return string The User url.
*/
public function get_url() {
return \esc_url( \trailingslashit( get_home_url() ) . '@' . $this->get_preferred_username() );
@ -164,25 +216,29 @@ class Blog extends Actor {
* @return string The auto-generated Username.
*/
public static function get_default_username() {
// check if domain host has a subdomain
// Check if domain host has a subdomain.
$host = \wp_parse_url( \get_home_url(), \PHP_URL_HOST );
$host = \preg_replace( '/^www\./i', '', $host );
/**
* Filter the default blog username.
* Filters the default blog username.
*
* @param string $host The default username.
* This filter allows developers to modify the default username that is
* generated for the blog, which by default is the site's host name
* without the 'www.' prefix.
*
* @param string $host The default username (site's host name).
*/
return apply_filters( 'activitypub_default_blog_username', $host );
}
/**
* Get the preferred User-Name.
* Get the preferred Username.
*
* @return string The User-Name.
* @return string The Username.
*/
public function get_preferred_username() {
$username = \get_option( 'activitypub_blog_user_identifier' );
$username = \get_option( 'activitypub_blog_identifier' );
if ( $username ) {
return $username;
@ -192,15 +248,15 @@ class Blog extends Actor {
}
/**
* Get the User-Icon.
* Get the User icon.
*
* @return array The User-Icon.
* @return string[] The User icon.
*/
public function get_icon() {
// try site icon first
// Try site_logo, falling back to site_icon, first.
$icon_id = get_option( 'site_icon' );
// try custom logo second
// Try custom logo second.
if ( ! $icon_id ) {
$icon_id = get_theme_mod( 'custom_logo' );
}
@ -215,7 +271,7 @@ class Blog extends Actor {
}
if ( ! $icon_url ) {
// fallback to default icon
// Fallback to default icon.
$icon_url = plugins_url( '/assets/img/wp-logo.png', ACTIVITYPUB_PLUGIN_FILE );
}
@ -228,19 +284,35 @@ class Blog extends Actor {
/**
* Get the User-Header-Image.
*
* @return array|null The User-Header-Image.
* @return string[]|null The User-Header-Image.
*/
public function get_image() {
if ( \has_header_image() ) {
$header_image = get_option( 'activitypub_header_image' );
$image_url = null;
if ( $header_image ) {
$image_url = \wp_get_attachment_url( $header_image );
}
if ( ! $image_url && \has_header_image() ) {
$image_url = \get_header_image();
}
if ( $image_url ) {
return array(
'type' => 'Image',
'url' => esc_url( \get_header_image() ),
'url' => esc_url( $image_url ),
);
}
return null;
}
/**
* Get the published date.
*
* @return string The published date.
*/
public function get_published() {
$first_post = new WP_Query(
array(
@ -256,13 +328,23 @@ class Blog extends Actor {
$time = \time();
}
return \gmdate( 'Y-m-d\TH:i:s\Z', $time );
return \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, $time );
}
/**
* Get the canonical URL.
*
* @return string|null The canonical URL.
*/
public function get_canonical_url() {
return \home_url();
}
/**
* Get the Moderators endpoint.
*
* @return string|null The Moderators endpoint.
*/
public function get_moderators() {
if ( is_single_user() || 'Group' !== $this->get_type() ) {
return null;
@ -271,6 +353,11 @@ class Blog extends Actor {
return get_rest_url_by_path( 'collections/moderators' );
}
/**
* Get attributedTo value.
*
* @return string|null The attributedTo value.
*/
public function get_attributed_to() {
if ( is_single_user() || 'Group' !== $this->get_type() ) {
return null;
@ -279,14 +366,24 @@ class Blog extends Actor {
return get_rest_url_by_path( 'collections/moderators' );
}
/**
* Get the public key information.
*
* @return string[] The public key.
*/
public function get_public_key() {
return array(
'id' => $this->get_id() . '#main-key',
'owner' => $this->get_id(),
'id' => $this->get_id() . '#main-key',
'owner' => $this->get_id(),
'publicKeyPem' => Signature::get_public_key_for( $this->get__id() ),
);
}
/**
* Returns whether posting is restricted to mods.
*
* @return bool|null True if posting is restricted to mods, null if not applicable.
*/
public function get_posting_restricted_to_mods() {
if ( 'Group' === $this->get_type() ) {
return true;
@ -331,10 +428,15 @@ class Blog extends Actor {
return get_rest_url_by_path( sprintf( 'actors/%d/following', $this->get__id() ) );
}
/**
* Returns endpoints.
*
* @return string[]|null The endpoints.
*/
public function get_endpoints() {
$endpoints = null;
if ( ACTIVITYPUB_SHARED_INBOX_FEATURE ) {
if ( \get_option( 'activitypub_shared_inbox' ) ) {
$endpoints = array(
'sharedInbox' => get_rest_url_by_path( 'inbox' ),
);
@ -361,45 +463,138 @@ class Blog extends Actor {
return get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $this->get__id() ) );
}
/**
* Returns whether the site is indexable.
*
* @return bool Whether the site is indexable.
*/
public function get_indexable() {
if ( \get_option( 'blog_public', 1 ) ) {
if ( is_blog_public() ) {
return true;
} else {
return false;
}
}
/**
* Update the Username.
*
* @param mixed $value The new value.
* @return bool True if the attribute was updated, false otherwise.
*/
public function update_name( $value ) {
return \update_option( 'blogname', $value );
}
/**
* Update the User description.
*
* @param mixed $value The new value.
* @return bool True if the attribute was updated, false otherwise.
*/
public function update_summary( $value ) {
return \update_option( 'blogdescription', $value );
}
/**
* Update the User icon.
*
* @param mixed $value The new value.
* @return bool True if the attribute was updated, false otherwise.
*/
public function update_icon( $value ) {
if ( ! wp_attachment_is_image( $value ) ) {
return false;
}
return \update_option( 'site_icon', $value );
}
/**
* Update the User-Header-Image.
*
* @param mixed $value The new value.
* @return bool True if the attribute was updated, false otherwise.
*/
public function update_header( $value ) {
if ( ! wp_attachment_is_image( $value ) ) {
return false;
}
return \update_option( 'activitypub_header_image', $value );
}
/**
* Get the User - Hashtags.
*
* @see https://docs.joinmastodon.org/spec/activitypub/#Hashtag
*
* @return string[] The User - Hashtags.
*/
public function get_tag() {
$hashtags = array();
$args = array(
'orderby' => 'count',
'order' => 'DESC',
'number' => 10,
);
$tags = get_tags( $args );
foreach ( $tags as $tag ) {
$hashtags[] = array(
'type' => 'Hashtag',
'href' => \get_tag_link( $tag->term_id ),
'name' => esc_hashtag( $tag->name ),
);
}
return $hashtags;
}
/**
* Extend the User-Output with Attachments.
*
* @return array The extended User-Output.
*/
public function get_attachment() {
$array = array();
$extra_fields = Extra_Fields::get_actor_fields( $this->_id );
return Extra_Fields::fields_to_attachments( $extra_fields );
}
$array[] = array(
'type' => 'PropertyValue',
'name' => \__( 'Blog', 'activitypub' ),
'value' => \html_entity_decode(
sprintf(
'<a rel="me" title="%s" target="_blank" href="%s">%s</a>',
\esc_attr( \home_url( '/' ) ),
\esc_url( \home_url( '/' ) ),
\wp_parse_url( \home_url( '/' ), \PHP_URL_HOST )
),
\ENT_QUOTES,
'UTF-8'
),
/**
* Returns the website hosts allowed to credit this blog.
*
* @return string[]|null The attribution domains or null if not found.
*/
public function get_attribution_domains() {
return get_attribution_domains();
}
/**
* Returns the alsoKnownAs.
*
* @return string[] The alsoKnownAs.
*/
public function get_also_known_as() {
$also_known_as = array(
\add_query_arg( 'author', $this->_id, \home_url( '/' ) ),
$this->get_url(),
$this->get_alternate_url(),
);
// Add support for FEP-fb2a, for more information see FEDERATION.md
$array[] = array(
'type' => 'Link',
'name' => \__( 'Blog', 'activitypub' ),
'href' => \esc_url( \home_url( '/' ) ),
'rel' => array( 'me' ),
);
$also_known_as = array_merge( $also_known_as, \get_option( 'activitypub_blog_user_also_known_as', array() ) );
return $array;
return array_unique( $also_known_as );
}
/**
* Returns the movedTo.
*
* @return string The movedTo.
*/
public function get_moved_to() {
$moved_to = \get_option( 'activitypub_blog_user_moved_to' );
return $moved_to && $moved_to !== $this->get_id() ? $moved_to : null;
}
}

Some files were not shown because too many files have changed in this diff Show More