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

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