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

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