PluginProbe
ActivityPub / 4.2.1
ActivityPub v4.2.1
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 4.2.1, at includes/functions.php

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