PluginProbe
ActivityPub / 3.2.5
ActivityPub v3.2.5
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.5, at includes/functions.php

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