updated plugin ActivityPub version 8.3.0

This commit is contained in:
2026-06-03 21:28:46 +00:00
committed by Gitium
parent a4b78ec277
commit 6fe182458a
340 changed files with 43232 additions and 7568 deletions

View File

@ -0,0 +1,3 @@
# Integrations
This folder contains classes that take care of compatibility with other plugins.

View File

@ -18,6 +18,36 @@ class Buddypress {
*/
public static function init() {
\add_filter( 'activitypub_json_author_array', array( self::class, 'add_user_metadata' ), 11, 2 );
\add_filter( 'render_block_activitypub/followers', array( self::class, 'escape_at_signs' ) );
\add_filter( 'render_block_activitypub/following', array( self::class, 'escape_at_signs' ) );
}
/**
* Escape `@` signs in block output to prevent BuddyPress mention linking.
*
* BuddyPress hooks `bp_activity_at_name_filter` into `the_content` to convert
* `@username` mentions into profile links. This corrupts the JSON in the
* `data-wp-context` attribute of Followers/Following blocks because the handles
* contain `@username` patterns that match BuddyPress's regex.
*
* Encoding `@` as `@` in the HTML attribute makes it invisible to
* BuddyPress's regex. The browser decodes the HTML entity before JavaScript
* reads the attribute, so the Interactivity API receives the original `@`.
*
* @since 8.1.0
*
* @param string $block_content The block content.
*
* @return string The block content with `@` signs escaped in data attributes.
*/
public static function escape_at_signs( $block_content ) {
return \preg_replace_callback(
'/data-wp-context="([^"]*)"/',
static function ( $matches ) {
return 'data-wp-context="' . \str_replace( '@', '@', $matches[1] ) . '"';
},
$block_content
);
}
/**
@ -29,7 +59,11 @@ class Buddypress {
* @return object The author object.
*/
public static function add_user_metadata( $author, $author_id ) {
$author->url = bp_core_get_user_domain( $author_id ); // Add BP member profile URL as user URL.
if ( \function_exists( 'bp_members_get_user_url' ) ) {
$author->url = bp_members_get_user_url( $author_id );
} else {
$author->url = bp_core_get_user_domain( $author_id );
}
// Add BuddyPress' cover_image instead of WordPress' header_image.
$cover_image_url = bp_attachments_get_attachment( 'url', array( 'item_id' => $author_id ) );
@ -48,9 +82,9 @@ class Buddypress {
'value' => \html_entity_decode(
sprintf(
'<a rel="me" title="%s" target="_blank" href="%s">%s</a>',
\esc_attr( bp_core_get_user_domain( $author_id ) ),
\bp_core_get_user_domain( $author_id ),
\wp_parse_url( \bp_core_get_user_domain( $author_id ), \PHP_URL_HOST )
\esc_attr( $author->url ),
\esc_url( $author->url ),
\wp_parse_url( $author->url, \PHP_URL_HOST )
),
\ENT_QUOTES,
'UTF-8'

View File

@ -0,0 +1,305 @@
<?php
/**
* Classic Editor integration file.
*
* @package Activitypub
*/
namespace Activitypub\Integration;
/**
* Classic Editor integration class.
*
* Handles compatibility with the Classic Editor plugin and sites without block editor support.
*/
class Classic_Editor {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_attachments_media_markup', array( self::class, 'filter_attachments_media_markup' ), 10, 2 );
\add_filter( 'activitypub_attachment_ids', array( self::class, 'filter_attached_media_ids' ), 10, 2 );
\add_action( 'add_meta_boxes', array( self::class, 'add_meta_box' ) );
\add_action( 'save_post', array( self::class, 'save_meta_data' ) );
if ( \function_exists( 'classicpress_version' ) ) {
\add_filter( 'activitypub_site_supports_blocks', '__return_false' );
}
}
/**
* Filter attachment media markup to use shortcodes instead of blocks.
*
* @param string $markup The custom markup. Empty string by default.
* @param array $attachment_ids Array of attachment IDs.
*
* @return string The generated shortcode markup.
*/
public static function filter_attachments_media_markup( $markup, $attachment_ids ) {
if ( empty( $attachment_ids ) ) {
return $markup;
}
$type = strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );
// Single video or audio file: use media shortcode.
if ( 1 === \count( $attachment_ids ) && ( 'video' === $type || 'audio' === $type ) ) {
return sprintf(
'[%1$s src="%2$s"]',
\esc_attr( $type ),
\esc_url( \wp_get_attachment_url( $attachment_ids[0] ) )
);
}
// Multiple attachments or images: use gallery shortcode.
return '[gallery ids="' . implode( ',', $attachment_ids ) . '" link="none"]';
}
/**
* Filter to add attached media IDs from the post's media library.
*
* Returns additional image attachments from the post's media library,
* respecting the maximum image attachment limit. Only returns new
* attachments that can be added without exceeding the limit.
*
* @param array $attachments The current list of attachments.
* @param \WP_Post $item The post item.
*
* @return array Array of new media IDs to add (as associative arrays with 'id' key).
* Returns empty array if already at or over the limit.
*/
public static function filter_attached_media_ids( $attachments, $item ) {
$max_media = \get_option( 'activitypub_max_image_attachments', \ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS );
$actual_count = \max( 0, $max_media - \count( $attachments ) );
if ( $actual_count <= 0 ) {
return $attachments;
}
$query = new \WP_Query(
array(
'post_parent' => $item->ID,
'post_status' => 'inherit',
'post_type' => 'attachment',
'post_mime_type' => 'image',
'order' => 'ASC',
'orderby' => 'menu_order ID',
'fields' => 'ids',
'posts_per_page' => $actual_count,
)
);
// Transform IDs into associative arrays.
$media_ids = \array_map(
static function ( $id ) {
return array( 'id' => $id );
},
$query->get_posts()
);
return \array_merge( $attachments, $media_ids );
}
/**
* Add ActivityPub meta box to the post editor.
*
* @param string $post_type The post type.
*/
public static function add_meta_box( $post_type ) {
// Only add for post types that support ActivityPub.
if ( ! \post_type_supports( $post_type, 'activitypub' ) ) {
return;
}
\add_meta_box(
'activitypub-settings',
\__( 'Fediverse ⁂', 'activitypub' ),
array( self::class, 'render_meta_box' ),
$post_type,
'side',
'default'
);
}
/**
* Render the ActivityPub meta box.
*
* @param \WP_Post $post The post object.
*/
public static function render_meta_box( $post ) {
// Add nonce for security.
\wp_nonce_field( 'activitypub_meta_box', 'activitypub_meta_box_nonce' );
// Get current values.
$content_warning = \get_post_meta( $post->ID, 'activitypub_content_warning', true );
$max_image_attachments = \get_post_meta( $post->ID, 'activitypub_max_image_attachments', true );
$content_visibility = self::get_default_visibility( $post );
$default_quote_policy = \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE );
$quote_interaction = \get_post_meta( $post->ID, 'activitypub_interaction_policy_quote', true ) ?: $default_quote_policy;
?>
<p>
<label for="activitypub_content_warning">
<strong><?php \esc_html_e( 'Content Warning', 'activitypub' ); ?></strong>
</label><br />
<input type="text" id="activitypub_content_warning" name="activitypub_content_warning" value="<?php echo \esc_attr( $content_warning ); ?>" class="widefat" placeholder="<?php \esc_attr_e( 'Optional content warning', 'activitypub' ); ?>" />
<span class="howto"><?php \esc_html_e( 'Content warnings do not change the content on your site, only in the fediverse.', 'activitypub' ); ?></span>
</p>
<p>
<label for="activitypub_max_image_attachments">
<strong><?php \esc_html_e( 'Maximum Image Attachments', 'activitypub' ); ?></strong>
</label><br />
<input type="number" id="activitypub_max_image_attachments" name="activitypub_max_image_attachments" value="<?php echo \esc_attr( $max_image_attachments ); ?>" min="0" max="10" class="small-text" />
<span class="howto"><?php \esc_html_e( 'Maximum number of image attachments to include when sharing to the fediverse.', 'activitypub' ); ?></span>
</p>
<p>
<strong><?php \esc_html_e( 'Visibility', 'activitypub' ); ?></strong><br />
<label>
<input type="radio" name="activitypub_content_visibility" value="<?php echo \esc_attr( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ); ?>" <?php \checked( $content_visibility, ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ); ?> />
<?php \esc_html_e( 'Public', 'activitypub' ); ?>
</label><br />
<label>
<input type="radio" name="activitypub_content_visibility" value="<?php echo \esc_attr( ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ); ?>" <?php \checked( $content_visibility, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ); ?> />
<?php \esc_html_e( 'Quiet public', 'activitypub' ); ?>
</label><br />
<label>
<input type="radio" name="activitypub_content_visibility" value="<?php echo \esc_attr( ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ); ?>" <?php \checked( $content_visibility, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ); ?> />
<?php \esc_html_e( 'Do not federate', 'activitypub' ); ?>
</label><br />
<span class="howto"><?php \esc_html_e( 'This adjusts the visibility of a post in the fediverse, but note that it won\'t affect how the post appears on the blog.', 'activitypub' ); ?></span>
</p>
<p>
<label for="activitypub_interaction_policy_quote">
<strong><?php \esc_html_e( 'Who can quote this post?', 'activitypub' ); ?></strong>
</label><br />
<select id="activitypub_interaction_policy_quote" name="activitypub_interaction_policy_quote" class="widefat">
<option value="<?php echo \esc_attr( ACTIVITYPUB_INTERACTION_POLICY_ANYONE ); ?>" <?php \selected( $quote_interaction, ACTIVITYPUB_INTERACTION_POLICY_ANYONE ); ?>><?php \esc_html_e( 'Anyone', 'activitypub' ); ?></option>
<option value="<?php echo \esc_attr( ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS ); ?>" <?php \selected( $quote_interaction, ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS ); ?>><?php \esc_html_e( 'Followers only', 'activitypub' ); ?></option>
<option value="<?php echo \esc_attr( ACTIVITYPUB_INTERACTION_POLICY_ME ); ?>" <?php \selected( $quote_interaction, ACTIVITYPUB_INTERACTION_POLICY_ME ); ?>><?php \esc_html_e( 'Just me', 'activitypub' ); ?></option>
</select>
<span class="howto">
<?php \esc_html_e( 'Quoting allows others to cite your post while adding their own commentary.', 'activitypub' ); ?>
<?php
printf(
/* translators: %s: The current site default quote policy. Note the leading space. */
\esc_html__( ' Site default: %s', 'activitypub' ),
\esc_html( self::get_quote_policy_label( $default_quote_policy ) )
);
?>
</span>
</p>
<?php
}
/**
* Get the label for a quote policy value.
*
* @param string $policy The policy value.
*
* @return string The translated label.
*/
private static function get_quote_policy_label( $policy ) {
$labels = array(
ACTIVITYPUB_INTERACTION_POLICY_ANYONE => \__( 'Anyone', 'activitypub' ),
ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS => \__( 'Followers only', 'activitypub' ),
ACTIVITYPUB_INTERACTION_POLICY_ME => \__( 'Just me', 'activitypub' ),
);
return $labels[ $policy ] ?? $labels[ ACTIVITYPUB_INTERACTION_POLICY_ANYONE ];
}
/**
* Get default visibility based on post age and federation status.
*
* @param \WP_Post $post The post object.
*
* @return string The default visibility value.
*/
private static function get_default_visibility( $post ) {
// If already set, use that value.
$saved_visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
if ( $saved_visibility ) {
return $saved_visibility;
}
// If post is federated, use public.
$status = \get_post_meta( $post->ID, 'activitypub_status', true );
if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $status ) {
return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
}
// If post is older than 1 month, default to local.
$post_timestamp = \strtotime( $post->post_date );
$one_month_ago = \strtotime( '-30 days' );
if ( $post_timestamp < $one_month_ago ) {
return ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL;
}
// Default to public for new posts.
return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
}
/**
* Save ActivityPub meta data.
*
* @param int $post_id The post ID.
*/
public static function save_meta_data( $post_id ) {
// Check if this is an autosave.
if ( \defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// Only process for post types that support ActivityPub.
if ( ! \post_type_supports( \get_post_type( $post_id ), 'activitypub' ) ) {
return;
}
// Check user permissions.
if ( ! \current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Verify nonce is present and valid.
if ( ! isset( $_POST['activitypub_meta_box_nonce'] ) ) {
return;
}
if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['activitypub_meta_box_nonce'] ) ), 'activitypub_meta_box' ) ) {
return;
}
// Save content warning.
if ( isset( $_POST['activitypub_content_warning'] ) ) {
$content_warning = \sanitize_text_field( \wp_unslash( $_POST['activitypub_content_warning'] ) );
if ( ! empty( $content_warning ) ) {
\update_post_meta( $post_id, 'activitypub_content_warning', $content_warning );
} else {
\delete_post_meta( $post_id, 'activitypub_content_warning' );
}
}
// Save max image attachments.
if ( isset( $_POST['activitypub_max_image_attachments'] ) ) {
$max_images = \absint( $_POST['activitypub_max_image_attachments'] );
\update_post_meta( $post_id, 'activitypub_max_image_attachments', $max_images );
}
// Save content visibility.
if ( isset( $_POST['activitypub_content_visibility'] ) ) {
$visibility = \sanitize_text_field( \wp_unslash( $_POST['activitypub_content_visibility'] ) );
\update_post_meta( $post_id, 'activitypub_content_visibility', $visibility );
}
// Save quote interaction policy.
if ( isset( $_POST['activitypub_interaction_policy_quote'] ) ) {
$quote_policy = \sanitize_text_field( \wp_unslash( $_POST['activitypub_interaction_policy_quote'] ) );
\update_post_meta( $post_id, 'activitypub_interaction_policy_quote', $quote_policy );
}
}
}

