PluginProbe
Friends / 4.3.1
Friends v4.3.1
4.3.2 4.3.1 4.3.0 4.2.2 4.2.1 4.2.0 4.1.0 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.1 All 88 releases
friends / feed-parsers / class-feed-parser-activitypub.php

class-feed-parser-activitypub.php in Friends 4.3.1, at feed-parsers/class-feed-parser-activitypub.php

5,526 lines 184.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Friends ActivityPub Parser
4 *
5 * With this parser, we can talk to ActivityPub.
6 *
7 * @since @2.1.4
8 *
9 * @package Friends
10 * @author Alex Kirk
11 */
12
13 namespace Friends;
14
15 use WP_Error;
16 use Enable_Mastodon_Apps\Entity\Account as Entity_Account;
17
18 /**
19 * This is the class for integrating ActivityPub into the Friends Plugin.
20 */
21 class Feed_Parser_ActivityPub extends Feed_Parser_V2 {
22 const SLUG = 'activitypub';
23 const NAME = 'ActivityPub';
24 const URL = 'https://www.w3.org/TR/activitypub/';
25 const ACTIVITYPUB_USERNAME_REGEXP = '(?:([A-Za-z0-9_.-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))';
26 const EXTERNAL_USERNAME = 'external';
27
28 private $activitypub_already_handled = array();
29 private $mapped_usernames = array();
30 private $friends_feed;
31
32 /**
33 * Constructor.
34 *
35 * @param Feed $friends_feed The friends feed.
36 */
37 public function __construct( Feed $friends_feed ) {
38 $this->friends_feed = $friends_feed;
39
40 \add_action( 'init', array( $this, 'register_post_meta' ) );
41 \add_filter( 'feed_item_allow_set_metadata', array( $this, 'feed_item_allow_set_metadata' ), 10, 3 );
42 \add_filter( 'friends_add_friends_input_placeholder', array( $this, 'friends_add_friends_input_placeholder' ) );
43 \add_action( 'friends_add_friend_form_top', array( $this, 'friends_add_friend_form_top' ) );
44
45 \add_action( 'activitypub_inbox_create', array( $this, 'handle_received_create' ), 15, 2 );
46 \add_filter( 'friends_feed_badge', array( $this, 'friends_feed_badge' ), 10, 3 );
47 \add_filter( 'friends_feed_list_title', array( $this, 'friends_feed_list_title' ), 10, 3 );
48 \add_action( 'friends_edit_feed_content_top', array( $this, 'render_feed_edit_content' ), 10, 3 );
49 \add_action( 'activitypub_inbox_delete', array( $this, 'handle_received_delete' ), 15, 2 );
50 \add_action( 'activitypub_inbox_announce', array( $this, 'handle_received_announce' ), 15, 2 );
51 \add_action( 'activitypub_inbox_like', array( $this, 'handle_received_like' ), 15, 2 );
52 \add_action( 'activitypub_inbox_undo', array( $this, 'handle_received_undo' ), 15, 2 );
53 \add_action( 'activitypub_inbox_move', array( $this, 'handle_received_move' ), 15, 2 );
54 \add_action( 'activitypub_inbox_update', array( $this, 'handle_received_update' ), 15, 2 );
55 \add_action( 'activitypub_handled_create', array( $this, 'activitypub_handled_create' ), 10, 4 );
56 \add_action( 'activitypub_interactions_follow_url', array( $this, 'activitypub_interactions_follow_url' ), 10, 2 );
57 \add_filter( 'activitypub_comment_post_id', array( $this, 'activitypub_comment_post_id' ), 10, 3 );
58 \add_filter( 'activitypub_is_post_disabled', array( $this, 'disable_activitypub_for_cached_posts' ), 10, 2 );
59 \add_filter( 'activitypub_is_post_publicly_queryable', array( $this, 'activitypub_cached_post_publicly_queryable' ), 10, 2 );
60
61 \add_action( 'friends_user_feed_activated', array( $this, 'queue_follow_user' ), 10 );
62 \add_action( 'friends_user_feed_deactivated', array( $this, 'queue_unfollow_user' ), 10 );
63 \add_action( 'friends_suggest_display_name', array( $this, 'suggest_display_name' ), 10, 2 );
64 \add_action( 'friends_feed_parser_activitypub_like', array( $this, 'activitypub_like_post' ), 10, 3 );
65 \add_action( 'friends_feed_parser_activitypub_unlike', array( $this, 'activitypub_unlike_post' ), 10, 3 );
66 \add_action( 'friends_feed_parser_activitypub_announce', array( $this, 'activitypub_announce' ), 10, 2 );
67 \add_action( 'friends_feed_parser_activitypub_unannounce', array( $this, 'activitypub_unannounce' ), 10, 2 );
68 \add_filter( 'friends_rewrite_incoming_url', array( get_called_class(), 'friends_webfinger_resolve' ), 10, 2 );
69
70 \add_filter( 'friends_edit_feeds_table_end', array( $this, 'activitypub_settings' ), 10 );
71 \add_filter( 'friends_edit_feeds_after_form_submit', array( $this, 'activitypub_save_settings' ), 10 );
72 \add_filter( 'friends_modify_feed_item', array( $this, 'modify_incoming_item' ), 9, 3 );
73 \add_filter( 'friends_potential_avatars', array( $this, 'friends_potential_avatars' ), 10, 2 );
74 \add_filter( 'friends_suggest_user_login', array( $this, 'suggest_user_login_from_url' ), 10, 2 );
75 \add_filter( 'friends_author_avatar_url', array( $this, 'author_avatar_url' ), 10, 3 );
76 \add_filter( 'friends_author_url', array( $this, 'author_url' ), 10, 3 );
77
78 \add_action( 'template_redirect', array( $this, 'cache_reply_to_boost' ) );
79 \add_filter( 'the_content', array( $this, 'the_content' ), 99, 2 );
80 \add_filter( 'activitypub_extract_mentions', array( $this, 'activitypub_extract_mentions' ), 10, 2 );
81 \add_filter( 'activitypub_extract_mentions', array( $this, 'activitypub_extract_in_reply_to_mentions' ), 10, 3 );
82 \add_filter( 'mastodon_api_external_mentions_user', array( $this, 'get_external_user' ) );
83 \add_filter( 'activitypub_rest_following', array( $this, 'activitypub_rest_following' ), 10, 2 );
84
85 \add_action( 'friends_user_post_reaction', array( $this, 'post_reaction' ) );
86 \add_action( 'friends_user_post_undo_reaction', array( $this, 'undo_post_reaction' ) );
87 \add_action( 'mastodon_api_react', array( $this, 'mastodon_api_react' ), 10, 2 );
88 \add_action( 'mastodon_api_unreact', array( $this, 'mastodon_api_unreact' ), 10, 2 );
89 \add_action( 'friends_get_reaction_display_name', array( $this, 'get_reaction_display_name' ), 10, 2 );
90
91 \add_filter( 'pre_comment_approved', array( $this, 'pre_comment_approved' ), 10, 2 );
92 \add_action( 'wp_insert_comment', array( $this, 'prepare_cached_post_for_comment_federation' ), 5, 2 );
93 \add_action( 'comment_post', array( $this, 'comment_post' ), 10, 3 );
94 \add_action( 'trashed_comment', array( $this, 'trashed_comment' ), 10, 2 );
95 \add_filter( 'friends_get_comments', array( $this, 'get_remote_comments' ), 10, 4 );
96 \add_filter( 'friends_comments_content', array( $this, 'append_comment_form' ), 10, 4 );
97 add_filter( 'comment_post_redirect', array( $this, 'comment_post_redirect' ), 10, 2 );
98
99 add_action( 'friends_post_footer_first', array( $this, 'boost_button' ) );
100 \add_filter( 'friends_search_autocomplete', array( $this, 'friends_search_autocomplete' ), 10, 2 );
101
102 add_action( 'wp_ajax_friends-boost', array( $this, 'ajax_boost' ) );
103 \add_action( 'mastodon_api_reblog', array( $this, 'mastodon_api_reblog' ) );
104 \add_action( 'mastodon_api_unreblog', array( $this, 'mastodon_api_unreblog' ) );
105
106 \add_filter( 'pre_get_remote_metadata_by_actor', array( $this, 'disable_webfinger_for_example_domains' ), 9, 2 );
107
108 add_filter( 'friends_get_feed_metadata', array( $this, 'friends_get_feed_metadata' ), 10, 2 );
109 add_filter( 'friends_get_activitypub_metadata', array( $this, 'friends_activitypub_metadata' ), 10, 2 );
110
111 add_filter( 'mastodon_api_mapback_user_id', array( $this, 'mastodon_api_mapback_user_id' ), 30, 4 );
112 add_filter( 'friends_mastodon_api_username', array( $this, 'friends_mastodon_api_username' ) );
113 add_filter( 'mastodon_api_status', array( $this, 'mastodon_api_status_add_reblogs' ), 40, 3 );
114 add_filter( 'mastodon_api_canonical_user_id', array( $this, 'mastodon_api_canonical_user_id' ), 20, 3 );
115 add_filter( 'mastodon_api_valid_user', array( $this, 'mastodon_api_valid_user' ), 15, 2 );
116 add_filter( 'mastodon_api_comment_parent_post_id', array( $this, 'mastodon_api_in_reply_to_id' ), 25 );
117 add_filter( 'mastodon_api_in_reply_to_id', array( $this, 'mastodon_api_in_reply_to_id' ), 25 );
118 add_filter( 'friends_cache_url_post_id', array( $this, 'check_url_to_postid' ), 10, 2 );
119
120 add_filter( 'friends_author_display_name_html', array( self::class, 'author_display_name_html' ), 10, 3 );
121
122 add_action( 'friends_post_author_meta', array( self::class, 'friends_post_author_meta' ) );
123 add_action( 'friends_get_template_part_frontend/parts/header-menu', array( self::class, 'header_menu' ) );
124 add_action( 'friends_comments_form', array( self::class, 'comment_form' ) );
125 add_action( 'comments_open', array( self::class, 'enable_comments_form' ), 10, 2 );
126 add_action( 'wp_ajax_friends-preview-activitypub', array( $this, 'ajax_preview' ) );
127 add_action( 'wp_ajax_friends-delete-follower', array( $this, 'ajax_delete_follower' ) );
128 add_action( 'wp_ajax_friends_check_activitypub_subscription', array( $this, 'ajax_check_subscription' ) );
129 add_action( 'wp_ajax_friends_check_activitypub_follower', array( $this, 'ajax_check_follower' ) );
130 add_action( 'wp_ajax_friends_relink_activitypub_actor', array( $this, 'ajax_relink_actor' ) );
131 add_action( 'wp_ajax_friends_refollow_activitypub', array( $this, 'ajax_refollow' ) );
132 add_action( 'friends_edit_feed_content_top', array( $this, 'render_subscription_check_button' ), 10, 3 );
133
134 add_action( 'mastodon_api_account_following', array( $this, 'mastodon_api_account_following' ), 10, 2 );
135 add_action( 'mastodon_api_account', array( $this, 'mastodon_api_account' ), 9, 2 );
136 add_filter( 'mastodon_api_account', array( $this, 'mastodon_api_account_external_user' ), 15, 4 );
137 add_action( 'friends_message_form_accounts', array( $this, 'friends_message_form_accounts' ), 10, 2 );
138 add_action( 'friends_send_direct_message', array( $this, 'friends_send_direct_message' ), 20, 6 );
139 add_action( 'activitypub_pre_send_to_inboxes', array( $this, 'record_direct_message_delivery_targets' ), 10, 3 );
140 add_action( 'activitypub_sent_to_inbox', array( $this, 'record_direct_message_delivery_result' ), 10, 5 );
141
142 // Auto-create Friend subscription when following via ActivityPub plugin.
143 add_action( 'post_activitypub_add_to_outbox', array( $this, 'handle_outbox_follow' ), 10, 4 );
144 }
145
146 public function friends_add_friends_input_placeholder() {
147 return __( 'Enter URL or @activitypub@handle.domain', 'friends' );
148 }
149
150 public function friends_add_friend_form_top() {
151 ?>
152 <p>
153 <?php
154 echo wp_kses(
155 sprintf(
156 // translators: %1$s and %2$s are links to the respective services.
157 __( '<strong>Note:</strong> Because you have the ActivityPub plugin installed, you can also follow people over that protocol, for example <a href=%1$s>Mastodon</a> or <a href=%2$s>Pixelfed</a>.', 'friends' ),
158 '"https://joinmastodon.org/"',
159 '"https://pixelfed.social/"'
160 ),
161 array(
162 'a' => array( 'href' => array() ),
163 'strong' => array(),
164 )
165 );
166 ?>
167 </p>
168 <?php
169 }
170
171 public function friends_get_feed_metadata( $meta, $feed ) {
172 if ( self::SLUG === $feed->get_parser() ) {
173 return $this->friends_activitypub_metadata( $meta, $feed->get_url() );
174 }
175 return $meta;
176 }
177
178 public function friends_activitypub_metadata( $ret, $url ) {
179 $meta = self::get_metadata( $url );
180 if ( is_null( $meta ) ) {
181 return $ret;
182 }
183 if ( is_wp_error( $meta ) ) {
184 if ( ! empty( $ret ) ) {
185 return $ret;
186 }
187 return $meta;
188 }
189 return array_merge( $ret, $meta );
190 }
191
192
193 public static function count_followers( $user_id ) {
194 if ( method_exists( '\ActivityPub\Collection\Followers', 'count' ) ) {
195 return \ActivityPub\Collection\Followers::count( $user_id );
196 }
197 return \ActivityPub\Collection\Followers::count_followers( $user_id );
198 }
199
200 /**
201 * Count mutual followers (followers you also follow back) with transient caching.
202 *
203 * @param int $user_id The user ID.
204 * @return int The number of mutual followers.
205 */
206 public static function count_mutual_followers( $user_id ) {
207 $cache_key = 'friends_mutual_count_' . $user_id;
208 $count = get_transient( $cache_key );
209 if ( false !== $count ) {
210 return (int) $count;
211 }
212
213 $count = 0;
214 if ( class_exists( '\ActivityPub\Collection\Followers' ) ) {
215 $follower_data = \ActivityPub\Collection\Followers::query( $user_id );
216 foreach ( $follower_data['followers'] as $follower ) {
217 $url = $follower->guid;
218 if ( ! $url ) {
219 continue;
220 }
221 $is_following = User_Feed::get_by_url( $url );
222 if ( ! $is_following || is_wp_error( $is_following ) ) {
223 $is_following = User_Feed::get_by_url( str_replace( '@', 'users/', $url ) );
224 }
225 if ( $is_following && ! is_wp_error( $is_following ) ) {
226 ++$count;
227 }
228 }
229 }
230
231 set_transient( $cache_key, $count, HOUR_IN_SECONDS );
232 return $count;
233 }
234
235 public static function determine_mastodon_api_user( $user_id ) {
236 static $user_id_map = array();
237 if ( null === $user_id ) {
238 return false;
239 }
240 $cache_key = is_scalar( $user_id ) ? $user_id : '';
241 if ( $cache_key && isset( $user_id_map[ $cache_key ] ) ) {
242 return $user_id_map[ $cache_key ];
243 }
244 $user = false;
245 if ( is_string( $user_id ) && ! is_numeric( $user_id ) ) {
246 $user = User::get_by_username( $user_id );
247 if ( ! $user ) {
248 $url = self::friends_webfinger_resolve( $user_id, $user_id );
249 $user_feed = User_Feed::get_by_url( $url );
250 if ( $user_feed && ! is_wp_error( $user_feed ) ) {
251 $user = $user_feed->get_friend_user();
252 }
253 }
254 } elseif ( ! is_wp_error( $user_id ) ) {
255 $user = User::get_user_by_id( $user_id );
256 if ( ! $user ) {
257 $user = User::get_user_by_id( 1e10 + $user_id );
258 }
259 }
260 if ( $cache_key ) {
261 $user_id_map[ $cache_key ] = $user;
262 }
263 return $user;
264 }
265
266
267 public function mastodon_api_mapback_user_id( $user_id ) {
268 if ( is_numeric( $user_id ) && $user_id < 1e10 ) {
269 return absint( $user_id );
270 }
271
272 $user = self::determine_mastodon_api_user( $user_id );
273 if ( $user ) {
274 return $user->ID;
275 }
276 return $user_id;
277 }
278
279 public function mastodon_api_status_add_reblogs( $status, $post_id, $request = null ) {
280 if ( Friends::CPT !== get_post_type( $post_id ) ) {
281 return $status;
282 }
283
284 $meta = get_post_meta( $post_id, self::SLUG, true );
285
286 // Only process reblogs with attributedTo.
287 if ( ! isset( $meta['reblog'] ) || ! $meta['reblog'] || ! isset( $meta['attributedTo'] ) ) {
288 return $status;
289 }
290
291 $attributed_to_url = self::get_actor_url_from_attributed_to( $meta['attributedTo'] );
292 if ( ! $attributed_to_url ) {
293 return $status;
294 }
295
296 $status->reblog = clone $status;
297 $status->reblog->account = clone $status->account;
298 $status->reblog->id = \Enable_Mastodon_Apps\Mastodon_API::remap_reblog_id( $status->reblog->id );
299
300 $account = false;
301 $friend_user = User::get_post_author( get_post( $post_id ) );
302 $external_user = $this->get_external_user();
303 $is_external_user = get_class( $external_user ) === get_class( $friend_user ) && $external_user->get_object_id() === $friend_user->get_object_id();
304
305 if ( $is_external_user ) {
306 $feed_url = get_post_meta( $post_id, 'feed_url', true );
307 if ( $feed_url ) {
308 $actor = self::get_actor_acct_from_attributed_to( $meta['attributedTo'] );
309 if ( ! $actor ) {
310 $actor = self::convert_actor_to_mastodon_handle( $feed_url );
311 }
312 $account = new Entity_Account();
313 $account->id = $feed_url;
314 $account->username = strtok( $actor, '@' );
315 $account->acct = $actor;
316 $account->display_name = $friend_user->display_name;
317 $account->url = $feed_url;
318 $account->note = $friend_user->description;
319 if ( ! $account->note ) {
320 $account->note = '';
321 }
322
323 $account->avatar = $friend_user->avatar;
324 if ( ! $account->avatar ) {
325 $account->avatar = '';
326 }
327
328 $account->avatar_static = $account->avatar;
329 $account->header = 'https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png';
330 $actor_metadata = self::get_actor_metadata_from_attributed_to( $meta['attributedTo'] );
331 if ( ! empty( $actor_metadata['header'] ) ) {
332 $account->header = $actor_metadata['header'];
333 }
334 $account->header_static = $account->header;
335 $account->created_at = $status->created_at;
336 }
337 } elseif ( $friend_user instanceof User ) {
338 $account = apply_filters( 'mastodon_api_account', null, $friend_user->ID, null, $post_id );
339 }
340
341 if ( $account instanceof Entity_Account ) {
342 $status->account = $account;
343
344 // Try to build reblog account from attributedTo metadata first (fast path).
345 $actor_metadata = self::get_actor_metadata_from_attributed_to( $meta['attributedTo'] );
346 if ( ! empty( $actor_metadata['preferredUsername'] ) ) {
347 $status->reblog->account->id = $attributed_to_url;
348 $status->reblog->account->username = $actor_metadata['preferredUsername'];
349 $acct = self::get_actor_acct_from_attributed_to( $meta['attributedTo'] );
350 if ( ! $acct ) {
351 $acct = self::convert_actor_to_mastodon_handle( $attributed_to_url );
352 }
353 $status->reblog->account->acct = $acct;
354 if ( ! empty( $actor_metadata['name'] ) ) {
355 $status->reblog->account->display_name = $actor_metadata['name'];
356 }
357 if ( ! empty( $actor_metadata['summary'] ) ) {
358 $status->reblog->account->note = $actor_metadata['summary'];
359 }
360 if ( ! empty( $actor_metadata['icon'] ) ) {
361 $status->reblog->account->avatar = $actor_metadata['icon'];
362 $status->reblog->account->avatar_static = $actor_metadata['icon'];
363 }
364 if ( ! empty( $actor_metadata['header'] ) ) {
365 $status->reblog->account->header = $actor_metadata['header'];
366 $status->reblog->account->header_static = $actor_metadata['header'];
367 }
368 $status->reblog->account->url = $attributed_to_url;
369 } else {
370 // Fall back to filter chain if metadata is insufficient.
371 $reblog_account = apply_filters( 'mastodon_api_account', null, $attributed_to_url );
372 if ( $reblog_account instanceof Entity_Account ) {
373 $status->reblog->account = $reblog_account;
374 }
375 }
376 }
377
378 return $status;
379 }
380
381 /**
382 * Get the badge for ActivityPub feeds.
383 *
384 * @return array Badge info.
385 */
386 public function get_badge() {
387 return array(
388 'label' => 'AP',
389 'color' => '#f6ad55',
390 'title' => __( 'ActivityPub', 'friends' ),
391 );
392 }
393
394 /**
395 * Filter the feed title to use actor name as fallback for ActivityPub feeds.
396 *
397 * @param string $title The feed title.
398 * @param User_Feed $feed The feed object.
399 * @param string $parser The parser slug.
400 * @return string The feed title.
401 */
402 public function friends_feed_list_title( $title, $feed, $parser ) {
403 if ( 'activitypub' !== $parser || $title ) {
404 return $title;
405 }
406
407 if ( ! \class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
408 return $title;
409 }
410
411 // Try to get actor by post ID first.
412 $ap_actor_id = $feed->get_ap_actor_id();
413 $ap_actor_post = $ap_actor_id ? \get_post( $ap_actor_id ) : null;
414
415 // Fallback: look up by URL.
416 if ( ! $ap_actor_post || 'ap_actor' !== $ap_actor_post->post_type ) {
417 $ap_actor_post = \Activitypub\Collection\Remote_Actors::get_by_uri( $feed->get_url() );
418 }
419
420 if ( $ap_actor_post && ! is_wp_error( $ap_actor_post ) ) {
421 return $ap_actor_post->post_title;
422 }
423
424 return $title;
425 }
426
427 /**
428 * Filter the feed badge for ActivityPub feeds to add extra info.
429 *
430 * @param array|null $badge The badge array.
431 * @param User_Feed $user_feed The user feed.
432 * @param string $parser The parser slug.
433 * @return array|null The badge array.
434 */
435 public function friends_feed_badge( $badge, $user_feed, $parser ) {
436 if ( 'activitypub' !== $parser ) {
437 return $badge;
438 }
439
440 if ( $user_feed && $user_feed->get_ap_actor_id() ) {
441 $badge['title'] = __( 'Linked to ActivityPub plugin', 'friends' );
442 }
443
444 return $badge;
445 }
446
447 /**
448 * Render extra content in the feed edit section for ActivityPub feeds.
449 *
450 * @param User_Feed $feed The feed being edited.
451 * @param int $term_id The term ID of the feed.
452 * @param string $parser The parser slug.
453 */
454 public function render_feed_edit_content( $feed, $term_id, $parser ) {
455 if ( 'activitypub' !== $parser ) {
456 return;
457 }
458
459 if ( ! \class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
460 return;
461 }
462
463 // Try to get actor by post ID first, then by URL.
464 $ap_actor_id = $feed->get_ap_actor_id();
465 $ap_actor_post = $ap_actor_id ? \get_post( $ap_actor_id ) : null;
466 $ap_actor_source = $ap_actor_id ? 'taxonomy' : null;
467
468 // Fallback: look up by URL if we have the post type but not a valid post.
469 if ( ! $ap_actor_post || 'ap_actor' !== $ap_actor_post->post_type ) {
470 $ap_actor_post = \Activitypub\Collection\Remote_Actors::get_by_uri( $feed->get_url() );
471 if ( $ap_actor_post && ! is_wp_error( $ap_actor_post ) ) {
472 $ap_actor_id = $ap_actor_post->ID;
473 $ap_actor_source = 'url_lookup';
474 }
475 }
476
477 if ( ! $ap_actor_post || is_wp_error( $ap_actor_post ) ) {
478 return;
479 }
480
481 $ap_actor_acct = \Activitypub\Collection\Remote_Actors::get_acct( $ap_actor_id );
482 $follow_status = null;
483 if ( \class_exists( '\Activitypub\Collection\Following' ) ) {
484 $follow_status = \Activitypub\Collection\Following::check_status( self::get_activitypub_actor_id( null ), $ap_actor_id );
485 }
486 ?>
487 <div class="activitypub-plugin-data" data-feed-id="<?php echo \esc_attr( $term_id ); ?>">
488 <div class="ap-section-header"><?php \esc_html_e( 'ActivityPub Plugin', 'friends' ); ?></div>
489 <input type="hidden" name="feeds[<?php echo \esc_attr( $term_id ); ?>][url]" value="<?php echo \esc_attr( $feed->get_url() ); ?>" />
490 <div class="ap-data-grid subscription-status">
491 <span class="ap-data-label"><?php \esc_html_e( 'Actor', 'friends' ); ?></span>
492 <span class="ap-data-value">
493 <span class="ap-actor-name"><?php echo \esc_html( $ap_actor_post->post_title ); ?></span>
494 <?php if ( $ap_actor_acct ) : ?>
495 <span class="ap-actor-acct">@<?php echo \esc_html( $ap_actor_acct ); ?></span>
496 <?php endif; ?>
497 </span>
498 <span class="ap-data-label"><?php \esc_html_e( 'Profile', 'friends' ); ?></span>
499 <span class="ap-data-value">
500 <a href="<?php echo \esc_url( $ap_actor_post->guid ); ?>" target="_blank" rel="noopener noreferrer"><?php echo \esc_html( $ap_actor_post->guid ); ?></a>
501 </span>
502 <span class="ap-data-label"><?php \esc_html_e( 'Actor Post', 'friends' ); ?></span>
503 <span class="ap-data-value">
504 <code>ap_actor</code>
505 <a href="<?php echo \esc_url( \admin_url( 'post.php?post=' . $ap_actor_id . '&action=edit' ) ); ?>">ID <?php echo \esc_html( $ap_actor_id ); ?></a>
506 <em style="color: <?php echo 'taxonomy' === $ap_actor_source ? 'green' : 'orange'; ?>;">(<?php echo \esc_html( $ap_actor_source ); ?>)</em>
507 </span>
508 <?php if ( 'url_lookup' === $ap_actor_source ) : ?>
509 <span class="ap-data-label"></span>
510 <span class="ap-data-value">
511 <button type="button" class="button button-small relink-actor-btn"
512 data-nonce="<?php echo \esc_attr( \wp_create_nonce( 'friends-relink-actor' ) ); ?>">
513 <?php \esc_html_e( 'Re-link actor', 'friends' ); ?>
514 </button>
515 </span>
516 <?php endif; ?>
517 <span class="ap-data-label"><?php \esc_html_e( 'Follow status', 'friends' ); ?></span>
518 <span class="ap-data-value">
519 <?php if ( 'accepted' === $follow_status ) : ?>
520 <em style="color: green;"><?php \esc_html_e( 'accepted', 'friends' ); ?></em>
521 <?php elseif ( 'pending' === $follow_status ) : ?>
522 <em style="color: orange;"><?php \esc_html_e( 'pending', 'friends' ); ?></em>
523 <button type="button" class="button button-small refollow-btn"
524 data-nonce="<?php echo \esc_attr( \wp_create_nonce( 'friends-refollow-activitypub' ) ); ?>">
525 <?php \esc_html_e( 'Re-follow', 'friends' ); ?>
526 </button>
527 <?php elseif ( false === $follow_status ) : ?>
528 <em style="color: orange;"><?php \esc_html_e( 'not managed by ActivityPub plugin', 'friends' ); ?></em>
529 <button type="button" class="button button-small refollow-btn"
530 data-nonce="<?php echo \esc_attr( \wp_create_nonce( 'friends-refollow-activitypub' ) ); ?>">
531 <?php \esc_html_e( 'Re-follow', 'friends' ); ?>
532 </button>
533 <?php else : ?>
534 <em><?php \esc_html_e( 'unknown', 'friends' ); ?></em>
535 <?php endif; ?>
536 </span>
537 <span class="ap-data-label"></span>
538 <span class="ap-data-value">
539 <button type="button" class="button button-small check-subscription-btn">
540 <?php \esc_html_e( 'Check Subscription', 'friends' ); ?>
541 </button>
542 <span class="spinner" style="float: none;"></span>
543 </span>
544 </div>
545 <div class="ap-section-footer">
546 <?php
547 echo \wp_kses(
548 \sprintf(
549 /* translators: %s is a link to the ActivityPub following list. */
550 \__( 'Managed by the <a href="%s">ActivityPub plugin</a>.', 'friends' ),
551 \esc_url( \admin_url( 'users.php?page=activitypub-following-list' ) )
552 ),
553 array( 'a' => array( 'href' => array() ) )
554 );
555 ?>
556 </div>
557 </div>
558 <?php $friend_login = ( $feed->get_friend_user() ) ? $feed->get_friend_user()->user_login : ''; ?>
559 <script>
560 jQuery( function( $ ) {
561 var $container = $( '.activitypub-plugin-data[data-feed-id="<?php echo \esc_js( $term_id ); ?>"]' );
562 if ( $container.data( 'bound' ) ) return;
563 $container.data( 'bound', true );
564
565 $container.on( 'click', '.relink-actor-btn', function() {
566 var $btn = $( this );
567 var $spinner = $container.find( '.spinner' );
568 var $grid = $container.find( '.subscription-status' );
569
570 $btn.prop( 'disabled', true );
571 $spinner.addClass( 'is-active' );
572
573 $.post( ajaxurl, {
574 action: 'friends_relink_activitypub_actor',
575 feed_id: <?php echo \intval( $term_id ); ?>,
576 _ajax_nonce: $btn.data( 'nonce' )
577 }, function( response ) {
578 $spinner.removeClass( 'is-active' );
579 if ( response.success ) {
580 $grid.find( '.relink-actor-btn' ).closest( 'span.ap-data-value' ).prev().addBack().remove();
581 $grid.find( '[style*="url_lookup"]' ).text( '(taxonomy)' ).css( 'color', 'green' );
582 } else {
583 $btn.prop( 'disabled', false );
584 }
585 } );
586 } );
587
588 $container.on( 'click', '.refollow-btn', function() {
589 var $btn = $( this );
590 var $spinner = $container.find( '.spinner' );
591 var $grid = $container.find( '.subscription-status' );
592
593 $btn.prop( 'disabled', true );
594 $spinner.addClass( 'is-active' );
595
596 $.post( ajaxurl, {
597 action: 'friends_refollow_activitypub',
598 feed_id: <?php echo \intval( $term_id ); ?>,
599 _ajax_nonce: $btn.data( 'nonce' )
600 }, function( response ) {
601 $spinner.removeClass( 'is-active' );
602 if ( response.success ) {
603 $btn.replaceWith( '<em style="color:green;">' + response.data.message + '</em>' );
604 } else {
605 $btn.prop( 'disabled', false );
606 }
607 } );
608 } );
609
610 $container.on( 'click', '.check-subscription-btn', function() {
611 var $btn = $( this );
612 var $spinner = $container.find( '.spinner' );
613 var $grid = $container.find( '.subscription-status' );
614
615 $btn.prop( 'disabled', true );
616 $spinner.addClass( 'is-active' );
617
618 $.post( ajaxurl, {
619 action: 'friends_check_activitypub_subscription',
620 feed_id: <?php echo \intval( $term_id ); ?>,
621 _ajax_nonce: '<?php echo \esc_js( \wp_create_nonce( 'friends-check-subscription' ) ); ?>'
622 }, function( response ) {
623 $spinner.removeClass( 'is-active' );
624 $btn.prop( 'disabled', false );
625
626 // Remove rows from any previous check.
627 $grid.find( '.ap-check-result' ).remove();
628
629 if ( ! response.success ) {
630 $btn.closest( 'span.ap-data-value' ).prev( 'span.ap-data-label' ).before(
631 '<span class="ap-data-label ap-check-result"></span>'
632 + '<span class="ap-data-value ap-check-result" style="color:red;">' + response.data + '</span>'
633 );
634 return;
635 }
636
637 var data = response.data;
638 var color = data.status === 'ok' ? 'green' : ( data.status === 'error' ? 'red' : 'orange' );
639 var rows = '<span class="ap-data-label ap-check-result"><?php echo \esc_js( __( 'Status', 'friends' ) ); ?></span>'
640 + '<span class="ap-data-value ap-check-result"><em style="color:' + color + ';">' + data.messages.join( '<br>' ) + '</em></span>';
641
642 if ( data.latest_remote ) {
643 rows += '<span class="ap-data-label ap-check-result"><?php echo \esc_js( __( 'Latest remote post', 'friends' ) ); ?></span>'
644 + '<span class="ap-data-value ap-check-result">' + data.latest_remote + '</span>';
645 }
646 if ( data.latest_local ) {
647 rows += '<span class="ap-data-label ap-check-result"><?php echo \esc_js( __( 'Latest local post', 'friends' ) ); ?></span>'
648 + '<span class="ap-data-value ap-check-result">' + data.latest_local + '</span>';
649 }
650
651 if ( data.can_fetch ) {
652 rows += '<span class="ap-data-label ap-check-result"></span>'
653 + '<span class="ap-data-value ap-check-result"><button type="button" class="button button-small fetch-posts-btn">'
654 + '<?php echo \esc_js( __( 'Fetch posts', 'friends' ) ); ?></button></span>';
655 }
656
657 // Insert before the label of the Check Subscription button row (once, not twice).
658 $btn.closest( 'span.ap-data-value' ).prev( 'span.ap-data-label' ).before( rows );
659 } );
660 } );
661
662 $container.on( 'click', '.fetch-posts-btn', function() {
663 var $btn = $( this );
664 var $spinner = $container.find( '.spinner' );
665
666 $btn.prop( 'disabled', true );
667 $spinner.addClass( 'is-active' );
668
669 $.post( ajaxurl, {
670 action: 'friends_fetch_feeds',
671 friend: '<?php echo \esc_js( $friend_login ); ?>',
672 _ajax_nonce: '<?php echo \esc_js( \wp_create_nonce( 'fetch-feeds-' . $friend_login ) ); ?>'
673 }, function( response ) {
674 $spinner.removeClass( 'is-active' );
675 if ( response.success ) {
676 $btn.replaceWith( '<em style="color:green;"><?php echo \esc_js( __( 'Posts fetched.', 'friends' ) ); ?></em>' );
677 } else {
678 $btn.prop( 'disabled', false );
679 }
680 } );
681 } );
682 } );
683 </script>
684 <?php
685 }
686
687 public function suggest_display_name( $display_name, $url ) {
688 $meta = $this->get_metadata( $url );
689 if ( is_wp_error( $meta ) ) {
690 return $display_name;
691 }
692 if ( isset( $meta['name'] ) ) {
693 return $meta['name'];
694 }
695 if ( isset( $meta['preferredUsername'] ) ) {
696 return $meta['preferredUsername'];
697 }
698 return $display_name;
699 }
700
701 public function friends_mastodon_api_username( $user_id ) {
702 if ( ! isset( $this->mapped_usernames[ $user_id ] ) ) {
703 $user = User::get_user_by_id( $user_id );
704 if ( $user ) {
705 foreach ( $user->get_active_feeds() as $user_feed ) {
706 if ( 'activitypub' === $user_feed->get_parser() ) {
707 $acct = self::get_actor_acct_from_user_feed( $user_feed );
708 $this->mapped_usernames[ $user_id ] = $acct ? $acct : self::convert_actor_to_mastodon_handle( $user_feed->get_url() );
709 break;
710 }
711 }
712 }
713 }
714
715 if ( ! isset( $this->mapped_usernames[ $user_id ] ) ) {
716 $this->mapped_usernames[ $user_id ] = $user_id;
717 }
718
719 return $this->mapped_usernames[ $user_id ];
720 }
721
722 public function mastodon_api_in_reply_to_id( $in_reply_to_id ) {
723 $in_reply_to_id = \Enable_Mastodon_Apps\Mastodon_API::maybe_get_remapped_url( $in_reply_to_id );
724 if ( ! is_string( $in_reply_to_id ) ) {
725 return $in_reply_to_id;
726 }
727 if ( filter_var( $in_reply_to_id, FILTER_VALIDATE_URL ) ) {
728 $in_reply_to_id = $this->cache_url( $in_reply_to_id );
729 }
730 return $in_reply_to_id;
731 }
732
733 public function mastodon_api_valid_user( $is_valid_user, $user_id ) {
734 if ( ! $is_valid_user ) {
735 $mapped_user_id = $this->mastodon_api_canonical_user_id( $user_id );
736 if ( $mapped_user_id ) {
737 return true;
738 }
739 }
740 return $is_valid_user;
741 }
742
743 public function mastodon_api_canonical_user_id( $user_id ) {
744 static $user_id_map = array();
745 if ( ! isset( $user_id_map[ $user_id ] ) && is_string( $user_id ) ) {
746 $user_feed = User_Feed::get_by_url( $user_id );
747 if ( $user_feed && ! is_wp_error( $user_feed ) ) {
748 $acct = self::get_actor_acct_from_user_feed( $user_feed );
749 $user_id_map[ $user_id ] = $acct ? $acct : self::convert_actor_to_mastodon_handle( $user_feed->get_url() );
750 }
751 }
752 if ( ! isset( $user_id_map[ $user_id ] ) ) {
753 if ( is_string( $user_id ) && ! is_numeric( $user_id ) ) {
754 $user = User::get_by_username( $user_id );
755 } else {
756 $user = User::get_user_by_id( $user_id );
757 if ( ! $user ) {
758 $user = User::get_user_by_id( 1e10 + $user_id );
759 }
760 }
761 if ( $user ) {
762 foreach ( $user->get_active_feeds() as $user_feed ) {
763 if ( 'activitypub' === $user_feed->get_parser() ) {
764 $acct = self::get_actor_acct_from_user_feed( $user_feed );
765 $user_id_map[ $user_id ] = $acct ? $acct : self::convert_actor_to_mastodon_handle( $user_feed->get_url() );
766 break;
767 }
768 }
769 }
770 }
771
772 if ( ! isset( $user_id_map[ $user_id ] ) ) {
773 $user_id_map[ $user_id ] = $user_id;
774 }
775
776 return $user_id_map[ $user_id ];
777 }
778
779 public function mastodon_api_account_following( $following, $user_id ) {
780 if ( ! method_exists( '\Friends\User_Feed', 'get_by_parser' ) ) {
781 return array();
782 }
783
784 foreach ( User_Feed::get_by_parser( self::SLUG ) as $user_feed ) {
785 $following[] = apply_filters( 'mastodon_api_account', null, $user_feed->get_friend_user()->ID );
786 }
787
788 return $following;
789 }
790
791 /**
792 * Add following count to the user data.
793 *
794 * @param Account $user_data The user data.
795 * @param string $user_id The user id.
796 *
797 * @return Account The filtered Account.
798 */
799 public static function mastodon_api_account( $user_data, $user_id ) {
800 if ( get_current_user_id() !== $user_id ) {
801 return $user_data;
802 }
803 if ( ! method_exists( '\Friends\User_Feed', 'get_by_parser' ) ) {
804 return $user_data;
805 }
806
807 $user_data->following_count = count( User_Feed::get_by_parser( self::SLUG ) );
808
809 return $user_data;
810 }
811
812 /**
813 * Filter the Mastodon API account for posts from the External user.
814 *
815 * When a post is from an external user (someone you don't follow who mentioned you),
816 * extract the actual actor information from the feed_url post meta.
817 *
818 * @param Entity_Account|null $account The current account object.
819 * @param int $user_id The user ID.
820 * @param mixed $request The request object.
821 * @param \WP_Post|null $post The post object.
822 * @return Entity_Account|null The filtered account.
823 */
824 public function mastodon_api_account_external_user( $account, $user_id, $request = null, $post = null ) {
825 if ( ! $post instanceof \WP_Post ) {
826 return $account;
827 }
828
829 if ( Friends::CPT !== $post->post_type ) {
830 return $account;
831 }
832
833 $meta = get_post_meta( $post->ID, self::SLUG, true );
834
835 // Check if this post has attributedTo metadata (ActivityPub post).
836 if ( ! is_array( $meta ) || ! isset( $meta['attributedTo'] ) ) {
837 return $account;
838 }
839
840 $attributed_to = self::get_actor_url_from_attributed_to( $meta['attributedTo'] );
841 if ( ! $attributed_to ) {
842 return $account;
843 }
844
845 $actor_metadata = self::get_actor_metadata_from_attributed_to( $meta['attributedTo'] );
846
847 $actor = self::get_actor_acct_from_attributed_to( $meta['attributedTo'] );
848 if ( ! $actor ) {
849 $actor = self::convert_actor_to_mastodon_handle( $attributed_to );
850 }
851 $account = new Entity_Account();
852 if ( ! empty( $meta['attributedTo']['ap_actor_id'] ) ) {
853 $account->id = \strval( $meta['attributedTo']['ap_actor_id'] );
854 } else {
855 $account->id = $attributed_to;
856 }
857 $account->username = strtok( $actor, '@' );
858 $account->acct = $actor;
859 $account->url = $attributed_to;
860 $account->created_at = new \DateTime( $post->post_date_gmt );
861 $account->display_name = '';
862 $account->note = '';
863 $account->avatar = '';
864 $account->avatar_static = '';
865
866 if ( ! empty( $actor_metadata['name'] ) ) {
867 $account->display_name = $actor_metadata['name'];
868 }
869 if ( ! empty( $actor_metadata['icon'] ) ) {
870 $account->avatar = $actor_metadata['icon'];
871 $account->avatar_static = $actor_metadata['icon'];
872 }
873 if ( ! empty( $actor_metadata['summary'] ) ) {
874 $account->note = $actor_metadata['summary'];
875 }
876
877 return $account;
878 }
879
880 public function friends_message_form_accounts( $accounts, User $friend_user ) {
881 foreach ( $friend_user->get_feeds() as $user_feed ) {
882 if ( 'activitypub' === $user_feed->get_parser() ) {
883 $acct = self::get_actor_acct_from_user_feed( $user_feed );
884 if ( ! $acct ) {
885 $acct = $this->convert_actor_to_mastodon_handle( $user_feed->get_url() );
886 }
887 // translators: %s is the user's handle.
888 $accounts[ $user_feed->get_url() ] = sprintf( __( '%s (via ActivityPub)', 'friends' ), '@' . $acct );
889 }
890 }
891
892 return $accounts;
893 }
894
895 public function friends_send_direct_message( $post_id, User $friend_user, $to, $message, $subject = null, $reply_to_post_id = null ) {
896 if ( is_wp_error( $post_id ) || ! is_int( $post_id ) ) {
897 return $post_id;
898 }
899 $send_to = null;
900 foreach ( $friend_user->get_feeds() as $user_feed ) {
901 if ( 'activitypub' === $user_feed->get_parser() && $user_feed->get_url() === $to ) {
902 $send_to = $to;
903 break;
904 }
905 }
906
907 if ( ! $send_to ) {
908 // We don't know this user.
909 return $post_id;
910 }
911
912 update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
913
914 require_once __DIR__ . '/activitypub/class-activitypub-transformer-message.php';
915
916 $user_id = $this->get_activitypub_actor_id( get_current_user_id() );
917 $actor = $this->get_activitypub_actor( $user_id );
918 if ( ! $actor ) {
919 return $post_id;
920 }
921
922 $transformer = new ActivityPub_Transformer_Message( get_post( $post_id ) );
923 if ( is_wp_error( $transformer ) ) {
924 return $transformer;
925 }
926 $transformer->set_content_visibility( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
927 $transformer->to = array( $send_to );
928 $reply_to_url = null;
929 if ( $reply_to_post_id ) {
930 $reply_to = get_post( $reply_to_post_id );
931 if ( ! is_wp_error( $reply_to ) ) {
932 $reply_to_url = $reply_to->guid;
933 $transformer->in_reply_to = $reply_to_url;
934 }
935 }
936 $object = $transformer->to_object();
937 $object->set_content( $transformer->get_rendered_content() );
938 $object->set_to( array( $send_to ) );
939 if ( $reply_to_url ) {
940 $object->set_in_reply_to( $reply_to_url );
941 }
942
943 $activity = new \Activitypub\Activity\Activity();
944 $activity->set_type( 'Create' );
945 $activity->set_id( home_url( '?p=' . $post_id ) . '#direct-message' );
946 $activity->set_actor( $actor->get_id() );
947 $activity->set_object( $object );
948 $activity->set_to( array( $send_to ) );
949
950 $outbox_activity_id = \Activitypub\add_to_outbox( $activity, null, $actor->get__id(), ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
951 if ( ! $outbox_activity_id ) {
952 return new \WP_Error(
953 'friends_activitypub_outbox_error',
954 __( 'Could not add ActivityPub direct message to the outbox.', 'friends' ),
955 compact( 'post_id', 'to', 'send_to' )
956 );
957 }
958
959 update_post_meta( $post_id, 'activitypub_direct_message_outbox_id', $outbox_activity_id );
960 update_post_meta( $outbox_activity_id, 'activitypub_direct_message_post_id', $post_id );
961 $this->update_direct_message_delivery_status(
962 $post_id,
963 array(
964 'status' => 'queued',
965 'message' => __( 'Queued for delivery', 'friends' ),
966 )
967 );
968
969 return $post_id;
970 }
971
972 /**
973 * Record the inboxes an ActivityPub direct message is being sent to.
974 *
975 * @param string $json The ActivityPub Activity JSON.
976 * @param array $inboxes The inboxes to send to.
977 * @param int $outbox_item_id The ActivityPub outbox item ID.
978 */
979 public function record_direct_message_delivery_targets( $json, $inboxes, $outbox_item_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
980 $post_id = $this->get_direct_message_post_id_from_outbox( $outbox_item_id );
981 if ( ! $post_id ) {
982 return;
983 }
984
985 if ( empty( $inboxes ) ) {
986 $this->update_direct_message_delivery_status(
987 $post_id,
988 array(
989 'status' => 'failed',
990 'message' => __( 'No delivery inbox found', 'friends' ),
991 )
992 );
993 return;
994 }
995
996 $this->update_direct_message_delivery_status(
997 $post_id,
998 array(
999 'status' => 'sending',
1000 'message' => __( 'Sending', 'friends' ),
1001 'inboxes' => array_values( array_unique( array_map( 'esc_url_raw', $inboxes ) ) ),
1002 )
1003 );
1004 }
1005
1006 /**
1007 * Record the delivery result for an ActivityPub direct message.
1008 *
1009 * @param array|\WP_Error $result The inbox delivery result.
1010 * @param string $inbox The inbox URL.
1011 * @param string $json The ActivityPub Activity JSON.
1012 * @param int $actor_id The actor ID.
1013 * @param int $outbox_item_id The ActivityPub outbox item ID.
1014 */
1015 public function record_direct_message_delivery_result( $result, $inbox, $json, $actor_id, $outbox_item_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1016 $post_id = $this->get_direct_message_post_id_from_outbox( $outbox_item_id );
1017 if ( ! $post_id ) {
1018 return;
1019 }
1020
1021 $status = 'failed';
1022 $message = __( 'Delivery failed', 'friends' );
1023 $code = null;
1024 $error = null;
1025
1026 if ( is_wp_error( $result ) ) {
1027 $error = $result->get_error_message();
1028 } else {
1029 $code = wp_remote_retrieve_response_code( $result );
1030 if ( $code >= 200 && $code < 300 ) {
1031 $status = 'delivered';
1032 $message = __( 'Delivered', 'friends' );
1033 } elseif ( $code ) {
1034 // translators: %d is an HTTP status code.
1035 $error = sprintf( __( 'Remote inbox returned HTTP %d', 'friends' ), $code );
1036 }
1037 }
1038
1039 $this->update_direct_message_delivery_status(
1040 $post_id,
1041 array(
1042 'status' => $status,
1043 'message' => $message,
1044 'inbox' => esc_url_raw( $inbox ),
1045 'code' => $code,
1046 'error' => $error,
1047 )
1048 );
1049 }
1050
1051 /**
1052 * Get a direct message post ID from an ActivityPub outbox item.
1053 *
1054 * @param int $outbox_item_id The ActivityPub outbox item ID.
1055 * @return int The direct message post ID, or 0.
1056 */
1057 private function get_direct_message_post_id_from_outbox( $outbox_item_id ) {
1058 $post_id = (int) get_post_meta( $outbox_item_id, 'activitypub_direct_message_post_id', true );
1059 if ( $post_id ) {
1060 return $post_id;
1061 }
1062
1063 $posts = get_posts(
1064 array(
1065 'post_type' => \Friends\Messages::CPT,
1066 'post_status' => array( 'friends_read', 'friends_unread' ),
1067 'posts_per_page' => 1,
1068 'fields' => 'ids',
1069 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1070 'meta_key' => 'activitypub_direct_message_outbox_id',
1071 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1072 'meta_value' => $outbox_item_id,
1073 )
1074 );
1075 if ( $posts ) {
1076 $post_id = (int) reset( $posts );
1077 update_post_meta( $outbox_item_id, 'activitypub_direct_message_post_id', $post_id );
1078 }
1079
1080 return $post_id;
1081 }
1082
1083 /**
1084 * Update delivery metadata for a direct message.
1085 *
1086 * @param int $post_id The direct message post ID.
1087 * @param array $status The delivery status data.
1088 */
1089 private function update_direct_message_delivery_status( $post_id, $status ) {
1090 $previous = get_post_meta( $post_id, 'activitypub_direct_message_delivery', true );
1091 if ( ! is_array( $previous ) ) {
1092 $previous = array();
1093 }
1094
1095 $status['updated_at'] = time();
1096
1097 update_post_meta(
1098 $post_id,
1099 'activitypub_direct_message_delivery',
1100 array_filter(
1101 array_merge( $previous, $status ),
1102 static function ( $value ) {
1103 return null !== $value && '' !== $value;
1104 }
1105 )
1106 );
1107 }
1108
1109 /**
1110 * Get display data for an ActivityPub direct message delivery indicator.
1111 *
1112 * @param int|\WP_Post $post The direct message post or ID.
1113 * @return array|null Delivery display data, or null for non-ActivityPub messages.
1114 */
1115 public static function get_direct_message_delivery_status( $post ) {
1116 $post = get_post( $post );
1117 if ( ! $post || \Friends\Messages::CPT !== $post->post_type ) {
1118 return null;
1119 }
1120
1121 $outbox_id = get_post_meta( $post->ID, 'activitypub_direct_message_outbox_id', true );
1122 if ( ! $outbox_id ) {
1123 return null;
1124 }
1125
1126 $delivery = get_post_meta( $post->ID, 'activitypub_direct_message_delivery', true );
1127 if ( ! is_array( $delivery ) ) {
1128 $delivery = array();
1129 }
1130
1131 $status = isset( $delivery['status'] ) ? $delivery['status'] : 'queued';
1132 $labels = array(
1133 'queued' => __( 'Queued', 'friends' ),
1134 'sending' => __( 'Sending', 'friends' ),
1135 'delivered' => __( 'Delivered', 'friends' ),
1136 'failed' => __( 'Delivery failed', 'friends' ),
1137 );
1138 $titles = array(
1139 'queued' => __( 'This message is waiting for ActivityPub delivery.', 'friends' ),
1140 'sending' => __( 'This message is being delivered to the remote inbox.', 'friends' ),
1141 'delivered' => __( 'The remote inbox accepted this message.', 'friends' ),
1142 'failed' => __( 'The remote inbox did not accept this message.', 'friends' ),
1143 );
1144 $title = isset( $titles[ $status ] ) ? $titles[ $status ] : __( 'Delivery status is pending.', 'friends' );
1145 if ( ! empty( $delivery['error'] ) ) {
1146 $title = $delivery['error'];
1147 } elseif ( 'failed' === $status && ! empty( $delivery['message'] ) ) {
1148 $title = $delivery['message'];
1149 }
1150
1151 return array(
1152 'status' => $status,
1153 'label' => isset( $labels[ $status ] ) ? $labels[ $status ] : __( 'Delivery pending', 'friends' ),
1154 'title' => $title,
1155 );
1156 }
1157
1158 public function handle_received_direct_message( $activity, $user_id ) {
1159 $actor_url = $activity['actor'];
1160 $user_feed = false;
1161 if ( Friends::check_url( $actor_url ) ) {
1162 // Let's check if we follow this actor. If not it might be a different URL representation.
1163 $user_feed = $this->friends_feed->get_user_feed_by_url( $actor_url );
1164 }
1165
1166 if ( ( ! $user_feed || is_wp_error( $user_feed ) ) && class_exists( '\Activitypub\Collection\Remote_Actors' ) && Friends::check_url( $actor_url ) ) {
1167 $ap_actor_post = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url );
1168 if ( ! is_wp_error( $ap_actor_post ) ) {
1169 $user_feed = User_Feed::get_by_ap_actor_id( $ap_actor_post->ID );
1170 }
1171 }
1172
1173 $object = $activity['object'];
1174 $remote_url = $object['id'];
1175 $reply_to = null;
1176 if ( isset( $object['inReplyTo'] ) ) {
1177 $reply_to = $object['inReplyTo'];
1178 }
1179 $message = $object['content'];
1180 $subject = null;
1181
1182 $friend_user = false;
1183 if ( $user_feed && ! is_wp_error( $user_feed ) ) {
1184 // A feed term can exist without resolving to a user, for example when
1185 // its subscription term was deleted. Treat that like an unknown sender
1186 // instead of passing a false to the notification hooks.
1187 $friend_user = $user_feed->get_friend_user();
1188 }
1189
1190 if ( ! $friend_user instanceof User ) {
1191 $actor = apply_filters( 'friends_get_activitypub_metadata', array(), $actor_url );
1192 if ( ! $actor || is_wp_error( $actor ) ) {
1193 return;
1194 }
1195
1196 $friend_user = $this->create_message_sender_from_actor( $actor_url, $actor );
1197 if ( ! is_wp_error( $friend_user ) && $friend_user instanceof User ) {
1198 $feed_url = ! empty( $actor['id'] ) && Friends::check_url( $actor['id'] ) ? $actor['id'] : $actor_url;
1199 do_action( 'notify_friend_message_received', $friend_user, $message, $subject, $feed_url, $remote_url, $reply_to );
1200 return;
1201 }
1202
1203 $sender_name = false;
1204 if ( ! empty( $actor['name'] ) ) {
1205 $sender_name = $actor['name'];
1206 } elseif ( ! empty( $actor['preferredUsername'] ) ) {
1207 $sender_name = $actor['preferredUsername'];
1208 }
1209
1210 if ( isset( $actor['id'] ) ) {
1211 if ( $sender_name ) {
1212 $sender_name .= ' (@' . self::convert_actor_to_mastodon_handle( $actor['id'] ) . ')';
1213 } else {
1214 $sender_name = '@' . self::convert_actor_to_mastodon_handle( $actor['id'] );
1215 }
1216 } elseif ( ! $sender_name ) {
1217 $sender_name = '@' . self::convert_actor_to_mastodon_handle( $actor_url );
1218 }
1219
1220 do_action( 'notify_unknown_friend_message_received', $sender_name, $message, $subject, $actor_url, $remote_url, $reply_to );
1221 return;
1222 }
1223
1224 do_action( 'notify_friend_message_received', $friend_user, $message, $subject, $user_feed->get_url(), $remote_url, $reply_to );
1225 }
1226
1227 /**
1228 * Create an inactive subscription identity for an ActivityPub DM sender.
1229 *
1230 * Receiving a direct message must make the sender visible and replyable, but
1231 * must not silently follow them or import their public outbox.
1232 *
1233 * @param string $actor_url The actor URL from the incoming activity.
1234 * @param array $actor The resolved actor metadata.
1235 * @return User|\WP_Error The sender subscription or an error.
1236 */
1237 private function create_message_sender_from_actor( $actor_url, $actor ) {
1238 $canonical_actor_url = ! empty( $actor['id'] ) && Friends::check_url( $actor['id'] ) ? $actor['id'] : $actor_url;
1239 if ( ! Friends::check_url( $canonical_actor_url ) ) {
1240 return new \WP_Error( 'invalid_actor_url', __( 'Invalid ActivityPub actor URL.', 'friends' ) );
1241 }
1242
1243 $display_name = null;
1244 if ( ! empty( $actor['name'] ) ) {
1245 $display_name = $actor['name'];
1246 } elseif ( ! empty( $actor['preferredUsername'] ) ) {
1247 $display_name = $actor['preferredUsername'];
1248 }
1249
1250 $avatar_url = null;
1251 if ( ! empty( $actor['icon']['url'] ) ) {
1252 $avatar_url = $actor['icon']['url'];
1253 } elseif ( ! empty( $actor['icon'] ) && is_string( $actor['icon'] ) ) {
1254 $avatar_url = $actor['icon'];
1255 }
1256
1257 $host = wp_parse_url( $canonical_actor_url, PHP_URL_HOST );
1258 $user_login = sanitize_title( ( $actor['preferredUsername'] ?? 'activitypub' ) . '.' . $host );
1259
1260 $friend_user = Subscription::create(
1261 $user_login,
1262 'subscription',
1263 $canonical_actor_url,
1264 $display_name,
1265 $avatar_url,
1266 $actor['summary'] ?? null
1267 );
1268
1269 if ( is_wp_error( $friend_user ) ) {
1270 return $friend_user;
1271 }
1272
1273 $user_feed = $friend_user->save_feed(
1274 $canonical_actor_url,
1275 array(
1276 'parser' => self::SLUG,
1277 'active' => false,
1278 'title' => $display_name ? $display_name : $canonical_actor_url,
1279 )
1280 );
1281
1282 if ( is_wp_error( $user_feed ) ) {
1283 return $user_feed;
1284 }
1285
1286 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
1287 $actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $canonical_actor_url );
1288 if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
1289 $user_feed->set_ap_actor_id( $actor_post->ID );
1290 }
1291 }
1292
1293 return $friend_user;
1294 }
1295
1296 public function register_post_meta() {
1297 register_post_meta(
1298 Friends::CPT,
1299 self::SLUG,
1300 array(
1301 'show_in_rest' => true,
1302 'single' => true,
1303 'type' => 'object',
1304 )
1305 );
1306 }
1307
1308 public function feed_item_allow_set_metadata( $verdict, $key, $value ) {
1309 if ( self::SLUG === $key && ! empty( $value ) ) {
1310 // We don't want to insert empty post meta.
1311 return true;
1312 }
1313 return $verdict;
1314 }
1315
1316 /**
1317 * Allow logging a message via an action.
1318 *
1319 * @param string $message The message to log.
1320 * @param array $objects Optional objects as meta data.
1321 * @return void
1322 */
1323 private function log( $message, $objects = array() ) {
1324 // Receive debug messages from the ActivityPub feed parser.
1325 do_action( 'friends_activitypub_log', $message, $objects );
1326 }
1327
1328 /**
1329 * Determines if this is a supported feed and to what degree we feel it's supported.
1330 *
1331 * @param string $url The url.
1332 * @param string $mime_type The mime type.
1333 * @param string $title The title.
1334 * @param string|null $content The content, it can't be assumed that it's always available.
1335 *
1336 * @return int Return 0 if unsupported, a positive value representing the confidence for the feed, use 10 if you're reasonably confident.
1337 */
1338 public function feed_support_confidence( $url, $mime_type, $title, $content = null ) {
1339 if ( preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $url ) ) {
1340 return 10;
1341 }
1342
1343 if ( 'application/activity+json' === $mime_type ) {
1344 return 10;
1345 }
1346
1347 if ( preg_match( '#^https?://[^/]+/@[a-z0-9-]+$#i', $url ) ) {
1348 return 9;
1349 }
1350
1351 if ( preg_match( '#^https?://[^/]+/(users|author)/[a-z0-9-]+$#i', $url ) ) {
1352 return 8;
1353 }
1354
1355 return 0;
1356 }
1357
1358 /**
1359 * Format the feed title and autoselect the posts feed.
1360 *
1361 * @param array $feed_details The feed details.
1362 *
1363 * @return array The (potentially) modified feed details.
1364 */
1365 public function update_feed_details( $feed_details ) {
1366 $meta = apply_filters( 'friends_get_activitypub_metadata', array(), $feed_details['url'] );
1367 if ( ! $meta || is_wp_error( $meta ) ) {
1368 return $feed_details;
1369 }
1370 if ( isset( $meta['name'] ) ) {
1371 $feed_details['title'] = $meta['name'];
1372 } elseif ( isset( $meta['preferredUsername'] ) ) {
1373 $feed_details['title'] = $meta['preferredUsername'];
1374 }
1375
1376 if ( isset( $meta['id'] ) ) {
1377 $feed_details['url'] = $meta['id'];
1378 }
1379
1380 if ( isset( $meta['icon']['type'] ) && 'image' === strtolower( $meta['icon']['type'] ) ) {
1381 $feed_details['avatar'] = $meta['icon']['url'];
1382 }
1383
1384 if ( ! empty( $meta['summary'] ) ) {
1385 $feed_details['description'] = $meta['summary'];
1386 }
1387
1388 // Disable polling.
1389 $feed_details['interval'] = YEAR_IN_SECONDS;
1390 $feed_details['next-poll'] = gmdate( 'Y-m-d H:i:s', time() + YEAR_IN_SECONDS );
1391 $feed_details['post-format'] = 'status';
1392
1393 $feed_details['type'] = 'application/activity+json';
1394 $feed_details['autoselect'] = true;
1395 $actor = $this->get_activitypub_actor( get_current_user_id() );
1396 if ( $actor ) {
1397 $feed_details['additional-info'] = 'You will follow as <tt>' . $actor->get_webfinger() . '</tt>';
1398 }
1399
1400 if ( ! empty( $meta['preferredUsername'] ) ) {
1401 $host = wp_parse_url( $feed_details['url'], PHP_URL_HOST );
1402 $feed_details['suggested-username'] = User::sanitize_username( $meta['preferredUsername'] . '.' . $host );
1403 }
1404
1405 return $feed_details;
1406 }
1407
1408 /**
1409 * Rewrite a Mastodon style URL @username@server to a URL via webfinger.
1410 *
1411 * @param string $url The URL to filter.
1412 * @param string $incoming_url Potentially a mastodon identifier.
1413 *
1414 * @return string The rewritten URL.
1415 */
1416 public static function friends_webfinger_resolve( $url, $incoming_url ) {
1417 $pre = apply_filters( 'pre_friends_webfinger_resolve', false, $url, $incoming_url );
1418 if ( $pre ) {
1419 return $pre;
1420 }
1421
1422 static $cache = array();
1423 if ( isset( $cache[ $incoming_url ] ) ) {
1424 return $cache[ $incoming_url ];
1425 }
1426
1427 if ( preg_match( '/^@?' . self::ACTIVITYPUB_USERNAME_REGEXP . '$/i', $incoming_url ) ) {
1428 $resolved_url = \Activitypub\Webfinger::resolve( $incoming_url );
1429 if ( ! is_wp_error( $resolved_url ) ) {
1430 $cache[ $incoming_url ] = $resolved_url;
1431 return $resolved_url;
1432 }
1433 }
1434 return $url;
1435 }
1436
1437 /**
1438 * Get the inbox URL for an actor
1439 *
1440 * @param string $url The URL of the actor.
1441 * @param string $type The type of the activity.
1442 * @return string|WP_Error
1443 */
1444 public static function get_inbox_by_actor( $url, $type ) {
1445 $metadata = self::get_metadata( $url );
1446 if ( \is_wp_error( $metadata ) ) {
1447 return $metadata;
1448 }
1449
1450 if ( ! in_array( $type, array( 'Follow' ) ) && isset( $metadata['endpoints'] ) && isset( $metadata['endpoints']['sharedInbox'] ) ) {
1451 return $metadata['endpoints']['sharedInbox'];
1452 }
1453
1454 if ( \array_key_exists( 'inbox', $metadata ) ) {
1455 return $metadata['inbox'];
1456 }
1457
1458 return new WP_Error( 'activitypub_no_inbox', \__( 'No "ActivityPub Inbox" found', 'friends' ), $metadata );
1459 }
1460
1461 public static function get_metadata( $url ) {
1462 if ( ! is_string( $url ) ) {
1463 return array();
1464 }
1465
1466 if ( false !== strpos( $url, '@' ) ) {
1467 if ( false === strpos( $url, '/' ) && preg_match( '#^https?://#', $url, $m ) ) {
1468 $url = substr( $url, strlen( $m[0] ) );
1469 }
1470 }
1471 return \Activitypub\get_remote_metadata_by_actor( $url );
1472 }
1473
1474 /**
1475 * Discover the feeds available at the URL specified.
1476 *
1477 * @param string $content The content for the URL is already provided here.
1478 * @param string $url The url to search.
1479 *
1480 * @return array A list of supported feeds at the URL.
1481 */
1482 public function discover_available_feeds( $content, $url ) {
1483 $discovered_feeds = array();
1484
1485 $meta = self::get_metadata( $url );
1486 if ( $meta && ! is_wp_error( $meta ) && isset( $meta['id'] ) ) {
1487 $discovered_feeds[ $meta['id'] ] = array(
1488 'type' => 'application/activity+json',
1489 'rel' => 'self',
1490 'post-format' => 'status',
1491 'parser' => self::SLUG,
1492 'autoselect' => true,
1493 );
1494 }
1495
1496 return $discovered_feeds;
1497 }
1498
1499 /**
1500 * Fetches a feed and returns the processed items.
1501 *
1502 * @param string $url The url.
1503 * @param User_Feed $user_feed The user feed.
1504 *
1505 * @return array An array of feed items.
1506 */
1507 public function fetch_feed( $url, ?User_Feed $user_feed = null ) {
1508 if ( $user_feed ) {
1509 $this->disable_polling( $user_feed );
1510 }
1511
1512 // This is only for previwing.
1513 if ( wp_doing_cron() ) {
1514 return array();
1515 }
1516
1517 $meta = self::get_metadata( $url );
1518 if ( is_wp_error( $meta ) || ! isset( $meta['outbox'] ) ) {
1519 return array();
1520 }
1521
1522 $response = \Activitypub\safe_remote_get( $meta['outbox'] );
1523 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
1524 return new \WP_Error( 'activitypub_could_not_get_outbox_meta', null, compact( 'meta', 'url' ) );
1525 }
1526
1527 $outbox = json_decode( wp_remote_retrieve_body( $response ), true );
1528
1529 if ( ! isset( $outbox['orderedItems'] ) ) {
1530 if ( ! isset( $outbox['first'] ) ) {
1531 return new \WP_Error( 'activitypub_could_not_find_outbox_first_page', null, compact( 'url', 'meta', 'outbox' ) );
1532 }
1533
1534 $response = \Activitypub\safe_remote_get( $outbox['first'] );
1535 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
1536 return new \WP_Error(
1537 'activitypub_could_not_get_outbox',
1538 null,
1539 array(
1540 'meta' => $outbox,
1541 $url => $outbox['first'],
1542 )
1543 );
1544 }
1545 $outbox_page = json_decode( wp_remote_retrieve_body( $response ), true );
1546
1547 if ( ! isset( $outbox_page['type'] ) || 'OrderedCollectionPage' !== $outbox_page['type'] ) {
1548 return new \WP_Error(
1549 'activitypub_outbox_page_invalid_type',
1550 null,
1551 array(
1552 'outbox_page' => $outbox_page,
1553 $url => $outbox['first'],
1554 )
1555 );
1556 }
1557
1558 $outbox = $outbox_page;
1559 }
1560
1561 $items = array();
1562 foreach ( $outbox['orderedItems'] as $object ) {
1563 $type = strtolower( $object['type'] );
1564 $items[] = $this->process_incoming_activity( $type, $object, get_current_user_id(), $user_feed );
1565 }
1566
1567 return $items;
1568 }
1569
1570 public function suggest_user_login_from_url( $login, $url ) {
1571 if ( preg_match( '#^https?://([^/]+)/users/([^/]+)#', $url, $m ) ) {
1572 return $m[2] . '-' . $m[1];
1573 }
1574 return $login;
1575 }
1576 /**
1577 * Disable polling for this feed.
1578 *
1579 * @param User_Feed $user_feed The user feed.
1580 * @return void
1581 */
1582 private function disable_polling( User_Feed $user_feed ) {
1583 $user_feed->update_metadata( 'interval', YEAR_IN_SECONDS );
1584 $user_feed->update_metadata( 'next-poll', gmdate( 'Y-m-d H:i:s', time() + YEAR_IN_SECONDS ) );
1585 }
1586
1587 /**
1588 * Check the subscription status for an ActivityPub feed.
1589 *
1590 * @param User_Feed $user_feed The user feed.
1591 * @return array The subscription status with keys: status, message, latest_remote, latest_local, missing_posts.
1592 */
1593 public function check_subscription_status( User_Feed $user_feed ) {
1594 $result = array(
1595 'status' => 'unknown',
1596 'messages' => array(),
1597 'follow_status' => null,
1598 );
1599
1600 $ap_actor_id = $user_feed->get_ap_actor_id();
1601 if ( ! $ap_actor_id ) {
1602 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
1603 $actor_post = \Activitypub\Collection\Remote_Actors::get_by_uri( $user_feed->get_url() );
1604 if ( $actor_post && ! is_wp_error( $actor_post ) && 'ap_actor' === get_post_type( $actor_post ) ) {
1605 $result['status'] = 'warning';
1606 $result['messages'][] = __( 'The feed is not linked to its ActivityPub actor. You can re-link it without needing to re-follow.', 'friends' );
1607 $result['can_relink'] = true;
1608 $result['found_actor_id'] = $actor_post->ID;
1609 } else {
1610 $result['status'] = 'warning';
1611 $result['messages'][] = __( 'No linked ActivityPub actor found for this feed.', 'friends' );
1612 }
1613 } else {
1614 $result['status'] = 'warning';
1615 $result['messages'][] = __( 'No linked ActivityPub actor found for this feed. The ActivityPub plugin may not be managing this subscription.', 'friends' );
1616 }
1617 }
1618
1619 // Check local follow status via ActivityPub plugin.
1620 if ( $ap_actor_id ) {
1621 if ( class_exists( '\Activitypub\Collection\Following' ) ) {
1622 $user_id = self::get_activitypub_actor_id( null );
1623 $follow_status = \Activitypub\Collection\Following::check_status( $user_id, $ap_actor_id );
1624 $result['follow_status'] = $follow_status;
1625
1626 if ( false === $follow_status ) {
1627 $result['status'] = 'warning';
1628 $result['messages'][] = __( 'The ActivityPub plugin is not managing this subscription. You may need to re-follow this actor.', 'friends' );
1629 $result['can_refollow'] = true;
1630 }
1631
1632 if ( 'pending' === $follow_status ) {
1633 $result['status'] = 'warning';
1634 $result['messages'][] = __( 'Your follow request is still pending. The remote server has not yet accepted it.', 'friends' );
1635 $result['can_refollow'] = true;
1636 }
1637
1638 if ( 'accepted' === $follow_status ) {
1639 $result['messages'][] = __( 'Your follow request has been accepted.', 'friends' );
1640 }
1641 } else {
1642 $result['status'] = 'warning';
1643 $result['messages'][] = __( 'The ActivityPub plugin is not active. ActivityPub subscriptions require it to receive new posts.', 'friends' );
1644 }
1645 }
1646
1647 // Fetch the outbox to check for newer posts than what we have locally.
1648 $url = $user_feed->get_url();
1649 $meta = self::get_metadata( $url );
1650 if ( is_wp_error( $meta ) || ! isset( $meta['outbox'] ) ) {
1651 if ( 'unknown' === $result['status'] ) {
1652 $result['status'] = 'warning';
1653 }
1654 $result['messages'][] = __( 'Could not fetch the remote actor metadata.', 'friends' );
1655 return $result;
1656 }
1657
1658 $response = \Activitypub\safe_remote_get( $meta['outbox'] );
1659 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
1660 if ( 'unknown' === $result['status'] ) {
1661 $result['status'] = 'warning';
1662 }
1663 $result['messages'][] = __( 'Could not fetch the remote outbox.', 'friends' );
1664 return $result;
1665 }
1666
1667 $outbox = json_decode( wp_remote_retrieve_body( $response ), true );
1668
1669 // Get the first page if needed.
1670 if ( ! isset( $outbox['orderedItems'] ) && isset( $outbox['first'] ) ) {
1671 $response = \Activitypub\safe_remote_get( $outbox['first'] );
1672 if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
1673 $outbox = json_decode( wp_remote_retrieve_body( $response ), true );
1674 }
1675 }
1676
1677 if ( isset( $outbox['orderedItems'] ) && ! empty( $outbox['orderedItems'] ) ) {
1678 // Find the latest post date in the outbox.
1679 $latest_remote = null;
1680 foreach ( $outbox['orderedItems'] as $item ) {
1681 $published = null;
1682 if ( isset( $item['published'] ) ) {
1683 $published = $item['published'];
1684 } elseif ( isset( $item['object']['published'] ) ) {
1685 $published = $item['object']['published'];
1686 }
1687 if ( $published ) {
1688 $date = strtotime( $published );
1689 if ( ! $latest_remote || $date > $latest_remote ) {
1690 $latest_remote = $date;
1691 }
1692 }
1693 }
1694
1695 if ( $latest_remote ) {
1696 $result['latest_remote'] = gmdate( 'Y-m-d H:i:s', $latest_remote );
1697
1698 // Get the latest local post for this user.
1699 $friend_user = $user_feed->get_friend_user();
1700 if ( $friend_user ) {
1701 $latest_local_post = new \WP_Query();
1702 $latest_local_post->set( 'post_type', Friends::CPT );
1703 $latest_local_post->set( 'post_status', array( 'publish', 'private' ) );
1704 $latest_local_post->set( 'posts_per_page', 1 );
1705 $latest_local_post->set( 'orderby', 'date' );
1706 $latest_local_post->set( 'order', 'DESC' );
1707 $latest_local_post = $friend_user->modify_query_by_author( $latest_local_post );
1708 $latest_local_post->get_posts();
1709
1710 if ( $latest_local_post->have_posts() ) {
1711 $result['latest_local'] = $latest_local_post->posts[0]->post_date_gmt;
1712
1713 $local_time = strtotime( $result['latest_local'] );
1714 $diff_hours = ( $latest_remote - $local_time ) / 3600;
1715
1716 if ( $diff_hours > 24 ) {
1717 $result['status'] = 'warning';
1718 $result['messages'][] = sprintf(
1719 // translators: %s is a time difference like "3 days".
1720 __( 'The remote outbox has posts up to %s newer than your latest local post. New posts may not be reaching your inbox.', 'friends' ),
1721 human_time_diff( $local_time, $latest_remote )
1722 );
1723 } elseif ( 'accepted' === ( $result['follow_status'] ?? null ) ) {
1724 $result['status'] = 'ok';
1725 $result['messages'][] = __( 'Your subscription appears to be working correctly.', 'friends' );
1726 }
1727 } else {
1728 $result['status'] = 'warning';
1729 $result['messages'][] = __( 'No local posts found for this subscription, but the remote outbox has posts available.', 'friends' );
1730 $result['can_fetch'] = true;
1731 }
1732 }
1733 }
1734 }
1735
1736 if ( 'unknown' === $result['status'] ) {
1737 $result['status'] = 'ok';
1738 }
1739
1740 return $result;
1741 }
1742
1743 /**
1744 * AJAX handler for checking an ActivityPub subscription.
1745 */
1746 public function ajax_check_subscription() {
1747 if ( ! isset( $_POST['feed_id'] ) ) {
1748 wp_send_json_error( 'missing-parameters' );
1749 }
1750
1751 check_ajax_referer( 'friends-check-subscription' );
1752
1753 $feed = User_Feed::get_by_id( intval( $_POST['feed_id'] ) );
1754 if ( is_wp_error( $feed ) ) {
1755 wp_send_json_error( 'invalid-feed' );
1756 }
1757
1758 if ( 'activitypub' !== $feed->get_parser() ) {
1759 wp_send_json_error( 'not-activitypub' );
1760 }
1761
1762 $result = $this->check_subscription_status( $feed );
1763 wp_send_json_success( $result );
1764 }
1765
1766 /**
1767 * AJAX handler for checking whether an ActivityPub actor follows the current user.
1768 */
1769 public function ajax_check_follower() {
1770 if ( ! isset( $_POST['actor_url'] ) ) {
1771 wp_send_json_error( 'missing-parameters' );
1772 }
1773
1774 check_ajax_referer( 'friends-check-follower' );
1775
1776 if ( ! is_user_logged_in() ) {
1777 wp_send_json_error( 'not-authorized' );
1778 }
1779
1780 if ( ! class_exists( '\Activitypub\Collection\Followers' ) ) {
1781 wp_send_json_error( 'activitypub-plugin-not-active' );
1782 }
1783
1784 $actor_url = sanitize_url( wp_unslash( $_POST['actor_url'] ) );
1785 if ( ! Friends::check_url( $actor_url ) ) {
1786 wp_send_json_error( 'invalid-actor-url' );
1787 }
1788
1789 $user_id = self::get_activitypub_actor_id( get_current_user_id() );
1790 if ( ! $user_id ) {
1791 wp_send_json_error( 'missing-local-activitypub-actor' );
1792 }
1793
1794 $follower = \Activitypub\Collection\Followers::get_by_uri( $user_id, $actor_url );
1795 if ( ! is_wp_error( $follower ) ) {
1796 wp_send_json_success(
1797 array(
1798 'follows' => true,
1799 'label' => __( 'Follows you', 'friends' ),
1800 'title' => __( 'This actor follows your ActivityPub account. Replies and direct messages should be deliverable.', 'friends' ),
1801 )
1802 );
1803 }
1804
1805 wp_send_json_success(
1806 array(
1807 'follows' => false,
1808 'label' => __( 'Not following you', 'friends' ),
1809 'title' => __( 'This actor is not in your local ActivityPub followers list. They may not receive follower-only or direct replies from your site.', 'friends' ),
1810 )
1811 );
1812 }
1813
1814 /**
1815 * AJAX handler for re-linking an ActivityPub actor to a feed.
1816 */
1817 public function ajax_relink_actor() {
1818 if ( ! isset( $_POST['feed_id'] ) ) {
1819 wp_send_json_error( 'missing-parameters' );
1820 }
1821
1822 check_ajax_referer( 'friends-relink-actor' );
1823
1824 $feed = User_Feed::get_by_id( intval( $_POST['feed_id'] ) );
1825 if ( is_wp_error( $feed ) ) {
1826 wp_send_json_error( 'invalid-feed' );
1827 }
1828
1829 if ( 'activitypub' !== $feed->get_parser() ) {
1830 wp_send_json_error( 'not-activitypub' );
1831 }
1832
1833 if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
1834 wp_send_json_error( 'activitypub-plugin-not-active' );
1835 }
1836
1837 $actor_post = \Activitypub\Collection\Remote_Actors::get_by_uri( $feed->get_url() );
1838 if ( ! $actor_post || is_wp_error( $actor_post ) || 'ap_actor' !== get_post_type( $actor_post ) ) {
1839 wp_send_json_error( 'actor-not-found' );
1840 }
1841
1842 $result = $feed->set_ap_actor_id( $actor_post->ID );
1843 if ( is_wp_error( $result ) ) {
1844 wp_send_json_error( $result->get_error_message() );
1845 }
1846
1847 wp_send_json_success( array( 'message' => __( 'The feed has been successfully re-linked to its ActivityPub actor.', 'friends' ) ) );
1848 }
1849
1850 /**
1851 * AJAX handler for re-sending a follow request for an ActivityPub feed.
1852 */
1853 public function ajax_refollow() {
1854 if ( ! isset( $_POST['feed_id'] ) ) {
1855 wp_send_json_error( 'missing-parameters' );
1856 }
1857
1858 check_ajax_referer( 'friends-refollow-activitypub' );
1859
1860 $feed = User_Feed::get_by_id( intval( $_POST['feed_id'] ) );
1861 if ( is_wp_error( $feed ) ) {
1862 wp_send_json_error( 'invalid-feed' );
1863 }
1864
1865 if ( 'activitypub' !== $feed->get_parser() ) {
1866 wp_send_json_error( 'not-activitypub' );
1867 }
1868
1869 // Fetch and link the actor first so author data is available immediately,
1870 // even on dev machines where the follow confirmation can never come back.
1871 $actor_post = null;
1872 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
1873 $actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $feed->get_url() );
1874 if ( $actor_post && ! is_wp_error( $actor_post ) && 'ap_actor' === get_post_type( $actor_post ) ) {
1875 $feed->set_ap_actor_id( $actor_post->ID );
1876 } else {
1877 $actor_post = null;
1878 }
1879 }
1880
1881 $queued = $this->queue_follow_user( $feed );
1882 if ( is_wp_error( $queued ) ) {
1883 wp_send_json_error( $queued->get_error_message() );
1884 }
1885
1886 if ( ! $queued ) {
1887 wp_send_json_error( __( 'Could not queue the follow request. Make sure the ActivityPub plugin is active.', 'friends' ) );
1888 }
1889
1890 $rescheduled = false;
1891 if ( is_numeric( $queued ) && class_exists( '\Activitypub\Collection\Outbox' ) ) {
1892 $rescheduled = \Activitypub\Collection\Outbox::reschedule( (int) $queued );
1893 }
1894
1895 $message = $actor_post
1896 ? __( 'Follow request queued and actor data fetched. The feed will start updating once the remote server accepts it.', 'friends' )
1897 : __( 'Follow request queued. The feed will start updating once the remote server accepts it.', 'friends' );
1898
1899 if ( $rescheduled ) {
1900 $message = __( 'Follow request resent. The feed will start updating once the remote server accepts it.', 'friends' );
1901 }
1902
1903 wp_send_json_success( array( 'message' => $message ) );
1904 }
1905
1906 /**
1907 * Render the subscription status and check button in the edit feeds form.
1908 *
1909 * @param User_Feed $feed The feed.
1910 * @param int $term_id The term ID.
1911 * @param string $parser The parser slug.
1912 */
1913 public function render_subscription_check_button( $feed, $term_id, $parser ) {
1914 if ( 'activitypub' !== $parser ) {
1915 return;
1916 }
1917
1918 // If render_feed_edit_content will handle this feed (actor found by ID or URL), skip.
1919 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
1920 if ( $feed->get_ap_actor_id() ) {
1921 return;
1922 }
1923 $check = \Activitypub\Collection\Remote_Actors::get_by_uri( $feed->get_url() );
1924 if ( $check && ! is_wp_error( $check ) && 'ap_actor' === get_post_type( $check ) ) {
1925 return;
1926 }
1927 }
1928
1929 // Compute local status without any HTTP requests.
1930 $ap_actor_id = $feed->get_ap_actor_id();
1931 $can_relink = false;
1932 $can_refollow = false;
1933 $follow_status = null;
1934 $footer_note = null;
1935 $actor_post = null;
1936
1937 if ( $ap_actor_id ) {
1938 if ( class_exists( '\Activitypub\Collection\Following' ) ) {
1939 $follow_status = \Activitypub\Collection\Following::check_status( self::get_activitypub_actor_id( null ), $ap_actor_id );
1940 if ( false === $follow_status || 'pending' === $follow_status ) {
1941 $can_refollow = true;
1942 }
1943 } else {
1944 $footer_note = __( 'The ActivityPub plugin is not active. ActivityPub subscriptions require it to receive new posts.', 'friends' );
1945 }
1946 } elseif ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
1947 $actor_post = \Activitypub\Collection\Remote_Actors::get_by_uri( $feed->get_url() );
1948 if ( $actor_post && ! is_wp_error( $actor_post ) && 'ap_actor' === get_post_type( $actor_post ) ) {
1949 $can_relink = true;
1950 } else {
1951 $actor_post = null;
1952 }
1953 } else {
1954 $footer_note = __( 'The ActivityPub plugin is not active. ActivityPub subscriptions require it to receive new posts.', 'friends' );
1955 }
1956 ?>
1957 <div class="activitypub-subscription-check" data-feed-id="<?php echo esc_attr( $term_id ); ?>">
1958 <div class="ap-section-header"><?php esc_html_e( 'ActivityPub Plugin', 'friends' ); ?></div>
1959 <div class="ap-data-grid subscription-status">
1960 <?php if ( $ap_actor_id ) : ?>
1961 <span class="ap-data-label"><?php esc_html_e( 'Follow status', 'friends' ); ?></span>
1962 <span class="ap-data-value">
1963 <?php if ( 'accepted' === $follow_status ) : ?>
1964 <em style="color: green;"><?php esc_html_e( 'accepted', 'friends' ); ?></em>
1965 <?php elseif ( 'pending' === $follow_status ) : ?>
1966 <em style="color: orange;"><?php esc_html_e( 'pending', 'friends' ); ?></em>
1967 <?php elseif ( false === $follow_status ) : ?>
1968 <em style="color: orange;"><?php esc_html_e( 'not managed by ActivityPub plugin', 'friends' ); ?></em>
1969 <?php else : ?>
1970 <em><?php esc_html_e( 'unknown', 'friends' ); ?></em>
1971 <?php endif; ?>
1972 <?php if ( $can_refollow ) : ?>
1973 <button type="button" class="button button-small refollow-btn"
1974 data-nonce="<?php echo esc_attr( wp_create_nonce( 'friends-refollow-activitypub' ) ); ?>">
1975 <?php esc_html_e( 'Re-follow', 'friends' ); ?>
1976 </button>
1977 <?php endif; ?>
1978 </span>
1979 <?php else : ?>
1980 <span class="ap-data-label"><?php esc_html_e( 'Actor', 'friends' ); ?></span>
1981 <span class="ap-data-value">
1982 <?php if ( $actor_post ) : ?>
1983 <span class="ap-actor-name"><?php echo esc_html( $actor_post->post_title ); ?></span>
1984 <?php $ap_actor_acct = \Activitypub\Collection\Remote_Actors::get_acct( $actor_post->ID ); ?>
1985 <?php if ( $ap_actor_acct ) : ?>
1986 <span class="ap-actor-acct">@<?php echo esc_html( $ap_actor_acct ); ?></span>
1987 <?php endif; ?>
1988 <?php else : ?>
1989 <em style="color: orange;"><?php esc_html_e( 'not found', 'friends' ); ?></em>
1990 <?php endif; ?>
1991 </span>
1992 <?php if ( $actor_post ) : ?>
1993 <span class="ap-data-label"><?php esc_html_e( 'Profile', 'friends' ); ?></span>
1994 <span class="ap-data-value">
1995 <a href="<?php echo esc_url( $actor_post->guid ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( $actor_post->guid ); ?></a>
1996 </span>
1997 <span class="ap-data-label"><?php esc_html_e( 'Actor Post', 'friends' ); ?></span>
1998 <span class="ap-data-value">
1999 <code>ap_actor</code>
2000 <a href="<?php echo esc_url( admin_url( 'post.php?post=' . $actor_post->ID . '&action=edit' ) ); ?>">ID <?php echo esc_html( $actor_post->ID ); ?></a>
2001 <em style="color: orange;"><?php esc_html_e( '(not linked)', 'friends' ); ?></em>
2002 </span>
2003 <span class="ap-data-label"></span>
2004 <span class="ap-data-value">
2005 <button type="button" class="button button-small relink-actor-btn"
2006 data-nonce="<?php echo esc_attr( wp_create_nonce( 'friends-relink-actor' ) ); ?>">
2007 <?php esc_html_e( 'Re-link actor', 'friends' ); ?>
2008 </button>
2009 </span>
2010 <?php else : ?>
2011 <span class="ap-data-label"><?php esc_html_e( 'Actor Post', 'friends' ); ?></span>
2012 <span class="ap-data-value">
2013 <em style="color: orange;"><?php esc_html_e( 'not found', 'friends' ); ?></em>
2014 </span>
2015 <?php endif; ?>
2016 <?php endif; ?>
2017 <?php if ( $ap_actor_id ) : ?>
2018 <span class="ap-data-label"></span>
2019 <span class="ap-data-value">
2020 <button type="button" class="button button-small check-subscription-btn">
2021 <?php esc_html_e( 'Check Subscription', 'friends' ); ?>
2022 </button>
2023 <span class="spinner" style="float: none;"></span>
2024 </span>
2025 <?php else : ?>
2026 <span class="ap-data-label"></span>
2027 <span class="ap-data-value"><span class="spinner" style="float: none;"></span></span>
2028 <?php endif; ?>
2029 </div>
2030 </div>
2031 <?php $friend_login = ( $feed->get_friend_user() ) ? $feed->get_friend_user()->user_login : ''; ?>
2032 <script>
2033 jQuery( function( $ ) {
2034 var $container = $( '.activitypub-subscription-check[data-feed-id="<?php echo esc_js( $term_id ); ?>"]' );
2035 if ( $container.data( 'bound' ) ) return;
2036 $container.data( 'bound', true );
2037
2038 $container.on( 'click', '.relink-actor-btn', function() {
2039 var $btn = $( this );
2040 var $spinner = $container.find( '.spinner' );
2041 var $grid = $container.find( '.subscription-status' );
2042
2043 $btn.prop( 'disabled', true );
2044 $spinner.addClass( 'is-active' );
2045
2046 $.post( ajaxurl, {
2047 action: 'friends_relink_activitypub_actor',
2048 feed_id: <?php echo intval( $term_id ); ?>,
2049 _ajax_nonce: $btn.data( 'nonce' )
2050 }, function( response ) {
2051 $spinner.removeClass( 'is-active' );
2052 if ( response.success ) {
2053 $grid.html(
2054 '<span class="ap-data-label"><?php echo esc_js( __( 'Actor', 'friends' ) ); ?></span>'
2055 + '<span class="ap-data-value"><em style="color:green;"><?php echo esc_js( __( 're-linked', 'friends' ) ); ?></em></span>'
2056 );
2057 $btn.remove();
2058 } else {
2059 $btn.prop( 'disabled', false );
2060 $grid.append(
2061 '<span class="ap-data-label"></span>'
2062 + '<span class="ap-data-value" style="color:red;">' + response.data + '</span>'
2063 );
2064 }
2065 } );
2066 } );
2067
2068 $container.on( 'click', '.refollow-btn', function() {
2069 var $btn = $( this );
2070 var $spinner = $container.find( '.spinner' );
2071 var $grid = $container.find( '.subscription-status' );
2072
2073 $btn.prop( 'disabled', true );
2074 $spinner.addClass( 'is-active' );
2075
2076 $.post( ajaxurl, {
2077 action: 'friends_refollow_activitypub',
2078 feed_id: <?php echo intval( $term_id ); ?>,
2079 _ajax_nonce: $btn.data( 'nonce' )
2080 }, function( response ) {
2081 $spinner.removeClass( 'is-active' );
2082 if ( response.success ) {
2083 $grid.append(
2084 '<span class="ap-data-label"><?php echo esc_js( __( 'Re-follow', 'friends' ) ); ?></span>'
2085 + '<span class="ap-data-value"><em style="color:green;">' + response.data.message + '</em></span>'
2086 );
2087 $btn.remove();
2088 } else {
2089 $btn.prop( 'disabled', false );
2090 $grid.append(
2091 '<span class="ap-data-label"></span>'
2092 + '<span class="ap-data-value" style="color:red;">' + response.data + '</span>'
2093 );
2094 }
2095 } );
2096 } );
2097
2098 $container.on( 'click', '.check-subscription-btn', function() {
2099 var $btn = $( this );
2100 var $spinner = $container.find( '.spinner' );
2101 var $grid = $container.find( '.subscription-status' );
2102
2103 $btn.prop( 'disabled', true );
2104 $spinner.addClass( 'is-active' );
2105
2106 $.post( ajaxurl, {
2107 action: 'friends_check_activitypub_subscription',
2108 feed_id: <?php echo intval( $term_id ); ?>,
2109 _ajax_nonce: '<?php echo esc_js( wp_create_nonce( 'friends-check-subscription' ) ); ?>'
2110 }, function( response ) {
2111 $spinner.removeClass( 'is-active' );
2112 $btn.prop( 'disabled', false );
2113
2114 if ( ! response.success ) {
2115 $grid.append(
2116 '<span class="ap-data-label"></span>'
2117 + '<span class="ap-data-value" style="color:red;">' + response.data + '</span>'
2118 );
2119 return;
2120 }
2121
2122 var data = response.data;
2123 var color = data.status === 'ok' ? 'green' : ( data.status === 'error' ? 'red' : 'orange' );
2124 var rows = '';
2125
2126 rows += '<span class="ap-data-label"><?php echo esc_js( __( 'Status', 'friends' ) ); ?></span>'
2127 + '<span class="ap-data-value"><em style="color:' + color + ';">' + data.messages.join( '<br>' ) + '</em></span>';
2128
2129 if ( data.latest_remote ) {
2130 rows += '<span class="ap-data-label"><?php echo esc_js( __( 'Latest remote post', 'friends' ) ); ?></span>'
2131 + '<span class="ap-data-value">' + data.latest_remote + '</span>';
2132 }
2133 if ( data.latest_local ) {
2134 rows += '<span class="ap-data-label"><?php echo esc_js( __( 'Latest local post', 'friends' ) ); ?></span>'
2135 + '<span class="ap-data-value">' + data.latest_local + '</span>';
2136 }
2137
2138 if ( data.can_relink ) {
2139 rows += '<span class="ap-data-label"></span>'
2140 + '<span class="ap-data-value"><button type="button" class="button relink-actor-btn"'
2141 + ' data-nonce="<?php echo esc_js( wp_create_nonce( 'friends-relink-actor' ) ); ?>">'
2142 + '<?php echo esc_js( __( 'Re-link actor', 'friends' ) ); ?></button></span>';
2143 }
2144
2145 if ( data.can_refollow ) {
2146 rows += '<span class="ap-data-label"></span>'
2147 + '<span class="ap-data-value"><button type="button" class="button refollow-btn"'
2148 + ' data-nonce="<?php echo esc_js( wp_create_nonce( 'friends-refollow-activitypub' ) ); ?>">'
2149 + '<?php echo esc_js( __( 'Re-follow', 'friends' ) ); ?></button></span>';
2150 }
2151
2152 if ( data.can_fetch ) {
2153 rows += '<span class="ap-data-label"></span>'
2154 + '<span class="ap-data-value"><button type="button" class="button button-small fetch-posts-btn">'
2155 + '<?php echo esc_js( __( 'Fetch posts', 'friends' ) ); ?></button></span>';
2156 }
2157
2158 $grid.html( rows );
2159 } );
2160 } );
2161
2162 $container.on( 'click', '.fetch-posts-btn', function() {
2163 var $btn = $( this );
2164 var $spinner = $container.find( '.spinner' );
2165
2166 $btn.prop( 'disabled', true );
2167 $spinner.addClass( 'is-active' );
2168
2169 $.post( ajaxurl, {
2170 action: 'friends_fetch_feeds',
2171 friend: '<?php echo esc_js( $friend_login ); ?>',
2172 _ajax_nonce: '<?php echo esc_js( wp_create_nonce( 'fetch-feeds-' . $friend_login ) ); ?>'
2173 }, function( response ) {
2174 $spinner.removeClass( 'is-active' );
2175 if ( response.success ) {
2176 $btn.replaceWith( '<em style="color:green;"><?php echo esc_js( __( 'Posts fetched.', 'friends' ) ); ?></em>' );
2177 } else {
2178 $btn.prop( 'disabled', false );
2179 }
2180 } );
2181 } );
2182 } );
2183 </script>
2184 <?php
2185 }
2186
2187 public function get_activitypub_actor( $user_id ) {
2188 return \Activitypub\Collection\Actors::get_by_id( self::get_activitypub_actor_id( $user_id ) );
2189 }
2190
2191 public static function get_activitypub_actor_id( $user_id ) {
2192 if ( null !== $user_id && \function_exists( '\Activitypub\user_can_activitypub' ) && ! \Activitypub\user_can_activitypub( $user_id ) ) {
2193 $user_id = null;
2194 }
2195 if ( null === $user_id ) {
2196 $user_id = Friends::get_main_friend_user_id();
2197 if ( \defined( 'ACTIVITYPUB_ACTOR_MODE' ) ) {
2198 $activitypub_actor_mode = \get_option( 'activitypub_actor_mode', \ACTIVITYPUB_ACTOR_MODE );
2199 if ( \ACTIVITYPUB_BLOG_MODE === $activitypub_actor_mode ) {
2200 $user_id = \Activitypub\Collection\Actors::BLOG_USER_ID;
2201 }
2202 }
2203 }
2204
2205 return $user_id;
2206 }
2207
2208 /**
2209 * Get the actor URL from a remote actor post ID.
2210 *
2211 * @param int $ap_actor_id The ap_actor post ID.
2212 * @return string|null The actor URL, or null if not found.
2213 */
2214 public static function get_actor_url_from_remote_actor_id( $ap_actor_id ) {
2215 if ( empty( $ap_actor_id ) || ! is_numeric( $ap_actor_id ) ) {
2216 return null;
2217 }
2218
2219 $actor_post = get_post( $ap_actor_id );
2220 if ( ! $actor_post || 'ap_actor' !== $actor_post->post_type ) {
2221 return null;
2222 }
2223
2224 // The guid contains the canonical actor URL.
2225 return $actor_post->guid;
2226 }
2227
2228 /**
2229 * Get the actor URL from attributedTo metadata.
2230 *
2231 * This method handles both the new format (ap_actor_id) and the legacy format (id/URL)
2232 * for backward compatibility.
2233 *
2234 * @param array $attributed_to The attributedTo metadata array.
2235 * @return string|null The actor URL, or null if not found.
2236 */
2237 public static function get_actor_url_from_attributed_to( $attributed_to ) {
2238 if ( ! is_array( $attributed_to ) ) {
2239 return null;
2240 }
2241
2242 // New format: use ap_actor_id to look up the URL.
2243 if ( isset( $attributed_to['ap_actor_id'] ) && $attributed_to['ap_actor_id'] ) {
2244 $url = self::get_actor_url_from_remote_actor_id( $attributed_to['ap_actor_id'] );
2245 if ( $url ) {
2246 return $url;
2247 }
2248 }
2249
2250 // Legacy format: the URL is stored directly in 'id'.
2251 if ( isset( $attributed_to['id'] ) && $attributed_to['id'] ) {
2252 return $attributed_to['id'];
2253 }
2254
2255 return null;
2256 }
2257
2258 /**
2259 * Get the actor acct from attributedTo metadata.
2260 *
2261 * @param array $attributed_to The attributedTo metadata array.
2262 * @return string The actor acct, or an empty string if unavailable.
2263 */
2264 public static function get_actor_acct_from_attributed_to( $attributed_to ) {
2265 if ( ! is_array( $attributed_to ) ) {
2266 return '';
2267 }
2268
2269 if ( ! empty( $attributed_to['acct'] ) ) {
2270 return ltrim( $attributed_to['acct'], '@' );
2271 }
2272
2273 if ( ! empty( $attributed_to['ap_actor_id'] ) ) {
2274 return self::get_actor_acct_from_remote_actor_id( $attributed_to['ap_actor_id'] );
2275 }
2276
2277 return '';
2278 }
2279
2280 /**
2281 * Get the actor acct from an ActivityPub remote actor post ID.
2282 *
2283 * @param int $ap_actor_id The ActivityPub remote actor post ID.
2284 * @return string The actor acct, or an empty string if unavailable.
2285 */
2286 private static function get_actor_acct_from_remote_actor_id( $ap_actor_id ) {
2287 $ap_actor_id = (int) $ap_actor_id;
2288 if ( ! $ap_actor_id || ! class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
2289 return '';
2290 }
2291
2292 return ltrim( \Activitypub\Collection\Remote_Actors::get_acct( $ap_actor_id ), '@' );
2293 }
2294
2295 /**
2296 * Get the actor acct for an ActivityPub user feed.
2297 *
2298 * @param User_Feed $user_feed The user feed.
2299 * @return string The actor acct, or an empty string if unavailable.
2300 */
2301 private static function get_actor_acct_from_user_feed( User_Feed $user_feed ) {
2302 $ap_actor_id = $user_feed->get_ap_actor_id();
2303 return self::get_actor_acct_from_remote_actor_id( $ap_actor_id );
2304 }
2305
2306 /**
2307 * Get actor metadata from attributedTo.
2308 *
2309 * Returns actor metadata (name, icon, summary, preferredUsername, header) using the
2310 * ActivityPub plugin's Remote_Actors API for the new format, or from legacy inline data.
2311 *
2312 * @param array $attributed_to The attributedTo metadata array.
2313 * @return array Actor metadata with keys: url, name, icon, summary, preferredUsername, header, emojis.
2314 */
2315 public static function get_actor_metadata_from_attributed_to( $attributed_to ) {
2316 static $cache = array();
2317
2318 $metadata = array(
2319 'url' => null,
2320 'name' => '',
2321 'acct' => '',
2322 'icon' => '',
2323 'header' => '',
2324 'summary' => '',
2325 'preferredUsername' => '',
2326 'emojis' => array(),
2327 );
2328
2329 if ( ! is_array( $attributed_to ) ) {
2330 return $metadata;
2331 }
2332
2333 // Build a cache key from the attributed_to data.
2334 $cache_key = null;
2335 if ( isset( $attributed_to['ap_actor_id'] ) && $attributed_to['ap_actor_id'] ) {
2336 $cache_key = 'ap_' . $attributed_to['ap_actor_id'];
2337 } elseif ( isset( $attributed_to['id'] ) ) {
2338 $cache_key = 'id_' . md5( wp_json_encode( $attributed_to ) );
2339 }
2340 if ( $cache_key && isset( $cache[ $cache_key ] ) ) {
2341 return $cache[ $cache_key ];
2342 }
2343
2344 // New format: fetch from ap_actor using ActivityPub plugin API.
2345 if ( isset( $attributed_to['ap_actor_id'] ) && $attributed_to['ap_actor_id'] ) {
2346 $ap_actor_id = $attributed_to['ap_actor_id'];
2347
2348 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
2349 self::refresh_actor_if_stale( $ap_actor_id );
2350
2351 $actor = \Activitypub\Collection\Remote_Actors::get_actor( $ap_actor_id );
2352
2353 if ( $actor && ! is_wp_error( $actor ) ) {
2354 $metadata['url'] = $actor->get_id();
2355 $metadata['acct'] = self::get_actor_acct_from_attributed_to( $attributed_to );
2356 $metadata['name'] = $actor->get_name() ?? '';
2357 $metadata['summary'] = $actor->get_summary() ?? '';
2358 $metadata['preferredUsername'] = $actor->get_preferred_username() ?? '';
2359 $metadata['emojis'] = self::get_custom_emojis_from_actor_post( $ap_actor_id );
2360 $icon = $actor->get_icon();
2361 if ( $icon ) {
2362 $metadata['icon'] = \Activitypub\object_to_uri( $icon );
2363 }
2364
2365 $image = $actor->get_image();
2366 if ( $image ) {
2367 $metadata['header'] = \Activitypub\object_to_uri( $image );
2368 }
2369
2370 if ( $cache_key ) {
2371 $cache[ $cache_key ] = $metadata;
2372 }
2373 return $metadata;
2374 }
2375 }
2376
2377 // Fallback: try to get URL from the post directly.
2378 $url = self::get_actor_url_from_remote_actor_id( $ap_actor_id );
2379 if ( $url ) {
2380 $metadata['url'] = $url;
2381 }
2382 }
2383
2384 // Legacy format: use inline data.
2385 if ( isset( $attributed_to['id'] ) ) {
2386 $metadata['url'] = $attributed_to['id'];
2387 }
2388 if ( isset( $attributed_to['name'] ) ) {
2389 $metadata['name'] = $attributed_to['name'];
2390 }
2391 $metadata['acct'] = self::get_actor_acct_from_attributed_to( $attributed_to );
2392 if ( isset( $attributed_to['icon'] ) ) {
2393 $metadata['icon'] = $attributed_to['icon'];
2394 }
2395 if ( isset( $attributed_to['header'] ) ) {
2396 $metadata['header'] = $attributed_to['header'];
2397 }
2398 if ( isset( $attributed_to['summary'] ) ) {
2399 $metadata['summary'] = $attributed_to['summary'];
2400 }
2401 if ( isset( $attributed_to['preferredUsername'] ) ) {
2402 $metadata['preferredUsername'] = $attributed_to['preferredUsername'];
2403 }
2404 if ( isset( $attributed_to['tag'] ) ) {
2405 $metadata['emojis'] = self::get_custom_emojis_from_actor_tags( $attributed_to['tag'] );
2406 }
2407
2408 if ( $cache_key ) {
2409 $cache[ $cache_key ] = $metadata;
2410 }
2411 return $metadata;
2412 }
2413
2414 /**
2415 * Refresh stale cached remote actor metadata.
2416 *
2417 * Boosted actors may not send Update activities to this site, so their
2418 * cached metadata can go stale even while the boosting feed remains fresh.
2419 *
2420 * @param int $ap_actor_id The ap_actor post ID.
2421 * @return int|\WP_Error|null Updated post ID, error, or null when no refresh was needed.
2422 */
2423 public static function refresh_actor_if_stale( $ap_actor_id ) {
2424 if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) || ! class_exists( '\Activitypub\Http' ) ) {
2425 return null;
2426 }
2427
2428 $actor_post = get_post( $ap_actor_id );
2429 if ( ! $actor_post || 'ap_actor' !== $actor_post->post_type ) {
2430 return null;
2431 }
2432
2433 $modified = strtotime( $actor_post->post_modified_gmt . ' GMT' );
2434 if ( $modified && time() - $modified < WEEK_IN_SECONDS ) {
2435 return null;
2436 }
2437
2438 $last_attempt = (int) get_post_meta( $actor_post->ID, '_friends_actor_refresh_attempted', true );
2439 if ( $last_attempt && time() - $last_attempt < DAY_IN_SECONDS ) {
2440 return null;
2441 }
2442
2443 $actor_url = self::get_actor_url_from_remote_actor_id( $actor_post->ID );
2444 if ( ! $actor_url ) {
2445 return null;
2446 }
2447
2448 update_post_meta( $actor_post->ID, '_friends_actor_refresh_attempted', time() );
2449
2450 $actor = \Activitypub\Http::get_remote_object( $actor_url, false );
2451 if ( is_wp_error( $actor ) ) {
2452 return $actor;
2453 }
2454
2455 $updated = \Activitypub\Collection\Remote_Actors::update( $actor_post, $actor );
2456 if ( is_wp_error( $updated ) ) {
2457 return $updated;
2458 }
2459
2460 delete_post_meta( $actor_post->ID, '_friends_actor_refresh_attempted' );
2461
2462 return $updated;
2463 }
2464
2465 /**
2466 * Get custom emoji metadata from a cached ActivityPub actor post.
2467 *
2468 * @param int $ap_actor_id The ap_actor post ID.
2469 * @return array Map of shortcode to image URL.
2470 */
2471 public static function get_custom_emojis_from_actor_post( $ap_actor_id ) {
2472 $actor_post = get_post( $ap_actor_id );
2473 if ( ! $actor_post || 'ap_actor' !== $actor_post->post_type || empty( $actor_post->post_content ) ) {
2474 return array();
2475 }
2476
2477 $actor = json_decode( $actor_post->post_content, true );
2478 if ( ! is_array( $actor ) || empty( $actor['tag'] ) ) {
2479 return array();
2480 }
2481
2482 return self::get_custom_emojis_from_actor_tags( $actor['tag'] );
2483 }
2484
2485 /**
2486 * Get custom emoji metadata from ActivityPub actor tags.
2487 *
2488 * @param array $tags ActivityPub tag data.
2489 * @return array Map of shortcode to image URL.
2490 */
2491 public static function get_custom_emojis_from_actor_tags( $tags ) {
2492 $emojis = array();
2493
2494 if ( ! is_array( $tags ) ) {
2495 return $emojis;
2496 }
2497
2498 if ( isset( $tags['type'] ) ) {
2499 $tags = array( $tags );
2500 }
2501
2502 foreach ( $tags as $tag ) {
2503 if ( ! is_array( $tag ) || ( $tag['type'] ?? null ) !== 'Emoji' || empty( $tag['name'] ) || empty( $tag['icon']['url'] ) ) {
2504 continue;
2505 }
2506
2507 $emojis[ $tag['name'] ] = $tag['icon']['url'];
2508 }
2509
2510 return $emojis;
2511 }
2512
2513 /**
2514 * Replaces ActivityPub custom emoji shortcodes with inline images.
2515 *
2516 * @param string $text The text that may contain custom emoji shortcodes.
2517 * @param array $emojis Map of shortcode to image URL.
2518 * @return string HTML with custom emoji images.
2519 */
2520 public static function replace_custom_emojis( $text, $emojis ) {
2521 if ( empty( $text ) || empty( $emojis ) || ! is_array( $emojis ) ) {
2522 return esc_html( $text );
2523 }
2524
2525 $parts = preg_split( '/(:[a-zA-Z0-9_+-]+:)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE );
2526 if ( false === $parts ) {
2527 return esc_html( $text );
2528 }
2529
2530 $html = '';
2531 foreach ( $parts as $part ) {
2532 if ( isset( $emojis[ $part ] ) ) {
2533 $html .= '<img class="activitypub-custom-emoji" src="' . esc_url( $emojis[ $part ] ) . '" alt="' . esc_attr( $part ) . '" title="' . esc_attr( trim( $part, ':' ) ) . '" loading="lazy" />';
2534 continue;
2535 }
2536
2537 $html .= esc_html( $part );
2538 }
2539
2540 return $html;
2541 }
2542
2543 /**
2544 * Get custom emoji metadata for a Friends user with an ActivityPub feed.
2545 *
2546 * @param User|null $friend_user The Friends user.
2547 * @return array Map of shortcode to image URL.
2548 */
2549 public static function get_custom_emojis_for_user( ?User $friend_user = null ) {
2550 if ( ! $friend_user ) {
2551 return array();
2552 }
2553
2554 foreach ( $friend_user->get_active_feeds() as $user_feed ) {
2555 if ( self::SLUG !== $user_feed->get_parser() ) {
2556 continue;
2557 }
2558
2559 $ap_actor_id = $user_feed->get_ap_actor_id();
2560 if ( ! $ap_actor_id ) {
2561 continue;
2562 }
2563
2564 self::refresh_actor_if_stale( $ap_actor_id );
2565 return self::get_custom_emojis_from_actor_post( $ap_actor_id );
2566 }
2567
2568 return array();
2569 }
2570
2571 /**
2572 * Replaces custom emoji shortcodes in a Friends user's display name.
2573 *
2574 * @param string $text The display name.
2575 * @param User|null $friend_user The Friends user.
2576 * @return string HTML with custom emoji images.
2577 */
2578 public static function replace_custom_emojis_for_user( $text, ?User $friend_user = null ) {
2579 $emojis = self::get_custom_emojis_for_user( $friend_user );
2580 $html = self::replace_custom_emojis( $text, $emojis );
2581
2582 if ( empty( $emojis ) || ! $friend_user || esc_html( $text ) !== $html ) {
2583 return $html;
2584 }
2585
2586 foreach ( $friend_user->get_active_feeds() as $user_feed ) {
2587 if ( self::SLUG !== $user_feed->get_parser() ) {
2588 continue;
2589 }
2590
2591 $ap_actor_id = $user_feed->get_ap_actor_id();
2592 if ( ! $ap_actor_id ) {
2593 continue;
2594 }
2595
2596 $actor_title = get_post_field( 'post_title', $ap_actor_id );
2597 if ( $actor_title && $actor_title !== $text ) {
2598 return self::replace_custom_emojis( $actor_title, $emojis );
2599 }
2600 }
2601
2602 return $html;
2603 }
2604
2605 /**
2606 * Get the HTML allowed for rendered ActivityPub custom emoji.
2607 *
2608 * @return array Allowed HTML.
2609 */
2610 public static function get_custom_emoji_allowed_html() {
2611 return array(
2612 'img' => array(
2613 'alt' => true,
2614 'class' => true,
2615 'loading' => true,
2616 'src' => true,
2617 'title' => true,
2618 ),
2619 );
2620 }
2621
2622 /**
2623 * Gets the external mentions user.
2624 *
2625 * @return User The external mentions user.
2626 */
2627 public function get_external_user() {
2628 $external_username = apply_filters( 'friends_external_username', self::EXTERNAL_USERNAME );
2629 $user = User::get_by_username( $external_username );
2630 if ( ! $user || is_wp_error( $user ) ) {
2631 $user = Subscription::create( $external_username, 'subscription', home_url(), _x( 'External', 'user name', 'friends' ) );
2632 }
2633
2634 if ( $user instanceof \WP_User && ! ( $user instanceof Subscription ) && ! is_user_member_of_blog( $user->ID, get_current_blog_id() ) ) {
2635 \add_user_to_blog( get_current_blog_id(), $user->ID, 'subscription' );
2636 }
2637
2638 return $user;
2639 }
2640
2641 public function get_external_mentions_feed() {
2642 require_once __DIR__ . '/activitypub/class-virtual-user-feed.php';
2643 $user = $this->get_external_user();
2644 return new Virtual_User_Feed( $user, __( 'External Mentions', 'friends' ) );
2645 }
2646
2647 /**
2648 * Set the post author as an implicit mention.
2649 *
2650 * @param array $mentions The already extracted mentions.
2651 * @param string $comment_content The comment content.
2652 * @param object $wp_object The object acted upon.
2653 * @return array The extracted mentions.
2654 */
2655 public function activitypub_extract_in_reply_to_mentions( $mentions, $comment_content, $wp_object ) {
2656 if ( ! $wp_object || 'WP_Comment' !== get_class( $wp_object ) ) {
2657 return $mentions;
2658 }
2659 $post_id = $wp_object->comment_post_ID;
2660 $post = get_post( $post_id );
2661 if ( Friends::CPT !== $post->post_type ) {
2662 return $mentions;
2663 }
2664
2665 $meta = get_post_meta( $post->ID, self::SLUG, true );
2666 $attributed_to_url = isset( $meta['attributedTo'] ) ? self::get_actor_url_from_attributed_to( $meta['attributedTo'] ) : null;
2667 if ( $attributed_to_url ) {
2668 $mentions[ $attributed_to_url ] = $attributed_to_url;
2669 }
2670
2671 return $mentions;
2672 }
2673
2674 public static function extract_html_mentions( $content ) {
2675 if ( ! class_exists( '\WP_HTML_Tag_Processor' ) ) {
2676 return $content;
2677 }
2678
2679 $tags = new \WP_HTML_Tag_Processor( $content );
2680 $mentions = array();
2681 while ( $tags->next_tag(
2682 array(
2683 'tag_name' => 'a',
2684 'class_name' => 'mention',
2685 )
2686 ) ) {
2687 $href = false;
2688 if ( ! in_array( 'hashtag', explode( ' ', $tags->get_attribute( 'class' ) ) ) ) {
2689 $href = $tags->get_attribute( 'href' );
2690 }
2691 if ( $href ) {
2692 $mentions[ $href ] = true;
2693 }
2694 }
2695
2696 return $mentions;
2697 }
2698
2699 public function activitypub_handled_create( $activity, $user_id, $state, $reaction ) {
2700 $this->log( 'ActivityPub handled create', compact( 'activity', 'user_id', 'state', 'reaction' ) );
2701 if ( $reaction ) {
2702 $this->activitypub_already_handled[ $activity['object']['id'] ] = $reaction;
2703 }
2704 return $activity;
2705 }
2706
2707 public function handle_received_create( $activity, $user_id ) {
2708 if (
2709 \Activitypub\is_activity_public( $activity ) ||
2710 // Only accept messages that have the user in the "to" field.
2711 empty( $activity['to'] ) ||
2712 ! in_array( \Activitypub\Collection\Actors::get_by_id( $user_id )->get_id(), (array) $activity['to'], true )
2713 ) {
2714 return $this->handle_received_activity( $activity, $user_id, 'create' );
2715 }
2716
2717 $this->handle_received_direct_message( $activity, $user_id );
2718 }
2719
2720 public function handle_received_undo( $activity, $user_id ) {
2721 return $this->handle_received_activity( $activity, $user_id, 'undo' );
2722 }
2723
2724 public function handle_received_move( $activity, $user_id ) {
2725 return $this->handle_received_activity( $activity, $user_id, 'move' );
2726 }
2727
2728 public function handle_received_update( $activity, $user_id ) {
2729 return $this->handle_received_activity( $activity, $user_id, 'update' );
2730 }
2731
2732 public function handle_received_delete( $activity, $user_id ) {
2733 return $this->handle_received_activity( $activity, $user_id, 'delete' );
2734 }
2735
2736 public function handle_received_announce( $activity, $user_id ) {
2737 return $this->handle_received_activity( $activity, $user_id, 'announce' );
2738 }
2739
2740 public function handle_received_like( $activity, $user_id ) {
2741 return $this->handle_received_activity( $activity, $user_id, 'like' );
2742 }
2743
2744 /**
2745 * Handles incoming ActivityPub requests.
2746 *
2747 * @param array $activity The activity object.
2748 * @param int $user_id The id of the local blog user.
2749 * @param string $type The type of the activity.
2750 */
2751 public function handle_received_activity( $activity, $user_id, $type ) {
2752 if ( isset( $activity['object']['id'] ) ) {
2753 if ( isset( $this->activitypub_already_handled[ $activity['object']['id'] ] ) ) {
2754 return;
2755 }
2756
2757 // Some remote servers deliver (or retry) the same activity more than once,
2758 // which can otherwise be processed concurrently by two requests and race
2759 // on inserting the same term_relationships row. Skip the redeliveries.
2760 $duplicate_lock = 'friends_ap_seen_' . md5( $type . '|' . $activity['object']['id'] );
2761 if ( false !== get_transient( $duplicate_lock ) ) {
2762 return false;
2763 }
2764 set_transient( $duplicate_lock, true, MINUTE_IN_SECONDS );
2765 }
2766
2767 // Check if this is a reply to an existing Friends post - if so, let the ActivityPub plugin handle it as a comment.
2768 if ( 'create' === $type && ! empty( $activity['object']['inReplyTo'] ) ) {
2769 $in_reply_to = $activity['object']['inReplyTo'];
2770 if ( is_array( $in_reply_to ) ) {
2771 $in_reply_to = reset( $in_reply_to );
2772 }
2773 $reply_to_post_id = Feed::url_to_postid( $in_reply_to );
2774 if ( $reply_to_post_id ) {
2775 // This is a reply to an existing Friends post - skip processing and let ActivityPub handle it as a comment.
2776 return false;
2777 }
2778 }
2779
2780 if ( 'undo' === $type ) {
2781 if ( ! isset( $activity['object'] ) ) {
2782 return false;
2783 }
2784 $activity = $activity['object'];
2785 switch ( strtolower( $activity['type'] ) ) {
2786 case 'like':
2787 $type = 'unlike';
2788 break;
2789 case 'announce':
2790 $type = 'unannounce';
2791 break;
2792 default:
2793 return false;
2794 }
2795 }
2796
2797 if ( ! in_array(
2798 $type,
2799 array(
2800 // We don't need to handle 'Accept' types since it's handled by the ActivityPub plugin itself.
2801 'create',
2802 'delete',
2803 'announce',
2804 'unannounce',
2805 'like',
2806 'unlike',
2807 'move',
2808 'update',
2809 ),
2810 true
2811 ) ) {
2812 return false;
2813 }
2814
2815 $actor_url = $activity['actor'];
2816 $user_feed = false;
2817
2818 // First try to find by ap_actor_id (more robust for URL variations).
2819 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) && Friends::check_url( $actor_url ) ) {
2820 $ap_actor_post = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url );
2821 if ( ! is_wp_error( $ap_actor_post ) ) {
2822 $user_feed = User_Feed::get_by_ap_actor_id( $ap_actor_post->ID );
2823 }
2824 }
2825
2826 // Fall back to URL-based lookup.
2827 if ( ( ! $user_feed || is_wp_error( $user_feed ) ) && Friends::check_url( $actor_url ) ) {
2828 $user_feed = $this->friends_feed->get_user_feed_by_url( $actor_url );
2829 }
2830
2831 // If both failed and URL might need resolution, try metadata lookup.
2832 if ( is_wp_error( $user_feed ) || ! Friends::check_url( $actor_url ) ) {
2833 $meta = $this->get_metadata( $actor_url );
2834 if ( ! $meta || is_wp_error( $meta ) || ! isset( $meta['url'] ) ) {
2835 $error = 'No URL found';
2836 if ( is_wp_error( $meta ) ) {
2837 $error = $meta->get_error_message();
2838 $error .= ' ' . print_r( $meta->get_error_data(), true ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
2839 }
2840 $this->log( 'Received invalid meta for ' . $actor_url . ' ' . $error, $meta );
2841 return false;
2842 }
2843
2844 $actor_url = $meta['url'];
2845 if ( ! Friends::check_url( $actor_url ) ) {
2846 $this->log( 'Received invalid meta url for ' . $actor_url );
2847 return false;
2848 }
2849
2850 // Try ap_actor_id lookup with resolved URL first.
2851 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
2852 $ap_actor_post = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url );
2853 if ( ! is_wp_error( $ap_actor_post ) ) {
2854 $user_feed = User_Feed::get_by_ap_actor_id( $ap_actor_post->ID );
2855 }
2856 }
2857
2858 // Fall back to URL-based lookup with resolved URL.
2859 if ( ! $user_feed || is_wp_error( $user_feed ) ) {
2860 $user_feed = $this->friends_feed->get_user_feed_by_url( $actor_url );
2861 }
2862 }
2863
2864 if ( ! $user_feed || is_wp_error( $user_feed ) ) {
2865 if ( isset( $activity['object']['tag'] ) && is_array( $activity['object']['tag'] ) ) {
2866 $my_activitypub_id = (string) $this->get_activitypub_actor( $user_id );
2867 foreach ( $activity['object']['tag'] as $tag ) {
2868 if ( isset( $tag['type'] ) && 'Mention' === $tag['type'] && isset( $tag['href'] ) && $tag['href'] === $my_activitypub_id ) {
2869 // It was a mention.
2870 $user_feed = $this->get_external_mentions_feed();
2871 break;
2872 }
2873 }
2874 }
2875 }
2876
2877 if ( ! $user_feed || is_wp_error( $user_feed ) ) {
2878 // We're not following this user.
2879 return false;
2880 }
2881
2882 $item = $this->process_incoming_activity( $type, $activity, $user_id, $user_feed );
2883
2884 if ( $item instanceof Feed_Item ) {
2885 $i = $this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed );
2886
2887 // If this is a reply and the user was mentioned, queue for background conversion to comment.
2888 if ( 'create' === $type && ! empty( $activity['object']['inReplyTo'] ) && ! empty( $item->friend_mention_tags ) ) {
2889 $post_id = Feed::url_to_postid( $item->permalink );
2890 if ( $post_id && Friend_Tag::has_mention( $post_id ) ) {
2891 wp_schedule_single_event( time() + 30, 'friends_convert_single_reply', array( $post_id ) );
2892 }
2893 }
2894
2895 return $item;
2896 }
2897
2898 return false;
2899 }
2900
2901 /**
2902 * Process an incoming activity.
2903 *
2904 * @param string $type The type of the activity.
2905 * @param array $activity The activity object.
2906 * @param int $user_id The id of the local user if any.
2907 * @param User_Feed $user_feed The user feed.
2908 * @return Feed_Item|null The feed item or null if it's not a valid activity.
2909 */
2910 protected function process_incoming_activity( $type, $activity, $user_id, $user_feed ) {
2911 $tags = array();
2912 $mention_tags = array();
2913
2914 // Extract mention tags from CC field - if the current user is in CC, add their mention tag.
2915 if ( isset( $activity['cc'] ) && is_array( $activity['cc'] ) ) {
2916 $my_activitypub_id = (string) $this->get_activitypub_actor( $user_id );
2917 if ( in_array( $my_activitypub_id, (array) $activity['cc'], true ) ) {
2918 $local_user = get_userdata( $user_id );
2919 if ( $local_user ) {
2920 $mention_tags[] = Friend_Tag::mention_tag( $local_user->user_login );
2921 }
2922 }
2923 }
2924
2925 // Extract hashtags from ActivityPub object tags.
2926 if ( isset( $activity['object']['tag'] ) && is_array( $activity['object']['tag'] ) ) {
2927 foreach ( $activity['object']['tag'] as $tag ) {
2928 if ( isset( $tag['type'] ) && 'Hashtag' === $tag['type'] && isset( $tag['name'] ) ) {
2929 $tag_name = ltrim( $tag['name'], '#' );
2930 $tag_name = sanitize_title( $tag_name );
2931 if ( ! empty( $tag_name ) ) {
2932 $tags[] = $tag_name;
2933 }
2934 }
2935 }
2936 }
2937
2938 switch ( $type ) {
2939 case 'create':
2940 return $this->handle_incoming_create( $activity['object'], $tags, $mention_tags );
2941 case 'update':
2942 if ( isset( $activity['object']['type'] ) && 'Person' === $activity['object']['type'] ) {
2943 if ( ! $user_feed instanceof User_Feed ) {
2944 return null;
2945 }
2946 return $this->handle_incoming_update_person( $activity['object'], $user_feed );
2947 }
2948 $item = $this->handle_incoming_create( $activity['object'], $tags, $mention_tags );
2949 if ( isset( $activity['object']['type'] ) && 'Note' === $activity['object']['type'] ) {
2950 $friend_user = $user_feed->get_friend_user();
2951 $post_id = Feed::url_to_postid( $item->permalink );
2952 $message = sprintf(
2953 // translators: %1$s is the post URL, %2$s is the linked user display name.
2954 __( 'Received <a href="%1$s">post update</a> for %2$s', 'friends' ),
2955 $friend_user->get_local_friends_page_url( $post_id ),
2956 '<a href="' . esc_url( $friend_user->get_local_friends_page_url() ) . '">' . esc_html( $friend_user->display_name ) . '</a>'
2957 );
2958 $details = array();
2959 if ( $post_id ) {
2960 $_post = get_post( $post_id );
2961 if ( ! class_exists( 'WP_Text_Diff_Renderer_inline', false ) ) {
2962 require ABSPATH . WPINC . '/wp-diff.php';
2963 }
2964 $diff = new \Text_Diff( explode( 'PHP_EOL', wp_strip_all_tags( $_post->post_content ) ), explode( 'PHP_EOL', wp_strip_all_tags( $item->content ) ) );
2965 $renderer = new \WP_Text_Diff_Renderer_inline();
2966 $details['content'] = $renderer->render( $diff );
2967 if ( empty( $details['content'] ) ) {
2968 unset( $details['content'] );
2969 }
2970 }
2971
2972 if ( ! empty( $details ) ) {
2973 $details['post_id'] = $post_id;
2974 $details['activity'] = $activity;
2975 Logging::log( 'post-update', $message, $details, self::SLUG, 0, $friend_user->ID );
2976 }
2977 }
2978 return $item;
2979 case 'delete':
2980 return $this->handle_incoming_delete( $activity['object'] );
2981 case 'announce':
2982 return $this->handle_incoming_announce( $activity['object'], $user_id, $activity['published'] );
2983 case 'unannounce':
2984 return $this->handle_incoming_unannounce( $activity['object'], $user_feed );
2985 case 'like':
2986 return $this->handle_incoming_like( $activity, $user_id );
2987 case 'unlike':
2988 return $this->handle_incoming_unlike( $activity, $user_id );
2989 case 'move':
2990 return $this->handle_incoming_move( $activity, $user_feed );
2991 }
2992 return null;
2993 }
2994
2995 /**
2996 * Map the Activity type to a post fomat.
2997 *
2998 * @param string $type The type.
2999 *
3000 * @return string The determined post format.
3001 */
3002 private function map_type_to_post_format( $type ) {
3003 return 'status';
3004 }
3005
3006 /**
3007 * We received a post for a feed, handle it.
3008 *
3009 * @param array $activity The object from ActivityPub.
3010 * @param array $tags Optional hashtags to attach to the item.
3011 * @param array $mention_tags Optional mention tags to attach to the item.
3012 */
3013 private function handle_incoming_create( $activity, $tags = array(), $mention_tags = array() ) {
3014 $permalink = $activity['id'];
3015 if ( isset( $activity['url'] ) ) {
3016 if ( is_array( $activity['url'] ) ) {
3017 if ( empty( $activity['attachment'] ) ) {
3018 $activity['attachment'] = $activity['url'];
3019 }
3020 } elseif ( is_string( $activity['url'] ) ) {
3021 $permalink = $activity['url'];
3022 }
3023 }
3024
3025 $data = array(
3026 'permalink' => $permalink,
3027 'content' => $activity['content'],
3028 'post_format' => $this->map_type_to_post_format( $activity['type'] ),
3029 'date' => $activity['published'],
3030 '_external_id' => $activity['id'],
3031 self::SLUG => array(),
3032 );
3033
3034 // Set author for all posts from attributedTo.
3035 if ( isset( $activity['attributedTo'] ) ) {
3036 $meta = $this->get_metadata( $activity['attributedTo'] );
3037 $this->log( 'Attributed to ' . $activity['attributedTo'], compact( 'meta' ) );
3038
3039 if ( $meta && ! is_wp_error( $meta ) ) {
3040 if ( isset( $meta['name'] ) ) {
3041 $data['author'] = $meta['name'];
3042 } elseif ( isset( $meta['preferredUsername'] ) ) {
3043 $data['author'] = $meta['preferredUsername'];
3044 }
3045
3046 // Store attributedTo for avatar/author URL display.
3047 $actor_url = isset( $meta['id'] ) ? $meta['id'] : $activity['attributedTo'];
3048
3049 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
3050 $actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url );
3051 if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
3052 $data[ self::SLUG ]['attributedTo'] = array( 'ap_actor_id' => $actor_post->ID );
3053 $acct = \Activitypub\Collection\Remote_Actors::get_acct( $actor_post->ID );
3054 if ( $acct ) {
3055 $data[ self::SLUG ]['attributedTo']['acct'] = ltrim( $acct, '@' );
3056 }
3057 } else {
3058 $data[ self::SLUG ]['attributedTo'] = array( 'id' => $actor_url );
3059 }
3060 } else {
3061 $data[ self::SLUG ]['attributedTo'] = array( 'id' => $actor_url );
3062 }
3063 if ( empty( $data[ self::SLUG ]['attributedTo']['acct'] ) ) {
3064 $acct = self::get_actor_acct_from_attributed_to(
3065 array_merge(
3066 $data[ self::SLUG ]['attributedTo'],
3067 array_intersect_key(
3068 $meta,
3069 array(
3070 'acct' => true,
3071 )
3072 )
3073 )
3074 );
3075 if ( $acct ) {
3076 $data[ self::SLUG ]['attributedTo']['acct'] = $acct;
3077 }
3078 }
3079 }
3080 }
3081
3082 if ( isset( $activity['reblog'] ) && $activity['reblog'] ) {
3083 $data[ self::SLUG ]['reblog'] = $activity['reblog'];
3084 }
3085
3086 if ( isset( $activity['application'] ) && $activity['application'] ) {
3087 $data[ self::SLUG ]['application'] = $activity['application'];
3088 }
3089
3090 if ( ! empty( $activity['attachment'] ) ) {
3091 $attachments = array();
3092 foreach ( $activity['attachment'] as $attachment ) {
3093 if ( ! isset( $attachment['type'] ) || ! isset( $attachment['mediaType'] ) ) {
3094 continue;
3095 }
3096 if ( ! in_array( $attachment['type'], array( 'Document', 'Image', 'Link' ), true ) ) {
3097 continue;
3098 }
3099
3100 if ( empty( $attachment['url'] ) ) {
3101 if ( ! empty( $attachment['href'] ) ) {
3102 $attachment['url'] = $attachment['href'];
3103 }
3104 }
3105 if ( 'application/x-mpegURL' === $attachment['mediaType'] ) {
3106 if ( ! empty( $attachment['tag'] ) ) {
3107 $videos = array();
3108 foreach ( $attachment['tag'] as $tag ) {
3109 if ( strpos( $tag['mediaType'], 'video/' ) === false ) {
3110 continue;
3111 }
3112 if ( empty( $tag['rel'] ) ) {
3113 $videos[ $tag['height'] ] = $tag;
3114 }
3115 }
3116
3117 if ( ! empty( $videos ) ) {
3118 if ( isset( $videos[720] ) ) {
3119 $video = $videos[720];
3120 } elseif ( isset( $videos[480] ) ) {
3121 $video = $videos[480];
3122 } elseif ( isset( $videos[360] ) ) {
3123 $video = $videos[360];
3124 } else {
3125 $video = reset( $videos );
3126 }
3127
3128 $data['content'] .= PHP_EOL;
3129 $data['content'] .= '<!-- wp:video -->';
3130 $data['content'] .= '<figure class="wp-block-video"><video controls="controls"';
3131 if ( isset( $video['width'] ) && $video['height'] ) {
3132 $data['content'] .= ' width="' . esc_attr( $video['width'] ) . '"';
3133 $data['content'] .= ' height="' . esc_attr( $video['height'] ) . '"';
3134 }
3135 if ( isset( $activity['icon'] ) && ! empty( $activity['icon'] ) ) {
3136 if ( is_array( $activity['icon'] ) ) {
3137 $poster = null;
3138 // choose the larger icon.
3139 foreach ( $activity['icon'] as $icon ) {
3140 if ( isset( $icon['width'] ) && isset( $icon['height'] ) && isset( $icon['url'] ) && ( ! $poster || ( $icon['width'] > $poster['width'] && $icon['height'] > $poster['height'] ) ) ) {
3141 $poster = $icon;
3142 }
3143 }
3144 } else {
3145 $poster = $activity['icon'];
3146 }
3147 if ( $poster ) {
3148 $data['content'] .= ' poster="' . esc_url( $poster['url'] ) . '"';
3149 }
3150 }
3151 $data['content'] .= ' src="' . esc_url( $video['href'] ) . '" type="' . esc_attr( $video['mediaType'] ) . '"';
3152 if ( isset( $activity['duration'] ) ) {
3153 $data['content'] .= ' duration="' . esc_attr( $activity['duration'] ) . '"';
3154 }
3155 $data['content'] .= '/>';
3156 if ( ! empty( $activity['subtitleLanguage'] ) ) {
3157 foreach ( $activity['subtitleLanguage'] as $subtitle ) {
3158 $type = '';
3159 if ( '.vtt' === substr( $subtitle['url'], -4 ) ) {
3160 $type = 'text/vtt';
3161 }
3162 $data['content'] .= '<track src="' . esc_url( $subtitle['url'] ) . '" kind="subtitles" srclang="' . esc_attr( $subtitle['identifier'] ) . '" type="' . esc_attr( $type ) . '" label="' . esc_attr( $subtitle['name'] ) . '" />';
3163 }
3164 }
3165 $data['content'] .= '</video>';
3166 if ( ! empty( $attachment['name'] ) ) {
3167 $data['content'] .= '<figcaption class="wp-element-caption">' . esc_html( $attachment['name'] ) . '</figcaption>';
3168 }
3169 $data['content'] .= '</figure>';
3170 $data['content'] .= '<!-- /wp:video -->';
3171
3172 }
3173 }
3174 } elseif ( strpos( $attachment['mediaType'], 'image/' ) === 0 ) {
3175 $data['content'] .= PHP_EOL;
3176 $data['content'] .= '<!-- wp:image -->';
3177 $data['content'] .= '<p><img src="' . esc_url( $attachment['url'] ) . '"';
3178 if ( isset( $attachment['width'] ) && $attachment['height'] ) {
3179 $data['content'] .= ' width="' . esc_attr( $attachment['width'] ) . '"';
3180 $data['content'] .= ' height="' . esc_attr( $attachment['height'] ) . '"';
3181 }
3182 $data['content'] .= ' class="size-full" /></p>';
3183 $data['content'] .= '<!-- /wp:image -->';
3184 } elseif ( strpos( $attachment['mediaType'], 'video/' ) === 0 ) {
3185 $data['content'] .= PHP_EOL;
3186 $data['content'] .= '<!-- wp:video -->';
3187 $data['content'] .= '<figure class="wp-block-video"><video controls src="' . esc_url( $attachment['url'] ) . '" type="' . esc_attr( $attachment['mediaType'] ) . '">';
3188 $data['content'] .= '</video>';
3189 if ( ! empty( $attachment['name'] ) ) {
3190 $data['content'] .= '<figcaption class="wp-element-caption">' . esc_html( $attachment['name'] ) . '</figcaption>';
3191 }
3192 $data['content'] .= '</figure>';
3193 $data['content'] .= '<!-- /wp:video -->';
3194 }
3195 }
3196 }
3197
3198 if ( ! empty( $tags ) && is_array( $tags ) ) {
3199 $data['friend_tags'] = $tags;
3200 }
3201
3202 // These are separate so that they won't be faked.
3203 if ( ! empty( $mention_tags ) && is_array( $mention_tags ) ) {
3204 $data['friend_mention_tags'] = $mention_tags;
3205 }
3206
3207 // Store mention URLs for reply filtering in modify_incoming_item.
3208 if ( ! empty( $activity['tag'] ) && is_array( $activity['tag'] ) ) {
3209 $mention_urls = array();
3210 foreach ( $activity['tag'] as $tag ) {
3211 if ( isset( $tag['type'] ) && 'Mention' === $tag['type'] && isset( $tag['href'] ) ) {
3212 $mention_urls[] = $tag['href'];
3213 }
3214 }
3215 if ( ! empty( $mention_urls ) ) {
3216 $data['_mention_urls'] = $mention_urls;
3217 }
3218 }
3219
3220 $this->log(
3221 'Received feed item',
3222 array(
3223 'url' => $permalink,
3224 'data' => $data,
3225 )
3226 );
3227
3228 if ( defined( 'ACTIVITYPUB_OBJECT_STATE_FEDERATED' ) ) {
3229 $data['meta']['activitypub_status'] = ACTIVITYPUB_OBJECT_STATE_FEDERATED;
3230 }
3231
3232 return new Feed_Item( $data );
3233 }
3234
3235 /**
3236 * We received a update of a person, handle it.
3237 *
3238 * @param array $activity The object from ActivityPub.
3239 * @param User_Feed $user_feed The user feed.
3240 */
3241 private function handle_incoming_update_person( $activity, User_Feed $user_feed ) {
3242 $friend_user = $user_feed->get_friend_user();
3243 $this->log( 'Received person update for ' . $friend_user->user_login, compact( 'activity' ) );
3244
3245 $message = sprintf(
3246 // translators: %s is the user login.
3247 __( 'Received person update for %s', 'friends' ),
3248 '<a href="' . esc_url( $friend_user->get_local_friends_page_url() ) . '">' . esc_html( $friend_user->display_name ) . '</a>'
3249 );
3250
3251 $details = array();
3252
3253 if ( ! empty( $activity['summary'] ) && $friend_user->description !== $activity['summary'] ) {
3254 if ( ! class_exists( 'WP_Text_Diff_Renderer_inline', false ) ) {
3255 require ABSPATH . WPINC . '/wp-diff.php';
3256 }
3257 $summary = wp_encode_emoji( $activity['summary'] );
3258 $diff = new \Text_Diff( explode( PHP_EOL, $friend_user->description ), explode( PHP_EOL, $summary ) );
3259 $renderer = new \WP_Text_Diff_Renderer_inline();
3260 $details['summary'] = $renderer->render( $diff );
3261 if ( empty( $details['summary'] ) ) {
3262 unset( $details['summary'] );
3263 } else {
3264 $message .= ' ' . __( 'Updated description.', 'friends' );
3265 }
3266
3267 $friend_user->description = $summary;
3268 }
3269 if ( ! empty( $activity['icon']['url'] ) && $friend_user->get_avatar_url() !== $activity['icon']['url'] ) {
3270 $details['old-icon'] = '<img src="' . esc_url( $friend_user->get_avatar_url() ) . '" style="max-height: 32px; max-width: 32px" />';
3271 $details['new-icon'] = '<img src="' . esc_url( $activity['icon']['url'] ) . '" style="max-height: 32px; max-width: 32px" />';
3272 $friend_user->update_user_icon_url( $activity['icon']['url'] );
3273 $message .= ' ' . __( 'Updated icon.', 'friends' );
3274 }
3275 $friend_user->save();
3276
3277 if ( ! empty( $details ) ) {
3278 $details['object'] = $activity;
3279 Logging::log( 'user-update', $message, $details, self::SLUG, 0, $friend_user->ID );
3280 }
3281
3282 return null; // No feed item to submit.
3283 }
3284 /**
3285 * We received a delete of a post, handle it.
3286 *
3287 * @param array $activity The object from ActivityPub.
3288 */
3289 private function handle_incoming_delete( $activity ) {
3290 $permalink = $activity['id'];
3291 if ( isset( $activity['url'] ) ) {
3292 $permalink = $activity['url'];
3293 }
3294
3295 $this->log( 'Received delete for ' . $permalink );
3296
3297 $comment_id = null;
3298 if ( preg_match( '/#comment-(\d+)$/i', $permalink, $matches ) ) {
3299 $comment_id = $matches[1];
3300 $permalink = substr( $permalink, 0, - strlen( $matches[0] ) );
3301 }
3302
3303 $post_id = Feed::url_to_postid( $permalink );
3304 if ( $post_id ) {
3305 if ( $comment_id ) {
3306 $comment = get_comment( $comment_id );
3307 if ( $comment ) {
3308 wp_delete_comment( $comment_id );
3309 return true;
3310 }
3311 } else {
3312 $meta = get_post_meta( $post_id, self::SLUG, true );
3313 if ( ! empty( $meta ) ) {
3314 wp_trash_post( $post_id );
3315 return true;
3316 }
3317 }
3318 }
3319 return false;
3320 }
3321
3322 /**
3323 * We received an announced URL (boost) for a feed, handle it.
3324 *
3325 * @param array $url The announced URL.
3326 * @param int $user_id The user id (for retrieving the keys).
3327 * @param string $published_date The published date.
3328 */
3329 private function handle_incoming_announce( $url, $user_id, $published_date = null ) {
3330 if ( ! Friends::check_url( $url ) ) {
3331 $this->log( 'Received invalid announce', compact( 'url' ) );
3332 return false;
3333 }
3334 $this->log( 'Received announce for ' . $url );
3335 $response = \Activitypub\safe_remote_get( $url );
3336 if ( \is_wp_error( $response ) ) {
3337 return $response;
3338 }
3339 $json = \wp_remote_retrieve_body( $response );
3340 $object = \json_decode( $json, true );
3341 if ( ! $object ) {
3342 $this->log( 'Received invalid json', compact( 'json' ) );
3343 return false;
3344 }
3345 $this->log( 'Received response', compact( 'url', 'object' ) );
3346 if ( ! $published_date ) {
3347 $published_date = 'now';
3348 }
3349 $object['published'] = gmdate( 'Y-m-d H:i:s', strtotime( $published_date ) );
3350 $object['reblog'] = true;
3351
3352 return $this->handle_incoming_create( $object, array(), array() );
3353 }
3354
3355 /**
3356 * We received an announced URL (boost) for a feed, handle it.
3357 *
3358 * @param array $url The announced URL.
3359 * @param User_Feed $user_feed The user feed.
3360 */
3361 private function handle_incoming_unannounce( $url, $user_feed ) {
3362 if ( ! Friends::check_url( $url ) ) {
3363 $this->log( 'Received invalid unannounce', compact( 'url' ) );
3364 return false;
3365 }
3366 $this->log( 'Received unannounce for ' . $url );
3367
3368 $post_id = Feed::url_to_postid( $url, $user_feed->get_friend_user()->ID );
3369 if ( $post_id ) {
3370 $meta = get_post_meta( $post_id, self::SLUG, true );
3371 if ( isset( $meta['reblog'] ) ) {
3372 wp_trash_post( $post_id );
3373 return true;
3374 }
3375 }
3376 return false;
3377 }
3378
3379 public function get_reaction_display_name( $display_name, $term ) {
3380 $url = get_term_meta( $term->term_id, 'url', true );
3381 $meta = \Activitypub\get_remote_metadata_by_actor( $url );
3382 if ( $meta && ! is_wp_error( $meta ) && ! empty( $meta['preferredUsername'] ) ) {
3383 $host = wp_parse_url( $meta['id'], PHP_URL_HOST );
3384 return '@' . $meta['preferredUsername'] . '@' . $host;
3385 }
3386 return $display_name;
3387 }
3388
3389 public function author_avatar_url( $avatar_url, $friend_user, $post_id ) {
3390 $meta = get_post_meta( $post_id, self::SLUG, true );
3391 if ( ! $meta || ! isset( $meta['attributedTo'] ) ) {
3392 return $avatar_url;
3393 }
3394
3395 // Only use ActivityPub actor metadata for reblogs where the original author differs.
3396 if ( ! is_array( $meta ) || empty( $meta['reblog'] ) ) {
3397 return $avatar_url;
3398 }
3399
3400 $actor_metadata = self::get_actor_metadata_from_attributed_to( $meta['attributedTo'] );
3401 if ( ! empty( $actor_metadata['icon'] ) ) {
3402 return $actor_metadata['icon'];
3403 }
3404 return $avatar_url;
3405 }
3406
3407 /**
3408 * Filter the author URL for a post.
3409 *
3410 * @param string $author_url The author URL.
3411 * @param User $friend_user The friend user.
3412 * @param int $post_id The post ID.
3413 * @return string The filtered author URL.
3414 */
3415 public function author_url( $author_url, $friend_user, $post_id ) {
3416 // Only override the URL for the External user.
3417 // Regular subscriptions should link to their local friends page.
3418 if ( self::EXTERNAL_USERNAME !== $friend_user->user_login ) {
3419 return $author_url;
3420 }
3421
3422 $meta = get_post_meta( $post_id, self::SLUG, true );
3423 if ( ! $meta || ! isset( $meta['attributedTo'] ) ) {
3424 return $author_url;
3425 }
3426 $actor_metadata = self::get_actor_metadata_from_attributed_to( $meta['attributedTo'] );
3427 if ( ! empty( $actor_metadata['url'] ) ) {
3428 return $actor_metadata['url'];
3429 }
3430 return $author_url;
3431 }
3432
3433
3434 public function handle_incoming_like( $activity, $user_id ) {
3435 $post_id = Feed::url_to_postid( $activity['object'] );
3436 if ( ! $post_id ) {
3437 return false;
3438 }
3439 $taxonomy_username = 'ap-' . crc32( $activity['actor'] );
3440
3441 // Allow setting the term with its meta.
3442 $taxonomy = Reactions::register_user_taxonomy( $taxonomy_username );
3443
3444 register_term_meta(
3445 $taxonomy,
3446 'url',
3447 array(
3448 'show_in_rest' => false,
3449 'single' => true,
3450 'type' => 'string',
3451 'sanitize_callback' => 'sanitize_text_field',
3452 )
3453 );
3454
3455 // 2b50 = star.
3456 // 2764 = heart.
3457 $term_id = apply_filters( 'friends_react', null, $post_id, '2b50', $taxonomy_username );
3458 if ( ! $term_id || is_wp_error( $term_id ) ) {
3459 return false;
3460 }
3461
3462 update_term_meta( $term_id, 'url', $activity['actor'] );
3463
3464 return $term_id;
3465 }
3466
3467 public function handle_incoming_unlike( $activity, $user_id ) {
3468 $post_id = Feed::url_to_postid( $activity['object'] );
3469 if ( ! $post_id ) {
3470 return false;
3471 }
3472
3473 $taxonomy_username = 'ap-' . crc32( $activity['actor'] );
3474
3475 // Allow removing the term.
3476 Reactions::register_user_taxonomy( $taxonomy_username );
3477
3478 // 2b50 = star.
3479 // 2764 = heart.
3480 $ret = apply_filters( 'friends_unreact', null, $post_id, '2b50', $taxonomy_username );
3481 if ( ! $ret || is_wp_error( $ret ) ) {
3482 return false;
3483 }
3484
3485 return true;
3486 }
3487
3488 public function handle_incoming_move( $activity, User_Feed $user_feed ) {
3489 $old_url = $activity['object'];
3490 if ( $user_feed->get_url() !== $old_url ) {
3491 $this->log( 'Could not determine the right feed to be moved. Looking for ' . $old_url . ', got ' . $user_feed->get_url() );
3492 return false;
3493 }
3494
3495 $feed = array(
3496 'url' => $activity['target'],
3497 'mime-type' => $user_feed->get_mime_type(),
3498 'title' => $user_feed->get_title(),
3499 'parser' => $user_feed->get_parser(),
3500 'post-format' => $user_feed->get_post_format(),
3501 'active' => $user_feed->is_active(),
3502 );
3503 $this->log( 'Received Move from ' . $old_url . ' to ' . $feed['url'] );
3504
3505 // Similar as in process_admin_edit_friend_feeds.
3506 if ( $user_feed->get_url() !== $feed['url'] ) {
3507 $friend = $user_feed->get_friend_user();
3508 do_action( 'friends_user_feed_deactivated', $user_feed );
3509
3510 if ( ! isset( $feed['mime-type'] ) ) {
3511 $feed['mime-type'] = $user_feed->get_mime_type();
3512 }
3513
3514 if ( $feed['active'] ) {
3515 $new_feed = $friend->subscribe( $feed['url'], $feed );
3516 if ( ! is_wp_error( $new_feed ) ) {
3517 do_action( 'friends_user_feed_activated', $new_feed );
3518 }
3519 } else {
3520 $new_feed = $friend->save_feed( $feed['url'], $feed );
3521 }
3522
3523 // Link the new feed to the ap_actor post for the new URL.
3524 if ( $new_feed instanceof User_Feed && class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
3525 $actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $feed['url'] );
3526 if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
3527 $new_feed->set_ap_actor_id( $actor_post->ID );
3528 }
3529 }
3530
3531 // Since the URL has changed, the above will create a new feed, therefore we need to delete the old one.
3532 $user_feed->delete();
3533
3534 if ( is_wp_error( $new_feed ) ) {
3535 do_action( 'friends_process_feed_item_submit_error', $new_feed, $feed );
3536 return $new_feed;
3537 }
3538
3539 do_action( 'friends_process_feed_item_submit', $new_feed, $feed );
3540 $new_feed->update_last_log(
3541 sprintf(
3542 // translators: %s is the old URL.
3543 __( 'Moved from old URL: %s', 'friends' ),
3544 $user_feed->get_url()
3545 )
3546 );
3547
3548 $message = sprintf(
3549 // translators: %s is the new URL.
3550 __( '%1$s moved to a new URL: %2$s', 'friends' ),
3551 '<a href="' . esc_url( $old_url ) . '">' . esc_html( $feed['title'] ) . '</a>',
3552 '<a href="' . esc_url( $new_feed->get_url() ) . '">' . esc_html( $new_feed->get_title() ) . '</a>'
3553 );
3554
3555 Logging::log( 'feed-move', $message, $activity, self::SLUG, 0, $friend->ID );
3556
3557 return $new_feed;
3558 } else {
3559 $this->log( 'Move URL didn\'t change, old: ' . $old_url . ', new: ' . $feed['url'] );
3560 }
3561
3562 return true;
3563 }
3564
3565 /**
3566 * Queue a hook to run async.
3567 *
3568 * @param string $hook The hook name.
3569 * @param array $args The arguments to pass to the hook.
3570 * @param string $unqueue_hook Optional a hook to unschedule before queuing.
3571 * @return void|bool Whether the hook was queued.
3572 */
3573 public function queue( $hook, $args, $unqueue_hook = null ) {
3574 if ( $unqueue_hook ) {
3575 $hook_timestamp = wp_next_scheduled( $unqueue_hook, $args );
3576 if ( $hook_timestamp ) {
3577 wp_unschedule_event( $hook_timestamp, $unqueue_hook, $args );
3578 }
3579 }
3580
3581 if ( wp_next_scheduled( $hook, $args ) ) {
3582 return;
3583 }
3584
3585 return \wp_schedule_single_event( \time(), $hook, $args );
3586 }
3587
3588 /**
3589 * Prepare to follow the user via a scheduled event.
3590 *
3591 * @param User_Feed $user_feed The user feed.
3592 *
3593 * @return bool|WP_Error Whether the event was queued.
3594 */
3595 public function queue_follow_user( User_Feed $user_feed ) {
3596 if ( self::SLUG !== $user_feed->get_parser() ) {
3597 return;
3598 }
3599
3600 $queued = \Activitypub\follow( $user_feed->get_url(), self::get_activitypub_actor_id( null ) );
3601
3602 if ( $queued ) {
3603 $user_feed->update_last_log( __( 'Queued follow request.', 'friends' ) );
3604 }
3605
3606 return $queued;
3607 }
3608
3609 /**
3610 * Handle Follow activities added to the ActivityPub outbox.
3611 *
3612 * When someone follows a user via the ActivityPub plugin (not through Friends),
3613 * this method auto-creates a corresponding Friends subscription.
3614 *
3615 * @param int $outbox_activity_id The outbox activity post ID.
3616 * @param object $activity The Activity object.
3617 * @param int $user_id The user ID.
3618 * @param string $content_visibility The content visibility.
3619 */
3620 public function handle_outbox_follow( $outbox_activity_id, $activity, $user_id, $content_visibility ) {
3621 // Only process Follow activities.
3622 if ( ! $activity || 'Follow' !== $activity->get_type() ) {
3623 return;
3624 }
3625
3626 $actor_url = $activity->get_object();
3627 if ( empty( $actor_url ) || ! is_string( $actor_url ) ) {
3628 return;
3629 }
3630
3631 // Check if a Friends subscription already exists for this actor.
3632 $existing_feed = User_Feed::get_by_url( $actor_url );
3633 if ( $existing_feed instanceof User_Feed ) {
3634 // Already have a subscription, ensure it's active.
3635 if ( ! $existing_feed->get_active() ) {
3636 $existing_feed->update_metadata( 'active', true );
3637 }
3638 return;
3639 }
3640
3641 // Create a new Friends subscription for this actor.
3642 $this->create_friend_subscription_from_actor( $actor_url );
3643 }
3644
3645 /**
3646 * Create a Friends subscription from an ActivityPub actor URL.
3647 *
3648 * @param string $actor_url The ActivityPub actor URL.
3649 * @return Subscription|\WP_Error The created subscription or error.
3650 */
3651 public function create_friend_subscription_from_actor( $actor_url ) {
3652 $meta = $this->get_metadata( $actor_url );
3653 if ( is_wp_error( $meta ) ) {
3654 return $meta;
3655 }
3656
3657 if ( ! is_array( $meta ) || empty( $meta['preferredUsername'] ) ) {
3658 return new \WP_Error( 'invalid_actor', 'Invalid actor metadata' );
3659 }
3660
3661 // Generate a unique user login.
3662 $host = wp_parse_url( $actor_url, PHP_URL_HOST );
3663 $user_login = sanitize_title( $meta['preferredUsername'] . '.' . $host );
3664
3665 // Get the ap_actor post ID.
3666 $ap_actor_id = null;
3667 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
3668 $actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url );
3669 if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
3670 $ap_actor_id = $actor_post->ID;
3671 }
3672 }
3673
3674 // Check if user already exists.
3675 $existing_user = get_user_by( 'login', $user_login );
3676 if ( $existing_user ) {
3677 // User exists, try to get their feeds.
3678 $user = User::get_user_by_id( $existing_user->ID );
3679 if ( $user ) {
3680 // Add the feed if it doesn't exist.
3681 $feeds = $user->get_active_feeds();
3682 foreach ( $feeds as $feed ) {
3683 if ( $feed->get_url() === $actor_url ) {
3684 // Ensure ap_actor_id is linked.
3685 if ( $ap_actor_id && ! $feed->get_ap_actor_id() ) {
3686 $feed->set_ap_actor_id( $ap_actor_id );
3687 }
3688 return $user; // Already have this feed.
3689 }
3690 }
3691 // Add the feed.
3692 $user_feed = $user->save_feed(
3693 $actor_url,
3694 array(
3695 'parser' => self::SLUG,
3696 'active' => true,
3697 'title' => $meta['name'] ?? $meta['preferredUsername'],
3698 )
3699 );
3700 if ( $user_feed instanceof User_Feed && $ap_actor_id ) {
3701 $user_feed->set_ap_actor_id( $ap_actor_id );
3702 }
3703 return $user;
3704 }
3705 }
3706
3707 // Create new subscription.
3708 $subscription = Subscription::create(
3709 $user_login,
3710 'subscription',
3711 $actor_url,
3712 $meta['name'] ?? $meta['preferredUsername'],
3713 isset( $meta['icon']['url'] ) ? $meta['icon']['url'] : null,
3714 $meta['summary'] ?? null
3715 );
3716
3717 if ( is_wp_error( $subscription ) ) {
3718 return $subscription;
3719 }
3720
3721 // Add the feed.
3722 $user_feed = $subscription->save_feed(
3723 $actor_url,
3724 array(
3725 'parser' => self::SLUG,
3726 'active' => true,
3727 'title' => $meta['name'] ?? $meta['preferredUsername'],
3728 )
3729 );
3730
3731 // Link the feed to the ap_actor post.
3732 if ( $user_feed instanceof User_Feed && $ap_actor_id ) {
3733 $user_feed->set_ap_actor_id( $ap_actor_id );
3734 }
3735
3736 return $subscription;
3737 }
3738
3739 /**
3740 * Prepare to unfollow the user via a scheduled event.
3741 *
3742 * @param User_Feed $user_feed The user feed.
3743 *
3744 * @return bool|WP_Error Whether the event was queued.
3745 */
3746 public function queue_unfollow_user( User_Feed $user_feed ) {
3747 if ( self::SLUG !== $user_feed->get_parser() ) {
3748 return false;
3749 }
3750
3751 $queued = \Activitypub\unfollow( $user_feed->get_url(), self::get_activitypub_actor_id( null ) );
3752
3753 if ( $queued ) {
3754 $user_feed->update_last_log( __( 'Queued unfollow request.', 'friends' ) );
3755 }
3756
3757 return $queued;
3758 }
3759
3760 /**
3761 * Extract the mentions from the post_content.
3762 *
3763 * @param array $mentions The already found mentions.
3764 * @param string $post_content The post content.
3765 * @return mixed The discovered mentions.
3766 */
3767 public function activitypub_extract_mentions( $mentions, $post_content ) {
3768 // Find all @mentions in the content.
3769 if ( ! preg_match_all( '/@([a-zA-Z0-9_.-]+(?:@[a-zA-Z0-9.-]+)?)\b/i', wp_strip_all_tags( $post_content ), $matches ) ) {
3770 return $mentions;
3771 }
3772
3773 foreach ( array_unique( $matches[1] ) as $handle ) {
3774 $url = $this->resolve_mention_to_url( $handle );
3775 if ( $url ) {
3776 $mentions[ '@' . $handle ] = $url;
3777 }
3778 }
3779
3780 return $mentions;
3781 }
3782
3783 /**
3784 * Resolve a mention handle to an ActivityPub URL.
3785 *
3786 * @param string $handle The handle (e.g., "bob" or "bob@mastodon.social").
3787 * @return string|null The ActivityPub URL or null if not found.
3788 */
3789 private function resolve_mention_to_url( $handle ) {
3790 // Check if it's a full handle like user@domain.
3791 if ( strpos( $handle, '@' ) !== false ) {
3792 return $this->resolve_full_handle( $handle );
3793 }
3794
3795 $sanitized_handle = sanitize_title( $handle );
3796
3797 // Check Friends/Subscriptions first (they take precedence for outgoing mentions).
3798 $url = $this->resolve_friend_by_slug( $sanitized_handle );
3799 if ( $url ) {
3800 return $url;
3801 }
3802
3803 // Check local users.
3804 $local_user = get_user_by( 'slug', $sanitized_handle );
3805 if ( ! $local_user ) {
3806 $local_user = get_user_by( 'login', $handle );
3807 }
3808 if ( $local_user ) {
3809 if ( function_exists( '\Activitypub\get_rest_url_by_path' ) ) {
3810 return \Activitypub\get_rest_url_by_path( 'actors/' . $local_user->ID );
3811 }
3812 return get_author_posts_url( $local_user->ID, $local_user->user_nicename );
3813 }
3814
3815 // Check if it's the blog name.
3816 if ( sanitize_title( get_bloginfo( 'name' ) ) === $sanitized_handle ) {
3817 if ( function_exists( '\Activitypub\get_rest_url_by_path' ) ) {
3818 return \Activitypub\get_rest_url_by_path( 'actors/0' );
3819 }
3820 return get_bloginfo( 'url' );
3821 }
3822
3823 return null;
3824 }
3825
3826 /**
3827 * Find a friend's ActivityPub URL by their sanitized slug.
3828 *
3829 * @param string $slug The sanitized slug to search for.
3830 * @return string|null The ActivityPub URL or null if not found.
3831 */
3832 private function resolve_friend_by_slug( $slug ) {
3833 $feeds = User_Feed::get_by_parser( 'activitypub' );
3834 foreach ( $feeds as $feed ) {
3835 $user = $feed->get_friend_user();
3836 if ( ! $user ) {
3837 continue;
3838 }
3839
3840 $user_slug = $user->user_nicename;
3841 if ( ! $user_slug ) {
3842 $user_slug = $user->user_login;
3843 }
3844
3845 if ( sanitize_title( $user_slug ) === $slug ) {
3846 return $feed->get_url();
3847 }
3848 }
3849
3850 return null;
3851 }
3852
3853 /**
3854 * Resolve a full Mastodon handle to an ActivityPub URL.
3855 *
3856 * @param string $handle The full handle (e.g., "bob@mastodon.social").
3857 * @return string|null The ActivityPub URL or null if not found.
3858 */
3859 private function resolve_full_handle( $handle ) {
3860 if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
3861 return null;
3862 }
3863
3864 $actor = \Activitypub\Collection\Remote_Actors::fetch_by_acct( $handle );
3865
3866 if ( ! is_wp_error( $actor ) ) {
3867 return $actor->guid;
3868 }
3869
3870 return null;
3871 }
3872
3873 /**
3874 * Add the follows for the main user.
3875 *
3876 * @param array $follow_list The array of following urls.
3877 * @param \Activitypub\Model\User $user The user object.
3878 *
3879 * @return array The array of following urls.
3880 */
3881 public function activitypub_rest_following( $follow_list, $user ) {
3882 if ( Friends::get_main_friend_user_id() === $user->get__id() ) {
3883 foreach ( User_Feed::get_by_parser( self::SLUG ) as $user_feed ) {
3884 $follow_list[] = array(
3885 'id' => $user_feed->get_url(),
3886 'name' => $user_feed->get_title(),
3887 );
3888 }
3889 }
3890
3891 return $follow_list;
3892 }
3893
3894 public function activitypub_interactions_follow_url( $redirect_uri, $uri ) {
3895 if ( ! $redirect_uri ) {
3896 $redirect_uri = add_query_arg( 'url', $uri, self_admin_url( 'admin.php?page=add-friend' ) );
3897 }
3898 return $redirect_uri;
3899 }
3900
3901 private function show_message_on_frontend( $message, $error = null ) {
3902 if ( is_wp_error( $error ) ) {
3903 $error = $error->get_error_message();
3904 }
3905 add_action(
3906 'friends_after_header',
3907 function () use ( $message, $error ) {
3908 Friends::template_loader()->get_template_part(
3909 'frontend/error-message',
3910 null,
3911 array(
3912 'message' => $message,
3913 'error' => $error,
3914 )
3915 );
3916 }
3917 );
3918 }
3919
3920 public function cache_reply_to_boost() {
3921 $url = false;
3922 $append_to_redirect = '';
3923
3924 // The ignores are not necessary now but when https://github.com/WordPress/WordPress-Coding-Standards/issues/2299 comes into effect.
3925 $in_reply_to = filter_input( INPUT_GET, 'in_reply_to', FILTER_SANITIZE_URL ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
3926 $boost = filter_input( INPUT_GET, 'boost', FILTER_SANITIZE_URL ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
3927 if ( $in_reply_to ) {
3928 $url = $in_reply_to;
3929 $append_to_redirect .= '#comment';
3930 } elseif ( $boost ) {
3931 $url = $boost;
3932 }
3933
3934 if ( ! $url ) {
3935 return;
3936 }
3937
3938 $post_id = $this->cache_url( $url );
3939 if ( is_wp_error( $post_id ) ) {
3940 // show_message_on_frontend was called inside cache_url.
3941 return;
3942 }
3943
3944 if ( ! $post_id ) {
3945 $this->show_message_on_frontend(
3946 sprintf(
3947 // translators: %s is a URl.
3948 __( 'Could not retrieve URL %s', 'friends' ),
3949 '<a href="' . esc_attr( $url ) . '">' . Friends::url_truncate( $url ) . '</a>'
3950 )
3951 );
3952 return;
3953 }
3954
3955 $post = get_post( $post_id );
3956 if ( ! ( $post instanceof \WP_Post ) ) {
3957 return;
3958 }
3959 $user = User::get_post_author( $post );
3960 wp_safe_redirect( $user->get_local_friends_page_url( $post_id ) . $append_to_redirect );
3961 exit;
3962 }
3963
3964 public function cache_url( $url ) {
3965 $post_id = apply_filters( 'friends_cache_url_post_id', false, $url );
3966 if ( ! $post_id ) {
3967 $user = $this->get_external_user();
3968 $user_feed = $this->get_external_mentions_feed();
3969 $response = \Activitypub\safe_remote_get( $url );
3970 if ( \is_wp_error( $response ) ) {
3971 $this->show_message_on_frontend(
3972 sprintf(
3973 // translators: %s is a URl.
3974 __( 'Could not retrieve URL %s', 'friends' ),
3975 '<a href="' . esc_attr( $url ) . '">' . Friends::url_truncate( $url ) . '</a>'
3976 ),
3977 $response
3978 );
3979 return $response;
3980 }
3981 $json = \wp_remote_retrieve_body( $response );
3982 $object = \json_decode( $json, true );
3983 if ( ! $object ) {
3984 $this->log( 'Received invalid json', compact( 'json' ) );
3985 return false;
3986 }
3987 $this->log( 'Received response', compact( 'url', 'object' ) );
3988 $item = $this->handle_incoming_create( $object, array(), array() );
3989 if ( $item instanceof Feed_Item ) {
3990 $new_posts = $this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed );
3991 if ( 1 === count( $new_posts ) ) {
3992 $post_id = reset( $new_posts );
3993 } else {
3994 $post_id = Feed::url_to_postid( $url );
3995 if ( is_null( $post_id ) ) {
3996 $post_id = Feed::url_to_postid( $item->permalink );
3997 }
3998 }
3999 }
4000 }
4001 return $post_id;
4002 }
4003
4004 public function check_url_to_postid( $post_id, $url ) {
4005 if ( ! $post_id ) {
4006 $post_id = \Friends\Feed::url_to_postid( $url );
4007 }
4008 return $post_id;
4009 }
4010
4011 /**
4012 * Resolve a URL to a Friends post ID for ActivityPub comment handling.
4013 *
4014 * This allows the ActivityPub plugin to create comments on friend_post_cache posts
4015 * instead of the Friends plugin creating new posts for replies.
4016 *
4017 * @param int $post_id The post ID (0 if not found).
4018 * @param string $url The target URL being resolved.
4019 * @param array $activity The activity object.
4020 * @return int The post ID.
4021 */
4022 public function activitypub_comment_post_id( $post_id, $url, $activity ) {
4023 if ( ! $post_id ) {
4024 $post_id = \Friends\Feed::url_to_postid( $url );
4025 }
4026 return $post_id;
4027 }
4028
4029 /**
4030 * Prevent the ActivityPub plugin from federating cached friend posts.
4031 *
4032 * Cached posts are remote content stored locally; they must never be
4033 * announced as the site's own activity. The ActivityPub plugin's
4034 * lifecycle override would otherwise keep firing Updates whenever a
4035 * cached post is re-fetched, if its `activitypub_status` post meta was
4036 * ever set.
4037 *
4038 * @param bool $disabled Whether ActivityPub processing is disabled.
4039 * @param \WP_Post $post The post being evaluated.
4040 *
4041 * @return bool
4042 */
4043 public function disable_activitypub_for_cached_posts( $disabled, $post ) {
4044 if ( $post instanceof \WP_Post && Friends::CPT === $post->post_type ) {
4045 return true;
4046 }
4047 return $disabled;
4048 }
4049
4050 /**
4051 * Allow local comments on cached ActivityPub posts to federate.
4052 *
4053 * Cached ActivityPub posts are remote public objects stored locally. They
4054 * should remain disabled for the ActivityPub post pipeline, but replies to
4055 * them need to pass ActivityPub's public-queryability gate so comments can
4056 * be sent as replies to the remote object.
4057 *
4058 * @param bool $queryable Whether the post is publicly queryable.
4059 * @param \WP_Post $post The post being evaluated.
4060 *
4061 * @return bool
4062 */
4063 public function activitypub_cached_post_publicly_queryable( $queryable, $post ) {
4064 if (
4065 ! $post instanceof \WP_Post ||
4066 Friends::CPT !== $post->post_type ||
4067 'publish' !== $post->post_status
4068 ) {
4069 return $queryable;
4070 }
4071
4072 if ( self::SLUG === User_Feed::get_parser_for_post_id( $post->ID ) ) {
4073 return true;
4074 }
4075
4076 $activitypub = get_post_meta( $post->ID, self::SLUG, true );
4077 return is_array( $activitypub ) && ! empty( $activitypub['attributedTo'] ) ? true : $queryable;
4078 }
4079
4080 /**
4081 * Mark cached ActivityPub posts as federated before comment scheduling.
4082 *
4083 * ActivityPub only federates a new local comment when its parent post is
4084 * considered federated. Cached Friends posts are remote objects, so they do
4085 * not go through the normal local post outbox lifecycle, but local replies
4086 * still need this state so ActivityPub can queue the comment as a reply to
4087 * the remote object.
4088 *
4089 * @param int $comment_id The comment ID.
4090 * @param \WP_Comment $comment The comment object.
4091 */
4092 public function prepare_cached_post_for_comment_federation( $comment_id, $comment ) {
4093 if ( ! defined( 'ACTIVITYPUB_OBJECT_STATE_FEDERATED' ) ) {
4094 return;
4095 }
4096
4097 $comment = get_comment( $comment );
4098 if ( ! $comment || ! $comment->user_id || 1 !== (int) $comment->comment_approved ) {
4099 return;
4100 }
4101
4102 $post = get_post( $comment->comment_post_ID );
4103 if (
4104 ! $post instanceof \WP_Post ||
4105 Friends::CPT !== $post->post_type ||
4106 'publish' !== $post->post_status ||
4107 ! self::post_supports_activitypub( $post->ID )
4108 ) {
4109 return;
4110 }
4111
4112 update_post_meta( $post->ID, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
4113 }
4114
4115 public function the_content( $the_content ) {
4116 if ( ! Friends::on_frontend() || ! class_exists( '\WP_HTML_Tag_Processor' ) ) {
4117 return $the_content;
4118 }
4119
4120 // replace all links in <a href="mention hashtag"> with /friends/tag/tagname using the WP_HTML_Tag_Processor.
4121 $processor = new \WP_HTML_Tag_Processor( $the_content );
4122 while ( $processor->next_tag( array( 'tag_name' => 'a' ) ) ) {
4123 if ( ! $processor->get_attribute( 'href' ) ) {
4124 continue;
4125 }
4126 if ( ! $processor->get_attribute( 'class' ) || false === strpos( $processor->get_attribute( 'class' ), 'tag' ) ) {
4127 // Also consider URLs that contain the word hashtag like https://twitter.com/hashtag/WordPress.
4128 if ( false === strpos( $processor->get_attribute( 'href' ), '/hashtag/' ) ) {
4129 continue;
4130 }
4131 }
4132 $href = $processor->get_attribute( 'href' );
4133 $path_parts = explode( '/', rtrim( wp_parse_url( $href, PHP_URL_PATH ), '/' ) );
4134 $tag = array_pop( $path_parts );
4135 $processor->set_attribute( 'href', '/friends/tag/' . sanitize_title_with_dashes( $tag ) . '/' );
4136 $processor->set_attribute( 'original-href', $href );
4137 }
4138
4139 $the_content = $processor->get_updated_html();
4140
4141 return $the_content;
4142 }
4143
4144 public function activitypub_save_settings( User $friend ) {
4145 if ( ! isset( $_POST['_wpnonce'] ) || wp_verify_nonce( sanitize_key( $_POST['_wpnonce'] ), 'edit-friend-feeds-' . $friend->user_login ) ) {
4146 return;
4147 }
4148
4149 if ( isset( $_POST['friends_show_replies'] ) && boolval( $_POST['friends_show_replies'] ) ) {
4150 $friend->update_user_option( 'activitypub_friends_show_replies', '1' );
4151 } else {
4152 $friend->delete_user_option( 'activitypub_friends_show_replies' );
4153 }
4154 }
4155
4156 public function activitypub_settings( User $friend ) {
4157 $has_activitypub_feed = false;
4158 foreach ( $friend->get_active_feeds() as $feed ) {
4159 if ( 'activitypub' === $feed->get_parser() ) {
4160 $has_activitypub_feed = true;
4161 }
4162 }
4163
4164 if ( ! $has_activitypub_feed ) {
4165 return;
4166 }
4167
4168 ?>
4169 <tr>
4170 <th>ActivityPub</th>
4171 <td>
4172 <fieldset>
4173 <div>
4174 <input type="checkbox" name="friends_show_replies" id="friends_show_replies" value="1" <?php checked( '1', $friend->get_user_option( 'activitypub_friends_show_replies' ) ); ?> />
4175 <label for="friends_show_replies"><?php esc_html_e( "Don't hide @mentions of feeds you don't follow", 'friends' ); ?></label>
4176 </div>
4177 </fieldset>
4178 <p class="description">
4179 <?php
4180 esc_html_e( "Show ActivityPub '@mention' posts even when you do not follow the feed being mentioned.", 'friends' );
4181 ?>
4182 </p>
4183 </td>
4184 </tr>
4185 <?php
4186 }
4187
4188 /**
4189 * Apply the feed rules
4190 *
4191 * @param Feed_Item $item The feed item.
4192 * @param User_Feed $feed The feed object.
4193 * @param User $friend_user The friend user.
4194 * @return Feed_Item The modified feed item.
4195 */
4196 public function modify_incoming_item( Feed_Item $item, ?User_Feed $feed = null, ?User $friend_user = null ) {
4197 if ( ! $feed || 'activitypub' !== $feed->get_parser() || ! $friend_user ) {
4198 return $item;
4199 }
4200
4201 $external_user = $this->get_external_user();
4202 $is_external_user = get_class( $external_user ) === get_class( $friend_user ) && $external_user->get_object_id() === $friend_user->get_object_id();
4203 if (
4204 ! $is_external_user &&
4205 // Don't hide mentions for the external mentions user.
4206 ! $friend_user->get_user_option( 'activitypub_friends_show_replies' )
4207 ) {
4208 $plain_text_content = \wp_strip_all_tags( $item->post_content );
4209
4210 // Check if post starts with a mention (it's a reply).
4211 if ( ! preg_match( '/^@[a-zA-Z0-9_.-]+/', $plain_text_content ) ) {
4212 // Not a reply, let it through.
4213 return $item;
4214 }
4215
4216 // Check if any mentioned actor is known.
4217 $mention_urls = $item->_mention_urls ?? array();
4218 if ( ! $this->has_known_mention( $mention_urls, $friend_user ) ) {
4219 $item->_feed_rule_transform = array(
4220 'post_status' => 'trash',
4221 );
4222 }
4223 }
4224
4225 return $item;
4226 }
4227
4228 /**
4229 * Check if any mention URL is a known actor.
4230 *
4231 * @param array $mention_urls The mention URLs from the activity.
4232 * @param User $friend_user The friend user.
4233 * @return bool True if any mention is known.
4234 */
4235 private function has_known_mention( array $mention_urls, User $friend_user ) {
4236 foreach ( $mention_urls as $mention_url ) {
4237 // Check if it's a local user.
4238 if ( $this->is_local_actor_url( $mention_url ) ) {
4239 return true;
4240 }
4241
4242 // Check Friends user feeds.
4243 $user_feed = $this->friends_feed->get_user_feed_by_url( $mention_url );
4244 if ( $user_feed && ! is_wp_error( $user_feed ) ) {
4245 return true;
4246 }
4247
4248 // Check ActivityPub Remote_Actors.
4249 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
4250 $remote_actor = \Activitypub\Collection\Remote_Actors::get_by_uri( $mention_url );
4251 if ( ! is_wp_error( $remote_actor ) ) {
4252 return true;
4253 }
4254 }
4255 }
4256
4257 return false;
4258 }
4259
4260 /**
4261 * Check if a URL is a local actor (local WordPress user or blog).
4262 *
4263 * @param string $url The URL to check.
4264 * @return bool True if it's a local actor.
4265 */
4266 private function is_local_actor_url( $url ) {
4267 $site_url = get_bloginfo( 'url' );
4268
4269 // Must be on this site.
4270 if ( strpos( $url, $site_url ) !== 0 ) {
4271 return false;
4272 }
4273
4274 // Check if it matches the blog actor.
4275 if ( function_exists( '\Activitypub\get_rest_url_by_path' ) ) {
4276 $blog_actor = \Activitypub\get_rest_url_by_path( 'actors/0' );
4277 if ( $url === $blog_actor ) {
4278 return true;
4279 }
4280 }
4281
4282 // Check local users.
4283 $local_users = get_users( array( 'fields' => array( 'ID' ) ) );
4284 foreach ( $local_users as $local_user ) {
4285 if ( function_exists( '\Activitypub\get_rest_url_by_path' ) ) {
4286 $actor_url = \Activitypub\get_rest_url_by_path( 'actors/' . $local_user->ID );
4287 if ( $url === $actor_url ) {
4288 return true;
4289 }
4290 }
4291 // Also check author posts URL.
4292 if ( get_author_posts_url( $local_user->ID ) === $url ) {
4293 return true;
4294 }
4295 }
4296
4297 return false;
4298 }
4299
4300 public function friends_potential_avatars( $avatars, User $friend_user ) {
4301 foreach ( $friend_user->get_active_feeds() as $user_feed ) {
4302 if ( 'activitypub' === $user_feed->get_parser() ) {
4303 // Try to get avatar from locally cached ap_actor data to avoid network requests.
4304 $ap_actor_id = $user_feed->get_ap_actor_id();
4305 $avatar_url = null;
4306 $title = $user_feed->get_title();
4307
4308 if ( $ap_actor_id && class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
4309 $avatar_url = \Activitypub\Collection\Remote_Actors::get_avatar_url( $ap_actor_id );
4310 $actor_post = get_post( $ap_actor_id );
4311 if ( $actor_post && $actor_post->post_title ) {
4312 $title = $actor_post->post_title;
4313 }
4314 }
4315
4316 if ( $avatar_url ) {
4317 $avatars[ $avatar_url ] = sprintf(
4318 // translators: %s is a username.
4319 __( 'Avatar of %s', 'friends' ),
4320 $title
4321 );
4322 }
4323 }
4324 }
4325 return $avatars;
4326 }
4327
4328 private function get_author_of_post( \WP_Post $post ) {
4329 $friend = User::get_post_author( $post );
4330 if ( ! $friend || is_wp_error( $friend ) ) {
4331 return $friend;
4332 }
4333
4334 $meta = get_post_meta( $post->ID, self::SLUG, true );
4335 $attributed_to_url = isset( $meta['attributedTo'] ) ? self::get_actor_url_from_attributed_to( $meta['attributedTo'] ) : null;
4336 if ( $attributed_to_url ) {
4337 return $attributed_to_url;
4338 }
4339
4340 $feed_url = get_post_meta( $post->ID, 'feed_url', true );
4341 if ( $feed_url ) {
4342 return $feed_url;
4343 }
4344
4345 return null;
4346 }
4347
4348 /**
4349 * Create a status based on a post reaction.
4350 *
4351 * @param int $post_id The post ID.
4352 */
4353 public function post_reaction( $post_id ) {
4354 $post = get_post( $post_id );
4355 if ( ! $post ) {
4356 return;
4357 }
4358 $author_url = $this->get_author_of_post( $post );
4359 if ( ! $author_url || is_wp_error( $author_url ) ) {
4360 return;
4361 }
4362 return $this->queue_like_post( $post, $author_url );
4363 }
4364
4365 /**
4366 * Prepare to like the post via a scheduled event.
4367 *
4368 * @param \WP_Post $post The post.
4369 * @param string $author_url The author url.
4370 *
4371 * @return bool|WP_Error Whether the event was queued.
4372 */
4373 public function queue_like_post( \WP_Post $post, $author_url ) {
4374 $external_post_id = get_post_meta( $post->ID, 'external-id', true );
4375 if ( ! $external_post_id ) {
4376 $external_post_id = $post->guid;
4377 }
4378 $user_feed = User_Feed::get_by_url( $author_url );
4379 if ( version_compare( ACTIVITYPUB_PLUGIN_VERSION, '5.3.0', '>=' ) ) {
4380 $outbox_activity_id = \Activitypub\add_to_outbox( $external_post_id, 'Like', get_current_user_id(), ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC );
4381 if ( ! $outbox_activity_id ) {
4382 if ( $user_feed instanceof User_Feed ) {
4383 $user_feed->update_last_log( __( 'Like failed.', 'friends' ) );
4384 }
4385 return false;
4386 }
4387 if ( $user_feed instanceof User_Feed ) {
4388 $user_feed->update_last_log( __( 'Sent like.', 'friends' ) );
4389 }
4390 update_post_meta( $post->ID, 'ap_outbox_like_id', $outbox_activity_id );
4391 return true;
4392 }
4393
4394 $queued = $this->queue(
4395 'friends_feed_parser_activitypub_like',
4396 array( $author_url, $external_post_id, get_current_user_id() ),
4397 'friends_feed_parser_activitypub_unlike'
4398 );
4399
4400 if ( $queued && $user_feed instanceof User_Feed ) {
4401 $user_feed->update_last_log( __( 'Queued like request.', 'friends' ) );
4402 }
4403
4404 return $queued;
4405 }
4406
4407 /**
4408 * Like a post.
4409 *
4410 * @param mixed $url The URL of the user.
4411 * @param mixed $external_post_id The post to like.
4412 * @param mixed $user_id The current user id.
4413 * @return void|WP_Error
4414 */
4415 public function activitypub_like_post( $url, $external_post_id, $user_id ) {
4416 $type = 'Like';
4417 $user_id = $this->get_activitypub_actor_id( $user_id );
4418 $actor = $this->get_activitypub_actor( $user_id );
4419
4420 $activity = new \Activitypub\Activity\Activity();
4421 $activity->set_type( $type );
4422 $activity->set_to( null );
4423 $activity->set_cc( null );
4424 $activity->set_actor( $actor );
4425 $activity->set_object( $external_post_id );
4426 $activity->set_id( $actor . '#like-' . \preg_replace( '~^https?://~', '', $external_post_id ) );
4427 $activity->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', time() ) );
4428
4429 $json = $activity->to_json();
4430
4431 if ( version_compare( ACTIVITYPUB_PLUGIN_VERSION, '5.2.0', '>=' ) ) {
4432 $inboxes = \Activitypub\Collection\Followers::get_inboxes_for_activity( $json, $actor->get__id(), 500 );
4433 } else {
4434 $inboxes = apply_filters( 'activitypub_send_to_inboxes', array(), $user_id, $activity );
4435 $inboxes = array_unique( $inboxes );
4436 }
4437
4438 if ( empty( $inboxes ) ) {
4439 $message = sprintf(
4440 // translators: %s is the URL of the post.
4441 __( 'Like failed for %s', 'friends' ),
4442 '<a href="' . esc_url( $url ) . '">' . $url . '</a>'
4443 );
4444
4445 $details = array(
4446 'url' => $url,
4447 'error' => __( 'No inboxes to send to.', 'friends' ),
4448 );
4449
4450 Logging::log( 'like-failed', $message, $details, self::SLUG, $user_id );
4451 return;
4452 }
4453
4454 $report = array();
4455 foreach ( $inboxes as $inbox ) {
4456 $response = \Activitypub\safe_remote_post( $inbox, $json, $user_id );
4457 $report[ $inbox ] = wp_remote_retrieve_response_message( $response );
4458 }
4459
4460 $user_feed = User_Feed::get_by_url( $url );
4461 if ( $user_feed instanceof User_Feed ) {
4462 $user_feed->update_last_log(
4463 sprintf(
4464 // translators: %s is the response from the remote server.
4465 __( 'Sent like request with response: %s', 'friends' ),
4466 wp_remote_retrieve_response_code( $response ) . ' ' . wp_remote_retrieve_response_message( $response )
4467 )
4468 );
4469 }
4470 $type = 'like';
4471 $message = sprintf(
4472 // translators: %s is the URL of the post.
4473 __( 'Liked %s', 'friends' ),
4474 '<a href="' . esc_url( $external_post_id ) . '">' . $external_post_id . '</a>'
4475 );
4476 $details = array(
4477 'actor' => $actor,
4478 'url' => $external_post_id,
4479 'inboxes' => $report,
4480 );
4481
4482 Logging::log( 'like', $message, $details, self::SLUG, $user_id );
4483 }
4484
4485 /**
4486 * Create a status based on a post reaction.
4487 *
4488 * @param int $post_id The post ID.
4489 */
4490 public function undo_post_reaction( $post_id ) {
4491 $post = get_post( $post_id );
4492 if ( ! $post ) {
4493 return;
4494 }
4495 $author_url = $this->get_author_of_post( $post );
4496 if ( ! $author_url || is_wp_error( $author_url ) ) {
4497 return;
4498 }
4499 return $this->queue_unlike_post( $post, $author_url );
4500 }
4501
4502 /**
4503 * Prepare to follow the user via a scheduled event.
4504 *
4505 * @param \WP_Post $post The post.
4506 * @param string $author_url The author url.
4507 *
4508 * @return bool|WP_Error Whether the event was queued.
4509 */
4510 public function queue_unlike_post( \WP_Post $post, $author_url ) {
4511 $external_post_id = get_post_meta( $post->ID, 'external-id', true );
4512 if ( ! $external_post_id ) {
4513 $external_post_id = $post->guid;
4514 }
4515 $user_feed = User_Feed::get_by_url( $author_url );
4516 if ( version_compare( ACTIVITYPUB_PLUGIN_VERSION, '5.3.0', '>=' ) ) {
4517 $outbox_activity_id = get_post_meta( $post->ID, 'ap_outbox_like_id', true );
4518 if ( ! $outbox_activity_id ) {
4519 if ( $user_feed instanceof User_Feed ) {
4520 $user_feed->update_last_log( __( 'Unlike failed.', 'friends' ) );
4521 }
4522 return false;
4523 }
4524 if ( $user_feed instanceof User_Feed ) {
4525 $user_feed->update_last_log( __( 'Sent unlike request.', 'friends' ) );
4526 }
4527 \Activitypub\Collection\Outbox::undo( $outbox_activity_id );
4528 return true;
4529 }
4530 $queued = $this->queue(
4531 'friends_feed_parser_activitypub_unlike',
4532 array( $author_url, $external_post_id, get_current_user_id() ),
4533 'friends_feed_parser_activitypub_like'
4534 );
4535
4536 if ( $queued && $user_feed instanceof User_Feed ) {
4537 $user_feed->update_last_log( __( 'Queued unlike request.', 'friends' ) );
4538 }
4539
4540 return $queued;
4541 }
4542
4543 /**
4544 * Unlike a post.
4545 *
4546 * @param mixed $url The URL of the user.
4547 * @param mixed $external_post_id The post to like.
4548 * @param mixed $user_id The current user id.
4549 * @return void|WP_Error
4550 */
4551 public function activitypub_unlike_post( $url, $external_post_id, $user_id ) {
4552 $type = 'Like';
4553 $user_id = $this->get_activitypub_actor_id( $user_id );
4554 $actor = $this->get_activitypub_actor( $user_id );
4555
4556 $activity = new \Activitypub\Activity\Activity();
4557 $activity->set_type( 'Undo' );
4558 $activity->set_to( null );
4559 $activity->set_cc( null );
4560 $activity->set_actor( $actor );
4561 $activity->set_object(
4562 array(
4563 'type' => $type,
4564 'actor' => $actor,
4565 'object' => $external_post_id,
4566 'id' => $actor . '#like-' . \preg_replace( '~^https?://~', '', $external_post_id ),
4567 )
4568 );
4569 $activity->set_id( $actor . '#unlike-' . \preg_replace( '~^https?://~', '', $external_post_id ) );
4570
4571 $json = $activity->to_json();
4572 if ( version_compare( ACTIVITYPUB_PLUGIN_VERSION, '5.2.0', '>=' ) ) {
4573 $inboxes = \Activitypub\Collection\Followers::get_inboxes_for_activity( $json, $actor->get__id(), 500 );
4574 } else {
4575 $inboxes = apply_filters( 'activitypub_send_to_inboxes', array(), $user_id, $activity );
4576 $inboxes = array_unique( $inboxes );
4577 }
4578
4579 if ( empty( $inboxes ) ) {
4580 $message = sprintf(
4581 // translators: %s is the URL of the post.
4582 __( 'Unlike failed for %s', 'friends' ),
4583 '<a href="' . esc_url( $url ) . '">' . $url . '</a>'
4584 );
4585
4586 $details = array(
4587 'url' => $url,
4588 'error' => __( 'No inboxes to send to.', 'friends' ),
4589 );
4590
4591 Logging::log( 'unlike-failed', $message, $details, self::SLUG, $user_id );
4592 return;
4593 }
4594
4595 $report = array();
4596 foreach ( $inboxes as $inbox ) {
4597 $response = \Activitypub\safe_remote_post( $inbox, $json, $user_id );
4598 $report[ $inbox ] = wp_remote_retrieve_response_message( $response );
4599 }
4600
4601 $user_feed = User_Feed::get_by_url( $url );
4602 if ( $user_feed instanceof User_Feed ) {
4603 $user_feed->update_last_log(
4604 sprintf(
4605 // translators: %s is the response from the remote server.
4606 __( 'Sent unlike request with response: %s', 'friends' ),
4607 wp_remote_retrieve_response_code( $response ) . ' ' . wp_remote_retrieve_response_message( $response )
4608 )
4609 );
4610 }
4611 $type = 'unlike';
4612 $message = sprintf(
4613 // translators: %s is the URL of the post.
4614 __( 'Unliked %s', 'friends' ),
4615 '<a href="' . esc_url( $external_post_id ) . '">' . $external_post_id . '</a>'
4616 );
4617 $details = array(
4618 'actor' => $actor,
4619 'url' => $external_post_id,
4620 'inboxes' => $report,
4621 );
4622
4623 Logging::log( 'unlike', $message, $details, self::SLUG, $user_id );
4624 }
4625
4626 public function boost_button() {
4627 Friends::template_loader()->get_template_part(
4628 'frontend/parts/activitypub/boost-button',
4629 null,
4630 array()
4631 );
4632 }
4633
4634 public function friends_search_autocomplete( $results, $q ) {
4635 $url = preg_match( '#^(https?:\/\/)?(?:w{3}\.)?[\w-]+(?:\.[\w-]+)+((?:\/[^\s\/]*)*)#i', $q, $m );
4636 $url_with_path = isset( $m[2] ) && $m[2];
4637
4638 $already_added = false;
4639 foreach ( $results as $result ) {
4640 if ( strpos( $result, $q ) !== false ) {
4641 $already_added = true;
4642 }
4643 }
4644
4645 if ( ! $already_added ) {
4646 if ( preg_match( '/^@?' . self::ACTIVITYPUB_USERNAME_REGEXP . '$/i', $q ) ) {
4647 $result = '<a href="' . esc_url( add_query_arg( 'url', $q, admin_url( 'admin.php?page=add-friend' ) ) ) . '" class="has-icon-left">';
4648 $result .= '<span class="ab-icon dashicons dashicons-businessperson"><span class="dashicons dashicons-plus"></span></span>';
4649 $result .= 'Follow ';
4650 $result .= ' <small>';
4651 $result .= esc_html( $q );
4652 $result .= '</small></a>';
4653 $results[] = $result;
4654 } elseif ( ( $url && ! $url_with_path ) || preg_match( '/^@?' . self::ACTIVITYPUB_USERNAME_REGEXP . '$/i', $q ) ) {
4655 $result = '<a href="' . esc_url( add_query_arg( 'url', $m[1] ? $q : 'https://' . $q, admin_url( 'admin.php?page=add-friend' ) ) ) . '" class="has-icon-left">';
4656 $result .= '<span class="ab-icon dashicons dashicons-businessperson"><span class="dashicons dashicons-plus"></span></span>';
4657 $result .= 'Follow ';
4658 $result .= ' <small>';
4659 $result .= esc_html( $q );
4660 $result .= '</small></a>';
4661 $results[] = $result;
4662 }
4663 }
4664
4665 if ( $url_with_path ) {
4666 $result = '<a href="' . esc_url( add_query_arg( 'boost', $q, home_url( '/friends/' ) ) ) . '" class="has-icon-left">';
4667 $result .= '<span class="ab-icon dashicons dashicons-controls-repeat"></span>';
4668 $result .= 'Boost ';
4669 $result .= ' <small>';
4670 $result .= esc_html( Friends::url_truncate( $q ) );
4671 $result .= '</small></a>';
4672 $results[] = $result;
4673 }
4674
4675 if ( $url_with_path ) {
4676 $result = '<a href="' . esc_url( add_query_arg( 'in_reply_to', $q, home_url( '/friends/' ) ) ) . '" class="has-icon-left">';
4677 $result .= '<span class="ab-icon dashicons dashicons-admin-comments"></span>';
4678 $result .= 'Reply to ';
4679 $result .= ' <small>';
4680 $result .= esc_html( Friends::url_truncate( $q ) );
4681 $result .= '</small></a>';
4682 $results[] = $result;
4683 }
4684
4685 return $results;
4686 }
4687
4688 public function mastodon_api_react( $post_id, $reaction ) {
4689 apply_filters( 'friends_react', null, $post_id, $reaction );
4690 }
4691
4692 public function mastodon_api_unreact( $post_id, $reaction ) {
4693 apply_filters( 'friends_unreact', null, $post_id, $reaction );
4694 }
4695
4696 public function ajax_boost() {
4697 if ( ! current_user_can( Friends::REQUIRED_ROLE ) ) {
4698 wp_send_json_error( 'error' );
4699 }
4700
4701 check_ajax_referer( 'friends-boost' );
4702
4703 if ( ! isset( $_POST['post_id'] ) ) {
4704 wp_send_json_error( 'missing-post-id' );
4705 }
4706 $post = get_post( intval( $_POST['post_id'] ) );
4707 if ( ! $post || ! Friends::check_url( $post->guid ) ) {
4708 wp_send_json_error( 'unknown-post', array( 'id' => $post->ID ) );
4709 }
4710
4711 if ( get_post_meta( $post->ID, 'boosted', true ) ) {
4712 $this->mastodon_api_unreblog( $post->ID );
4713 wp_send_json_success( 'unboosted', array( 'id' => $post->ID ) );
4714 return;
4715 }
4716 $this->mastodon_api_reblog( $post->ID );
4717
4718 wp_send_json_success( 'boosted', array( 'id' => $post->ID ) );
4719 }
4720
4721 public function mastodon_api_reblog( $post_id ) {
4722 $post = get_post( $post_id );
4723 if ( ! $post ) {
4724 return;
4725 }
4726
4727 $user_id = get_current_user_id();
4728 if ( ! $user_id ) {
4729 return;
4730 }
4731
4732 update_post_meta( $post->ID, 'boosted', $user_id );
4733 update_post_meta( $post->ID, 'boosted_at', current_time( 'mysql', true ) );
4734
4735 $this->queue_announce( $post );
4736 }
4737
4738 public function mastodon_api_unreblog( $post_id ) {
4739 $post = get_post( $post_id );
4740 if ( ! $post ) {
4741 return;
4742 }
4743
4744 delete_post_meta( $post->ID, 'boosted' );
4745 delete_post_meta( $post->ID, 'boosted_at' );
4746
4747 $this->queue_unannounce( $post );
4748 }
4749
4750 /**
4751 * Don't create a note via ActivityPub since we'll use announce there.
4752 *
4753 * @param bool $ret The return value.
4754 *
4755 * @return bool The return value.
4756 */
4757 public function unqueue_activitypub_create( $ret ) {
4758 $hook = 'transition_post_status';
4759 $filter = array( '\Activitypub\Activitypub', 'schedule_post_activity' );
4760 $priority = has_filter( $hook, $filter );
4761 if ( $priority ) {
4762 remove_filter( $hook, $filter, $priority );
4763 }
4764 return $ret;
4765 }
4766
4767 public function reblog( $ret, $post ) {
4768 $post = get_post( $post );
4769 if ( ! $post ) {
4770 return $ret;
4771 }
4772 if ( User_Feed::get_parser_for_post_id( $post->ID ) === 'activitypub' ) {
4773 $this->queue_announce( $post );
4774 \update_post_meta( $post->ID, 'reblogged', 'activitypub' );
4775 \update_post_meta( $post->ID, 'reblogged_by', get_current_user_id() );
4776 return true;
4777 }
4778 return $ret;
4779 }
4780
4781 public function unreblog( $ret, $post ) {
4782 $post = get_post( $post );
4783 if ( ! $post ) {
4784 return $ret;
4785 }
4786 if ( get_post_meta( $post->ID, 'reblogged', true ) === 'activitypub' ) {
4787 $this->queue_unannounce( $post );
4788 \delete_post_meta( $post->ID, 'reblogged', 'activitypub' );
4789 \delete_post_meta( $post->ID, 'reblogged_by', get_current_user_id() );
4790 return true;
4791 }
4792 return $ret;
4793 }
4794
4795 /**
4796 * Prepare to announce the post via a scheduled event.
4797 *
4798 * @param \WP_Post $post The post.
4799 *
4800 * @return bool|WP_Error Whether the event was queued.
4801 */
4802 public function queue_announce( \WP_Post $post ) {
4803 $url = get_permalink( $post );
4804 if ( version_compare( ACTIVITYPUB_PLUGIN_VERSION, '5.3.0', '>=' ) ) {
4805 $outbox_activity_id = \Activitypub\add_to_outbox( $url, 'Announce', get_current_user_id(), ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC );
4806 if ( ! $outbox_activity_id ) {
4807 return false;
4808 }
4809 update_post_meta( $post->ID, 'ap_outbox_announce_id', $outbox_activity_id );
4810 return true;
4811 }
4812 $queued = $this->queue(
4813 'friends_feed_parser_activitypub_announce',
4814 array( $url, get_current_user_id() ),
4815 'friends_feed_parser_activitypub_unannounce'
4816 );
4817
4818 return $queued;
4819 }
4820
4821 /**
4822 * Announce the post via ActivityPub.
4823 *
4824 * @param string $url The url.
4825 * @param id $user_id The user id.
4826 */
4827 public function activitypub_announce( $url, $user_id ) {
4828 $user_id = $this->get_activitypub_actor_id( $user_id );
4829 $actor = $this->get_activitypub_actor( $user_id );
4830
4831 $activity = new \Activitypub\Activity\Activity();
4832 $activity->set_type( 'Announce' );
4833 $activity->set_actor( $actor );
4834 $activity->set_object( $url );
4835 $activity->set_id( $actor . '#activitypub_announce-' . \preg_replace( '~^https?://~', '', $url ) );
4836 $activity->set_to( array( 'https://www.w3.org/ns/activitystreams#Public' ) );
4837 $activity->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', time() ) );
4838
4839 $json = $activity->to_json();
4840 if ( version_compare( ACTIVITYPUB_PLUGIN_VERSION, '5.2.0', '>=' ) ) {
4841 $inboxes = \Activitypub\Collection\Followers::get_inboxes_for_activity( $json, $actor->get__id(), 500 );
4842 } else {
4843 $inboxes = apply_filters( 'activitypub_send_to_inboxes', array(), $user_id, $activity );
4844 $inboxes = array_unique( $inboxes );
4845 }
4846
4847 if ( empty( $inboxes ) ) {
4848 $message = sprintf(
4849 // translators: %s is the URL of the post.
4850 __( 'Announce failed for %s', 'friends' ),
4851 '<a href="' . esc_url( $url ) . '">' . $url . '</a>'
4852 );
4853
4854 $details = array(
4855 'url' => $url,
4856 'error' => __( 'No inboxes to send to.', 'friends' ),
4857 );
4858
4859 Logging::log( 'announce-failed', $message, $details, self::SLUG, $user_id );
4860 return;
4861 }
4862
4863 $report = array();
4864 foreach ( $inboxes as $inbox ) {
4865 $response = \Activitypub\safe_remote_post( $inbox, $json, $user_id );
4866 $report[ $inbox ] = wp_remote_retrieve_response_message( $response );
4867 }
4868
4869 $message = sprintf(
4870 // translators: %s is the URL of the post.
4871 __( 'Announced %s', 'friends' ),
4872 '<a href="' . esc_url( $url ) . '">' . $url . '</a>'
4873 );
4874
4875 $details = array(
4876 'url' => $url,
4877 'inboxes' => $report,
4878 );
4879
4880 Logging::log( 'announce', $message, $details, self::SLUG, $user_id );
4881 }
4882
4883 /**
4884 * Prepare to announce the post via a scheduled event.
4885 *
4886 * @param \WP_Post $post The post.
4887 *
4888 * @return bool|WP_Error Whether the event was queued.
4889 */
4890 public function queue_unannounce( \WP_Post $post ) {
4891 $url = get_permalink( $post );
4892 if ( version_compare( ACTIVITYPUB_PLUGIN_VERSION, '5.3.0', '>=' ) ) {
4893 $outbox_activity_id = get_post_meta( $post->ID, 'ap_outbox_announce_id', true );
4894 if ( ! $outbox_activity_id ) {
4895 return false;
4896 }
4897 \Activitypub\Collection\Outbox::undo( $outbox_activity_id );
4898 return true;
4899 }
4900 $queued = $this->queue(
4901 'friends_feed_parser_activitypub_unannounce',
4902 array( $url, get_current_user_id() ),
4903 'friends_feed_parser_activitypub_announce'
4904 );
4905
4906 return $queued;
4907 }
4908
4909 /**
4910 * Unannounce the post via ActivityPub.
4911 *
4912 * @param string $url The url.
4913 * @param id $user_id The user id.
4914 */
4915 public function activitypub_unannounce( $url, $user_id ) {
4916 $user_id = $this->get_activitypub_actor_id( $user_id );
4917 $actor = $this->get_activitypub_actor( $user_id );
4918
4919 $activity = new \Activitypub\Activity\Activity();
4920 $activity->set_type( 'Undo' );
4921 $activity->set_to( null );
4922 $activity->set_cc( null );
4923 $activity->set_actor( $actor );
4924 $activity->set_id( $actor . '#activitypub_unannounce-' . \preg_replace( '~^https?://~', '', $url ) );
4925 $activity->set_object(
4926 array(
4927 'type' => 'Announce',
4928 'actor' => $actor,
4929 'object' => $url,
4930 'id' => $actor . '#activitypub_announce-' . \preg_replace( '~^https?://~', '', $url ),
4931 )
4932 );
4933 $activity->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', time() ) );
4934
4935 $json = $activity->to_json();
4936 if ( version_compare( ACTIVITYPUB_PLUGIN_VERSION, '5.2.0', '>=' ) ) {
4937 $inboxes = \Activitypub\Collection\Followers::get_inboxes_for_activity( $json, $actor->get__id(), 500 );
4938 } else {
4939 $inboxes = apply_filters( 'activitypub_send_to_inboxes', array(), $user_id, $activity );
4940 $inboxes = array_unique( $inboxes );
4941 }
4942
4943 if ( empty( $inboxes ) ) {
4944 $message = sprintf(
4945 // translators: %s is the URL of the post.
4946 __( 'Unannounce failed for %s', 'friends' ),
4947 '<a href="' . esc_url( $url ) . '">' . $url . '</a>'
4948 );
4949
4950 $details = array(
4951 'url' => $url,
4952 'error' => __( 'No inboxes to send to.', 'friends' ),
4953 );
4954
4955 Logging::log( 'unannounce-failed', $message, $details, self::SLUG, $user_id );
4956 return;
4957 }
4958
4959 $report = array();
4960 foreach ( $inboxes as $inbox ) {
4961 $response = \Activitypub\safe_remote_post( $inbox, $json, $user_id );
4962 $report[ $inbox ] = wp_remote_retrieve_response_message( $response );
4963 }
4964
4965 $message = sprintf(
4966 // translators: %s is the URL of the post.
4967 __( 'Unannounced %s', 'friends' ),
4968 '<a href="' . esc_url( $url ) . '">' . $url . '</a>'
4969 );
4970
4971 $details = array(
4972 'url' => $url,
4973 'inboxes' => $report,
4974 );
4975
4976 Logging::log( 'unannounce', $message, $details, self::SLUG, $user_id );
4977 }
4978
4979 /**
4980 * Approve incoming fediverse comments.
4981 *
4982 * @param bool $approved Whether the comment is approved.
4983 * @param array $commentdata The commentdata.
4984 *
4985 * @return bool Whether the comment is approved.
4986 */
4987 public function pre_comment_approved( $approved, $commentdata ) {
4988 if ( is_string( $approved ) && 'activitypub' === $commentdata['comment_meta']['protocol'] ) {
4989 // If the author is someone we already follow.
4990 $user_feed = User_Feed::get_by_url( $commentdata['comment_author_url'] );
4991 if ( $user_feed instanceof User_Feed ) {
4992 $approved = true;
4993 }
4994
4995 // If the parent post is a Friends::CPT.
4996 $post = get_post( $commentdata['comment_post_ID'] );
4997 if ( $post && Friends::CPT === $post->post_type ) {
4998 $approved = true;
4999 }
5000 if ( $post && self::SLUG === User_Feed::get_parser_for_post_id( $post->ID ) ) {
5001 $approved = true;
5002 }
5003 }
5004 return $approved;
5005 }
5006
5007 public function comment_post( $comment_id, $comment_approved, $commentdata ) {
5008 if ( isset( $commentdata['commentmeta']['protocol'] ) && self::SLUG === $commentdata['commentmeta']['protocol'] ) {
5009 // Don't act upon incoming comments via ActivityPub.
5010 return;
5011 }
5012
5013 if ( empty( $commentdata['user_id'] ) ) {
5014 // Don't act on other people's comments.
5015 return;
5016 }
5017
5018 $user = new \WP_User( $commentdata['user_id'] );
5019 if ( User::is_friends_plugin_user( $user ) ) {
5020 // Don't act on non-local comments.
5021 return;
5022 }
5023
5024 // TODO: in the ActivityPub plugin, we should be able to use the comment_post hook to send out the comment.
5025 }
5026
5027 public function trashed_comment( $comment_id, $comment ) {
5028 if ( get_comment_meta( $comment_id, 'protocol', true ) === 'activitypub' ) {
5029 // Don't act on comments that came via ActivityPub.
5030 return;
5031 }
5032
5033 if ( empty( $comment->user_id ) ) {
5034 // Don't act on other people's comments.
5035 return;
5036 }
5037
5038 $user = new \WP_User( $comment->user_id );
5039 if ( User::is_friends_plugin_user( $user ) ) {
5040 // Don't act on non-local comments.
5041 return;
5042 }
5043
5044 // TODO: in the ActivityPub plugin, we should be able to use the trashed_comment hook to send out the comment deletion.
5045 }
5046
5047
5048 /**
5049 * Get comments via ActivityPub.
5050 *
5051 * @param array $comments The comments.
5052 * @param int $post_id The post id.
5053 * @param User $friend_user The friend user.
5054 * @param User_Feed $user_feed The user feed.
5055 *
5056 * @return array The comments.
5057 */
5058 public function get_remote_comments( $comments, $post_id, ?User $friend_user = null, ?User_Feed $user_feed = null ) {
5059 if ( User_Feed::get_parser_for_post_id( $post_id ) !== self::SLUG ) {
5060 return $comments;
5061 }
5062
5063 $post = get_post( $post_id );
5064 if ( Friends::CPT !== $post->post_type ) {
5065 return $comments;
5066 }
5067
5068 // Leverage the Enable Mastodon Apps API to get the comments.
5069 $context = array(
5070 'ancestors' => array(),
5071 'descendants' => array(),
5072 );
5073 $context = apply_filters( 'mastodon_api_status_context', $context, $post_id, $post->guid );
5074 foreach ( $context['descendants'] as $status ) {
5075
5076 $comments[] = new \WP_Comment(
5077 (object) array(
5078 'comment_ID' => $status->id,
5079 'comment_post_ID' => $post_id,
5080 'comment_author' => $status->account->display_name,
5081 'comment_author_url' => $status->account->url,
5082 'comment_author_email' => $status->account->acct,
5083 'comment_content' => $status->content,
5084 'comment_date' => $status->created_at->format( 'Y-m-d H:i:s' ),
5085 'comment_date_gmt' => $status->created_at->format( 'Y-m-d H:i:s' ),
5086 'comment_approved' => 1,
5087 'comment_type' => 'activitypub',
5088 'comment_parent' => $status->in_reply_to_id,
5089
5090 )
5091 );
5092 }
5093
5094 if ( empty( $comments ) ) {
5095 add_filter(
5096 'friends_no_comments_feed_available',
5097 function () use ( $post ) {
5098 return __( 'No comments yet.', 'friends' ) . ' <a href="' . esc_url( get_permalink( $post ) ) . '" target="_blank">' . __( 'View this at the source', 'friends' ) . '</a>';
5099 }
5100 );
5101 }
5102 return $comments;
5103 }
5104
5105 /**
5106 * Render a display name with the actor's custom emoji.
5107 *
5108 * Hooked to `friends_author_display_name_html`, which is only filtered while
5109 * this parser is registered, i.e. while the ActivityPub plugin is active.
5110 * Without it the escaped display name passed in is used as-is.
5111 *
5112 * @param string $html The display name as safe HTML.
5113 * @param string $display_name The raw display name.
5114 * @param User|null $friend_user The user the name belongs to.
5115 * @return string Safe HTML.
5116 */
5117 public static function author_display_name_html( $html, $display_name, $friend_user = null ) {
5118 if ( ! $friend_user instanceof User ) {
5119 return $html;
5120 }
5121
5122 return wp_kses( self::replace_custom_emojis_for_user( $display_name, $friend_user ), self::get_custom_emoji_allowed_html() );
5123 }
5124
5125 public static function friends_post_author_meta( $friend_user ) {
5126 $meta = get_post_meta( get_the_ID(), self::SLUG, true );
5127 if ( ! isset( $meta['reblog'] ) || ! $meta['reblog'] ) {
5128 return;
5129 }
5130
5131 if ( ! isset( $meta['attributedTo'] ) ) {
5132 return;
5133 }
5134
5135 $actor_metadata = self::get_actor_metadata_from_attributed_to( $meta['attributedTo'] );
5136 if ( ! $actor_metadata['url'] ) {
5137 return;
5138 }
5139
5140 Friends::template_loader()->get_template_part(
5141 'frontend/parts/activitypub/follow-link',
5142 null,
5143 array(
5144 'url' => $actor_metadata['url'],
5145 'name' => $actor_metadata['name'],
5146 'handle' => $actor_metadata['acct'] ? $actor_metadata['acct'] : self::convert_actor_to_mastodon_handle( $actor_metadata['url'] ),
5147 'summary' => wp_strip_all_tags( $actor_metadata['summary'] ),
5148 'emojis' => $actor_metadata['emojis'],
5149 )
5150 );
5151 }
5152
5153 public static function header_menu() {
5154 if ( ! get_post_meta( get_the_ID(), self::SLUG, true ) ) {
5155 return;
5156 }
5157
5158 Friends::template_loader()->get_template_part(
5159 'frontend/parts/activitypub/header-menu',
5160 null,
5161 array()
5162 );
5163 }
5164
5165 /**
5166 * Check if a post's permalink supports ActivityPub.
5167 *
5168 * Returns true if the post was parsed by the ActivityPub parser,
5169 * has ActivityPub metadata, or its permalink host is a known ActivityPub instance.
5170 *
5171 * @param int $post_id The post ID.
5172 * @return bool Whether the post supports ActivityPub.
5173 */
5174 public static function post_supports_activitypub( $post_id ) {
5175 if ( User_Feed::get_parser_for_post_id( $post_id ) === self::SLUG ) {
5176 return true;
5177 }
5178
5179 $meta = get_post_meta( $post_id, self::SLUG, true );
5180 if ( $meta ) {
5181 return true;
5182 }
5183
5184 $post = get_post( $post_id );
5185 if ( $post && $post->guid ) {
5186 $host = wp_parse_url( $post->guid, PHP_URL_HOST );
5187 if ( $host && self::is_known_activitypub_host( $host ) ) {
5188 return true;
5189 }
5190 }
5191
5192 return false;
5193 }
5194
5195 public static function enable_comments_form( bool $comments_open, int $post_id ) {
5196 if ( is_user_logged_in() && self::post_supports_activitypub( $post_id ) ) {
5197 return true;
5198 }
5199 return $comments_open;
5200 }
5201
5202 public static function comment_form( $post_id ) {
5203 if ( ! self::post_supports_activitypub( $post_id ) ) {
5204 $post = get_post( $post_id );
5205 if ( $post ) {
5206 printf(
5207 '<p class="friends-activitypub-comment-unavailable">%s</p>',
5208 sprintf(
5209 /* translators: %s is a link to the original post */
5210 __( 'Commenting via ActivityPub is not available for this post. You can try to <a href="%s" target="_blank" rel="noopener noreferrer">comment at the source</a>.', 'friends' ),
5211 esc_url( $post->guid )
5212 )
5213 );
5214 }
5215 return;
5216 }
5217
5218 $post = get_post( $post_id );
5219 $mentions = self::extract_html_mentions( $post->post_content );
5220 $meta = get_post_meta( $post->ID, self::SLUG, true );
5221 $attributed_to_url = isset( $meta['attributedTo'] ) ? self::get_actor_url_from_attributed_to( $meta['attributedTo'] ) : null;
5222 if ( $attributed_to_url ) {
5223 $mentions[ $attributed_to_url ] = $attributed_to_url;
5224 }
5225
5226 // Exclude the current user's own actor URL from mentions.
5227 $current_user_actor_url = null;
5228 if ( is_user_logged_in() && class_exists( '\Activitypub\Collection\Actors' ) ) {
5229 $actor_id = self::get_activitypub_actor_id( get_current_user_id() );
5230 $actor = \Activitypub\Collection\Actors::get_by_id( $actor_id );
5231 if ( $actor && ! is_wp_error( $actor ) ) {
5232 $current_user_actor_url = $actor->get_id();
5233 }
5234 }
5235 if ( $current_user_actor_url ) {
5236 unset( $mentions[ $current_user_actor_url ] );
5237 }
5238
5239 $comment_content = '';
5240 if ( $mentions ) {
5241 $handles = array_filter( array_map( array( self::class, 'convert_actor_to_mastodon_handle' ), array_keys( $mentions ) ) );
5242 if ( $handles ) {
5243 $comment_content = '@' . implode( ' @', $handles ) . ' ';
5244 }
5245 }
5246 $html5 = current_theme_supports( 'html5', 'comment-form' ) ? 'html5' : 'xhtml';
5247 $required_attribute = ( $html5 ? ' required' : ' required="required"' );
5248 $required_indicator = ' ' . wp_required_field_indicator();
5249
5250 \comment_form(
5251 array(
5252 'title_reply' => __( 'Send a Reply via ActivityPub', 'friends' ),
5253 'title_reply_before' => '<h5 id="reply-title" class="comment-reply-title">',
5254 'title_reply_after' => '</h5>',
5255 'logged_in_as' => '',
5256 'comment_notes_before' => '',
5257 'comment_field' => sprintf(
5258 '<p class="comment-form-comment">%s %s</p>',
5259 sprintf(
5260 '<label for="comment">%s%s</label>',
5261 _x( 'Comment', 'noun' ), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain
5262 $required_indicator
5263 ),
5264 '<textarea id="comment" name="comment" cols="45" rows="8" maxlength="65525"' . $required_attribute . '>' . esc_html( $comment_content ) . '</textarea>'
5265 ),
5266 ),
5267 $post_id
5268 );
5269 }
5270
5271 public function append_comment_form( $content, $post_id, ?User $friend_user = null, ?User_Feed $user_feed = null ) {
5272 ob_start();
5273 self::comment_form( $post_id );
5274 $comment_form = ob_get_contents();
5275 ob_end_clean();
5276
5277 return $content . $comment_form;
5278 }
5279
5280 public function comment_post_redirect( $location, $comment ) {
5281 if ( get_comment_meta( $comment->comment_ID, 'protocol', true ) === 'activitypub' ) {
5282 // Don't act on comments that came via ActivityPub.
5283 return $location;
5284 }
5285
5286 if ( empty( $comment->user_id ) ) {
5287 // Don't act on other people's comments.
5288 return $location;
5289 }
5290 $post = get_post( $comment->comment_post_ID );
5291 if ( ! $post || ! in_array( $post->post_type, apply_filters( 'friends_frontend_post_types', array() ), true ) ) {
5292 // Only redirect for friend posts.
5293 return $location;
5294 }
5295 $user = User::get_post_author( $post );
5296
5297 return $user->get_local_friends_page_url( $post->ID ) . '#comment-' . $comment->comment_ID;
5298 }
5299
5300 /**
5301 * Check if a host is a known ActivityPub instance.
5302 *
5303 * Loads all ap_actor guids and activitypub user feed URLs once per request
5304 * and extracts their hosts. Use the friends_is_known_activitypub_host filter
5305 * to register additional known hosts or patterns.
5306 *
5307 * @param string $host The hostname to check.
5308 * @return bool Whether the host is known.
5309 */
5310 private static function is_known_activitypub_host( $host ) {
5311 static $known_hosts = null;
5312
5313 if ( null === $known_hosts ) {
5314 $known_hosts = array();
5315 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
5316 global $wpdb;
5317 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
5318 $guids = $wpdb->get_col(
5319 $wpdb->prepare(
5320 "SELECT guid FROM $wpdb->posts WHERE post_type = %s",
5321 'ap_actor'
5322 )
5323 );
5324 foreach ( $guids as $guid ) {
5325 $guid_host = wp_parse_url( $guid, PHP_URL_HOST );
5326 if ( $guid_host ) {
5327 $known_hosts[ $guid_host ] = true;
5328 }
5329 }
5330 }
5331
5332 // Also collect hosts from activitypub user feeds we follow.
5333 $term_query = new \WP_Term_Query(
5334 array(
5335 'taxonomy' => User_Feed::TAXONOMY,
5336 'hide_empty' => false,
5337 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
5338 array(
5339 'key' => 'parser',
5340 'value' => self::SLUG,
5341 ),
5342 ),
5343 )
5344 );
5345 foreach ( $term_query->get_terms() as $term ) {
5346 $feed_host = wp_parse_url( $term->name, PHP_URL_HOST );
5347 if ( $feed_host ) {
5348 $known_hosts[ $feed_host ] = true;
5349 }
5350 }
5351 }
5352
5353 if ( isset( $known_hosts[ $host ] ) ) {
5354 return true;
5355 }
5356
5357 return (bool) apply_filters( 'friends_is_known_activitypub_host', false, $host );
5358 }
5359
5360 private static function convert_actor_to_mastodon_handle( $actor ) {
5361 static $cache = array();
5362 if ( isset( $cache[ $actor ] ) ) {
5363 return $cache[ $actor ];
5364 }
5365
5366 $p = wp_parse_url( $actor );
5367 if ( $p && ! empty( $p['host'] ) && ! empty( $p['path'] ) ) {
5368 $host = $p['host'];
5369 $path_parts = explode( '/', trim( $p['path'], '/' ) );
5370 $username = null;
5371
5372 if ( 1 === count( $path_parts ) && str_starts_with( $path_parts[0], '@' ) ) {
5373 // /@username — ActivityPub-specific, safe on any domain.
5374 $username = ltrim( $path_parts[0], '@' );
5375 } elseif ( 2 === count( $path_parts ) && self::is_known_activitypub_host( $host ) ) {
5376 // /users/username, /activitypub/username, etc. on known fediverse hosts.
5377 $username = $path_parts[1];
5378 }
5379
5380 if ( $username ) {
5381 $cache[ $actor ] = $username . '@' . $host;
5382 return $cache[ $actor ];
5383 }
5384 }
5385
5386 $cache[ $actor ] = $actor;
5387 return $actor;
5388 }
5389
5390 /**
5391 * Disable Webfinger for example domains
5392 *
5393 * @param mixed $metadata Already retrieved metadata.
5394 * @param mixed $actor The actor as URL or username.
5395 * @return mixed The potentially added metadata for example domains.
5396 */
5397 public function disable_webfinger_for_example_domains( $metadata, $actor ) {
5398 if ( ! is_string( $actor ) ) {
5399 return $metadata;
5400 }
5401 if ( ! $metadata ) {
5402 $username = null;
5403 $domain = null;
5404 if ( preg_match( '/^@?' . self::ACTIVITYPUB_USERNAME_REGEXP . '$/i', $actor, $m ) ) {
5405 $username = $m[1];
5406 $domain = $m[2];
5407 } else {
5408 $p = wp_parse_url( $actor );
5409 if ( $p ) {
5410 if ( isset( $p['host'] ) ) {
5411 $domain = $p['host'];
5412 }
5413 if ( isset( $p['path'] ) ) {
5414 $path_parts = explode( '/', trim( $p['path'], '/' ) );
5415 $username = ltrim( array_pop( $path_parts ), '@' );
5416 }
5417 }
5418 }
5419 if ( preg_match( '/[^a-zA-Z0-9.-]/', $domain ) ) { // Has invalid characters.
5420 return $metadata;
5421 }
5422
5423 if (
5424 rtrim( strtok( $domain, '.' ), '0123456789' ) === 'example' // A classic example.org domain.
5425 || preg_match( '/(my|your|our)-(domain)/', $domain )
5426 || preg_match( '/(test)/', $domain )
5427 || in_array( $username, array( 'example' ), true )
5428 ) {
5429 $metadata = array(
5430 'url' => sprintf( 'https://%s/users/%s/', $domain, $username ),
5431 'name' => $username,
5432 );
5433 }
5434 }
5435 return $metadata;
5436 }
5437
5438 public function ajax_delete_follower() {
5439 if ( ! current_user_can( Friends::REQUIRED_ROLE ) ) {
5440 wp_send_json_error( 'error' );
5441 }
5442 check_ajax_referer( 'friends-followers' );
5443
5444 if ( ! isset( $_POST['id'] ) ) {
5445 return wp_send_json_error( 'missing-id' );
5446 }
5447
5448 if ( ! \Activitypub\Collection\Followers::remove_follower( get_current_user_id(), sanitize_text_field( wp_unslash( $_POST['id'] ) ) ) ) {
5449 return wp_send_json_error( 'Follower not found' );
5450 }
5451 wp_send_json_success();
5452 }
5453
5454 public function ajax_preview() {
5455 if ( ! current_user_can( Friends::REQUIRED_ROLE ) ) {
5456 wp_send_json_error( 'error' );
5457 }
5458
5459 check_ajax_referer( 'friends-preview' );
5460
5461 if ( ! isset( $_POST['url'] ) || ! filter_input( INPUT_POST, 'url', FILTER_VALIDATE_URL ) ) {
5462 wp_send_json_error( 'missing-url' );
5463 }
5464
5465 $items = $this->friends_feed->preview( self::SLUG, sanitize_text_field( wp_unslash( $_POST['url'] ) ) );
5466 if ( is_wp_error( $items ) ) {
5467 wp_send_json_error( $items );
5468 }
5469
5470 $followers = __( '? followers', 'friends' );
5471 if ( isset( $_POST['followers'] ) && filter_input( INPUT_POST, 'followers', FILTER_VALIDATE_URL ) ) {
5472 $data = \Activitypub\safe_remote_get( sanitize_text_field( wp_unslash( $_POST['followers'] ) ) );
5473 if ( ! is_wp_error( $data ) ) {
5474 $body = json_decode( wp_remote_retrieve_body( $data ), true );
5475 if ( isset( $body['totalItems'] ) ) {
5476 $followers = sprintf(
5477 // translators: %s is the number of followers.
5478 _n( '%s follower', '%s followers', $body['totalItems'], 'friends' ),
5479 $body['totalItems']
5480 );
5481 }
5482 }
5483 }
5484 $following = __( '? following', 'friends' );
5485 if ( isset( $_POST['following'] ) && filter_input( INPUT_POST, 'following', FILTER_VALIDATE_URL ) ) {
5486 $data = \Activitypub\safe_remote_get( sanitize_text_field( wp_unslash( $_POST['following'] ) ) );
5487 if ( ! is_wp_error( $data ) ) {
5488 $body = json_decode( wp_remote_retrieve_body( $data ), true );
5489 if ( isset( $body['totalItems'] ) ) {
5490 $following = sprintf(
5491 // translators: %s is the number of followings.
5492 _n( '%s following', '%s following', $body['totalItems'], 'friends' ),
5493 $body['totalItems']
5494 );
5495 }
5496 }
5497 }
5498
5499 $posts = '<div class="posts">';
5500 foreach ( $items as $item ) {
5501 $posts .= '<div class="card">';
5502 $posts .= '<header class="card-header">';
5503 $posts .= '<div class="post-meta">';
5504 $posts .= '<div class="permalink">';
5505 if ( $item->permalink ) {
5506 $posts .= '<a href="' . esc_url( $item->permalink ) . '">';
5507 }
5508 if ( $item->date ) {
5509 $posts .= esc_html( $item->date );
5510 }
5511 if ( $item->permalink ) {
5512 $posts .= '</a>';
5513 }
5514 $posts .= '</div>';
5515 $posts .= '</div>';
5516 $posts .= '</header>';
5517 $posts .= '<div class="card-body">';
5518 $posts .= wp_kses_post( $item->content );
5519 $posts .= '</div>';
5520 $posts .= '</div>';
5521 }
5522
5523 wp_send_json_success( compact( 'posts', 'followers', 'following' ) );
5524 }
5525 }
5526