PluginProbe
ActivityPub / 5.7.0
ActivityPub v5.7.0
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / functions.php

functions.php in ActivityPub 5.7.0, at includes/functions.php

1,683 lines 43.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Functions file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use WP_Error;
11 use Activitypub\Activity\Activity;
12 use Activitypub\Activity\Actor;
13 use Activitypub\Activity\Base_Object;
14 use Activitypub\Collection\Actors;
15 use Activitypub\Collection\Outbox;
16 use Activitypub\Collection\Followers;
17 use Activitypub\Transformer\Post;
18 use Activitypub\Transformer\Factory as Transformer_Factory;
19
20 /**
21 * Returns the ActivityPub default JSON-context.
22 *
23 * @return array The activitypub context.
24 */
25 function get_context() {
26 $context = Activity::JSON_LD_CONTEXT;
27
28 /**
29 * Filters the ActivityPub JSON-LD context.
30 *
31 * This filter allows developers to modify or extend the JSON-LD context used
32 * in ActivityPub responses. The context defines the vocabulary and terms used
33 * in the ActivityPub JSON objects.
34 *
35 * @param array $context The default ActivityPub JSON-LD context array.
36 */
37 return \apply_filters( 'activitypub_json_context', $context );
38 }
39
40 /**
41 * Send a POST request to a remote server.
42 *
43 * @param string $url The URL endpoint.
44 * @param string $body The Post Body.
45 * @param int $user_id The WordPress user ID.
46 *
47 * @return array|WP_Error The POST Response or an WP_Error.
48 */
49 function safe_remote_post( $url, $body, $user_id ) {
50 return Http::post( $url, $body, $user_id );
51 }
52
53 /**
54 * Send a GET request to a remote server.
55 *
56 * @param string $url The URL endpoint.
57 *
58 * @return array|WP_Error The GET Response or an WP_Error.
59 */
60 function safe_remote_get( $url ) {
61 return Http::get( $url );
62 }
63
64 /**
65 * Returns a users WebFinger "resource".
66 *
67 * @param int $user_id The user ID.
68 *
69 * @return string The User resource.
70 */
71 function get_webfinger_resource( $user_id ) {
72 return Webfinger::get_user_resource( $user_id );
73 }
74
75 /**
76 * Requests the Meta-Data from the Actors profile.
77 *
78 * @param array|string $actor The Actor array or URL.
79 * @param bool $cached Optional. Whether the result should be cached. Default true.
80 *
81 * @return array|WP_Error The Actor profile as array or WP_Error on failure.
82 */
83 function get_remote_metadata_by_actor( $actor, $cached = true ) {
84 /**
85 * Filters the metadata before it is retrieved from a remote actor.
86 *
87 * Passing a non-false value will effectively short-circuit the remote request,
88 * returning that value instead.
89 *
90 * @param mixed $pre The value to return instead of the remote metadata.
91 * Default false to continue with the remote request.
92 * @param string $actor The actor URL.
93 */
94 $pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
95 if ( $pre ) {
96 return $pre;
97 }
98
99 return Http::get_remote_object( $actor, $cached );
100 }
101
102 /**
103 * Returns the followers of a given user.
104 *
105 * @param int $user_id The user ID.
106 *
107 * @return array The followers.
108 */
109 function get_followers( $user_id ) {
110 return Followers::get_followers( $user_id );
111 }
112
113 /**
114 * Count the number of followers for a given user.
115 *
116 * @param int $user_id The user ID.
117 *
118 * @return int The number of followers.
119 */
120 function count_followers( $user_id ) {
121 return Followers::count_followers( $user_id );
122 }
123
124 /**
125 * Examine a url and try to determine the author ID it represents.
126 *
127 * Checks are supposedly from the hosted site blog.
128 *
129 * @param string $url Permalink to check.
130 *
131 * @return int|null User ID, or null on failure.
132 */
133 function url_to_authorid( $url ) {
134 global $wp_rewrite;
135
136 // Check if url hase the same host.
137 $request_host = \wp_parse_url( $url, \PHP_URL_HOST );
138 if ( \wp_parse_url( \home_url(), \PHP_URL_HOST ) !== $request_host && get_option( 'activitypub_old_host' ) !== $request_host ) {
139 return null;
140 }
141
142 // First, check to see if there is an 'author=N' to match against.
143 if ( \preg_match( '/[?&]author=(\d+)/i', $url, $values ) ) {
144 return \absint( $values[1] );
145 }
146
147 // Check to see if we are using rewrite rules.
148 $rewrite = $wp_rewrite->wp_rewrite_rules();
149
150 // Not using rewrite rules, and 'author=N' method failed, so we're out of options.
151 if ( empty( $rewrite ) ) {
152 return null;
153 }
154
155 // Generate rewrite rule for the author url.
156 $author_rewrite = $wp_rewrite->get_author_permastruct();
157 $author_regexp = \str_replace( '%author%', '', $author_rewrite );
158
159 // Match the rewrite rule with the passed url.
160 if ( \preg_match( '/https?:\/\/(.+)' . \preg_quote( $author_regexp, '/' ) . '([^\/]+)/i', $url, $match ) ) {
161 $user = \get_user_by( 'slug', $match[2] );
162 if ( $user ) {
163 return $user->ID;
164 }
165 }
166
167 return null;
168 }
169
170 /**
171 * Verify that url is a wp_ap_comment or a previously received remote comment.
172 *
173 * @return int|bool Comment ID or false if not found.
174 */
175 function is_comment() {
176 $comment_id = get_query_var( 'c', null );
177
178 if ( ! is_null( $comment_id ) ) {
179 $comment = \get_comment( $comment_id );
180
181 if ( $comment ) {
182 return $comment_id;
183 }
184 }
185
186 return false;
187 }
188
189 /**
190 * Check for Tombstone Objects.
191 *
192 * @see https://www.w3.org/TR/activitypub/#delete-activity-outbox
193 *
194 * @param WP_Error $wp_error A WP_Error-Response of an HTTP-Request.
195 *
196 * @return boolean True if HTTP-Code is 410 or 404.
197 */
198 function is_tombstone( $wp_error ) {
199 if ( ! is_wp_error( $wp_error ) ) {
200 return false;
201 }
202
203 if ( in_array( (int) $wp_error->get_error_code(), array( 404, 410 ), true ) ) {
204 return true;
205 }
206
207 return false;
208 }
209
210 /**
211 * Get the REST URL relative to this plugin's namespace.
212 *
213 * @param string $path Optional. REST route path. Default ''.
214 *
215 * @return string REST URL relative to this plugin's namespace.
216 */
217 function get_rest_url_by_path( $path = '' ) {
218 // We'll handle the leading slash.
219 $path = ltrim( $path, '/' );
220 $namespaced_path = sprintf( '/%s/%s', ACTIVITYPUB_REST_NAMESPACE, $path );
221 return \get_rest_url( null, $namespaced_path );
222 }
223
224 /**
225 * Convert a string from camelCase to snake_case.
226 *
227 * @param string $input The string to convert.
228 *
229 * @return string The converted string.
230 */
231 function camel_to_snake_case( $input ) {
232 return strtolower( preg_replace( '/(?<!^)[A-Z]/', '_$0', $input ) );
233 }
234
235 /**
236 * Convert a string from snake_case to camelCase.
237 *
238 * @param string $input The string to convert.
239 *
240 * @return string The converted string.
241 */
242 function snake_to_camel_case( $input ) {
243 return lcfirst( str_replace( '_', '', ucwords( $input, '_' ) ) );
244 }
245
246 /**
247 * Escapes a Tag, to be used as a hashtag.
248 *
249 * @param string $input The string to escape.
250 *
251 * @return string The escaped hashtag.
252 */
253 function esc_hashtag( $input ) {
254
255 $hashtag = \wp_specialchars_decode( $input, ENT_QUOTES );
256 // Remove all characters that are not letters, numbers, or underscores.
257 $hashtag = \preg_replace( '/emoji-regex(*SKIP)(?!)|[^\p{L}\p{Nd}_]+/u', '_', $hashtag );
258
259 // Capitalize every letter that is preceded by an underscore.
260 $hashtag = preg_replace_callback(
261 '/_(.)/',
262 function ( $matches ) {
263 return strtoupper( $matches[1] );
264 },
265 $hashtag
266 );
267
268 // Add a hashtag to the beginning of the string.
269 $hashtag = ltrim( $hashtag, '#' );
270 $hashtag = '#' . $hashtag;
271
272 /**
273 * Allow defining your own custom hashtag generation rules.
274 *
275 * @param string $hashtag The hashtag to be returned.
276 * @param string $input The original string.
277 */
278 $hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $input );
279
280 return esc_html( $hashtag );
281 }
282
283 /**
284 * Check if a request is for an ActivityPub request.
285 *
286 * @return bool False by default.
287 */
288 function is_activitypub_request() {
289 return Query::get_instance()->is_activitypub_request();
290 }
291
292 /**
293 * Check if a post is disabled for ActivityPub.
294 *
295 * This function checks if the post type supports ActivityPub and if the post is set to be local.
296 *
297 * @param mixed $post The post object or ID.
298 *
299 * @return boolean True if the post is disabled, false otherwise.
300 */
301 function is_post_disabled( $post ) {
302 $post = \get_post( $post );
303 $disabled = false;
304
305 if ( ! $post ) {
306 return true;
307 }
308
309 $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
310
311 if (
312 ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL === $visibility ||
313 ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ||
314 ! \post_type_supports( $post->post_type, 'activitypub' ) ||
315 'private' === $post->post_status ||
316 ! empty( $post->post_password )
317 ) {
318 $disabled = true;
319 }
320
321 /**
322 * Allow plugins to disable posts for ActivityPub.
323 *
324 * @param boolean $disabled True if the post is disabled, false otherwise.
325 * @param \WP_Post $post The post object.
326 */
327 return \apply_filters( 'activitypub_is_post_disabled', $disabled, $post );
328 }
329
330 /**
331 * This function checks if a user is enabled for ActivityPub.
332 *
333 * @param int|string $user_id The user ID.
334 * @return boolean True if the user is enabled, false otherwise.
335 */
336 function user_can_activitypub( $user_id ) {
337 if ( ! is_numeric( $user_id ) ) {
338 return false;
339 }
340
341 switch ( $user_id ) {
342 case Actors::APPLICATION_USER_ID:
343 $enabled = true; // Application user is always enabled.
344 break;
345
346 case Actors::BLOG_USER_ID:
347 $enabled = ! is_user_type_disabled( 'blog' );
348 break;
349
350 default:
351 if ( ! \get_user_by( 'id', $user_id ) ) {
352 $enabled = false;
353 break;
354 }
355
356 if ( is_user_type_disabled( 'user' ) ) {
357 $enabled = false;
358 break;
359 }
360
361 $enabled = \user_can( $user_id, 'activitypub' );
362 }
363
364 /**
365 * Allow plugins to disable users for ActivityPub.
366 *
367 * @deprecated 5.7.0 Use the `activitypub_user_can_activitypub` filter instead.
368 *
369 * @param boolean $disabled True if the user is disabled, false otherwise.
370 * @param int $user_id The user ID.
371 */
372 $enabled = ! \apply_filters_deprecated( 'activitypub_is_user_disabled', array( ! $enabled, $user_id ), '5.7.0', 'activitypub_user_can_activitypub' );
373
374 /**
375 * Allow plugins to enable/disable users for ActivityPub.
376 *
377 * @param boolean $enabled True if the user is enabled, false otherwise.
378 * @param int $user_id The user ID.
379 */
380 return apply_filters( 'activitypub_user_can_activitypub', $enabled, $user_id );
381 }
382
383 /**
384 * This function checks if a user is disabled for ActivityPub.
385 *
386 * @deprecated 5.7.0 Use the `user_can_activitypub` function instead.
387 *
388 * @param int $user_id The user ID.
389 *
390 * @return boolean True if the user is disabled, false otherwise.
391 */
392 function is_user_disabled( $user_id ) {
393 _deprecated_function( __FUNCTION__, 'unreleased', 'user_can_activitypub' );
394
395 return ! user_can_activitypub( $user_id );
396 }
397
398 /**
399 * Checks if a User-Type is disabled for ActivityPub.
400 *
401 * This function is used to check if the 'blog' or 'user'
402 * type is disabled for ActivityPub.
403 *
404 * @param string $type User type. 'blog' or 'user'.
405 *
406 * @return boolean True if the user type is disabled, false otherwise.
407 */
408 function is_user_type_disabled( $type ) {
409 switch ( $type ) {
410 case 'blog':
411 if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
412 if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
413 $disabled = false;
414 break;
415 }
416 }
417
418 if ( \defined( 'ACTIVITYPUB_DISABLE_BLOG_USER' ) ) {
419 $disabled = ACTIVITYPUB_DISABLE_BLOG_USER;
420 break;
421 }
422
423 if ( ACTIVITYPUB_ACTOR_MODE === \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
424 $disabled = true;
425 break;
426 }
427
428 $disabled = false;
429 break;
430 case 'user':
431 if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
432 if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
433 $disabled = true;
434 break;
435 }
436 }
437
438 if ( \defined( 'ACTIVITYPUB_DISABLE_USER' ) ) {
439 $disabled = ACTIVITYPUB_DISABLE_USER;
440 break;
441 }
442
443 if ( ACTIVITYPUB_BLOG_MODE === \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
444 $disabled = true;
445 break;
446 }
447
448 $disabled = false;
449 break;
450 default:
451 $disabled = new WP_Error(
452 'activitypub_wrong_user_type',
453 __( 'Wrong user type', 'activitypub' ),
454 array( 'status' => 400 )
455 );
456 break;
457 }
458
459 /**
460 * Allow plugins to disable user types for ActivityPub.
461 *
462 * @param boolean $disabled True if the user type is disabled, false otherwise.
463 * @param string $type The User-Type.
464 */
465 return apply_filters( 'activitypub_is_user_type_disabled', $disabled, $type );
466 }
467
468 /**
469 * Check if the blog is in single-user mode.
470 *
471 * @return boolean True if the blog is in single-user mode, false otherwise.
472 */
473 function is_single_user() {
474 if (
475 false === is_user_type_disabled( 'blog' ) &&
476 true === is_user_type_disabled( 'user' )
477 ) {
478 return true;
479 }
480
481 return false;
482 }
483
484 /**
485 * Check if a site supports the block editor.
486 *
487 * @return boolean True if the site supports the block editor, false otherwise.
488 */
489 function site_supports_blocks() {
490 /**
491 * Allow plugins to disable block editor support,
492 * thus disabling blocks registered by the ActivityPub plugin.
493 *
494 * @param boolean $supports_blocks True if the site supports the block editor, false otherwise.
495 */
496 return apply_filters( 'activitypub_site_supports_blocks', true );
497 }
498
499 /**
500 * Check if data is valid JSON.
501 *
502 * @param string $data The data to check.
503 *
504 * @return boolean True if the data is JSON, false otherwise.
505 */
506 function is_json( $data ) {
507 return \is_array( \json_decode( $data, true ) ) ? true : false;
508 }
509
510 /**
511 * Check whether a blog is public based on the `blog_public` option.
512 *
513 * @return bool True if public, false if not
514 */
515 function is_blog_public() {
516 /**
517 * Filter whether the blog is public.
518 *
519 * @param bool $public Whether the blog is public.
520 */
521 return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) );
522 }
523
524 /**
525 * Extract recipient URLs from Activity object.
526 *
527 * @param array $data The Activity object as array.
528 *
529 * @return array The list of user URLs.
530 */
531 function extract_recipients_from_activity( $data ) {
532 $recipient_items = array();
533
534 foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) {
535 if ( array_key_exists( $i, $data ) ) {
536 if ( is_array( $data[ $i ] ) ) {
537 $recipient = $data[ $i ];
538 } else {
539 $recipient = array( $data[ $i ] );
540 }
541 $recipient_items = array_merge( $recipient_items, $recipient );
542 }
543
544 if ( is_array( $data['object'] ) && array_key_exists( $i, $data['object'] ) ) {
545 if ( is_array( $data['object'][ $i ] ) ) {
546 $recipient = $data['object'][ $i ];
547 } else {
548 $recipient = array( $data['object'][ $i ] );
549 }
550 $recipient_items = array_merge( $recipient_items, $recipient );
551 }
552 }
553
554 $recipients = array();
555
556 // Flatten array.
557 foreach ( $recipient_items as $recipient ) {
558 if ( is_array( $recipient ) ) {
559 // Check if recipient is an object.
560 if ( array_key_exists( 'id', $recipient ) ) {
561 $recipients[] = $recipient['id'];
562 }
563 } else {
564 $recipients[] = $recipient;
565 }
566 }
567
568 return array_unique( $recipients );
569 }
570
571 /**
572 * Check if passed Activity is Public.
573 *
574 * @param array $data The Activity object as array.
575 *
576 * @return boolean True if public, false if not.
577 */
578 function is_activity_public( $data ) {
579 $recipients = extract_recipients_from_activity( $data );
580
581 return in_array( 'https://www.w3.org/ns/activitystreams#Public', $recipients, true );
582 }
583
584 /**
585 * Check if passed Activity is a reply.
586 *
587 * @param array $data The Activity object as array.
588 *
589 * @return boolean True if a reply, false if not.
590 */
591 function is_activity_reply( $data ) {
592 return ! empty( $data['object']['inReplyTo'] );
593 }
594
595 /**
596 * Get active users based on a given duration.
597 *
598 * @param int $duration Optional. The duration to check in month(s). Default 1.
599 *
600 * @return int The number of active users.
601 */
602 function get_active_users( $duration = 1 ) {
603
604 $duration = intval( $duration );
605 $transient_key = sprintf( 'monthly_active_users_%d', $duration );
606 $count = get_transient( $transient_key );
607
608 if ( false === $count ) {
609 global $wpdb;
610
611 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
612 $count = $wpdb->get_var(
613 $wpdb->prepare(
614 "SELECT COUNT( DISTINCT post_author ) FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND post_date <= DATE_SUB( NOW(), INTERVAL %d MONTH )",
615 $duration
616 )
617 );
618
619 set_transient( $transient_key, $count, DAY_IN_SECONDS );
620 }
621
622 // If 0 authors where active.
623 if ( 0 === $count ) {
624 return 0;
625 }
626
627 // If single user mode.
628 if ( is_single_user() ) {
629 return 1;
630 }
631
632 // If blog user is disabled.
633 if ( ! user_can_activitypub( Actors::BLOG_USER_ID ) ) {
634 return (int) $count;
635 }
636
637 // Also count blog user.
638 return (int) $count + 1;
639 }
640
641 /**
642 * Get the total number of users.
643 *
644 * @return int The total number of users.
645 */
646 function get_total_users() {
647 // If single user mode.
648 if ( is_single_user() ) {
649 return 1;
650 }
651
652 $users = \get_users(
653 array(
654 'capability__in' => array( 'activitypub' ),
655 )
656 );
657
658 if ( is_array( $users ) ) {
659 $users = count( $users );
660 } else {
661 $users = 1;
662 }
663
664 // If blog user is disabled.
665 if ( ! user_can_activitypub( Actors::BLOG_USER_ID ) ) {
666 return (int) $users;
667 }
668
669 return (int) $users + 1;
670 }
671
672 /**
673 * Examine a comment ID and look up an existing comment it represents.
674 *
675 * @param string $id ActivityPub object ID (usually a URL) to check.
676 *
677 * @return \WP_Comment|boolean Comment, or false on failure.
678 */
679 function object_id_to_comment( $id ) {
680 return Comment::object_id_to_comment( $id );
681 }
682
683 /**
684 * Verify that URL is a local comment or a previously received remote comment.
685 * (For threading comments locally)
686 *
687 * @param string $url The URL to check.
688 *
689 * @return string|null Comment ID or null if not found
690 */
691 function url_to_commentid( $url ) {
692 return Comment::url_to_commentid( $url );
693 }
694
695 /**
696 * Get the URI of an ActivityPub object.
697 *
698 * @param array|string $data The ActivityPub object.
699 *
700 * @return string The URI of the ActivityPub object
701 */
702 function object_to_uri( $data ) {
703 // Check whether it is already simple.
704 if ( ! $data || is_string( $data ) ) {
705 return $data;
706 }
707
708 if ( is_object( $data ) ) {
709 $data = $data->to_array();
710 }
711
712 /*
713 * Check if it is a list, then take first item.
714 * This plugin does not support collections.
715 */
716 if ( array_is_list( $data ) ) {
717 $data = $data[0];
718 }
719
720 // Check if it is simplified now.
721 if ( is_string( $data ) ) {
722 return $data;
723 }
724
725 $type = 'Object';
726 if ( isset( $data['type'] ) ) {
727 $type = $data['type'];
728 }
729
730 // Return part of Object that makes most sense.
731 switch ( $type ) {
732 case 'Image':
733 $data = $data['url'];
734 break;
735 case 'Link':
736 $data = $data['href'];
737 break;
738 default:
739 $data = $data['id'];
740 break;
741 }
742
743 return $data;
744 }
745
746 /**
747 * Check if a comment should be federated.
748 *
749 * We consider a comment should be federated if it is authored by a user that is
750 * not disabled for federation and if it is a reply directly to the post or to a
751 * federated comment.
752 *
753 * @param mixed $comment Comment object or ID.
754 *
755 * @return boolean True if the comment should be federated, false otherwise.
756 */
757 function should_comment_be_federated( $comment ) {
758 return Comment::should_be_federated( $comment );
759 }
760
761 /**
762 * Check if a comment was federated.
763 *
764 * This function checks if a comment was federated via ActivityPub.
765 *
766 * @param mixed $comment Comment object or ID.
767 *
768 * @return boolean True if the comment was federated, false otherwise.
769 */
770 function was_comment_sent( $comment ) {
771 return Comment::was_sent( $comment );
772 }
773
774 /**
775 * Check if a comment is federated.
776 *
777 * We consider a comment federated if comment was received via ActivityPub.
778 *
779 * Use this function to check if it is comment that was received via ActivityPub.
780 *
781 * @param mixed $comment Comment object or ID.
782 *
783 * @return boolean True if the comment is federated, false otherwise.
784 */
785 function was_comment_received( $comment ) {
786 return Comment::was_received( $comment );
787 }
788
789 /**
790 * Check if a comment is local only.
791 *
792 * This function checks if a comment is local only and was not sent or received via ActivityPub.
793 *
794 * @param mixed $comment Comment object or ID.
795 *
796 * @return boolean True if the comment is local only, false otherwise.
797 */
798 function is_local_comment( $comment ) {
799 return Comment::is_local( $comment );
800 }
801
802 /**
803 * Mark a WordPress object as federated.
804 *
805 * @param \WP_Comment|\WP_Post $wp_object The WordPress object.
806 * @param string $state The state of the object.
807 */
808 function set_wp_object_state( $wp_object, $state ) {
809 $meta_key = 'activitypub_status';
810
811 if ( $wp_object instanceof \WP_Post ) {
812 \update_post_meta( $wp_object->ID, $meta_key, $state );
813 } elseif ( $wp_object instanceof \WP_Comment ) {
814 \update_comment_meta( $wp_object->comment_ID, $meta_key, $state );
815 } else {
816 /**
817 * Allow plugins to mark WordPress objects as federated.
818 *
819 * @param \WP_Comment|\WP_Post $wp_object The WordPress object.
820 */
821 \apply_filters( 'activitypub_mark_wp_object_as_federated', $wp_object );
822 }
823 }
824
825 /**
826 * Get the federation state of a WordPress object.
827 *
828 * @param \WP_Comment|\WP_Post $wp_object The WordPress object.
829 *
830 * @return string|false The state of the object or false if not found.
831 */
832 function get_wp_object_state( $wp_object ) {
833 $meta_key = 'activitypub_status';
834
835 if ( $wp_object instanceof \WP_Post ) {
836 return \get_post_meta( $wp_object->ID, $meta_key, true );
837 } elseif ( $wp_object instanceof \WP_Comment ) {
838 return \get_comment_meta( $wp_object->comment_ID, $meta_key, true );
839 } else {
840 /**
841 * Allow plugins to get the federation state of a WordPress object.
842 *
843 * @param false $state The state of the object.
844 * @param \WP_Comment|\WP_Post $wp_object The WordPress object.
845 */
846 return \apply_filters( 'activitypub_get_wp_object_state', false, $wp_object );
847 }
848 }
849
850 /**
851 * Get the description of a post type.
852 *
853 * Set some default descriptions for the default post types.
854 *
855 * @param \WP_Post_Type $post_type The post type object.
856 *
857 * @return string The description of the post type.
858 */
859 function get_post_type_description( $post_type ) {
860 $description = '';
861
862 switch ( $post_type->name ) {
863 case 'post':
864 $description = '';
865 break;
866 case 'page':
867 $description = '';
868 break;
869 case 'attachment':
870 $description = ' - ' . __( 'The attachments that you have uploaded to a post (images, videos, documents or other files).', 'activitypub' );
871 break;
872 default:
873 if ( ! empty( $post_type->description ) ) {
874 $description = ' - ' . $post_type->description;
875 }
876 }
877
878 /**
879 * Allow plugins to get the description of a post type.
880 *
881 * @param string $description The description of the post type.
882 * @param string $post_type_name The post type name.
883 * @param \WP_Post_Type $post_type The post type object.
884 */
885 return apply_filters( 'activitypub_post_type_description', $description, $post_type->name, $post_type );
886 }
887
888 /**
889 * Get the masked WordPress version to only show the major and minor version.
890 *
891 * @return string The masked version.
892 */
893 function get_masked_wp_version() {
894 // Only show the major and minor version.
895 $version = get_bloginfo( 'version' );
896 // Strip the RC or beta part.
897 $version = preg_replace( '/-.*$/', '', $version );
898 $version = explode( '.', $version );
899 $version = array_slice( $version, 0, 2 );
900
901 return implode( '.', $version );
902 }
903
904 /**
905 * Get the enclosures of a post.
906 *
907 * @param int $post_id The post ID.
908 *
909 * @return array The enclosures.
910 */
911 function get_enclosures( $post_id ) {
912 $enclosures = get_post_meta( $post_id, 'enclosure', false );
913
914 if ( ! $enclosures ) {
915 return array();
916 }
917
918 $enclosures = array_map(
919 function ( $enclosure ) {
920 // Check if the enclosure is a string.
921 if ( ! $enclosure || ! is_string( $enclosure ) ) {
922 return false;
923 }
924
925 $attributes = explode( "\n", $enclosure );
926
927 if ( ! isset( $attributes[0] ) || ! \wp_http_validate_url( $attributes[0] ) ) {
928 return false;
929 }
930
931 return array(
932 'url' => $attributes[0],
933 'length' => $attributes[1] ?? null,
934 'mediaType' => $attributes[2] ?? 'application/octet-stream',
935 );
936 },
937 $enclosures
938 );
939
940 return array_filter( $enclosures );
941 }
942
943 /**
944 * Retrieves the IDs of the ancestors of a comment.
945 *
946 * Adaption of `get_post_ancestors` from WordPress core.
947 *
948 * @see https://developer.wordpress.org/reference/functions/get_post_ancestors/
949 *
950 * @param int|\WP_Comment $comment Comment ID or comment object.
951 *
952 * @return int[] Array of ancestor IDs.
953 */
954 function get_comment_ancestors( $comment ) {
955 $comment = \get_comment( $comment );
956
957 if ( ! $comment || empty( $comment->comment_parent ) || (int) $comment->comment_parent === (int) $comment->comment_ID ) {
958 return array();
959 }
960
961 $ancestors = array();
962
963 $id = (int) $comment->comment_parent;
964 $ancestors[] = $id;
965
966 while ( $id > 0 ) {
967 $ancestor = \get_comment( $id );
968 $parent_id = (int) $ancestor->comment_parent;
969
970 // Loop detection: If the ancestor has been seen before, break.
971 if ( empty( $parent_id ) || ( $parent_id === (int) $comment->comment_ID ) || in_array( $parent_id, $ancestors, true ) ) {
972 break;
973 }
974
975 $id = $parent_id;
976 $ancestors[] = $id;
977 }
978
979 return $ancestors;
980 }
981
982 /**
983 * Change the display of large numbers on the site.
984 *
985 * @author Jeremy Herve
986 *
987 * @see https://wordpress.org/support/topic/abbreviate-numbers-with-k/
988 *
989 * @param string $formatted Converted number in string format.
990 * @param float $number The number to convert based on locale.
991 * @param int $decimals Precision of the number of decimal places.
992 *
993 * @return string Converted number in string format.
994 */
995 function custom_large_numbers( $formatted, $number, $decimals ) {
996 global $wp_locale;
997
998 $decimals = 0;
999 $decimal_point = '.';
1000 $thousands_sep = ',';
1001
1002 if ( isset( $wp_locale ) ) {
1003 $decimals = (int) $wp_locale->number_format['decimal_point'];
1004 $decimal_point = $wp_locale->number_format['decimal_point'];
1005 $thousands_sep = $wp_locale->number_format['thousands_sep'];
1006 }
1007
1008 if ( $number < 1000 ) { // Any number less than a Thousand.
1009 return \number_format( $number, $decimals, $decimal_point, $thousands_sep );
1010 } elseif ( $number < 1000000 ) { // Any number less than a million.
1011 return \number_format( $number / 1000, $decimals, $decimal_point, $thousands_sep ) . 'K';
1012 } elseif ( $number < 1000000000 ) { // Any number less than a billion.
1013 return \number_format( $number / 1000000, $decimals, $decimal_point, $thousands_sep ) . 'M';
1014 } else { // At least a billion.
1015 return \number_format( $number / 1000000000, $decimals, $decimal_point, $thousands_sep ) . 'B';
1016 }
1017
1018 // Default fallback. We should not get here.
1019 return $formatted;
1020 }
1021
1022 /**
1023 * Registers a ActivityPub comment type.
1024 *
1025 * @param string $comment_type Key for comment type.
1026 * @param array $args Optional. Array of arguments for registering a comment type. Default empty array.
1027 *
1028 * @return array The registered Activitypub comment type.
1029 */
1030 function register_comment_type( $comment_type, $args = array() ) {
1031 global $activitypub_comment_types;
1032
1033 if ( ! is_array( $activitypub_comment_types ) ) {
1034 $activitypub_comment_types = array();
1035 }
1036
1037 // Sanitize comment type name.
1038 $comment_type = sanitize_key( $comment_type );
1039
1040 $activitypub_comment_types[ $comment_type ] = $args;
1041
1042 /**
1043 * Fires after a ActivityPub comment type is registered.
1044 *
1045 * @param string $comment_type Comment type.
1046 * @param array $args Arguments used to register the comment type.
1047 */
1048 do_action( 'activitypub_registered_comment_type', $comment_type, $args );
1049
1050 return $args;
1051 }
1052
1053 /**
1054 * Normalize a URL.
1055 *
1056 * @param string $url The URL.
1057 *
1058 * @return string The normalized URL.
1059 */
1060 function normalize_url( $url ) {
1061 $url = \untrailingslashit( $url );
1062 $url = \str_replace( 'https://', '', $url );
1063 $url = \str_replace( 'http://', '', $url );
1064 $url = \str_replace( 'www.', '', $url );
1065
1066 return $url;
1067 }
1068
1069 /**
1070 * Normalize a host.
1071 *
1072 * @param string $host The host.
1073 *
1074 * @return string The normalized host.
1075 */
1076 function normalize_host( $host ) {
1077 return \str_replace( 'www.', '', $host );
1078 }
1079
1080 /**
1081 * Get the reply intent URI as a JavaScript URI.
1082 *
1083 * @return string The reply intent URI.
1084 */
1085 function get_reply_intent_js() {
1086 return sprintf(
1087 'javascript:(()=>{window.open(\'%s\'+encodeURIComponent(window.location.href));})();',
1088 get_reply_intent_url()
1089 );
1090 }
1091
1092 /**
1093 * Get the reply intent URI.
1094 *
1095 * @return string The reply intent URI.
1096 */
1097 function get_reply_intent_url() {
1098 /**
1099 * Filters the reply intent parameters.
1100 *
1101 * @param array $params The reply intent parameters.
1102 */
1103 $params = \apply_filters( 'activitypub_reply_intent_params', array() );
1104
1105 $params += array( 'in_reply_to' => '' );
1106 $query = \http_build_query( $params );
1107 $path = 'post-new.php?' . $query;
1108 $url = \admin_url( $path );
1109
1110 /**
1111 * Filters the reply intent URL.
1112 *
1113 * @param string $url The reply intent URL.
1114 */
1115 $url = \apply_filters( 'activitypub_reply_intent_url', $url );
1116
1117 return esc_url_raw( $url );
1118 }
1119
1120 /**
1121 * Replace content with links, mentions or hashtags by Regex callback and not affect protected tags.
1122 *
1123 * @param string $content The content that should be changed.
1124 * @param string $regex The regex to use.
1125 * @param callable $regex_callback Callback for replacement logic.
1126 *
1127 * @return string The content with links, mentions, hashtags, etc.
1128 */
1129 function enrich_content_data( $content, $regex, $regex_callback ) {
1130 // Small protection against execution timeouts: limit to 1 MB.
1131 if ( mb_strlen( $content ) > MB_IN_BYTES ) {
1132 return $content;
1133 }
1134 $tag_stack = array();
1135 $protected_tags = array(
1136 'pre',
1137 'code',
1138 'textarea',
1139 'style',
1140 'a',
1141 );
1142 $content_with_links = '';
1143 $in_protected_tag = false;
1144 foreach ( wp_html_split( $content ) as $chunk ) {
1145 if ( preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
1146 $content_with_links .= $chunk;
1147 continue;
1148 }
1149
1150 if ( preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
1151 $tag = strtolower( $m[2] );
1152 if ( '/' === $m[1] ) {
1153 // Closing tag.
1154 $i = array_search( $tag, $tag_stack, true );
1155 // We can only remove the tag from the stack if it is in the stack.
1156 if ( false !== $i ) {
1157 $tag_stack = array_slice( $tag_stack, 0, $i );
1158 }
1159 } else {
1160 // Opening tag, add it to the stack.
1161 $tag_stack[] = $tag;
1162 }
1163
1164 // If we're in a protected tag, the tag_stack contains at least one protected tag string.
1165 // The protected tag state can only change when we encounter a start or end tag.
1166 $in_protected_tag = array_intersect( $tag_stack, $protected_tags );
1167
1168 // Never inspect tags.
1169 $content_with_links .= $chunk;
1170 continue;
1171 }
1172
1173 if ( $in_protected_tag ) {
1174 // Don't inspect a chunk inside an inspected tag.
1175 $content_with_links .= $chunk;
1176 continue;
1177 }
1178
1179 // Only reachable when there is no protected tag in the stack.
1180 $content_with_links .= \preg_replace_callback( $regex, $regex_callback, $chunk );
1181 }
1182
1183 return $content_with_links;
1184 }
1185
1186 /**
1187 * Generate a summary of a post.
1188 *
1189 * This function generates a summary of a post by extracting:
1190 *
1191 * 1. The post excerpt if it exists.
1192 * 2. The first part of the post content if it contains the <!--more--> tag.
1193 * 3. An excerpt of the post content if it is longer than the specified length.
1194 *
1195 * @param int|\WP_Post $post The post ID or post object.
1196 * @param integer $length The maximum length of the summary.
1197 * Default is 500. It will be ignored if the post excerpt
1198 * and the content above the <!--more--> tag.
1199 *
1200 * @return string The generated post summary.
1201 */
1202 function generate_post_summary( $post, $length = 500 ) {
1203 $post = get_post( $post );
1204
1205 if ( ! $post ) {
1206 return '';
1207 }
1208
1209 $content = \sanitize_post_field( 'post_excerpt', $post->post_excerpt, $post->ID );
1210
1211 if ( $content ) {
1212 /** This filter is documented in wp-includes/post-template.php */
1213 return \apply_filters( 'the_excerpt', $content );
1214 }
1215
1216 $content = \sanitize_post_field( 'post_content', $post->post_content, $post->ID );
1217 $content_parts = \get_extended( $content );
1218
1219 /**
1220 * Filters the excerpt more value.
1221 *
1222 * @param string $excerpt_more The excerpt more.
1223 */
1224 $excerpt_more = \apply_filters( 'activitypub_excerpt_more', '[…]' );
1225 $length = $length - strlen( $excerpt_more );
1226
1227 // Check for the <!--more--> tag.
1228 if (
1229 ! empty( $content_parts['extended'] ) &&
1230 ! empty( $content_parts['main'] )
1231 ) {
1232 $content = $content_parts['main'] . ' ' . $excerpt_more;
1233 $length = null;
1234 }
1235
1236 $content = \html_entity_decode( $content );
1237 $content = \wp_strip_all_tags( $content );
1238 $content = \trim( $content );
1239 $content = \preg_replace( '/\R+/m', "\n\n", $content );
1240 $content = \preg_replace( '/[\r\t]/', '', $content );
1241
1242 if ( $length && \strlen( $content ) > $length ) {
1243 $content = \wordwrap( $content, $length, '</activitypub-summary>' );
1244 $content = \explode( '</activitypub-summary>', $content, 2 );
1245 $content = $content[0] . ' ' . $excerpt_more;
1246 }
1247
1248 /*
1249 Removed until this is merged: https://github.com/mastodon/mastodon/pull/28629
1250 /** This filter is documented in wp-includes/post-template.php
1251 return \apply_filters( 'the_excerpt', $content );
1252 */
1253 return $content;
1254 }
1255
1256 /**
1257 * Get the content warning of a post.
1258 *
1259 * @param int|\WP_Post $post_id The post ID or post object.
1260 *
1261 * @return string|false The content warning or false if not found.
1262 */
1263 function get_content_warning( $post_id ) {
1264 $post = get_post( $post_id );
1265 if ( ! $post ) {
1266 return false;
1267 }
1268
1269 $warning = get_post_meta( $post->ID, 'activitypub_content_warning', true );
1270 if ( empty( $warning ) ) {
1271 return false;
1272 }
1273
1274 return $warning;
1275 }
1276
1277 /**
1278 * Get the ActivityPub ID of a User by the WordPress User ID.
1279 *
1280 * @param int $id The WordPress User ID.
1281 *
1282 * @return string The ActivityPub ID (a URL) of the User.
1283 */
1284 function get_user_id( $id ) {
1285 $user = Actors::get_by_id( $id );
1286
1287 if ( ! $user ) {
1288 return false;
1289 }
1290
1291 return $user->get_id();
1292 }
1293
1294 /**
1295 * Get the ActivityPub ID of a Post by the WordPress Post ID.
1296 *
1297 * @param int $id The WordPress Post ID.
1298 *
1299 * @return string The ActivityPub ID (a URL) of the Post.
1300 */
1301 function get_post_id( $id ) {
1302 $post = get_post( $id );
1303
1304 if ( ! $post ) {
1305 return false;
1306 }
1307
1308 $transformer = new Post( $post );
1309 return $transformer->get_id();
1310 }
1311
1312 /**
1313 * Check if a URL is from the same domain as the site.
1314 *
1315 * @param string $url The URL to check.
1316 *
1317 * @return boolean True if the URL is from the same domain, false otherwise.
1318 */
1319 function is_same_domain( $url ) {
1320 $remote = \wp_parse_url( $url, PHP_URL_HOST );
1321
1322 if ( ! $remote ) {
1323 return false;
1324 }
1325
1326 $remote = normalize_host( $remote );
1327 $self = normalize_host( home_host() );
1328
1329 return $remote === $self;
1330 }
1331
1332 /**
1333 * Get the visibility of a post.
1334 *
1335 * @param int $post_id The post ID.
1336 *
1337 * @return string|false The visibility of the post or false if not found.
1338 */
1339 function get_content_visibility( $post_id ) {
1340 $post = get_post( $post_id );
1341 if ( ! $post ) {
1342 return false;
1343 }
1344
1345 $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
1346 $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
1347 $options = array(
1348 ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC,
1349 ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE,
1350 ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL,
1351 );
1352
1353 if ( in_array( $visibility, $options, true ) ) {
1354 $_visibility = $visibility;
1355 }
1356
1357 /**
1358 * Filters the visibility of a post.
1359 *
1360 * @param string $_visibility The visibility of the post. Possible values are:
1361 * - 'public': Post is public and federated.
1362 * - 'quiet_public': Post is public but not federated.
1363 * - 'local': Post is only visible locally.
1364 * @param \WP_Post $post The post object.
1365 */
1366 return \apply_filters( 'activitypub_content_visibility', $_visibility, $post );
1367 }
1368
1369 /**
1370 * Retrieves the Host for the current site where the front end is accessible.
1371 *
1372 * @return string The host for the current site.
1373 */
1374 function home_host() {
1375 return \wp_parse_url( \home_url(), PHP_URL_HOST );
1376 }
1377
1378 /**
1379 * Returns the website hosts allowed to credit this blog.
1380 *
1381 * @return array|null The attribution domains or null if not found.
1382 */
1383 function get_attribution_domains() {
1384 if ( '1' !== \get_option( 'activitypub_use_opengraph', '1' ) ) {
1385 return null;
1386 }
1387
1388 $domains = \get_option( 'activitypub_attribution_domains', home_host() );
1389 $domains = explode( PHP_EOL, $domains );
1390
1391 if ( ! $domains ) {
1392 $domains = null;
1393 }
1394
1395 return $domains;
1396 }
1397
1398 /**
1399 * Get the base URL for uploads.
1400 *
1401 * @return string The upload base URL.
1402 */
1403 function get_upload_baseurl() {
1404 /**
1405 * Early filter to allow plugins to set the upload base URL.
1406 *
1407 * @param string|false $maybe_upload_dir The upload base URL or false if not set.
1408 */
1409 $maybe_upload_dir = apply_filters( 'pre_activitypub_get_upload_baseurl', false );
1410 if ( false !== $maybe_upload_dir ) {
1411 return $maybe_upload_dir;
1412 }
1413
1414 $upload_dir = \wp_get_upload_dir();
1415
1416 /**
1417 * Filters the upload base URL.
1418 *
1419 * @param string $upload_dir The upload base URL. Default \wp_get_upload_dir()['baseurl']
1420 */
1421 return apply_filters( 'activitypub_get_upload_baseurl', $upload_dir['baseurl'] );
1422 }
1423
1424 /**
1425 * Check if Authorized-Fetch is enabled.
1426 *
1427 * @see https://docs.joinmastodon.org/admin/config/#authorized_fetch
1428 *
1429 * @return boolean True if Authorized-Fetch is enabled, false otherwise.
1430 */
1431 function use_authorized_fetch() {
1432 $use = (bool) \get_option( 'activitypub_authorized_fetch' );
1433
1434 /**
1435 * Filters whether to use Authorized-Fetch.
1436 *
1437 * @param boolean $use_authorized_fetch True if Authorized-Fetch is enabled, false otherwise.
1438 */
1439 return apply_filters( 'activitypub_use_authorized_fetch', $use );
1440 }
1441
1442 /**
1443 * Check if an ID is from the same domain as the site.
1444 *
1445 * @param string $id The ID URI to check.
1446 *
1447 * @return boolean True if the ID is a self-pint, false otherwise.
1448 */
1449 function is_self_ping( $id ) {
1450 $query_string = \wp_parse_url( $id, PHP_URL_QUERY );
1451
1452 if ( ! $query_string ) {
1453 return false;
1454 }
1455
1456 $query = array();
1457 \parse_str( $query_string, $query );
1458
1459 if (
1460 is_same_domain( $id ) &&
1461 in_array( 'c', array_keys( $query ), true )
1462 ) {
1463 return true;
1464 }
1465
1466 return false;
1467 }
1468
1469 /**
1470 * Add an object to the outbox.
1471 *
1472 * @param mixed $data The object to add to the outbox.
1473 * @param string|null $activity_type Optional. The type of the Activity or null if `$data` is an Activity. Default null.
1474 * @param integer $user_id Optional. The User-ID. Default 0.
1475 * @param string $content_visibility Optional. The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`. Default null.
1476 *
1477 * @return boolean|int The ID of the outbox item or false on failure.
1478 */
1479 function add_to_outbox( $data, $activity_type = null, $user_id = 0, $content_visibility = null ) {
1480 $transformer = Transformer_Factory::get_transformer( $data );
1481
1482 if ( ! $transformer || is_wp_error( $transformer ) ) {
1483 return false;
1484 }
1485
1486 if ( $content_visibility ) {
1487 $transformer->set_content_visibility( $content_visibility );
1488 } else {
1489 $content_visibility = $transformer->get_content_visibility();
1490 }
1491
1492 if ( $activity_type ) {
1493 $activity = $transformer->to_activity( $activity_type );
1494 } else {
1495 $activity = $transformer->to_object();
1496 }
1497
1498 if ( ! $activity || \is_wp_error( $activity ) ) {
1499 return false;
1500 }
1501
1502 // If the user is disabled, fall back to the blog user when available.
1503 if ( ! user_can_activitypub( $user_id ) ) {
1504 if ( user_can_activitypub( Actors::BLOG_USER_ID ) ) {
1505 $user_id = Actors::BLOG_USER_ID;
1506 } else {
1507 return false;
1508 }
1509 }
1510
1511 $outbox_activity_id = Outbox::add( $activity, $user_id, $content_visibility );
1512
1513 if ( ! $outbox_activity_id ) {
1514 return false;
1515 }
1516
1517 /**
1518 * Action triggered after an object has been added to the outbox.
1519 *
1520 * @param int $outbox_activity_id The ID of the outbox item.
1521 * @param Activity $activity The activity object.
1522 * @param int $user_id The User-ID.
1523 * @param string $content_visibility The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`.
1524 */
1525 \do_action( 'post_activitypub_add_to_outbox', $outbox_activity_id, $activity, $user_id, $content_visibility );
1526
1527 set_wp_object_state( $data, 'federated' );
1528
1529 return $outbox_activity_id;
1530 }
1531
1532 /**
1533 * Check if an `$data` is an Activity.
1534 *
1535 * @see https://www.w3.org/ns/activitystreams#activities
1536 *
1537 * @param array|object|string $data The data to check.
1538 *
1539 * @return boolean True if the `$data` is an Activity, false otherwise.
1540 */
1541 function is_activity( $data ) {
1542 /**
1543 * Filters the activity types.
1544 *
1545 * @param array $types The activity types.
1546 */
1547 $types = apply_filters( 'activitypub_activity_types', Activity::TYPES );
1548
1549 if ( is_string( $data ) ) {
1550 return in_array( $data, $types, true );
1551 }
1552
1553 if ( is_array( $data ) && isset( $data['type'] ) ) {
1554 return in_array( $data['type'], $types, true );
1555 }
1556
1557 if ( is_object( $data ) && $data instanceof Base_Object ) {
1558 return in_array( $data->get_type(), $types, true );
1559 }
1560
1561 return false;
1562 }
1563
1564 /**
1565 * Check if an `$data` is an Actor.
1566 *
1567 * @see https://www.w3.org/ns/activitystreams#actor
1568 *
1569 * @param array|object|string $data The data to check.
1570 *
1571 * @return boolean True if the `$data` is an Actor, false otherwise.
1572 */
1573 function is_actor( $data ) {
1574 /**
1575 * Filters the actor types.
1576 *
1577 * @param array $types The actor types.
1578 */
1579 $types = apply_filters( 'activitypub_actor_types', Actor::TYPES );
1580
1581 if ( is_string( $data ) ) {
1582 return in_array( $data, $types, true );
1583 }
1584
1585 if ( is_array( $data ) && isset( $data['type'] ) ) {
1586 return in_array( $data['type'], $types, true );
1587 }
1588
1589 if ( is_object( $data ) && $data instanceof Base_Object ) {
1590 return in_array( $data->get_type(), $types, true );
1591 }
1592
1593 return false;
1594 }
1595
1596 /**
1597 * Get an ActivityPub embed HTML for a URL.
1598 *
1599 * @param string $url The URL to get the embed for.
1600 * @param boolean $inline_css Whether to inline CSS. Default true.
1601 *
1602 * @return string|false The embed HTML or false if not found.
1603 */
1604 function get_embed_html( $url, $inline_css = true ) {
1605 // Try to get ActivityPub representation.
1606 $object = Http::get_remote_object( $url );
1607 if ( is_wp_error( $object ) ) {
1608 return false;
1609 }
1610
1611 $author_name = $object['attributedTo'] ?? '';
1612 $avatar_url = $object['icon']['url'] ?? '';
1613 $author_url = $author_name;
1614
1615 // If we don't have an avatar URL but we have an author URL, try to fetch it.
1616 if ( ! $avatar_url && $author_url ) {
1617 $author = Http::get_remote_object( $author_url );
1618 if ( ! is_wp_error( $author ) ) {
1619 $avatar_url = $author['icon']['url'] ?? '';
1620 $author_name = $author['name'] ?? $author_name;
1621 }
1622 }
1623
1624 // Create Webfinger where not found.
1625 if ( empty( $author['webfinger'] ) ) {
1626 if ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
1627 // Construct webfinger-style identifier from username and domain.
1628 $domain = wp_parse_url( $author['url'], PHP_URL_HOST );
1629 $author['webfinger'] = '@' . $author['preferredUsername'] . '@' . $domain;
1630 } else {
1631 // Fallback to URL.
1632 $author['webfinger'] = $author_url;
1633 }
1634 }
1635
1636 $title = $object['name'] ?? '';
1637 $content = $object['content'] ?? '';
1638 $published = isset( $object['published'] ) ? gmdate( get_option( 'date_format' ) . ', ' . get_option( 'time_format' ), strtotime( $object['published'] ) ) : '';
1639 $boosts = isset( $object['shares']['totalItems'] ) ? (int) $object['shares']['totalItems'] : null;
1640 $favorites = isset( $object['likes']['totalItems'] ) ? (int) $object['likes']['totalItems'] : null;
1641
1642 $image = '';
1643 if ( isset( $object['image']['url'] ) ) {
1644 $image = $object['image']['url'];
1645 } elseif ( isset( $object['attachment'] ) ) {
1646 foreach ( $object['attachment'] as $attachment ) {
1647 if ( isset( $attachment['type'] ) && in_array( $attachment['type'], array( 'Image', 'Document' ), true ) ) {
1648 $image = $attachment['url'];
1649 break;
1650 }
1651 }
1652 }
1653
1654 ob_start();
1655 load_template(
1656 ACTIVITYPUB_PLUGIN_DIR . 'templates/reply-embed.php',
1657 false,
1658 array(
1659 'author_name' => $author_name,
1660 'author_url' => $author_url,
1661 'avatar_url' => $avatar_url,
1662 'published' => $published,
1663 'title' => $title,
1664 'content' => $content,
1665 'image' => $image,
1666 'boosts' => $boosts,
1667 'favorites' => $favorites,
1668 'url' => $url,
1669 'webfinger' => $author['webfinger'],
1670 )
1671 );
1672
1673 if ( $inline_css ) {
1674 // Grab the CSS.
1675 $css = \file_get_contents( ACTIVITYPUB_PLUGIN_DIR . 'assets/css/activitypub-embed.css' ); // phpcs:ignore
1676 // We embed CSS directly because this may be in an iframe.
1677 printf( '<style>%s</style>', $css ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1678 }
1679
1680 // A little light whitespace cleanup.
1681 return preg_replace( '/\s+/', ' ', ob_get_clean() );
1682 }
1683