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

1,532 lines 37.3 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 // Check if the enclosure is a string.
1010 if ( ! $enclosure || ! is_string( $enclosure ) ) {
1011 return false;
1012 }
1013
1014 $attributes = explode( "\n", $enclosure );
1015
1016 if ( ! isset( $attributes[0] ) || ! \wp_http_validate_url( $attributes[0] ) ) {
1017 return false;
1018 }
1019
1020 return array(
1021 'url' => $attributes[0],
1022 'length' => $attributes[1] ?? null,
1023 'mediaType' => $attributes[2] ?? 'application/octet-stream',
1024 );
1025 },
1026 $enclosures
1027 );
1028
1029 return array_filter( $enclosures );
1030 }
1031
1032 /**
1033 * Retrieves the IDs of the ancestors of a comment.
1034 *
1035 * Adaption of `get_post_ancestors` from WordPress core.
1036 *
1037 * @see https://developer.wordpress.org/reference/functions/get_post_ancestors/
1038 *
1039 * @param int|\WP_Comment $comment Comment ID or comment object.
1040 *
1041 * @return \WP_Comment[] Array of ancestor comments or empty array if there are none.
1042 */
1043 function get_comment_ancestors( $comment ) {
1044 $comment = \get_comment( $comment );
1045
1046 if ( ! $comment || empty( $comment->comment_parent ) || (int) $comment->comment_parent === (int) $comment->comment_ID ) {
1047 return array();
1048 }
1049
1050 $ancestors = array();
1051
1052 $id = (int) $comment->comment_parent;
1053 $ancestors[] = $id;
1054
1055 while ( $id > 0 ) {
1056 $ancestor = \get_comment( $id );
1057 $parent_id = (int) $ancestor->comment_parent;
1058
1059 // Loop detection: If the ancestor has been seen before, break.
1060 if ( empty( $parent_id ) || ( $parent_id === (int) $comment->comment_ID ) || in_array( $parent_id, $ancestors, true ) ) {
1061 break;
1062 }
1063
1064 $id = $parent_id;
1065 $ancestors[] = $id;
1066 }
1067
1068 return $ancestors;
1069 }
1070
1071 /**
1072 * Change the display of large numbers on the site.
1073 *
1074 * @author Jeremy Herve
1075 *
1076 * @see https://wordpress.org/support/topic/abbreviate-numbers-with-k/
1077 *
1078 * @param string $formatted Converted number in string format.
1079 * @param float $number The number to convert based on locale.
1080 * @param int $decimals Precision of the number of decimal places.
1081 *
1082 * @return string Converted number in string format.
1083 */
1084 function custom_large_numbers( $formatted, $number, $decimals ) {
1085 global $wp_locale;
1086
1087 $decimals = 0;
1088 $decimal_point = '.';
1089 $thousands_sep = ',';
1090
1091 if ( isset( $wp_locale ) ) {
1092 $decimals = (int) $wp_locale->number_format['decimal_point'];
1093 $decimal_point = $wp_locale->number_format['decimal_point'];
1094 $thousands_sep = $wp_locale->number_format['thousands_sep'];
1095 }
1096
1097 if ( $number < 1000 ) { // Any number less than a Thousand.
1098 return \number_format( $number, $decimals, $decimal_point, $thousands_sep );
1099 } elseif ( $number < 1000000 ) { // Any number less than a million.
1100 return \number_format( $number / 1000, $decimals, $decimal_point, $thousands_sep ) . 'K';
1101 } elseif ( $number < 1000000000 ) { // Any number less than a billion.
1102 return \number_format( $number / 1000000, $decimals, $decimal_point, $thousands_sep ) . 'M';
1103 } else { // At least a billion.
1104 return \number_format( $number / 1000000000, $decimals, $decimal_point, $thousands_sep ) . 'B';
1105 }
1106
1107 // Default fallback. We should not get here.
1108 return $formatted;
1109 }
1110
1111 /**
1112 * Registers a ActivityPub comment type.
1113 *
1114 * @param string $comment_type Key for comment type.
1115 * @param array $args Optional. Array of arguments for registering a comment type. Default empty array.
1116 *
1117 * @return array The registered Activitypub comment type.
1118 */
1119 function register_comment_type( $comment_type, $args = array() ) {
1120 global $activitypub_comment_types;
1121
1122 if ( ! is_array( $activitypub_comment_types ) ) {
1123 $activitypub_comment_types = array();
1124 }
1125
1126 // Sanitize comment type name.
1127 $comment_type = sanitize_key( $comment_type );
1128
1129 $activitypub_comment_types[ $comment_type ] = $args;
1130
1131 /**
1132 * Fires after a ActivityPub comment type is registered.
1133 *
1134 * @param string $comment_type Comment type.
1135 * @param array $args Arguments used to register the comment type.
1136 */
1137 do_action( 'activitypub_registered_comment_type', $comment_type, $args );
1138
1139 return $args;
1140 }
1141
1142 /**
1143 * Normalize a URL.
1144 *
1145 * @param string $url The URL.
1146 *
1147 * @return string The normalized URL.
1148 */
1149 function normalize_url( $url ) {
1150 $url = \untrailingslashit( $url );
1151 $url = \str_replace( 'https://', '', $url );
1152 $url = \str_replace( 'http://', '', $url );
1153 $url = \str_replace( 'www.', '', $url );
1154
1155 return $url;
1156 }
1157
1158 /**
1159 * Normalize a host.
1160 *
1161 * @param string $host The host.
1162 *
1163 * @return string The normalized host.
1164 */
1165 function normalize_host( $host ) {
1166 return \str_replace( 'www.', '', $host );
1167 }
1168
1169 /**
1170 * Get the reply intent URI as a JavaScript URI.
1171 *
1172 * @return string The reply intent URI.
1173 */
1174 function get_reply_intent_js() {
1175 return sprintf(
1176 'javascript:(()=>{window.open(\'%s\'+encodeURIComponent(window.location.href));})();',
1177 get_reply_intent_url()
1178 );
1179 }
1180
1181 /**
1182 * Get the reply intent URI.
1183 *
1184 * @return string The reply intent URI.
1185 */
1186 function get_reply_intent_url() {
1187 /**
1188 * Filters the reply intent parameters.
1189 *
1190 * @param array $params The reply intent parameters.
1191 */
1192 $params = \apply_filters( 'activitypub_reply_intent_params', array() );
1193
1194 $params += array( 'in_reply_to' => '' );
1195 $query = \http_build_query( $params );
1196 $path = 'post-new.php?' . $query;
1197 $url = \admin_url( $path );
1198
1199 /**
1200 * Filters the reply intent URL.
1201 *
1202 * @param string $url The reply intent URL.
1203 */
1204 $url = \apply_filters( 'activitypub_reply_intent_url', $url );
1205
1206 return esc_url_raw( $url );
1207 }
1208
1209 /**
1210 * Replace content with links, mentions or hashtags by Regex callback and not affect protected tags.
1211 *
1212 * @param string $content The content that should be changed.
1213 * @param string $regex The regex to use.
1214 * @param callable $regex_callback Callback for replacement logic.
1215 *
1216 * @return string The content with links, mentions, hashtags, etc.
1217 */
1218 function enrich_content_data( $content, $regex, $regex_callback ) {
1219 // Small protection against execution timeouts: limit to 1 MB.
1220 if ( mb_strlen( $content ) > MB_IN_BYTES ) {
1221 return $content;
1222 }
1223 $tag_stack = array();
1224 $protected_tags = array(
1225 'pre',
1226 'code',
1227 'textarea',
1228 'style',
1229 'a',
1230 );
1231 $content_with_links = '';
1232 $in_protected_tag = false;
1233 foreach ( wp_html_split( $content ) as $chunk ) {
1234 if ( preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
1235 $content_with_links .= $chunk;
1236 continue;
1237 }
1238
1239 if ( preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
1240 $tag = strtolower( $m[2] );
1241 if ( '/' === $m[1] ) {
1242 // Closing tag.
1243 $i = array_search( $tag, $tag_stack, true );
1244 // We can only remove the tag from the stack if it is in the stack.
1245 if ( false !== $i ) {
1246 $tag_stack = array_slice( $tag_stack, 0, $i );
1247 }
1248 } else {
1249 // Opening tag, add it to the stack.
1250 $tag_stack[] = $tag;
1251 }
1252
1253 // If we're in a protected tag, the tag_stack contains at least one protected tag string.
1254 // The protected tag state can only change when we encounter a start or end tag.
1255 $in_protected_tag = array_intersect( $tag_stack, $protected_tags );
1256
1257 // Never inspect tags.
1258 $content_with_links .= $chunk;
1259 continue;
1260 }
1261
1262 if ( $in_protected_tag ) {
1263 // Don't inspect a chunk inside an inspected tag.
1264 $content_with_links .= $chunk;
1265 continue;
1266 }
1267
1268 // Only reachable when there is no protected tag in the stack.
1269 $content_with_links .= \preg_replace_callback( $regex, $regex_callback, $chunk );
1270 }
1271
1272 return $content_with_links;
1273 }
1274
1275 /**
1276 * Generate a summary of a post.
1277 *
1278 * This function generates a summary of a post by extracting:
1279 *
1280 * 1. The post excerpt if it exists.
1281 * 2. The first part of the post content if it contains the <!--more--> tag.
1282 * 3. An excerpt of the post content if it is longer than the specified length.
1283 *
1284 * @param int|\WP_Post $post The post ID or post object.
1285 * @param integer $length The maximum length of the summary.
1286 * Default is 500. It will be ignored if the post excerpt
1287 * and the content above the <!--more--> tag.
1288 *
1289 * @return string The generated post summary.
1290 */
1291 function generate_post_summary( $post, $length = 500 ) {
1292 $post = get_post( $post );
1293
1294 if ( ! $post ) {
1295 return '';
1296 }
1297
1298 $content = \sanitize_post_field( 'post_excerpt', $post->post_excerpt, $post->ID );
1299
1300 if ( $content ) {
1301 /**
1302 * Filters the post excerpt.
1303 *
1304 * @param string $content The post excerpt.
1305 */
1306 return \apply_filters( 'the_excerpt', $content );
1307 }
1308
1309 $content = \sanitize_post_field( 'post_content', $post->post_content, $post->ID );
1310 $content_parts = \get_extended( $content );
1311
1312 /**
1313 * Filters the excerpt more value.
1314 *
1315 * @param string $excerpt_more The excerpt more.
1316 */
1317 $excerpt_more = \apply_filters( 'activitypub_excerpt_more', '[…]' );
1318 $length = $length - strlen( $excerpt_more );
1319
1320 // Check for the <!--more--> tag.
1321 if (
1322 ! empty( $content_parts['extended'] ) &&
1323 ! empty( $content_parts['main'] )
1324 ) {
1325 $content = $content_parts['main'] . ' ' . $excerpt_more;
1326 $length = null;
1327 }
1328
1329 $content = \html_entity_decode( $content );
1330 $content = \wp_strip_all_tags( $content );
1331 $content = \trim( $content );
1332 $content = \preg_replace( '/\R+/m', "\n\n", $content );
1333 $content = \preg_replace( '/[\r\t]/', '', $content );
1334
1335 if ( $length && \strlen( $content ) > $length ) {
1336 $content = \wordwrap( $content, $length, '</activitypub-summary>' );
1337 $content = \explode( '</activitypub-summary>', $content, 2 );
1338 $content = $content[0] . ' ' . $excerpt_more;
1339 }
1340
1341 /*
1342 Removed until this is merged: https://github.com/mastodon/mastodon/pull/28629
1343 return \apply_filters( 'the_excerpt', $content );
1344 */
1345 return $content;
1346 }
1347
1348 /**
1349 * Get the content warning of a post.
1350 *
1351 * @param int|\WP_Post $post_id The post ID or post object.
1352 *
1353 * @return string|false The content warning or false if not found.
1354 */
1355 function get_content_warning( $post_id ) {
1356 $post = get_post( $post_id );
1357 if ( ! $post ) {
1358 return false;
1359 }
1360
1361 $warning = get_post_meta( $post->ID, 'activitypub_content_warning', true );
1362 if ( empty( $warning ) ) {
1363 return false;
1364 }
1365
1366 return $warning;
1367 }
1368
1369 /**
1370 * Get the ActivityPub ID of a User by the WordPress User ID.
1371 *
1372 * @param int $id The WordPress User ID.
1373 *
1374 * @return string The ActivityPub ID (a URL) of the User.
1375 */
1376 function get_user_id( $id ) {
1377 $user = Actors::get_by_id( $id );
1378
1379 if ( ! $user ) {
1380 return false;
1381 }
1382
1383 return $user->get_id();
1384 }
1385
1386 /**
1387 * Get the ActivityPub ID of a Post by the WordPress Post ID.
1388 *
1389 * @param int $id The WordPress Post ID.
1390 *
1391 * @return string The ActivityPub ID (a URL) of the Post.
1392 */
1393 function get_post_id( $id ) {
1394 $post = get_post( $id );
1395
1396 if ( ! $post ) {
1397 return false;
1398 }
1399
1400 $transformer = new Post( $post );
1401 return $transformer->get_id();
1402 }
1403
1404 /**
1405 * Check if a URL is from the same domain as the site.
1406 *
1407 * @param string $url The URL to check.
1408 *
1409 * @return boolean True if the URL is from the same domain, false otherwise.
1410 */
1411 function is_same_domain( $url ) {
1412 $remote = \wp_parse_url( $url, PHP_URL_HOST );
1413
1414 if ( ! $remote ) {
1415 return false;
1416 }
1417
1418 $remote = normalize_host( $remote );
1419 $self = normalize_host( home_host() );
1420
1421 return $remote === $self;
1422 }
1423
1424 /**
1425 * Get the visibility of a post.
1426 *
1427 * @param int $post_id The post ID.
1428 *
1429 * @return string|false The visibility of the post or false if not found.
1430 */
1431 function get_content_visibility( $post_id ) {
1432 $post = get_post( $post_id );
1433 if ( ! $post ) {
1434 return false;
1435 }
1436
1437 $visibility = get_post_meta( $post->ID, 'activitypub_content_visibility', true );
1438 $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
1439 $options = array(
1440 ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC,
1441 ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL,
1442 );
1443
1444 if ( in_array( $visibility, $options, true ) ) {
1445 $_visibility = $visibility;
1446 }
1447
1448 return \apply_filters( 'activitypub_content_visibility', $_visibility, $post );
1449 }
1450
1451 /**
1452 * Retrieves the Host for the current site where the front end is accessible.
1453 *
1454 * @return string The host for the current site.
1455 */
1456 function home_host() {
1457 return \wp_parse_url( \home_url(), PHP_URL_HOST );
1458 }
1459
1460 /**
1461 * Returns the website hosts allowed to credit this blog.
1462 *
1463 * @return array|null The attribution domains or null if not found.
1464 */
1465 function get_attribution_domains() {
1466 if ( '1' !== \get_option( 'activitypub_use_opengraph', '1' ) ) {
1467 return null;
1468 }
1469
1470 $domains = \get_option( 'activitypub_attribution_domains', home_host() );
1471 $domains = explode( PHP_EOL, $domains );
1472
1473 if ( ! $domains ) {
1474 $domains = null;
1475 }
1476
1477 return $domains;
1478 }
1479
1480 /**
1481 * Get the base URL for uploads.
1482 *
1483 * @return string The upload base URL.
1484 */
1485 function get_upload_baseurl() {
1486 /**
1487 * Early filter to allow plugins to set the upload base URL.
1488 *
1489 * @param string|false $maybe_upload_dir The upload base URL or false if not set.
1490 */
1491 $maybe_upload_dir = apply_filters( 'pre_activitypub_get_upload_baseurl', false );
1492 if ( false !== $maybe_upload_dir ) {
1493 return $maybe_upload_dir;
1494 }
1495
1496 $upload_dir = \wp_get_upload_dir();
1497
1498 /**
1499 * Filters the upload base URL.
1500 *
1501 * @param string \wp_get_upload_dir()['baseurl'] The upload base URL.
1502 */
1503 return apply_filters( 'activitypub_get_upload_baseurl', $upload_dir['baseurl'] );
1504 }
1505
1506 /**
1507 * Check if an ID is from the same domain as the site.
1508 *
1509 * @param string $id The ID URI to check.
1510 *
1511 * @return boolean True if the ID is a self-pint, false otherwise.
1512 */
1513 function is_self_ping( $id ) {
1514 $query_string = \wp_parse_url( $id, PHP_URL_QUERY );
1515
1516 if ( ! $query_string ) {
1517 return false;
1518 }
1519
1520 $query = array();
1521 \parse_str( $query_string, $query );
1522
1523 if (
1524 is_same_domain( $id ) &&
1525 in_array( 'c', array_keys( $query ), true )
1526 ) {
1527 return true;
1528 }
1529
1530 return false;
1531 }
1532