';
// Try to get and append the embed if requested.
+ $embed = null;
if ( $show_embed ) {
- $embed = wp_oembed_get( $attrs['url'] );
+ // Use the theme's content width or a reasonable default to avoid narrow embeds.
+ $embed_width = ! empty( $GLOBALS['content_width'] ) ? $GLOBALS['content_width'] : 600;
+ $embed = wp_oembed_get( $attrs['url'], array( 'width' => $embed_width ) );
if ( $embed ) {
$html .= $embed;
+ \wp_enqueue_script( 'wp-embed' );
}
}
- // Only show the link if we're not showing the embed.
- if ( ! $show_embed ) {
+ // Show the link if embed is not requested or if embed failed.
+ if ( ! $show_embed || ! $embed ) {
$html .= sprintf(
'
%3$s
',
esc_url( $attrs['url'] ),
@@ -383,43 +715,195 @@ class Blocks {
}
/**
- * Render a follower.
+ * Renders a modal component that can be used by different blocks.
*
- * @param \Activitypub\Model\Follower $follower The follower to render.
+ * @param array $args {
+ * Arguments for the modal.
*
- * @return string The HTML to render.
+ * @type string $content The modal content HTML.
+ * @type string $id Optional ID prefix for the modal elements.
+ * @type bool $is_compact Whether the modal is compact (popover-style). Default false.
+ * @type string $title Static title text for the modal header.
+ * @type string $title_binding Optional Interactivity API binding for a dynamic title
+ * (e.g. 'context.modal.title'). When set, uses data-wp-text
+ * on the title element and enables dynamic compact toggling.
+ * }
*/
- public static function render_follower( $follower ) {
- $external_svg = '
';
- $template =
- '
-
-
- %s
- /
- @%s
-
- %s
- ';
-
- $data = $follower->to_array();
-
- return sprintf(
- $template,
- esc_url( object_to_uri( $data['url'] ) ),
- esc_attr( $data['name'] ),
- esc_attr( $data['icon']['url'] ),
- esc_html( $data['name'] ),
- esc_html( $data['preferredUsername'] ),
- $external_svg
+ public static function render_modal( $args = array() ) {
+ $defaults = array(
+ 'content' => '',
+ 'id' => '',
+ 'is_compact' => false,
+ 'title' => '',
+ 'title_binding' => '',
);
+
+ $args = \wp_parse_args( $args, $defaults );
+ ?>
+
+
+ data-wp-class--compact="context.modal.isCompact"
+
+ role="dialog"
+ aria-modal="true"
+ hidden
+ >
+
+
+ ` element that explains decentralized
+ * interactions to users unfamiliar with the Fediverse.
+ *
+ * @since 8.0.0
+ */
+ public static function render_modal_help() {
+ ?>
+
+
+
+
+
+
+
+
+
+ true,
+ 'show_pagination' => true,
+ 'total' => 0,
+ 'per_page' => 10,
+ 'nav_label' => __( 'Actor navigation', 'activitypub' ),
+ );
+
+ $args = \wp_parse_args( $args, $defaults );
+
+ // Sanitize numeric values, ensuring per_page is at least 1 to avoid division by zero.
+ $args['total'] = \absint( $args['total'] );
+ $args['per_page'] = \max( 1, \absint( $args['per_page'] ) );
+ ?>
+
+
+
+
+ $args['per_page'] ) : ?>
+
+
+
+
+
+ .*?#is', $data['post_content'], $matches );
$blocks = \array_map(
- function ( $paragraph ) {
+ static function ( $paragraph ) {
return '' . PHP_EOL . $paragraph . PHP_EOL . '' . PHP_EOL;
},
$matches[0] ?? array()
@@ -436,11 +920,410 @@ class Blocks {
$data['post_content'] = \rtrim( \implode( PHP_EOL, $blocks ), PHP_EOL );
// Add reply block if it's a reply.
- if ( null !== $post->object->inReplyTo ) {
- $reply_block = \sprintf( '' . PHP_EOL, \esc_url( $post->object->inReplyTo ) );
+ if ( ! empty( $post['object']['inReplyTo'] ) ) {
+ $reply_block = \sprintf( '' . PHP_EOL, \esc_url( $post['object']['inReplyTo'] ) );
$data['post_content'] = $reply_block . $data['post_content'];
}
return $data;
}
+
+ /**
+ * Add Interactivity directions to the specified element.
+ *
+ * @param string $content The block content.
+ * @param string[] $selector The selector for the element to add directions to.
+ * @param string[] $attributes The attributes to add to the element.
+ *
+ * @return string The updated content.
+ */
+ public static function add_directions( $content, $selector, $attributes ) {
+ $tags = new \WP_HTML_Tag_Processor( $content );
+
+ while ( $tags->next_tag( $selector ) ) {
+ foreach ( $attributes as $key => $value ) {
+ if ( 'class' === $key ) {
+ $tags->add_class( $value );
+ continue;
+ }
+
+ $tags->set_attribute( $key, $value );
+ }
+ }
+
+ return $tags->get_updated_html();
+ }
+
+ /**
+ * Add post transformation callbacks.
+ *
+ * @param object $post The post object.
+ */
+ public static function add_post_transformation_callbacks( $post ) {
+ \add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 );
+ \add_filter( 'render_block_activitypub/stats', '__return_empty_string' );
+
+ // Only transform reply link if it's the first block in the post.
+ $blocks = \parse_blocks( $post->post_content );
+ if ( ! empty( $blocks ) && 'activitypub/reply' === $blocks[0]['blockName'] ) {
+ \add_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ), 10, 2 );
+ }
+ }
+
+ /**
+ * Remove post transformation callbacks.
+ *
+ * @param string $content The post content.
+ *
+ * @return string The updated content.
+ */
+ public static function remove_post_transformation_callbacks( $content ) {
+ \remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
+ \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
+ \remove_filter( 'render_block_activitypub/stats', '__return_empty_string' );
+
+ return $content;
+ }
+
+ /**
+ * Generate HTML @ link for reply block.
+ *
+ * @param string $block_content The block content.
+ * @param array $block The block data.
+ *
+ * @return string The HTML @ link.
+ */
+ public static function generate_reply_link( $block_content, $block ) {
+ // Unhook ourselves after first execution to ensure only the first reply block gets transformed.
+ \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
+
+ // Return empty string if no URL is provided.
+ if ( empty( $block['attrs']['url'] ) ) {
+ return '';
+ }
+
+ $url = $block['attrs']['url'];
+
+ // Try to get ActivityPub representation. Is likely already cached.
+ $object = Http::get_remote_object( $url );
+ if ( \is_wp_error( $object ) ) {
+ return '';
+ }
+
+ $author_url = $object['attributedTo'] ?? '';
+ if ( ! $author_url ) {
+ return '';
+ }
+
+ // Fetch author information.
+ $author = Http::get_remote_object( $author_url );
+ if ( \is_wp_error( $author ) ) {
+ return '';
+ }
+
+ // Get webfinger identifier.
+ $webfinger = '';
+ if ( ! empty( $author['webfinger'] ) ) {
+ $webfinger = \str_replace( 'acct:', '', $author['webfinger'] );
+ } elseif ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
+ // Construct webfinger-style identifier from username and domain.
+ $domain = \wp_parse_url( $author['url'], PHP_URL_HOST );
+ $webfinger = '@' . $author['preferredUsername'] . '@' . $domain;
+ }
+
+ if ( ! $webfinger ) {
+ return '';
+ }
+
+ // Generate HTML @ link.
+ return \sprintf(
+ '
%3$s
',
+ \esc_url( $url ),
+ \esc_attr( $webfinger ),
+ \esc_html( '@' . strtok( $webfinger, '@' ) )
+ );
+ }
+
+ /**
+ * Add the stats image as an attachment when a post contains the stats block.
+ *
+ * Parses the post content for activitypub/stats blocks and appends each
+ * as an Image attachment to the ActivityPub object.
+ *
+ * @since 8.1.0
+ *
+ * @param array $attachments The existing attachments.
+ * @param \WP_Post $post The post object.
+ *
+ * @return array The attachments with stats images appended.
+ */
+ public static function add_stats_image_attachment( $attachments, $post ) {
+ if ( ! Stats_Image::is_available() ) {
+ return $attachments;
+ }
+
+ /*
+ * The stats image intentionally bypasses the `activitypub_max_image_attachments`
+ * limit because it replaces the block content rather than being an inline image
+ * extracted from the post. It is always appended so that the share-pic is
+ * included in the federated activity regardless of the attachment cap.
+ */
+ $blocks = \parse_blocks( $post->post_content );
+ $stats_blocks = self::find_blocks_recursive( $blocks, 'activitypub/stats' );
+
+ foreach ( $stats_blocks as $block ) {
+ $user_id = self::get_user_id( $block['attrs']['selectedUser'] ?? 'blog' );
+
+ if ( null === $user_id ) {
+ continue;
+ }
+
+ $year = (int) ( $block['attrs']['year'] ?? (int) \gmdate( 'Y' ) - 1 );
+ $url = Stats_Image::get_url( $user_id, $year );
+
+ if ( \is_wp_error( $url ) ) {
+ continue;
+ }
+
+ // Determine mime type from URL extension.
+ $mime_type = \str_ends_with( $url, '.webp' ) ? 'image/webp' : 'image/png';
+
+ $attachments[] = array(
+ 'type' => 'Image',
+ 'mediaType' => $mime_type,
+ 'url' => $url,
+ 'name' => \sprintf(
+ /* translators: %d: The year */
+ \__( 'Fediverse Stats %d', 'activitypub' ),
+ $year
+ ),
+ );
+ }
+
+ return $attachments;
+ }
+
+ /**
+ * Recursively find blocks of a given type in a block tree.
+ *
+ * @since 8.1.0
+ *
+ * @param array $blocks The parsed blocks.
+ * @param string $block_name The block name to search for.
+ *
+ * @return array The matching blocks.
+ */
+ private static function find_blocks_recursive( $blocks, $block_name ) {
+ $found = array();
+
+ foreach ( $blocks as $block ) {
+ if ( $block_name === $block['blockName'] ) {
+ $found[] = $block;
+ }
+
+ if ( ! empty( $block['innerBlocks'] ) ) {
+ $found = \array_merge( $found, self::find_blocks_recursive( $block['innerBlocks'], $block_name ) );
+ }
+ }
+
+ return $found;
+ }
+
+ /**
+ * Transform Embed blocks to block level link.
+ *
+ * Remote servers will simply drop iframe elements, rendering incomplete content.
+ *
+ * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content
+ * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML
+ *
+ * @param string $block_content The block content (html).
+ * @param object $block The block object.
+ *
+ * @return string A block level link
+ */
+ public static function revert_embed_links( $block_content, $block ) {
+ if ( ! isset( $block['attrs']['url'] ) ) {
+ return $block_content;
+ }
+ return '
' . $block['attrs']['url'] . '
';
+ }
+
+ /**
+ * Convert HTML content to blocks.
+ *
+ * Tokenizes the content with wp_html_split(), tracks nesting depth,
+ * and wraps each top-level element in block comment delimiters.
+ *
+ * @since 8.1.0
+ *
+ * @param string $content The HTML content.
+ *
+ * @return string The content converted to blocks.
+ */
+ public static function convert_from_html( $content ) {
+ if ( empty( $content ) ) {
+ return '';
+ }
+
+ $tokens = \wp_html_split( $content );
+ $_content = '';
+ $depth = 0;
+ $current_tag = '';
+ $current_html = '';
+
+ foreach ( $tokens as $token ) {
+ if ( '' === $token ) {
+ continue;
+ }
+
+ // Text content — accumulate only inside a top-level element.
+ if ( '<' !== $token[0] ) {
+ if ( $depth > 0 ) {
+ $current_html .= $token;
+ }
+ continue;
+ }
+
+ // Closing tag.
+ if ( '/' === $token[1] ) {
+ $current_html .= $token;
+ --$depth;
+
+ if ( 0 === $depth && '' !== $current_tag ) {
+ $_content .= self::to_block( $current_tag, $current_html );
+ $current_tag = '';
+ $current_html = '';
+ }
+ continue;
+ }
+
+ // Extract the tag name from the opening tag.
+ if ( ! \preg_match( '/^<([a-zA-Z][a-zA-Z0-9]*)/', $token, $m ) ) {
+ if ( $depth > 0 ) {
+ $current_html .= $token;
+ }
+ continue;
+ }
+
+ $tag = \strtoupper( $m[1] );
+
+ // Start of a new top-level element.
+ if ( 0 === $depth ) {
+ $current_tag = $tag;
+ $current_html = $token;
+ } else {
+ $current_html .= $token;
+ }
+
+ // Void elements don't increase depth — flush immediately at top level.
+ if ( \in_array( $tag, self::VOID_TAGS, true ) ) {
+ if ( 0 === $depth && '' !== $current_tag ) {
+ $_content .= self::to_block( $current_tag, $current_html );
+ $current_tag = '';
+ $current_html = '';
+ }
+ } else {
+ ++$depth;
+ }
+ }
+
+ return $_content;
+ }
+
+ /**
+ * Wrap an HTML element in block comment delimiters.
+ *
+ * @since 8.1.0
+ *
+ * @param string $tag The uppercase tag name.
+ * @param string $html The element HTML.
+ *
+ * @return string The block-wrapped HTML, or empty string for skipped tags.
+ */
+ private static function to_block( $tag, $html ) {
+ if ( \in_array( $tag, self::SKIP_TAGS, true ) ) {
+ return '';
+ }
+
+ $block_type = self::BLOCK_MAP[ $tag ] ?? 'html';
+ $block_attrs = array();
+
+ if ( 'OL' === $tag ) {
+ $block_attrs['ordered'] = true;
+ }
+
+ return \get_comment_delimited_block_content( $block_type, $block_attrs, \trim( $html ) );
+ }
+
+ /**
+ * Filter the main query to exclude replies.
+ *
+ * Adds a WHERE clause to exclude posts containing the `activitypub/reply`
+ * block when the visitor has explicitly requested the "Posts" tab via
+ * `?filter=posts`. This filters the main query so that Query Loop blocks
+ * with `inherit: true` also pick up the filter.
+ *
+ * The filter only attaches on that explicit opt-in. Admin, feed, and any
+ * regular frontend request (front page, archives, search…) are never
+ * touched, which is why no block-presence probing is needed: the only
+ * way `?filter=posts` appears in a URL is from a click on the
+ * `activitypub/posts-and-replies` tab block.
+ *
+ * @since 8.1.0
+ *
+ * @param WP_Query $query The WP_Query instance.
+ */
+ public static function filter_query_loop_vars( $query ) {
+ // Never touch admin or feed queries.
+ if ( \is_admin() || $query->is_feed() ) {
+ return;
+ }
+
+ if ( ! $query->is_main_query() || $query->is_singular() ) {
+ return;
+ }
+
+ // Skip the reply-exclusion filter for queries that only target
+ // non-ActivityPub post types to avoid a full table scan.
+ $query_post_type = $query->get( 'post_type' );
+ if ( ! empty( $query_post_type ) && 'any' !== $query_post_type ) {
+ $query_post_types = (array) $query_post_type;
+ if ( ! array_intersect( $query_post_types, \get_post_types_by_support( 'activitypub' ) ) ) {
+ return;
+ }
+ }
+
+ // Only filter when the "Posts" tab has been explicitly selected.
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( ! isset( $_GET['filter'] ) || 'posts' !== \sanitize_key( \wp_unslash( $_GET['filter'] ) ) ) {
+ return;
+ }
+
+ \add_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
+ }
+
+ /**
+ * Exclude posts containing the activitypub/reply block.
+ *
+ * Removes itself after the first execution to avoid
+ * affecting secondary queries on the same page.
+ *
+ * @since 8.1.0
+ *
+ * @param string $where The WHERE clause.
+ * @return string Modified WHERE clause.
+ */
+ public static function exclude_replies_where( $where ) {
+ \remove_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
+
+ global $wpdb;
+
+ $where .= $wpdb->prepare(
+ " AND {$wpdb->posts}.post_content NOT LIKE %s",
+ '%', \wp_json_encode( $attributes ) ) );
/**
* Filters the HTML markup for the ActivityPub remote comment reply container.
*
- * @param string $div The HTML markup for the remote reply container. Default is a div
- * with class 'activitypub-remote-reply' and data attributes for
- * the selected comment ID and internal comment ID.
+ * @param string $block The HTML markup for the remote reply container.
*/
- return apply_filters( 'activitypub_comment_reply_link', $div );
- }
-
- /**
- * Create a link to reply to a federated comment.
- *
- * This function adds a title attribute to the reply link to inform the user
- * that the comment was received from the fediverse and the reply will be sent
- * to the original author.
- *
- * @param string $link The HTML markup for the comment reply link.
- * @param array $args The args provided by the `comment_reply_link` filter.
- *
- * @return string The modified HTML markup for the comment reply link.
- */
- private static function create_fediverse_reply_link( $link, $args ) {
- $str_to_replace = sprintf( '>%s<', $args['reply_text'] );
- $replace_with = sprintf(
- ' title="%s">%s<',
- esc_attr__( 'This comment was received from the fediverse and your reply will be sent to the original author', 'activitypub' ),
- esc_html__( 'Reply with federation', 'activitypub' )
- );
- return str_replace( $str_to_replace, $replace_with, $link );
+ return \apply_filters( 'activitypub_comment_reply_link', $block );
}
/**
@@ -120,11 +184,12 @@ class Comment {
return false;
}
- if ( is_single_user() && \user_can( $current_user, 'publish_posts' ) ) {
- // On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user.
+ if ( is_single_user() && \user_can( $current_user, 'activitypub' ) ) {
+ // On a single user site, comments by users with the `activitypub` capability will be federated as the blog user.
$current_user = Actors::BLOG_USER_ID;
}
+ // User is not allowed to federate comments.
return user_can_activitypub( $current_user );
}
@@ -225,7 +290,7 @@ class Comment {
}
if ( is_single_user() && \user_can( $user_id, 'activitypub' ) ) {
- // On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user.
+ // On a single user site, comments by users with the `activitypub` capability will be federated as the blog user.
$user_id = Actors::BLOG_USER_ID;
}
@@ -253,7 +318,7 @@ class Comment {
* @return \WP_Comment|false Comment object, or false on failure.
*/
public static function object_id_to_comment( $id ) {
- $comment_query = new WP_Comment_Query(
+ $comment_query = new \WP_Comment_Query(
array(
'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
@@ -278,16 +343,16 @@ class Comment {
* @return string|null Comment ID or null if not found.
*/
public static function url_to_commentid( $url ) {
- if ( ! $url || ! filter_var( $url, \FILTER_VALIDATE_URL ) ) {
+ if ( ! $url || ! \filter_var( $url, \FILTER_VALIDATE_URL ) ) {
return null;
}
// Check for local comment.
- if ( \wp_parse_url( \home_url(), \PHP_URL_HOST ) === \wp_parse_url( $url, \PHP_URL_HOST ) ) {
+ if ( is_same_domain( $url ) ) {
$query = \wp_parse_url( $url, \PHP_URL_QUERY );
if ( $query ) {
- parse_str( $query, $params );
+ \parse_str( $query, $params );
if ( ! empty( $params['c'] ) ) {
$comment = \get_comment( $params['c'] );
@@ -314,7 +379,7 @@ class Comment {
),
);
- $query = new WP_Comment_Query();
+ $query = new \WP_Comment_Query();
$comments = $query->query( $args );
if ( $comments && is_array( $comments ) ) {
@@ -342,6 +407,38 @@ class Comment {
return $classes;
}
+ /**
+ * Makes the comment feed filterable by comment type.
+ *
+ * Also excludes ActivityPub comment types from the feed when no type is specified.
+ *
+ * @param string $where The `WHERE` clause for the comment feed query.
+ *
+ * @return string The modified `WHERE` clause.
+ */
+ public static function comment_feed_where( $where ) {
+ global $wpdb;
+
+ $comment_type = \get_query_var( 'type' );
+
+ if ( 'all' === $comment_type ) {
+ return $where;
+ }
+
+ $comment_types = self::get_comment_type_slugs();
+
+ if ( \in_array( $comment_type, $comment_types, true ) ) {
+ $where .= $wpdb->prepare( ' AND comment_type = %s', $comment_type );
+ } else {
+ $comment_types = \array_map( 'esc_sql', $comment_types );
+ $placeholders = implode( ', ', array_fill( 0, count( $comment_types ), '%s' ) );
+ // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.NotPrepared
+ $where .= $wpdb->prepare( sprintf( ' AND comment_type NOT IN (%s)', $placeholders ), ...$comment_types );
+ }
+
+ return $where;
+ }
+
/**
* Gets the public comment id via the WordPress comments meta.
*
@@ -391,13 +488,16 @@ class Comment {
* @return string $url
*/
public static function remote_comment_link( $comment_link, $comment ) {
- if ( ! $comment || is_admin() ) {
+ if ( ! $comment || \is_admin() || \is_search() ) {
return $comment_link;
}
- $public_comment_link = self::get_source_url( $comment->comment_ID );
+ $remote_comment_link = null;
+ if ( 'comment' === $comment->comment_type ) {
+ $remote_comment_link = self::get_source_url( $comment->comment_ID );
+ }
- return $public_comment_link ?? $comment_link;
+ return $remote_comment_link ?? $comment_link;
}
@@ -419,7 +519,7 @@ class Comment {
}
// Generate URI based on comment ID.
- return \add_query_arg( 'c', $comment->comment_ID, \trailingslashit( \home_url() ) );
+ return \add_query_arg( 'c', $comment->comment_ID, \home_url( '/' ) );
}
/**
@@ -452,60 +552,6 @@ class Comment {
return ! empty( $comments );
}
- /**
- * Enqueue scripts for remote comments
- */
- public static function enqueue_scripts() {
- if ( ! \is_singular() || \is_user_logged_in() ) {
- // Only on single pages, only for logged-out users.
- return;
- }
-
- if ( ! \post_type_supports( \get_post_type(), 'activitypub' ) ) {
- // Post type does not support ActivityPub.
- return;
- }
-
- if ( ! \comments_open() || ! \get_comments_number() ) {
- // No comments, no need to load the script.
- return;
- }
-
- if ( ! self::post_has_remote_comments( \get_the_ID() ) ) {
- // No remote comments, no need to load the script.
- return;
- }
-
- $handle = 'activitypub-remote-reply';
- $data = array(
- 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
- 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
- );
- $js = sprintf( 'var _activityPubOptions = %s;', wp_json_encode( $data ) );
- $asset_file = ACTIVITYPUB_PLUGIN_DIR . 'build/remote-reply/index.asset.php';
-
- if ( \file_exists( $asset_file ) ) {
- $assets = require_once $asset_file;
-
- \wp_enqueue_script(
- $handle,
- \plugins_url( 'build/remote-reply/index.js', __DIR__ ),
- $assets['dependencies'],
- $assets['version'],
- true
- );
- \wp_add_inline_script( $handle, $js, 'before' );
- \wp_set_script_translations( $handle, 'activitypub' );
-
- \wp_enqueue_style(
- $handle,
- \plugins_url( 'build/remote-reply/style-index.css', __DIR__ ),
- array( 'wp-components' ),
- $assets['version']
- );
- }
- }
-
/**
* Get the comment type by activity type.
*
@@ -535,7 +581,7 @@ class Comment {
public static function get_comment_types() {
global $activitypub_comment_types;
- return $activitypub_comment_types;
+ return (array) $activitypub_comment_types;
}
/**
@@ -560,22 +606,15 @@ class Comment {
* @return array The registered custom comment type slugs.
*/
public static function get_comment_type_slugs() {
+ if ( ! did_action( 'init' ) ) {
+ _doing_it_wrong( __METHOD__, 'This function should not be called before the init action has run. Comment types are only available after init.', '7.5.0' );
+
+ return array();
+ }
+
return array_keys( self::get_comment_types() );
}
- /**
- * Return the registered custom comment type slugs.
- *
- * @deprecated 4.5.0 Use get_comment_type_slugs instead.
- *
- * @return array The registered custom comment type slugs.
- */
- public static function get_comment_type_names() {
- _deprecated_function( __METHOD__, '4.5.0', 'get_comment_type_slugs' );
-
- return self::get_comment_type_slugs();
- }
-
/**
* Get the custom comment type.
*
@@ -643,7 +682,7 @@ class Comment {
array(
'label' => __( 'Reposts', 'activitypub' ),
'singular' => __( 'Repost', 'activitypub' ),
- 'description' => __( 'A repost on the indieweb is a post that is purely a 100% re-publication of another (typically someone else\'s) post.', 'activitypub' ),
+ 'description' => 'A repost (or Announce) is when a post appears in the timeline because someone else shared it, while still showing the original author as the source.',
'icon' => '♻️',
'class' => 'p-repost',
'type' => 'repost',
@@ -662,7 +701,7 @@ class Comment {
array(
'label' => __( 'Likes', 'activitypub' ),
'singular' => __( 'Like', 'activitypub' ),
- 'description' => __( 'A like is a popular webaction button and in some cases post type on various silos such as Facebook and Instagram.', 'activitypub' ),
+ 'description' => 'A like is a small positive reaction that shows appreciation for a post without sharing it further.',
'icon' => '👍',
'class' => 'p-like',
'type' => 'like',
@@ -675,6 +714,25 @@ class Comment {
'count_plural' => _x( '%d likes', 'number of likes', 'activitypub' ),
)
);
+
+ register_comment_type(
+ 'quote',
+ array(
+ 'label' => __( 'Quotes', 'activitypub' ),
+ 'singular' => __( 'Quote', 'activitypub' ),
+ 'description' => 'A quote is when a post is shared along with an added comment, so the original post appears together with the sharer’s own words.',
+ 'icon' => '❞',
+ 'class' => 'p-quote',
+ 'type' => 'quote',
+ 'collection' => 'quotes',
+ 'activity_types' => array( 'quote' ),
+ 'excerpt' => html_entity_decode( \__( '… quoted this!', 'activitypub' ) ),
+ /* translators: %d: Number of quotes */
+ 'count_single' => _x( '%d quote', 'number of quotes', 'activitypub' ),
+ /* translators: %d: Number of quotes */
+ 'count_plural' => _x( '%d quotes', 'number of quotes', 'activitypub' ),
+ )
+ );
}
/**
@@ -698,10 +756,10 @@ class Comment {
*
* @see https://github.com/janboddez/indieblocks/blob/a2d59de358031056a649ee47a1332ce9e39d4ce2/includes/functions.php#L423-L432
*
- * @param WP_Comment_Query $query Comment count.
+ * @param \WP_Comment_Query $query Comment count.
*/
public static function comment_query( $query ) {
- if ( ! $query instanceof WP_Comment_Query ) {
+ if ( ! $query instanceof \WP_Comment_Query ) {
return;
}
@@ -710,53 +768,125 @@ class Comment {
return;
}
- // Do not exclude likes and reposts on REST requests.
+ // Do not exclude likes and reposts on REST requests (handled by rest_comment_query).
if ( \wp_is_serving_rest_request() ) {
return;
}
- // Do not exclude likes and reposts on admin pages or on non-singular pages.
- if ( is_admin() || ! is_singular() ) {
+ // Filter post types for admin requests.
+ if ( \is_admin() ) {
+ $query->query_vars['post_type'] = self::get_allowed_comment_post_types();
return;
}
- // Do not exclude likes and reposts if the query is for comments.
+ // Do not exclude likes and reposts on non-singular pages.
+ if ( ! \is_singular() ) {
+ return;
+ }
+
+ // Do not exclude likes and reposts if the query is for specific types.
if ( ! empty( $query->query_vars['type__in'] ) || ! empty( $query->query_vars['type'] ) ) {
return;
}
+ // Do not exclude likes and reposts if the query is already excluding other comment types.
+ if ( ! empty( $query->query_vars['type__not_in'] ) ) {
+ return;
+ }
+
// Exclude likes and reposts by the ActivityPub plugin.
$query->query_vars['type__not_in'] = self::get_comment_type_slugs();
}
+ /**
+ * Filters comments in REST API requests.
+ *
+ * Excludes comments on ActivityPub post types and ActivityPub comment
+ * types (likes, reposts) from the REST API.
+ *
+ * @param array $prepared_args Array of arguments for WP_Comment_Query.
+ *
+ * @return array Modified array of arguments.
+ */
+ public static function rest_comment_query( $prepared_args ) {
+ // Exclude comments on ActivityPub post types.
+ $prepared_args['post_type'] = self::get_allowed_comment_post_types();
+
+ // Exclude ActivityPub comment types (likes, reposts) unless explicitly requested.
+ if ( empty( $prepared_args['type'] ) && empty( $prepared_args['type__in'] ) ) {
+ $prepared_args['type__not_in'] = self::get_comment_type_slugs();
+ }
+
+ return $prepared_args;
+ }
+
+ /**
+ * Returns post types that should show comments (excluding hidden post types).
+ *
+ * @return array Array of post type names.
+ */
+ private static function get_allowed_comment_post_types() {
+ $hide_for = self::hide_for();
+
+ if ( empty( $hide_for ) ) {
+ return \get_post_types_by_support( 'comments' );
+ }
+
+ return \array_diff( \get_post_types_by_support( 'comments' ), $hide_for );
+ }
+
/**
* Filter the comment status before it is set.
*
- * @param string $approved The approved comment status.
- * @param array $commentdata The comment data.
+ * @param int|string|\WP_Error $approved The approved comment status.
+ * @param array $comment_data The comment data.
*
- * @return boolean `true` if the comment is approved, `false` otherwise.
+ * @return int|string|\WP_Error The approval status. 1, 0, 'spam', 'trash', or WP_Error.
*/
- public static function pre_comment_approved( $approved, $commentdata ) {
- if ( $approved || \is_wp_error( $approved ) ) {
+ public static function pre_comment_approved( $approved, $comment_data ) {
+ /*
+ * Only return early for already-approved comments, trash, or errors.
+ * Don't short-circuit on 'spam' - we may want to override Akismet.
+ * Respect 'trash' since it comes from the WordPress disallowed list.
+ */
+ if ( 1 === $approved || '1' === $approved || 'trash' === $approved || \is_wp_error( $approved ) ) {
return $approved;
}
+ // Maybe auto-approve likes and reposts.
+ if (
+ \in_array( $comment_data['comment_type'], self::get_comment_type_slugs(), true ) &&
+ '1' === \get_option( 'activitypub_auto_approve_reactions' )
+ ) {
+ return 1;
+ }
+
+ /*
+ * Always auto-approve comments on remote posts (ap_post) since
+ * they are not visible in the WP admin comment moderation screen.
+ */
+ $post_id = $comment_data['comment_post_ID'];
+ $post = \get_post( $post_id );
+
+ if ( $post && \in_array( $post->post_type, self::hide_for(), true ) ) {
+ return 1;
+ }
+
if ( '1' !== \get_option( 'comment_previously_approved' ) ) {
return $approved;
}
if (
- empty( $commentdata['comment_meta']['protocol'] ) ||
- 'activitypub' !== $commentdata['comment_meta']['protocol']
+ empty( $comment_data['comment_meta']['protocol'] ) ||
+ 'activitypub' !== $comment_data['comment_meta']['protocol']
) {
return $approved;
}
global $wpdb;
- $author = $commentdata['comment_author'];
- $author_url = $commentdata['comment_author_url'];
+ $author = $comment_data['comment_author'];
+ $author_url = $comment_data['comment_author_url'];
// phpcs:ignore
$ok_to_comment = $wpdb->get_var( $wpdb->prepare( "SELECT comment_approved FROM $wpdb->comments WHERE comment_author = %s AND comment_author_url = %s and comment_approved = '1' LIMIT 1", $author, $author_url ) );
@@ -795,6 +925,29 @@ class Comment {
$excluded_types = array_filter( self::get_comment_type_slugs(), array( self::class, 'is_comment_type_enabled' ) );
if ( ! empty( $excluded_types ) ) {
+ /*
+ * Include 'note' type when Gutenberg's filter is registered, so a
+ * single query excludes both ActivityPub and Gutenberg types.
+ */
+ if ( \has_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count' ) ) {
+ $excluded_types[] = 'note';
+ }
+
+ /**
+ * Filters the comment types excluded from the comment count.
+ *
+ * Runs at priority 5 on `pre_wp_update_comment_count_now` so that
+ * a single query can exclude types from multiple plugins. Other
+ * plugins can hook here to add their own comment types.
+ *
+ * @since 8.0.0
+ *
+ * @param string[] $excluded_types The comment type slugs to exclude.
+ * @param int $post_id The post ID.
+ */
+ $excluded_types = \apply_filters( 'activitypub_excluded_comment_types', $excluded_types, $post_id );
+ $excluded_types = array_unique( array_filter( $excluded_types ) );
+
global $wpdb;
// phpcs:ignore WordPress.DB
@@ -814,4 +967,72 @@ class Comment {
public static function is_comment_type_enabled( $comment_type ) {
return '1' === get_option( "activitypub_allow_{$comment_type}s", '1' );
}
+
+ /**
+ * Get post types to hide comments for in admin.
+ *
+ * These are non-public post types whose comments should not appear
+ * in the main comments list in the WordPress admin.
+ *
+ * @return string[] Array of post type names to hide comments for.
+ */
+ public static function hide_for() {
+ $post_types = array( Remote_Posts::POST_TYPE );
+
+ /**
+ * Filters the list of post types to hide comments for.
+ *
+ * @param string[] $post_types Array of post type names to hide comments for.
+ */
+ return \apply_filters( 'activitypub_hide_comments_for', $post_types );
+ }
+
+ /**
+ * Render emoji in comment author name.
+ *
+ * Replaces emoji shortcodes with img tags on the get_comment_author filter.
+ * Emoji data is retrieved from the linked remote actor.
+ *
+ * @param string $author The comment author name.
+ * @param string $comment_id The comment ID as a numeric string.
+ *
+ * @return string The comment author name with rendered emoji.
+ */
+ public static function render_emoji( $author, $comment_id ) {
+ $remote_actor_id = \get_comment_meta( $comment_id, '_activitypub_remote_actor_id', true );
+
+ if ( empty( $remote_actor_id ) ) {
+ return $author;
+ }
+
+ $emoji_data = \get_post_meta( $remote_actor_id, '_activitypub_emoji', true );
+
+ if ( empty( $emoji_data ) ) {
+ return $author;
+ }
+
+ return Emoji::replace_from_json( $author, $emoji_data );
+ }
+
+ /**
+ * Selectively unescape emoji images in comment author.
+ *
+ * This runs at priority 20 after WordPress's esc_html() filter on comment_author.
+ *
+ * @param string $author The comment author name (already escaped by WordPress).
+ *
+ * @return string The comment author name with emoji images unescaped.
+ */
+ public static function unescape_emoji( $author ) {
+ // Only attempt to unescape if there are emoji images present in the escaped string.
+ if ( false === \strpos( $author, 'class="emoji"' ) ) {
+ return $author;
+ }
+
+ // Decode entities so we can selectively restore emoji
![]()
tags.
+ $decoded = \html_entity_decode( $author, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+
+ // Use strict KSES validation to only allow valid emoji img tags.
+ return \wp_kses( $decoded, Emoji::get_kses_allowed_html() );
+ }
}
diff --git a/wp-content/plugins/activitypub/includes/class-debug.php b/wp-content/plugins/activitypub/includes/class-debug.php
deleted file mode 100644
index af7608dd..00000000
--- a/wp-content/plugins/activitypub/includes/class-debug.php
+++ /dev/null
@@ -1,79 +0,0 @@
-ID, '_activitypub_activity_type', true );
$actor = Outbox::get_actor( $outbox_item );
- if ( \is_wp_error( $actor ) ) {
+ if ( \is_wp_error( $actor ) && 'Delete' !== $type ) {
// If the actor is not found, publish the post and don't try again.
\wp_publish_post( $outbox_item );
return;
@@ -100,13 +132,13 @@ class Dispatcher {
$activity = Outbox::get_activity( $outbox_item );
// Send to mentioned and replied-to users. Everyone other than followers.
- self::send_to_additional_inboxes( $activity, $actor->get__id(), $outbox_item );
+ self::send_to_additional_inboxes( $activity, $outbox_item->post_author, $outbox_item );
if ( self::should_send_to_followers( $activity, $actor, $outbox_item ) ) {
- Scheduler::async_batch(
- self::$callback,
+ \do_action(
+ 'activitypub_send_activity',
$outbox_item->ID,
- self::$batch_size,
+ self::get_batch_size(),
\get_post_meta( $outbox_item->ID, '_activitypub_outbox_offset', true ) ?: 0 // phpcs:ignore
);
} else {
@@ -119,17 +151,31 @@ class Dispatcher {
/**
* Asynchronously runs batch processing routines.
*
- * @param int $outbox_item_id The Outbox item ID.
- * @param int $batch_size Optional. The batch size. Default ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE.
- * @param int $offset Optional. The offset. Default 0.
+ * @param int $outbox_item_id The Outbox item ID.
+ * @param int|null $batch_size Optional. The batch size. Default null (uses filtered batch size).
+ * @param int $offset Optional. The offset. Default 0.
*
* @return array|void The next batch of followers to process, or void if done.
*/
public static function send_to_followers( $outbox_item_id, $batch_size = ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE, $offset = 0 ) {
- $json = Outbox::get_activity( $outbox_item_id )->to_json();
- $actor = Outbox::get_actor( \get_post( $outbox_item_id ) );
- $inboxes = Followers::get_inboxes_for_activity( $json, $actor->get__id(), $batch_size, $offset );
+ if ( null === $batch_size ) {
+ $batch_size = self::get_batch_size();
+ }
+ $outbox_item = \get_post( $outbox_item_id );
+
+ if ( ! $outbox_item ) {
+ return;
+ }
+
+ $activity = Outbox::get_activity( $outbox_item_id );
+
+ if ( \is_wp_error( $activity ) ) {
+ return;
+ }
+
+ $json = $activity->to_json();
+ $inboxes = Followers::get_inboxes_for_activity( $json, $outbox_item->post_author, $batch_size, $offset );
$retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
// Retry failed inboxes.
@@ -150,7 +196,7 @@ class Dispatcher {
* @param int $batch_size The batch size.
* @param int $offset The offset.
*/
- \do_action( 'activitypub_outbox_processing_complete', $inboxes, $json, $actor->get__id(), $outbox_item_id, $batch_size, $offset );
+ \do_action( 'activitypub_outbox_processing_complete', $inboxes, $json, $outbox_item->post_author, $outbox_item_id, $batch_size, $offset );
// No more followers to process for this update.
\wp_publish_post( $outbox_item_id );
@@ -167,7 +213,7 @@ class Dispatcher {
* @param int $batch_size The batch size.
* @param int $offset The offset.
*/
- \do_action( 'activitypub_outbox_processing_batch_complete', $inboxes, $json, $actor->get__id(), $outbox_item_id, $batch_size, $offset );
+ \do_action( 'activitypub_outbox_processing_batch_complete', $inboxes, $json, $outbox_item->post_author, $outbox_item_id, $batch_size, $offset );
return array( $outbox_item_id, $batch_size, $offset + $batch_size );
}
@@ -192,7 +238,7 @@ class Dispatcher {
$retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
// Retry failed inboxes.
- if ( ++$attempt < 3 && ! empty( $retries ) ) {
+ if ( ++$attempt < self::get_retry_max_attempts() && ! empty( $retries ) ) {
self::schedule_retry( $retries, $outbox_item_id, $attempt );
}
}
@@ -205,8 +251,16 @@ class Dispatcher {
* @return array The failed inboxes.
*/
private static function send_to_inboxes( $inboxes, $outbox_item_id ) {
- $json = Outbox::get_activity( $outbox_item_id )->to_json();
- $actor = Outbox::get_actor( \get_post( $outbox_item_id ) );
+ $outbox_item = \get_post( $outbox_item_id );
+
+ $activity = Outbox::get_activity( $outbox_item_id );
+
+ if ( \is_wp_error( $activity ) ) {
+ return array();
+ }
+
+ $json = $activity->to_json();
+
$retries = array();
/**
@@ -219,27 +273,67 @@ class Dispatcher {
\do_action( 'activitypub_pre_send_to_inboxes', $json, $inboxes, $outbox_item_id );
foreach ( $inboxes as $inbox ) {
- $result = safe_remote_post( $inbox, $json, $actor->get__id() );
+ // Handle local inboxes via internal REST API, remote via HTTP.
+ if ( is_same_domain( $inbox ) ) {
+ $result = self::send_to_local_inbox( $inbox, $json );
+ } else {
+ $result = safe_remote_post( $inbox, $json, $outbox_item->post_author );
+ }
- if ( is_wp_error( $result ) && in_array( $result->get_error_code(), self::$retry_error_codes, true ) ) {
+ if ( \is_wp_error( $result ) && in_array( $result->get_error_code(), self::get_retry_error_codes(), true ) ) {
$retries[] = $inbox;
}
/**
* Fires after an Activity has been sent to an inbox.
*
- * @param array $result The result of the remote post request.
+ * @param array $result The result of the internal or remote post request.
* @param string $inbox The inbox URL.
* @param string $json The ActivityPub Activity JSON.
* @param int $actor_id The actor ID.
* @param int $outbox_item_id The Outbox item ID.
*/
- \do_action( 'activitypub_sent_to_inbox', $result, $inbox, $json, $actor->get__id(), $outbox_item_id );
+ \do_action( 'activitypub_sent_to_inbox', $result, $inbox, $json, $outbox_item->post_author, $outbox_item_id );
}
return $retries;
}
+ /**
+ * Send an activity to a local inbox via internal REST API request.
+ *
+ * @param string $inbox_url The local inbox URL.
+ * @param string $json The ActivityPub Activity JSON.
+ * @return array|\WP_Error The result in the format of a remote post response, or WP_Error on failure.
+ */
+ private static function send_to_local_inbox( $inbox_url, $json ) {
+ // Parse the inbox URL to extract the REST route.
+ $path = \wp_parse_url( $inbox_url, PHP_URL_PATH ) ?? '';
+ $rest_route = \preg_replace( '#^/' . preg_quote( \rest_get_url_prefix(), '#' ) . '#', '', $path );
+
+ // Create a REST request.
+ $request = new \WP_REST_Request( 'POST', $rest_route );
+ $request->set_header( 'Content-Type', 'application/activity+json' );
+ $request->set_body( $json );
+ $request->get_json_params();
+
+ \add_filter( 'activitypub_defer_signature_verification', '__return_true' );
+ $response = \rest_do_request( $request );
+ \remove_filter( 'activitypub_defer_signature_verification', '__return_true' );
+
+ // Return result in format similar to remote post response.
+ if ( $response->is_error() ) {
+ return $response->as_error();
+ }
+
+ return array(
+ 'response' => array(
+ 'code' => $response->get_status(),
+ ),
+ 'body' => \wp_json_encode( $response->get_data() ),
+ );
+ }
+
/**
* Schedule a retry.
*
@@ -252,14 +346,9 @@ class Dispatcher {
\set_transient( $transient_key, $retries, WEEK_IN_SECONDS );
\wp_schedule_single_event(
- \time() + ( $attempt * $attempt * HOUR_IN_SECONDS ),
- 'activitypub_async_batch',
- array(
- array( self::class, 'retry_send_to_followers' ),
- $transient_key,
- $outbox_item_id,
- $attempt,
- )
+ \time() + ( $attempt * $attempt * self::get_retry_delay() ),
+ 'activitypub_retry_activity',
+ array( $transient_key, $outbox_item_id, $attempt )
);
}
@@ -306,13 +395,8 @@ class Dispatcher {
$audience = array_merge( $cc, $to );
- // Remove "public placeholder" and "same domain" from the audience.
- $audience = array_filter(
- $audience,
- function ( $actor ) {
- return 'https://www.w3.org/ns/activitystreams#Public' !== $actor && ! is_same_domain( $actor );
- }
- );
+ // Remove "public placeholder" from the audience.
+ $audience = array_diff( $audience, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS );
if ( $audience ) {
$mentioned_inboxes = Mention::get_inboxes( $audience );
@@ -377,47 +461,31 @@ class Dispatcher {
}
/**
- * Adds Blog Actor inboxes to Updates so the Blog User's followers are notified of edits.
- *
- * @deprecated 5.2.0 Use {@see Followers::maybe_add_inboxes_of_blog_user} instead.
- *
- * @param array $inboxes The list of Inboxes.
- * @param int $actor_id The WordPress Actor-ID.
- * @param Activity $activity The ActivityPub Activity.
- *
- * @return array The filtered Inboxes.
- */
- public static function maybe_add_inboxes_of_blog_user( $inboxes, $actor_id, $activity ) { // phpcs:ignore
- _deprecated_function( __METHOD__, '5.2.0', 'Followers::maybe_add_inboxes_of_blog_user' );
-
- return $inboxes;
- }
-
- /**
- * Check if passed Activity is public.
+ * Check if an Activity should be sent to followers.
*
* @param Activity $activity The Activity object.
* @param \Activitypub\Model\User|\Activitypub\Model\Blog $actor The Actor object.
* @param \WP_Post $outbox_item The Outbox item.
*
- * @return boolean True if public, false if not.
+ * @return boolean True if the Activity should be sent to followers, false if not.
*/
protected static function should_send_to_followers( $activity, $actor, $outbox_item ) {
- // Check if follower endpoint is set.
- $cc = $activity->get_cc() ?? array();
- $to = $activity->get_to() ?? array();
+ $cc = (array) ( $activity->get_cc() ?? array() );
+ $to = (array) ( $activity->get_to() ?? array() );
+ $bcc = (array) ( $activity->get_bcc() ?? array() );
+ $bto = (array) ( $activity->get_bto() ?? array() );
- $audience = array_merge( $cc, $to );
+ $audience = array_merge( $cc, $to, $bcc, $bto );
$send = (
// Check if activity is public.
- in_array( 'https://www.w3.org/ns/activitystreams#Public', $audience, true ) ||
+ is_activity_public( $activity ) ||
// ...or check if follower endpoint is set.
in_array( $actor->get_followers(), $audience, true )
);
if ( $send ) {
- $followers = Followers::get_inboxes_for_activity( $activity->to_json(), $actor->get__id() );
+ $followers = Followers::get_inboxes_for_activity( $activity->to_json(), $outbox_item->post_author );
// Only send if there are followers to send to.
$send = ! is_countable( $followers ) || 0 < count( $followers );
@@ -431,7 +499,7 @@ class Dispatcher {
* @param int $actor_id The actor ID.
* @param \WP_Post $outbox_item The WordPress object.
*/
- return apply_filters( 'activitypub_send_activity_to_followers', $send, $activity, $actor->get__id(), $outbox_item );
+ return apply_filters( 'activitypub_send_activity_to_followers', $send, $activity, $outbox_item->post_author, $outbox_item );
}
/**
@@ -444,14 +512,8 @@ class Dispatcher {
* @return array The filtered Inboxes.
*/
public static function add_inboxes_of_relays( $inboxes, $actor_id, $activity ) {
- // Check if follower endpoint is set.
- $cc = $activity->get_cc() ?? array();
- $to = $activity->get_to() ?? array();
-
- $audience = array_merge( $cc, $to );
-
// Check if activity is public.
- if ( ! in_array( 'https://www.w3.org/ns/activitystreams#Public', $audience, true ) ) {
+ if ( ! is_activity_public( $activity ) ) {
return $inboxes;
}
@@ -463,4 +525,54 @@ class Dispatcher {
return array_merge( $inboxes, $relays );
}
+
+ /**
+ * Fire outbox handlers for activities.
+ *
+ * Triggers activity type-specific handlers to process outbox activities,
+ * allowing handlers to create WordPress posts or perform other side effects.
+ *
+ * @param int $outbox_id The Outbox item ID.
+ * @param Activity $activity The Activity that was just added to the Outbox.
+ */
+ public static function fire_outbox_handlers( $outbox_id, $activity ) {
+ $outbox_item = \get_post( $outbox_id );
+
+ if ( ! $outbox_item ) {
+ return;
+ }
+
+ $type = $activity->get_type();
+ $user_id = $outbox_item->post_author;
+ $data = $activity->to_array( false );
+
+ /**
+ * Fires when an activity has been added to the outbox.
+ *
+ * Handlers can implement side effects like creating WordPress posts.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ \do_action( 'activitypub_handled_outbox_' . \strtolower( $type ), $data, $user_id, $activity, $outbox_id );
+ }
+
+ /**
+ * Send an immediate Accept activity for the given Outbox item.
+ *
+ * @param int $outbox_id The Outbox item ID.
+ * @param Activity $activity The Activity that was just added to the Outbox.
+ */
+ public static function send_immediate_accept( $outbox_id, $activity ) {
+ $outbox_item = \get_post( $outbox_id );
+
+ if ( ! $outbox_item || 'Accept' !== $activity->get_type() ) {
+ return;
+ }
+
+ // Send to mentioned and replied-to users. Everyone other than followers.
+ self::send_to_additional_inboxes( $activity, $outbox_item->post_author, $outbox_item );
+ }
}
diff --git a/wp-content/plugins/activitypub/includes/class-embed.php b/wp-content/plugins/activitypub/includes/class-embed.php
index 978e66f4..c06a8be2 100644
--- a/wp-content/plugins/activitypub/includes/class-embed.php
+++ b/wp-content/plugins/activitypub/includes/class-embed.php
@@ -8,12 +8,12 @@
namespace Activitypub;
/**
- * Class to handle embedding ActivityPub content
+ * Class to handle embedding ActivityPub content.
*/
class Embed {
/**
- * Initialize the embed handler
+ * Initialize the embed handler.
*/
public static function init() {
\add_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 );
@@ -32,7 +32,8 @@ class Embed {
public static function get_html( $url, $inline_css = true ) {
// Try to get ActivityPub representation.
$object = Http::get_remote_object( $url );
- if ( is_wp_error( $object ) ) {
+
+ if ( \is_wp_error( $object ) || ! is_activity_object( $object ) ) {
return false;
}
@@ -55,9 +56,11 @@ class Embed {
// If we don't have an avatar URL, but we have an author URL, try to fetch it.
if ( ! $avatar_url && $author_url ) {
$author = Http::get_remote_object( $author_url );
- if ( ! is_wp_error( $author ) ) {
+ if ( is_wp_error( $author ) ) {
+ $author = array();
+ } else {
$avatar_url = $author['icon']['url'] ?? '';
- $author_name = $author['name'] ?? $author_name;
+ $author_name = empty( $author['name'] ) ? $author_name : $author['name'];
}
}
@@ -65,7 +68,7 @@ class Embed {
if ( empty( $author['webfinger'] ) ) {
if ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
// Construct webfinger-style identifier from username and domain.
- $domain = wp_parse_url( $author['url'], PHP_URL_HOST );
+ $domain = \wp_parse_url( object_to_uri( $author['url'] ), PHP_URL_HOST );
$author['webfinger'] = '@' . $author['preferredUsername'] . '@' . $domain;
} else {
// Fallback to URL.
@@ -79,33 +82,53 @@ class Embed {
$boosts = isset( $activity_object['shares']['totalItems'] ) ? (int) $activity_object['shares']['totalItems'] : null;
$favorites = isset( $activity_object['likes']['totalItems'] ) ? (int) $activity_object['likes']['totalItems'] : null;
- $image = '';
+ $audio = null;
+ $images = array();
+ $video = null;
if ( isset( $activity_object['image']['url'] ) ) {
- $image = $activity_object['image']['url'];
+ $images = array(
+ array(
+ 'type' => 'Image',
+ 'url' => $activity_object['image']['url'],
+ 'name' => $activity_object['image']['name'] ?? '',
+ ),
+ );
} elseif ( isset( $activity_object['attachment'] ) ) {
foreach ( $activity_object['attachment'] as $attachment ) {
- if ( isset( $attachment['type'] ) && in_array( $attachment['type'], array( 'Image', 'Document' ), true ) ) {
- $image = $attachment['url'];
- break;
+ $type = isset( $attachment['mediaType'] ) ? strtok( $attachment['mediaType'], '/' ) : strtolower( $attachment['type'] );
+
+ switch ( $type ) {
+ case 'image':
+ $images[] = $attachment;
+ break;
+ case 'video':
+ $video = $attachment;
+ break 2;
+ case 'audio':
+ $audio = $attachment;
+ break 2;
}
}
+ $images = \array_slice( $images, 0, 4 );
}
ob_start();
load_template(
- ACTIVITYPUB_PLUGIN_DIR . 'templates/reply-embed.php',
+ ACTIVITYPUB_PLUGIN_DIR . 'templates/embed.php',
false,
array(
+ 'audio' => $audio,
'author_name' => $author_name,
'author_url' => $author_url,
'avatar_url' => $avatar_url,
+ 'boosts' => $boosts,
+ 'content' => $content,
+ 'favorites' => $favorites,
+ 'images' => $images,
'published' => $published,
'title' => $title,
- 'content' => $content,
- 'image' => $image,
- 'boosts' => $boosts,
- 'favorites' => $favorites,
'url' => $activity_object['id'],
+ 'video' => $video,
'webfinger' => $author['webfinger'],
)
);
@@ -130,7 +153,7 @@ class Embed {
*/
public static function has_real_oembed( $url, $args = array() ) {
// Temporarily remove our filter to avoid infinite loops.
- \remove_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 );
+ \remove_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ) );
// Try to get a "real" oEmbed result. If found, it'll be cached to avoid unnecessary HTTP requests in `wp_oembed_get`.
$oembed_result = \wp_oembed_get( $url, $args );
@@ -201,7 +224,7 @@ class Embed {
}
// Try to get ActivityPub representation.
- $activitypub_html = get_embed_html( $url );
+ $activitypub_html = self::get_html( $url );
if ( ! $activitypub_html ) {
return $html;
}
@@ -234,14 +257,18 @@ class Embed {
* @return \WP_REST_Response|\WP_Error The response to send to the client.
*/
public static function oembed_fediverse_fallback( $response, $handler, $request ) {
- if ( is_wp_error( $response ) && 'oembed_invalid_url' === $response->get_error_code() ) {
+ if ( '/oembed/1.0/proxy' !== $request->get_route() ) {
+ return $response;
+ }
+
+ if ( ( is_wp_error( $response ) && 'oembed_invalid_url' === $response->get_error_code() ) || empty( $response->html ) ) {
$url = $request->get_param( 'url' );
- $html = get_embed_html( $url );
+ $html = self::get_html( $url );
if ( $html ) {
$args = $request->get_params();
$data = (object) array(
- 'provider_name' => 'Embed Handler',
+ 'provider_name' => 'ActivityPub oEmbed',
'html' => $html,
'scripts' => array(),
);
@@ -256,6 +283,21 @@ class Embed {
$response = new \WP_REST_Response( $data );
}
+ } elseif ( ! empty( $request->get_param( 'activitypub' ) ) ) {
+ /*
+ * If the 'activitypub' parameter is present, perform an additional validation step:
+ * Ensure the provided URL resolves to a valid ActivityPub object.
+ *
+ * This differs from the standard oEmbed flow, which does not explicitly validate
+ * the URL as an ActivityPub object unless the initial oEmbed lookup fails.
+ * This block is triggered for requests from the Federated Reply block, where we
+ * want to inform users whether post authors will be notified of the reply.
+ */
+ $object = Http::get_remote_object( $request->get_param( 'url' ) );
+
+ if ( \is_wp_error( $object ) || ! is_activity_object( $object ) ) {
+ $response = new \WP_Error( 'oembed_invalid_url', \get_status_header_desc( 404 ), array( 'status' => 404 ) );
+ }
}
return $response;
diff --git a/wp-content/plugins/activitypub/includes/class-emoji.php b/wp-content/plugins/activitypub/includes/class-emoji.php
new file mode 100644
index 00000000..390866ce
--- /dev/null
+++ b/wp-content/plugins/activitypub/includes/class-emoji.php
@@ -0,0 +1,277 @@
+ \esc_url( $url ) );
+
+ if ( ! empty( $tag['updated'] ) && \is_string( $tag['updated'] ) && \strtotime( $tag['updated'] ) ) {
+ $block_attrs['updated'] = \sanitize_text_field( $tag['updated'] );
+ }
+
+ $wrapped = \sprintf(
+ '%s',
+ \wp_json_encode( $block_attrs ),
+ $shortcode
+ );
+
+ // Case-insensitive replacement, avoid already wrapped shortcodes.
+ $pattern = '/(?)' . \preg_quote( $shortcode, '/' ) . '(?!)/i';
+ $content = \preg_replace_callback(
+ $pattern,
+ function () use ( $wrapped ) {
+ return $wrapped;
+ },
+ $content
+ );
+ }
+
+ return $content;
+ }
+
+ /**
+ * Generate an emoji img tag.
+ *
+ * @param string $url The emoji image URL.
+ * @param string $name The emoji name (without colons).
+ *
+ * @return string The emoji img tag HTML.
+ */
+ public static function get_img_tag( $url, $name ) {
+ return \sprintf(
+ '

',
+ \esc_url( $url ),
+ \esc_attr( $name ),
+ \esc_attr( $name )
+ );
+ }
+
+ /**
+ * Get the allowed HTML structure for emoji img tags.
+ *
+ * Used by Comment class for KSES validation of emoji in author names.
+ *
+ * @return array The allowed HTML structure for use with wp_kses.
+ */
+ public static function get_kses_allowed_html() {
+ return array(
+ 'img' => array(
+ 'class' => array(
+ 'required' => true,
+ 'values' => array( 'emoji' ),
+ ),
+ 'src' => array(
+ 'required' => true,
+ 'value_callback' => array( self::class, 'validate_emoji_src' ),
+ ),
+ 'alt' => array( 'required' => true ),
+ 'title' => array( 'required' => true ),
+ 'height' => array(
+ 'required' => true,
+ 'values' => array( '20' ),
+ ),
+ 'width' => array(
+ 'required' => true,
+ 'values' => array( '20' ),
+ ),
+ 'draggable' => array(
+ 'required' => true,
+ 'values' => array( 'false' ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Validate emoji src attribute for wp_kses.
+ *
+ * By default, only allows locally cached emoji URLs for privacy.
+ * Remote URLs are only allowed when caching is explicitly disabled.
+ *
+ * @param string $value The src attribute value.
+ *
+ * @return bool True if the src is valid, false otherwise.
+ */
+ public static function validate_emoji_src( $value ) {
+ $upload_dir = \wp_upload_dir();
+ $emoji_base = $upload_dir['baseurl'] . '/activitypub/emoji/';
+
+ // Allow local cached emoji.
+ if ( \str_starts_with( $value, $emoji_base ) ) {
+ return true;
+ }
+
+ // Only allow remote URLs when caching is explicitly disabled.
+ // This protects user privacy by defaulting to local-only emoji.
+ $allow_remote = ! Cache::is_enabled();
+
+ // Validate the URL format if remote is allowed.
+ if ( $allow_remote ) {
+ $allow_remote = (bool) \wp_http_validate_url( $value );
+ }
+
+ /**
+ * Filters whether a remote emoji URL is valid.
+ *
+ * Use this filter to explicitly allow remote emoji URLs when needed
+ * (e.g., for CDN proxying).
+ *
+ * @since 5.6.0
+ *
+ * @param bool $valid Whether the URL is valid.
+ * @param string $value The emoji src URL.
+ */
+ return \apply_filters( 'activitypub_validate_emoji_src', $allow_remote, $value );
+ }
+
+ /**
+ * Prepare actor meta for emoji storage.
+ *
+ * Used for storing actor emoji data for comment author name rendering.
+ *
+ * @param array $actor The actor array containing potential emoji in tags.
+ *
+ * @return array Meta input array with emoji data, or empty array if no emoji.
+ */
+ public static function prepare_actor_meta( $actor ) {
+ if ( empty( $actor['tag'] ) || ! \is_array( $actor['tag'] ) ) {
+ return array();
+ }
+
+ $emoji_tags = \array_values(
+ \array_filter(
+ $actor['tag'],
+ function ( $tag ) {
+ return \is_array( $tag ) && isset( $tag['type'] ) && 'Emoji' === $tag['type'];
+ }
+ )
+ );
+
+ if ( empty( $emoji_tags ) ) {
+ return array();
+ }
+
+ return array(
+ '_activitypub_emoji' => \wp_json_encode( $emoji_tags ),
+ );
+ }
+
+ /**
+ * Replace emoji from stored JSON data.
+ *
+ * Used for comment author name replacement at display time.
+ *
+ * @param string $text The text to process.
+ * @param string $emoji_json JSON-encoded emoji tag data.
+ *
+ * @return string The processed text with emoji replacements.
+ */
+ public static function replace_from_json( $text, $emoji_json ) {
+ $tags = \json_decode( $emoji_json, true );
+
+ if ( empty( $tags ) || ! \is_array( $tags ) ) {
+ return $text;
+ }
+
+ foreach ( $tags as $tag ) {
+ if ( empty( $tag['name'] ) ) {
+ continue;
+ }
+
+ $url = object_to_uri( $tag['icon'] ?? null );
+ if ( empty( $url ) ) {
+ continue;
+ }
+
+ /**
+ * Filters a remote media URL for caching.
+ *
+ * @param string $url The remote media URL.
+ * @param string $context The context ('emoji').
+ * @param string|null $entity_id The entity ID.
+ * @param array $options Additional options.
+ */
+ $cached_url = \apply_filters(
+ 'activitypub_remote_media_url',
+ $url,
+ 'emoji',
+ null,
+ array( 'updated' => $tag['updated'] ?? null )
+ );
+
+ $name = \trim( $tag['name'], ':' );
+ $img = self::get_img_tag( $cached_url ?: $url, $name );
+
+ $text = \str_ireplace( $tag['name'], $img, $text );
+ }
+
+ return $text;
+ }
+
+ /**
+ * Replace emoji in text using a remote actor's stored emoji data.
+ *
+ * Used by Mailer class for actor name/summary in emails.
+ *
+ * @param string $text The text to process.
+ * @param string $actor_url The actor's URL to look up emoji data.
+ *
+ * @return string The processed text with emoji replacements.
+ */
+ public static function replace_for_actor( $text, $actor_url ) {
+ $actor_post = Collection\Remote_Actors::get_by_uri( $actor_url );
+ if ( ! $actor_post || \is_wp_error( $actor_post ) ) {
+ return $text;
+ }
+
+ $emoji_data = \get_post_meta( $actor_post->ID, '_activitypub_emoji', true );
+ if ( empty( $emoji_data ) ) {
+ return $text;
+ }
+
+ return self::replace_from_json( $text, $emoji_data );
+ }
+}
diff --git a/wp-content/plugins/activitypub/includes/class-event-stream.php b/wp-content/plugins/activitypub/includes/class-event-stream.php
new file mode 100644
index 00000000..ffb6cd32
--- /dev/null
+++ b/wp-content/plugins/activitypub/includes/class-event-stream.php
@@ -0,0 +1,61 @@
+post_content . "\n" . $post->post_excerpt;
+ $content = self::extract_text_outside_protected_tags( $content );
+
$tags = array();
-
- // Skip hashtags in HTML attributes, like hex colors.
- $content = wp_strip_all_tags( $post->post_content . "\n" . $post->post_excerpt );
-
if ( \preg_match_all( '/' . ACTIVITYPUB_HASHTAGS_REGEXP . '/i', $content, $match ) ) {
- $tags = array_unique( $match[1] );
+ $tags = \array_unique( $match[1] );
}
\wp_add_post_tags( $post->ID, \implode( ', ', $tags ) );
}
+ /**
+ * Extract text content from outside protected HTML elements.
+ *
+ * Uses WP_HTML_Tag_Processor to properly parse HTML and skip content inside
+ * protected tags, matching the behavior of enrich_content_data().
+ *
+ * @param string $content The HTML content to process.
+ *
+ * @return string Text content from non-protected areas only.
+ */
+ private static function extract_text_outside_protected_tags( $content ) {
+ $processor = new \WP_HTML_Tag_Processor( $content );
+
+ /*
+ * Do not process content inside protected tags.
+ *
+ * Note: SCRIPT, STYLE, and TEXTAREA are "atomic" elements in
+ * WP_HTML_Tag_Processor, meaning their content is bundled with the tag
+ * token and won't appear as separate #text nodes. Because of this they
+ * do not need to be listed in $protected_tags: their inner text is
+ * never surfaced as #text tokens for us to process.
+ * See https://github.com/WordPress/wordpress-develop/blob/0fb3bb29596918864d808d156268a2df63c83620/src/wp-includes/html-api/class-wp-html-tag-processor.php#L276
+ */
+ $protected_tags = array( 'PRE', 'CODE', 'A' );
+ $tag_stack = array();
+ $filtered_content = '';
+
+ while ( $processor->next_token() ) {
+ $token_type = $processor->get_token_type();
+
+ if ( '#tag' === $token_type ) {
+ $tag_name = $processor->get_tag();
+
+ if ( $processor->is_tag_closer() ) {
+ // Closing tag: remove from stack.
+ $i = \array_search( $tag_name, $tag_stack, true );
+ if ( false !== $i ) {
+ $tag_stack = \array_slice( $tag_stack, 0, $i );
+ }
+ } elseif ( \in_array( $tag_name, $protected_tags, true ) ) {
+ // Opening tag: add to stack.
+ $tag_stack[] = $tag_name;
+ }
+ } elseif ( '#text' === $token_type && empty( $tag_stack ) ) {
+ // Only include text chunks that are outside protected tags.
+ $filtered_content .= $processor->get_modifiable_text();
+ }
+ }
+
+ return $filtered_content;
+ }
+
/**
* Filter to replace the #tags in the content with links.
*
diff --git a/wp-content/plugins/activitypub/includes/class-http.php b/wp-content/plugins/activitypub/includes/class-http.php
index 9f9a8dd0..1ddc0ebb 100644
--- a/wp-content/plugins/activitypub/includes/class-http.php
+++ b/wp-content/plugins/activitypub/includes/class-http.php
@@ -7,7 +7,6 @@
namespace Activitypub;
-use WP_Error;
use Activitypub\Collection\Actors;
/**
@@ -23,7 +22,7 @@ class Http {
* @param string $body The Post Body.
* @param int $user_id The WordPress User-ID.
*
- * @return array|WP_Error The POST Response or an WP_Error.
+ * @return array|\WP_Error The POST Response or an WP_Error.
*/
public static function post( $url, $body, $user_id ) {
/**
@@ -35,38 +34,42 @@ class Http {
*/
\do_action( 'activitypub_pre_http_post', $url, $body, $user_id );
- $date = \gmdate( 'D, d M Y H:i:s T' );
- $digest = Signature::generate_digest( $body );
- $signature = Signature::generate_signature( $user_id, 'post', $url, $date, $digest );
-
- $wp_version = get_masked_wp_version();
-
/**
* Filters the HTTP headers user agent string.
*
* @param string $user_agent The user agent string.
+ * @param string $url The request URL.
*/
- $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
- $args = array(
- 'timeout' => 100,
+ $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . get_masked_wp_version() . '; ' . \get_bloginfo( 'url' ), $url );
+
+ /**
+ * Filters the timeout duration for remote POST requests in ActivityPub.
+ *
+ * @param int $timeout The timeout value in seconds. Default 10 seconds.
+ */
+ $timeout = \apply_filters( 'activitypub_remote_post_timeout', 10 );
+
+ $args = array(
+ 'timeout' => $timeout,
'limit_response_size' => 1048576,
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'Content-Type' => 'application/activity+json',
- 'Digest' => $digest,
- 'Signature' => $signature,
- 'Date' => $date,
+ 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
),
'body' => $body,
+ 'key_id' => \json_decode( $body )->actor . '#main-key',
+ 'private_key' => Actors::get_private_key( $user_id ),
+ 'user_id' => $user_id,
);
$response = \wp_safe_remote_post( $url, $args );
$code = \wp_remote_retrieve_response_code( $response );
if ( $code >= 400 ) {
- $response = new WP_Error(
+ $response = new \WP_Error(
$code,
__( 'Failed HTTP Request', 'activitypub' ),
array(
@@ -79,10 +82,10 @@ class Http {
/**
* Action to save the response of the remote POST request.
*
- * @param array|WP_Error $response The response of the remote POST request.
- * @param string $url The URL endpoint.
- * @param string $body The Post Body.
- * @param int $user_id The WordPress User-ID.
+ * @param array|\WP_Error $response The response of the remote POST request.
+ * @param string $url The URL endpoint.
+ * @param string $body The Post Body.
+ * @param int $user_id The WordPress User-ID.
*/
\do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id );
@@ -93,11 +96,24 @@ class Http {
* Send a GET Request with the needed HTTP Headers.
*
* @param string $url The URL endpoint.
- * @param bool|int $cached Optional. Whether the result should be cached, or its duration. Default false.
+ * @param array $args Optional. Additional arguments to customize the request.
+ * - 'headers': Array of headers to override defaults.
+ * @param bool|int $cached Optional. Whether to return cached results, or cache duration. Default false.
*
- * @return array|WP_Error The GET Response or a WP_Error.
+ * @return array|\WP_Error The GET Response or a WP_Error.
*/
- public static function get( $url, $cached = false ) {
+ public static function get( $url, $args = array(), $cached = false ) {
+ // Backward compatibility: if $args is boolean/int, it's the old $cached parameter.
+ if ( ! \is_array( $args ) ) {
+ \_deprecated_argument(
+ __METHOD__,
+ '7.9.0',
+ \esc_html__( 'The $cached parameter should now be passed as the third argument.', 'activitypub' )
+ );
+ $cached = $args;
+ $args = array();
+ }
+
/**
* Fires before an HTTP GET request is made.
*
@@ -105,17 +121,18 @@ class Http {
*/
\do_action( 'activitypub_pre_http_get', $url );
- if ( $cached ) {
- $transient_key = self::generate_cache_key( $url );
+ $transient_key = self::generate_cache_key( $url );
+ // Check cache only if caching is requested.
+ if ( $cached ) {
$response = \get_transient( $transient_key );
if ( $response ) {
/**
* Action to save the response of the remote GET request.
*
- * @param array|WP_Error $response The response of the remote GET request.
- * @param string $url The URL endpoint.
+ * @param array|\WP_Error $response The response of the remote GET request.
+ * @param string $url The URL endpoint.
*/
\do_action( 'activitypub_safe_remote_get_response', $response, $url );
@@ -123,29 +140,22 @@ class Http {
}
}
- $date = \gmdate( 'D, d M Y H:i:s T' );
- $signature = Signature::generate_signature( Actors::APPLICATION_USER_ID, 'get', $url, $date );
-
- $wp_version = get_masked_wp_version();
-
/**
* Filters the HTTP headers user agent string.
*
- * This filter allows developers to modify the user agent string that is
- * sent with HTTP requests.
- *
* @param string $user_agent The user agent string.
+ * @param string $url The request URL.
*/
- $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
+ $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . get_masked_wp_version() . '; ' . \get_bloginfo( 'url' ), $url );
/**
* Filters the timeout duration for remote GET requests in ActivityPub.
*
- * @param int $timeout The timeout value in seconds. Default 100 seconds.
+ * @param int $timeout The timeout value in seconds. Default 10 seconds.
*/
- $timeout = \apply_filters( 'activitypub_remote_get_timeout', 100 );
+ $timeout = \apply_filters( 'activitypub_remote_get_timeout', 10 );
- $args = array(
+ $defaults = array(
'timeout' => $timeout,
'limit_response_size' => 1048576,
'redirection' => 3,
@@ -153,33 +163,54 @@ class Http {
'headers' => array(
'Accept' => 'application/activity+json',
'Content-Type' => 'application/activity+json',
- 'Signature' => $signature,
- 'Date' => $date,
+ 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
),
+ 'key_id' => Actors::get_by_id( Actors::APPLICATION_USER_ID )->get_id() . '#main-key',
+ 'private_key' => Actors::get_private_key( Actors::APPLICATION_USER_ID ),
);
+ $args = \wp_parse_args( $args, $defaults );
+ $args['headers'] = \wp_parse_args( $args['headers'], $defaults['headers'] );
+
$response = \wp_safe_remote_get( $url, $args );
$code = \wp_remote_retrieve_response_code( $response );
- if ( $code >= 400 ) {
- $response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
+ if ( \is_wp_error( $response ) || $code >= 400 ) {
+ if ( ! $code ) {
+ $code = 0;
+ }
+ $response = new \WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
+
+ /*
+ * Always cache errors to prevent repeated timeout waits.
+ * - Retriable errors (timeouts, 5xx): 1 minute (server may recover quickly).
+ * - Other errors (4xx): 15 minutes (client errors are more permanent).
+ */
+ if ( \in_array( $code, ACTIVITYPUB_RETRY_ERROR_CODES, true ) || 0 === $code ) {
+ $cache_duration = MINUTE_IN_SECONDS;
+ } else {
+ $cache_duration = 15 * MINUTE_IN_SECONDS;
+ }
+
+ \set_transient( $transient_key, $response, $cache_duration );
+
+ return $response;
}
/**
* Action to save the response of the remote GET request.
*
- * @param array|WP_Error $response The response of the remote GET request.
- * @param string $url The URL endpoint.
+ * @param array|\WP_Error $response The response of the remote GET request.
+ * @param string $url The URL endpoint.
*/
\do_action( 'activitypub_safe_remote_get_response', $response, $url );
- if ( $cached ) {
- $cache_duration = $cached;
- if ( ! is_int( $cache_duration ) ) {
- $cache_duration = HOUR_IN_SECONDS;
- }
- \set_transient( $transient_key, $response, $cache_duration );
+ // Always cache successful responses.
+ $cache_duration = $cached;
+ if ( ! is_int( $cache_duration ) ) {
+ $cache_duration = HOUR_IN_SECONDS;
}
+ \set_transient( $transient_key, $response, $cache_duration );
return $response;
}
@@ -192,27 +223,9 @@ class Http {
* @return bool True if the URL is a tombstone.
*/
public static function is_tombstone( $url ) {
- /**
- * Fires before checking if the URL is a tombstone.
- *
- * @param string $url The URL to check.
- */
- \do_action( 'activitypub_pre_http_is_tombstone', $url );
+ _deprecated_function( __METHOD__, '7.3.0', 'Activitypub\Tombstone::exists_remote' );
- $response = \wp_safe_remote_get( $url, array( 'headers' => array( 'Accept' => 'application/activity+json' ) ) );
- $code = \wp_remote_retrieve_response_code( $response );
-
- if ( in_array( (int) $code, array( 404, 410 ), true ) ) {
- return true;
- }
-
- $data = \wp_remote_retrieve_body( $response );
- $data = \json_decode( $data, true );
- if ( $data && isset( $data['type'] ) && 'Tombstone' === $data['type'] ) {
- return true;
- }
-
- return false;
+ return Tombstone::exists_remote( $url );
}
/**
@@ -232,17 +245,28 @@ class Http {
* @param array|string $url_or_object The Object or the Object URL.
* @param bool $cached Optional. Whether the result should be cached. Default true.
*
- * @return array|WP_Error The Object data as array or WP_Error on failure.
+ * @return array|\WP_Error The Object data as array or WP_Error on failure.
*/
public static function get_remote_object( $url_or_object, $cached = true ) {
+ /**
+ * Filters the preemptive return value of a remote object request.
+ *
+ * @param array|string|null $response The response.
+ * @param array|string|null $url_or_object The Object or the Object URL.
+ */
+ $response = apply_filters( 'activitypub_pre_http_get_remote_object', null, $url_or_object );
+ if ( null !== $response ) {
+ return $response;
+ }
+
$url = object_to_uri( $url_or_object );
- if ( preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $url ) ) {
+ if ( Webfinger::is_acct( $url ) ) {
$url = Webfinger::resolve( $url );
}
if ( ! $url ) {
- return new WP_Error(
+ return new \WP_Error(
'activitypub_no_valid_actor_identifier',
\__( 'The "actor" identifier is not valid', 'activitypub' ),
array(
@@ -252,23 +276,12 @@ class Http {
);
}
- if ( is_wp_error( $url ) ) {
+ if ( \is_wp_error( $url ) ) {
return $url;
}
- $transient_key = self::generate_cache_key( $url );
-
- // Only check the cache if needed.
- if ( $cached ) {
- $data = \get_transient( $transient_key );
-
- if ( $data ) {
- return $data;
- }
- }
-
if ( ! \wp_http_validate_url( $url ) ) {
- return new WP_Error(
+ return new \WP_Error(
'activitypub_no_valid_object_url',
\__( 'The "object" is/has no valid URL', 'activitypub' ),
array(
@@ -278,7 +291,7 @@ class Http {
);
}
- $response = self::get( $url );
+ $response = self::get( $url, array(), $cached );
if ( \is_wp_error( $response ) ) {
return $response;
@@ -288,7 +301,7 @@ class Http {
$data = \json_decode( $data, true );
if ( ! $data ) {
- return new WP_Error(
+ return new \WP_Error(
'activitypub_invalid_json',
\__( 'No valid JSON data', 'activitypub' ),
array(
@@ -298,8 +311,6 @@ class Http {
);
}
- \set_transient( $transient_key, $data, WEEK_IN_SECONDS );
-
return $data;
}
}
diff --git a/wp-content/plugins/activitypub/includes/class-link.php b/wp-content/plugins/activitypub/includes/class-link.php
index 783f1fec..cb9e68a5 100644
--- a/wp-content/plugins/activitypub/includes/class-link.php
+++ b/wp-content/plugins/activitypub/includes/class-link.php
@@ -28,10 +28,7 @@ class Link {
* @return array Rhe activity object array.
*/
public static function filter_activity_object( $activity ) {
- /* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
- Only changed it for Person and Group as long is not merged: https://github.com/mastodon/mastodon/pull/28629
- */
- if ( ! empty( $activity['summary'] ) && in_array( $activity['type'], array( 'Person', 'Group' ), true ) ) {
+ if ( ! empty( $activity['summary'] ) && is_actor( $activity ) ) {
$activity['summary'] = self::the_content( $activity['summary'] );
}
diff --git a/wp-content/plugins/activitypub/includes/class-mailer.php b/wp-content/plugins/activitypub/includes/class-mailer.php
index 095d8396..7be76afe 100644
--- a/wp-content/plugins/activitypub/includes/class-mailer.php
+++ b/wp-content/plugins/activitypub/includes/class-mailer.php
@@ -2,7 +2,7 @@
/**
* Mailer Class.
*
- * @package ActivityPub
+ * @package Activitypub
*/
namespace Activitypub;
@@ -20,9 +20,13 @@ class Mailer {
\add_filter( 'comment_notification_subject', array( self::class, 'comment_notification_subject' ), 10, 2 );
\add_filter( 'comment_notification_text', array( self::class, 'comment_notification_text' ), 10, 2 );
- \add_action( 'activitypub_inbox_follow', array( self::class, 'new_follower' ), 10, 2 );
+ \add_action( 'activitypub_handled_follow', array( self::class, 'new_follower' ), 10, 3 );
+
\add_action( 'activitypub_inbox_create', array( self::class, 'direct_message' ), 10, 2 );
- \add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 10, 2 );
+ \add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 20, 2 ); /** After @see \Activitypub\Handler\Create::handle_create() */
+
+ \add_filter( 'notify_post_author', array( self::class, 'maybe_prevent_comment_notification' ), 10, 2 );
+ \add_filter( 'notify_moderator', array( self::class, 'maybe_prevent_comment_notification' ), 10, 2 );
}
/**
@@ -86,10 +90,33 @@ class Mailer {
}
$post = \get_post( $comment->comment_post_ID );
- $comment_author_domain = \gethostbyaddr( $comment->comment_author_IP );
+ $comment_author_domain = '';
+
+ // Only attempt to resolve hostname if we have a valid IP address.
+ if ( \filter_var( $comment->comment_author_IP, FILTER_VALIDATE_IP ) ) {
+ $comment_author_domain = \gethostbyaddr( $comment->comment_author_IP );
+ }
+
+ // Check if this is a reaction to a post or a comment.
+ if ( 0 === (int) $comment->comment_parent ) {
+ $notify_message = \sprintf(
+ /* translators: 1: Comment type, 2: Post title */
+ \html_entity_decode( esc_html__( 'New %1$s on your post “%2$s”.', 'activitypub' ) ),
+ \esc_html( $comment_type['singular'] ),
+ \esc_html( $post->post_title )
+ ) . PHP_EOL . PHP_EOL;
+
+ } else {
+ $parent_comment = \get_comment( $comment->comment_parent );
+ $notify_message = \sprintf(
+ /* translators: 1: Comment type, 2: Post title, 3: Parent comment author */
+ \html_entity_decode( esc_html__( 'New %1$s on your post “%2$s” in reply to %3$s’s comment.', 'activitypub' ) ),
+ \esc_html( $comment_type['singular'] ),
+ \esc_html( $post->post_title ),
+ \esc_html( $parent_comment->comment_author )
+ ) . PHP_EOL . PHP_EOL;
+ }
- /* translators: 1: Comment type, 2: Post title */
- $notify_message = \sprintf( html_entity_decode( esc_html__( 'New %1$s on your post “%2$s”.', 'activitypub' ) ), \esc_html( $comment_type['singular'] ), \esc_html( $post->post_title ) ) . "\r\n\r\n";
/* translators: 1: Website name, 2: Website IP address, 3: Website hostname. */
$notify_message .= \sprintf( \esc_html__( 'From: %1$s (IP address: %2$s, %3$s)', 'activitypub' ), \esc_html( $comment->comment_author ), \esc_html( $comment->comment_author_IP ), \esc_html( $comment_author_domain ) ) . "\r\n";
/* translators: Reaction author URL. */
@@ -104,10 +131,24 @@ class Mailer {
/**
* Send a notification email for every new follower.
*
- * @param array $activity The activity object.
- * @param int $user_id The id of the local blog-user.
+ * @param array $activity The activity object.
+ * @param int|int[] $user_ids The id(s) of the local blog-user(s).
+ * @param bool $success True on success, false otherwise.
*/
- public static function new_follower( $activity, $user_id ) {
+ public static function new_follower( $activity, $user_ids, $success ) {
+ // Only send notification if the follow was successful.
+ if ( ! $success ) {
+ return;
+ }
+
+ // Extract the user ID (follows are always for a single user).
+ $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
+
+ // Do not send notifications to the Application user.
+ if ( Actors::APPLICATION_USER_ID === $user_id ) {
+ return;
+ }
+
if ( $user_id > Actors::BLOG_USER_ID ) {
if ( ! \get_user_option( 'activitypub_mailer_new_follower', $user_id ) ) {
return;
@@ -129,8 +170,14 @@ class Mailer {
return;
}
- if ( empty( $actor['webfinger'] ) ) {
- $actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
+ $actor = self::normalize_actor( $actor );
+
+ // Replace emoji in actor name and summary.
+ if ( ! empty( $actor['name'] ) ) {
+ $actor['name'] = Emoji::replace_for_actor( $actor['name'], $actor['url'] );
+ }
+ if ( ! empty( $actor['summary'] ) ) {
+ $actor['summary'] = Emoji::replace_for_actor( $actor['summary'], $actor['url'] );
}
$template_args = array_merge(
@@ -151,7 +198,7 @@ class Mailer {
continue;
}
- $result = Http::get( $actor[ $field ], true );
+ $result = Http::get( $actor[ $field ], array(), true );
if ( 200 === \wp_remote_retrieve_response_code( $result ) ) {
$body = \json_decode( \wp_remote_retrieve_body( $result ), true );
if ( isset( $body['totalItems'] ) ) {
@@ -167,7 +214,7 @@ class Mailer {
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-follower.php', false, $template_args );
$html_message = \ob_get_clean();
- $alt_function = function ( $mailer ) use ( $actor, $admin_url ) {
+ $alt_function = static function ( $mailer ) use ( $actor, $admin_url ) {
/* translators: 1: Follower name */
$message = \sprintf( \__( 'New Follower: %1$s.', 'activitypub' ), $actor['name'] ) . "\r\n\r\n";
/* translators: Follower URL */
@@ -186,152 +233,318 @@ class Mailer {
/**
* Send a direct message.
*
- * @param array $activity The activity object.
- * @param int $user_id The id of the local blog-user.
+ * @param array $activity The activity object.
+ * @param int|int[] $user_ids The id(s) of the local blog-user(s).
*/
- public static function direct_message( $activity, $user_id ) {
- if (
- is_activity_public( $activity ) ||
- // Only accept messages that have the user in the "to" field.
- empty( $activity['to'] ) ||
- ! in_array( Actors::get_by_id( $user_id )->get_id(), (array) $activity['to'], true )
- ) {
+ public static function direct_message( $activity, $user_ids ) {
+ // Early return if activity is public or has no recipients.
+ if ( is_activity_public( $activity ) || empty( $activity['to'] ) ) {
return;
}
- if ( $user_id > Actors::BLOG_USER_ID ) {
- if ( ! \get_user_option( 'activitypub_mailer_new_dm', $user_id ) ) {
- return;
+ // Normalize to array.
+ $user_ids = (array) $user_ids;
+
+ // Build a map of user_id => actor_id and filter to only users in the "to" field.
+ $recipients = array();
+ foreach ( $user_ids as $user_id ) {
+ $actor = Actors::get_by_id( $user_id );
+ if ( \is_wp_error( $actor ) ) {
+ continue;
}
- $email = \get_userdata( $user_id )->user_email;
- } else {
- if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_dm', '1' ) ) {
- return;
+ $actor_id = $actor->get_id();
+ if ( \in_array( $actor_id, (array) $activity['to'], true ) ) {
+ $recipients[ $user_id ] = $actor_id;
}
-
- $email = \get_option( 'admin_email' );
}
- $actor = get_remote_metadata_by_actor( $activity['actor'] );
+ // No matching recipients.
+ if ( empty( $recipients ) ) {
+ return;
+ }
+ // Get actor metadata once (shared for all emails).
+ $actor = get_remote_metadata_by_actor( $activity['actor'] );
if ( ! $actor || \is_wp_error( $actor ) || empty( $activity['object']['content'] ) ) {
return;
}
- if ( empty( $actor['webfinger'] ) ) {
- $actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
- }
+ $actor = self::normalize_actor( $actor );
- $template_args = array(
- 'activity' => $activity,
- 'actor' => $actor,
- 'user_id' => $user_id,
- );
+ // Send email to each recipient.
+ foreach ( $recipients as $user_id => $actor_id ) {
+ // Check user preferences.
+ if ( $user_id > Actors::BLOG_USER_ID ) {
+ if ( ! \get_user_option( 'activitypub_mailer_new_dm', $user_id ) ) {
+ continue;
+ }
- /* translators: 1: Blog name, 2 Actor name */
- $subject = \sprintf( \esc_html__( '[%1$s] Direct Message from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
+ $email = \get_userdata( $user_id )->user_email;
+ } else {
+ if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_dm', '1' ) ) {
+ continue;
+ }
- \ob_start();
- \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-dm.php', false, $template_args );
- $html_message = \ob_get_clean();
+ $email = \get_option( 'admin_email' );
+ }
- $alt_function = function ( $mailer ) use ( $actor, $activity ) {
- $content = \html_entity_decode(
- \wp_strip_all_tags(
- str_replace( '', PHP_EOL . PHP_EOL, $activity['object']['content'] )
- ),
- ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
+ $template_args = array(
+ 'activity' => $activity,
+ 'actor' => $actor,
+ 'user_id' => $user_id,
);
- /* translators: Actor name */
- $message = \sprintf( \esc_html__( 'New Direct Message: %s', 'activitypub' ), $content ) . "\r\n\r\n";
- /* translators: Actor name */
- $message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
- /* translators: Message URL */
- $message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
+ /* translators: 1: Blog name, 2 Actor name */
+ $subject = \sprintf( \esc_html__( '[%1$s] Direct Message from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
- $mailer->{'AltBody'} = $message;
- };
- \add_action( 'phpmailer_init', $alt_function );
+ \ob_start();
+ \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-dm.php', false, $template_args );
+ $html_message = \ob_get_clean();
- \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
+ $alt_function = static function ( $mailer ) use ( $actor, $activity ) {
+ $content = \html_entity_decode(
+ \wp_strip_all_tags(
+ str_replace( '', PHP_EOL . PHP_EOL, $activity['object']['content'] )
+ ),
+ ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
+ );
- \remove_action( 'phpmailer_init', $alt_function );
+ /* translators: Actor name */
+ $message = \sprintf( \esc_html__( 'New Direct Message: %s', 'activitypub' ), $content ) . "\r\n\r\n";
+ /* translators: Actor name */
+ $message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
+ /* translators: Message URL */
+ $message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
+
+ $mailer->{'AltBody'} = $message;
+ };
+ \add_action( 'phpmailer_init', $alt_function );
+
+ \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
+
+ \remove_action( 'phpmailer_init', $alt_function );
+ }
}
/**
* Send a mention notification.
*
- * @param array $activity The activity object.
- * @param int $user_id The id of the local blog-user.
+ * @param array $activity The activity object.
+ * @param int|int[] $user_ids The id(s) of the local blog-user(s).
*/
- public static function mention( $activity, $user_id ) {
- if (
- // Only accept messages that have the user in the "cc" field.
- empty( $activity['cc'] ) ||
- ! in_array( Actors::get_by_id( $user_id )->get_id(), (array) $activity['cc'], true )
- ) {
+ public static function mention( $activity, $user_ids ) {
+ // Early return if activity has no mentions.
+ if ( empty( $activity['object']['tag'] ) ) {
return;
}
- if ( $user_id > Actors::BLOG_USER_ID ) {
- if ( ! \get_user_option( 'activitypub_mailer_new_mention', $user_id ) ) {
- return;
- }
-
- $email = \get_userdata( $user_id )->user_email;
- } else {
- if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_mention', '1' ) ) {
- return;
- }
-
- $email = \get_option( 'admin_email' );
+ // Do not send a mention notification if the activity is a reply to a local post or comment.
+ if ( is_activity_reply( $activity ) && object_id_to_comment( $activity['object']['id'] ) ) {
+ return;
}
+ $recipients = array();
+ $mentions = wp_list_filter( (array) $activity['object']['tag'], array( 'type' => 'Mention' ) );
+ $mentions = array_map( '\Activitypub\object_to_uri', $mentions );
+ foreach ( (array) $user_ids as $user_id ) {
+ $actor = Actors::get_by_id( $user_id );
+ if ( \is_wp_error( $actor ) ) {
+ continue;
+ }
+
+ $actor_id = $actor->get_id();
+ if ( \in_array( $actor_id, $mentions, true ) ) {
+ $recipients[ $user_id ] = $actor_id;
+ }
+ }
+
+ // No matching recipients.
+ if ( empty( $recipients ) ) {
+ return;
+ }
+
+ // Get actor metadata once (shared for all emails).
$actor = get_remote_metadata_by_actor( $activity['actor'] );
if ( \is_wp_error( $actor ) ) {
return;
}
+ $actor = self::normalize_actor( $actor );
+
+ // Send email to each recipient.
+ foreach ( $recipients as $user_id => $actor_id ) {
+ // Check user preferences.
+ if ( $user_id > Actors::BLOG_USER_ID ) {
+ if ( ! \get_user_option( 'activitypub_mailer_new_mention', $user_id ) ) {
+ continue;
+ }
+
+ $email = \get_userdata( $user_id )->user_email;
+ } else {
+ if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_mention', '1' ) ) {
+ continue;
+ }
+
+ $email = \get_option( 'admin_email' );
+ }
+
+ $template_args = array(
+ 'activity' => $activity,
+ 'actor' => $actor,
+ 'user_id' => $user_id,
+ );
+
+ /* translators: 1: Blog name, 2 Actor name */
+ $subject = \sprintf( \esc_html__( '[%1$s] Mention from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
+
+ \ob_start();
+ \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-mention.php', false, $template_args );
+ $html_message = \ob_get_clean();
+
+ $alt_function = static function ( $mailer ) use ( $actor, $activity ) {
+ $content = \html_entity_decode(
+ \wp_strip_all_tags(
+ str_replace( '', PHP_EOL . PHP_EOL, $activity['object']['content'] )
+ ),
+ ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
+ );
+
+ /* translators: Message content */
+ $message = \sprintf( \esc_html__( 'New Mention: %s', 'activitypub' ), $content ) . "\r\n\r\n";
+ /* translators: Actor name */
+ $message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
+ /* translators: Message URL */
+ $message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
+
+ $mailer->{'AltBody'} = $message;
+ };
+ \add_action( 'phpmailer_init', $alt_function );
+
+ \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
+
+ \remove_action( 'phpmailer_init', $alt_function );
+ }
+ }
+
+ /**
+ * Send a templated email to a user.
+ *
+ * @param int $user_id The user ID (or BLOG_USER_ID for blog actor).
+ * @param string $subject The email subject.
+ * @param string $template The template name (without path/extension).
+ * @param array $args Template arguments.
+ * @param string $alt_body Optional plain text alternative. Auto-generated from HTML if empty.
+ *
+ * @return bool True if email was sent, false otherwise.
+ */
+ public static function send( $user_id, $subject, $template, $args = array(), $alt_body = '' ) {
+ // Get the recipient email address.
+ if ( $user_id > Actors::BLOG_USER_ID ) {
+ $user = \get_userdata( $user_id );
+ if ( ! $user || empty( $user->user_email ) ) {
+ return false;
+ }
+ $email = $user->user_email;
+ } else {
+ $email = \get_option( 'admin_email' );
+ }
+
+ // Load the HTML template.
+ $template_file = ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/' . \sanitize_file_name( $template ) . '.php';
+
+ /**
+ * Filter the email template file path.
+ *
+ * @param string $template_file The template file path.
+ * @param string $template The template name.
+ * @param int $user_id The user ID.
+ * @param array $args Template arguments.
+ */
+ $template_file = \apply_filters( 'activitypub_email_template', $template_file, $template, $user_id, $args );
+
+ if ( ! \file_exists( $template_file ) ) {
+ return false;
+ }
+
+ \ob_start();
+ \load_template( $template_file, false, $args );
+ $html_message = \ob_get_clean();
+
+ // Build plain text alternative from HTML if not provided.
+ if ( empty( $alt_body ) ) {
+ $alt_body = \wp_strip_all_tags( $html_message );
+ }
+ $alt_function = static function ( $mailer ) use ( $alt_body ) {
+ $mailer->{'AltBody'} = $alt_body;
+ };
+ \add_action( 'phpmailer_init', $alt_function );
+
+ $result = \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
+
+ \remove_action( 'phpmailer_init', $alt_function );
+
+ return $result;
+ }
+
+ /**
+ * Apply defaults to the actor object.
+ *
+ * Ensure that the actor object has a name, url, and webfinger.
+ *
+ * @param array $actor The actor object.
+ *
+ * @return array The inflated actor object.
+ */
+ private static function normalize_actor( $actor ) {
+ if ( empty( $actor['name'] ) ) {
+ $actor['name'] = $actor['preferredUsername'];
+ }
+
+ if ( empty( $actor['url'] ) ) {
+ $actor['url'] = $actor['id'];
+ }
+ $actor['url'] = object_to_uri( $actor['url'] );
+
if ( empty( $actor['webfinger'] ) ) {
$actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
}
- $template_args = array(
- 'activity' => $activity,
- 'actor' => $actor,
- 'user_id' => $user_id,
- );
+ return $actor;
+ }
- /* translators: 1: Blog name, 2 Actor name */
- $subject = \sprintf( \esc_html__( '[%1$s] Mention from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
+ /**
+ * Maybe prevent email notifications for comments.
+ *
+ * This filter can prevent both post author and moderator notifications
+ * for comments on specific post types, such as ActivityPub custom post types.
+ *
+ * @param bool $maybe_notify Whether to send the notification.
+ * @param int $comment_id The comment ID.
+ *
+ * @return bool False to prevent notification, original value otherwise.
+ */
+ public static function maybe_prevent_comment_notification( $maybe_notify, $comment_id ) {
+ // If already disabled, respect that.
+ if ( ! $maybe_notify ) {
+ return $maybe_notify;
+ }
- \ob_start();
- \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-mention.php', false, $template_args );
- $html_message = \ob_get_clean();
+ $comment = \get_comment( $comment_id );
+ if ( ! $comment ) {
+ return $maybe_notify;
+ }
- $alt_function = function ( $mailer ) use ( $actor, $activity ) {
- $content = \html_entity_decode(
- \wp_strip_all_tags(
- str_replace( '', PHP_EOL . PHP_EOL, $activity['object']['content'] )
- ),
- ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
- );
+ $post = \get_post( $comment->comment_post_ID );
+ if ( ! $post ) {
+ return $maybe_notify;
+ }
- /* translators: Message content */
- $message = \sprintf( \esc_html__( 'New Mention: %s', 'activitypub' ), $content ) . "\r\n\r\n";
- /* translators: Actor name */
- $message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
- /* translators: Message URL */
- $message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
+ // Prevent notifications for comments on ap_post.
+ if ( is_ap_post( $post ) ) {
+ return false;
+ }
- $mailer->{'AltBody'} = $message;
- };
- \add_action( 'phpmailer_init', $alt_function );
-
- \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
-
- \remove_action( 'phpmailer_init', $alt_function );
+ return $maybe_notify;
}
}
diff --git a/wp-content/plugins/activitypub/includes/class-mention.php b/wp-content/plugins/activitypub/includes/class-mention.php
index 93ada0bf..41a25e4d 100644
--- a/wp-content/plugins/activitypub/includes/class-mention.php
+++ b/wp-content/plugins/activitypub/includes/class-mention.php
@@ -7,7 +7,7 @@
namespace Activitypub;
-use WP_Error;
+use Activitypub\Collection\Remote_Actors;
/**
* ActivityPub Mention Class.
@@ -19,9 +19,9 @@ class Mention {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
- \add_filter( 'the_content', array( self::class, 'the_content' ), 99, 1 );
- \add_filter( 'comment_text', array( self::class, 'the_content' ), 10, 1 );
- \add_filter( 'activitypub_extra_field_content', array( self::class, 'the_content' ), 10, 1 );
+ \add_filter( 'the_content', array( self::class, 'the_content' ), 99 );
+ \add_filter( 'comment_text', array( self::class, 'the_content' ) );
+ \add_filter( 'activitypub_extra_field_content', array( self::class, 'the_content' ) );
\add_filter( 'activitypub_extract_mentions', array( self::class, 'extract_mentions' ), 99, 2 );
\add_filter( 'activitypub_activity_object_array', array( self::class, 'filter_activity_object' ), 99 );
}
@@ -65,27 +65,22 @@ class Mention {
* @return string The final string.
*/
public static function replace_with_links( $result ) {
- $metadata = get_remote_metadata_by_actor( $result[0] );
+ $post = Remote_Actors::fetch_by_acct( $result[0] );
- if (
- ! empty( $metadata ) &&
- ! is_wp_error( $metadata ) &&
- ( ! empty( $metadata['id'] ) || ! empty( $metadata['url'] ) )
- ) {
- $username = ltrim( $result[0], '@' );
- if ( ! empty( $metadata['name'] ) ) {
- $username = $metadata['name'];
- }
- if ( ! empty( $metadata['preferredUsername'] ) ) {
- $username = $metadata['preferredUsername'];
- }
-
- $url = isset( $metadata['url'] ) ? object_to_uri( $metadata['url'] ) : object_to_uri( $metadata['id'] );
-
- return \sprintf( '
@%2$s', esc_url( $url ), esc_html( $username ) );
+ if ( \is_wp_error( $post ) ) {
+ return $result[0];
}
- return $result[0];
+ $actor = Remote_Actors::get_actor( $post );
+
+ if ( \is_wp_error( $actor ) ) {
+ return $result[0];
+ }
+
+ $username = $actor->get_preferred_username() ?: $actor->get_name() ?: Sanitize::webfinger( $result[0] );
+ $url = object_to_uri( $actor->get_url() ?: $actor->get_id() );
+
+ return \sprintf( '
@%2$s', esc_url( $url ), esc_html( $username ) );
}
/**
@@ -114,7 +109,7 @@ class Mention {
*
* @param string $actor The Actor URL.
*
- * @return string|WP_Error The Inbox-URL or WP_Error if not found.
+ * @return string|\WP_Error The Inbox-URL or WP_Error if not found.
*/
public static function get_inbox_by_mentioned_actor( $actor ) {
$metadata = get_remote_metadata_by_actor( $actor );
@@ -131,7 +126,7 @@ class Mention {
return $metadata['inbox'];
}
- return new WP_Error( 'activitypub_no_inbox', \__( 'No "Inbox" found', 'activitypub' ), $metadata );
+ return new \WP_Error( 'activitypub_no_inbox', \__( 'No "Inbox" found', 'activitypub' ), $metadata );
}
/**
diff --git a/wp-content/plugins/activitypub/includes/class-migration.php b/wp-content/plugins/activitypub/includes/class-migration.php
index 9132f6b6..7c4c7df8 100644
--- a/wp-content/plugins/activitypub/includes/class-migration.php
+++ b/wp-content/plugins/activitypub/includes/class-migration.php
@@ -10,7 +10,9 @@ namespace Activitypub;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Extra_Fields;
use Activitypub\Collection\Followers;
+use Activitypub\Collection\Following;
use Activitypub\Collection\Outbox;
+use Activitypub\Collection\Remote_Actors;
use Activitypub\Transformer\Factory;
/**
@@ -23,27 +25,16 @@ class Migration {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
- \add_action( 'activitypub_migrate', array( self::class, 'async_migration' ) );
- \add_action( 'activitypub_upgrade', array( self::class, 'async_upgrade' ), 10, 99 );
- \add_action( 'activitypub_update_comment_counts', array( self::class, 'update_comment_counts' ), 10, 2 );
-
self::maybe_migrate();
- }
- /**
- * Get the target version.
- *
- * This is the version that the database structure will be updated to.
- * It is the same as the plugin version.
- *
- * @deprecated 4.2.0 Use constant ACTIVITYPUB_PLUGIN_VERSION directly.
- *
- * @return string The target version.
- */
- public static function get_target_version() {
- _deprecated_function( __FUNCTION__, '4.2.0', 'ACTIVITYPUB_PLUGIN_VERSION' );
-
- return ACTIVITYPUB_PLUGIN_VERSION;
+ Scheduler::register_async_batch_callback( 'activitypub_migrate_from_0_17', array( self::class, 'migrate_from_0_17' ) );
+ Scheduler::register_async_batch_callback( 'activitypub_update_comment_counts', array( self::class, 'update_comment_counts' ) );
+ Scheduler::register_async_batch_callback( 'activitypub_create_post_outbox_items', array( self::class, 'create_post_outbox_items' ) );
+ Scheduler::register_async_batch_callback( 'activitypub_create_comment_outbox_items', array( self::class, 'create_comment_outbox_items' ) );
+ Scheduler::register_async_batch_callback( 'activitypub_migrate_avatar_to_remote_actors', array( self::class, 'migrate_avatar_to_remote_actors' ) );
+ Scheduler::register_async_batch_callback( 'activitypub_migrate_actor_emoji', array( self::class, 'migrate_actor_emoji' ) );
+ Scheduler::register_async_batch_callback( 'activitypub_backfill_statistics', array( Statistics::class, 'backfill_historical_stats' ) );
+ Scheduler::register_async_batch_callback( 'activitypub_tombstone_migrate', array( self::class, 'migrate_tombstones_to_cpt' ) );
}
/**
@@ -137,13 +128,12 @@ class Migration {
$version_from_db = ACTIVITYPUB_PLUGIN_VERSION;
}
- // Schedule the async migration.
- if ( ! \wp_next_scheduled( 'activitypub_migrate', $version_from_db ) ) {
- \wp_schedule_single_event( \time(), 'activitypub_migrate', array( $version_from_db ) );
- }
if ( \version_compare( $version_from_db, '0.17.0', '<' ) ) {
self::migrate_from_0_16();
}
+ if ( \version_compare( $version_from_db, '1.0.0', '<' ) ) {
+ \wp_schedule_single_event( \time(), 'activitypub_migrate_from_0_17' );
+ }
if ( \version_compare( $version_from_db, '1.3.0', '<' ) ) {
self::migrate_from_1_2_0();
}
@@ -171,22 +161,14 @@ class Migration {
if ( \version_compare( $version_from_db, '4.7.2', '<' ) ) {
self::migrate_to_4_7_2();
}
- if ( \version_compare( $version_from_db, '4.7.3', '<' ) ) {
- add_action( 'init', 'flush_rewrite_rules', 20 );
- }
if ( \version_compare( $version_from_db, '5.0.0', '<' ) ) {
Scheduler::register_schedules();
- \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'create_post_outbox_items' ) );
- \wp_schedule_single_event( \time() + 15, 'activitypub_upgrade', array( 'create_comment_outbox_items' ) );
- add_action( 'init', 'flush_rewrite_rules', 20 );
- }
- if ( \version_compare( $version_from_db, '5.2.0', '<' ) ) {
- Scheduler::register_schedules();
+ \wp_schedule_single_event( \time(), 'activitypub_create_post_outbox_items' );
+ \wp_schedule_single_event( \time() + 15, 'activitypub_create_comment_outbox_items' );
}
if ( \version_compare( $version_from_db, '5.4.0', '<' ) ) {
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_slashing' ) );
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_comment_author_emails' ) );
- \add_action( 'init', 'flush_rewrite_rules', 20 );
}
if ( \version_compare( $version_from_db, '5.7.0', '<' ) ) {
self::delete_mastodon_api_orphaned_extra_fields();
@@ -194,6 +176,59 @@ class Migration {
if ( \version_compare( $version_from_db, '5.8.0', '<' ) ) {
self::update_notification_options();
}
+ if ( \version_compare( $version_from_db, '6.0.0', '<' ) ) {
+ self::migrate_followers_to_ap_actor_cpt();
+ \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_storage' ) );
+ }
+ if ( \version_compare( $version_from_db, '6.0.1', '<' ) ) {
+ self::migrate_followers_to_ap_actor_cpt();
+ \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_storage' ) );
+ }
+ if ( \version_compare( $version_from_db, '7.0.0', '<' ) ) {
+ wp_unschedule_hook( 'activitypub_update_followers' );
+ wp_unschedule_hook( 'activitypub_cleanup_followers' );
+
+ if ( ! \wp_next_scheduled( 'activitypub_update_remote_actors' ) ) {
+ \wp_schedule_event( time(), 'hourly', 'activitypub_update_remote_actors' );
+ }
+
+ if ( ! \wp_next_scheduled( 'activitypub_cleanup_remote_actors' ) ) {
+ \wp_schedule_event( time(), 'daily', 'activitypub_cleanup_remote_actors' );
+ }
+ }
+ if ( \version_compare( $version_from_db, '7.3.0', '<' ) ) {
+ self::remove_pending_application_user_follow_requests();
+ }
+ if ( \version_compare( $version_from_db, '7.5.0', '<' ) ) {
+ self::sync_jetpack_following_meta();
+ }
+ if ( \version_compare( $version_from_db, '7.6.0', '<' ) ) {
+ self::clean_up_inbox();
+ \wp_schedule_single_event( \time(), 'activitypub_migrate_avatar_to_remote_actors' );
+ }
+ if ( \version_compare( $version_from_db, '7.9.0', '<' ) ) {
+ \wp_schedule_single_event( \time(), 'activitypub_migrate_actor_emoji' );
+ }
+ if ( \version_compare( $version_from_db, '8.1.0', '<' ) && ! \wp_next_scheduled( 'activitypub_backfill_statistics' ) ) {
+ // Backfill historical statistics data (delay + jitter to avoid load spikes on hosts running many sites).
+ \wp_schedule_single_event( \time() + HOUR_IN_SECONDS + \wp_rand( 0, 6 * HOUR_IN_SECONDS ), 'activitypub_backfill_statistics' );
+ }
+ if ( \version_compare( $version_from_db, '8.3.0', '<' ) ) {
+ if ( ! \wp_next_scheduled( 'activitypub_tombstone_migrate' ) ) {
+ \wp_schedule_single_event( \time() + MINUTE_IN_SECONDS, 'activitypub_tombstone_migrate' );
+ }
+ }
+
+ /*
+ * Defer the flush to late in the `init` cycle (priority 20). Migration::init
+ * runs at priority 1, which is earlier than most plugins register their
+ * rewrite rules. Flushing synchronously here would persist a truncated
+ * ruleset that omits third-party rules added on `init` at priority 10.
+ */
+ \add_action( 'init', array( Activitypub::class, 'flush_rewrite_rules' ), 20 );
+
+ // Ensure all required cron schedules are registered.
+ Scheduler::register_schedules();
/*
* Add new update routines above this comment. ^
@@ -220,49 +255,6 @@ class Migration {
self::unlock();
}
- /**
- * Asynchronously migrates the database structure.
- *
- * @param string $version_from_db The version from which to migrate.
- */
- public static function async_migration( $version_from_db ) {
- if ( \version_compare( $version_from_db, '1.0.0', '<' ) ) {
- self::migrate_from_0_17();
- }
- }
-
- /**
- * Asynchronously runs upgrade routines.
- *
- * @param callable $callback Callable upgrade routine. Must be a method of this class.
- * @params mixed ...$args Optional. Parameters that get passed to the callback.
- */
- public static function async_upgrade( $callback ) {
- $args = \func_get_args();
-
- // Bail if the existing lock is still valid.
- if ( self::is_locked() ) {
- \wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'activitypub_upgrade', $args );
- return;
- }
-
- self::lock();
-
- $callback = array_shift( $args ); // Remove $callback from arguments.
- $next = \call_user_func_array( array( self::class, $callback ), $args );
-
- self::unlock();
-
- if ( ! empty( $next ) ) {
- // Schedule the next run, adding the result to the arguments.
- \wp_schedule_single_event(
- \time() + 30,
- 'activitypub_upgrade',
- \array_merge( array( $callback ), \array_values( $next ) )
- );
- }
- }
-
/**
* Updates the custom template to use shortcodes instead of the deprecated templates.
*/
@@ -310,12 +302,10 @@ class Migration {
if ( $followers ) {
foreach ( $followers as $actor ) {
- Followers::add_follower( $user_id, $actor );
+ Followers::add( $user_id, $actor );
}
}
}
-
- Activitypub::flush_rewrite_rules();
}
/**
@@ -404,14 +394,14 @@ class Migration {
);
foreach ( $users as $user ) {
- $followers = Followers::get_followers( $user->ID );
+ $followers = Followers::get_many( $user->ID );
if ( $followers ) {
\update_user_option( $user->ID, 'activitypub_use_permalink_as_id', '1' );
}
}
- $followers = Followers::get_followers( Actors::BLOG_USER_ID );
+ $followers = Followers::get_many( Actors::BLOG_USER_ID );
if ( $followers ) {
\update_option( 'activitypub_use_permalink_as_id_for_blog', '1' );
@@ -421,7 +411,7 @@ class Migration {
}
/**
- * Upate to 4.1.0
+ * Update to 4.1.0
*
* * Migrate the `activitypub_post_content_type` to only use `activitypub_custom_post_content`.
*/
@@ -491,7 +481,7 @@ class Migration {
global $wpdb;
// phpcs:ignore WordPress.DB
$followers = $wpdb->get_col(
- $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", Followers::POST_TYPE )
+ $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", Remote_Actors::POST_TYPE )
);
foreach ( $followers as $id ) {
clean_post_cache( $id );
@@ -504,25 +494,12 @@ class Migration {
* @see Comment::pre_wp_update_comment_count_now()
* @param int $batch_size Optional. Number of posts to process per batch. Default 100.
* @param int $offset Optional. Number of posts to skip. Default 0.
+ *
+ * @return int[]|void Array with batch size and offset if there are more posts to process.
*/
public static function update_comment_counts( $batch_size = 100, $offset = 0 ) {
global $wpdb;
- // Bail if the existing lock is still valid.
- if ( self::is_locked() ) {
- \wp_schedule_single_event(
- time() + ( 5 * MINUTE_IN_SECONDS ),
- 'activitypub_update_comment_counts',
- array(
- 'batch_size' => $batch_size,
- 'offset' => $offset,
- )
- );
- return;
- }
-
- self::lock();
-
Comment::register_comment_types();
$comment_types = Comment::get_comment_type_slugs();
$type_inclusion = "AND comment_type IN ('" . implode( "','", $comment_types ) . "')";
@@ -543,17 +520,8 @@ class Migration {
if ( count( $post_ids ) === $batch_size ) {
// Schedule next batch.
- \wp_schedule_single_event(
- time() + MINUTE_IN_SECONDS,
- 'activitypub_update_comment_counts',
- array(
- 'batch_size' => $batch_size,
- 'offset' => $offset + $batch_size,
- )
- );
+ return array( $batch_size, $offset + $batch_size );
}
-
- self::unlock();
}
/**
@@ -574,7 +542,7 @@ class Migration {
'meta_query' => array(
array(
'key' => 'activitypub_status',
- 'value' => 'federated',
+ 'value' => ACTIVITYPUB_OBJECT_STATE_FEDERATED,
),
),
)
@@ -621,7 +589,7 @@ class Migration {
'meta_query' => array(
array(
'key' => 'activitypub_status',
- 'value' => 'federated',
+ 'value' => ACTIVITYPUB_OBJECT_STATE_FEDERATED,
),
),
)
@@ -697,6 +665,7 @@ class Migration {
array(
'number' => $batch_size,
'offset' => $offset,
+ 'orderby' => 'comment_ID',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
@@ -835,7 +804,7 @@ class Migration {
}
/**
- * Rename meta keys.
+ * Rename user meta keys.
*
* @param string $old_key The old comment meta key.
* @param string $new_key The new comment meta key.
@@ -852,6 +821,24 @@ class Migration {
);
}
+ /**
+ * Update post meta keys.
+ *
+ * @param string $old_key The old post meta key.
+ * @param string $new_key The new post meta key.
+ */
+ private static function update_postmeta_key( $old_key, $new_key ) {
+ global $wpdb;
+
+ $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->postmeta,
+ array( 'meta_key' => $new_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ array( 'meta_key' => $old_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ array( '%s' ),
+ array( '%s' )
+ );
+ }
+
/**
* Rename option keys.
*
@@ -930,15 +917,417 @@ class Migration {
\add_option( 'activitypub_blog_user_mailer_new_follower', $new_follower );
\add_option( 'activitypub_blog_user_mailer_new_mention', '1' );
+ $user_ids = \get_users(
+ array(
+ 'capability__in' => array( 'activitypub' ),
+ 'fields' => 'id',
+ )
+ );
+
// Add the actor notification options.
- foreach ( Actors::get_collection() as $actor ) {
- \update_user_option( $actor->get__id(), 'activitypub_mailer_new_dm', $new_dm );
- \update_user_option( $actor->get__id(), 'activitypub_mailer_new_follower', $new_follower );
- \update_user_option( $actor->get__id(), 'activitypub_mailer_new_mention', '1' );
+ foreach ( $user_ids as $user_id ) {
+ \update_user_option( $user_id, 'activitypub_mailer_new_dm', $new_dm );
+ \update_user_option( $user_id, 'activitypub_mailer_new_follower', $new_follower );
+ \update_user_option( $user_id, 'activitypub_mailer_new_mention', '1' );
}
// Delete the old notification options.
\delete_option( 'activitypub_mailer_new_dm' );
\delete_option( 'activitypub_mailer_new_follower' );
}
+
+ /**
+ * Migrate followers to the new CPT.
+ */
+ public static function migrate_followers_to_ap_actor_cpt() {
+ global $wpdb;
+
+ $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->posts,
+ array( 'post_type' => Remote_Actors::POST_TYPE ),
+ array( 'post_type' => 'ap_follower' ),
+ array( '%s' ),
+ array( '%s' )
+ );
+
+ self::update_postmeta_key( '_activitypub_user_id', Followers::FOLLOWER_META_KEY );
+ }
+
+ /**
+ * Update _activitypub_actor_json meta values to ensure they are properly slashed.
+ *
+ * @param int $batch_size Optional. Number of meta values to process per batch. Default 100.
+ *
+ * @return array|void Array with batch size and offset if there are more meta values to process, void otherwise.
+ */
+ public static function update_actor_json_storage( $batch_size = 100 ) {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $meta_values = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_actor_json' LIMIT %d",
+ $batch_size
+ )
+ );
+
+ $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
+ if ( $has_kses ) {
+ // Prevent KSES from corrupting JSON in post_content.
+ \kses_remove_filters();
+ }
+
+ foreach ( $meta_values as $meta ) {
+ $post = \get_post( $meta->post_id );
+
+ if ( ! $post ) {
+ \delete_post_meta( $meta->post_id, '_activitypub_actor_json' );
+ continue;
+ }
+
+ $post_content = \json_decode( $meta->meta_value, true );
+
+ if ( \json_last_error() !== JSON_ERROR_NONE ) {
+ $post_content = Http::get_remote_object( $post->guid );
+
+ if ( \is_wp_error( $post_content ) ) {
+ \delete_post_meta( $post->ID, '_activitypub_actor_json' );
+ continue;
+ }
+ }
+
+ \wp_update_post(
+ array(
+ 'ID' => $post->ID,
+ 'post_content' => \wp_slash( \wp_json_encode( $post_content ) ),
+ )
+ );
+
+ \delete_post_meta( $post->ID, '_activitypub_actor_json' );
+ }
+
+ if ( $has_kses ) {
+ // Restore KSES filters.
+ \kses_init_filters();
+ }
+
+ if ( \count( $meta_values ) === $batch_size ) {
+ return array(
+ 'batch_size' => $batch_size,
+ );
+ }
+ }
+
+ /**
+ * Removes pending follow requests for the application user.
+ */
+ public static function remove_pending_application_user_follow_requests() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->delete(
+ $wpdb->postmeta,
+ array(
+ 'meta_key' => '_activitypub_following', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ 'meta_value' => Actors::APPLICATION_USER_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ )
+ );
+ }
+
+ /**
+ * Sync Jetpack meta for all followings.
+ *
+ * Replays the added_post_meta sync action for Jetpack with the Following::FOLLOWING_META_KEY meta key.
+ */
+ public static function sync_jetpack_following_meta() {
+ if ( ! \class_exists( 'Jetpack' ) || ! \Jetpack::is_connection_ready() ) {
+ return;
+ }
+
+ global $wpdb;
+
+ // Get all posts that have the following meta key.
+ $posts_with_following = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->prepare(
+ "SELECT meta_id, post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s",
+ Following::FOLLOWING_META_KEY
+ ),
+ ARRAY_N
+ );
+
+ // Trigger the added_post_meta action for each following relationship.
+ foreach ( $posts_with_following as $meta ) {
+ /**
+ * Fires when post meta is added.
+ *
+ * @param int $meta_id ID of the metadata entry.
+ * @param int $object_id Post ID.
+ * @param string $meta_key Metadata key.
+ * @param mixed $meta_value Metadata value.
+ */
+ \do_action( 'added_post_meta', ...$meta );
+ }
+ }
+
+ /**
+ * Clean up inbox items for shared inbox migration.
+ *
+ * Deletes all existing inbox items to prepare for the new shared inbox structure
+ * where activities are stored once with multiple recipients as metadata.
+ */
+ private static function clean_up_inbox() {
+ global $wpdb;
+
+ // Get all inbox post IDs.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $inbox_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s",
+ \Activitypub\Collection\Inbox::POST_TYPE
+ )
+ );
+
+ // Delete all inbox items and their metadata.
+ foreach ( $inbox_ids as $post_id ) {
+ \wp_delete_post( $post_id, true );
+ }
+ }
+
+ /**
+ * Migrate URLs from the legacy `activitypub_tombstone_urls` option into the
+ * `ap_tombstone` custom post type.
+ *
+ * Chunked async migration. Locking and rescheduling is handled by
+ * Scheduler::async_batch — the callback returns `array( 'batch_size' => N )`
+ * to request another run, or `null` when the option is fully drained.
+ *
+ * Legacy entries are already-normalized strings (no scheme), so we bypass
+ * URL validation and insert directly via wp_insert_post.
+ *
+ * @since 8.3.0
+ *
+ * @param int $batch_size Optional. Number of URLs to process per call. Default 500.
+ * @return array|null Args for the next run, or null when migration is complete.
+ */
+ public static function migrate_tombstones_to_cpt( $batch_size = 500 ) {
+ global $wpdb;
+
+ $urls = \get_option( 'activitypub_tombstone_urls', null );
+
+ if ( null === $urls || ! \is_array( $urls ) || empty( $urls ) ) {
+ \delete_option( 'activitypub_tombstone_urls' );
+ return null;
+ }
+
+ $chunk = \array_slice( $urls, 0, (int) $batch_size );
+ $remaining = \array_slice( $urls, (int) $batch_size );
+ $progressed = false;
+
+ foreach ( $chunk as $normalized ) {
+ if ( ! \is_string( $normalized ) || '' === $normalized ) {
+ // Drop garbage entries — counts as progress.
+ $progressed = true;
+ continue;
+ }
+
+ $hash = \md5( $normalized );
+
+ /*
+ * Light existence check. `get_page_by_path()` would hydrate a
+ * full `WP_Post` per loop iteration; on a large registry that
+ * adds up fast. We only need a boolean here.
+ */
+ $exists = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->prepare(
+ "SELECT 1 FROM {$wpdb->posts} WHERE post_type = %s AND post_name = %s LIMIT 1",
+ Tombstone::POST_TYPE,
+ $hash
+ )
+ );
+ if ( $exists ) {
+ $progressed = true;
+ continue;
+ }
+
+ /*
+ * `guid` is intentionally omitted: the legacy option only kept
+ * the normalized (schemeless) form, so we can't reconstruct the
+ * original URL. Storing the schemeless string would be mangled
+ * by `esc_url()`. Leave WordPress to auto-generate the guid
+ * — it's not used for lookups, only for debugging.
+ */
+ $result = \wp_insert_post(
+ array(
+ 'post_type' => Tombstone::POST_TYPE,
+ 'post_status' => 'publish',
+ 'post_name' => $hash,
+ 'post_author' => 0,
+ ),
+ true
+ );
+
+ if ( \is_wp_error( $result ) || ! $result ) {
+ /*
+ * Keep failed inserts in the legacy option so the next batch
+ * retries them. `Tombstone::exists_local()` still falls back
+ * to the option, so the tombstone remains discoverable.
+ */
+ $remaining[] = $normalized;
+ } else {
+ $progressed = true;
+ }
+ }
+
+ if ( empty( $remaining ) ) {
+ \delete_option( 'activitypub_tombstone_urls' );
+ return null;
+ }
+
+ /*
+ * Disable autoload while we drain. The point of the migration is to
+ * stop this option from contributing to `alloptions` pressure, so
+ * flip the flag immediately rather than waiting for the option to
+ * be fully empty before the relief kicks in.
+ */
+ \update_option( 'activitypub_tombstone_urls', \array_values( $remaining ), false );
+
+ /*
+ * If nothing in this batch was drained — every insert errored and
+ * nothing was already migrated — halt the scheduler so we don't loop
+ * forever on a persistent failure. The legacy option still backs
+ * exists_local(), so the data isn't lost; an admin can re-trigger
+ * the migration via `wp cron event run activitypub_tombstone_migrate`
+ * after fixing the underlying cause.
+ */
+ if ( ! $progressed ) {
+ return null;
+ }
+
+ return array( 'batch_size' => (int) $batch_size );
+ }
+
+ /**
+ * Migrate avatar URLs from comment meta to remote actors in batches.
+ *
+ * This migration:
+ * 1. Finds all comments with ActivityPub protocol and avatar_url meta
+ * 2. Looks up the remote actor by comment_author_url
+ * 3. Adds _activitypub_remote_actor_id to comment meta
+ * 4. Stores avatar_url in remote actor post meta
+ *
+ * Note: We don't use offset because as we add _activitypub_remote_actor_id,
+ * comments are filtered out of the query. We just keep fetching the next
+ * batch until no more comments match the criteria.
+ *
+ * @param int $batch_size Optional. Number of comments to process per batch. Default 50.
+ * @return array|null Array with batch size if there are more comments to process, null otherwise.
+ */
+ public static function migrate_avatar_to_remote_actors( $batch_size = 50 ) {
+ global $wpdb;
+
+ /*
+ * Get comments with avatar_url meta that don't have _activitypub_remote_actor_id yet.
+ * Uses conditional aggregation to reduce JOINs from 3 to 1, improving query performance.
+ * Filters meta_key before GROUP BY to reduce rows processed during aggregation.
+ * No offset needed - as we process comments, they're filtered out by the HAVING clause.
+ */
+ $comments = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->prepare(
+ "SELECT c.comment_ID, c.comment_author_url,
+ MAX(CASE WHEN cm.meta_key = 'avatar_url' THEN cm.meta_value END) AS avatar_url,
+ MAX(CASE WHEN cm.meta_key = 'protocol' THEN cm.meta_value END) AS protocol,
+ MAX(CASE WHEN cm.meta_key = '_activitypub_remote_actor_id' THEN cm.meta_value END) AS remote_actor_id
+ FROM {$wpdb->comments} c
+ INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
+ WHERE cm.meta_key IN ('avatar_url', 'protocol', '_activitypub_remote_actor_id')
+ GROUP BY c.comment_ID, c.comment_author_url
+ HAVING protocol = 'activitypub'
+ AND avatar_url IS NOT NULL
+ AND (remote_actor_id IS NULL OR remote_actor_id = '')
+ LIMIT %d",
+ $batch_size
+ )
+ );
+
+ foreach ( $comments as $comment ) {
+ if ( empty( $comment->comment_author_url ) ) {
+ continue;
+ }
+
+ // Try to get the remote actor by URI.
+ $remote_actor = Remote_Actors::fetch_by_uri( $comment->comment_author_url );
+
+ // If we have a valid remote actor, store the reference.
+ if ( ! \is_wp_error( $remote_actor ) ) {
+ // Add _activitypub_remote_actor_id to comment meta.
+ \add_comment_meta( $comment->comment_ID, '_activitypub_remote_actor_id', $remote_actor->ID, true );
+
+ // Ensure avatar is stored on remote actor if not already present.
+ $existing_avatar = \get_post_meta( $remote_actor->ID, '_activitypub_avatar_url', true );
+ if ( empty( $existing_avatar ) && ! empty( $comment->avatar_url ) ) {
+ \update_post_meta( $remote_actor->ID, '_activitypub_avatar_url', \esc_url_raw( $comment->avatar_url ) );
+ }
+ }
+ }
+
+ // Return batch info if there are more comments to process.
+ if ( count( $comments ) === $batch_size ) {
+ return array(
+ 'batch_size' => $batch_size,
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * Migrate emoji data from stored actor JSON to post meta.
+ *
+ * This migration:
+ * 1. Finds all remote actor posts without _activitypub_emoji meta
+ * 2. Extracts emoji from stored JSON in post_content
+ * 3. Stores as _activitypub_emoji post meta
+ *
+ * @param int $batch_size Optional. Number of actors to process per batch. Default 50.
+ * @param int $offset Optional. Offset for pagination. Default 0.
+ * @return array|null Array with batch size if there are more actors to process, null otherwise.
+ */
+ public static function migrate_actor_emoji( $batch_size = 50, $offset = 0 ) {
+ $actors = \get_posts(
+ array(
+ 'post_type' => Remote_Actors::POST_TYPE,
+ 'posts_per_page' => $batch_size,
+ 'offset' => $offset,
+ 'post_status' => 'any',
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ )
+ );
+
+ foreach ( $actors as $actor_post ) {
+ if ( empty( $actor_post->post_content ) ) {
+ continue;
+ }
+
+ $actor_data = \json_decode( $actor_post->post_content, true );
+ if ( ! $actor_data ) {
+ continue;
+ }
+
+ $emoji_meta = Emoji::prepare_actor_meta( $actor_data );
+ if ( ! empty( $emoji_meta['_activitypub_emoji'] ) ) {
+ \update_post_meta( $actor_post->ID, '_activitypub_emoji', $emoji_meta['_activitypub_emoji'] );
+ }
+ }
+
+ // Return batch info if there are more actors to process.
+ if ( count( $actors ) === $batch_size ) {
+ return array(
+ 'batch_size' => $batch_size,
+ 'offset' => $offset + $batch_size,
+ );
+ }
+
+ return null;
+ }
}
diff --git a/wp-content/plugins/activitypub/includes/class-moderation.php b/wp-content/plugins/activitypub/includes/class-moderation.php
new file mode 100644
index 00000000..b28164b0
--- /dev/null
+++ b/wp-content/plugins/activitypub/includes/class-moderation.php
@@ -0,0 +1,426 @@
+ 'activitypub_blocked_domains',
+ self::TYPE_KEYWORD => 'activitypub_blocked_keywords',
+ );
+
+ /**
+ * Option key for site-wide blocked keywords.
+ */
+ const OPTION_KEYS = array(
+ self::TYPE_DOMAIN => 'activitypub_site_blocked_domains',
+ self::TYPE_KEYWORD => 'activitypub_site_blocked_keywords',
+ );
+
+ /**
+ * Check if an activity should be blocked for a specific user.
+ *
+ * @param Activity $activity The activity.
+ * @param int|null $user_id The user ID to check blocks for.
+ * @return bool True if blocked, false otherwise.
+ */
+ public static function activity_is_blocked( $activity, $user_id = null ) {
+ if ( ! $activity instanceof Activity ) {
+ return false;
+ }
+
+ // First check site-wide blocks (admin moderation).
+ if ( self::activity_is_blocked_site_wide( $activity ) ) {
+ return true;
+ }
+
+ // Then check user-specific blocks.
+ if ( $user_id && self::activity_is_blocked_for_user( $activity, $user_id ) ) {
+ return true;
+ }
+
+ $remote_addr = \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
+ $user_agent = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
+
+ // Fall back to WordPress comment disallowed list.
+ return \wp_check_comment_disallowed_list( $activity->to_json( false ), '', '', $activity->get_content(), $remote_addr, $user_agent );
+ }
+
+ /**
+ * Check if an activity is blocked site-wide.
+ *
+ * @param Activity $activity The activity.
+ * @return bool True if blocked, false otherwise.
+ */
+ public static function activity_is_blocked_site_wide( $activity ) {
+ $blocks = self::get_site_blocks();
+
+ return self::check_activity_against_blocks( $activity, $blocks['actors'], $blocks['domains'], $blocks['keywords'] );
+ }
+
+ /**
+ * Check if an activity is blocked for a specific user.
+ *
+ * @param Activity $activity The activity.
+ * @param int $user_id The user ID.
+ * @return bool True if blocked, false otherwise.
+ */
+ public static function activity_is_blocked_for_user( $activity, $user_id ) {
+ $blocks = self::get_user_blocks( $user_id );
+
+ return self::check_activity_against_blocks( $activity, $blocks['actors'], $blocks['domains'], $blocks['keywords'] );
+ }
+
+ /**
+ * Add a block for a user.
+ *
+ * @param int $user_id The user ID.
+ * @param string $type The block type (actor, domain, keyword).
+ * @param string $value The value to block.
+ * @return bool True on success, false on failure.
+ */
+ public static function add_user_block( $user_id, $type, $value ) {
+ switch ( $type ) {
+ case self::TYPE_ACTOR:
+ return Blocked_Actors::add( $user_id, $value );
+
+ case self::TYPE_DOMAIN:
+ case self::TYPE_KEYWORD:
+ $blocks = \get_user_meta( $user_id, self::USER_META_KEYS[ $type ], true ) ?: array();
+
+ if ( ! \in_array( $value, $blocks, true ) ) {
+ /**
+ * Fired when a domain or keyword is blocked.
+ *
+ * @param string $value The blocked domain or keyword.
+ * @param string $type The block type (actor, domain, keyword).
+ * @param int $user_id The user ID.
+ */
+ \do_action( 'activitypub_add_user_block', $value, $type, $user_id );
+
+ $blocks[] = $value;
+ return (bool) \update_user_meta( $user_id, self::USER_META_KEYS[ $type ], $blocks );
+ }
+ break;
+ }
+
+ return true; // Already blocked.
+ }
+
+ /**
+ * Remove a block for a user.
+ *
+ * @param int $user_id The user ID.
+ * @param string $type The block type (actor, domain, keyword).
+ * @param string $value The value to unblock.
+ * @return bool True on success, false on failure.
+ */
+ public static function remove_user_block( $user_id, $type, $value ) {
+ switch ( $type ) {
+ case self::TYPE_ACTOR:
+ return Blocked_Actors::remove( $user_id, $value );
+
+ case self::TYPE_DOMAIN:
+ case self::TYPE_KEYWORD:
+ $blocks = \get_user_meta( $user_id, self::USER_META_KEYS[ $type ], true ) ?: array();
+ $key = \array_search( $value, $blocks, true );
+
+ if ( false !== $key ) {
+ /**
+ * Fired when a domain or keyword is unblocked.
+ *
+ * @param string $value The unblocked domain or keyword.
+ * @param string $type The block type (actor, domain, keyword).
+ * @param int $user_id The user ID.
+ */
+ \do_action( 'activitypub_remove_user_block', $value, $type, $user_id );
+
+ unset( $blocks[ $key ] );
+ return \update_user_meta( $user_id, self::USER_META_KEYS[ $type ], \array_values( $blocks ) );
+ }
+ break;
+ }
+
+ return true; // Not blocked anyway.
+ }
+
+ /**
+ * Get all blocks for a user.
+ *
+ * @param int $user_id The user ID.
+ * @return array Array of blocks organized by type.
+ */
+ public static function get_user_blocks( $user_id ) {
+ return array(
+ 'actors' => \wp_list_pluck( Blocked_Actors::get_many( $user_id ), 'guid' ),
+ 'domains' => \get_user_meta( $user_id, self::USER_META_KEYS[ self::TYPE_DOMAIN ], true ) ?: array(),
+ 'keywords' => \get_user_meta( $user_id, self::USER_META_KEYS[ self::TYPE_KEYWORD ], true ) ?: array(),
+ );
+ }
+
+ /**
+ * Add a site-wide block.
+ *
+ * @param string $type The block type (actor, domain, keyword).
+ * @param string $value The value to block.
+ * @return bool True on success, false on failure.
+ */
+ public static function add_site_block( $type, $value ) {
+ switch ( $type ) {
+ case self::TYPE_ACTOR:
+ // Site-wide actor blocking uses the BLOG_USER_ID.
+ return self::add_user_block( Actors::BLOG_USER_ID, self::TYPE_ACTOR, $value );
+
+ case self::TYPE_DOMAIN:
+ case self::TYPE_KEYWORD:
+ $blocks = \get_option( self::OPTION_KEYS[ $type ], array() );
+
+ if ( ! \in_array( $value, $blocks, true ) ) {
+ /**
+ * Fired when a domain or keyword is blocked site-wide.
+ *
+ * @param string $value The blocked domain or keyword.
+ * @param string $type The block type (actor, domain, keyword).
+ */
+ \do_action( 'activitypub_add_site_block', $value, $type );
+
+ $blocks[] = $value;
+ return \update_option( self::OPTION_KEYS[ $type ], $blocks );
+ }
+ break;
+ }
+
+ return true; // Already blocked.
+ }
+
+ /**
+ * Add multiple site-wide blocks at once.
+ *
+ * More efficient than calling add_site_block() in a loop as it
+ * performs a single database update.
+ *
+ * @param string $type The block type (domain or keyword only).
+ * @param array $values Array of values to block.
+ */
+ public static function add_site_blocks( $type, $values ) {
+ if ( ! in_array( $type, array( self::TYPE_DOMAIN, self::TYPE_KEYWORD ), true ) ) {
+ return;
+ }
+
+ if ( empty( $values ) ) {
+ return;
+ }
+
+ foreach ( $values as $value ) {
+ /**
+ * Fired when a domain or keyword is blocked site-wide.
+ *
+ * @param string $value The blocked domain or keyword.
+ * @param string $type The block type (actor, domain, keyword).
+ */
+ \do_action( 'activitypub_add_site_block', $value, $type );
+ }
+
+ $existing = \get_option( self::OPTION_KEYS[ $type ], array() );
+ \update_option( self::OPTION_KEYS[ $type ], array_unique( array_merge( $existing, $values ) ) );
+ }
+
+ /**
+ * Remove a site-wide block.
+ *
+ * @param string $type The block type (actor, domain, keyword).
+ * @param string $value The value to unblock.
+ * @return bool True on success, false on failure.
+ */
+ public static function remove_site_block( $type, $value ) {
+ switch ( $type ) {
+ case self::TYPE_ACTOR:
+ // Site-wide actor unblocking uses the BLOG_USER_ID.
+ return self::remove_user_block( Actors::BLOG_USER_ID, self::TYPE_ACTOR, $value );
+
+ case self::TYPE_DOMAIN:
+ case self::TYPE_KEYWORD:
+ $blocks = \get_option( self::OPTION_KEYS[ $type ], array() );
+ $key = \array_search( $value, $blocks, true );
+
+ if ( false !== $key ) {
+ /**
+ * Fired when a domain or keyword is unblocked site-wide.
+ *
+ * @param string $value The unblocked domain or keyword.
+ * @param string $type The block type (actor, domain, keyword).
+ */
+ \do_action( 'activitypub_remove_site_block', $value, $type );
+
+ unset( $blocks[ $key ] );
+ return \update_option( self::OPTION_KEYS[ $type ], \array_values( $blocks ) );
+ }
+ break;
+ }
+
+ return true; // Not blocked anyway.
+ }
+
+ /**
+ * Get all site-wide blocks.
+ *
+ * @return array Array of blocks organized by type.
+ */
+ public static function get_site_blocks() {
+ return array(
+ 'actors' => \wp_list_pluck( Blocked_Actors::get_many( Actors::BLOG_USER_ID ), 'guid' ),
+ 'domains' => \get_option( self::OPTION_KEYS[ self::TYPE_DOMAIN ], array() ),
+ 'keywords' => \get_option( self::OPTION_KEYS[ self::TYPE_KEYWORD ], array() ),
+ );
+ }
+
+ /**
+ * Check if an actor is blocked by user or site-wide.
+ *
+ * @param string $actor_uri Actor URI to check.
+ * @param int $user_id Optional. User ID to check user blocks for. Defaults to 0 (site-wide only).
+ * @return bool True if blocked, false otherwise.
+ */
+ public static function is_actor_blocked( $actor_uri, $user_id = 0 ) {
+ if ( ! $actor_uri ) {
+ return false;
+ }
+
+ // Check site-wide blocks.
+ $site_blocks = self::get_site_blocks();
+ if ( \in_array( $actor_uri, $site_blocks['actors'], true ) ) {
+ return true;
+ }
+
+ // Check site-wide domain blocks.
+ $actor_domain = \wp_parse_url( $actor_uri, PHP_URL_HOST );
+ if ( $actor_domain && \in_array( $actor_domain, $site_blocks['domains'], true ) ) {
+ return true;
+ }
+
+ // Check user-specific blocks if user_id is provided.
+ if ( $user_id > 0 ) {
+ $user_blocks = self::get_user_blocks( $user_id );
+ if ( \in_array( $actor_uri, $user_blocks['actors'], true ) ) {
+ return true;
+ }
+
+ // Check user-specific domain blocks.
+ if ( $actor_domain && \in_array( $actor_domain, $user_blocks['domains'], true ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check activity against blocklists.
+ *
+ * @param Activity $activity The activity.
+ * @param array $blocked_actors List of blocked actors.
+ * @param array $blocked_domains List of blocked domains.
+ * @param array $blocked_keywords List of blocked keywords.
+ * @return bool True if blocked, false otherwise.
+ */
+ private static function check_activity_against_blocks( $activity, $blocked_actors, $blocked_domains, $blocked_keywords ) {
+ $has_object = \is_object( $activity->get_object() );
+
+ // Extract actor information.
+ $actor_id = object_to_uri( $activity->get_actor() );
+
+ // Check blocked actors.
+ if ( $actor_id ) {
+ // If actor_id is not a URL, resolve it via webfinger.
+ if ( ! \str_starts_with( $actor_id, 'http' ) ) {
+ $resolved_url = Webfinger::resolve( $actor_id );
+ if ( ! \is_wp_error( $resolved_url ) ) {
+ $actor_id = $resolved_url;
+ }
+ }
+
+ if ( \in_array( $actor_id, $blocked_actors, true ) ) {
+ return true;
+ }
+ }
+
+ // Check blocked domains.
+ $urls = array(
+ \wp_parse_url( $actor_id, PHP_URL_HOST ),
+ \wp_parse_url( $activity->get_id(), PHP_URL_HOST ),
+ \wp_parse_url( object_to_uri( $activity->get_object() ) ?? '', PHP_URL_HOST ),
+ );
+ foreach ( $blocked_domains as $domain ) {
+ if ( \in_array( $domain, $urls, true ) ) {
+ return true;
+ }
+ }
+
+ // Check blocked keywords in activity content.
+ if ( $has_object ) {
+ $object = $activity->get_object();
+ $content_map = array();
+ $content_map[] = $object->get_content();
+ $content_map[] = $object->get_summary();
+ $content_map[] = $object->get_name();
+
+ if ( is_actor( $object ) ) {
+ /* @var Actor $object Actor object */
+ $content_map[] = $object->get_preferred_username();
+ }
+
+ if ( \is_array( $object->get_content_map() ) ) {
+ $content_map = \array_merge( $content_map, \array_values( $object->get_content_map() ) );
+ }
+
+ if ( \is_array( $object->get_summary_map() ) ) {
+ $content_map = \array_merge( $content_map, \array_values( $object->get_summary_map() ) );
+ }
+
+ if ( \is_array( $object->get_name_map() ) ) {
+ $content_map = \array_merge( $content_map, \array_values( $object->get_name_map() ) );
+ }
+
+ $content_map = \array_filter( $content_map );
+ $content = \implode( ' ', $content_map );
+
+ foreach ( $blocked_keywords as $keyword ) {
+ if ( \stripos( $content, $keyword ) !== false ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/wp-content/plugins/activitypub/includes/class-move.php b/wp-content/plugins/activitypub/includes/class-move.php
index 18e54ad4..4f553442 100644
--- a/wp-content/plugins/activitypub/includes/class-move.php
+++ b/wp-content/plugins/activitypub/includes/class-move.php
@@ -7,8 +7,8 @@
namespace Activitypub;
-use Activitypub\Activity\Actor;
use Activitypub\Activity\Activity;
+use Activitypub\Activity\Actor;
use Activitypub\Collection\Actors;
use Activitypub\Model\Blog;
use Activitypub\Model\User;
@@ -65,7 +65,7 @@ class Move {
/**
* Move an ActivityPub Actor from one location (internal) to another (external).
*
- * This helps migrating local profiles to a new external profile:
+ * This helps with migrating local profiles to a new external profile:
*
* `Move::externally( 'https://example.com/?author=123', 'https://mastodon.example/users/foo' );`
*
@@ -119,7 +119,7 @@ class Move {
*
* Move an ActivityPub Actor from one location (internal) to another (internal).
*
- * This helps migrating abandoned profiles to `Move` to other profiles:
+ * This helps with migrating abandoned profiles to `Move` to other profiles:
*
* `Move::internally( 'https://example.com/?author=123', 'https://example.com/?author=321' );`
*
@@ -172,7 +172,6 @@ class Move {
* @param string $from The current account URL.
*/
private static function update_user_also_known_as( $user_id, $from ) {
- // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$also_known_as = \get_user_option( 'activitypub_also_known_as', $user_id ) ?: array();
$also_known_as[] = $from;
@@ -225,8 +224,15 @@ class Move {
$result = self::internally( $old_actor_id, $actor_id );
if ( \is_wp_error( $result ) ) {
- // Log the error and continue with the next actor.
- Debug::write_log( 'Error moving actor: ' . $actor_id . ' - ' . $result->get_error_message() );
+ /**
+ * Fires when an actor move fails during domain change.
+ *
+ * @since 8.1.0
+ *
+ * @param \WP_Error $result The error that occurred.
+ * @param string $actor_id The actor ID that failed to move.
+ */
+ \do_action( 'activitypub_move_failed', $result, $actor_id );
continue;
}
@@ -236,7 +242,7 @@ class Move {
if ( $actor instanceof Blog ) {
\update_option( 'activitypub_blog_user_old_host_data', $json, false );
} else {
- \update_user_option( $actor->get__id(), 'activitypub_old_host_data', $json, false );
+ \update_user_option( $actor->get__id(), 'activitypub_old_host_data', $json );
}
$results[] = array(
diff --git a/wp-content/plugins/activitypub/includes/class-notification.php b/wp-content/plugins/activitypub/includes/class-notification.php
index 68283133..9b22d4bc 100644
--- a/wp-content/plugins/activitypub/includes/class-notification.php
+++ b/wp-content/plugins/activitypub/includes/class-notification.php
@@ -9,6 +9,8 @@ namespace Activitypub;
/**
* Notification class.
+ *
+ * @deprecated 7.5.0 Use action hooks like 'activitypub_handled_{type}' instead.
*/
class Notification {
/**
@@ -48,6 +50,8 @@ class Notification {
* @param int $target The WordPress User-Id.
*/
public function __construct( $type, $actor, $activity, $target ) {
+ \_deprecated_class( __CLASS__, '7.5.0', 'Use action hooks like "activitypub_handled_{type}" instead.' );
+
$this->type = $type;
$this->actor = $actor;
$this->object = $activity;
@@ -63,15 +67,19 @@ class Notification {
/**
* Action to send ActivityPub notifications.
*
+ * @deprecated 7.5.0 Use "activitypub_handled_{$type}" instead.
+ *
* @param Notification $instance The notification object.
*/
- do_action( 'activitypub_notification', $this );
+ \do_action_deprecated( 'activitypub_notification', array( $this ), '7.5.0', "activitypub_handled_{$type}" );
/**
* Type-specific action to send ActivityPub notifications.
*
+ * @deprecated 7.5.0 Use "activitypub_handled_{$type}" instead.
+ *
* @param Notification $instance The notification object.
*/
- do_action( "activitypub_notification_{$type}", $this );
+ \do_action_deprecated( "activitypub_notification_{$type}", array( $this ), '7.5.0', "activitypub_handled_{$type}" );
}
}
diff --git a/wp-content/plugins/activitypub/includes/class-options.php b/wp-content/plugins/activitypub/includes/class-options.php
index d30ef9ad..083dc60e 100644
--- a/wp-content/plugins/activitypub/includes/class-options.php
+++ b/wp-content/plugins/activitypub/includes/class-options.php
@@ -2,15 +2,15 @@
/**
* Options file.
*
- * @package ActivityPub
+ * @package Activitypub
*/
-namespace ActivityPub;
+namespace Activitypub;
+
+use Activitypub\Model\Blog;
/**
* Options class.
- *
- * @package ActivityPub
*/
class Options {
@@ -18,15 +18,478 @@ class Options {
* Initialize the options.
*/
public static function init() {
+ \add_action( 'admin_init', array( self::class, 'register_settings' ) );
+ \add_action( 'rest_api_init', array( self::class, 'register_settings' ) );
+
\add_filter( 'pre_option_activitypub_actor_mode', array( self::class, 'pre_option_activitypub_actor_mode' ) );
\add_filter( 'pre_option_activitypub_authorized_fetch', array( self::class, 'pre_option_activitypub_authorized_fetch' ) );
- \add_filter( 'pre_option_activitypub_shared_inbox', array( self::class, 'pre_option_activitypub_shared_inbox' ) );
\add_filter( 'pre_option_activitypub_vary_header', array( self::class, 'pre_option_activitypub_vary_header' ) );
+ \add_filter( 'pre_option_activitypub_following_ui', array( self::class, 'pre_option_activitypub_following_ui' ) );
+ \add_filter( 'pre_option_activitypub_create_posts', array( self::class, 'pre_option_activitypub_create_posts' ) );
\add_filter( 'pre_option_activitypub_allow_likes', array( self::class, 'maybe_disable_interactions' ) );
\add_filter( 'pre_option_activitypub_allow_replies', array( self::class, 'maybe_disable_interactions' ) );
+
+ \add_filter( 'default_option_activitypub_negotiate_content', array( self::class, 'default_option_activitypub_negotiate_content' ) );
+ \add_filter( 'option_activitypub_max_image_attachments', array( self::class, 'default_max_image_attachments' ) );
+ \add_filter( 'option_activitypub_support_post_types', array( self::class, 'support_post_types_ensure_array' ) );
+ \add_filter( 'option_activitypub_object_type', array( self::class, 'default_object_type' ) );
+
+ \add_filter( 'option_activitypub_outbox_purge_days', array( self::class, 'sanitize_purge_days' ) );
+ \add_filter( 'option_activitypub_inbox_purge_days', array( self::class, 'sanitize_purge_days' ) );
+ \add_filter( 'option_activitypub_ap_post_purge_days', array( self::class, 'sanitize_purge_days' ) );
+
+ \add_action( 'update_option_activitypub_relay_mode', array( self::class, 'relay_mode_changed' ), 10, 2 );
}
+ /**
+ * Register ActivityPub settings.
+ */
+ public static function register_settings() {
+ /*
+ * Options Group: activitypub
+ */
+ \register_setting(
+ 'activitypub',
+ 'activitypub_post_content_type',
+ array(
+ 'type' => 'string',
+ 'description' => 'Use title and link, summary, full or custom content',
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'enum' => array( 'title', 'excerpt', 'content' ),
+ ),
+ ),
+ 'default' => 'content',
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_custom_post_content',
+ array(
+ 'type' => 'string',
+ 'description' => 'Define your own custom post template',
+ 'show_in_rest' => true,
+ 'default' => ACTIVITYPUB_CUSTOM_POST_CONTENT,
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_max_image_attachments',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Number of images to attach to posts.',
+ 'default' => ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS,
+ 'sanitize_callback' => static function ( $value ) {
+ return \is_numeric( $value ) ? \absint( $value ) : ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS;
+ },
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_use_hashtags',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Add hashtags in the content as native tags and replace the #tag with the tag-link',
+ 'default' => '0',
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_use_opengraph',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Automatically add "fediverse:creator" OpenGraph tags for Authors and the Blog-User.',
+ 'default' => '1',
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_support_post_types',
+ array(
+ 'type' => 'string',
+ 'description' => 'Enable ActivityPub support for post types',
+ 'show_in_rest' => true,
+ 'default' => array( 'post' ),
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_actor_mode',
+ array(
+ 'type' => 'string',
+ 'description' => 'Choose your preferred Actor-Mode.',
+ 'default' => ACTIVITYPUB_ACTOR_MODE,
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'type' => 'string',
+ 'enum' => array(
+ ACTIVITYPUB_ACTOR_MODE,
+ ACTIVITYPUB_BLOG_MODE,
+ ACTIVITYPUB_ACTOR_AND_BLOG_MODE,
+ ),
+ ),
+ ),
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_attribution_domains',
+ array(
+ 'type' => 'string',
+ 'description' => 'Websites allowed to credit you.',
+ 'default' => home_host(),
+ 'sanitize_callback' => array( Sanitize::class, 'host_list' ),
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_allow_likes',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Allow likes.',
+ 'default' => '1',
+ 'sanitize_callback' => 'absint',
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_allow_reposts',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Allow reposts.',
+ 'default' => '1',
+ 'sanitize_callback' => 'absint',
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_auto_approve_reactions',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Auto-approve Reactions.',
+ 'default' => '0',
+ 'sanitize_callback' => 'absint',
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_default_quote_policy',
+ array(
+ 'type' => 'string',
+ 'description' => 'Default quote policy for new posts.',
+ 'default' => ACTIVITYPUB_INTERACTION_POLICY_ANYONE,
+ 'sanitize_callback' => static function ( $value ) {
+ $allowed = array(
+ ACTIVITYPUB_INTERACTION_POLICY_ANYONE,
+ ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS,
+ ACTIVITYPUB_INTERACTION_POLICY_ME,
+ );
+ return \in_array( $value, $allowed, true ) ? $value : ACTIVITYPUB_INTERACTION_POLICY_ANYONE;
+ },
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_relays',
+ array(
+ 'type' => 'array',
+ 'description' => 'Relays',
+ 'default' => array(),
+ 'sanitize_callback' => array( Sanitize::class, 'url_list' ),
+ )
+ );
+
+ \register_setting(
+ 'activitypub',
+ 'activitypub_site_blocked_actors',
+ array(
+ 'type' => 'array',
+ 'description' => 'Site-wide blocked ActivityPub actors.',
+ 'default' => array(),
+ 'sanitize_callback' => array( Sanitize::class, 'identifier_list' ),
+ )
+ );
+
+ /*
+ * Options Group: activitypub_advanced
+ */
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_outbox_purge_days',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Number of days to keep items in the Outbox.',
+ 'default' => ACTIVITYPUB_OUTBOX_PURGE_DAYS,
+ 'sanitize_callback' => static function ( $value ) {
+ return \max( 1, \absint( $value ) );
+ },
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_inbox_purge_days',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Number of days to keep items in the Inbox.',
+ 'default' => ACTIVITYPUB_INBOX_PURGE_DAYS,
+ 'sanitize_callback' => static function ( $value ) {
+ return \max( 1, \absint( $value ) );
+ },
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_ap_post_purge_days',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Number of days to keep remote posts.',
+ 'default' => ACTIVITYPUB_AP_POST_PURGE_DAYS,
+ 'sanitize_callback' => static function ( $value ) {
+ return \max( 1, \absint( $value ) );
+ },
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_vary_header',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Add the Vary header to the ActivityPub response.',
+ 'default' => true,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_content_negotiation',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Enable content negotiation.',
+ 'default' => true,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_authorized_fetch',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Require HTTP signature authentication.',
+ 'default' => false,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_rfc9421_signature',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Use RFC-9421 signature.',
+ 'default' => false,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_following_ui',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Show Following UI in admin menus and settings.',
+ 'default' => false,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_reader_ui',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Enable the Reader to view posts from accounts you follow.',
+ 'default' => false,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_create_posts',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Allow creating posts via ActivityPub.',
+ 'default' => false,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_api',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Enable the ActivityPub API to allow third-party clients.',
+ 'default' => false,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_object_type',
+ array(
+ 'type' => 'string',
+ 'description' => 'The Activity-Object-Type',
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'enum' => array( 'note', 'wordpress-post-format' ),
+ ),
+ ),
+ 'default' => ACTIVITYPUB_DEFAULT_OBJECT_TYPE,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_relay_mode',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Enable relay mode to forward public activities to all followers.',
+ 'default' => 0,
+ 'sanitize_callback' => 'absint',
+ )
+ );
+
+ /*
+ * Options Group: activitypub_blog
+ */
+ \register_setting(
+ 'activitypub_blog',
+ 'activitypub_blog_description',
+ array(
+ 'type' => 'string',
+ 'description' => 'The Description of the Blog-User',
+ 'show_in_rest' => true,
+ 'default' => '',
+ )
+ );
+
+ \register_setting(
+ 'activitypub_blog',
+ 'activitypub_blog_identifier',
+ array(
+ 'type' => 'string',
+ 'description' => 'The Identifier of the Blog-User',
+ 'show_in_rest' => true,
+ 'default' => Blog::get_default_username(),
+ 'sanitize_callback' => array( Sanitize::class, 'blog_identifier' ),
+ )
+ );
+
+ \register_setting(
+ 'activitypub_blog',
+ 'activitypub_header_image',
+ array(
+ 'type' => 'integer',
+ 'description' => 'The Attachment-ID of the Sites Header-Image',
+ 'default' => null,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_blog',
+ 'activitypub_blog_user_mailer_new_dm',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Send a notification when someone sends a user of the blog a direct message.',
+ 'default' => 1,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_blog',
+ 'activitypub_blog_user_mailer_new_follower',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Send a notification when someone starts to follow a user of the blog.',
+ 'default' => 1,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_blog',
+ 'activitypub_blog_user_mailer_new_mention',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Send a notification when someone mentions a user of the blog.',
+ 'default' => 1,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_blog',
+ 'activitypub_mailer_annual_report',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Send the annual Fediverse Year in Review email.',
+ 'default' => 1,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_blog',
+ 'activitypub_mailer_monthly_report',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Send a monthly Fediverse stats report email.',
+ 'default' => 0,
+ )
+ );
+
+ \register_setting(
+ 'activitypub_blog',
+ 'activitypub_blog_user_also_known_as',
+ array(
+ 'type' => 'array',
+ 'description' => 'An array of URLs that the blog user is known by.',
+ 'default' => array(),
+ 'sanitize_callback' => array( Sanitize::class, 'identifier_list' ),
+ )
+ );
+
+ \register_setting(
+ 'activitypub_blog',
+ 'activitypub_hide_social_graph',
+ array(
+ 'type' => 'integer',
+ 'description' => 'Hide Followers and Followings on Profile.',
+ 'default' => 0,
+ 'sanitize_callback' => 'absint',
+ 'show_in_rest' => true,
+ )
+ );
+ }
+
+ /**
+ * Delete all options.
+ */
+ public static function delete() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->query( "DELETE FROM $wpdb->options WHERE option_name LIKE 'activitypub_%'" );
+ }
/**
* Pre-get option filter for the Actor-Mode.
@@ -70,25 +533,6 @@ class Options {
return '0';
}
- /**
- * Pre-get option filter for the Shared Inbox.
- *
- * @param string $pre The pre-get option value.
- *
- * @return string If the constant is defined, return the value, otherwise return the pre-get option value.
- */
- public static function pre_option_activitypub_shared_inbox( $pre ) {
- if ( ! \defined( 'ACTIVITYPUB_SHARED_INBOX_FEATURE' ) ) {
- return $pre;
- }
-
- if ( ACTIVITYPUB_SHARED_INBOX_FEATURE ) {
- return '1';
- }
-
- return '0';
- }
-
/**
* Pre-get option filter for the Vary Header.
*
@@ -108,17 +552,190 @@ class Options {
return '0';
}
+ /**
+ * Pre-get option filter for the Following UI.
+ *
+ * Forces the Following UI to be enabled when the Reader is enabled.
+ *
+ * @param string $pre The pre-get option value.
+ *
+ * @return string If the Reader is enabled, return '1', otherwise return the pre-get option value.
+ */
+ public static function pre_option_activitypub_following_ui( $pre ) {
+ /*
+ * Bypass the filter to get the actual stored value for activitypub_reader_ui.
+ * This avoids infinite loops if activitypub_reader_ui also had a pre_option filter.
+ */
+ if ( \get_option( 'activitypub_reader_ui', '0' ) ) {
+ return '1';
+ }
+
+ return $pre;
+ }
+
+ /**
+ * Pre-get option filter for the Create Posts setting.
+ *
+ * Forces the Create Posts setting to be enabled when the Reader is enabled.
+ *
+ * @param string $pre The pre-get option value.
+ *
+ * @return string If the Reader is enabled, return '1', otherwise return the pre-get option value.
+ */
+ public static function pre_option_activitypub_create_posts( $pre ) {
+ if ( \get_option( 'activitypub_reader_ui', '0' ) ) {
+ return '1';
+ }
+
+ return $pre;
+ }
+
/**
* Disallow interactions if the constant is set.
*
- * @param bool $pre_option The value of the option.
+ * @param bool $pre The value of the option.
+ *
* @return bool|string The value of the option.
*/
- public static function maybe_disable_interactions( $pre_option ) {
+ public static function maybe_disable_interactions( $pre ) {
if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
return '0';
}
- return $pre_option;
+ return $pre;
+ }
+
+ /**
+ * Default option filter for the Content-Negotiation.
+ *
+ * @see https://github.com/Automattic/wordpress-activitypub/wiki/Caching
+ *
+ * @param string $default_value The default value of the option.
+ *
+ * @return string The default value of the option.
+ */
+ public static function default_option_activitypub_negotiate_content( $default_value ) {
+ $disable_for_plugins = array(
+ 'wp-optimize/wp-optimize.php',
+ 'wp-rocket/wp-rocket.php',
+ 'w3-total-cache/w3-total-cache.php',
+ 'wp-fastest-cache/wp-fastest-cache.php',
+ 'sg-cachepress/sg-cachepress.php',
+ );
+
+ foreach ( $disable_for_plugins as $plugin ) {
+ if ( \is_plugin_active( $plugin ) ) {
+ return '0';
+ }
+ }
+
+ return $default_value;
+ }
+
+ /**
+ * Default max image attachments.
+ *
+ * @param string $value The value of the option.
+ *
+ * @return string|int The value of the option.
+ */
+ public static function default_max_image_attachments( $value ) {
+ if ( ! \is_numeric( $value ) ) {
+ $value = ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Ensure support post types is an array.
+ *
+ * @param string[] $value The value of the option.
+ *
+ * @return string[] The value of the option.
+ */
+ public static function support_post_types_ensure_array( $value ) {
+ return (array) $value;
+ }
+
+ /**
+ * Default object type.
+ *
+ * @param string $value The value of the option.
+ *
+ * @return string The value of the option.
+ */
+ public static function default_object_type( $value ) {
+ if ( ! $value ) {
+ $value = ACTIVITYPUB_DEFAULT_OBJECT_TYPE;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Sanitize purge day values.
+ *
+ * Ensures the value is a non-negative integer. Returns the
+ * registered default when the stored value is empty or false
+ * (option not properly set), but allows 0 to disable purging.
+ *
+ * @since 8.1.0
+ *
+ * @param mixed $value The stored option value.
+ *
+ * @return int The sanitized value.
+ */
+ public static function sanitize_purge_days( $value ) {
+ if ( '' === $value || false === $value ) {
+ $filter = \current_filter();
+ $defaults = array(
+ 'option_activitypub_outbox_purge_days' => ACTIVITYPUB_OUTBOX_PURGE_DAYS,
+ 'option_activitypub_inbox_purge_days' => ACTIVITYPUB_INBOX_PURGE_DAYS,
+ 'option_activitypub_ap_post_purge_days' => ACTIVITYPUB_AP_POST_PURGE_DAYS,
+ );
+
+ return $defaults[ $filter ] ?? ACTIVITYPUB_OUTBOX_PURGE_DAYS;
+ }
+
+ return \max( 1, \absint( $value ) );
+ }
+
+ /**
+ * Handle relay mode option changes.
+ *
+ * When relay mode is enabled, switch to blog-only mode and set username to "relay".
+ * When disabled, restore previous settings.
+ *
+ * @param mixed $old_value The old option value.
+ * @param mixed $new_value The new option value.
+ */
+ public static function relay_mode_changed( $old_value, $new_value ) {
+ if ( $new_value && ! $old_value ) {
+ // Enabling relay mode.
+ // Store previous username and actor mode for restoration.
+ \update_option( 'activitypub_relay_previous_blog_identifier', \get_option( 'activitypub_blog_identifier' ) );
+ \update_option( 'activitypub_relay_previous_actor_mode', \get_option( 'activitypub_actor_mode' ) );
+
+ // Set blog username to "relay".
+ \update_option( 'activitypub_blog_identifier', 'relay' );
+
+ // Switch to blog-only mode.
+ \update_option( 'activitypub_actor_mode', ACTIVITYPUB_BLOG_MODE );
+ } elseif ( ! $new_value && $old_value ) {
+ // Disabling relay mode - restore previous settings.
+ $previous_identifier = \get_option( 'activitypub_relay_previous_blog_identifier' );
+ $previous_actor_mode = \get_option( 'activitypub_relay_previous_actor_mode' );
+
+ if ( $previous_identifier ) {
+ \update_option( 'activitypub_blog_identifier', $previous_identifier );
+ \delete_option( 'activitypub_relay_previous_blog_identifier' );
+ }
+
+ if ( $previous_actor_mode ) {
+ \update_option( 'activitypub_actor_mode', $previous_actor_mode );
+ \delete_option( 'activitypub_relay_previous_actor_mode' );
+ }
+ }
}
}
diff --git a/wp-content/plugins/activitypub/includes/class-post-types.php b/wp-content/plugins/activitypub/includes/class-post-types.php
new file mode 100644
index 00000000..88a69f74
--- /dev/null
+++ b/wp-content/plugins/activitypub/includes/class-post-types.php
@@ -0,0 +1,1022 @@
+ array(
+ 'name' => \_x( 'Followers', 'post_type plural name', 'activitypub' ),
+ 'singular_name' => \_x( 'Follower', 'post_type single name', 'activitypub' ),
+ ),
+ 'public' => false,
+ 'show_in_rest' => true,
+ 'hierarchical' => false,
+ 'rewrite' => false,
+ 'query_var' => false,
+ 'delete_with_user' => false,
+ 'can_export' => true,
+ 'supports' => array( 'custom-fields' ),
+ )
+ );
+
+ // Register meta for Remote Actors post type.
+ \register_post_meta(
+ Remote_Actors::POST_TYPE,
+ '_activitypub_inbox',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'sanitize_callback' => 'sanitize_url',
+ )
+ );
+
+ \register_post_meta(
+ Remote_Actors::POST_TYPE,
+ '_activitypub_errors',
+ array(
+ 'type' => 'string',
+ 'single' => false,
+ 'sanitize_callback' => 'sanitize_text_field',
+ )
+ );
+
+ \register_post_meta(
+ Remote_Actors::POST_TYPE,
+ Followers::FOLLOWER_META_KEY,
+ array(
+ 'type' => 'string',
+ 'single' => false,
+ 'show_in_rest' => true,
+ 'sanitize_callback' => 'sanitize_text_field',
+ )
+ );
+ }
+
+ /**
+ * Register the Inbox post type and its meta.
+ */
+ public static function register_inbox_post_type() {
+ \register_post_type(
+ Inbox::POST_TYPE,
+ array(
+ 'labels' => array(
+ 'name' => \_x( 'Inbox', 'post_type plural name', 'activitypub' ),
+ 'singular_name' => \_x( 'Inbox Item', 'post_type single name', 'activitypub' ),
+ ),
+ 'capabilities' => array(
+ 'create_posts' => false,
+ ),
+ 'map_meta_cap' => true,
+ 'public' => false,
+ 'show_in_rest' => false,
+ 'rewrite' => false,
+ 'query_var' => false,
+ 'supports' => array( 'title', 'editor', 'author', 'custom-fields' ),
+ 'delete_with_user' => true,
+ 'can_export' => true,
+ 'exclude_from_search' => true,
+ )
+ );
+
+ // Register meta for Inbox post type.
+ \register_post_meta(
+ Inbox::POST_TYPE,
+ '_activitypub_object_id',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'description' => 'The ID (ActivityPub URI) of the object that the inbox item is about.',
+ 'sanitize_callback' => 'sanitize_url',
+ )
+ );
+
+ \register_post_meta(
+ Inbox::POST_TYPE,
+ '_activitypub_activity_type',
+ array(
+ 'type' => 'string',
+ 'description' => 'The type of the activity',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'sanitize_callback' => static function ( $value ) {
+ $schema = array(
+ 'type' => 'string',
+ 'enum' => Activity::TYPES,
+ 'default' => 'Create',
+ );
+
+ if ( \is_wp_error( \rest_validate_enum( $value, $schema, '' ) ) ) {
+ return $schema['default'];
+ }
+
+ return $value;
+ },
+ )
+ );
+
+ \register_post_meta(
+ Inbox::POST_TYPE,
+ '_activitypub_activity_remote_actor',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'description' => 'The ID (ActivityPub URI) of the remote actor that sent the activity.',
+ 'sanitize_callback' => 'sanitize_url',
+ )
+ );
+
+ \register_post_meta(
+ Inbox::POST_TYPE,
+ 'activitypub_content_visibility',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'sanitize_callback' => static function ( $value ) {
+ $schema = array(
+ 'type' => 'string',
+ 'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ),
+ 'default' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
+ );
+
+ if ( \is_wp_error( \rest_validate_enum( $value, $schema, '' ) ) ) {
+ return $schema['default'];
+ }
+
+ return $value;
+ },
+ )
+ );
+
+ \register_post_meta(
+ Inbox::POST_TYPE,
+ '_activitypub_user_id',
+ array(
+ 'type' => 'integer',
+ 'single' => false, // Allow multiple values - one per recipient.
+ 'description' => 'User ID of a recipient of this activity. Multiple entries allowed.',
+ 'sanitize_callback' => 'absint',
+ 'show_in_rest' => true,
+ )
+ );
+ }
+
+ /**
+ * Register the Outbox post type and its meta.
+ */
+ public static function register_outbox_post_type() {
+ \register_post_type(
+ Outbox::POST_TYPE,
+ array(
+ 'labels' => array(
+ 'name' => \_x( 'Outbox', 'post_type plural name', 'activitypub' ),
+ 'singular_name' => \_x( 'Outbox Item', 'post_type single name', 'activitypub' ),
+ ),
+ 'capabilities' => array(
+ 'create_posts' => false,
+ ),
+ 'map_meta_cap' => true,
+ 'public' => false,
+ 'show_in_rest' => false,
+ 'rewrite' => false,
+ 'query_var' => false,
+ 'supports' => array( 'title', 'editor', 'author', 'custom-fields' ),
+ 'delete_with_user' => true,
+ 'can_export' => true,
+ 'exclude_from_search' => true,
+ )
+ );
+
+ // Register meta for Outbox post type.
+ /**
+ * Register Activity Type meta for Outbox items.
+ *
+ * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
+ */
+ \register_post_meta(
+ Outbox::POST_TYPE,
+ '_activitypub_activity_type',
+ array(
+ 'type' => 'string',
+ 'description' => 'The type of the activity',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'sanitize_callback' => static function ( $value ) {
+ $schema = array(
+ 'type' => 'string',
+ 'enum' => Activity::TYPES,
+ 'default' => 'Announce',
+ );
+
+ if ( \is_wp_error( \rest_validate_enum( $value, $schema, '' ) ) ) {
+ return $schema['default'];
+ }
+
+ return $value;
+ },
+ )
+ );
+
+ \register_post_meta(
+ Outbox::POST_TYPE,
+ '_activitypub_activity_actor',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'sanitize_callback' => static function ( $value ) {
+ $schema = array(
+ 'type' => 'string',
+ 'enum' => array( 'application', 'blog', 'user' ),
+ 'default' => 'user',
+ );
+
+ if ( \is_wp_error( \rest_validate_enum( $value, $schema, '' ) ) ) {
+ return $schema['default'];
+ }
+
+ return $value;
+ },
+ )
+ );
+
+ \register_post_meta(
+ Outbox::POST_TYPE,
+ '_activitypub_outbox_offset',
+ array(
+ 'type' => 'integer',
+ 'single' => true,
+ 'description' => 'Keeps track of the followers offset when processing outbox items.',
+ 'sanitize_callback' => 'absint',
+ 'default' => 0,
+ )
+ );
+
+ \register_post_meta(
+ Outbox::POST_TYPE,
+ '_activitypub_object_id',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'description' => 'The ID (ActivityPub URI) of the object that the outbox item is about.',
+ 'sanitize_callback' => 'sanitize_url',
+ )
+ );
+
+ \register_post_meta(
+ Outbox::POST_TYPE,
+ 'activitypub_content_visibility',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'sanitize_callback' => static function ( $value ) {
+ $schema = array(
+ 'type' => 'string',
+ 'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ),
+ 'default' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
+ );
+
+ if ( \is_wp_error( \rest_validate_enum( $value, $schema, '' ) ) ) {
+ return $schema['default'];
+ }
+
+ return $value;
+ },
+ )
+ );
+ }
+
+ /**
+ * Register the Post post type.
+ */
+ public static function register_post_post_type() {
+ \register_post_type(
+ Remote_Posts::POST_TYPE,
+ array(
+ 'labels' => array(
+ 'name' => \_x( 'Posts', 'post_type plural name', 'activitypub' ),
+ 'singular_name' => \_x( 'Post', 'post_type single name', 'activitypub' ),
+ ),
+ 'capabilities' => array(
+ 'activitypub' => true,
+ ),
+ 'map_meta_cap' => true,
+ 'public' => false,
+ 'show_in_rest' => true,
+ 'rewrite' => false,
+ 'query_var' => false,
+ 'supports' => array( 'title', 'editor', 'author', 'custom-fields', 'excerpt', 'comments' ),
+ 'delete_with_user' => true,
+ 'can_export' => true,
+ 'exclude_from_search' => true,
+ 'taxonomies' => array( 'ap_tag', 'ap_object_type' ),
+ )
+ );
+
+ \register_taxonomy(
+ 'ap_tag',
+ array( Remote_Posts::POST_TYPE ),
+ array(
+ 'public' => false,
+ 'query_var' => true,
+ 'show_in_rest' => true,
+ )
+ );
+
+ \register_taxonomy(
+ 'ap_object_type',
+ array( Remote_Posts::POST_TYPE ),
+ array(
+ 'public' => false,
+ 'query_var' => true,
+ 'show_in_rest' => true,
+ )
+ );
+
+ \register_post_meta(
+ Remote_Posts::POST_TYPE,
+ '_activitypub_remote_actor_id',
+ array(
+ 'type' => 'integer',
+ 'single' => true,
+ 'description' => 'The local ID of the remote actor that created the object.',
+ 'sanitize_callback' => 'absint',
+ )
+ );
+
+ \register_post_meta(
+ Remote_Posts::POST_TYPE,
+ '_activitypub_user_id',
+ array(
+ 'type' => 'integer',
+ 'single' => true,
+ 'description' => 'The ID of the local user that received the activity.',
+ 'sanitize_callback' => 'absint',
+ )
+ );
+ }
+
+ /**
+ * Register the Extra Fields post types.
+ */
+ public static function register_extra_fields_post_types() {
+ $extra_field_args = array(
+ 'labels' => array(
+ 'name' => \_x( 'Extra fields', 'post_type plural name', 'activitypub' ),
+ 'singular_name' => \_x( 'Extra field', 'post_type single name', 'activitypub' ),
+ 'add_new' => \__( 'Add new', 'activitypub' ),
+ 'add_new_item' => \__( 'Add new extra field', 'activitypub' ),
+ 'new_item' => \__( 'New extra field', 'activitypub' ),
+ 'edit_item' => \__( 'Edit extra field', 'activitypub' ),
+ 'view_item' => \__( 'View extra field', 'activitypub' ),
+ 'all_items' => \__( 'All extra fields', 'activitypub' ),
+ ),
+ 'public' => false,
+ 'hierarchical' => false,
+ 'query_var' => false,
+ 'has_archive' => false,
+ 'publicly_queryable' => false,
+ 'show_in_menu' => false,
+ 'delete_with_user' => true,
+ 'can_export' => true,
+ 'exclude_from_search' => true,
+ 'show_in_rest' => true,
+ 'map_meta_cap' => true,
+ 'show_ui' => true,
+ 'supports' => array( 'title', 'editor', 'page-attributes', 'author' ),
+ 'capabilities' => array(
+ 'create_posts' => 'activitypub', // Require activitypub capability to create extra fields.
+ 'edit_others_posts' => 'do_not_allow', // Disallow editing others' Extra Fields (only own ones).
+ ),
+ );
+
+ \register_post_type( Extra_Fields::USER_POST_TYPE, $extra_field_args );
+
+ // Blog Extra Fields require manage_options capability.
+ $extra_field_args['capabilities'] = array( 'create_posts' => 'manage_options' );
+ \register_post_type( Extra_Fields::BLOG_POST_TYPE, $extra_field_args );
+
+ /**
+ * Fires after ActivityPub custom post types have been registered.
+ */
+ \do_action( 'activitypub_after_register_post_type' );
+ }
+
+ /**
+ * Register OAuth 2.0 post types for C2S support.
+ *
+ * Registers post type for OAuth clients.
+ * Note: Tokens are stored in user meta and authorization codes in transients.
+ */
+ public static function register_oauth_post_types() {
+ // OAuth Clients post type.
+ \register_post_type(
+ Client::POST_TYPE,
+ array(
+ 'labels' => array(
+ 'name' => \_x( 'OAuth Clients', 'post_type plural name', 'activitypub' ),
+ 'singular_name' => \_x( 'OAuth Client', 'post_type single name', 'activitypub' ),
+ ),
+ 'public' => false,
+ 'show_in_rest' => false,
+ 'hierarchical' => false,
+ 'rewrite' => false,
+ 'query_var' => false,
+ 'delete_with_user' => false,
+ 'can_export' => true,
+ 'supports' => array( 'title', 'editor', 'custom-fields' ),
+ 'exclude_from_search' => true,
+ )
+ );
+
+ // OAuth Client meta.
+ \register_post_meta(
+ Client::POST_TYPE,
+ '_activitypub_client_id',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'description' => 'Unique OAuth client identifier (UUID).',
+ 'sanitize_callback' => 'sanitize_text_field',
+ )
+ );
+
+ \register_post_meta(
+ Client::POST_TYPE,
+ '_activitypub_client_secret_hash',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'description' => 'SHA-256 hash of the client secret (null for public clients).',
+ 'sanitize_callback' => 'sanitize_text_field',
+ )
+ );
+
+ \register_post_meta(
+ Client::POST_TYPE,
+ '_activitypub_redirect_uris',
+ array(
+ 'type' => 'array',
+ 'single' => true,
+ 'description' => 'Allowed redirect URIs for this client.',
+ 'sanitize_callback' => static function ( $value ) {
+ if ( ! is_array( $value ) ) {
+ return array();
+ }
+ return array_map( array( Sanitize::class, 'redirect_uri' ), $value );
+ },
+ )
+ );
+
+ \register_post_meta(
+ Client::POST_TYPE,
+ '_activitypub_allowed_scopes',
+ array(
+ 'type' => 'array',
+ 'single' => true,
+ 'description' => 'Allowed OAuth scopes for this client.',
+ 'sanitize_callback' => array( Scope::class, 'sanitize' ),
+ )
+ );
+
+ \register_post_meta(
+ Client::POST_TYPE,
+ '_activitypub_is_public',
+ array(
+ 'type' => 'boolean',
+ 'single' => true,
+ 'description' => 'Whether this is a public client (PKCE-only, no secret).',
+ 'sanitize_callback' => 'rest_sanitize_boolean',
+ 'default' => true,
+ )
+ );
+
+ \register_post_meta(
+ Client::POST_TYPE,
+ Token::USER_META_KEY,
+ array(
+ 'type' => 'integer',
+ 'single' => false,
+ 'description' => 'User IDs that have active tokens for this client.',
+ 'sanitize_callback' => 'absint',
+ )
+ );
+ }
+
+ /**
+ * Register the ap_tombstone post type.
+ *
+ * Stores local tombstone URLs out of the autoloaded options row.
+ * The post type is fully internal — never queried publicly, never shown in UI.
+ *
+ * @since 8.3.0
+ */
+ public static function register_tombstone_post_type() {
+ \register_post_type(
+ Tombstone::POST_TYPE,
+ array(
+ 'public' => false,
+ 'publicly_queryable' => false,
+ 'show_ui' => false,
+ 'show_in_menu' => false,
+ 'show_in_nav_menus' => false,
+ 'show_in_admin_bar' => false,
+ 'show_in_rest' => false,
+ 'exclude_from_search' => true,
+ 'has_archive' => false,
+ 'rewrite' => false,
+ 'query_var' => false,
+ 'can_export' => false,
+ 'delete_with_user' => false,
+ 'supports' => array(),
+ )
+ );
+ }
+
+ /**
+ * Register post meta for ActivityPub supported post types.
+ */
+ public static function register_activitypub_post_meta() {
+ $ap_post_types = \get_post_types_by_support( 'activitypub' );
+ foreach ( $ap_post_types as $post_type ) {
+ \register_post_meta(
+ $post_type,
+ 'activitypub_content_warning',
+ array(
+ 'show_in_rest' => true,
+ 'single' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ )
+ );
+
+ \register_post_meta(
+ $post_type,
+ 'activitypub_content_visibility',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'sanitize_callback' => static function ( $value ) {
+ $schema = array(
+ 'type' => 'string',
+ 'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ),
+ 'default' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
+ );
+
+ if ( \is_wp_error( \rest_validate_enum( $value, $schema, '' ) ) ) {
+ return $schema['default'];
+ }
+
+ return $value;
+ },
+ )
+ );
+
+ \register_post_meta(
+ $post_type,
+ 'activitypub_max_image_attachments',
+ array(
+ 'type' => 'integer',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'default' => \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ),
+ 'sanitize_callback' => 'absint',
+ )
+ );
+
+ \register_post_meta(
+ $post_type,
+ 'activitypub_interaction_policy_quote',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'default' => \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ),
+ 'sanitize_callback' => static function ( $value ) {
+ $schema = array(
+ 'type' => 'string',
+ 'enum' => array( ACTIVITYPUB_INTERACTION_POLICY_ANYONE, ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS, ACTIVITYPUB_INTERACTION_POLICY_ME ),
+ 'default' => ACTIVITYPUB_INTERACTION_POLICY_ANYONE,
+ );
+
+ if ( \is_wp_error( \rest_validate_enum( $value, $schema, '' ) ) ) {
+ return $schema['default'];
+ }
+
+ return $value;
+ },
+ )
+ );
+
+ \register_post_meta(
+ $post_type,
+ 'activitypub_status',
+ array(
+ 'type' => 'string',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'sanitize_callback' => static function ( $value ) {
+ // Allow empty values to pass through without setting a default.
+ if ( empty( $value ) ) {
+ return '';
+ }
+
+ $schema = array(
+ 'type' => 'string',
+ 'enum' => array(
+ ACTIVITYPUB_OBJECT_STATE_PENDING,
+ ACTIVITYPUB_OBJECT_STATE_FEDERATED,
+ ACTIVITYPUB_OBJECT_STATE_FAILED,
+ ACTIVITYPUB_OBJECT_STATE_DELETED,
+ ),
+ 'default' => '',
+ );
+
+ if ( \is_wp_error( \rest_validate_enum( $value, $schema, '' ) ) ) {
+ return $schema['default'];
+ }
+
+ return $value;
+ },
+ )
+ );
+ }
+ }
+
+ /**
+ * Register REST field for ap_actor posts.
+ */
+ public static function register_ap_actor_rest_field() {
+ \register_rest_field(
+ Remote_Actors::POST_TYPE,
+ 'activitypub_json',
+ array(
+ /**
+ * Get the raw post content without WordPress content filtering.
+ *
+ * @param array $response Prepared response array.
+ * @return string The raw post content.
+ */
+ 'get_callback' => static function ( $response ) {
+ return \get_post_field( 'post_content', $response['id'] );
+ },
+ 'schema' => array(
+ 'description' => 'Raw ActivityPub JSON data without WordPress content filtering',
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ )
+ );
+
+ // Add formatted actor data field.
+ \register_rest_field(
+ Remote_Actors::POST_TYPE,
+ 'actor_info',
+ array(
+ 'get_callback' => function ( $response ) {
+ $actor = Remote_Actors::get_actor( $response['id'] );
+ if ( \is_wp_error( $actor ) ) {
+ return null;
+ }
+ return array(
+ 'username' => $actor->get_preferred_username(),
+ 'name' => $actor->get_name() ?? $actor->get_preferred_username(),
+ 'icon' => object_to_uri( $actor->get_icon() ),
+ 'url' => object_to_uri( $actor->get_url() ?? $actor->get_id() ),
+ 'webfinger' => Remote_Actors::get_acct( $response['id'] ),
+ 'identifier' => $actor->get_id(),
+ );
+ },
+ 'schema' => array(
+ 'description' => 'Parsed ActivityPub actor information',
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ )
+ );
+
+ // Add follow status field.
+ \register_rest_field(
+ Remote_Actors::POST_TYPE,
+ 'follow_status',
+ array(
+ 'get_callback' => function ( $response ) {
+ $current_user_id = \get_current_user_id();
+ if ( ! $current_user_id ) {
+ return array( 'follows_back' => false );
+ }
+ return array(
+ 'follows_back' => Following::check_status( $current_user_id, $response['id'] ),
+ );
+ },
+ 'schema' => array(
+ 'description' => 'Follow relationship status',
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ )
+ );
+
+ // Add custom query parameter for filtering by follower relationships.
+ \add_filter( 'rest_ap_actor_query', array( self::class, 'filter_ap_actor_query_by_follower' ), 10, 2 );
+ }
+
+ /**
+ * Filter WP_Query args to support follower_of parameter.
+ *
+ * @param array $args Array of arguments for WP_Query.
+ * @param \WP_REST_Request $request The REST API request.
+ * @return array Modified query arguments.
+ */
+ public static function filter_ap_actor_query_by_follower( $args, $request ) {
+ if ( ! empty( $request['follower_of'] ) ) {
+ // Add meta_query to filter by _activitypub_following.
+ if ( ! isset( $args['meta_query'] ) ) {
+ $args['meta_query'] = array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ }
+
+ $args['meta_query'][] = array(
+ 'key' => Followers::FOLLOWER_META_KEY,
+ 'value' => $request['follower_of'],
+ );
+ }
+
+ return $args;
+ }
+
+ /**
+ * Register a REST field for the ap_post post type to embed remote actor data.
+ */
+ public static function register_ap_post_actor_rest_field() {
+ \register_rest_field(
+ Remote_Posts::POST_TYPE,
+ 'actor_info',
+ array(
+ /**
+ * Get the remote actor data for an ap_post.
+ *
+ * @param array $response Prepared response array.
+ * @return array|null The actor data or null if not found.
+ */
+ 'get_callback' => function ( $response ) {
+ $id = \get_post_meta( $response['id'], '_activitypub_remote_actor_id', true );
+ $actor = Remote_Actors::get_actor( $id );
+
+ if ( \is_wp_error( $actor ) ) {
+ return null;
+ }
+
+ return array(
+ 'username' => $actor->get_preferred_username(),
+ 'name' => $actor->get_name() ?? $actor->get_preferred_username(),
+ 'icon' => object_to_uri( $actor->get_icon() ),
+ 'url' => object_to_uri( $actor->get_url() ?? $actor->get_id() ),
+ 'webfinger' => Remote_Actors::get_acct( $id ),
+ 'identifier' => $actor->get_id(),
+ );
+ },
+ 'schema' => array(
+ 'description' => 'Remote actor data',
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Register custom REST API parameters for ap_post endpoint.
+ */
+ public static function register_ap_post_rest_params() {
+ \add_filter(
+ 'rest_' . Remote_Posts::POST_TYPE . '_collection_params',
+ function ( $params ) {
+ $params['user_id'] = array(
+ 'description' => __( 'Filter posts by user ID (0 for site/blog actor).', 'activitypub' ),
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ );
+
+ $params['ap_object_type'] = array(
+ 'description' => 'Filter posts by ActivityPub object type.',
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ 'minimum' => 0,
+ ),
+ );
+
+ $params['ap_tag'] = array(
+ 'description' => 'Filter posts by ActivityPub tag (term IDs).',
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ 'minimum' => 0,
+ ),
+ );
+
+ return $params;
+ }
+ );
+ }
+
+ /**
+ * Filter ap_post REST query to only show posts for the current user.
+ *
+ * @param array $args Query arguments.
+ * @param \WP_REST_Request $request The REST API request.
+ *
+ * @return array Modified query arguments.
+ */
+ public static function filter_ap_post_by_user( $args, $request ) {
+ $ap_tag = $request->get_param( 'ap_tag' );
+ if ( ! empty( $ap_tag ) ) {
+ if ( ! isset( $args['tax_query'] ) ) {
+ $args['tax_query'] = array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ }
+
+ $args['tax_query'][] = array(
+ 'taxonomy' => 'ap_tag',
+ 'field' => 'term_id',
+ 'terms' => $ap_tag,
+ );
+
+ return $args;
+ }
+
+ // Filter by user_id (defaults to current user, use 0 for site/blog actor).
+ $user_id = isset( $request['user_id'] ) ? (int) $request['user_id'] : \get_current_user_id();
+
+ if ( ! isset( $args['meta_query'] ) ) {
+ $args['meta_query'] = array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ }
+
+ $args['meta_query'][] = array(
+ 'key' => '_activitypub_user_id',
+ 'value' => $user_id,
+ 'compare' => '=',
+ );
+
+ // Filter by object type if provided.
+ if ( ! empty( $request['ap_object_type'] ) ) {
+ if ( ! isset( $args['tax_query'] ) ) {
+ $args['tax_query'] = array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ }
+
+ $args['tax_query'][] = array(
+ 'taxonomy' => 'ap_object_type',
+ 'field' => 'term_id',
+ 'terms' => $request['ap_object_type'],
+ );
+ }
+
+ return $args;
+ }
+
+ /**
+ * Register user_id parameter for ap_object_type taxonomy REST API.
+ *
+ * @param array $params Existing collection parameters.
+ *
+ * @return array Modified collection parameters.
+ */
+ public static function register_object_type_user_param( $params ) {
+ $params['user_id'] = array(
+ 'description' => __( 'Filter terms to those with posts from this user ID.', 'activitypub' ),
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ );
+
+ return $params;
+ }
+
+ /**
+ * Filter ap_object_type REST query to only return terms that have posts for the given user.
+ *
+ * Uses a direct SQL query to efficiently get term IDs without loading all post IDs.
+ *
+ * @param array $args Query arguments.
+ * @param \WP_REST_Request $request The REST API request.
+ *
+ * @return array Modified query arguments.
+ */
+ public static function filter_object_type_by_user( $args, $request ) {
+ $user_id = $request->get_param( 'user_id' );
+ if ( null === $user_id ) {
+ return $args;
+ }
+
+ global $wpdb;
+
+ // Get term IDs that have at least one ap_post for this user.
+ $term_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->prepare(
+ "SELECT DISTINCT tt.term_id
+ FROM {$wpdb->term_taxonomy} tt
+ INNER JOIN {$wpdb->term_relationships} tr ON tt.term_taxonomy_id = tr.term_taxonomy_id
+ INNER JOIN {$wpdb->posts} p ON tr.object_id = p.ID
+ INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
+ WHERE tt.taxonomy = 'ap_object_type'
+ AND p.post_type = 'ap_post'
+ AND pm.meta_key = '_activitypub_user_id'
+ AND pm.meta_value = %s",
+ $user_id
+ )
+ );
+
+ if ( empty( $term_ids ) ) {
+ // Force empty result.
+ $term_ids = array( 0 );
+ }
+
+ $args['include'] = \array_map( 'intval', $term_ids );
+
+ return $args;
+ }
+
+ /**
+ * Prevent empty or default meta values.
+ *
+ * @param null|bool $check Whether to allow updating metadata for the given type.
+ * @param int $object_id ID of the object metadata is for.
+ * @param string $meta_key Metadata key.
+ * @param mixed $meta_value Metadata value. Must be serializable if non-scalar.
+ */
+ public static function prevent_empty_post_meta( $check, $object_id, $meta_key, $meta_value ) {
+ $post_metas = array(
+ 'activitypub_content_visibility' => '',
+ 'activitypub_content_warning' => '',
+ 'activitypub_max_image_attachments' => (string) \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ),
+ );
+
+ if ( isset( $post_metas[ $meta_key ] ) && $post_metas[ $meta_key ] === (string) $meta_value ) {
+ if ( 'update_post_metadata' === current_action() ) {
+ \delete_post_meta( $object_id, $meta_key );
+ }
+
+ $check = true;
+ }
+
+ return $check;
+ }
+}
diff --git a/wp-content/plugins/activitypub/includes/class-query.php b/wp-content/plugins/activitypub/includes/class-query.php
index d2200c0f..89c44390 100644
--- a/wp-content/plugins/activitypub/includes/class-query.php
+++ b/wp-content/plugins/activitypub/includes/class-query.php
@@ -7,6 +7,7 @@
namespace Activitypub;
+use Activitypub\Activity\Extended_Object\Quote_Authorization;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
use Activitypub\Transformer\Factory;
@@ -138,6 +139,10 @@ class Query {
private function prepare_activitypub_data() {
$queried_object = $this->get_queried_object();
+ if ( $queried_object instanceof \WP_Post && \get_query_var( 'stamp' ) ) {
+ return $this->maybe_get_stamp();
+ }
+
// Check for Outbox Activity.
if (
$queried_object instanceof \WP_Post &&
@@ -193,6 +198,14 @@ class Query {
}
}
+ // Check Term by ID.
+ if ( ! $queried_object ) {
+ $term_id = \get_query_var( 'term_id' );
+ if ( $term_id ) {
+ $queried_object = \get_term( $term_id );
+ }
+ }
+
// Try to get Author by ID.
if ( ! $queried_object ) {
$url = $this->get_request_url();
@@ -248,7 +261,7 @@ class Query {
*
* @return string|null The request URL.
*/
- protected function get_request_url() {
+ public function get_request_url() {
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
return null;
}
@@ -267,50 +280,74 @@ class Query {
* @return bool True if the request is an ActivityPub request, false otherwise.
*/
public function is_activitypub_request() {
- if ( isset( $this->is_activitypub_request ) ) {
- return $this->is_activitypub_request;
- }
+ if ( ! isset( $this->is_activitypub_request ) ) {
+ global $wp_query;
- global $wp_query;
+ $this->is_activitypub_request = false;
- // One can trigger an ActivityPub request by adding `?activitypub` to the URL.
- if (
- isset( $wp_query->query_vars['activitypub'] ) ||
- // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- isset( $_GET['activitypub'] )
- ) {
- \defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
- $this->is_activitypub_request = true;
-
- return true;
- }
-
- /*
- * The other (more common) option to make an ActivityPub request
- * is to send an Accept header.
- */
- if ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
- $accept = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
-
- /*
- * $accept can be a single value, or a comma separated list of values.
- * We want to support both scenarios,
- * and return true when the header includes at least one of the following:
- * - application/activity+json
- * - application/ld+json
- * - application/json
- */
- if ( \preg_match( '/(application\/(ld\+json|activity\+json|json))/i', $accept ) ) {
+ // One can trigger an ActivityPub request by adding `?activitypub` to the URL.
+ if ( isset( $wp_query->query_vars['activitypub'] ) || isset( $_GET['activitypub'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
\defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
$this->is_activitypub_request = true;
- return true;
+ // The other (more common) option to make an ActivityPub request is to send an Accept header.
+ } elseif ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
+ $accept = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
+
+ /*
+ * $accept can be a single value, or a comma separated list of values.
+ * We want to support both scenarios,
+ * and return true when the header includes at least one of the following:
+ * - application/activity+json
+ * - application/ld+json
+ * - application/json
+ */
+ if ( \preg_match( '/(application\/(ld\+json|activity\+json|json))/i', $accept ) ) {
+ \defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
+ $this->is_activitypub_request = true;
+ }
}
}
- $this->is_activitypub_request = false;
+ /**
+ * Filters whether the current request is an ActivityPub request.
+ *
+ * @param bool $is_activitypub_request True if the request is an ActivityPub request, false otherwise.
+ */
+ return \apply_filters( 'activitypub_is_activitypub_request', $this->is_activitypub_request );
+ }
- return false;
+ /**
+ * Check if content negotiation is allowed for a request.
+ *
+ * @return bool True if content negotiation is allowed, false otherwise.
+ */
+ public function should_negotiate_content() {
+ $return = false;
+ $always_negotiate = array( 'p', 'c', 'author', 'actor', 'stamp', 'preview', 'activitypub' );
+ $url = \wp_parse_url( $this->get_request_url(), PHP_URL_QUERY );
+ $query = array();
+ \wp_parse_str( $url, $query );
+
+ // Check if any of the query params are in the `$always_negotiate` array.
+ if ( \array_intersect( \array_keys( $query ), $always_negotiate ) ) {
+ $return = true;
+ }
+
+ if ( \get_option( 'activitypub_content_negotiation', '1' ) ) {
+ $return = true;
+ }
+
+ if ( \is_author() && \get_user_option( 'activitypub_use_permalink_as_id', \get_queried_object_id() ) ) {
+ $return = true;
+ }
+
+ /**
+ * Filters whether content negotiation should be forced.
+ *
+ * @param bool $return Whether content negotiation should be forced.
+ */
+ return \apply_filters( 'activitypub_should_negotiate_content', $return );
}
/**
@@ -348,4 +385,52 @@ class Query {
public function set_old_host_request( $state = true ) {
$this->is_old_host_request = $state;
}
+
+ /**
+ * Maybe get a QuoteAuthorization object from a stamp.
+ *
+ * @return bool True if the object was prepared, false otherwise.
+ */
+ private function maybe_get_stamp() {
+ require_once ABSPATH . 'wp-admin/includes/post.php';
+
+ $stamp = \get_query_var( 'stamp' );
+ $meta = \get_post_meta_by_id( (int) $stamp );
+
+ if ( ! $meta ) {
+ return false;
+ }
+
+ $post = $this->get_queried_object();
+
+ // Ensure the meta belongs to the queried post to prevent arbitrary meta disclosure.
+ if ( (int) $meta->post_id !== $post->ID ) {
+ return false;
+ }
+
+ $user_uri = get_user_id( $post->post_author );
+
+ if ( ! $user_uri ) {
+ return false;
+ }
+
+ $stamp_uri = \add_query_arg(
+ array(
+ 'p' => $post->ID,
+ 'stamp' => $meta->meta_id,
+ ),
+ \home_url( '/' )
+ );
+
+ $activitypub_object = new Quote_Authorization();
+ $activitypub_object->set_id( $stamp_uri );
+ $activitypub_object->set_attributed_to( $user_uri );
+ $activitypub_object->set_interacting_object( $meta->meta_value );
+ $activitypub_object->set_interaction_target( get_post_id( $post->ID ) );
+
+ $this->activitypub_object = $activitypub_object;
+ $this->activitypub_object_id = $activitypub_object->get_id();
+
+ return true;
+ }
}
diff --git a/wp-content/plugins/activitypub/includes/class-relay.php b/wp-content/plugins/activitypub/includes/class-relay.php
new file mode 100644
index 00000000..769437f3
--- /dev/null
+++ b/wp-content/plugins/activitypub/includes/class-relay.php
@@ -0,0 +1,81 @@
+set_type( 'Announce' );
+ $announce->set_actor( Actors::BLOG_USER_ID );
+ $announce->set_object( $activity );
+ $announce->set_published( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339 ) );
+
+ // Add to outbox for distribution. The outbox will generate the ID.
+ Outbox::add( $announce, Actors::BLOG_USER_ID );
+ }
+
+ /**
+ * Unhook settings fields when relay mode is enabled.
+ *
+ * Removes all settings sections except moderation when relay mode is active.
+ */
+ public static function unhook_settings_fields() {
+ global $wp_settings_sections;
+
+ if ( ! isset( $wp_settings_sections['activitypub_settings'] ) ) {
+ return;
+ }
+
+ // Keep only the moderation section.
+ foreach ( $wp_settings_sections['activitypub_settings'] as $section_id => $section ) {
+ if ( 'activitypub_moderation' !== $section_id ) {
+ unset( $wp_settings_sections['activitypub_settings'][ $section_id ] );
+ }
+ }
+ }
+}
diff --git a/wp-content/plugins/activitypub/includes/class-router.php b/wp-content/plugins/activitypub/includes/class-router.php
new file mode 100644
index 00000000..e51cced7
--- /dev/null
+++ b/wp-content/plugins/activitypub/includes/class-router.php
@@ -0,0 +1,383 @@
+get_activitypub_object();
+
+ if ( Tombstone::exists_local( Query::get_instance()->get_request_url() ) ) {
+ // Set 410 Gone for permanently deleted posts, 200 OK for soft-deleted.
+ if ( ! $activitypub_object ) {
+ \status_header( 410 );
+ }
+
+ return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php';
+ }
+
+ $activitypub_template = false;
+
+ if ( $activitypub_object ) {
+ if ( \get_query_var( 'preview' ) ) {
+ \define( 'ACTIVITYPUB_PREVIEW', true );
+
+ /**
+ * Filter the template used for the ActivityPub preview.
+ *
+ * @param string $activitypub_template Absolute path to the template file.
+ */
+ $activitypub_template = apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
+ } else {
+ $activitypub_template = ACTIVITYPUB_PLUGIN_DIR . 'templates/activitypub-json.php';
+ }
+ }
+
+ /*
+ * Check if the request is authorized.
+ *
+ * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
+ * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
+ */
+ if ( $activitypub_template && use_authorized_fetch() ) {
+ $verification = Signature::verify_http_signature( $_SERVER );
+ if ( \is_wp_error( $verification ) ) {
+ \status_header( 401 );
+
+ // Fallback as template_loader can't return http headers.
+ return $template;
+ }
+ }
+
+ if ( $activitypub_template ) {
+ \set_query_var( 'is_404', false );
+
+ // Check if header already sent.
+ if ( ! \headers_sent() ) {
+ // Send 200 status header.
+ \status_header( 200 );
+ }
+
+ return $activitypub_template;
+ }
+
+ return $template;
+ }
+
+ /**
+ * Add the 'self' link to the header.
+ */
+ public static function add_headers() {
+ $id = Query::get_instance()->get_activitypub_object_id();
+
+ /*
+ * Send CORS headers for resolved ActivityPub objects and outbox
+ * items. Outbox items need CORS even when the object ID doesn't
+ * resolve, because browser preflight requests don't carry the
+ * Authorization header needed to authenticate private items.
+ */
+ $post_id = \get_query_var( 'p' );
+ $is_outbox_url = $post_id && Outbox::POST_TYPE === \get_post_type( $post_id );
+
+ if ( ! \headers_sent() && ( $id || $is_outbox_url ) ) {
+ \header( 'Access-Control-Allow-Origin: *' );
+ \header( 'Access-Control-Allow-Methods: GET, OPTIONS' );
+ \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' );
+ }
+
+ if ( ! $id ) {
+ return;
+ }
+
+ if ( ! \headers_sent() ) {
+ \header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
+
+ if ( \get_option( 'activitypub_vary_header', '1' ) ) {
+ // Send Vary header for Accept header.
+ \header( 'Vary: Accept', false );
+ }
+ }
+
+ \add_action(
+ 'wp_head',
+ static function () use ( $id ) {
+ echo PHP_EOL . '
' . PHP_EOL;
+ }
+ );
+ }
+
+ /**
+ * Remove trailing slash from ActivityPub @username requests.
+ *
+ * @param string $redirect_url The URL to redirect to.
+ * @param string $requested_url The requested URL.
+ *
+ * @return string $redirect_url The possibly-unslashed redirect URL.
+ */
+ public static function no_trailing_redirect( $redirect_url, $requested_url ) {
+ if ( get_query_var( 'actor' ) ) {
+ return $requested_url;
+ }
+
+ return $redirect_url;
+ }
+
+ /**
+ * Add support for `p` and `author` query vars.
+ *
+ * @param string $redirect_url The URL to redirect to.
+ * @param string $requested_url The requested URL.
+ *
+ * @return string $redirect_url
+ */
+ public static function redirect_canonical( $redirect_url, $requested_url ) {
+ if ( ! is_activitypub_request() ) {
+ return $redirect_url;
+ }
+
+ $query = \wp_parse_url( $requested_url, PHP_URL_QUERY );
+
+ if ( ! $query ) {
+ return $redirect_url;
+ }
+
+ $query_params = \wp_parse_args( $query );
+ unset( $query_params['activitypub'] );
+ unset( $query_params['stamp'] );
+
+ if ( 1 !== count( $query_params ) ) {
+ return $redirect_url;
+ }
+
+ if ( isset( $query_params['p'] ) ) {
+ return null;
+ }
+
+ if ( isset( $query_params['author'] ) ) {
+ return null;
+ }
+
+ return $requested_url;
+ }
+
+ /**
+ * Custom redirects for ActivityPub requests.
+ *
+ * @return void
+ */
+ public static function template_redirect() {
+ global $wp_query;
+
+ $comment_id = \get_query_var( 'c', null );
+
+ // Check if it seems to be a comment.
+ if ( $comment_id ) {
+ $comment = \get_comment( $comment_id );
+
+ // Load a 404-page if `c` is set but not valid.
+ if ( ! $comment ) {
+ $wp_query->set_404();
+ return;
+ }
+
+ // Stop if it's not an ActivityPub comment.
+ if ( is_activitypub_request() && ! is_local_comment( $comment ) ) {
+ return;
+ }
+
+ \wp_safe_redirect( get_comment_link( $comment ) );
+ exit;
+ }
+
+ $actor = \get_query_var( 'actor', null );
+ if ( $actor ) {
+ $actor = Actors::get_by_username( $actor );
+ if ( ! $actor || \is_wp_error( $actor ) ) {
+ $wp_query->set_404();
+ return;
+ }
+
+ if ( is_activitypub_request() ) {
+ return;
+ }
+
+ \wp_safe_redirect( $actor->get_url(), 301 );
+ exit;
+ }
+
+ $term_id = \get_query_var( 'term_id', null );
+ if ( $term_id ) {
+ $term = \get_term( $term_id );
+
+ // Load a 404-page if `term_id` is set but not valid.
+ if ( ! $term || \is_wp_error( $term ) ) {
+ $wp_query->set_404();
+ return;
+ }
+
+ /**
+ * Filters the taxonomies supported for term redirects.
+ *
+ * @since 7.8.3
+ *
+ * @param array $supported_taxonomies Array of taxonomy names. Default array( 'category', 'post_tag' ).
+ */
+ $supported_taxonomies = \apply_filters( 'activitypub_supported_taxonomies', array( 'category', 'post_tag' ) );
+
+ if ( ! in_array( $term->taxonomy, $supported_taxonomies, true ) ) {
+ return;
+ }
+
+ // Don't redirect for ActivityPub requests.
+ if ( is_activitypub_request() ) {
+ return;
+ }
+
+ $term_link = \get_term_link( $term );
+ if ( ! \is_wp_error( $term_link ) ) {
+ \wp_safe_redirect( $term_link, 301 );
+ exit;
+ }
+ }
+ }
+
+ /**
+ * Add the 'activitypub' query variable so WordPress won't mangle it.
+ *
+ * @param array $vars The query variables.
+ *
+ * @return array The query variables.
+ */
+ public static function add_query_vars( $vars ) {
+ $vars[] = 'activitypub';
+ $vars[] = 'preview';
+ $vars[] = 'author';
+ $vars[] = 'actor';
+ $vars[] = 'stamp';
+ $vars[] = 'type';
+ $vars[] = 'c';
+ $vars[] = 'p';
+ $vars[] = 'term_id';
+
+ return $vars;
+ }
+
+ /**
+ * Optimize home page query for ActivityPub requests.
+ *
+ * Skip the database query entirely for ActivityPub requests on the home page
+ * since we only need to return the blog actor, not posts.
+ *
+ * @param \WP_Query $wp_query The WP_Query instance.
+ */
+ public static function fix_is_home_check( $wp_query ) {
+ if (
+ $wp_query->get( 'actor' ) ||
+ $wp_query->get( 'stamp' ) ||
+ $wp_query->get( 'c' )
+ ) {
+ $wp_query->is_home = false;
+ }
+ }
+}
diff --git a/wp-content/plugins/activitypub/includes/class-sanitize.php b/wp-content/plugins/activitypub/includes/class-sanitize.php
index 42d32491..73e1be8e 100644
--- a/wp-content/plugins/activitypub/includes/class-sanitize.php
+++ b/wp-content/plugins/activitypub/includes/class-sanitize.php
@@ -7,12 +7,57 @@
namespace Activitypub;
+use Activitypub\Collection\Remote_Actors;
use Activitypub\Model\Blog;
/**
* Sanitization class.
*/
class Sanitize {
+
+ /**
+ * Elements to strip including their inner content.
+ *
+ * WordPress's wp_kses removes disallowed tags but preserves their inner text.
+ * These elements contain content that is meaningless or harmful
+ * without the surrounding tag (scripts, styles, interactive UI,
+ * embedded objects), so we remove them entirely before wp_kses runs.
+ *
+ * @var array
+ */
+ const STRIP_ELEMENTS = array(
+ 'script',
+ 'style',
+ 'button',
+ 'nav',
+ 'form',
+ 'textarea',
+ 'select',
+ 'input',
+ 'fieldset',
+ 'iframe',
+ 'embed',
+ 'object',
+ );
+
+ /**
+ * MathML global attributes allowed per the W3C MathML safe list.
+ *
+ * @see https://w3c.github.io/mathml-docs/mathml-safe-list
+ *
+ * @var array
+ */
+ const MATHML_GLOBAL_ATTRS = array(
+ 'dir' => true,
+ 'displaystyle' => true,
+ 'mathbackground' => true,
+ 'mathcolor' => true,
+ 'mathsize' => true,
+ 'scriptlevel' => true,
+ 'intent' => true,
+ 'arg' => true,
+ );
+
/**
* Sanitize a list of URLs.
*
@@ -21,7 +66,7 @@ class Sanitize {
*/
public static function url_list( $value ) {
if ( ! \is_array( $value ) ) {
- $value = \explode( PHP_EOL, $value );
+ $value = \explode( PHP_EOL, (string) $value );
}
$value = \array_filter( $value );
@@ -32,6 +77,50 @@ class Sanitize {
return \array_values( $value );
}
+ /**
+ * Sanitize and normalize a list of account identifiers to ActivityPub IDs.
+ *
+ * This function processes various identifier formats, such as URLs and
+ * webfinger identifiers, and normalizes them into a consistent format.
+ *
+ * @param string|array $value The value to sanitize.
+ *
+ * @return array The sanitized and normalized list of account identifiers.
+ */
+ public static function identifier_list( $value ) {
+ if ( ! \is_array( $value ) ) {
+ $value = \explode( PHP_EOL, (string) $value );
+ }
+
+ $value = \array_filter( $value );
+ $uris = array();
+
+ foreach ( $value as $uri ) {
+ $uri = \trim( $uri );
+ $uri = \ltrim( $uri, '@' );
+
+ if ( \is_email( $uri ) ) {
+ $_uri = Webfinger::resolve( $uri );
+ if ( \is_wp_error( $_uri ) ) {
+ $uris[] = $uri;
+ continue;
+ }
+
+ $uri = $_uri;
+ }
+
+ $uri = \sanitize_url( $uri );
+ $actor = Remote_Actors::fetch_by_uri( $uri );
+ if ( \is_wp_error( $actor ) ) {
+ $uris[] = $uri;
+ } else {
+ $uris[] = \sanitize_url( $actor->guid );
+ }
+ }
+
+ return \array_values( \array_unique( $uris ) );
+ }
+
/**
* Sanitize a list of hosts.
*
@@ -39,9 +128,9 @@ class Sanitize {
* @return string The sanitized list of hosts.
*/
public static function host_list( $value ) {
- $value = \explode( PHP_EOL, $value );
+ $value = \explode( PHP_EOL, (string) $value );
$value = \array_map(
- function ( $host ) {
+ static function ( $host ) {
$host = \trim( $host );
$host = \strtolower( $host );
$host = \set_url_scheme( $host );
@@ -68,10 +157,14 @@ class Sanitize {
*/
public static function blog_identifier( $value ) {
// Hack to allow dots in the username.
- $parts = \explode( '.', $value );
+ $parts = \explode( '.', (string) $value );
$sanitized = \array_map( 'sanitize_title', $parts );
$sanitized = \implode( '.', $sanitized );
+ if ( empty( $sanitized ) ) {
+ return Blog::get_default_username();
+ }
+
// Check for login or nicename.
$user = new \WP_User_Query(
array(
@@ -119,4 +212,349 @@ class Sanitize {
return $value;
}
+
+ /**
+ * Sanitize a webfinger identifier.
+ *
+ * @param string $value The value to sanitize.
+ *
+ * @return string The sanitized webfinger identifier.
+ */
+ public static function webfinger( $value ) {
+ $value = \str_replace( 'acct:', '', $value );
+ $value = \trim( $value, '@' );
+
+ return $value;
+ }
+
+ /**
+ * Sanitize content for ActivityPub.
+ *
+ * @param string $content The content to convert.
+ *
+ * @return string The converted content.
+ */
+ public static function content( $content ) {
+ // Only make URLs clickable if no anchor tags exist, to avoid corrupting existing links.
+ if ( false === \strpos( $content, '[\n\r\t]+', '><', $content ) );
+ }
+
+ /**
+ * Sanitize a redirect URI, preserving custom protocol schemes.
+ *
+ * WordPress's sanitize_url() and esc_url_raw() strip unknown protocols.
+ * This method extracts the scheme and passes it as allowed so custom
+ * URI schemes for native apps (RFC 8252 Section 7.1) are preserved.
+ *
+ * @since 8.1.0
+ *
+ * @param string $uri The redirect URI to sanitize.
+ * @return string The sanitized URI.
+ */
+ public static function redirect_uri( $uri ) {
+ /*
+ * Extract scheme manually because wp_parse_url() returns false
+ * for URIs like "myapp://" (scheme + empty authority, no path).
+ */
+ if ( ! preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
+ return '';
+ }
+
+ $scheme = \strtolower( $matches[1] );
+
+ // For standard schemes, use default sanitization.
+ if ( in_array( $scheme, array( 'http', 'https' ), true ) ) {
+ return \sanitize_url( $uri );
+ }
+
+ // For custom schemes, include the scheme in allowed protocols.
+ return \sanitize_url( $uri, array_merge( \wp_allowed_protocols(), array( $scheme ) ) );
+ }
+
+ /**
+ * Clean HTML for ActivityPub federation.
+ *
+ * Uses a positive allowlist based on FEP-b2b8 (Long-form Text) for the
+ * `content` property, extended with common WordPress content elements.
+ * Interactive, navigational, and scripting elements are stripped entirely.
+ *
+ * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/b2b8/fep-b2b8.md
+ * @see https://github.com/Automattic/wordpress-activitypub/issues/2619
+ *
+ * @param string $content The HTML content to clean.
+ *
+ * @return string The cleaned HTML content.
+ */
+ public static function clean_html( $content ) {
+ if ( empty( $content ) ) {
+ return $content;
+ }
+
+ /*
+ * Strip elements whose inner content is noise (scripts, styles, interactive UI, embeds).
+ * This runs before wp_kses because wp_kses strips tags but keeps inner text,
+ * and content inside