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

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