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

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