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

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