PluginProbe
Friends / 2.8.2
Friends v2.8.2
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 2.8.2, at feed-parsers/class-feed-parser-activitypub.php

1,812 lines 54.5 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
17 /**
18 * This is the class for integrating ActivityPub into the Friends Plugin.
19 */
20 class Feed_Parser_ActivityPub extends Feed_Parser_V2 {
21 const SLUG = 'activitypub';
22 const NAME = 'ActivityPub';
23 const URL = 'https://www.w3.org/TR/activitypub/';
24 const ACTIVITYPUB_USERNAME_REGEXP = '(?:([A-Za-z0-9_-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))';
25 const EXTERNAL_MENTIONS_USERNAME = 'external-mentions';
26
27 private $friends_feed;
28
29 /**
30 * Constructor.
31 *
32 * @param Feed $friends_feed The friends feed.
33 */
34 public function __construct( Feed $friends_feed ) {
35 $this->friends_feed = $friends_feed;
36
37 \add_action( 'init', array( $this, 'register_post_meta' ) );
38 \add_action( 'admin_menu', array( $this, 'admin_menu' ), 20 );
39 \add_filter( 'feed_item_allow_set_metadata', array( $this, 'feed_item_allow_set_metadata' ), 10, 3 );
40 \add_filter( 'friends_add_friends_input_placeholder', array( $this, 'friends_add_friends_input_placeholder' ) );
41 \add_action( 'friends_add_friend_form_top', array( $this, 'friends_add_friend_form_top' ) );
42
43 \add_action( 'activitypub_inbox', array( $this, 'handle_received_activity' ), 10, 3 );
44 \add_action( 'friends_user_feed_activated', array( $this, 'queue_follow_user' ), 10 );
45 \add_action( 'friends_user_feed_deactivated', array( $this, 'queue_unfollow_user' ), 10 );
46 \add_action( 'friends_feed_parser_activitypub_follow', array( $this, 'activitypub_follow_user' ), 10, 2 );
47 \add_action( 'friends_feed_parser_activitypub_unfollow', array( $this, 'activitypub_unfollow_user' ), 10, 2 );
48 \add_action( 'friends_feed_parser_activitypub_like', array( $this, 'activitypub_like_post' ), 10, 3 );
49 \add_action( 'friends_feed_parser_activitypub_unlike', array( $this, 'activitypub_unlike_post' ), 10, 3 );
50 \add_action( 'friends_feed_parser_activitypub_announce', array( $this, 'activitypub_announce' ), 10, 2 );
51 \add_action( 'friends_feed_parser_activitypub_unannounce', array( $this, 'activitypub_unannounce' ), 10, 2 );
52 \add_filter( 'friends_rewrite_incoming_url', array( $this, 'friends_webfinger_resolve' ), 10, 2 );
53
54 \add_filter( 'friends_edit_friend_table_end', array( $this, 'activitypub_settings' ), 10 );
55 \add_filter( 'friends_edit_friend_after_form_submit', array( $this, 'activitypub_save_settings' ), 10 );
56 \add_filter( 'friends_modify_feed_item', array( $this, 'modify_incoming_item' ), 9, 3 );
57 \add_filter( 'friends_potential_avatars', array( $this, 'friends_potential_avatars' ), 10, 2 );
58 \add_filter( 'friends_suggest_user_login', array( $this, 'suggest_user_login' ), 10, 2 );
59
60 \add_filter( 'the_content', array( $this, 'the_content' ), 99, 2 );
61 \add_filter( 'activitypub_extract_mentions', array( $this, 'activitypub_extract_mentions' ), 10, 2 );
62 \add_filter( 'mastodon_api_external_mentions_user', array( $this, 'get_external_mentions_user' ) );
63 \add_filter( 'activitypub_post', array( $this, 'activitypub_post_in_reply_to' ), 10, 2 );
64 \add_filter( 'activitypub_activity_object_array', array( $this, 'activitypub_activity_object_array_in_reply_to' ), 10, 3 );
65
66 \add_action( 'friends_user_post_reaction', array( $this, 'post_reaction' ) );
67 \add_action( 'friends_user_post_undo_reaction', array( $this, 'undo_post_reaction' ) );
68 \add_action( 'mastodon_api_react', array( $this, 'mastodon_api_react' ), 10, 2 );
69 \add_action( 'mastodon_api_unreact', array( $this, 'mastodon_api_unreact' ), 10, 2 );
70 \add_action( 'friends_get_reaction_display_name', array( $this, 'get_reaction_display_name' ), 10, 2 );
71
72 \add_filter( 'pre_comment_approved', array( $this, 'pre_comment_approved' ), 10, 2 );
73 \add_action( 'comment_post', array( $this, 'comment_post' ), 10, 3 );
74 \add_action( 'trashed_comment', array( $this, 'trashed_comment' ), 10, 2 );
75
76 \add_filter( 'friends_reblog_button_label', array( $this, 'friends_reblog_button_label' ), 10, 2 );
77
78 \add_filter( 'friends_reblog', array( $this, 'unqueue_activitypub_create' ), 9 );
79 \add_action( 'mastodon_api_reblog', array( $this, 'mastodon_api_reblog' ) );
80 \add_action( 'mastodon_api_unreblog', array( $this, 'mastodon_api_unreblog' ) );
81 \add_filter( 'friends_reblog', array( $this, 'reblog' ), 20, 2 );
82 \add_filter( 'friends_unreblog', array( $this, 'unreblog' ), 20, 2 );
83 \add_filter( 'friends_reblog', array( $this, 'maybe_unqueue_friends_reblog_post' ), 9, 2 );
84
85 \add_filter( 'pre_get_remote_metadata_by_actor', array( $this, 'disable_webfinger_for_example_domains' ), 9, 2 );
86
87 add_filter( 'friends_get_feed_metadata', array( $this, 'friends_get_feed_metadata' ), 10, 2 );
88 add_filter( 'friends_get_activitypub_metadata', array( $this, 'friends_activitypub_metadata' ), 10, 2 );
89 }
90
91 /**
92 * Add the admin menu to the sidebar.
93 */
94 public function admin_menu() {
95 $friends = Friends::get_instance();
96 $unread_badge = $friends->admin->get_unread_badge();
97
98 $menu_title = __( 'Friends', 'friends' ) . $unread_badge;
99 $page_type = sanitize_title( $menu_title );
100
101 add_submenu_page(
102 'friends',
103 __( 'ActivityPub', 'friends' ),
104 __( 'ActivityPub', 'friends' ),
105 Friends::required_menu_role(),
106 'friends-activitypub-settings',
107 array( $this, 'settings' )
108 );
109
110 add_action( 'load-' . $page_type . '_page_friends-activitypub-settings', array( $this, 'process_settings' ) );
111 }
112
113 public function settings() {
114 Friends::template_loader()->get_template_part(
115 'admin/settings-header',
116 null,
117 array(
118 'menu' => array(
119 __( 'ActivityPub Settings', 'friends' ) => 'friends-activitypub-settings',
120 ),
121 'active' => 'friends-activitypub-settings',
122 'title' => __( 'Friends', 'friends' ),
123 )
124 );
125
126 if ( isset( $_GET['updated'] ) ) {
127 ?>
128 <div id="message" class="updated notice is-dismissible"><p><?php esc_html_e( 'Settings were updated.', 'friends' ); ?></p></div>
129 <?php
130 } elseif ( isset( $_GET['error'] ) ) {
131 ?>
132 <div id="message" class="updated error is-dismissible"><p><?php esc_html_e( 'An error occurred.', 'friends' ); ?></p></div>
133 <?php
134 }
135
136 Friends::template_loader()->get_template_part(
137 'admin/activitypub-settings',
138 null,
139 array(
140 'reblog' => ! get_user_option( 'friends_activitypub_dont_reblog' ),
141 )
142 );
143
144 Friends::template_loader()->get_template_part( 'admin/settings-footer' );
145 }
146
147 public function process_settings() {
148 if ( empty( $_POST ) || ! wp_verify_nonce( $_POST['_wpnonce'], 'friends-activitypub-settings' ) ) {
149 return;
150 }
151
152 if ( isset( $_POST['activitypub_reblog'] ) ) {
153 delete_user_option( get_current_user_id(), 'friends_activitypub_dont_reblog' );
154 } else {
155 update_user_option( get_current_user_id(), 'friends_activitypub_dont_reblog', true );
156 }
157 wp_safe_redirect( add_query_arg( 'updated', 'true', admin_url( 'admin.php?page=friends-activitypub-settings' ) ) );
158 exit;
159 }
160
161 public function friends_add_friends_input_placeholder() {
162 return __( 'Enter URL or @activitypub@handle.domain', 'friends' );
163 }
164
165 public function friends_add_friend_form_top() {
166 ?>
167 <p>
168 <?php
169 echo wp_kses(
170 sprintf(
171 // translators: %1$s and %2$s are links to the respective services.
172 __( '<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' ),
173 '"https://joinmastodon.org/"',
174 '"https://pixelfed.social/"'
175 ),
176 array(
177 'a' => array( 'href' => array() ),
178 'strong' => array(),
179 )
180 );
181 ?>
182 </p>
183 <?php
184 }
185
186 public function friends_get_feed_metadata( $meta, $feed ) {
187 if ( self::SLUG === $feed->get_parser() ) {
188 return $this->friends_activitypub_metadata( $meta, $feed->get_url() );
189 }
190 return $meta;
191 }
192
193 public function friends_activitypub_metadata( $ret, $url ) {
194 $meta = self::get_metadata( $url );
195 if ( is_null( $meta ) ) {
196 return $ret;
197 }
198 if ( is_wp_error( $meta ) ) {
199 if ( ! empty( $ret ) ) {
200 return $ret;
201 }
202 return $meta;
203 }
204 return array_merge( $ret, $meta );
205 }
206
207 public function register_post_meta() {
208 register_post_meta(
209 Friends::CPT,
210 self::SLUG,
211 array(
212 'show_in_rest' => true,
213 'single' => true,
214 'type' => 'object',
215 )
216 );
217 }
218
219 public function feed_item_allow_set_metadata( $verdict, $key, $value ) {
220 if ( self::SLUG === $key && ! empty( $value ) ) {
221 // We don't want to insert empty post meta.
222 return true;
223 }
224 return $verdict;
225 }
226
227 /**
228 * Allow logging a message via an action.
229 *
230 * @param string $message The message to log.
231 * @param array $objects Optional objects as meta data.
232 * @return void
233 */
234 private function log( $message, $objects = array() ) {
235 // Receive debug messages from the ActivityPub feed parser.
236 do_action( 'friends_activitypub_log', $message, $objects );
237 }
238
239 /**
240 * Determines if this is a supported feed and to what degree we feel it's supported.
241 *
242 * @param string $url The url.
243 * @param string $mime_type The mime type.
244 * @param string $title The title.
245 * @param string|null $content The content, it can't be assumed that it's always available.
246 *
247 * @return int Return 0 if unsupported, a positive value representing the confidence for the feed, use 10 if you're reasonably confident.
248 */
249 public function feed_support_confidence( $url, $mime_type, $title, $content = null ) {
250 if ( preg_match( '/^@?[^@]+@((?:[a-z0-9-]+\.)+[a-z]+)$/i', $url ) ) {
251 return 10;
252 }
253
254 if ( 'application/activity+json' === $mime_type ) {
255 return 10;
256 }
257
258 if ( preg_match( '#^https?://[^/]+/@[a-z0-9-]+$#i', $url ) ) {
259 return 9;
260 }
261
262 if ( preg_match( '#^https?://[^/]+/(users|author)/[a-z0-9-]+$#i', $url ) ) {
263 return 8;
264 }
265
266 return 0;
267 }
268
269 /**
270 * Format the feed title and autoselect the posts feed.
271 *
272 * @param array $feed_details The feed details.
273 *
274 * @return array The (potentially) modified feed details.
275 */
276 public function update_feed_details( $feed_details ) {
277 $meta = apply_filters( 'friends_get_activitypub_metadata', array(), $feed_details['url'] );
278 if ( ! $meta || is_wp_error( $meta ) ) {
279 return $feed_details;
280 }
281 if ( isset( $meta['name'] ) ) {
282 $feed_details['title'] = $meta['name'];
283 } elseif ( isset( $meta['preferredUsername'] ) ) {
284 $feed_details['title'] = $meta['preferredUsername'];
285 }
286
287 if ( isset( $meta['id'] ) ) {
288 $feed_details['url'] = $meta['id'];
289 }
290
291 if ( isset( $meta['icon']['type'] ) && 'image' === strtolower( $meta['icon']['type'] ) ) {
292 $feed_details['avatar'] = $meta['icon']['url'];
293 }
294
295 if ( ! empty( $meta['summary'] ) ) {
296 $feed_details['description'] = $meta['summary'];
297 }
298
299 // Disable polling.
300 $feed_details['interval'] = YEAR_IN_SECONDS;
301 $feed_details['next-poll'] = gmdate( 'Y-m-d H:i:s', time() + YEAR_IN_SECONDS );
302 $feed_details['post-format'] = 'status';
303
304 return $feed_details;
305 }
306
307 /**
308 * Rewrite a Mastodon style URL @username@server to a URL via webfinger.
309 *
310 * @param string $url The URL to filter.
311 * @param string $incoming_url Potentially a mastodon identifier.
312 *
313 * @return string The rewritten URL.
314 */
315 public function friends_webfinger_resolve( $url, $incoming_url ) {
316 if ( preg_match( '/^@?' . self::ACTIVITYPUB_USERNAME_REGEXP . '$/i', $incoming_url ) ) {
317 $resolved_url = \Activitypub\Webfinger::resolve( $incoming_url );
318 if ( ! is_wp_error( $resolved_url ) ) {
319 return $resolved_url;
320 }
321 }
322 return $url;
323 }
324
325 /**
326 * Get the inbox URL for an actor
327 *
328 * @param string $url The URL of the actor.
329 * @return string|WP_Error
330 */
331 public static function get_inbox_by_actor( $url ) {
332 $metadata = self::get_metadata( $url );
333 if ( \is_wp_error( $metadata ) ) {
334 return $metadata;
335 }
336
337 if ( isset( $metadata['endpoints'] ) && isset( $metadata['endpoints']['sharedInbox'] ) ) {
338 return $metadata['endpoints']['sharedInbox'];
339 }
340
341 if ( \array_key_exists( 'inbox', $metadata ) ) {
342 return $metadata['inbox'];
343 }
344
345 return new WP_Error( 'activitypub_no_inbox', \__( 'No "ActivityPub Inbox" found', 'friends' ), $metadata );
346 }
347
348 public static function get_metadata( $url ) {
349 if ( ! is_string( $url ) ) {
350 return array();
351 }
352
353 if ( false !== strpos( $url, '@' ) ) {
354 if ( false === strpos( $url, '/' ) && preg_match( '#^https?://#', $url, $m ) ) {
355 $url = substr( $url, strlen( $m[0] ) );
356 }
357 return \Activitypub\get_remote_metadata_by_actor( $url );
358 }
359
360 $transient_key = 'friends_activitypub_' . crc32( $url );
361
362 $response = \get_transient( $transient_key );
363 if ( $response ) {
364 return $response;
365 }
366
367 $response = \wp_remote_get(
368 $url,
369 array(
370 'headers' => array( 'Accept' => 'application/activity+json' ),
371 'redirection' => 2,
372 'timeout' => 5,
373 )
374 );
375
376 if ( \is_wp_error( $response ) ) {
377 \set_transient( $transient_key, $response, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
378 return $response;
379 }
380
381 if ( \is_wp_error( $response ) ) {
382 \set_transient( $transient_key, $response, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
383 return $response;
384 }
385
386 $body = \wp_remote_retrieve_body( $response );
387 $body = \json_decode( $body, true );
388
389 \set_transient( $transient_key, $body, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
390
391 return $body;
392 }
393 /**
394 * Discover the feeds available at the URL specified.
395 *
396 * @param string $content The content for the URL is already provided here.
397 * @param string $url The url to search.
398 *
399 * @return array A list of supported feeds at the URL.
400 */
401 public function discover_available_feeds( $content, $url ) {
402 $discovered_feeds = array();
403
404 $meta = self::get_metadata( $url );
405 if ( $meta && ! is_wp_error( $meta ) ) {
406 $discovered_feeds[ $meta['id'] ] = array(
407 'type' => 'application/activity+json',
408 'rel' => 'self',
409 'post-format' => 'status',
410 'parser' => self::SLUG,
411 'autoselect' => true,
412 );
413 }
414
415 return $discovered_feeds;
416 }
417
418 /**
419 * Fetches a feed and returns the processed items.
420 *
421 * @param string $url The url.
422 * @param User_Feed $user_feed The user feed.
423 *
424 * @return array An array of feed items.
425 */
426 public function fetch_feed( $url, User_Feed $user_feed = null ) {
427 if ( $user_feed ) {
428 $this->disable_polling( $user_feed );
429 }
430
431 // This is only for previwing.
432 if ( wp_doing_cron() ) {
433 return array();
434 }
435
436 $meta = self::get_metadata( $url );
437 if ( is_wp_error( $meta ) || ! isset( $meta['outbox'] ) ) {
438 return array();
439 }
440
441 $response = \Activitypub\safe_remote_get( $meta['outbox'], Friends::get_main_friend_user_id() );
442 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
443 return new \WP_Error( 'activitypub_could_not_get_outbox_meta', null, compact( 'meta', 'url' ) );
444 }
445
446 $outbox = json_decode( wp_remote_retrieve_body( $response ), true );
447 if ( ! isset( $outbox['first'] ) ) {
448 return new \WP_Error( 'activitypub_could_not_find_outbox_first_page', null, compact( 'url', 'meta', 'outbox' ) );
449 }
450
451 $response = \Activitypub\safe_remote_get( $outbox['first'], Friends::get_main_friend_user_id() );
452 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
453 return new \WP_Error(
454 'activitypub_could_not_get_outbox',
455 null,
456 array(
457 'meta' => $outbox,
458 $url => $outbox['first'],
459 )
460 );
461 }
462 $outbox_page = json_decode( wp_remote_retrieve_body( $response ), true );
463 if ( ! isset( $outbox_page['type'] ) || 'OrderedCollectionPage' !== $outbox_page['type'] ) {
464 return new \WP_Error(
465 'activitypub_outbox_page_invalid_type',
466 null,
467 array(
468 'outbox_page' => $outbox_page,
469 $url => $outbox['first'],
470 )
471 );
472 }
473
474 $items = array();
475 foreach ( $outbox_page['orderedItems'] as $object ) {
476 $type = strtolower( $object['type'] );
477 $items[] = $this->process_incoming_activity( $type, $object, get_current_user_id(), $user_feed );
478 }
479
480 return $items;
481 }
482
483 public function suggest_user_login( $login, $url ) {
484 if ( preg_match( '#^https?://([^/]+)/users/([^/]+)#', $url, $m ) ) {
485 return $m[2] . '-' . $m[1];
486 }
487 return $login;
488 }
489 /**
490 * Disable polling for this feed.
491 *
492 * @param User_Feed $user_feed The user feed.
493 * @return void
494 */
495 private function disable_polling( User_Feed $user_feed ) {
496 $user_feed->update_metadata( 'interval', YEAR_IN_SECONDS );
497 $user_feed->update_metadata( 'next-poll', gmdate( 'Y-m-d H:i:s', time() + YEAR_IN_SECONDS ) );
498 }
499
500 /**
501 * Gets the external mentions user.
502 *
503 * @return User The external mentions user.
504 */
505 public function get_external_mentions_user() {
506 $external_mentions_username = apply_filters( 'friends_external_mentions_username', self::EXTERNAL_MENTIONS_USERNAME );
507 $user = get_user_by( 'login', $external_mentions_username );
508 if ( ! $user ) {
509 $user = Subscription::create( $external_mentions_username, 'subscription', home_url(), __( 'External Mentions', 'friends' ) );
510 } else {
511 $user = User::get_by_username( $external_mentions_username );
512 }
513
514 if ( $user instanceof \WP_User && ! ( $user instanceof Subscription ) && ! is_user_member_of_blog( $user->ID, get_current_blog_id() ) ) {
515 \add_user_to_blog( get_current_blog_id(), $user->ID, 'subscription' );
516 }
517
518 return $user;
519 }
520
521 private function get_external_mentions_feed() {
522 require_once __DIR__ . '/activitypub/class-virtual-user-feed.php';
523 $user = $this->get_external_mentions_user();
524 return new Virtual_User_Feed( $user, __( 'External Mentions', 'friends' ) );
525 }
526
527 /**
528 * Set the inReplyTo property for the outgoing activity (object).
529 *
530 * @param array $ret The activity from the ActivityPub plugin.
531 * @param WP_Post $post The post object.
532 * @return mixed
533 */
534 public function activitypub_post_in_reply_to( $ret, $post ) {
535 if ( get_post_meta( $post->ID, 'activitypub_in_reply_to', true ) ) {
536 $ret['inReplyTo'] = get_post_meta( $post->ID, 'activitypub_in_reply_to', true );
537 $ret['object']['inReplyTo'] = $ret['inReplyTo'];
538 }
539 return $ret;
540 }
541
542 /**
543 * Set the inReplyTo property for the outgoing activity object.
544 *
545 * @param array $ret The activity object from the ActivityPub plugin.
546 * @param object $c The class.
547 * @param string $url The URL of the post.
548 * @return array The modified activity object.
549 */
550 public function activitypub_activity_object_array_in_reply_to( $ret, $c, $url ) {
551 $post_id = url_to_postid( $url );
552 if ( get_post_meta( $post_id, 'activitypub_in_reply_to', true ) ) {
553 $ret['inReplyTo'] = get_post_meta( $post_id, 'activitypub_in_reply_to', true );
554 }
555 return $ret;
556 }
557
558 /**
559 * Handles "Create" requests
560 *
561 * @param array $activity The activity object.
562 * @param int $user_id The id of the local blog user.
563 * @param string $type The type of the activity.
564 */
565 public function handle_received_activity( $activity, $user_id, $type ) {
566 if ( 'undo' === $type ) {
567 if ( ! isset( $activity['object'] ) ) {
568 return false;
569 }
570 $activity = $activity['object'];
571 switch ( strtolower( $activity['type'] ) ) {
572 case 'like':
573 $type = 'unlike';
574 break;
575 case 'announce':
576 $type = 'unannounce';
577 break;
578 default:
579 return false;
580 }
581 }
582 if ( ! in_array(
583 $type,
584 array(
585 // We don't need to handle 'Accept' types since it's handled by the ActivityPub plugin itself.
586 'create',
587 'delete',
588 'announce',
589 'unannounce',
590 'like',
591 'unlike',
592 ),
593 true
594 ) ) {
595 return false;
596 }
597 $actor_url = $activity['actor'];
598 $user_feed = false;
599 if ( Friends::check_url( $actor_url ) ) {
600 // Let's check if we follow this actor. If not it might be a different URL representation.
601 $user_feed = $this->friends_feed->get_user_feed_by_url( $actor_url );
602 }
603
604 if ( is_wp_error( $user_feed ) || ! Friends::check_url( $actor_url ) ) {
605 $meta = $this->get_metadata( $actor_url );
606 if ( ! $meta || is_wp_error( $meta ) || ! isset( $meta['url'] ) ) {
607 $this->log( 'Received invalid meta for ' . $actor_url );
608 return false;
609 }
610
611 $actor_url = $meta['url'];
612 if ( ! Friends::check_url( $actor_url ) ) {
613 $this->log( 'Received invalid meta url for ' . $actor_url );
614 return false;
615 }
616 }
617
618 $user_feed = $this->friends_feed->get_user_feed_by_url( $actor_url );
619 if ( ! $user_feed || is_wp_error( $user_feed ) ) {
620 if ( isset( $activity['object']['tag'] ) && is_array( $activity['object']['tag'] ) ) {
621 $my_activitypub_id = \get_author_posts_url( $user_id );
622 foreach ( $activity['object']['tag'] as $tag ) {
623 if ( isset( $tag['type'] ) && 'Mention' === $tag['type'] && isset( $tag['href'] ) && $tag['href'] === $my_activitypub_id ) {
624 // It was a mention.
625 $user_feed = $this->get_external_mentions_feed();
626 break;
627 }
628 }
629 }
630 }
631
632 if ( ! $user_feed || is_wp_error( $user_feed ) ) {
633 // We're not following this user.
634 return false;
635 }
636
637 $item = $this->process_incoming_activity( $type, $activity, $user_id, $user_feed );
638
639 if ( $item instanceof Feed_Item ) {
640 $this->friends_feed->process_incoming_feed_items( array( $item ), $user_feed );
641 return true;
642 }
643
644 return false;
645 }
646
647 /**
648 * Process an incoming activity.
649 *
650 * @param string $type The type of the activity.
651 * @param array $activity The activity object.
652 * @param int $user_id The id of the local user if any.
653 * @param User_Feed $user_feed The user feed.
654 * @return Feed_Item|null The feed item or null if it's not a valid activity.
655 */
656 private function process_incoming_activity( $type, $activity, $user_id, $user_feed ) {
657 switch ( $type ) {
658 case 'create':
659 return $this->handle_incoming_create( $activity['object'] );
660 case 'delete':
661 return $this->handle_incoming_delete( $activity['object'] );
662 case 'announce':
663 return $this->handle_incoming_announce( $activity['object'], $user_id, $activity['published'] );
664 case 'unannounce':
665 return $this->handle_incoming_unannounce( $activity['object'], $user_feed );
666 case 'like':
667 return $this->handle_incoming_like( $activity, $user_id );
668 case 'unlike':
669 return $this->handle_incoming_unlike( $activity, $user_id );
670 }
671 return null;
672 }
673
674 /**
675 * Map the Activity type to a post fomat.
676 *
677 * @param string $type The type.
678 *
679 * @return string The determined post format.
680 */
681 private function map_type_to_post_format( $type ) {
682 return 'status';
683 }
684
685 /**
686 * We received a post for a feed, handle it.
687 *
688 * @param array $activity The object from ActivityPub.
689 */
690 private function handle_incoming_create( $activity ) {
691 $permalink = $activity['id'];
692 if ( isset( $activity['url'] ) ) {
693 $permalink = $activity['url'];
694 }
695
696 $data = array(
697 'permalink' => $permalink,
698 'content' => $activity['content'],
699 'post_format' => $this->map_type_to_post_format( $activity['type'] ),
700 'date' => $activity['published'],
701 '_external_id' => $activity['id'],
702 self::SLUG => array(),
703 );
704
705 if ( isset( $activity['reblog'] ) && $activity['reblog'] ) {
706 $data[ self::SLUG ]['reblog'] = $activity['reblog'];
707 }
708
709 if ( isset( $activity['attributedTo'] ) ) {
710 $meta = $this->get_metadata( $activity['attributedTo'] );
711 $this->log( 'Attributed to ' . $activity['attributedTo'], compact( 'meta' ) );
712
713 if ( $meta && ! is_wp_error( $meta ) ) {
714 if ( isset( $meta['name'] ) ) {
715 $data['author'] = $meta['name'];
716 } elseif ( isset( $meta['preferredUsername'] ) ) {
717 $data['author'] = $meta['preferredUsername'];
718 }
719
720 $data[ self::SLUG ]['attributedTo'] = array(
721 'id' => $meta['id'],
722 );
723 if ( ! empty( $meta['icon']['url'] ) ) {
724 $data[ self::SLUG ]['attributedTo']['icon'] = $meta['icon']['url'];
725 }
726
727 if ( ! empty( $meta['summary'] ) ) {
728 $data[ self::SLUG ]['attributedTo']['summary'] = $meta['summary'];
729 }
730
731 if ( ! empty( $meta['preferredUsername'] ) ) {
732 $data[ self::SLUG ]['attributedTo']['preferredUsername'] = $meta['preferredUsername'];
733 }
734
735 if ( ! empty( $meta['name'] ) ) {
736 $data[ self::SLUG ]['attributedTo']['name'] = $meta['name'];
737 }
738 }
739 }
740
741 if ( isset( $activity['application'] ) && $activity['application'] ) {
742 $data[ self::SLUG ]['application'] = $activity['application'];
743 }
744
745 if ( ! empty( $activity['attachment'] ) ) {
746 foreach ( $activity['attachment'] as $attachment ) {
747 if ( ! isset( $attachment['type'] ) || ! isset( $attachment['mediaType'] ) ) {
748 continue;
749 }
750 if ( 'Document' !== $attachment['type'] ) {
751 continue;
752 }
753
754 if ( strpos( $attachment['mediaType'], 'image/' ) === 0 ) {
755 $data['content'] .= PHP_EOL;
756 $data['content'] .= '<!-- wp:image -->';
757 $data['content'] .= '<p><img src="' . esc_url( $attachment['url'] ) . '" width="' . esc_attr( $attachment['width'] ) . '" height="' . esc_attr( $attachment['height'] ) . '" class="size-full" /></p>';
758 $data['content'] .= '<!-- /wp:image -->';
759 } elseif ( strpos( $attachment['mediaType'], 'video/' ) === 0 ) {
760 $data['content'] .= PHP_EOL;
761 $data['content'] .= '<!-- wp:video -->';
762 $data['content'] .= '<figure class="wp-block-video"><video controls src="' . esc_url( $attachment['url'] ) . '" />';
763 if ( ! empty( $attachment['name'] ) ) {
764 $data['content'] .= '<figcaption class="wp-element-caption">' . esc_html( $attachment['name'] ) . '</figcaption>';
765 }
766 $data['content'] .= '</figure>';
767 $data['content'] .= '<!-- /wp:video -->';
768 }
769 }
770 }
771
772 $this->log(
773 'Received feed item',
774 array(
775 'url' => $permalink,
776 'data' => $data,
777 )
778 );
779 return new Feed_Item( $data );
780 }
781
782
783 /**
784 * We received a delete of a post, handle it.
785 *
786 * @param array $activity The object from ActivityPub.
787 */
788 private function handle_incoming_delete( $activity ) {
789 $permalink = $activity['id'];
790 if ( isset( $activity['url'] ) ) {
791 $permalink = $activity['url'];
792 }
793
794 $this->log( 'Received delete for ' . $permalink );
795
796 $comment_id = null;
797 if ( preg_match( '/#comment-(\d+)$/i', $permalink, $matches ) ) {
798 $comment_id = $matches[1];
799 $permalink = substr( $permalink, 0, - strlen( $matches[0] ) );
800 }
801
802 $post_id = Feed::url_to_postid( $permalink );
803 if ( $post_id ) {
804 if ( $comment_id ) {
805 $comment = get_comment( $comment_id );
806 if ( $comment ) {
807 wp_delete_comment( $comment_id );
808 return true;
809 }
810 } else {
811 $meta = get_post_meta( $post_id, self::SLUG, true );
812 if ( ! empty( $meta ) ) {
813 wp_trash_post( $post_id );
814 return true;
815 }
816 }
817 }
818 return false;
819 }
820
821 /**
822 * We received an announced URL (boost) for a feed, handle it.
823 *
824 * @param array $url The announced URL.
825 * @param int $user_id The user id (for retrieving the keys).
826 * @param string $published_date The published date.
827 */
828 private function handle_incoming_announce( $url, $user_id, $published_date = null ) {
829 if ( ! Friends::check_url( $url ) ) {
830 $this->log( 'Received invalid announce', compact( 'url' ) );
831 return false;
832 }
833 $this->log( 'Received announce for ' . $url );
834 if ( null === $user_id ) {
835 $user_id = Friends::get_main_friend_user_id();
836 }
837 $response = \Activitypub\safe_remote_get( $url, $user_id );
838 if ( \is_wp_error( $response ) ) {
839 return $response;
840 }
841 $json = \wp_remote_retrieve_body( $response );
842 $object = \json_decode( $json, true );
843 if ( ! $object ) {
844 $this->log( 'Received invalid json', compact( 'json' ) );
845 return false;
846 }
847 $this->log( 'Received response', compact( 'url', 'object' ) );
848 if ( ! $published_date ) {
849 $published_date = 'now';
850 }
851 $object['published'] = gmdate( 'Y-m-d H:i:s', strtotime( $published_date ) );
852 $object['reblog'] = true;
853
854 return $this->handle_incoming_create( $object );
855 }
856
857 /**
858 * We received an announced URL (boost) for a feed, handle it.
859 *
860 * @param array $url The announced URL.
861 * @param User_Feed $user_feed The user feed.
862 */
863 private function handle_incoming_unannounce( $url, $user_feed ) {
864 if ( ! Friends::check_url( $url ) ) {
865 $this->log( 'Received invalid unannounce', compact( 'url' ) );
866 return false;
867 }
868 $this->log( 'Received unannounce for ' . $url );
869
870 $post_id = Feed::url_to_postid( $url, $user_feed->get_friend_user()->ID );
871 if ( $post_id ) {
872 $meta = get_post_meta( $post_id, self::SLUG, true );
873 if ( isset( $meta['reblog'] ) ) {
874 wp_trash_post( $post_id );
875 return true;
876 }
877 }
878 return false;
879 }
880
881 public function get_reaction_display_name( $display_name, $term ) {
882 $url = get_term_meta( $term->term_id, 'url', true );
883 $meta = \Activitypub\get_remote_metadata_by_actor( $url );
884 if ( $meta && ! is_wp_error( $meta ) && ! empty( $meta['preferredUsername'] ) ) {
885 $host = parse_url( $meta['id'], PHP_URL_HOST );
886 return '@' . $meta['preferredUsername'] . '@' . $host;
887 }
888 return $display_name;
889 }
890
891 public function handle_incoming_like( $activity, $user_id ) {
892 $post_id = Feed::url_to_postid( $activity['object'] );
893 if ( ! $post_id ) {
894 return false;
895 }
896 $taxonomy_username = 'ap-' . crc32( $activity['actor'] );
897
898 // Allow setting the term with its meta.
899 $taxonomy = Reactions::register_user_taxonomy( $taxonomy_username );
900
901 register_term_meta(
902 $taxonomy,
903 'url',
904 array(
905 'show_in_rest' => false,
906 'single' => true,
907 'type' => 'string',
908 'sanitize_callback' => 'sanitize_text_field',
909 )
910 );
911
912 // 2b50 = star.
913 // 2764 = heart.
914 $term_id = apply_filters( 'friends_react', null, $post_id, '2b50', $taxonomy_username );
915 if ( ! $term_id || is_wp_error( $term_id ) ) {
916 return false;
917 }
918
919 update_term_meta( $term_id, 'url', $activity['actor'] );
920
921 return $term_id;
922 }
923
924 public function handle_incoming_unlike( $activity, $user_id ) {
925 $post_id = Feed::url_to_postid( $activity['object'] );
926 if ( ! $post_id ) {
927 return false;
928 }
929
930 $taxonomy_username = 'ap-' . crc32( $activity['actor'] );
931
932 // Allow removing the term.
933 Reactions::register_user_taxonomy( $taxonomy_username );
934
935 // 2b50 = star.
936 // 2764 = heart.
937 $ret = apply_filters( 'friends_unreact', null, $post_id, '2b50', $taxonomy_username );
938 if ( ! $ret || is_wp_error( $ret ) ) {
939 return false;
940 }
941
942 return true;
943 }
944
945 /**
946 * Queue a hook to run async.
947 *
948 * @param string $hook The hook name.
949 * @param array $args The arguments to pass to the hook.
950 * @param string $unqueue_hook Optional a hook to unschedule before queuing.
951 * @return void|bool Whether the hook was queued.
952 */
953 public function queue( $hook, $args, $unqueue_hook = null ) {
954 if ( $unqueue_hook ) {
955 $hook_timestamp = wp_next_scheduled( $unqueue_hook, $args );
956 if ( $hook_timestamp ) {
957 wp_unschedule_event( $hook_timestamp, $unqueue_hook, $args );
958 }
959 }
960
961 if ( wp_next_scheduled( $hook, $args ) ) {
962 return;
963 }
964
965 return \wp_schedule_single_event( \time(), $hook, $args );
966 }
967
968 /**
969 * Prepare to follow the user via a scheduled event.
970 *
971 * @param User_Feed $user_feed The user feed.
972 *
973 * @return bool|WP_Error Whether the event was queued.
974 */
975 public function queue_follow_user( User_Feed $user_feed ) {
976 if ( self::SLUG !== $user_feed->get_parser() ) {
977 return;
978 }
979
980 $queued = $this->queue(
981 'friends_feed_parser_activitypub_follow',
982 array( $user_feed->get_url() ),
983 'friends_feed_parser_activitypub_unfollow'
984 );
985
986 if ( $queued ) {
987 $user_feed->update_last_log( __( 'Queued follow request.', 'friends' ) );
988 }
989
990 return $queued;
991 }
992
993 /**
994 * Follow a user via ActivityPub at a URL.
995 *
996 * @param string $url The url.
997 * @param int $user_id The current user id.
998 */
999 public function activitypub_follow_user( $url, $user_id = null ) {
1000 if ( null === $user_id ) {
1001 $user_id = Friends::get_main_friend_user_id();
1002 }
1003
1004 $meta = $this->get_metadata( $url );
1005 $user_feed = User_Feed::get_by_url( $url );
1006 if ( is_wp_error( $meta ) ) {
1007 if ( $user_feed instanceof User_Feed ) {
1008 $user_feed->update_last_log(
1009 sprintf(
1010 // translators: %s an error message.
1011 __( 'Error: %s', 'friends' ),
1012 $meta->get_error_code() . ' ' . $meta->get_error_message()
1013 )
1014 );
1015 }
1016 return $meta;
1017 }
1018 $to = $meta['id'];
1019 $inbox = self::get_inbox_by_actor( $to );
1020 if ( is_wp_error( $inbox ) ) {
1021 return $inbox;
1022 }
1023 $actor = \get_author_posts_url( $user_id );
1024
1025 $activity = new \Activitypub\Activity\Activity();
1026 $activity->set_type( 'Follow' );
1027 $activity->set_to( null );
1028 $activity->set_cc( null );
1029 $activity->set_actor( $actor );
1030 $activity->set_object( $to );
1031 $activity->set_id( $actor . '#follow-' . \preg_replace( '~^https?://~', '', $to ) );
1032 $activity = $activity->to_json();
1033 $response = \Activitypub\safe_remote_post( $inbox, $activity, $user_id );
1034
1035 if ( $user_feed instanceof User_Feed ) {
1036 $user_feed->update_last_log(
1037 sprintf(
1038 // translators: %s is the response from the remote server.
1039 __( 'Sent follow request with response: %s', 'friends' ),
1040 wp_remote_retrieve_response_code( $response ) . ' ' . wp_remote_retrieve_response_message( $response )
1041 )
1042 );
1043 }
1044 }
1045
1046 /**
1047 * Prepare to unfollow the user via a scheduled event.
1048 *
1049 * @param User_Feed $user_feed The user feed.
1050 *
1051 * @return bool|WP_Error Whether the event was queued.
1052 */
1053 public function queue_unfollow_user( User_Feed $user_feed ) {
1054 if ( self::SLUG !== $user_feed->get_parser() ) {
1055 return false;
1056 }
1057
1058 $queued = $this->queue(
1059 'friends_feed_parser_activitypub_unfollow',
1060 array( $user_feed->get_url() ),
1061 'friends_feed_parser_activitypub_follow'
1062 );
1063
1064 if ( $queued ) {
1065 $user_feed->update_last_log( __( 'Queued unfollow request.', 'friends' ) );
1066 }
1067
1068 return $queued;
1069 }
1070
1071 /**
1072 * Unfllow a user via ActivityPub at a URL.
1073 *
1074 * @param string $url The url.
1075 * @param int $user_id The current user id.
1076 */
1077 public function activitypub_unfollow_user( $url, $user_id = null ) {
1078 if ( null === $user_id ) {
1079 $user_id = Friends::get_main_friend_user_id();
1080 }
1081 $meta = $this->get_metadata( $url );
1082 $user_feed = User_Feed::get_by_url( $url );
1083 if ( is_wp_error( $meta ) ) {
1084 if ( $user_feed instanceof User_Feed ) {
1085 $user_feed->update_last_log(
1086 sprintf(
1087 // translators: %s an error message.
1088 __( 'Error: %s', 'friends' ),
1089 $meta->get_error_code() . ' ' . $meta->get_error_message()
1090 )
1091 );
1092 }
1093 return $meta;
1094 }
1095 $to = $meta['id'];
1096 $inbox = self::get_inbox_by_actor( $to );
1097 if ( is_wp_error( $inbox ) ) {
1098 return $inbox;
1099 }
1100 $actor = \get_author_posts_url( $user_id );
1101
1102 $activity = new \Activitypub\Activity\Activity();
1103 $activity->set_type( 'Undo' );
1104 $activity->set_to( null );
1105 $activity->set_cc( null );
1106 $activity->set_actor( $actor );
1107 $activity->set_object(
1108 array(
1109 'type' => 'Follow',
1110 'actor' => $actor,
1111 'object' => $to,
1112 'id' => $to,
1113 )
1114 );
1115 $activity->set_id( $actor . '#unfollow-' . \preg_replace( '~^https?://~', '', $to ) );
1116 $activity = $activity->to_json();
1117 $response = \Activitypub\safe_remote_post( $inbox, $activity, $user_id );
1118
1119 $user_feed = User_Feed::get_by_url( $url );
1120 if ( $user_feed instanceof User_Feed ) {
1121 $user_feed->update_last_log(
1122 sprintf(
1123 // translators: %s is the response from the remote server.
1124 __( 'Sent unfollow request with response: %s', 'friends' ),
1125 wp_remote_retrieve_response_code( $response ) . ' ' . wp_remote_retrieve_response_message( $response )
1126 )
1127 );
1128 }
1129 }
1130
1131 public function get_possible_mentions() {
1132 static $users = null;
1133 if ( ! method_exists( '\Friends\User_Feed', 'get_by_parser' ) ) {
1134 return array();
1135 }
1136
1137 if ( is_null( $users ) || ! apply_filters( 'activitypub_cache_possible_friend_mentions', true ) ) {
1138 $feeds = User_Feed::get_by_parser( 'activitypub' );
1139 $users = array();
1140 foreach ( $feeds as $feed ) {
1141 $user = $feed->get_friend_user();
1142 $slug = sanitize_title( $user->user_nicename );
1143 $users[ '@' . $slug ] = $feed->get_url();
1144 }
1145 }
1146 return $users;
1147 }
1148
1149 /**
1150 * Extract the mentions from the post_content.
1151 *
1152 * @param array $mentions The already found mentions.
1153 * @param string $post_content The post content.
1154 * @return mixed The discovered mentions.
1155 */
1156 public function activitypub_extract_mentions( $mentions, $post_content ) {
1157 $users = $this->get_possible_mentions();
1158 preg_match_all( '/@(?:[a-zA-Z0-9_-]+)/', $post_content, $matches );
1159 foreach ( $matches[0] as $match ) {
1160 if ( isset( $users[ $match ] ) ) {
1161 $mentions[ $match ] = $users[ $match ];
1162 }
1163 }
1164 return $mentions;
1165 }
1166
1167 public function the_content( $the_content ) {
1168 $protected_tags = array();
1169 $the_content = preg_replace_callback(
1170 '#<a.*?href=[^>]+>.*?</a>#i',
1171 function ( $m ) use ( &$protected_tags ) {
1172 $c = count( $protected_tags );
1173 $protect = '!#!#PROTECT' . $c . '#!#!';
1174 $protected_tags[ $protect ] = $m[0];
1175 return $protect;
1176 },
1177 $the_content
1178 );
1179
1180 $the_content = \preg_replace_callback( '/@(?:[a-zA-Z0-9_-]+)/', array( $this, 'replace_with_links' ), $the_content );
1181
1182 $the_content = str_replace( array_keys( $protected_tags ), array_values( $protected_tags ), $the_content );
1183
1184 return $the_content;
1185 }
1186
1187 /**
1188 * Replace the mention with a link to the user.
1189 *
1190 * @param array $result The matched username.
1191 *
1192 * @return string The replaced username.
1193 */
1194 public function replace_with_links( array $result ) {
1195 $users = $this->get_possible_mentions();
1196 if ( ! isset( $users[ $result[0] ] ) ) {
1197 return $result[0];
1198 }
1199
1200 $metadata = $this->get_metadata( $users[ $result[0] ] );
1201 if ( is_wp_error( $metadata ) || empty( $metadata['url'] ) ) {
1202 return $result[0];
1203 }
1204
1205 $username = ltrim( $users[ $result[0] ], '@' );
1206 if ( ! empty( $metadata['name'] ) ) {
1207 $username = $metadata['name'];
1208 }
1209 if ( ! empty( $metadata['preferredUsername'] ) ) {
1210 $username = $metadata['preferredUsername'];
1211 }
1212 $username = '@<span>' . $username . '</span>';
1213 return \sprintf( '<a rel="mention" class="u-url mention" href="%s">%s</a>', $metadata['url'], $username );
1214 }
1215
1216 public function activitypub_save_settings( User $friend ) {
1217 if ( isset( $_POST['_wpnonce'] ) && wp_verify_nonce( $_POST['_wpnonce'], 'edit-friend-feeds-' . $friend->user_login ) ) {
1218
1219 if ( isset( $_POST['friends_show_replies'] ) && $_POST['friends_show_replies'] ) {
1220 $friend->update_user_option( 'activitypub_friends_show_replies', '1' );
1221 } else {
1222 $friend->delete_user_option( 'activitypub_friends_show_replies' );
1223 }
1224 }
1225 }
1226
1227 public function activitypub_settings( User $friend ) {
1228 $has_activitypub_feed = false;
1229 foreach ( $friend->get_active_feeds() as $feed ) {
1230 if ( 'activitypub' === $feed->get_parser() ) {
1231 $has_activitypub_feed = true;
1232 }
1233 }
1234
1235 if ( ! $has_activitypub_feed ) {
1236 return;
1237 }
1238
1239 ?>
1240 <tr>
1241 <th>ActivityPub</th>
1242 <td>
1243 <fieldset>
1244 <div>
1245 <input type="checkbox" name="friends_show_replies" id="friends_show_replies" value="1" <?php checked( '1', $friend->get_user_option( 'activitypub_friends_show_replies' ) ); ?> />
1246 <label for="friends_show_replies"><?php esc_html_e( "Don't hide @mentions of feeds you don't follow", 'friends' ); ?></label>
1247 </div>
1248 </fieldset>
1249 <p class="description">
1250 <?php
1251 esc_html_e( "Show ActivityPub '@mention' posts even when you do not follow the feed being mentioned.", 'friends' );
1252 ?>
1253 </p>
1254 </td>
1255 </tr>
1256 <?php
1257 }
1258
1259 /**
1260 * Apply the feed rules
1261 *
1262 * @param Feed_Item $item The feed item.
1263 * @param User_Feed $feed The feed object.
1264 * @param User $friend_user The friend user.
1265 * @return Feed_Item The modified feed item.
1266 */
1267 public function modify_incoming_item( Feed_Item $item, User_Feed $feed = null, User $friend_user = null ) {
1268 if ( ! $feed || 'activitypub' !== $feed->get_parser() ) {
1269 return $item;
1270 }
1271
1272 if ( ! $friend_user->get_user_option( 'activitypub_friends_show_replies' ) ) {
1273 $plain_text_content = \wp_strip_all_tags( $item->post_content );
1274 $possible_mentions = $this->get_possible_mentions();
1275
1276 $no_known_user_found = true;
1277 if ( preg_match( '/^@(?:[a-zA-Z0-9_.-]+)/i', $plain_text_content, $m ) ) {
1278 if ( ! isset( $possible_mentions[ $m[0] ] ) ) {
1279 $no_known_user_found = false;
1280 }
1281 }
1282
1283 if ( $no_known_user_found ) {
1284 if ( $friend_user && false !== strpos( $item->post_content, \get_author_posts_url( $friend_user->ID ) ) ) {
1285 $no_known_user_found = false;
1286 }
1287 }
1288
1289 if ( $no_known_user_found ) {
1290 foreach ( $possible_mentions as $username => $mention_url ) {
1291 if ( false !== strpos( $item->post_content, $mention_url ) ) {
1292 $no_known_user_found = false;
1293 break;
1294 }
1295 if ( false !== strpos( $username, $mention_url ) ) {
1296 $no_known_user_found = false;
1297 break;
1298 }
1299 }
1300 }
1301
1302 if ( ! $no_known_user_found ) {
1303 $item->_feed_rule_transform = array(
1304 'post_status' => 'trash',
1305 );
1306 }
1307 }
1308
1309 return $item;
1310 }
1311
1312 public function friends_potential_avatars( $avatars, User $friend_user ) {
1313 foreach ( $friend_user->get_feeds() as $user_feed ) {
1314 if ( 'activitypub' === $user_feed->get_parser() ) {
1315 $details = $this->update_feed_details(
1316 array(
1317 'url' => $user_feed->get_url(),
1318 )
1319 );
1320 if ( isset( $details['avatar'] ) ) {
1321 $avatars[ $details['avatar'] ] = sprintf(
1322 // translators: %s is a username.
1323 __( 'Avatar of %s', 'friends' ),
1324 $details['title']
1325 );
1326 }
1327 }
1328 }
1329 return $avatars;
1330 }
1331
1332 private function get_author_of_post( \WP_Post $post ) {
1333 $friend = User::get_post_author( $post );
1334 if ( ! $friend || is_wp_error( $friend ) ) {
1335 return $friend;
1336 }
1337
1338 $meta = get_post_meta( $post->ID, self::SLUG, true );
1339 if ( isset( $meta['attributedTo']['id'] ) ) {
1340 return $meta['attributedTo']['id'];
1341 }
1342
1343 $host = wp_parse_url( $post->guid, PHP_URL_HOST );
1344
1345 foreach ( $friend->get_active_feeds() as $feed ) {
1346 if ( 'activitypub' !== $feed->get_parser() ) {
1347 continue;
1348 }
1349
1350 $feed_host = wp_parse_url( $feed->get_url(), PHP_URL_HOST );
1351 if ( $feed_host !== $host ) {
1352 continue;
1353 }
1354
1355 return $feed->get_url();
1356 }
1357
1358 return null;
1359 }
1360
1361 /**
1362 * Create a status based on a post reaction.
1363 *
1364 * @param int $post_id The post ID.
1365 */
1366 public function post_reaction( $post_id ) {
1367 $post = get_post( $post_id );
1368 if ( ! $post ) {
1369 return;
1370 }
1371 $author_url = $this->get_author_of_post( $post );
1372 if ( ! $author_url || is_wp_error( $author_url ) ) {
1373 return;
1374 }
1375 return $this->queue_like_post( $post, $author_url );
1376 }
1377
1378 /**
1379 * Prepare to follow the user via a scheduled event.
1380 *
1381 * @param \WP_Post $post The post.
1382 * @param string $author_url The author url.
1383 *
1384 * @return bool|WP_Error Whether the event was queued.
1385 */
1386 public function queue_like_post( \WP_Post $post, $author_url ) {
1387 $external_post_id = get_post_meta( $post->ID, 'external-id', true );
1388 if ( ! $external_post_id ) {
1389 $external_post_id = $post->guid;
1390 }
1391
1392 $queued = $this->queue(
1393 'friends_feed_parser_activitypub_like',
1394 array( $author_url, $external_post_id, get_current_user_id() ),
1395 'friends_feed_parser_activitypub_unlike'
1396 );
1397
1398 return $queued;
1399 }
1400
1401 /**
1402 * Like a post.
1403 *
1404 * @param mixed $url The URL of the user.
1405 * @param mixed $external_post_id The post to like.
1406 * @param mixed $user_id The current user id.
1407 * @return void|WP_Error
1408 */
1409 public function activitypub_like_post( $url, $external_post_id, $user_id ) {
1410 $inbox = self::get_inbox_by_actor( $url );
1411 if ( is_wp_error( $inbox ) ) {
1412 return $inbox;
1413 }
1414 $actor = \get_author_posts_url( $user_id );
1415
1416 $activity = new \Activitypub\Activity\Activity();
1417 $activity->set_type( 'Like' );
1418 $activity->set_to( null );
1419 $activity->set_cc( null );
1420 $activity->set_actor( $actor );
1421 $activity->set_object( $external_post_id );
1422 $activity->set_id( $actor . '#like-' . \preg_replace( '~^https?://~', '', $external_post_id ) );
1423 $activity = $activity->to_json();
1424 $response = \Activitypub\safe_remote_post( $inbox, $activity, $user_id );
1425
1426 $user_feed = User_Feed::get_by_url( $url );
1427 if ( $user_feed instanceof User_Feed ) {
1428 $user_feed->update_last_log(
1429 sprintf(
1430 // translators: %s is the response from the remote server.
1431 __( 'Sent like request with response: %s', 'friends' ),
1432 wp_remote_retrieve_response_code( $response ) . ' ' . wp_remote_retrieve_response_message( $response )
1433 )
1434 );
1435 }
1436 }
1437
1438 /**
1439 * Create a status based on a post reaction.
1440 *
1441 * @param int $post_id The post ID.
1442 */
1443 public function undo_post_reaction( $post_id ) {
1444 $post = get_post( $post_id );
1445 if ( ! $post ) {
1446 return;
1447 }
1448 $author_url = $this->get_author_of_post( $post );
1449 if ( ! $author_url || is_wp_error( $author_url ) ) {
1450 return;
1451 }
1452 return $this->queue_unlike_post( $post, $author_url );
1453 }
1454
1455 /**
1456 * Prepare to follow the user via a scheduled event.
1457 *
1458 * @param \WP_Post $post The post.
1459 * @param string $author_url The author url.
1460 *
1461 * @return bool|WP_Error Whether the event was queued.
1462 */
1463 public function queue_unlike_post( \WP_Post $post, $author_url ) {
1464 $external_post_id = get_post_meta( $post->ID, 'external-id', true );
1465 if ( ! $external_post_id ) {
1466 $external_post_id = $post->guid;
1467 }
1468
1469 $queued = $this->queue(
1470 'friends_feed_parser_activitypub_unlike',
1471 array( $author_url, $external_post_id, get_current_user_id() ),
1472 'friends_feed_parser_activitypub_like'
1473 );
1474
1475 if ( $queued ) {
1476 $user_feed->update_last_log( __( 'Queued unlike request.', 'friends' ) );
1477 }
1478
1479 return $queued;
1480 }
1481
1482 /**
1483 * Unlike a post.
1484 *
1485 * @param mixed $url The URL of the user.
1486 * @param mixed $external_post_id The post to like.
1487 * @param mixed $user_id The current user id.
1488 * @return void|WP_Error
1489 */
1490 public function activitypub_unlike_post( $url, $external_post_id, $user_id ) {
1491 $inbox = self::get_inbox_by_actor( $url );
1492 if ( is_wp_error( $inbox ) ) {
1493 return $inbox;
1494 }
1495 $actor = \get_author_posts_url( $user_id );
1496
1497 $activity = new \Activitypub\Activity\Activity();
1498 $activity->set_type( 'Undo' );
1499 $activity->set_to( null );
1500 $activity->set_cc( null );
1501 $activity->set_actor( $actor );
1502 $activity->set_object(
1503 array(
1504 'type' => 'Like',
1505 'actor' => $actor,
1506 'object' => $external_post_id,
1507 'id' => $actor . '#like-' . \preg_replace( '~^https?://~', '', $external_post_id ),
1508 )
1509 );
1510 $activity->set_id( $actor . '#unlike-' . \preg_replace( '~^https?://~', '', $external_post_id ) );
1511 $activity = $activity->to_json();
1512 $response = \Activitypub\safe_remote_post( $inbox, $activity, $user_id );
1513
1514 $user_feed = User_Feed::get_by_url( $url );
1515 if ( $user_feed instanceof User_Feed ) {
1516 $user_feed->update_last_log(
1517 sprintf(
1518 // translators: %s is the response from the remote server.
1519 __( 'Sent unlike request with response: %s', 'friends' ),
1520 wp_remote_retrieve_response_code( $response ) . ' ' . wp_remote_retrieve_response_message( $response )
1521 )
1522 );
1523 }
1524 }
1525
1526 public function friends_reblog_button_label( $button_label ) {
1527 if ( User_Feed::get_parser_for_post_id( get_the_ID() ) === 'activitypub' ) {
1528 if ( get_user_option( 'friends_activitypub_dont_reblog' ) ) {
1529 $button_label = _x( 'Boost', 'button', 'friends' );
1530 } else {
1531 $button_label = _x( 'Reblog & Boost', 'button', 'friends' );
1532 }
1533 }
1534 return $button_label;
1535 }
1536
1537 public function mastodon_api_react( $post_id, $reaction ) {
1538 apply_filters( 'friends_react', null, $post_id, $reaction );
1539 }
1540
1541 public function mastodon_api_unreact( $post_id, $reaction ) {
1542 apply_filters( 'friends_unreact', null, $post_id, $reaction );
1543 }
1544
1545 public function mastodon_api_reblog( $post_id ) {
1546 apply_filters( 'friends_reblog', null, $post_id );
1547 }
1548
1549 public function mastodon_api_unreblog( $post_id ) {
1550 apply_filters( 'friends_unreblog', null, $post_id );
1551 }
1552
1553 /**
1554 * Don't create a note via ActivityPub since we'll use announce there.
1555 *
1556 * @param bool $ret The return value.
1557 *
1558 * @return bool The return value.
1559 */
1560 public function unqueue_activitypub_create( $ret ) {
1561 $hook = 'transition_post_status';
1562 $filter = array( '\Activitypub\Activitypub', 'schedule_post_activity' );
1563 $priority = has_filter( $hook, $filter );
1564 if ( $priority ) {
1565 remove_filter( $hook, $filter, $priority );
1566 }
1567 return $ret;
1568 }
1569
1570 public function maybe_unqueue_friends_reblog_post( $ret, $post ) {
1571 if ( ! get_user_option( 'friends_activitypub_dont_reblog' ) ) {
1572 return $ret;
1573 }
1574
1575 if ( User_Feed::get_parser_for_post_id( $post->ID ) !== 'activitypub' ) {
1576 return $ret;
1577 }
1578
1579 remove_filter( 'friends_reblog', array( 'Friends\Frontend', 'reblog' ), 10, 2 );
1580 return $ret;
1581 }
1582
1583 public function reblog( $ret, $post ) {
1584 $post = get_post( $post );
1585 if ( ! $post ) {
1586 return $ret;
1587 }
1588 if ( User_Feed::get_parser_for_post_id( $post->ID ) === 'activitypub' ) {
1589 $this->queue_announce( $post->guid );
1590 \update_post_meta( $post->ID, 'reblogged', 'activitypub' );
1591 \update_post_meta( $post->ID, 'reblogged_by', get_current_user_id() );
1592 return true;
1593 }
1594 return $ret;
1595 }
1596
1597 public function unreblog( $ret, $post ) {
1598 $post = get_post( $post );
1599 if ( ! $post ) {
1600 return $ret;
1601 }
1602 if ( get_post_meta( $post->ID, 'reblogged', true ) === 'activitypub' ) {
1603 $this->queue_unannounce( $post->guid );
1604 \delete_post_meta( $post->ID, 'reblogged', 'activitypub' );
1605 \delete_post_meta( $post->ID, 'reblogged_by', get_current_user_id() );
1606 return true;
1607 }
1608 return $ret;
1609 }
1610
1611 /**
1612 * Prepare to announce the post via a scheduled event.
1613 *
1614 * @param string $url The url to announce.
1615 *
1616 * @return bool|WP_Error Whether the event was queued.
1617 */
1618 public function queue_announce( $url ) {
1619 $queued = $this->queue(
1620 'friends_feed_parser_activitypub_announce',
1621 array( $url, get_current_user_id() ),
1622 'friends_feed_parser_activitypub_unannounce'
1623 );
1624
1625 return $queued;
1626 }
1627
1628 /**
1629 * Announce the post via ActivityPub.
1630 *
1631 * @param string $url The url.
1632 * @param id $user_id The user id.
1633 */
1634 public function activitypub_announce( $url, $user_id ) {
1635 $actor = \get_author_posts_url( $user_id );
1636
1637 $activity = new \Activitypub\Activity\Activity();
1638 $activity->set_type( 'Announce' );
1639 $activity->set_actor( $actor );
1640 $activity->set_object( $url );
1641
1642 $follower_inboxes = \Activitypub\Collection\Followers::get_inboxes( $user_id );
1643 $mentioned_inboxes = \Activitypub\Mention::get_inboxes( $activity->get_cc() );
1644
1645 $inboxes = array_merge( $follower_inboxes, $mentioned_inboxes );
1646 $inboxes = array_unique( $inboxes );
1647
1648 $json = $activity->to_json();
1649
1650 foreach ( $inboxes as $inbox ) {
1651 \Activitypub\safe_remote_post( $inbox, $json, $user_id );
1652 }
1653 }
1654
1655 /**
1656 * Prepare to announce the post via a scheduled event.
1657 *
1658 * @param string $url The url to announce.
1659 *
1660 * @return bool|WP_Error Whether the event was queued.
1661 */
1662 public function queue_unannounce( $url ) {
1663 $queued = $this->queue(
1664 'friends_feed_parser_activitypub_unannounce',
1665 array( $url, get_current_user_id() ),
1666 'friends_feed_parser_activitypub_announce'
1667 );
1668
1669 return $queued;
1670 }
1671
1672 /**
1673 * Unannounce the post via ActivityPub.
1674 *
1675 * @param string $url The url.
1676 * @param id $user_id The user id.
1677 */
1678 public function activitypub_unannounce( $url, $user_id ) {
1679 $actor = \get_author_posts_url( $user_id );
1680
1681 $activity = new \Activitypub\Activity\Activity();
1682 $activity->set_type( 'Undo' );
1683 $activity->set_to( null );
1684 $activity->set_cc( null );
1685 $activity->set_actor( $actor );
1686 $activity->set_id( $actor . '#activitypub_unannounce-' . \preg_replace( '~^https?://~', '', $url ) );
1687 $activity->set_object(
1688 array(
1689 'type' => 'Announce',
1690 'actor' => $actor,
1691 'object' => $url,
1692 'id' => $actor . '#activitypub_announce-' . \preg_replace( '~^https?://~', '', $url ),
1693 )
1694 );
1695
1696 $follower_inboxes = \Activitypub\Collection\Followers::get_inboxes( $user_id );
1697 $mentioned_inboxes = \Activitypub\Mention::get_inboxes( $activity->get_cc() );
1698
1699 $inboxes = array_merge( $follower_inboxes, $mentioned_inboxes );
1700 $inboxes = array_unique( $inboxes );
1701
1702 $json = $activity->to_json();
1703
1704 foreach ( $inboxes as $inbox ) {
1705 \Activitypub\safe_remote_post( $inbox, $json, $user_id );
1706 }
1707 }
1708
1709 /**
1710 * Approve comments that
1711 *
1712 * @param bool $approved Whether the comment is approved.
1713 * @param array $commentdata The commentdata.
1714 *
1715 * @return bool Whether the comment is approved.
1716 */
1717 public function pre_comment_approved( $approved, $commentdata ) {
1718 if ( ! $approved || is_string( $approved ) && 'activitypub' === $commentdata['comment_meta']['protocol'] ) {
1719 $user_feed = User_Feed::get_by_url( $commentdata['comment_author_url'] );
1720 if ( $user_feed instanceof User_Feed ) {
1721 $approved = true;
1722 }
1723 }
1724 return $approved;
1725 }
1726
1727 public function comment_post( $comment_id, $comment_approved, $commentdata ) {
1728 if ( isset( $commentdata['commentmeta']['protocol'] ) && 'activitypub' === $commentdata['commentmeta']['protocol'] ) {
1729 // Don't act upon incoming comments via ActivityPub.
1730 return;
1731 }
1732
1733 if ( empty( $commentdata['user_id'] ) ) {
1734 // Don't act on other people's comments.
1735 return;
1736 }
1737
1738 $user = new \WP_User( $commentdata['user_id'] );
1739 if ( User::is_friends_plugin_user( $user ) ) {
1740 // Don't act on non-local comments.
1741 return;
1742 }
1743
1744 // TODO: in the ActivityPub plugin, we should be able to use the comment_post hook to send out the comment.
1745 }
1746
1747 public function trashed_comment( $comment_id, $comment ) {
1748 if ( get_comment_meta( $comment_id, 'protocol', true ) === 'activitypub' ) {
1749 // Don't act on comments that came via ActivityPub.
1750 return;
1751 }
1752
1753 if ( empty( $comment->user_id ) ) {
1754 // Don't act on other people's comments.
1755 return;
1756 }
1757
1758 $user = new \WP_User( $comment->user_id );
1759 if ( User::is_friends_plugin_user( $user ) ) {
1760 // Don't act on non-local comments.
1761 return;
1762 }
1763
1764 // TODO: in the ActivityPub plugin, we should be able to use the trashed_comment hook to send out the comment deletion.
1765 }
1766
1767 /**
1768 * Disable Webfinger for example domains
1769 *
1770 * @param mixed $metadata Already retrieved metadata.
1771 * @param mixed $actor The actor as URL or username.
1772 * @return mixed The potentially added metadata for example domains.
1773 */
1774 public function disable_webfinger_for_example_domains( $metadata, $actor ) {
1775 if ( ! $metadata ) {
1776 $username = null;
1777 $domain = null;
1778 if ( preg_match( '/^@?' . self::ACTIVITYPUB_USERNAME_REGEXP . '$/i', $actor, $m ) ) {
1779 $username = $m[1];
1780 $domain = $m[2];
1781 } else {
1782 $p = wp_parse_url( $actor );
1783 if ( $p ) {
1784 if ( isset( $p['host'] ) ) {
1785 $domain = $p['host'];
1786 }
1787 if ( isset( $p['path'] ) ) {
1788 $path_parts = explode( '/', trim( $p['path'], '/' ) );
1789 $username = ltrim( array_pop( $path_parts ), '@' );
1790 }
1791 }
1792 }
1793 if ( preg_match( '/[^a-zA-Z0-9.-]/', $domain ) ) { // Has invalid characters.
1794 return $metadata;
1795 }
1796
1797 if (
1798 rtrim( strtok( $domain, '.' ), '0123456789' ) === 'example' // A classic example.org domain.
1799 || preg_match( '/(my|your|our)-(domain)/', $domain )
1800 || preg_match( '/(test)/', $domain )
1801 || in_array( $username, array( 'example' ), true )
1802 ) {
1803 $metadata = array(
1804 'url' => sprintf( 'https://%s/users/%s/', $domain, $username ),
1805 'name' => $username,
1806 );
1807 }
1808 }
1809 return $metadata;
1810 }
1811 }
1812