View File

@ -7,17 +7,19 @@
namespace Activitypub\Integration;
use DateTime;
use Activitypub\Webfinger as Webfinger_Util;
use Activitypub\Activity\Actor;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Extra_Fields;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Remote_Actors;
use Activitypub\Http;
use Activitypub\Mention;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Extra_Fields;
use Activitypub\Transformer\Factory;
use Activitypub\Webfinger as Webfinger_Util;
use Enable_Mastodon_Apps\Entity\Account;
use Enable_Mastodon_Apps\Entity\Status;
use Enable_Mastodon_Apps\Entity\Media_Attachment;
use Enable_Mastodon_Apps\Entity\Notification;
use Enable_Mastodon_Apps\Entity\Status;
use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\is_user_type_disabled;
@ -30,10 +32,18 @@ use function Activitypub\is_user_type_disabled;
* @see https://github.com/akirk/enable-mastodon-apps
*/
class Enable_Mastodon_Apps {
/**
* Default limit for notifications.
*
* @var int
*/
const DEFAULT_NOTIFICATION_LIMIT = 15;
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'mastodon_api_valid_user', array( self::class, 'is_ap_actor' ), 10, 2 );
\add_filter( 'mastodon_api_account_followers', array( self::class, 'api_account_followers' ), 10, 2 );
\add_filter( 'mastodon_api_account', array( self::class, 'api_account_external' ), 15, 2 );
\add_filter( 'mastodon_api_account', array( self::class, 'api_account_internal' ), 9, 2 );
@ -42,9 +52,12 @@ class Enable_Mastodon_Apps {
\add_filter( 'mastodon_api_search', array( self::class, 'api_search_by_url' ), 40, 2 );
\add_filter( 'mastodon_api_get_posts_query_args', array( self::class, 'api_get_posts_query_args' ) );
\add_filter( 'mastodon_api_statuses', array( self::class, 'api_statuses_external' ), 10, 2 );
\add_filter( 'mastodon_api_status_by_url', array( self::class, 'api_status_by_url' ), 10, 2 );
\add_filter( 'mastodon_api_status_context', array( self::class, 'api_get_replies' ), 10, 3 );
\add_filter( 'mastodon_api_update_credentials', array( self::class, 'api_update_credentials' ), 10, 2 );
\add_filter( 'mastodon_api_submit_status_text', array( Mention::class, 'the_content' ) );
\add_filter( 'mastodon_api_notifications_get', array( self::class, 'api_notifications_get' ), 10, 5 );
\add_filter( 'mastodon_api_tag_timeline', array( self::class, 'api_tag_timeline_tags_pub' ), 20, 2 );
}
/**
@ -175,6 +188,26 @@ class Enable_Mastodon_Apps {
}
}
/**
* Validate ap_actor post IDs as valid Mastodon API users.
*
* @param bool $is_valid Whether the user is valid.
* @param string|int $user_id The user ID to check.
*
* @return bool True if the user ID is a valid ap_actor post.
*/
public static function is_ap_actor( $is_valid, $user_id ) {
if ( $is_valid ) {
return $is_valid;
}
if ( \is_numeric( $user_id ) && Remote_Actors::POST_TYPE === \get_post_type( (int) $user_id ) ) {
return true;
}
return $is_valid;
}
/**
* Add followers to Mastodon API.
*
@ -185,45 +218,30 @@ class Enable_Mastodon_Apps {
*/
public static function api_account_followers( $followers, $user_id ) {
$user_id = self::maybe_map_user_to_blog( $user_id );
$activitypub_followers = Followers::get_followers( $user_id, 40 );
$mastodon_followers = array_map(
function ( $item ) {
$acct = Webfinger_Util::uri_to_acct( $item->get_id() );
$activitypub_followers = Followers::get_many( $user_id, 40 );
$mastodon_followers = array();
if ( $acct && ! is_wp_error( $acct ) ) {
$acct = \str_replace( 'acct:', '', $acct );
} else {
$acct = $item->get_id();
}
foreach ( $activitypub_followers as $follower ) {
$actor = Remote_Actors::get_actor( $follower );
if ( ! $actor || \is_wp_error( $actor ) ) {
continue;
}
$account = new Account();
$account->id = \strval( $item->get__id() );
$account->username = $item->get_preferred_username();
$account->acct = $acct;
$account->display_name = $item->get_name();
$account->url = $item->get_url();
$account->avatar = $item->get_icon_url();
$account->avatar_static = $item->get_icon_url();
$account->created_at = new DateTime( $item->get_published() );
$account->last_status_at = new DateTime( $item->get_published() );
$account->note = $item->get_summary();
$account->header = $item->get_image_url();
$account->header_static = $item->get_image_url();
$account->followers_count = 0;
$account->following_count = 0;
$account->statuses_count = 0;
$account->bot = false;
$account->locked = false;
$account->group = false;
$account->discoverable = false;
$account->noindex = false;
$account->fields = array();
$account->emojis = array();
$account = self::actor_to_account( $actor, $follower->ID );
return $account;
},
$activitypub_followers
);
$account->followers_count = 0;
$account->following_count = 0;
$account->statuses_count = 0;
$account->bot = false;
$account->locked = false;
$account->group = false;
$account->discoverable = false;
$account->noindex = false;
$account->fields = array();
$account->emojis = array();
$mastodon_followers[] = $account;
}
return array_merge( $mastodon_followers, $followers );
}
@ -237,6 +255,13 @@ class Enable_Mastodon_Apps {
* @return Account The filtered Account.
*/
public static function api_account_external( $user_data, $user_id ) {
if ( ! $user_data && \is_numeric( $user_id ) && Remote_Actors::POST_TYPE === \get_post_type( (int) $user_id ) ) {
$actor = Remote_Actors::get_actor( (int) $user_id );
if ( $actor && ! \is_wp_error( $actor ) ) {
return self::actor_to_account( $actor, (int) $user_id );
}
}
if ( $user_data || ( is_numeric( $user_id ) && $user_id ) ) {
// Only augment.
return $user_data;
@ -299,7 +324,7 @@ class Enable_Mastodon_Apps {
$account->header_static = $account->header;
}
$account->created_at = new DateTime( $user->get_published() );
$account->created_at = new \DateTime( $user->get_published() );
$post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) );
$query_args = array(
@ -310,26 +335,26 @@ class Enable_Mastodon_Apps {
$query_args['author'] = $user_id;
}
$posts = \get_posts( $query_args );
$account->last_status_at = ! empty( $posts ) ? new DateTime( $posts[0]->post_date_gmt ) : $account->created_at;
$account->last_status_at = ! empty( $posts ) ? new \DateTime( $posts[0]->post_date_gmt ) : $account->created_at;
$account->fields = self::get_extra_fields( $user_id_to_use );
// Now do it in source['fields'] with stripped tags.
$account->source['fields'] = \array_map(
function ( $field ) {
static function ( $field ) {
$field['value'] = \wp_strip_all_tags( $field['value'], true );
return $field;
},
$account->fields
);
$account->followers_count = Followers::count_followers( $user->get__id() );
$account->followers_count = Followers::count( $user_id );
return $account;
}
/**
* Use our representation of posts to power each status item.
* Includes proper referncing of 3rd party comments that arrived via federation.
* Includes proper referencing of 3rd party comments that arrived via federation.
*
* @param null|Status $status The status, typically null to allow later filters their shot.
* @param int $post_id The post ID.
@ -368,59 +393,122 @@ class Enable_Mastodon_Apps {
/**
* Get account for actor.
*
* @param string $uri The URI.
* @param string|Actor $actor_or_uri The Actor object or URI.
*
* @return Account|null The account.
*/
private static function get_account_for_actor( $uri ) {
if ( ! is_string( $uri ) || empty( $uri ) ) {
return null;
private static function get_account_for_actor( $actor_or_uri ) {
// If it's already an Actor object, use it directly.
if ( $actor_or_uri instanceof Actor ) {
return self::actor_to_account( $actor_or_uri );
}
$data = get_remote_metadata_by_actor( $uri );
if ( ! $data || is_wp_error( $data ) ) {
if ( ! \is_string( $actor_or_uri ) || empty( $actor_or_uri ) ) {
return null;
}
// Fetch actor from cache or remote.
$actor_post = Remote_Actors::fetch_by_uri( $actor_or_uri );
if ( ! $actor_post || \is_wp_error( $actor_post ) ) {
return null;
}
$actor = Remote_Actors::get_actor( $actor_post );
if ( ! $actor || \is_wp_error( $actor ) ) {
return null;
}
return self::actor_to_account( $actor, $actor_post->ID );
}
/**
* Convert an Actor object to an Account.
*
* @param Actor $actor The actor object.
* @param int|null $post_id Optional WordPress post ID for the actor.
*
* @return Account The account.
*/
private static function actor_to_account( $actor, $post_id = null ) {
$account = new Account();
$acct = Webfinger_Util::uri_to_acct( $uri );
if ( ! $acct || is_wp_error( $acct ) ) {
$actor_id = $post_id ? $post_id : $actor->get__id();
if ( ! $actor_id ) {
$actor_id = $actor->get_id();
}
$account->id = \strval( $actor_id );
$account->username = $actor->get_preferred_username();
$account->acct = $actor->get_webfinger();
$account->display_name = $actor->get_name();
$account->url = $actor->get_url();
$account->created_at = new \DateTime( 'now' );
$icon = $actor->get_icon();
$avatar = null;
if ( $icon ) {
if ( \is_array( $icon ) && isset( $icon['url'] ) ) {
$avatar = $icon['url'];
} elseif ( \is_string( $icon ) ) {
$avatar = $icon;
}
}
if ( $avatar ) {
$account->avatar = $avatar;
$account->avatar_static = $avatar;
}
$summary = $actor->get_summary();
if ( $summary ) {
$account->note = $summary;
}
$image = $actor->get_image();
$header = null;
if ( $image ) {
if ( \is_array( $image ) && isset( $image['url'] ) ) {
$header = $image['url'];
} elseif ( \is_string( $image ) ) {
$header = $image;
}
}
if ( $header ) {
$account->header = $header;
$account->header_static = $header;
}
$published = $actor->get_published();
if ( $published ) {
$account->created_at = new \DateTime( $published );
}
return $account;
}
/**
* Fetch a status by its remote URL.
*
* @param Status|null $status The current status.
* @param string $url The remote URL of the status.
*
* @return Status|null The status, or null if it could not be fetched.
*/
public static function api_status_by_url( $status, $url ) {
if ( $status ) {
return $status;
}
$object = Http::get_remote_object( $url, true );
if ( \is_wp_error( $object ) || ! isset( $object['attributedTo'] ) ) {
return null;
}
if ( str_starts_with( $acct, 'acct:' ) ) {
$acct = substr( $acct, 5 );
$account = self::get_account_for_actor( $object['attributedTo'] );
if ( ! $account ) {
return null;
}
$account->id = $acct;
$account->username = $acct;
$account->acct = $acct;
$account->display_name = $data['name'];
$account->url = $uri;
if ( ! empty( $data['summary'] ) ) {
$account->note = $data['summary'];
}
if (
isset( $data['icon']['type'] ) &&
isset( $data['icon']['url'] ) &&
'Image' === $data['icon']['type']
) {
$account->avatar = $data['icon']['url'];
$account->avatar_static = $data['icon']['url'];
}
if ( isset( $data['image'] ) ) {
$account->header = $data['image']['url'];
$account->header_static = $data['image']['url'];
}
if ( ! isset( $data['published'] ) ) {
$data['published'] = 'now';
}
$account->created_at = new DateTime( $data['published'] );
return $account;
return self::activity_to_status( $object, $account );
}
/**
@ -437,17 +525,7 @@ class Enable_Mastodon_Apps {
return $search_data;
}
$object = Http::get_remote_object( $request->get_param( 'q' ), true );
if ( is_wp_error( $object ) || ! isset( $object['attributedTo'] ) ) {
return $search_data;
}
$account = self::get_account_for_actor( $object['attributedTo'] );
if ( ! $account ) {
return $search_data;
}
$status = self::activity_to_status( $object, $account );
$status = \apply_filters( 'mastodon_api_status_by_url', null, $request->get_param( 'q' ) );
if ( $status ) {
$search_data['statuses'][] = $status;
}
@ -475,34 +553,20 @@ class Enable_Mastodon_Apps {
}
$q = sanitize_text_field( wp_unslash( $q ) );
$followers = Followers::get_followers( $user_id, 40, null, array( 's' => $q ) );
$followers = Followers::get_many( $user_id, 40, null, array( 's' => $q ) );
if ( ! $followers ) {
return $search_data;
}
foreach ( $followers as $follower ) {
$acct = Webfinger_Util::uri_to_acct( $follower->get_id() );
if ( $acct && ! is_wp_error( $acct ) ) {
$acct = \str_replace( 'acct:', '', $acct );
} else {
$acct = $follower->get_url();
$actor = Remote_Actors::get_actor( $follower );
if ( ! $actor || \is_wp_error( $actor ) ) {
continue;
}
$account = new Account();
$account->id = \strval( $follower->get__id() );
$account->username = $follower->get_preferred_username();
$account->acct = $acct;
$account->display_name = $follower->get_name();
$account->url = $follower->get_url();
$account->uri = $follower->get_id();
$account->avatar = $follower->get_icon_url();
$account->avatar_static = $follower->get_icon_url();
$account->created_at = new DateTime( $follower->get_published() );
$account->last_status_at = new DateTime( $follower->get_published() );
$account->note = $follower->get_summary();
$account->header = $follower->get_image_url();
$account->header_static = $follower->get_image_url();
$account = self::actor_to_account( $actor, $follower->ID );
$account->uri = $actor->get_id();
$search_data['accounts'][] = $account;
}
@ -545,13 +609,13 @@ class Enable_Mastodon_Apps {
$object = $item;
}
if ( ! isset( $object['type'] ) || 'Note' !== $object['type'] || ! $account ) {
if ( ! isset( $object['type'] ) || ! in_array( $object['type'], array( 'Article', 'Note' ), true ) || ! $account ) {
return null;
}
$status = new Status();
$status->id = $post_id ?? $object['id'];
$status->created_at = new DateTime( $object['published'] );
$status->created_at = new \DateTime( $object['published'] );
$status->content = $object['content'];
$status->account = $account;
@ -571,7 +635,7 @@ class Enable_Mastodon_Apps {
if ( ! empty( $object['attachment'] ) ) {
$status->media_attachments = array_map(
function ( $attachment ) {
static function ( $attachment ) {
$default_attachment = array(
'url' => null,
'mediaType' => null,
@ -659,7 +723,7 @@ class Enable_Mastodon_Apps {
}
$new_statuses = array_map(
function ( $item ) use ( $account, $args ) {
static function ( $item ) use ( $account, $args ) {
if ( $args['exclude_replies'] ) {
if ( isset( $item['object']['inReplyTo'] ) && $item['object']['inReplyTo'] ) {
return null;
@ -680,6 +744,208 @@ class Enable_Mastodon_Apps {
return array_slice( $activitypub_statuses, 0, $limit );
}
/**
* Maximum number of tags.pub items to resolve per request.
*
* Each outbox item requires multiple HTTP round-trips to resolve
* (Announce activity → original post → author actor), so we ignore
* the client-requested limit and use this small batch size instead.
* Results are cached, so subsequent requests are fast.
*/
const TAGS_PUB_BATCH_SIZE = 5;
/**
* Supplement tag timeline with posts from tags.pub outbox.
*
* Fetches the outbox of the corresponding tags.pub actor (e.g. @wordpress@tags.pub)
* and resolves Announce activities to their original posts.
*
* @param \WP_REST_Response|null $statuses The current statuses (WP_REST_Response with Status[] data).
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response|null The statuses including remote ones.
*/
public static function api_tag_timeline_tags_pub( $statuses, $request ) {
$hashtag = \strtolower( $request->get_param( 'hashtag' ) );
if ( ! $hashtag ) {
return $statuses;
}
$remote_statuses = self::fetch_tags_pub_outbox( $hashtag, self::TAGS_PUB_BATCH_SIZE );
if ( empty( $remote_statuses ) ) {
return $statuses;
}
if ( ! $statuses instanceof \WP_REST_Response ) {
return $statuses;
}
$merged = \array_merge( $statuses->data, $remote_statuses );
// Deduplicate by status ID to prevent client crashes (e.g. Tusky).
$seen = array();
$merged = \array_values(
\array_filter(
$merged,
function ( $status ) use ( &$seen ) {
if ( isset( $seen[ $status->id ] ) ) {
return false;
}
$seen[ $status->id ] = true;
return true;
}
)
);
// Sort by created_at descending.
\usort(
$merged,
function ( $a, $b ) {
$a_ts = isset( $a->created_at ) ? $a->created_at->getTimestamp() : 0;
$b_ts = isset( $b->created_at ) ? $b->created_at->getTimestamp() : 0;
return $b_ts - $a_ts;
}
);
$limit = $request->get_param( 'limit' ) ?: 20;
$statuses->data = \array_slice( $merged, 0, $limit );
return $statuses;
}
/**
* Fetch posts from the tags.pub outbox for a given hashtag.
*
* Resolved ActivityPub objects are cached as plain arrays in a transient
* to avoid storing PHP objects (which is brittle across deployments).
* Status entities are built fresh from the cached data on each request.
*
* @param string $hashtag The hashtag name (without #).
* @param int $limit Maximum number of posts to return.
*
* @return Status[] Array of Status entities.
*/
private static function fetch_tags_pub_outbox( $hashtag, $limit = 20 ) {
$transient_key = 'activitypub_tags_pub_' . \md5( $hashtag );
$cached = \get_transient( $transient_key );
if ( false === $cached ) {
$cached = self::resolve_tags_pub_items( $hashtag, $limit );
$ttl = empty( $cached ) ? 5 * \MINUTE_IN_SECONDS : 15 * \MINUTE_IN_SECONDS;
\set_transient( $transient_key, $cached, $ttl );
}
// Build Status entities from the cached ActivityPub data.
$statuses = array();
foreach ( $cached as $entry ) {
$account = self::get_account_for_actor( $entry['actor_uri'] );
if ( $account ) {
$status = self::activity_to_status( $entry['object'], $account );
if ( $status ) {
$statuses[] = $status;
}
}
}
return $statuses;
}
/**
* Fetch and resolve tags.pub outbox items to cacheable arrays.
*
* Each item requires multiple HTTP round-trips (Announce → original
* post → author actor), so results are cached by the caller.
*
* @param string $hashtag The hashtag name (without #).
* @param int $limit Maximum number of items to resolve.
*
* @return array[] Array of arrays with 'object' and 'actor_uri' keys.
*/
private static function resolve_tags_pub_items( $hashtag, $limit ) {
/**
* Filters the tags.pub base URL for tag timeline lookups.
*
* @since 8.1.0
*
* @param string $base_url The base URL. Default 'https://tags.pub'.
*/
$base_url = \apply_filters( 'activitypub_tags_pub_base_url', 'https://tags.pub' );
$outbox_url = \trailingslashit( $base_url ) . 'user/' . \rawurlencode( $hashtag ) . '/outbox';
$outbox = Http::get_remote_object( $outbox_url, true );
if ( \is_wp_error( $outbox ) || empty( $outbox['first'] ) ) {
return array();
}
$page = Http::get_remote_object( $outbox['first'], true );
if ( \is_wp_error( $page ) ) {
return array();
}
$items = $page['orderedItems'] ?? $page['items'] ?? array();
$results = array();
foreach ( $items as $item ) {
if ( \count( $results ) >= $limit ) {
break;
}
$resolved = self::resolve_tags_pub_item( $item );
if ( $resolved ) {
$results[] = $resolved;
}
}
return $results;
}
/**
* Resolve a tags.pub outbox item (Announce activity) to cacheable data.
*
* @param string|array $item The outbox item (URI string or activity object).
*
* @return array|null Array with 'object' and 'actor_uri' keys, or null on failure.
*/
private static function resolve_tags_pub_item( $item ) {
// Resolve item to an activity object.
if ( \is_string( $item ) ) {
$activity = Http::get_remote_object( $item, true );
if ( \is_wp_error( $activity ) ) {
return null;
}
} elseif ( \is_array( $item ) ) {
$activity = $item;
} else {
return null;
}
$type = $activity['type'] ?? '';
if ( 'Announce' !== $type ) {
return null;
}
// Get the original post URL from the Announce.
$object_url = \is_string( $activity['object'] ) ? $activity['object'] : ( $activity['object']['id'] ?? null );
if ( ! $object_url ) {
return null;
}
$object = Http::get_remote_object( $object_url, true );
if ( \is_wp_error( $object ) ) {
return null;
}
$actor_uri = $object['attributedTo'] ?? '';
if ( ! $actor_uri ) {
return null;
}
return array(
'object' => $object,
'actor_uri' => $actor_uri,
);
}
/**
* Get replies for Mastodon API.
*
@ -718,7 +984,7 @@ class Enable_Mastodon_Apps {
} else {
continue;
}
$response = Http::get( $url, true );
$response = Http::get( $url, array(), true );
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
continue;
}
@ -737,4 +1003,244 @@ class Enable_Mastodon_Apps {
return $context;
}
/**
* Add repost, like, and follow notifications from ActivityPub data.
*
* @param array $notifications The notifications array.
* @param object $request The request object.
* @param int|null $limit Max number of notifications per page.
* @param string|null $before_date MySQL datetime; only return notifications before this date.
* @param string|null $after_date MySQL datetime; only return notifications after this date.
*
* @return array The filtered notifications.
*/
public static function api_notifications_get( $notifications, $request, $limit = null, $before_date = null, $after_date = null ) {
$types = $request->get_param( 'types' );
$exclude_types = $request->get_param( 'exclude_types' );
$include_reblog = ( ! \is_array( $types ) || \in_array( 'reblog', $types, true ) ) &&
( ! \is_array( $exclude_types ) || ! \in_array( 'reblog', $exclude_types, true ) );
$include_favourite = ( ! \is_array( $types ) || \in_array( 'favourite', $types, true ) ) &&
( ! \is_array( $exclude_types ) || ! \in_array( 'favourite', $exclude_types, true ) );
$include_follow = ( ! \is_array( $types ) || \in_array( 'follow', $types, true ) ) &&
( ! \is_array( $exclude_types ) || ! \in_array( 'follow', $exclude_types, true ) );
if ( ! $include_reblog && ! $include_favourite && ! $include_follow ) {
return $notifications;
}
$user_id = \get_current_user_id();
if ( ! $user_id ) {
return $notifications;
}
if ( ! \class_exists( Notification::class ) ) {
return $notifications;
}
if ( null === $limit ) {
$limit = $request->get_param( 'limit' ) ? $request->get_param( 'limit' ) : self::DEFAULT_NOTIFICATION_LIMIT;
}
// Get reblog/favourite notifications from comments.
if ( $include_reblog || $include_favourite ) {
$comment_types = array();
if ( $include_reblog ) {
$comment_types[] = 'repost';
}
if ( $include_favourite ) {
$comment_types[] = 'like';
}
$post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) );
$comment_args = array(
'post_author' => $user_id,
'post_type' => $post_types,
'type__in' => $comment_types,
'number' => $limit,
'orderby' => 'comment_date',
'order' => 'DESC',
);
$date_query = self::build_date_query( $before_date, $after_date, 'comment_date' );
if ( $date_query ) {
$comment_args['date_query'] = $date_query;
}
$comments = \get_comments( $comment_args );
foreach ( $comments as $comment ) {
$type = 'repost' === $comment->comment_type ? 'reblog' : 'favourite';
$account = self::get_account_for_comment( $comment );
if ( ! $account ) {
continue;
}
$status = self::api_post_status( $comment->comment_post_ID );
if ( ! $status ) {
continue;
}
$notification = new Notification();
$notification->id = \strval( $comment->comment_ID );
$notification->type = $type;
$notification->created_at = \mysql2date( 'Y-m-d\TH:i:s.000P', $comment->comment_date, false );
$notification->account = $account;
$notification->status = $status;
$notifications[] = $notification;
}
}
// Get follow notifications from followers.
if ( $include_follow ) {
$notifications = self::add_follow_notifications( $notifications, $user_id, $limit, $before_date, $after_date );
}
return $notifications;
}
/**
* Build a WP_Query/WP_Comment_Query date_query array from optional date bounds.
*
* @param string|null $before_date MySQL datetime upper bound (exclusive).
* @param string|null $after_date MySQL datetime lower bound (exclusive).
* @param string|null $column Optional date column name (e.g. 'comment_date'). Omit for default.
*
* @return array|null date_query array, or null when no bounds are set.
*/
private static function build_date_query( $before_date, $after_date, $column = null ) {
if ( ! $before_date && ! $after_date ) {
return null;
}
$date_query = array();
if ( $before_date ) {
$clause = array(
'before' => $before_date,
'inclusive' => false,
);
if ( $column ) {
$clause['column'] = $column;
}
$date_query[] = $clause;
}
if ( $after_date ) {
$clause = array(
'after' => $after_date,
'inclusive' => false,
);
if ( $column ) {
$clause['column'] = $column;
}
$date_query[] = $clause;
}
return $date_query;
}
/**
* Add follow notifications from ActivityPub followers.
*
* @param array $notifications The notifications array.
* @param int $user_id The user ID.
* @param int $limit Max number of followers to fetch.
* @param string|null $before_date MySQL datetime; only return followers added before this date.
* @param string|null $after_date MySQL datetime; only return followers added after this date.
*
* @return array The notifications array with follow notifications added.
*/
private static function add_follow_notifications( $notifications, $user_id, $limit = self::DEFAULT_NOTIFICATION_LIMIT, $before_date = null, $after_date = null ) {
$user_id = self::maybe_map_user_to_blog( $user_id );
$follower_args = array(
'orderby' => 'post_date',
'order' => 'DESC',
);
$date_query = self::build_date_query( $before_date, $after_date );
if ( $date_query ) {
$follower_args['date_query'] = $date_query;
}
$followers = Followers::get_many(
$user_id,
$limit,
null,
$follower_args
);
foreach ( $followers as $follower ) {
$actor = Remote_Actors::get_actor( $follower );
if ( ! $actor || \is_wp_error( $actor ) ) {
continue;
}
$account = self::actor_to_account( $actor, $follower->ID );
if ( ! $account ) {
continue;
}
$notification = new Notification();
$notification->id = \strval( $follower->ID );
$notification->type = 'follow';
$notification->created_at = \mysql2date( 'Y-m-d\TH:i:s.000P', $follower->post_date, false );
$notification->account = $account;
$notifications[] = $notification;
}
return $notifications;
}
/**
* Get account for a comment from cached data.
*
* @param object $comment The comment object.
*
* @return Account|null The account.
*/
private static function get_account_for_comment( $comment ) {
$default_avatar = \get_avatar_url( $comment->comment_author_email ?: '', array( 'size' => 96 ) );
// Try to get cached remote actor data.
$remote_actor_id = \get_comment_meta( $comment->comment_ID, '_activitypub_remote_actor_id', true );
if ( $remote_actor_id ) {
$actor = Remote_Actors::get_actor( $remote_actor_id );
if ( $actor && ! \is_wp_error( $actor ) ) {
$account = self::actor_to_account( $actor );
// Use remote actor post ID as account ID.
$account->id = \strval( $remote_actor_id );
// Use default avatar if actor has none.
if ( empty( $account->avatar ) ) {
$account->avatar = $default_avatar;
$account->avatar_static = $default_avatar;
}
return $account;
}
}
// Fallback to comment author data.
if ( empty( $comment->comment_author_url ) ) {
return null;
}
$account = new Account();
$account->id = $comment->comment_author_url;
$account->username = $comment->comment_author;
$account->acct = $comment->comment_author_email ?: $comment->comment_author;
$account->display_name = $comment->comment_author;
$account->url = $comment->comment_author_url;
$account->avatar = $default_avatar;
$account->avatar_static = $default_avatar;
$account->created_at = new \DateTime( $comment->comment_date );
return $account;
}
}

View File

@ -7,7 +7,12 @@
namespace Activitypub\Integration;
use Activitypub\Comment;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Following;
use Activitypub\Http;
use Automattic\Jetpack\Connection\Manager;
use function Activitypub\is_activity_object;
/**
* Jetpack integration class.
@ -18,9 +23,42 @@ class Jetpack {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'jetpack_sync_post_meta_whitelist', array( self::class, 'add_sync_meta' ) );
\add_filter( 'jetpack_json_api_comment_types', array( self::class, 'add_comment_types' ) );
\add_filter( 'jetpack_api_include_comment_types_count', array( self::class, 'add_comment_types' ) );
if ( ! \defined( 'IS_WPCOM' ) ) {
\add_filter( 'jetpack_sync_options_whitelist', array( self::class, 'add_sync_options' ) );
\add_filter( 'jetpack_sync_post_meta_whitelist', array( self::class, 'add_sync_meta' ) );
\add_filter( 'jetpack_sync_comment_meta_whitelist', array( self::class, 'add_sync_comment_meta' ) );
\add_filter( 'jetpack_sync_whitelisted_comment_types', array( self::class, 'add_comment_types' ) );
\add_filter( 'jetpack_json_api_comment_types', array( self::class, 'add_comment_types' ) );
\add_filter( 'jetpack_api_include_comment_types_count', array( self::class, 'add_comment_types' ) );
}
if (
( \defined( 'IS_WPCOM' ) && IS_WPCOM ) ||
( \class_exists( '\Automattic\Jetpack\Connection\Manager' ) && ( new Manager() )->is_user_connected() )
) {
\add_filter( 'activitypub_following_row_actions', array( self::class, 'add_reader_link' ), 10, 2 );
\add_filter( 'pre_option_activitypub_following_ui', array( self::class, 'pre_option_activitypub_following_ui' ) );
}
\add_action( 'load-post-new.php', array( self::class, 'adapt_post_share' ) );
}
/**
* Add ActivityPub options to the Jetpack sync allow list.
*
* @since 8.1.0
*
* @param array $allow_list The Jetpack sync options allow list.
*
* @return array The allow list with ActivityPub options.
*/
public static function add_sync_options( $allow_list ) {
$allow_list[] = 'activitypub_blog_identifier';
$allow_list[] = 'activitypub_blog_description';
$allow_list[] = 'activitypub_header_image';
$allow_list[] = 'activitypub_actor_mode';
return $allow_list;
}
/**
@ -31,24 +69,107 @@ class Jetpack {
* @return array The Jetpack sync allow list with ActivityPub meta keys.
*/
public static function add_sync_meta( $allow_list ) {
if ( ! is_array( $allow_list ) ) {
return $allow_list;
}
$activitypub_meta_keys = array(
'_activitypub_user_id',
'_activitypub_inbox',
'_activitypub_actor_json',
);
return \array_merge( $allow_list, $activitypub_meta_keys );
$allow_list[] = Followers::FOLLOWER_META_KEY;
$allow_list[] = Following::FOLLOWING_META_KEY;
return $allow_list;
}
/**
* Add ActivityPub comment meta keys to the Jetpack sync allow list.
*
* @param array $allow_list The Jetpack sync allow list.
*
* @return array The Jetpack sync allow list with ActivityPub comment meta keys.
*/
public static function add_sync_comment_meta( $allow_list ) {
$allow_list[] = 'avatar_url';
return $allow_list;
}
/**
* Add custom comment types to the list of comment types.
*
* @param array $comment_types Default comment types.
* @return array
*
* @return array The comment types with ActivityPub types added.
*/
public static function add_comment_types( $comment_types ) {
return array_unique( \array_merge( $comment_types, Comment::get_comment_type_slugs() ) );
$comment_types[] = 'like';
$comment_types[] = 'quote';
$comment_types[] = 'repost';
return array_unique( $comment_types );
}
/**
* Add a "Reader" link to the bulk actions dropdown on the following list screen.
*
* @param array $actions The bulk actions.
* @param array $item The current following item.
*
* @return array The bulk actions with the "Reader" link.
*/
public static function add_reader_link( $actions, $item ) {
// Do not show the link for pending follow requests.
if ( 'pending' === $item['status'] ) {
return $actions;
}
$feed = \get_post_meta( $item['id'], '_activitypub_actor_feed', true );
// Generate Reader URL based on environment.
if ( \defined( 'IS_WPCOM' ) && IS_WPCOM ) {
if ( empty( $feed['feed_id'] ) ) {
return $actions; // No feed_id available on WPCOM.
}
$url = sprintf( 'https://wordpress.com/reader/feeds/%d', (int) $feed['feed_id'] );
} else {
$url = sprintf( 'https://wordpress.com/reader/feeds/lookup/%s', rawurlencode( $item['identifier'] ) );
}
return array_merge(
array(
'reader' => sprintf(
'<a href="%1$s" target="_blank">%2$s<span class="screen-reader-text"> %3$s</span><span aria-hidden="true" class="dashicons dashicons-external"></span></a>',
esc_url( $url ),
esc_html__( 'View Feed', 'activitypub' ),
/* translators: Hidden accessibility text. */
esc_html__( '(opens in a new tab)', 'activitypub' )
),
),
$actions
);
}
/**
* Force the ActivityPub Following UI to be enabled when Jetpack is active.
*
* @return string '1' to enable the ActivityPub Following UI.
*/
public static function pre_option_activitypub_following_ui() {
return '1';
}
/**
* Adapt the parameters for a post share request to be compatible with the Federated Reply block.
*/
public static function adapt_post_share() {
if ( ! isset( $_GET['is_post_share'], $_GET['url'] ) || ! $_GET['is_post_share'] ) { // phpcs:ignore WordPress.Security
return;
}
$url = \sanitize_url( \wp_unslash( $_GET['url'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
if ( is_activity_object( Http::get_remote_object( $url ) ) ) {
$args = array(
'post_type' => 'post',
'in_reply_to' => $url,
);
\wp_safe_redirect( \add_query_arg( $args, \admin_url( 'post-new.php' ) ) );
exit;
}
}
}

View File

@ -0,0 +1,230 @@
<?php
/**
* LiteSpeed Cache integration file.
*
* @package Activitypub
*/
namespace Activitypub\Integration;
use function Activitypub\is_plugin_active;
/**
* LiteSpeed Cache integration.
*
* @see https://wordpress.org/support/topic/avoiding-caching-activitypub-content/
*/
class Litespeed_Cache {
/**
* The rules to add to the htaccess file.
*
* @var string
*/
public static $rules = '<IfModule LiteSpeed>
RewriteEngine On
RewriteCond %{HTTP:Accept} application
RewriteRule ^ - [E=Cache-Control:vary=%{ENV:LSCACHE_VARY_VALUE}+isjson]
</IfModule>';
/**
* The option name to store the htaccess rules.
*
* @var string
*/
public static $option_name = 'activitypub_litespeed_cache_setup';
/**
* The marker to identify the rules in the htaccess file.
*
* @var string
*/
public static $marker = 'ActivityPub LiteSpeed Cache';
/**
* The LiteSpeed Cache plugin slug.
*
* @var string
*/
public static $plugin_slug = 'litespeed-cache/litespeed-cache.php';
/**
* Initialize the integration.
*/
public static function init() {
// Add rules if LiteSpeed Cache is active and rules aren't set.
if ( is_plugin_active( self::$plugin_slug ) ) {
if ( ! \get_option( self::$option_name ) ) {
self::add_htaccess_rules();
}
\add_filter( 'site_status_tests', array( self::class, 'add_site_health_test' ) );
// Remove rules if LiteSpeed Cache is not active but rules were previously set.
} elseif ( \get_option( self::$option_name ) ) {
self::remove_htaccess_rules();
}
// Clean up when LiteSpeed Cache plugin is deleted.
\add_action( 'deleted_plugin', array( self::class, 'on_plugin_deleted' ) );
}
/**
* Clean up htaccess rules when LiteSpeed Cache plugin is deleted.
*
* @param string $plugin_file Path to the plugin file relative to the plugins directory.
*/
public static function on_plugin_deleted( $plugin_file ) {
if ( self::$plugin_slug === $plugin_file && \get_option( self::$option_name ) ) {
self::remove_htaccess_rules();
}
}
/**
* Add the LiteSpeed Cache htaccess rules.
*/
public static function add_htaccess_rules() {
$added_rules = self::append_with_markers( self::$marker, self::$rules );
if ( $added_rules ) {
\update_option( self::$option_name, '1' );
} else {
\update_option( self::$option_name, '0' );
}
}
/**
* Remove the LiteSpeed Cache htaccess rules.
*/
public static function remove_htaccess_rules() {
self::append_with_markers( self::$marker, '' );
\delete_option( self::$option_name );
}
/**
* Add the LiteSpeed Cache config test to site health.
*
* @param array $tests The site health tests.
*
* @return array The site health tests with the LiteSpeed Cache config test.
*/
public static function add_site_health_test( $tests ) {
$tests['direct']['activitypub_test_litespeed_cache_integration'] = array(
'label' => \__( 'LiteSpeed Cache Test', 'activitypub' ),
'test' => array( self::class, 'test_litespeed_cache_integration' ),
);
return $tests;
}
/**
* Test the LiteSpeed Cache integration.
*
* @return array The test results.
*/
public static function test_litespeed_cache_integration() {
$result = array(
'label' => \__( 'Compatibility with LiteSpeed Cache', 'activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'ActivityPub', 'activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\__( 'LiteSpeed Cache is well configured to work with ActivityPub.', 'activitypub' )
),
'actions' => '',
'test' => 'test_litespeed_cache_integration',
);
if ( ! \get_option( self::$option_name ) ) {
$result['status'] = 'critical';
$result['label'] = \__( 'LiteSpeed Cache might not be properly configured.', 'activitypub' );
$result['badge']['color'] = 'red';
$result['description'] = \sprintf(
'<p>%s</p>',
\__( 'LiteSpeed Cache isn&#8217;t currently set up to work with ActivityPub. While this isn&#8217;t a major problem, it&#8217;s a good idea to enable support. Without it, some technical files (like JSON) might accidentally show up in your website&#8217;s cache and be visible to visitors.', 'activitypub' )
);
$result['actions'] = \sprintf(
'<p>%s</p><pre>%s</pre>',
\__( 'To enable the ActivityPub integration with LiteSpeed Cache, add the following rules to your <code>.htaccess</code> file:', 'activitypub' ),
\esc_html( self::$rules )
);
}
return $result;
}
/**
* Prepend rules to the top of a file with markers.
*
* @param string $marker The marker to identify the rules in the file.
* @param string $rules The rules to prepend.
*
* @return bool True on success, false on failure.
*/
private static function append_with_markers( $marker, $rules ) {
$htaccess_file = self::get_htaccess_file_path();
if ( ! \wp_is_writable( $htaccess_file ) ) {
return false;
}
// Ensure WP_Filesystem() is declared.
require_once ABSPATH . 'wp-admin/includes/file.php';
global $wp_filesystem;
\WP_Filesystem();
$htaccess = $wp_filesystem->get_contents( $htaccess_file );
// If marker exists, remove the old block first.
if ( strpos( $htaccess, $marker ) !== false ) {
// Remove existing marker block.
$pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '.*?# END ' . preg_quote( $marker, '/' ) . '\r?\n?/s';
$htaccess = preg_replace( $pattern, '', $htaccess );
$htaccess = trim( $htaccess );
}
// If rules are empty, just return (for removal case).
if ( empty( $rules ) ) {
return $wp_filesystem->put_contents( $htaccess_file, $htaccess, FS_CHMOD_FILE );
}
// Prepend new rules to the top of the file.
$start_marker = "# BEGIN {$marker}";
$end_marker = "# END {$marker}";
$rules = $start_marker . PHP_EOL . $rules . PHP_EOL . $end_marker;
$htaccess = $rules . PHP_EOL . PHP_EOL . $htaccess;
return $wp_filesystem->put_contents( $htaccess_file, $htaccess, FS_CHMOD_FILE );
}
/**
* Get the htaccess file.
*
* @return string|false The htaccess file or false.
*/
private static function get_htaccess_file_path() {
$htaccess_file = false;
// Ensure get_home_path() is declared.
require_once ABSPATH . 'wp-admin/includes/file.php';
// phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( @file_exists( \get_home_path() . '.htaccess' ) ) {
/** The htaccess file resides in ABSPATH */
$htaccess_file = \get_home_path() . '.htaccess';
}
/**
* Filter the htaccess file path.
*
* @param string|false $htaccess_file The htaccess file path.
*/
return \apply_filters( 'activitypub_litespeed_cache_htaccess_file', $htaccess_file );
}
}

View File

@ -26,8 +26,8 @@ class Multisite_Language_Switcher {
/**
* Short-circuit saving Multisite Language Switcher data for the Outbox post type.
*
* @param int $post_id The post id.
* @param WP_Post $post The post object.
* @param int $post_id The post id.
* @param \WP_Post $post The post object.
*/
public static function ignore_outbox_post( $post_id, $post ) {
if ( Outbox::POST_TYPE === $post->post_type ) {
@ -38,8 +38,8 @@ class Multisite_Language_Switcher {
/**
* Remove short-circuit for Multisite Language Switcher data.
*
* @param int $post_id The post id.
* @param WP_Post $post The post object.
* @param int $post_id The post id.
* @param \WP_Post $post The post object.
*/
public static function unignore_outbox_post( $post_id, $post ) {
if ( Outbox::POST_TYPE === $post->post_type ) {

View File

@ -7,9 +7,11 @@
namespace Activitypub\Integration;
use function Activitypub\get_total_users;
use Activitypub\Webfinger;
use function Activitypub\get_active_users;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_total_users;
/**
* Compatibility with the NodeInfo plugin.
@ -36,19 +38,52 @@ class Nodeinfo {
* @return array The extended array.
*/
public static function add_nodeinfo_data( $nodeinfo, $version ) {
if ( $version >= '2.0' ) {
$nodeinfo['protocols'][] = 'activitypub';
} else {
$nodeinfo['protocols']['inbound'][] = 'activitypub';
$nodeinfo['protocols']['outbound'][] = 'activitypub';
$nodeinfo = wp_parse_args(
$nodeinfo,
array(
'version' => $version,
'software' => array(),
'usage' => array(
'users' => array(
'total' => 0,
'activeMonth' => 0,
'activeHalfyear' => 0,
),
),
'protocols' => array(),
'services' => array(
'inbound' => array(),
'outbound' => array(),
),
'metadata' => array(),
)
);
if ( \version_compare( $version, '2.1', '>=' ) ) {
$nodeinfo['software']['homepage'] = 'https://wordpress.org/plugins/activitypub/';
$nodeinfo['software']['repository'] = 'https://github.com/Automattic/wordpress-activitypub';
}
$nodeinfo['protocols'][] = 'activitypub';
$nodeinfo['services']['inbound'] = array_merge(
$nodeinfo['services']['inbound'],
array( 'gnusocial' )
);
$nodeinfo['services']['outbound'] = array_merge(
$nodeinfo['services']['outbound'],
array( 'friendica', 'gnusocial', 'mediagoblin', 'wordpress' )
);
$nodeinfo['usage']['users'] = array(
'total' => get_total_users(),
'activeMonth' => get_active_users(),
'activeHalfyear' => get_active_users( 6 ),
);
$nodeinfo['metadata']['federation'] = array( 'enabled' => true );
$nodeinfo['metadata']['staffAccounts'] = self::get_staff();
return $nodeinfo;
}
@ -86,4 +121,25 @@ class Nodeinfo {
return $data;
}
/**
* Get all staff accounts (admin users with the "activitypub" capability) and return them in WebFinger resource format.
*
* @return array List of staff accounts in WebFinger resource format.
*/
private static function get_staff() {
// Get all admin users with the cap activitypub.
$admins = get_users(
array(
'role' => 'administrator',
'orderby' => 'ID',
'order' => 'ASC',
'cap' => 'activitypub',
'fields' => 'ID',
)
);
$admins = array_map( array( Webfinger::class, 'get_user_resource' ), $admins );
return array_values( array_filter( $admins ) );
}
}

View File

@ -7,8 +7,8 @@
namespace Activitypub\Integration;
use Activitypub\Model\Blog;
use Activitypub\Collection\Actors;
use Activitypub\Model\Blog;
use function Activitypub\is_single_user;
use function Activitypub\is_user_type_disabled;

View File

@ -0,0 +1,180 @@
<?php
/**
* Podlove Podcast Publisher integration file.
*
* @package Activitypub
*/
namespace Activitypub\Integration;
use Activitypub\Transformer\Post;
use function Activitypub\object_to_uri;
use function Activitypub\seconds_to_iso8601;
/**
* Compatibility with the Podlove Podcast Publisher plugin.
*
* This is a transformer for the Podlove Podcast Publisher plugin,
* that extends the default transformer for WordPress posts.
*
* @see https://wordpress.org/plugins/podlove-podcasting-plugin-for-wordpress/
*/
class Podlove_Podcast_Publisher extends Post {
/**
* The Podlove Episode object.
*
* @var \Podlove\Model\Episode|null
*/
private $episode = null;
/**
* Get the Podlove Episode object.
*
* @return \Podlove\Model\Episode|null The episode object or null if not found.
*/
protected function get_episode() {
if ( null === $this->episode && \class_exists( '\Podlove\Model\Episode' ) ) {
$this->episode = \Podlove\Model\Episode::find_one_by_post_id( $this->item->ID );
}
return $this->episode;
}
/**
* Gets the attachment for a podcast episode.
*
* This method is overridden to add the audio/video files as attachments.
*
* @return array The attachments array.
*/
public function get_attachment() {
$episode = $this->get_episode();
if ( ! $episode ) {
return parent::get_attachment();
}
$attachments = array();
// Get media files from Podlove.
$media_files = $episode->media_files();
foreach ( $media_files as $media_file ) {
if ( ! $media_file->is_valid() ) {
continue;
}
$episode_asset = $media_file->episode_asset();
if ( ! $episode_asset ) {
continue;
}
$file_type = $episode_asset->file_type();
if ( ! $file_type ) {
continue;
}
// Only include audio and video files.
if ( ! in_array( $file_type->type, array( 'audio', 'video' ), true ) ) {
continue;
}
// Use tracking URL if analytics is enabled, otherwise direct file URL.
if ( 'ptm_analytics' === \Podlove\get_setting( 'tracking', 'mode' ) ) {
$file_url = $media_file->get_public_file_url( 'activitypub' );
} else {
$file_url = $media_file->get_file_url();
}
$attachment = array(
'type' => \esc_attr( ucfirst( $file_type->type ) ),
'url' => \esc_url( $file_url ),
'mediaType' => \esc_attr( $file_type->mime_type ),
'name' => \esc_attr( $episode->title() ?? '' ),
);
// Add duration if available (in ISO 8601 format).
$duration = $episode->get_duration( 'seconds' );
if ( $duration && is_numeric( $duration ) && (int) $duration > 0 ) {
$attachment['duration'] = seconds_to_iso8601( (int) $duration );
}
$attachments[] = $attachment;
}
// If we have media files, add episode image as icon.
if ( ! empty( $attachments ) ) {
$icon = $this->get_episode_image();
if ( $icon ) {
foreach ( $attachments as $key => $attachment ) {
$attachments[ $key ]['icon'] = \esc_url( $icon );
}
}
}
// If no Podlove media files found, fall back to parent.
if ( empty( $attachments ) ) {
return parent::get_attachment();
}
return $attachments;
}
/**
* Get the episode image URL.
*
* @return string|null The image URL or null if not found.
*/
protected function get_episode_image() {
$episode = $this->get_episode();
if ( ! $episode ) {
return null;
}
$image = $episode->cover_art_with_fallback();
if ( $image && method_exists( $image, 'url' ) ) {
return $image->url();
}
// Fall back to post thumbnail.
$icon = $this->get_icon();
if ( $icon ) {
return object_to_uri( $icon );
}
return null;
}
/**
* Get the duration of the episode in ISO 8601 format.
*
* @return string|null The duration in ISO 8601 format or null if not available.
*/
public function get_duration() {
$episode = $this->get_episode();
if ( ! $episode ) {
return null;
}
$duration_seconds = $episode->get_duration( 'seconds' );
// Ensure we have a valid numeric duration.
if ( ! $duration_seconds || ! is_numeric( $duration_seconds ) ) {
return null;
}
$duration_seconds = (int) $duration_seconds;
if ( $duration_seconds <= 0 ) {
return null;
}
return seconds_to_iso8601( $duration_seconds );
}
}

View File

@ -10,7 +10,6 @@ namespace Activitypub\Integration;
use Activitypub\Transformer\Post;
use function Activitypub\object_to_uri;
use function Activitypub\generate_post_summary;
/**
* Compatibility with the Seriously Simple Podcasting plugin.
@ -47,26 +46,4 @@ class Seriously_Simple_Podcasting extends Post {
return array( $attachment );
}
/**
* Gets the object type for a podcast episode.
*
* Always returns 'Note' for the best possible compatibility with ActivityPub.
*
* @return string The object type.
*/
public function get_type() {
return 'Note';
}
/**
* Returns the content for the ActivityPub Item.
*
* The content will be generated based on the user settings.
*
* @return string The content.
*/
public function get_content() {
return generate_post_summary( $this->item );
}
}

View File

@ -0,0 +1,206 @@
<?php
/**
* Surge integration file.
*
* @package Activitypub
*/
namespace Activitypub\Integration;
use function Activitypub\is_plugin_active;
/**
* Surge Cache integration.
*
* This class handles the compatibility with the Surge plugin.
*
* @see https://wordpress.org/plugins/surge/
*/
class Surge {
/**
* The pattern to find the Surge cache config constant.
*
* @var string
*/
public static $cache_config_pattern = '/define\s*\(\s*[\'"](WP_CACHE_CONFIG)[\'"]\s*,\s*[\'"](.*?)[\'"]\s*\)\s*;/i';
/**
* Initialize the Surge integration.
*/
public static function init() {
\add_action( 'activate_surge/surge.php', array( self::class, 'add_cache_config' ) );
\add_action( 'deactivate_surge/surge.php', array( self::class, 'remove_cache_config' ) );
\add_filter( 'site_status_tests', array( self::class, 'maybe_add_site_health' ) );
}
/**
* Add the Surge cache config.
*/
public static function add_cache_config() {
// Check if surge is installed and active.
if ( ! is_plugin_active( 'surge/surge.php' ) ) {
return;
}
// Check if the constant already exists.
if ( \defined( 'WP_CACHE_CONFIG' ) ) {
return;
}
$file = self::get_config_file_path();
if ( ! \wp_is_writable( $file ) ) {
return;
}
if ( ! \function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
global $wp_filesystem;
\WP_Filesystem();
$config = $wp_filesystem->get_contents( $file );
// Check if the constant already exists.
if ( \preg_match( self::$cache_config_pattern, $config ) ) {
return;
}
// Add a WP_CACHE_CONFIG to wp-config.php.
$anchor = "/* That's all, stop editing!";
if ( false !== \strpos( $config, $anchor ) ) {
$config = \str_replace( $anchor, self::get_cache_config() . PHP_EOL . PHP_EOL . $anchor, $config );
} elseif ( false !== \strpos( $config, '<?php' ) ) {
$config = \str_replace( '<?php', '<?php' . PHP_EOL . PHP_EOL . self::get_cache_config() . PHP_EOL, $config );
}
$wp_filesystem->put_contents( $file, $config, FS_CHMOD_FILE );
}
/**
* Remove the Surge cache config.
*/
public static function remove_cache_config() {
if ( ! \defined( 'WP_CACHE_CONFIG' ) ) {
return;
}
$file = self::get_config_file_path();
if ( ! \wp_is_writable( $file ) ) {
return;
}
global $wp_filesystem;
\WP_Filesystem();
$config = $wp_filesystem->get_contents( $file );
// Remove the define line.
$config = \preg_replace( PHP_EOL . self::$cache_config_pattern . PHP_EOL, '', $config );
$wp_filesystem->put_contents( $file, $config, FS_CHMOD_FILE );
}
/**
* Get the config file.
*
* @return string|false The config file or false.
*/
public static function get_config_file_path() {
$config_file = false;
// phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( @file_exists( ABSPATH . 'wp-config.php' ) ) {
/** The config file resides in ABSPATH */
$config_file = ABSPATH . 'wp-config.php';
// phpcs:ignore WordPress.PHP.NoSilencedErrors
} elseif ( @file_exists( dirname( ABSPATH ) . '/wp-config.php' ) && ! @file_exists( dirname( ABSPATH ) . '/wp-settings.php' ) ) {
/** The config file resides one level above ABSPATH but is not part of another installation */
$config_file = dirname( ABSPATH ) . '/wp-config.php';
}
/**
* Filter the config file path.
*
* @param string|false $config_file The config file path.
*/
return \apply_filters( 'activitypub_surge_cache_config_file', $config_file );
}
/**
* Maybe add the Surge cache config to the site health.
*
* @param array $tests The site health tests.
*
* @return array The site health tests with the Surge cache config test.
*/
public static function maybe_add_site_health( $tests ) {
if ( ! is_plugin_active( 'surge/surge.php' ) ) {
return $tests;
}
$tests['direct']['activitypub_test_surge_integration'] = array(
'label' => \__( 'Surge Test', 'activitypub' ),
'test' => array( self::class, 'test_surge_integration' ),
);
return $tests;
}
/**
* Surge integration test.
*
* @return array The test result.
*/
public static function test_surge_integration() {
$result = array(
'label' => \__( 'Compatibility with Surge', 'activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'ActivityPub', 'activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\__( 'Surge is well configured to work with ActivityPub.', 'activitypub' )
),
'actions' => '',
'test' => 'test_surge_integration',
);
if ( ! \defined( 'WP_CACHE_CONFIG' ) ) {
$result['status'] = 'critical';
$result['label'] = \__( 'Surge might not be properly configured.', 'activitypub' );
$result['badge']['color'] = 'red';
$result['description'] = \sprintf(
'<p>%s</p>',
\__( 'Surge isn&#8217;t currently set up to work with ActivityPub. While this isn&#8217;t a major problem, it&#8217;s a good idea to enable support. Without it, some technical files (like JSON) might accidentally show up in your website&#8217;s cache and be visible to visitors.', 'activitypub' )
);
$result['actions'] = \sprintf(
'<p>%s</p>',
\sprintf(
// translators: %s: Plugin directory path.
\__( 'To enable the ActivityPub integration with Surge, add the following line to your <code>wp-config.php</code> file: <br /><code>%s</code>', 'activitypub' ),
self::get_cache_config()
)
);
}
return $result;
}
/**
* Get the cache config.
*
* @return string The cache config.
*/
public static function get_cache_config() {
return \sprintf( "define( 'WP_CACHE_CONFIG', '%s/integration/surge-cache-config.php' );", \rtrim( ACTIVITYPUB_PLUGIN_DIR, '/' ) );
}
}

View File

@ -23,6 +23,8 @@ class Webfinger {
public static function init() {
\add_filter( 'webfinger_user_data', array( self::class, 'add_user_discovery' ), 1, 3 );
\add_filter( 'webfinger_data', array( self::class, 'add_pseudo_user_discovery' ), 1, 2 );
\add_filter( 'webfinger_data', array( self::class, 'add_interaction_links' ), 11 );
}
/**
@ -55,11 +57,6 @@ class Webfinger {
'href' => $user->get_id(),
);
$jrd['links'][] = array(
'rel' => 'http://ostatus.org/schema/1.0/subscribe',
'template' => get_rest_url_by_path( 'interactions?uri={uri}' ),
);
return $jrd;
}
@ -101,10 +98,6 @@ class Webfinger {
'type' => 'text/html',
'href' => $user->get_id(),
),
array(
'rel' => 'http://ostatus.org/schema/1.0/subscribe',
'template' => get_rest_url_by_path( 'interactions?uri={uri}' ),
),
),
);
@ -116,4 +109,42 @@ class Webfinger {
return $profile;
}
/**
* Add interaction links to the WebFinger data.
*
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
*
* @param array $jrd The jrd array.
*
* @return array The jrd array.
*/
public static function add_interaction_links( $jrd ) {
if ( ! is_array( $jrd ) ) {
return $jrd;
}
$jrd['links'][] = array(
'rel' => 'http://ostatus.org/schema/1.0/subscribe',
'template' => get_rest_url_by_path( 'interactions?uri={uri}' ),
);
/*
* Note: The parameter name `{inReplyTo}` is used here for all 'Create' intents,
* not just replies, to maintain compatibility with existing implementations and
* the FEP-3b86 specification. If a more generic parameter name is adopted in the
* future, this should be updated accordingly.
*/
$jrd['links'][] = array(
'rel' => 'https://w3id.org/fep/3b86/Create',
'template' => get_rest_url_by_path( 'interactions?uri={inReplyTo}&intent=create' ),
);
$jrd['links'][] = array(
'rel' => 'https://w3id.org/fep/3b86/Follow',
'template' => get_rest_url_by_path( 'interactions?uri={object}&intent=follow' ),
);
return $jrd;
}
}

View File

@ -0,0 +1,148 @@
<?php
/**
* WP REST Cache integration file.
*
* This file contains code for caching ActivityPub REST requests.
*
* Copyright (C) 2025 Epiphyt
* Original code: https://epiph.yt/en/blog/2025/accidental-ddos-through-activitypub-plugin/
*
* Portions of this code are adapted from GPL v2 licensed code.
* As such, you may also redistribute and/or modify those portions under the terms of
* the GNU General Public License as published by the Free Software Foundation.
*
* https://www.gnu.org/licenses/gpl-2.0.html
*
* @package Activitypub
*/
namespace Activitypub\Integration;
use Activitypub\Collection\Outbox;
use Activitypub\Comment;
use WP_Rest_Cache_Plugin\Includes\Caching\Caching;
/**
* Compatibility with the WP REST Cache plugin.
*
* @see https://wordpress.org/plugins/wp-rest-cache/
*/
class WP_Rest_Cache {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'wp_rest_cache/allowed_endpoints', array( self::class, 'add_activitypub_endpoints' ) );
\add_filter( 'wp_rest_cache/determine_object_type', array( self::class, 'set_object_type' ), 10, 4 );
\add_filter( 'wp_rest_cache/is_single_item', array( self::class, 'set_is_single_item' ), 10, 3 );
\add_action( 'transition_post_status', array( self::class, 'transition_post_status' ), 10, 3 );
\add_action( 'transition_comment_status', array( self::class, 'transition_comment_status' ), 10, 3 );
}
/**
* Add ActivityPub endpoints to the list of allowed endpoints.
*
* @param array $endpoints List of allowed endpoints.
*
* @return array Filtered list of allowed endpoints.
*/
public static function add_activitypub_endpoints( $endpoints ) {
$endpoints[ ACTIVITYPUB_REST_NAMESPACE ] = array( 'actors', 'collections', 'comments', 'interactions', 'nodeinfo', 'posts', 'users' );
return $endpoints;
}
/**
* Set whether the cache represents a single item.
*
* Always return false for ActivityPub endpoints, since cache entries cannot be flushed otherwise.
*
* @param bool $is_single Whether the current cache represents a single item.
* @param mixed $data Data to cache.
* @param string $uri Request URI.
*
* @return bool Whether the cache represents a single item.
*/
public static function set_is_single_item( $is_single, $data, $uri ) {
if ( self::is_activitypub_endpoint( $uri ) ) {
return false;
}
return $is_single;
}
/**
* Set object type for ActivityPub.
*
* @param string $object_type Object type.
* @param string $cache_key Object key.
* @param mixed $data Data to cache.
* @param string $uri Request URI.
*
* @return string Updated object type.
*/
public static function set_object_type( $object_type, $cache_key, $data, $uri ) {
if ( self::is_activitypub_endpoint( $uri ) ) {
return 'ActivityPub';
}
return $object_type;
}
/**
* Reset cache by transition post status.
*
* @param string $new_status New post status.
* @param string $old_status Old post status.
* @param \WP_Post $post Post object.
*/
public static function transition_post_status( $new_status, $old_status, $post ) {
if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
return;
}
$post_types = \get_option( 'activitypub_support_post_types', array() );
$post_types[] = Outbox::POST_TYPE;
if ( ! \in_array( $post->post_type, $post_types, true ) ) {
return;
}
Caching::get_instance()->delete_object_type_caches( 'ActivityPub' );
}
/**
* Reset cache by transition comment status.
*
* @param string $new_status The new comment status.
* @param string $old_status The old comment status.
* @param \WP_Comment $comment Comment object.
*/
public static function transition_comment_status( $new_status, $old_status, $comment ) {
if ( 'approved' !== $new_status && 'approved' !== $old_status ) {
return;
}
$comment_types = Comment::get_comment_type_slugs();
$comment_types[] = 'comment';
if ( ! \in_array( $comment->comment_type ?: 'comment', $comment_types, true ) ) {
return;
}
Caching::get_instance()->delete_object_type_caches( 'ActivityPub' );
}
/**
* Test, whether the current endpoint is an ActivityPub endpoint.
*
* @param string $uri URI to test.
*
* @return bool Whether the current endpoint is an ActivityPub endpoint.
*/
private static function is_activitypub_endpoint( $uri ) {
$search = '/' . ACTIVITYPUB_REST_NAMESPACE . '/';
return \str_contains( $uri, $search ) || \str_contains( $uri, 'rest_route=' . \rawurlencode( $search ) );
}
}

View File

@ -24,7 +24,7 @@ class WPML {
* Fetch the post locale from the WPML post data.
*
* @param string $lang The language code.
* @param int $post The post object.
* @param mixed $post The post object.
*
* @return string The modified language code.
*/

View File

@ -0,0 +1,178 @@
<?php
/**
* Yoast SEO integration file.
*
* @package Activitypub
*/
namespace Activitypub\Integration;
/**
* Yoast SEO integration class.
*/
class Yoast_Seo {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'site_status_tests', array( self::class, 'add_site_health_tests' ), 11 ); // After Health_Check::add_tests().
}
/**
* Add Yoast-specific site health tests.
*
* @param array $tests The site health tests array.
*
* @return array The modified tests array.
*/
public static function add_site_health_tests( $tests ) {
// Only add the test if attachment post type is supported by ActivityPub.
if ( self::is_attachment_supported() ) {
$tests['direct']['activitypub_yoast_seo_media_pages'] = array(
'label' => \__( 'Yoast SEO Media Pages Test', 'activitypub' ),
'test' => array( self::class, 'test_yoast_seo_media_pages' ),
);
}
$tests['direct']['activitypub_yoast_seo_author_archives'] = array(
'label' => \__( 'Yoast SEO Author Archives Test', 'activitypub' ),
'test' => array( self::class, 'test_yoast_seo_author_archives' ),
);
// Remove author URL test if author archives are disabled. There is no need to show this error twice.
if ( self::is_author_archives_disabled() && isset( $tests['direct']['activitypub_test_author_url'] ) ) {
unset( $tests['direct']['activitypub_test_author_url'] );
}
return $tests;
}
/**
* Test if Yoast's "Enable media pages" setting is properly configured.
*
* @return array The test result.
*/
public static function test_yoast_seo_media_pages() {
$result = array(
'label' => \__( 'Yoast SEO media pages are enabled', 'activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'ActivityPub', 'activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\__( 'Media pages are enabled in Yoast SEO, which allows media attachments to be federated and interacted with through ActivityPub.', 'activitypub' )
),
'actions' => '',
'test' => 'test_yoast_seo_media_pages',
);
if ( self::is_media_pages_disabled() ) {
$result['status'] = 'recommended';
$result['label'] = \__( 'Yoast SEO media pages should be enabled', 'activitypub' );
$result['badge']['color'] = 'orange';
$result['description'] = \sprintf(
'<p>%s</p>',
\__( 'Yoast SEO&#8217;s &#8220;Enable media pages&#8221; setting is currently disabled. Since you have media attachments configured to be federated through ActivityPub, you should enable media pages so that media can be properly accessed and interacted with by ActivityPub clients and other federated platforms.', 'activitypub' )
);
$result['actions'] = \sprintf(
'<p>%s</p>',
\sprintf(
// translators: %s: Yoast SEO settings URL.
\__( 'You can enable media pages in <a href="%s">Yoast SEO > Settings > Advanced > Media pages</a>.', 'activitypub' ),
\esc_url( \admin_url( 'admin.php?page=wpseo_page_settings#/media-pages' ) )
)
);
}
return $result;
}
/**
* Check if Yoast SEO media pages are disabled.
*
* @return bool True if media pages are disabled, false otherwise.
*/
public static function is_media_pages_disabled() {
// Get Yoast SEO options.
$yoast_options = \get_option( 'wpseo_titles' );
if ( ! is_array( $yoast_options ) ) {
return false;
}
// Check if disable-attachment is set to true (media pages disabled).
return isset( $yoast_options['disable-attachment'] ) && true === $yoast_options['disable-attachment'];
}
/**
* Check if attachment post type is supported by ActivityPub.
*
* @return bool True if attachment is supported, false otherwise.
*/
private static function is_attachment_supported() {
$supported_post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) );
return in_array( 'attachment', $supported_post_types, true );
}
/**
* Test if Yoast's "Enable author archives" setting is properly configured.
*
* @return array The test result.
*/
public static function test_yoast_seo_author_archives() {
$result = array(
'label' => \__( 'Yoast SEO author archives are enabled', 'activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'ActivityPub', 'activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\__( 'Author archives are enabled in Yoast SEO, which allows author archives to be federated and interacted with through ActivityPub.', 'activitypub' )
),
'actions' => '',
'test' => 'test_yoast_seo_author_archives',
);
if ( self::is_author_archives_disabled() ) {
$result['status'] = 'critical';
$result['label'] = \__( 'Yoast SEO author archives are not enabled', 'activitypub' );
$result['badge']['color'] = 'red';
$result['description'] = \sprintf(
'<p>%s</p>',
\__( 'The &#8220;Enable author archives&#8221; setting in Yoast SEO is currently disabled. Author archives are essential for ActivityPub, as they act as user profile pages. Without them, other platforms wont be able to view those profiles.', 'activitypub' )
);
$result['actions'] = \sprintf(
'<p>%s</p>',
\sprintf(
// translators: %s: Yoast SEO settings URL.
\__( 'You can enable author archives in <a href="%s">Yoast SEO > Settings > Advanced > Author archives</a>.', 'activitypub' ),
\esc_url( \admin_url( 'admin.php?page=wpseo_page_settings#/author-archives' ) )
)
);
}
return $result;
}
/**
* Check if Yoast SEO author archives are disabled.
*
* @return bool True if author archives are disabled, false otherwise.
*/
public static function is_author_archives_disabled() {
// Get Yoast SEO options.
$yoast_options = \get_option( 'wpseo_titles' );
if ( ! is_array( $yoast_options ) ) {
return false;
}
// Check if disable-author is set (author archives disabled).
return (bool) ( $yoast_options['disable-author'] ?? false );
}
}

View File

@ -7,6 +7,8 @@
namespace Activitypub\Integration;
use function Activitypub\site_supports_blocks;
\Activitypub\Autoloader::register_path( __NAMESPACE__, __DIR__ );
/**
@ -14,24 +16,27 @@ namespace Activitypub\Integration;
*/
function plugin_init() {
/**
* Adds WebFinger (plugin) support.
* Adds Akismet support.
*
* This class handles the compatibility with the WebFinger plugin
* and coordinates the internal WebFinger implementation.
* This class handles the compatibility with the Akismet plugin.
*
* @see https://wordpress.org/plugins/webfinger/
* @see https://wordpress.org/plugins/akismet/
*/
Webfinger::init();
if ( \defined( 'AKISMET_VERSION' ) ) {
Akismet::init();
}
/**
* Adds NodeInfo (plugin) support.
* Adds Classic Editor support.
*
* This class handles the compatibility with the NodeInfo plugin
* and coordinates the internal NodeInfo implementation.
* This class handles the compatibility with the Classic Editor plugin
* and sites without block editor support.
*
* @see https://wordpress.org/plugins/nodeinfo/
* @see https://wordpress.org/plugins/classic-editor/
*/
Nodeinfo::init();
if ( \class_exists( '\Classic_Editor' ) || \function_exists( 'classicpress_version' ) || ! site_supports_blocks() ) {
Classic_Editor::init();
}
/**
* Adds Enable Mastodon Apps support.
@ -44,17 +49,6 @@ function plugin_init() {
Enable_Mastodon_Apps::init();
}
/**
* Adds OpenGraph support.
*
* This class handles the compatibility with the OpenGraph plugin.
*
* @see https://wordpress.org/plugins/opengraph/
*/
if ( '1' === \get_option( 'activitypub_use_opengraph', '1' ) ) {
Opengraph::init();
}
/**
* Adds Jetpack support.
*
@ -62,20 +56,18 @@ function plugin_init() {
*
* @see https://jetpack.com/
*/
if ( \defined( 'JETPACK__VERSION' ) && ! \defined( 'IS_WPCOM' ) ) {
if ( \defined( 'JETPACK__VERSION' ) ) {
Jetpack::init();
}
/**
* Adds Akismet support.
* Adds LiteSpeed Cache support.
*
* This class handles the compatibility with the Akismet plugin.
* The check for whether LiteSpeed Cache is loaded and initialized happens inside Litespeed_Cache::init().
*
* @see https://wordpress.org/plugins/akismet/
* @see https://wordpress.org/plugins/litespeed-cache/
*/
if ( \defined( 'AKISMET_VERSION' ) ) {
Akismet::init();
}
Litespeed_Cache::init();
/**
* Adds Multisite Language Switcher support.
@ -88,6 +80,55 @@ function plugin_init() {
Multisite_Language_Switcher::init();
}
/**
* Adds NodeInfo (plugin) support.
*
* This class handles the compatibility with the NodeInfo plugin
* and coordinates the internal NodeInfo implementation.
*
* @see https://wordpress.org/plugins/nodeinfo/
*/
Nodeinfo::init();
/**
* Adds OpenGraph support.
*
* This class handles the compatibility with the OpenGraph plugin.
*
* @see https://wordpress.org/plugins/opengraph/
*/
if ( '1' === \get_option( 'activitypub_use_opengraph', '1' ) ) {
Opengraph::init();
}
/**
* Adds Podlove Podcast Publisher support.
*
* This class handles the compatibility with Podlove Podcast Publisher.
*
* @see https://wordpress.org/plugins/podlove-podcasting-plugin-for-wordpress/
*/
if ( \defined( 'Podlove\PLUGIN_FILE' ) ) {
// Enable ActivityPub support for the podcast post type.
\add_post_type_support( 'podcast', 'activitypub' );
\add_filter(
'activitypub_transformer',
static function ( $transformer, $data, $object_class ) {
if (
'WP_Post' === $object_class &&
'podcast' === $data->post_type &&
\Podlove\Model\Episode::find_one_by_post_id( $data->ID )
) {
return new Podlove_Podcast_Publisher( $data );
}
return $transformer;
},
10,
3
);
}
/**
* Adds Seriously Simple Podcasting support.
*
@ -98,7 +139,7 @@ function plugin_init() {
if ( \defined( 'SSP_VERSION' ) ) {
add_filter(
'activitypub_transformer',
function ( $transformer, $data, $object_class ) {
static function ( $transformer, $data, $object_class ) {
if (
'WP_Post' === $object_class &&
\get_post_meta( $data->ID, 'audio_file', true )
@ -112,6 +153,45 @@ function plugin_init() {
);
}
/**
* Adds Stream support.
*
* This class handles the compatibility with the Stream plugin.
*
* @see https://wordpress.org/plugins/stream/
*/
Stream\Stream::init();
/**
* Adds Surge support.
*
* Only load code that needs Surge to run once Surge is loaded and initialized.
*
* @see https://wordpress.org/plugins/surge/
*/
Surge::init();
/**
* Adds WebFinger (plugin) support.
*
* This class handles the compatibility with the WebFinger plugin
* and coordinates the internal WebFinger implementation.
*
* @see https://wordpress.org/plugins/webfinger/
*/
Webfinger::init();
/**
* Adds WP REST Cache support.
*
* This class handles the compatibility with the WP REST Cache plugin.
*
* @see https://wordpress.org/plugins/wp-rest-cache/
*/
if ( \class_exists( 'WP_Rest_Cache_Plugin\Includes\Plugin' ) ) {
WP_Rest_Cache::init();
}
/**
* Adds WPML Multilingual CMS (plugin) support.
*
@ -122,37 +202,27 @@ function plugin_init() {
if ( \defined( 'ICL_SITEPRESS_VERSION' ) ) {
WPML::init();
}
/**
* Adds Yoast SEO support.
*
* This class handles the compatibility with Yoast SEO.
*
* @see https://wordpress.org/plugins/wordpress-seo/
*/
if ( \defined( 'WPSEO_VERSION' ) ) {
Yoast_Seo::init();
}
}
\add_action( 'plugins_loaded', __NAMESPACE__ . '\plugin_init' );
/**
* Register the Stream Connector for ActivityPub.
*
* @param array $classes The Stream connectors.
*
* @return array The Stream connectors with the ActivityPub connector.
*/
function register_stream_connector( $classes ) {
$class = new Stream_Connector();
// Register activation and deactivation hooks for Surge integration.
\register_activation_hook( ACTIVITYPUB_PLUGIN_FILE, array( __NAMESPACE__ . '\Surge', 'add_cache_config' ) );
\register_deactivation_hook( ACTIVITYPUB_PLUGIN_FILE, array( __NAMESPACE__ . '\Surge', 'remove_cache_config' ) );
if ( method_exists( $class, 'is_dependency_satisfied' ) && $class->is_dependency_satisfied() ) {
$classes[] = $class;
}
return $classes;
}
add_filter( 'wp_stream_connectors', __NAMESPACE__ . '\register_stream_connector' );
// Excluded ActivityPub post types from the Stream.
add_filter(
'wp_stream_posts_exclude_post_types',
function ( $post_types ) {
$post_types[] = 'ap_follower';
$post_types[] = 'ap_extrafield';
$post_types[] = 'ap_extrafield_blog';
return $post_types;
}
);
// Register activation and deactivation hooks for LiteSpeed Cache integration.
\register_activation_hook( ACTIVITYPUB_PLUGIN_FILE, array( __NAMESPACE__ . '\LiteSpeed_Cache', 'add_htaccess_rules' ) );
\register_deactivation_hook( ACTIVITYPUB_PLUGIN_FILE, array( __NAMESPACE__ . '\LiteSpeed_Cache', 'remove_htaccess_rules' ) );
/**
* Load the BuddyPress integration.
@ -161,4 +231,4 @@ add_filter(
*
* @see https://buddypress.org/
*/
add_action( 'bp_include', array( __NAMESPACE__ . '\Buddypress', 'init' ), 0 );
\add_action( 'bp_include', array( __NAMESPACE__ . '\Buddypress', 'init' ), 0 );

View File

@ -5,9 +5,10 @@
* @package Activitypub
*/
namespace Activitypub\Integration;
namespace Activitypub\Integration\Stream;
use Activitypub\Collection\Actors;
use function Activitypub\url_to_authorid;
use function Activitypub\url_to_commentid;
@ -18,7 +19,7 @@ use function Activitypub\url_to_commentid;
*
* @see https://wordpress.org/plugins/stream/
*/
class Stream_Connector extends \WP_Stream\Connector {
class Connector extends \WP_Stream\Connector {
/**
* Connector slug.
*
@ -32,7 +33,7 @@ class Stream_Connector extends \WP_Stream\Connector {
* @var array
*/
public $actions = array(
'activitypub_notification_follow',
'activitypub_handled_follow',
'activitypub_sent_to_inbox',
'activitypub_outbox_processing_complete',
'activitypub_outbox_processing_batch_complete',
@ -44,7 +45,7 @@ class Stream_Connector extends \WP_Stream\Connector {
* @return string
*/
public function get_label() {
return __( 'ActivityPub', 'activitypub' );
return \__( 'ActivityPub', 'activitypub' );
}
/**
@ -63,7 +64,7 @@ class Stream_Connector extends \WP_Stream\Connector {
*/
public function get_action_labels() {
return array(
'processed' => __( 'Processed', 'activitypub' ),
'processed' => \__( 'Processed', 'activitypub' ),
);
}
@ -79,25 +80,25 @@ class Stream_Connector extends \WP_Stream\Connector {
*/
public function action_links( $links, $record ) {
if ( 'processed' === $record->action ) {
$error = json_decode( $record->get_meta( 'error', true ), true );
$error = \json_decode( $record->get_meta( 'error', true ), true );
if ( $error ) {
$message = sprintf(
$message = \sprintf(
'<details><summary>%1$s</summary><pre>%2$s</pre></details>',
__( 'Inbox Error', 'activitypub' ),
wp_json_encode( $error )
\__( 'Inbox Error', 'activitypub' ),
\wp_json_encode( $error )
);
$links[ $message ] = '';
}
$debug = json_decode( $record->get_meta( 'debug', true ), true );
$debug = \json_decode( $record->get_meta( 'debug', true ), true );
if ( $debug ) {
$message = sprintf(
$message = \sprintf(
'<details><summary>%1$s</summary><pre>%2$s</pre></details>',
__( 'Debug', 'activitypub' ),
wp_json_encode( $debug )
\__( 'Debug', 'activitypub' ),
\wp_json_encode( $debug )
);
$links[ $message ] = '';
@ -108,24 +109,30 @@ class Stream_Connector extends \WP_Stream\Connector {
}
/**
* Callback for activitypub_notification_follow.
* Callback for activitypub_handled_follow.
*
* @param \Activitypub\Notification $notification The notification object.
* @param array $activity The ActivityPub activity data.
* @param int|null $user_id The local user ID, or null if not applicable.
* @param mixed $state Status or WP_Error object indicating the result of the follow handling.
* @param \WP_Post|null $context The WP_Post object representing the remote actor/follower.
*/
public function callback_activitypub_notification_follow( $notification ) {
public function callback_activitypub_handled_follow( $activity, $user_id, $state, $context ) {
$actor_url = \is_object( $context ) && ! \is_wp_error( $context ) ? $context->guid : $activity['actor'];
$this->log(
sprintf(
\sprintf(
// translators: %s is a URL.
__( 'New Follower: %s', 'activitypub' ),
$notification->actor
\__( 'New Follower: %s', 'activitypub' ),
$actor_url
),
array(
'notification' => \wp_json_encode( $notification, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
'activity' => \wp_json_encode( $activity, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
'remote_actor' => \wp_json_encode( $context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
),
null,
'notification',
$notification->type,
$notification->target
'follow',
$user_id
);
}
@ -142,13 +149,13 @@ class Stream_Connector extends \WP_Stream\Connector {
$outbox_data = $this->prepare_outbox_data_for_response( $outbox_item );
$this->log(
sprintf(
\sprintf(
// translators: %s is a URL.
__( 'Outbox processing complete: %s', 'activitypub' ),
\__( 'Outbox processing complete: %s', 'activitypub' ),
$outbox_data['title']
),
array(
'debug' => wp_json_encode(
'debug' => \wp_json_encode(
array(
'actor_id' => $actor_id,
'outbox_item_id' => $outbox_item_id,
@ -176,13 +183,10 @@ class Stream_Connector extends \WP_Stream\Connector {
$outbox_data = $this->prepare_outbox_data_for_response( $outbox_item );
$this->log(
sprintf(
// translators: %s is a URL.
__( 'Outbox processing batch complete: %s', 'activitypub' ),
$outbox_data['title']
),
// translators: %s is a URL.
\sprintf( \__( 'Outbox processing batch complete: %s', 'activitypub' ), $outbox_data['title'] ),
array(
'debug' => wp_json_encode(
'debug' => \wp_json_encode(
array(
'actor_id' => $actor_id,
'outbox_item_id' => $outbox_item_id,
@ -209,9 +213,9 @@ class Stream_Connector extends \WP_Stream\Connector {
$object_type = $outbox_item->post_type;
$object_title = $outbox_item->post_title;
$post_id = url_to_postid( $outbox_item->post_title );
$post_id = \url_to_postid( $outbox_item->post_title );
if ( $post_id ) {
$post = get_post( $post_id );
$post = \get_post( $post_id );
$object_id = $post_id;
$object_type = $post->post_type;
@ -219,7 +223,7 @@ class Stream_Connector extends \WP_Stream\Connector {
} else {
$comment_id = url_to_commentid( $outbox_item->post_title );
if ( $comment_id ) {
$comment = get_comment( $comment_id );
$comment = \get_comment( $comment_id );
$object_id = $comment_id;
$object_type = 'comments';
@ -231,11 +235,11 @@ class Stream_Connector extends \WP_Stream\Connector {
$object_type = 'profiles';
if ( $author_id ) {
$object_title = get_userdata( $author_id )->display_name;
$object_title = \get_userdata( $author_id )->display_name;
} elseif ( Actors::BLOG_USER_ID === $author_id ) {
$object_title = __( 'Blog User', 'activitypub' );
$object_title = \__( 'Blog User', 'activitypub' );
} elseif ( Actors::APPLICATION_USER_ID === $author_id ) {
$object_title = __( 'Application User', 'activitypub' );
$object_title = \__( 'Application User', 'activitypub' );
}
}
}

View File

@ -0,0 +1,58 @@
<?php
/**
* Stream integration file.
*
* @package Activitypub
*/
namespace Activitypub\Integration\Stream;
/**
* Stream integration.
*
* This class handles the compatibility with the Stream plugin.
*
* @see https://wordpress.org/plugins/stream/
*/
class Stream {
/**
* Initialize the Stream integration.
*/
public static function init() {
\add_filter( 'wp_stream_connectors', array( self::class, 'register_connector' ) );
\add_filter( 'wp_stream_posts_exclude_post_types', array( self::class, 'exclude_post_types' ) );
}
/**
* Register the Stream Connector for ActivityPub.
*
* @param array $classes The Stream connectors.
*
* @return array The Stream connectors with the ActivityPub connector.
*/
public static function register_connector( $classes ) {
$class = new Connector();
if ( \method_exists( $class, 'is_dependency_satisfied' ) && $class->is_dependency_satisfied() ) {
$classes[] = $class;
}
return $classes;
}
/**
* Exclude ActivityPub post types from the Stream.
*
* @param array $post_types The post types to exclude.
*
* @return array The post types to exclude with ActivityPub post types.
*/
public static function exclude_post_types( $post_types ) {
$post_types[] = 'ap_actor';
$post_types[] = 'ap_extrafield';
$post_types[] = 'ap_extrafield_blog';
$post_types[] = 'ap_post';
return $post_types;
}
}

View File

@ -0,0 +1,32 @@
<?php
/**
* Content negotiation fix for Surge.
*
* @see https://dominikschilling.de/notes/http-accept-header-wordpress-cache-activitypub/
*
* @package Activitypub
*/
$representation = 'html';
if ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
$accept = strtolower( $_SERVER['HTTP_ACCEPT'] );
if ( str_contains( $accept, 'text/html' ) ) {
$representation = 'html';
} elseif (
str_contains( $accept, 'application/json' ) ||
str_contains( $accept, 'application/activity+json' ) ||
str_contains( $accept, 'application/ld+json' )
) {
$representation = 'json';
}
}
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
$config['variants']['representation'] = $representation;
unset( $accept, $representation );
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
return $config;