PluginProbe
ActivityPub / 3.2.3
ActivityPub v3.2.3
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 3.2.3, at includes/functions.php

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