]*class=["\']quote-inline["\'][^>]*>.*?<\/p>/is'; $activity['object']['content'] = \preg_replace( $pattern, '', $activity['object']['content'], 1 ); } } $comment_post_id = get_target_post_id( $target_url, $parent_comment_id ); if ( ! $comment_post_id ) { // Not a reply to a post or comment. return false; } /* * Image blocks are only for remote replies and quotes. They are built here, not in * activity_to_comment(), which reactions share. */ $images = ''; if ( null === $user_id ) { $images = process_remote_images( '', self::get_image_attachments( $activity['object'] ) ); // Validation lets image-only replies in without checking the hosts, at least one image has to pass. if ( '' === $images && ! isset( $activity['object']['content'] ) ) { return false; } } /* * Built only once the target is known: resolving the author fetches the remote actor and * its WebFinger, which is wasted on a reply to something that is not here. */ $comment_data = self::activity_to_comment( $activity, $user_id ); if ( ! $comment_data ) { return false; } $comment_data['comment_content'] .= \addslashes( $images ); if ( $comment_type ) { $comment_data['comment_type'] = $comment_type; } $comment_data['comment_post_ID'] = $comment_post_id; $comment_data['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0; return self::persist( $comment_data ); } /** * Update a comment. * * @param array $activity The activity object. * * @return array|string|int|\WP_Error|false The comment data or false on failure. */ public static function update_comment( $activity ) { $meta = get_remote_metadata_by_actor( $activity['actor'] ); if ( \is_wp_error( $meta ) || ! \is_array( $meta ) ) { return $meta; } // Determine comment_ID, by the same identifier add_comment() stored it under. $object_id = object_to_uri( $activity['object'] ); $comment = object_id_to_comment( \esc_url_raw( $object_id ) ); $comment_data = \get_comment( $comment, ARRAY_A ); if ( ! $comment_data ) { return false; } /* * Only the comment's author may update it. The comment maps to the remote actor that * created it via _activitypub_remote_actor_id; that actor post's guid is the * (signature-bound) actor URI. The Update's actor must match it, otherwise a remote * server could rewrite another actor's comment by sending an Update whose object.id * points at it. * * Comments created before this mapping existed have no owner recorded; those are let * through for backward compatibility, but only from the host of the identifier the comment * was found by, as in add_comment(). A recorded owner whose actor post is gone is rejected. * On mismatch, return a WP_Error rather than false: false would make the Update handler * fall back to Create (which re-dispatches to Update for an existing comment and recurses), * while the unchanged comment array would be read as a successful update and relayed * onward. A WP_Error is handled but unsuccessful: no Create fallback, and the * handled-update success flag stays false. */ $owner_id = (int) \get_comment_meta( $comment_data['comment_ID'], '_activitypub_remote_actor_id', true ); if ( $owner_id ? object_to_uri( $activity['actor'] ) !== \get_post_field( 'guid', $owner_id, 'raw' ) : ! is_same_host( $activity['actor'], $object_id ) ) { return new \WP_Error( 'activitypub_update_forbidden', \__( 'The Update actor does not own the target comment.', 'activitypub' ) ); } // Found a local comment id. $comment_data['comment_author'] = \wp_slash( empty( $meta['name'] ) ? $meta['preferredUsername'] : $meta['name'] ); /* * Sanitize before wrapping: emoji blocks are our own markup, and kses would * mangle the block comments they are made of. */ $content = Sanitize::comment_content( $activity['object']['content'] ?? '' ); $content = Emoji::wrap_in_content( $content, $activity['object'] ); $content .= process_remote_images( '', self::get_image_attachments( $activity['object'] ) ); $comment_data['comment_content'] = \addslashes( $content ); $result = self::persist( $comment_data, self::UPDATE ); /* * `persist()` returns false when it refuses to write, typically because the post is no * longer federated. That is not "no comment to update": the comment exists, it was found * above. The Update handler treats false as not-found and falls back to Create, which * finds the same comment and dispatches back to Update, without end. A WP_Error is * handled but unsuccessful, so it stops here. */ if ( false === $result ) { return new \WP_Error( 'activitypub_update_refused', \__( 'The comment can no longer be updated.', 'activitypub' ) ); } return $result; } /** * Adds an incoming Like, Announce, ... as a comment to a post. * * @param array $activity Activity array. * * @return array|string|int|\WP_Error|false Comment data or `false` on failure. */ public static function add_reaction( $activity ) { /* * The reaction is stored under its own id (source_id); that id must be on the * signature-verified actor's host, so a remote server cannot file a reaction * whose recorded id points at a different host and take over that id. */ if ( ! is_same_host( $activity['actor'] ?? '', $activity['id'] ?? '' ) ) { return false; } $url = object_to_uri( $activity['object'] ); $parent_comment_id = url_to_commentid( $url ); $comment_post_id = get_target_post_id( $url, $parent_comment_id ); if ( ! $comment_post_id ) { // Not a reply to a post or comment. return false; } $comment_type = Comment::get_comment_type_by_activity_type( $activity['type'] ); if ( ! $comment_type ) { // Not a valid comment type. return false; } $comment_data = self::activity_to_comment( $activity ); if ( ! $comment_data ) { return false; } $comment_data['comment_post_ID'] = $comment_post_id; $comment_data['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0; $comment_data['comment_content'] = \esc_html( $comment_type['excerpt'] ); $comment_data['comment_type'] = \esc_attr( $comment_type['type'] ); $comment_data['comment_meta']['source_id'] = \esc_url_raw( $activity['id'] ); return self::persist( $comment_data ); } /** * Get interaction(s) by ID. * * @param string $url The URL/ID to get interactions for. * * @return array The interactions as WP_Comment objects. */ public static function get_by_id( $url ) { $args = array( 'nopaging' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( 'relation' => 'AND', array( 'key' => 'protocol', 'value' => 'activitypub', ), array( 'relation' => 'OR', array( 'key' => 'source_url', 'value' => $url, ), array( 'key' => 'source_id', 'value' => $url, ), ), ), ); $query = new \WP_Comment_Query( $args ); return $query->comments; } /** * Get interaction(s) for a given URL/ID. * * @deprecated 7.6.0 Use {@see Interactions::get_by_id()}. * * @param string $url The URL/ID to get interactions for. * * @return array The interactions as WP_Comment objects. */ public static function get_interaction_by_id( $url ) { \_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Interactions::get_by_id' ); return self::get_by_id( $url ); } /** * Get interaction(s) by actor. * * @param string $actor The Actor-URL. * * @return array The interactions as WP_Comment objects. */ public static function get_by_actor( $actor ) { $meta = get_remote_metadata_by_actor( $actor ); // Get URL, because $actor seems to be the ID. if ( $meta && ! \is_wp_error( $meta ) && isset( $meta['url'] ) ) { $actor = object_to_uri( $meta['url'] ); } $args = array( 'nopaging' => true, 'author_url' => $actor, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => 'protocol', 'value' => 'activitypub', ), ), ); return \get_comments( $args ); } /** * Get interaction(s) by remote actor ID. * * This is an optimized query that uses the remote actor post ID directly * instead of querying by author_url. * * @param int $remote_actor_id The remote actor post ID. * * @return array The interactions as WP_Comment objects. */ public static function get_by_remote_actor_id( $remote_actor_id ) { $args = array( 'nopaging' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( 'relation' => 'AND', array( 'key' => 'protocol', 'value' => 'activitypub', ), array( 'key' => '_activitypub_remote_actor_id', 'value' => $remote_actor_id, ), ), ); return \get_comments( $args ); } /** * Get interaction(s) for a given actor. * * @deprecated 7.6.0 Use {@see Interactions::get_by_actor()}. * * @param string $actor The Actor-URL. * * @return array The interactions as WP_Comment objects. */ public static function get_interactions_by_actor( $actor ) { \_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Interactions::get_by_actor' ); return self::get_by_actor( $actor ); } /** * Adds line breaks to the list of allowed comment tags. * * @param array $allowed_tags Allowed HTML tags. * @param string $context Optional. Context. Default empty. * * @return array Filtered tag list. */ public static function allowed_comment_html( $allowed_tags, $context = '' ) { if ( 'pre_comment_content' !== $context ) { // Do nothing. return $allowed_tags; } return Sanitize::get_allowed_comment_html( $allowed_tags ); } /** * Force Akismet's comment nonce check to `inactive` while persisting. * * Inbound activities have no browser-issued nonce, so Akismet's nonce * verification cannot apply to this submission route. A named method (rather * than an anonymous closure) is used so it can be removed by reference again. * * @since 9.4.0 * * @return string Always `inactive`. */ public static function akismet_comment_nonce_inactive() { return 'inactive'; } /** * Convert an Activity to a WP_Comment. * * When $user_id is provided, comment author data is built from the * local WordPress user instead of fetching remote actor metadata. * * @param array $activity The Activity array. * @param int|null $user_id Optional. Local user ID for outbox comments. * * @return array|false The comment data or false on failure. */ public static function activity_to_comment( $activity, $user_id = null ) { $comment = $user_id ? self::prepare_local_comment_data( $activity, $user_id ) : self::prepare_remote_comment_data( $activity ); if ( ! $comment ) { return false; } $published = $activity['object']['published'] ?? $activity['published'] ?? 'now'; $gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $published ) ); return \array_merge( $comment, array( 'comment_type' => 'comment', 'comment_date' => \get_date_from_gmt( $gm_date ), 'comment_date_gmt' => $gm_date, ) ); } /** * Prepare comment fields for a local author. * * @since 9.4.0 * * @param array $activity The Activity array. * @param int $user_id The local WordPress user ID. * * @return array|false The prepared fields, or false when the user is unavailable. */ private static function prepare_local_comment_data( $activity, $user_id ) { $user = \get_userdata( $user_id ); if ( ! $user ) { return false; } return array( 'comment_author' => \wp_slash( $user->display_name ), 'comment_author_url' => $user->user_url, // Same gate and slashing as the remote branch: one policy for the column, whatever wrote it. 'comment_content' => \addslashes( Sanitize::comment_content( $activity['object']['content'] ?? '' ) ), 'comment_author_email' => $user->user_email, 'comment_meta' => array(), 'user_id' => $user_id, ); } /** * Prepare comment fields for a remote author. * * @since 9.4.0 * * @param array $activity The Activity array. * * @return array|false The prepared fields, or false when actor data is unavailable. */ private static function prepare_remote_comment_data( $activity ) { $actor_id = object_to_uri( $activity['actor'] ?? null ); $actor = get_remote_metadata_by_actor( $actor_id ); if ( ! $actor || \is_wp_error( $actor ) ) { return false; } $comment_author = null; if ( ! empty( $actor['name'] ) ) { $comment_author = $actor['name']; } elseif ( ! empty( $actor['preferredUsername'] ) ) { $comment_author = $actor['preferredUsername']; } if ( empty( $comment_author ) && \get_option( 'require_name_email' ) ) { return false; } // Core's `pre_comment_author_name` chain sanitizes this column on the way in, so only // the slashing is ours: wp_new_comment() unslashes the whole array. $comment_author = \wp_slash( $comment_author ?? \__( 'Anonymous', 'activitypub' ) ); $comment_author_url = \esc_url_raw( object_to_uri( $actor['url'] ?? $actor['id'] ) ); $webfinger = Webfinger::uri_to_acct( $actor_id ); // Sanitized the way core stores it, so the handle that is checked below is the one that is matched. $comment_author_email = \is_wp_error( $webfinger ) ? '' : \sanitize_email( Sanitize::webfinger( $webfinger ) ); /* * Core's "previously approved" check matches name and email, so the email has to belong to the signed actor. * The actor's server may name any account, so the account has to point back to the actor. */ if ( '' !== $comment_author_email && Webfinger::resolve( $comment_author_email ) !== $actor_id ) { $comment_author_email = ''; } // Sanitize remote HTML before adding our own emoji and image blocks. $content = Sanitize::comment_content( $activity['object']['content'] ?? '' ); $content = Emoji::wrap_in_content( $content, $activity['object'] ); $comment_content = \addslashes( $content ); return array( 'comment_author' => $comment_author, 'comment_author_url' => $comment_author_url, 'comment_content' => $comment_content, 'comment_author_email' => $comment_author_email, 'comment_meta' => self::prepare_remote_comment_meta( $activity ), ); } /** * Get the image attachments displayed in a remote comment. * * @since 9.4.0 * * @param array $activity_object The ActivityPub object. * @param bool $check_hosts Optional. Whether the hosts must be public. Default true. * Validation before the signature check skips the DNS lookups. * @return array Image attachments. */ public static function get_image_attachments( $activity_object, $check_hosts = true ) { // WordPress does not show images in comments, so this is opt-in. if ( ! \get_option( 'activitypub_allow_comment_images' ) ) { return array(); } /** * Filters the maximum number of image attachments displayed in a Fediverse comment. * * @since 9.4.0 * * @param int $limit Maximum number of images. Default 3. Zero disables image attachments. * @param array $activity_object The ActivityPub object. */ $limit = \max( 0, (int) \apply_filters( 'activitypub_comment_image_limit', 3, $activity_object ) ); if ( ! $limit ) { return array(); } $images = array(); foreach ( Remote_Posts::extract_attachments( $activity_object, \max( 20, $limit ), 'image' ) as $attachment ) { // Comments show images to every visitor, so the host must be public. if ( ! $check_hosts || \wp_http_validate_url( $attachment['url'] ) ) { $images[] = $attachment; if ( \count( $images ) >= $limit ) { break; } } } return $images; } /** * Prepare ActivityPub-specific comment metadata. * * @since 9.4.0 * * @param array $activity The Activity array. * * @return array The comment metadata. */ private static function prepare_remote_comment_meta( $activity ) { $comment_meta = array( 'protocol' => 'activitypub', 'source_id' => \esc_url_raw( object_to_uri( $activity['object'] ) ), ); $actor_uri = object_to_uri( $activity['actor'] ?? null ); if ( $actor_uri ) { $remote_actor = Remote_Actors::get_by_uri( $actor_uri ); if ( ! \is_wp_error( $remote_actor ) ) { $comment_meta['_activitypub_remote_actor_id'] = $remote_actor->ID; } } if ( isset( $activity['object']['url'] ) ) { $comment_meta['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) ); } return $comment_meta; } /** * Persist a comment. * * @param array $comment_data The comment data array. * @param string $action Optional. Either 'insert' or 'update'. Default 'insert'. * * @return array|string|int|\WP_Error|false The comment data or false on failure */ public static function persist( $comment_data, $action = self::INSERT ) { if ( is_post_disabled( $comment_data['comment_post_ID'] ) && ! is_ap_post( $comment_data['comment_post_ID'] ) ) { return false; } $is_insert = self::INSERT === $action; $flood_priority = \has_action( 'check_comment_flood', 'check_comment_flood_db' ); $akismet_callback = array( self::class, 'akismet_comment_nonce_inactive' ); $kses_callback = array( self::class, 'allowed_comment_html' ); // Disable flood control, restoring it at its original priority afterwards. if ( false !== $flood_priority ) { \remove_action( 'check_comment_flood', 'check_comment_flood_db', $flood_priority ); } \add_filter( 'akismet_comment_nonce', $akismet_callback ); // No nonce possible for this submission route. \add_filter( 'wp_kses_allowed_html', $kses_callback, 10, 2 ); if ( $is_insert ) { $state = \wp_new_comment( $comment_data, true ); } else { $state = \wp_update_comment( $comment_data, true ); } \remove_filter( 'wp_kses_allowed_html', $kses_callback ); \remove_filter( 'akismet_comment_nonce', $akismet_callback ); if ( false !== $flood_priority ) { \add_action( 'check_comment_flood', 'check_comment_flood_db', $flood_priority, 4 ); } if ( ! $is_insert && 1 === $state ) { return $comment_data; } return $state; // Either a comment ID, false, a WP_Error, or 0. } /** * Get interaction counts grouped by comment type. * * Results are cached against WordPress's comment cache generation so inserts, * updates, deletions, and comment meta changes invalidate them automatically. * * @since 9.4.0 * * @param int $post_id The post ID. * * @return array Counts keyed by comment type. */ public static function get_counts( $post_id ) { $post_id = \absint( $post_id ); $last_changed = \wp_cache_get_last_changed( 'comment' ); $cache_key = "interaction_counts_{$post_id}:{$last_changed}"; $counts = \wp_cache_get( $cache_key, 'activitypub' ); if ( false !== $counts ) { return $counts; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cached against the core comment cache generation. $results = $wpdb->get_results( $wpdb->prepare( "SELECT comment_type, COUNT(*) AS total FROM {$wpdb->comments} WHERE comment_post_ID = %d AND comment_approved = '1' GROUP BY comment_type", $post_id ), ARRAY_A ); $counts = array(); foreach ( $results as $result ) { $counts[ $result['comment_type'] ] = (int) $result['total']; } \wp_cache_set( $cache_key, $counts, 'activitypub' ); return $counts; } /** * Get the total number of interactions by type for a given ID. * * @param int $post_id The post ID. * @param string $type The type of interaction to count. * * @return int The total number of interactions. */ public static function count_by_type( $post_id, $type ) { $counts = self::get_counts( $post_id ); $type = \sanitize_key( $type ); return $counts[ $type ] ?? 0; } /** * Get the quote URL from an activity. * * Checks for quote properties in priority order: quote -> quoteUrl -> quoteUri -> _misskey_quote. * * @param array $activity The activity array. * * @return string|false The quote URL or false if not found. */ public static function get_quote_url( $activity ) { return get_quote_url( $activity ); } }