PluginProbe
ActivityPub / 7.8.4
ActivityPub v7.8.4
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 7.8.4, at includes/functions.php

1,901 lines 50.8 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 Activitypub\Activity\Activity;
11 use Activitypub\Activity\Actor;
12 use Activitypub\Activity\Base_Object;
13 use Activitypub\Collection\Actors;
14 use Activitypub\Collection\Followers;
15 use Activitypub\Collection\Following;
16 use Activitypub\Collection\Outbox;
17 use Activitypub\Collection\Posts;
18 use Activitypub\Collection\Remote_Actors;
19 use Activitypub\Transformer\Factory as Transformer_Factory;
20
21 /**
22 * Returns the ActivityPub default JSON-context.
23 *
24 * @return array The activitypub context.
25 *
26 * @deprecated 7.6.0 Use the respective context function instead.
27 */
28 function get_context() {
29 \_deprecated_function( __FUNCTION__, '7.6.0', 'Use the respective context function instead.' );
30
31 $context = Activity::JSON_LD_CONTEXT;
32
33 /**
34 * Filters the ActivityPub JSON-LD context.
35 *
36 * This filter allows developers to modify or extend the JSON-LD context used
37 * in ActivityPub responses. The context defines the vocabulary and terms used
38 * in the ActivityPub JSON objects.
39 *
40 * @param array $context The default ActivityPub JSON-LD context array.
41 */
42 return \apply_filters( 'activitypub_json_context', $context );
43 }
44
45 /**
46 * Send a POST request to a remote server.
47 *
48 * @param string $url The URL endpoint.
49 * @param string $body The Post Body.
50 * @param int $user_id The WordPress user ID.
51 *
52 * @return array|\WP_Error The POST Response or an WP_Error.
53 */
54 function safe_remote_post( $url, $body, $user_id ) {
55 return Http::post( $url, $body, $user_id );
56 }
57
58 /**
59 * Send a GET request to a remote server.
60 *
61 * @param string $url The URL endpoint.
62 *
63 * @return array|\WP_Error The GET Response or an WP_Error.
64 */
65 function safe_remote_get( $url ) {
66 return Http::get( $url );
67 }
68
69 /**
70 * Returns a users WebFinger "resource".
71 *
72 * @deprecated 7.1.0 Use {@see \Activitypub\Webfinger::get_user_resource} instead.
73 *
74 * @param int $user_id The user ID.
75 *
76 * @return string The User resource.
77 */
78 function get_webfinger_resource( $user_id ) {
79 \_deprecated_function( __FUNCTION__, '7.1.0', 'Activitypub\Webfinger::get_user_resource' );
80
81 return Webfinger::get_user_resource( $user_id );
82 }
83
84 /**
85 * Requests the Meta-Data from the Actors profile.
86 *
87 * @param array|string $actor The Actor array or URL.
88 * @param bool $cached Optional. Whether the result should be cached. Default true.
89 *
90 * @return array|\WP_Error The Actor profile as array or WP_Error on failure.
91 */
92 function get_remote_metadata_by_actor( $actor, $cached = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
93 /**
94 * Filters the metadata before it is retrieved from a remote actor.
95 *
96 * Passing a non-false value will effectively short-circuit the remote request,
97 * returning that value instead.
98 *
99 * @param mixed $pre The value to return instead of the remote metadata.
100 * Default false to continue with the remote request.
101 * @param string $actor The actor URL.
102 */
103 $pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
104 if ( $pre ) {
105 return $pre;
106 }
107
108 $remote_actor = Remote_Actors::fetch_by_various( $actor );
109
110 if ( is_wp_error( $remote_actor ) ) {
111 return $remote_actor;
112 }
113
114 return json_decode( $remote_actor->post_content, true );
115 }
116
117 /**
118 * Returns the followers of a given user.
119 *
120 * @param int $user_id The user ID.
121 *
122 * @return array The followers.
123 */
124 function get_followers( $user_id ) {
125 return Followers::get_many( $user_id );
126 }
127
128 /**
129 * Count the number of followers for a given user.
130 *
131 * @param int $user_id The user ID.
132 *
133 * @return int The number of followers.
134 */
135 function count_followers( $user_id ) {
136 return Followers::count( $user_id );
137 }
138
139 /**
140 * Examine a url and try to determine the author ID it represents.
141 *
142 * Checks are supposedly from the hosted site blog.
143 *
144 * @param string $url Permalink to check.
145 *
146 * @return int|null User ID, or null on failure.
147 */
148 function url_to_authorid( $url ) {
149 global $wp_rewrite;
150
151 // Check if url hase the same host.
152 $request_host = \wp_parse_url( $url, \PHP_URL_HOST );
153 if ( \wp_parse_url( \home_url(), \PHP_URL_HOST ) !== $request_host && get_option( 'activitypub_old_host' ) !== $request_host ) {
154 return null;
155 }
156
157 // First, check to see if there is an 'author=N' to match against.
158 if ( \preg_match( '/[?&]author=(\d+)/i', $url, $values ) ) {
159 return \absint( $values[1] );
160 }
161
162 // Check to see if we are using rewrite rules.
163 $rewrite = $wp_rewrite->wp_rewrite_rules();
164
165 // Not using rewrite rules, and 'author=N' method failed, so we're out of options.
166 if ( empty( $rewrite ) ) {
167 return null;
168 }
169
170 // Generate rewrite rule for the author url.
171 $author_rewrite = $wp_rewrite->get_author_permastruct();
172 $author_regexp = \str_replace( '%author%', '', $author_rewrite );
173
174 // Match the rewrite rule with the passed url.
175 if ( \preg_match( '/https?:\/\/(.+)' . \preg_quote( $author_regexp, '/' ) . '([^\/]+)/i', $url, $match ) ) {
176 $user = \get_user_by( 'slug', $match[2] );
177 if ( $user ) {
178 return $user->ID;
179 }
180 }
181
182 return null;
183 }
184
185 /**
186 * Verify that url is a wp_ap_comment or a previously received remote comment.
187 *
188 * @deprecated 7.1.0
189 *
190 * @return int|bool Comment ID or false if not found.
191 */
192 function is_comment() {
193 \_deprecated_function( __FUNCTION__, '7.1.0' );
194
195 $comment_id = get_query_var( 'c', null );
196
197 if ( ! is_null( $comment_id ) ) {
198 $comment = \get_comment( $comment_id );
199
200 if ( $comment ) {
201 return $comment_id;
202 }
203 }
204
205 return false;
206 }
207
208 /**
209 * Check for Tombstone Objects.
210 *
211 * @deprecated 7.3.0 Use {@see Tombstone::exists_in_error()}.
212 * @see https://www.w3.org/TR/activitypub/#delete-activity-outbox
213 *
214 * @param \WP_Error $wp_error A WP_Error-Response of an HTTP-Request.
215 *
216 * @return boolean True if HTTP-Code is 410 or 404.
217 */
218 function is_tombstone( $wp_error ) {
219 \_deprecated_function( __FUNCTION__, '7.3.0', 'Activitypub\Tombstone::exists_in_error' );
220
221 return Tombstone::exists_in_error( $wp_error );
222 }
223
224 /**
225 * Get the REST URL relative to this plugin's namespace.
226 *
227 * @param string $path Optional. REST route path. Default ''.
228 *
229 * @return string REST URL relative to this plugin's namespace.
230 */
231 function get_rest_url_by_path( $path = '' ) {
232 // We'll handle the leading slash.
233 $path = ltrim( $path, '/' );
234 $namespaced_path = sprintf( '/%s/%s', ACTIVITYPUB_REST_NAMESPACE, $path );
235 return \get_rest_url( null, $namespaced_path );
236 }
237
238 /**
239 * Convert a string from camelCase to snake_case.
240 *
241 * @param string $input The string to convert.
242 *
243 * @return string The converted string.
244 */
245 function camel_to_snake_case( $input ) {
246 return strtolower( preg_replace( '/(?<!^)[A-Z]/', '_$0', $input ) );
247 }
248
249 /**
250 * Convert a string from snake_case to camelCase.
251 *
252 * @param string $input The string to convert.
253 *
254 * @return string The converted string.
255 */
256 function snake_to_camel_case( $input ) {
257 return lcfirst( str_replace( '_', '', ucwords( $input, '_' ) ) );
258 }
259
260 /**
261 * Escapes a Tag, to be used as a hashtag.
262 *
263 * @param string $input The string to escape.
264 *
265 * @return string The escaped hashtag.
266 */
267 function esc_hashtag( $input ) {
268
269 $hashtag = \wp_specialchars_decode( $input, ENT_QUOTES );
270 // Remove all characters that are not letters, numbers, or hyphens.
271 $hashtag = \preg_replace( '/emoji-regex(*SKIP)(?!)|[^\p{L}\p{Nd}-]+/u', '-', $hashtag );
272
273 // Capitalize every letter that is preceded by a hyphen.
274 $hashtag = preg_replace_callback(
275 '/-+(.)/',
276 static function ( $matches ) {
277 return strtoupper( $matches[1] );
278 },
279 $hashtag
280 );
281
282 // Add a hashtag to the beginning of the string.
283 $hashtag = ltrim( $hashtag, '#' );
284 $hashtag = trim( $hashtag, '-' );
285 $hashtag = '#' . $hashtag;
286
287 /**
288 * Allow defining your own custom hashtag generation rules.
289 *
290 * @param string $hashtag The hashtag to be returned.
291 * @param string $input The original string.
292 */
293 $hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $input );
294
295 return esc_html( $hashtag );
296 }
297
298 /**
299 * Check if a request is for an ActivityPub request.
300 *
301 * @return bool False by default.
302 */
303 function is_activitypub_request() {
304 return Query::get_instance()->is_activitypub_request();
305 }
306
307 /**
308 * Check if content negotiation is allowed for a request.
309 *
310 * @return bool True if content negotiation is allowed, false otherwise.
311 */
312 function should_negotiate_content() {
313 return Query::get_instance()->should_negotiate_content();
314 }
315
316 /**
317 * Check if a post is disabled for ActivityPub.
318 *
319 * This function checks if the post type supports ActivityPub and if the post is set to be local.
320 *
321 * @param mixed $post The post object or ID.
322 *
323 * @return boolean True if the post is disabled, false otherwise.
324 */
325 function is_post_disabled( $post ) {
326 $post = \get_post( $post );
327 $disabled = false;
328
329 if ( ! $post ) {
330 return true;
331 }
332
333 $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
334
335 if (
336 ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL === $visibility ||
337 ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ||
338 ! \post_type_supports( $post->post_type, 'activitypub' ) ||
339 'private' === $post->post_status ||
340 ! empty( $post->post_password )
341 ) {
342 $disabled = true;
343 }
344
345 /**
346 * Allow plugins to disable posts for ActivityPub.
347 *
348 * @param boolean $disabled True if the post is disabled, false otherwise.
349 * @param \WP_Post $post The post object.
350 */
351 return \apply_filters( 'activitypub_is_post_disabled', $disabled, $post );
352 }
353
354 /**
355 * Check if a post is an ActivityPub post.
356 *
357 * @param mixed $post The post object or ID.
358 *
359 * @return boolean True if the post is an ActivityPub post, false otherwise.
360 */
361 function is_ap_post( $post ) {
362 $post = \get_post( $post );
363
364 if ( ! $post ) {
365 return false;
366 }
367
368 // Check for ap_post post type.
369 return Posts::POST_TYPE === $post->post_type;
370 }
371
372 /**
373 * This function checks if a user is enabled for ActivityPub.
374 *
375 * @param int|string $user_id The user ID.
376 *
377 * @return boolean True if the user is enabled, false otherwise.
378 */
379 function user_can_activitypub( $user_id ) {
380 if ( ! is_numeric( $user_id ) ) {
381 return false;
382 }
383
384 switch ( $user_id ) {
385 case Actors::APPLICATION_USER_ID:
386 $enabled = true; // Application user is always enabled.
387 break;
388
389 case Actors::BLOG_USER_ID:
390 $enabled = ! is_user_type_disabled( 'blog' );
391 break;
392
393 default:
394 if ( ! \get_user_by( 'id', $user_id ) ) {
395 $enabled = false;
396 break;
397 }
398
399 if ( is_user_type_disabled( 'user' ) ) {
400 $enabled = false;
401 break;
402 }
403
404 $enabled = \user_can( $user_id, 'activitypub' );
405 }
406
407 /**
408 * Allow plugins to enable/disable users for ActivityPub.
409 *
410 * @param boolean $enabled True if the user is enabled, false otherwise.
411 * @param int $user_id The user ID.
412 */
413 return apply_filters( 'activitypub_user_can_activitypub', $enabled, $user_id );
414 }
415
416 /**
417 * Checks if a User-Type is disabled for ActivityPub.
418 *
419 * This function is used to check if the 'blog' or 'user'
420 * type is disabled for ActivityPub.
421 *
422 * @param string $type User type. 'blog' or 'user'.
423 *
424 * @return boolean True if the user type is disabled, false otherwise.
425 */
426 function is_user_type_disabled( $type ) {
427 switch ( $type ) {
428 case 'blog':
429 if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
430 if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
431 $disabled = false;
432 break;
433 }
434 }
435
436 if ( \defined( 'ACTIVITYPUB_DISABLE_BLOG_USER' ) ) {
437 $disabled = ACTIVITYPUB_DISABLE_BLOG_USER;
438 break;
439 }
440
441 if ( ACTIVITYPUB_ACTOR_MODE === \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
442 $disabled = true;
443 break;
444 }
445
446 $disabled = false;
447 break;
448 case 'user':
449 if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
450 if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
451 $disabled = true;
452 break;
453 }
454 }
455
456 if ( \defined( 'ACTIVITYPUB_DISABLE_USER' ) ) {
457 $disabled = ACTIVITYPUB_DISABLE_USER;
458 break;
459 }
460
461 if ( ACTIVITYPUB_BLOG_MODE === \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
462 $disabled = true;
463 break;
464 }
465
466 $disabled = false;
467 break;
468 default:
469 $disabled = new \WP_Error(
470 'activitypub_wrong_user_type',
471 __( 'Wrong user type', 'activitypub' ),
472 array( 'status' => 400 )
473 );
474 break;
475 }
476
477 /**
478 * Allow plugins to disable user types for ActivityPub.
479 *
480 * @param boolean $disabled True if the user type is disabled, false otherwise.
481 * @param string $type The User-Type.
482 */
483 return apply_filters( 'activitypub_is_user_type_disabled', $disabled, $type );
484 }
485
486 /**
487 * Check if the blog is in single-user mode.
488 *
489 * @return boolean True if the blog is in single-user mode, false otherwise.
490 */
491 function is_single_user() {
492 if (
493 false === is_user_type_disabled( 'blog' ) &&
494 true === is_user_type_disabled( 'user' )
495 ) {
496 return true;
497 }
498
499 return false;
500 }
501
502 /**
503 * Check if a site supports the block editor.
504 *
505 * @return boolean True if the site supports the block editor, false otherwise.
506 */
507 function site_supports_blocks() {
508 /**
509 * Allow plugins to disable block editor support,
510 * thus disabling blocks registered by the ActivityPub plugin.
511 *
512 * @param boolean $supports_blocks True if the site supports the block editor, false otherwise.
513 */
514 return apply_filters( 'activitypub_site_supports_blocks', true );
515 }
516
517 /**
518 * Check if data is valid JSON.
519 *
520 * @deprecated 7.1.0 Use {@see \json_decode}.
521 *
522 * @param string $data The data to check.
523 *
524 * @return boolean True if the data is JSON, false otherwise.
525 */
526 function is_json( $data ) {
527 \_deprecated_function( __FUNCTION__, '7.1.0', 'json_decode' );
528
529 return \is_array( \json_decode( $data, true ) );
530 }
531
532 /**
533 * Check whether a blog is public based on the `blog_public` option.
534 *
535 * @return bool True if public, false if not
536 */
537 function is_blog_public() {
538 /**
539 * Filter whether the blog is public.
540 *
541 * @param bool $public Whether the blog is public.
542 */
543 return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) );
544 }
545
546 /**
547 * Extract recipient URLs from Activity object.
548 *
549 * @param array $data The Activity object as array.
550 *
551 * @return array The list of user URLs.
552 */
553 function extract_recipients_from_activity( $data ) {
554 $recipient_items = array();
555
556 foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) {
557 $recipient_items = \array_merge( $recipient_items, extract_recipients_from_activity_property( $i, $data ) );
558 }
559
560 return \array_unique( $recipient_items );
561 }
562
563 /**
564 * Extract recipient URLs from a specific property of an Activity object.
565 *
566 * @param string $property The property to extract recipients from (e.g., 'to', 'cc').
567 * @param array $data The Activity object as array.
568 *
569 * @return array The list of user URLs.
570 */
571 function extract_recipients_from_activity_property( $property, $data ) {
572 $recipients = array();
573
574 if ( ! empty( $data[ $property ] ) ) {
575 $recipients = $data[ $property ];
576 } elseif ( ! empty( $data['object'][ $property ] ) ) {
577 $recipients = $data['object'][ $property ];
578 }
579
580 $recipients = \array_map( '\Activitypub\object_to_uri', (array) $recipients );
581
582 return \array_unique( \array_filter( $recipients ) );
583 }
584
585 /**
586 * Determine the visibility of the activity based on its recipients.
587 *
588 * @param array $activity The activity data.
589 *
590 * @return string The visibility level: 'public', 'private', or 'direct'.
591 */
592 function get_activity_visibility( $activity ) {
593 // Set default visibility for specific activity types.
594 if ( ! empty( $activity['type'] ) && in_array( $activity['type'], array( 'Accept', 'Delete', 'Follow', 'Reject', 'Undo' ), true ) ) {
595 return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
596 }
597
598 // Check 'to' field for public visibility.
599 $to = extract_recipients_from_activity_property( 'to', $activity );
600 if ( ! empty( array_intersect( $to, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) {
601 return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
602 }
603
604 // Check 'cc' field for quiet public visibility.
605 $cc = extract_recipients_from_activity_property( 'cc', $activity );
606 if ( ! empty( array_intersect( $cc, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) {
607 return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC;
608 }
609
610 // Activities with no recipients are treated as public.
611 $recipients = extract_recipients_from_activity( $activity );
612 if ( empty( $recipients ) ) {
613 return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
614 }
615
616 return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
617 }
618
619 /**
620 * Check if passed Activity is Public.
621 *
622 * @see https://github.com/w3c/activitypub/issues/404#issuecomment-2926310561
623 *
624 * @param Base_Object|array $data The Activity object as Base_Object or array.
625 *
626 * @return boolean True if public, false if not.
627 */
628 function is_activity_public( $data ) {
629 if ( $data instanceof Base_Object ) {
630 $data = $data->to_array();
631 }
632
633 $recipients = extract_recipients_from_activity( $data );
634
635 if ( empty( $recipients ) ) {
636 return true;
637 }
638
639 return ! empty( array_intersect( $recipients, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) );
640 }
641
642 /**
643 * Check if passed Activity is a reply.
644 *
645 * @param array $data The Activity object as array.
646 *
647 * @return boolean True if a reply, false if not.
648 */
649 function is_activity_reply( $data ) {
650 return ! empty( $data['object']['inReplyTo'] );
651 }
652
653 /**
654 * Check if passed Activity is a quote.
655 *
656 * Checks for quote properties: quote, quoteUrl, quoteUri, or _misskey_quote.
657 *
658 * @param array $data The Activity object as array.
659 *
660 * @return boolean True if a quote, false if not.
661 */
662 function is_quote_activity( $data ) {
663 return ! empty( $data['object']['quote'] ) ||
664 ! empty( $data['object']['quoteUrl'] ) ||
665 ! empty( $data['object']['quoteUri'] ) ||
666 ! empty( $data['object']['_misskey_quote'] );
667 }
668
669 /**
670 * Get active users based on a given duration.
671 *
672 * @param int $duration Optional. The duration to check in month(s). Default 1.
673 *
674 * @return int The number of active users.
675 */
676 function get_active_users( $duration = 1 ) {
677
678 $duration = intval( $duration );
679 $transient_key = sprintf( 'monthly_active_users_%d', $duration );
680 $count = get_transient( $transient_key );
681
682 if ( false === $count ) {
683 global $wpdb;
684
685 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
686 $count = $wpdb->get_var(
687 $wpdb->prepare(
688 "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 )",
689 $duration
690 )
691 );
692
693 set_transient( $transient_key, $count, DAY_IN_SECONDS );
694 }
695
696 // If 0 authors where active.
697 if ( 0 === $count ) {
698 return 0;
699 }
700
701 // If single user mode.
702 if ( is_single_user() ) {
703 return 1;
704 }
705
706 // If blog user is disabled.
707 if ( ! user_can_activitypub( Actors::BLOG_USER_ID ) ) {
708 return (int) $count;
709 }
710
711 // Also count blog user.
712 return (int) $count + 1;
713 }
714
715 /**
716 * Get the total number of users.
717 *
718 * @return int The total number of users.
719 */
720 function get_total_users() {
721 // If single user mode.
722 if ( is_single_user() ) {
723 return 1;
724 }
725
726 $users = \get_users(
727 array(
728 'capability__in' => array( 'activitypub' ),
729 )
730 );
731
732 if ( is_array( $users ) ) {
733 $users = count( $users );
734 } else {
735 $users = 1;
736 }
737
738 // If blog user is disabled.
739 if ( ! user_can_activitypub( Actors::BLOG_USER_ID ) ) {
740 return (int) $users;
741 }
742
743 return (int) $users + 1;
744 }
745
746 /**
747 * Examine a comment ID and look up an existing comment it represents.
748 *
749 * @param string $id ActivityPub object ID (usually a URL) to check.
750 *
751 * @return \WP_Comment|boolean Comment, or false on failure.
752 */
753 function object_id_to_comment( $id ) {
754 return Comment::object_id_to_comment( $id );
755 }
756
757 /**
758 * Verify that URL is a local comment or a previously received remote comment.
759 * (For threading comments locally)
760 *
761 * @param string $url The URL to check.
762 *
763 * @return string|null Comment ID or null if not found
764 */
765 function url_to_commentid( $url ) {
766 return Comment::url_to_commentid( $url );
767 }
768
769 /**
770 * Get the URI of an ActivityPub object.
771 *
772 * @param array|string $data The ActivityPub object.
773 *
774 * @return string|null The URI of the ActivityPub object.
775 */
776 function object_to_uri( $data ) {
777 // Check whether it is already simple.
778 if ( ! $data || is_string( $data ) ) {
779 return $data;
780 }
781
782 if ( is_object( $data ) ) {
783 $data = $data->to_array();
784 }
785
786 /*
787 * Check if it is a list, then take first item.
788 * This plugin does not support collections.
789 */
790 if ( array_is_list( $data ) ) {
791 $data = $data[0];
792 }
793
794 // Check if it is simplified now.
795 if ( is_string( $data ) ) {
796 return $data;
797 }
798
799 $type = 'Object';
800 if ( isset( $data['type'] ) ) {
801 $type = $data['type'];
802 }
803
804 // Return part of Object that makes most sense.
805 switch ( $type ) {
806 case 'Audio': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio.
807 case 'Document': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document.
808 case 'Image': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image.
809 case 'Video': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video.
810 $data = object_to_uri( $data['url'] );
811 break;
812
813 case 'Link': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link.
814 case 'Mention': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mention.
815 $data = $data['href'];
816 break;
817
818 default:
819 $data = $data['id'];
820 break;
821 }
822
823 return $data;
824 }
825
826 /**
827 * Check if a comment should be federated.
828 *
829 * We consider a comment should be federated if it is authored by a user that is
830 * not disabled for federation and if it is a reply directly to the post or to a
831 * federated comment.
832 *
833 * @param mixed $comment Comment object or ID.
834 *
835 * @return boolean True if the comment should be federated, false otherwise.
836 */
837 function should_comment_be_federated( $comment ) {
838 return Comment::should_be_federated( $comment );
839 }
840
841 /**
842 * Check if a comment was federated.
843 *
844 * This function checks if a comment was federated via ActivityPub.
845 *
846 * @param mixed $comment Comment object or ID.
847 *
848 * @return boolean True if the comment was federated, false otherwise.
849 */
850 function was_comment_sent( $comment ) {
851 return Comment::was_sent( $comment );
852 }
853
854 /**
855 * Check if a comment is federated.
856 *
857 * We consider a comment federated if comment was received via ActivityPub.
858 *
859 * Use this function to check if it is comment that was received via ActivityPub.
860 *
861 * @param mixed $comment Comment object or ID.
862 *
863 * @return boolean True if the comment is federated, false otherwise.
864 */
865 function was_comment_received( $comment ) {
866 return Comment::was_received( $comment );
867 }
868
869 /**
870 * Check if a comment is local only.
871 *
872 * This function checks if a comment is local only and was not sent or received via ActivityPub.
873 *
874 * @param mixed $comment Comment object or ID.
875 *
876 * @return boolean True if the comment is local only, false otherwise.
877 */
878 function is_local_comment( $comment ) {
879 return Comment::is_local( $comment );
880 }
881
882 /**
883 * Mark a WordPress object as federated.
884 *
885 * @param \WP_Comment|\WP_Post $wp_object The WordPress object.
886 * @param string $state The state of the object.
887 */
888 function set_wp_object_state( $wp_object, $state ) {
889 $meta_key = 'activitypub_status';
890
891 if ( $wp_object instanceof \WP_Post ) {
892 \update_post_meta( $wp_object->ID, $meta_key, $state );
893 } elseif ( $wp_object instanceof \WP_Comment ) {
894 \update_comment_meta( $wp_object->comment_ID, $meta_key, $state );
895 } else {
896 /**
897 * Allow plugins to mark WordPress objects as federated.
898 *
899 * @param \WP_Comment|\WP_Post $wp_object The WordPress object.
900 */
901 \apply_filters( 'activitypub_mark_wp_object_as_federated', $wp_object );
902 }
903 }
904
905 /**
906 * Get the federation state of a WordPress object.
907 *
908 * @param \WP_Comment|\WP_Post $wp_object The WordPress object.
909 *
910 * @return string|false The state of the object or false if not found.
911 */
912 function get_wp_object_state( $wp_object ) {
913 $meta_key = 'activitypub_status';
914
915 if ( $wp_object instanceof \WP_Post ) {
916 return \get_post_meta( $wp_object->ID, $meta_key, true );
917 } elseif ( $wp_object instanceof \WP_Comment ) {
918 return \get_comment_meta( $wp_object->comment_ID, $meta_key, true );
919 } else {
920 /**
921 * Allow plugins to get the federation state of a WordPress object.
922 *
923 * @param false $state The state of the object.
924 * @param \WP_Comment|\WP_Post $wp_object The WordPress object.
925 */
926 return \apply_filters( 'activitypub_get_wp_object_state', false, $wp_object );
927 }
928 }
929
930 /**
931 * Get the description of a post type.
932 *
933 * Set some default descriptions for the default post types.
934 *
935 * @param \WP_Post_Type $post_type The post type object.
936 *
937 * @return string The description of the post type.
938 */
939 function get_post_type_description( $post_type ) {
940 switch ( $post_type->name ) {
941 case 'post':
942 case 'page':
943 $description = '';
944 break;
945 case 'attachment':
946 $description = ' - ' . __( 'Files uploaded to the media library (such as images, videos, documents, or other attachments). Note: This federates every file upload, not just published content.', 'activitypub' );
947 break;
948 default:
949 $description = '';
950 if ( ! empty( $post_type->description ) ) {
951 $description = ' - ' . $post_type->description;
952 }
953 }
954
955 /**
956 * Allow plugins to get the description of a post type.
957 *
958 * @param string $description The description of the post type.
959 * @param string $post_type_name The post type name.
960 * @param \WP_Post_Type $post_type The post type object.
961 */
962 return apply_filters( 'activitypub_post_type_description', $description, $post_type->name, $post_type );
963 }
964
965 /**
966 * Get the masked WordPress version to only show the major and minor version.
967 *
968 * @return string The masked version.
969 */
970 function get_masked_wp_version() {
971 // Only show the major and minor version.
972 $version = get_bloginfo( 'version' );
973 // Strip the RC or beta part.
974 $version = preg_replace( '/-.*$/', '', $version );
975 $version = explode( '.', $version );
976 $version = array_slice( $version, 0, 2 );
977
978 return implode( '.', $version );
979 }
980
981 /**
982 * Get the enclosures of a post.
983 *
984 * @param int $post_id The post ID.
985 *
986 * @return array The enclosures.
987 */
988 function get_enclosures( $post_id ) {
989 $enclosures = get_post_meta( $post_id, 'enclosure', false );
990
991 if ( ! $enclosures ) {
992 return array();
993 }
994
995 $enclosures = array_map(
996 static function ( $enclosure ) {
997 // Check if the enclosure is a string.
998 if ( ! $enclosure || ! is_string( $enclosure ) ) {
999 return false;
1000 }
1001
1002 $attributes = explode( "\n", $enclosure );
1003
1004 if ( ! isset( $attributes[0] ) || ! \wp_http_validate_url( $attributes[0] ) ) {
1005 return false;
1006 }
1007
1008 return array(
1009 'url' => $attributes[0],
1010 'length' => $attributes[1] ?? null,
1011 'mediaType' => $attributes[2] ?? 'application/octet-stream',
1012 );
1013 },
1014 $enclosures
1015 );
1016
1017 return array_filter( $enclosures );
1018 }
1019
1020 /**
1021 * Retrieves the IDs of the ancestors of a comment.
1022 *
1023 * Adaption of `get_post_ancestors` from WordPress core.
1024 *
1025 * @see https://developer.wordpress.org/reference/functions/get_post_ancestors/
1026 *
1027 * @param int|\WP_Comment $comment Comment ID or comment object.
1028 *
1029 * @return int[] Array of ancestor IDs.
1030 */
1031 function get_comment_ancestors( $comment ) {
1032 $comment = \get_comment( $comment );
1033
1034 if ( ! $comment || empty( $comment->comment_parent ) || (int) $comment->comment_parent === (int) $comment->comment_ID ) {
1035 return array();
1036 }
1037
1038 $ancestors = array();
1039
1040 $id = (int) $comment->comment_parent;
1041 $ancestors[] = $id;
1042
1043 while ( $id > 0 ) {
1044 $ancestor = \get_comment( $id );
1045
1046 if ( ! $ancestor ) {
1047 break;
1048 }
1049
1050 $parent_id = (int) $ancestor->comment_parent;
1051
1052 // Loop detection: If the ancestor has been seen before, break.
1053 if ( empty( $parent_id ) || ( $parent_id === (int) $comment->comment_ID ) || in_array( $parent_id, $ancestors, true ) ) {
1054 break;
1055 }
1056
1057 $id = $parent_id;
1058 $ancestors[] = $id;
1059 }
1060
1061 return $ancestors;
1062 }
1063
1064 /**
1065 * Change the display of large numbers on the site.
1066 *
1067 * @author Jeremy Herve
1068 *
1069 * @see https://wordpress.org/support/topic/abbreviate-numbers-with-k/
1070 *
1071 * @param string $formatted Converted number in string format.
1072 * @param float $number The number to convert based on locale.
1073 *
1074 * @return string Converted number in string format.
1075 */
1076 function custom_large_numbers( $formatted, $number ) {
1077 global $wp_locale;
1078
1079 $decimals = 0;
1080 $decimal_point = '.';
1081 $thousands_sep = ',';
1082
1083 if ( isset( $wp_locale ) ) {
1084 $decimals = (int) $wp_locale->number_format['decimal_point'];
1085 $decimal_point = $wp_locale->number_format['decimal_point'];
1086 $thousands_sep = $wp_locale->number_format['thousands_sep'];
1087 }
1088
1089 if ( $number < 1000 ) { // Any number less than a Thousand.
1090 return \number_format( $number, $decimals, $decimal_point, $thousands_sep );
1091 } elseif ( $number < 1000000 ) { // Any number less than a million.
1092 return \number_format( $number / 1000, $decimals, $decimal_point, $thousands_sep ) . 'K';
1093 } elseif ( $number < 1000000000 ) { // Any number less than a billion.
1094 return \number_format( $number / 1000000, $decimals, $decimal_point, $thousands_sep ) . 'M';
1095 } else { // At least a billion.
1096 return \number_format( $number / 1000000000, $decimals, $decimal_point, $thousands_sep ) . 'B';
1097 }
1098 }
1099
1100 /**
1101 * Registers a ActivityPub comment type.
1102 *
1103 * @param string $comment_type Key for comment type.
1104 * @param array $args Optional. Array of arguments for registering a comment type. Default empty array.
1105 *
1106 * @return array The registered Activitypub comment type.
1107 */
1108 function register_comment_type( $comment_type, $args = array() ) {
1109 global $activitypub_comment_types;
1110
1111 if ( ! is_array( $activitypub_comment_types ) ) {
1112 $activitypub_comment_types = array();
1113 }
1114
1115 // Sanitize comment type name.
1116 $comment_type = sanitize_key( $comment_type );
1117
1118 $activitypub_comment_types[ $comment_type ] = $args;
1119
1120 /**
1121 * Fires after a ActivityPub comment type is registered.
1122 *
1123 * @param string $comment_type Comment type.
1124 * @param array $args Arguments used to register the comment type.
1125 */
1126 do_action( 'activitypub_registered_comment_type', $comment_type, $args );
1127
1128 return $args;
1129 }
1130
1131 /**
1132 * Normalize a URL.
1133 *
1134 * @param string $url The URL.
1135 *
1136 * @return string The normalized URL.
1137 */
1138 function normalize_url( $url ) {
1139 $url = \untrailingslashit( $url );
1140 $url = \preg_replace( '/^https?:\/\/(www\.)?/', '', $url );
1141
1142 return $url;
1143 }
1144
1145 /**
1146 * Normalize a host.
1147 *
1148 * @param string $host The host.
1149 *
1150 * @return string The normalized host.
1151 */
1152 function normalize_host( $host ) {
1153 return \preg_replace( '/^www\./', '', $host );
1154 }
1155
1156 /**
1157 * Get the reply intent URI as a JavaScript URI.
1158 *
1159 * @return string The reply intent URI.
1160 */
1161 function get_reply_intent_js() {
1162 return sprintf(
1163 'javascript:(()=>{window.open(\'%s\'+encodeURIComponent(window.location.href));})();',
1164 get_reply_intent_url()
1165 );
1166 }
1167
1168 /**
1169 * Get the reply intent URI.
1170 *
1171 * @return string The reply intent URI.
1172 */
1173 function get_reply_intent_url() {
1174 /**
1175 * Filters the reply intent parameters.
1176 *
1177 * @param array $params The reply intent parameters.
1178 */
1179 $params = \apply_filters( 'activitypub_reply_intent_params', array() );
1180
1181 $params += array( 'in_reply_to' => '' );
1182 $query = \http_build_query( $params );
1183 $path = 'post-new.php?' . $query;
1184 $url = \admin_url( $path );
1185
1186 /**
1187 * Filters the reply intent URL.
1188 *
1189 * @param string $url The reply intent URL.
1190 */
1191 $url = \apply_filters( 'activitypub_reply_intent_url', $url );
1192
1193 return esc_url_raw( $url );
1194 }
1195
1196 /**
1197 * Replace content with links, mentions or hashtags by Regex callback and not affect protected tags.
1198 *
1199 * @param string $content The content that should be changed.
1200 * @param string $regex The regex to use.
1201 * @param callable $regex_callback Callback for replacement logic.
1202 *
1203 * @return string The content with links, mentions, hashtags, etc.
1204 */
1205 function enrich_content_data( $content, $regex, $regex_callback ) {
1206 // Small protection against execution timeouts: limit to 1 MB.
1207 if ( mb_strlen( $content ) > MB_IN_BYTES ) {
1208 return $content;
1209 }
1210 $tag_stack = array();
1211 $protected_tags = array(
1212 'pre',
1213 'code',
1214 'textarea',
1215 'style',
1216 'a',
1217 );
1218 $content_with_links = '';
1219 $in_protected_tag = false;
1220 foreach ( wp_html_split( $content ) as $chunk ) {
1221 if ( preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
1222 $content_with_links .= $chunk;
1223 continue;
1224 }
1225
1226 if ( preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
1227 $tag = strtolower( $m[2] );
1228 if ( '/' === $m[1] ) {
1229 // Closing tag.
1230 $i = array_search( $tag, $tag_stack, true );
1231 // We can only remove the tag from the stack if it is in the stack.
1232 if ( false !== $i ) {
1233 $tag_stack = array_slice( $tag_stack, 0, $i );
1234 }
1235 } else {
1236 // Opening tag, add it to the stack.
1237 $tag_stack[] = $tag;
1238 }
1239
1240 // If we're in a protected tag, the tag_stack contains at least one protected tag string.
1241 // The protected tag state can only change when we encounter a start or end tag.
1242 $in_protected_tag = array_intersect( $tag_stack, $protected_tags );
1243
1244 // Never inspect tags.
1245 $content_with_links .= $chunk;
1246 continue;
1247 }
1248
1249 if ( $in_protected_tag ) {
1250 // Don't inspect a chunk inside an inspected tag.
1251 $content_with_links .= $chunk;
1252 continue;
1253 }
1254
1255 // Only reachable when there is no protected tag in the stack.
1256 $content_with_links .= \preg_replace_callback( $regex, $regex_callback, $chunk );
1257 }
1258
1259 return $content_with_links;
1260 }
1261
1262 /**
1263 * Generate a summary of a post.
1264 *
1265 * This function generates a summary of a post by extracting:
1266 *
1267 * 1. The post excerpt if it exists.
1268 * 2. The first part of the post content if it contains the <!--more--> tag.
1269 * 3. An excerpt of the post content if it is longer than the specified length.
1270 *
1271 * @param int|\WP_Post $post The post ID or post object.
1272 * @param integer $length The maximum length of the summary.
1273 * Default is 500. It will be ignored if the post excerpt
1274 * and the content above the <!--more--> tag.
1275 *
1276 * @return string The generated post summary.
1277 */
1278 function generate_post_summary( $post, $length = 500 ) {
1279 $post = get_post( $post );
1280
1281 if ( ! $post ) {
1282 return '';
1283 }
1284
1285 /**
1286 * Filters the excerpt more value.
1287 *
1288 * @param string $excerpt_more The excerpt more.
1289 */
1290 $excerpt_more = \apply_filters( 'activitypub_excerpt_more', '[…]' );
1291 $length = $length - \mb_strlen( $excerpt_more, 'UTF-8' );
1292
1293 $content = \sanitize_post_field( 'post_excerpt', $post->post_excerpt, $post->ID );
1294
1295 if ( $content ) {
1296 // Ignore length if excerpt is set.
1297 $length = null;
1298 } else {
1299 $content = \sanitize_post_field( 'post_content', $post->post_content, $post->ID );
1300 $content_parts = \get_extended( $content );
1301
1302 // Check for the <!--more--> tag.
1303 if (
1304 ! empty( $content_parts['extended'] ) &&
1305 ! empty( $content_parts['main'] )
1306 ) {
1307 $content = \trim( $content_parts['main'] ) . ' ' . $excerpt_more;
1308 $length = null;
1309 }
1310 }
1311
1312 $content = \strip_shortcodes( $content );
1313 $content = \wp_strip_all_tags( $content );
1314 $content = \html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
1315 $content = \trim( $content );
1316 $content = \preg_replace( '/\R+/mu', "\n\n", $content );
1317 $content = \preg_replace( '/[\r\t]/u', '', $content );
1318
1319 if ( $length && \mb_strlen( $content, 'UTF-8' ) > $length ) {
1320 $content = \wordwrap( $content, $length, '</activitypub-summary>' );
1321 $content = \explode( '</activitypub-summary>', $content, 2 );
1322 $content = $content[0] . ' ' . $excerpt_more;
1323 }
1324
1325 /*
1326 There is no proper support for HTML in ActivityPub summaries yet.
1327 // This filter is documented in wp-includes/post-template.php.
1328 return \apply_filters( 'the_excerpt', $content );
1329 */
1330 return $content;
1331 }
1332
1333 /**
1334 * Get the content warning of a post.
1335 *
1336 * @param int|\WP_Post $post_id The post ID or post object.
1337 *
1338 * @return string|false The content warning or false if not found.
1339 */
1340 function get_content_warning( $post_id ) {
1341 $post = get_post( $post_id );
1342 if ( ! $post ) {
1343 return false;
1344 }
1345
1346 $warning = get_post_meta( $post->ID, 'activitypub_content_warning', true );
1347 if ( empty( $warning ) ) {
1348 return false;
1349 }
1350
1351 return $warning;
1352 }
1353
1354 /**
1355 * Get the ActivityPub ID of a User by the WordPress User ID.
1356 *
1357 * Fall back to blog user if in blog mode or if user is not found.
1358 *
1359 * @param int $id The WordPress User ID.
1360 *
1361 * @return string|false The ActivityPub ID (a URL) of the User or false if not found.
1362 */
1363 function get_user_id( $id ) {
1364 $mode = \get_option( 'activitypub_actor_mode', 'default' );
1365
1366 if ( ACTIVITYPUB_BLOG_MODE === $mode ) {
1367 $user = Actors::get_by_id( Actors::BLOG_USER_ID );
1368 } else {
1369 $user = Actors::get_by_id( $id );
1370
1371 if ( \is_wp_error( $user ) ) {
1372 $user = Actors::get_by_id( Actors::BLOG_USER_ID );
1373 }
1374 }
1375
1376 if ( \is_wp_error( $user ) ) {
1377 return false;
1378 }
1379
1380 return $user->get_id();
1381 }
1382
1383 /**
1384 * Get the ActivityPub ID of a Post by the WordPress Post ID.
1385 *
1386 * @param int $id The WordPress Post ID.
1387 *
1388 * @return string The ActivityPub ID (a URL) of the Post.
1389 */
1390 function get_post_id( $id ) {
1391 $last_legacy_id = (int) \get_option( 'activitypub_last_post_with_permalink_as_id', 0 );
1392 $post_id = (int) $id;
1393
1394 if ( $post_id > $last_legacy_id ) {
1395 // Generate URI based on post ID.
1396 return \add_query_arg( 'p', $post_id, \home_url( '/' ) );
1397 }
1398
1399 return \get_permalink( $post_id );
1400 }
1401
1402 /**
1403 * Check if a URL is from the same domain as the site.
1404 *
1405 * @param string $url The URL to check.
1406 *
1407 * @return boolean True if the URL is from the same domain, false otherwise.
1408 */
1409 function is_same_domain( $url ) {
1410 $remote = \wp_parse_url( $url, PHP_URL_HOST );
1411
1412 if ( ! $remote ) {
1413 return false;
1414 }
1415
1416 $remote = normalize_host( $remote );
1417 $self = normalize_host( home_host() );
1418
1419 return $remote === $self;
1420 }
1421
1422 /**
1423 * Get the visibility of a post.
1424 *
1425 * @param int $post_id The post ID.
1426 *
1427 * @return string|false The visibility of the post or false if not found.
1428 */
1429 function get_content_visibility( $post_id ) {
1430 $post = get_post( $post_id );
1431 if ( ! $post ) {
1432 return false;
1433 }
1434
1435 $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
1436 $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
1437 $options = array(
1438 ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC,
1439 ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE,
1440 ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL,
1441 );
1442
1443 if ( in_array( $visibility, $options, true ) ) {
1444 $_visibility = $visibility;
1445 }
1446
1447 /**
1448 * Filters the visibility of a post.
1449 *
1450 * @param string $_visibility The visibility of the post. Possible values are:
1451 * - 'public': Post is public and federated.
1452 * - 'quiet_public': Post is public but not federated.
1453 * - 'local': Post is only visible locally.
1454 * @param \WP_Post $post The post object.
1455 */
1456 return \apply_filters( 'activitypub_content_visibility', $_visibility, $post );
1457 }
1458
1459 /**
1460 * Retrieves the Host for the current site where the front end is accessible.
1461 *
1462 * @return string The host for the current site.
1463 */
1464 function home_host() {
1465 return \wp_parse_url( \home_url(), PHP_URL_HOST );
1466 }
1467
1468 /**
1469 * Returns the website hosts allowed to credit this blog.
1470 *
1471 * @return array|null The attribution domains or null if not found.
1472 */
1473 function get_attribution_domains() {
1474 if ( '1' !== \get_option( 'activitypub_use_opengraph', '1' ) ) {
1475 return null;
1476 }
1477
1478 $domains = \get_option( 'activitypub_attribution_domains', home_host() );
1479 $domains = explode( PHP_EOL, $domains );
1480
1481 if ( ! $domains ) {
1482 $domains = null;
1483 }
1484
1485 return $domains;
1486 }
1487
1488 /**
1489 * Get the base URL for uploads.
1490 *
1491 * @return string The upload base URL.
1492 */
1493 function get_upload_baseurl() {
1494 /**
1495 * Early filter to allow plugins to set the upload base URL.
1496 *
1497 * @param string|false $maybe_upload_dir The upload base URL or false if not set.
1498 */
1499 $maybe_upload_dir = apply_filters( 'pre_activitypub_get_upload_baseurl', false );
1500 if ( false !== $maybe_upload_dir ) {
1501 return $maybe_upload_dir;
1502 }
1503
1504 $upload_dir = \wp_get_upload_dir();
1505
1506 /**
1507 * Filters the upload base URL.
1508 *
1509 * @param string $upload_dir The upload base URL. Default \wp_get_upload_dir()['baseurl']
1510 */
1511 return apply_filters( 'activitypub_get_upload_baseurl', $upload_dir['baseurl'] );
1512 }
1513
1514 /**
1515 * Check if Authorized-Fetch is enabled.
1516 *
1517 * @see https://docs.joinmastodon.org/admin/config/#authorized_fetch
1518 *
1519 * @return boolean True if Authorized-Fetch is enabled, false otherwise.
1520 */
1521 function use_authorized_fetch() {
1522 $use = (bool) \get_option( 'activitypub_authorized_fetch' );
1523
1524 /**
1525 * Filters whether to use Authorized-Fetch.
1526 *
1527 * @param boolean $use_authorized_fetch True if Authorized-Fetch is enabled, false otherwise.
1528 */
1529 return apply_filters( 'activitypub_use_authorized_fetch', $use );
1530 }
1531
1532 /**
1533 * Check if an ID is from the same domain as the site.
1534 *
1535 * @param string $id The ID URI to check.
1536 *
1537 * @return boolean True if the ID is a self-pint, false otherwise.
1538 */
1539 function is_self_ping( $id ) {
1540 $query_string = \wp_parse_url( $id, PHP_URL_QUERY );
1541
1542 if ( ! $query_string ) {
1543 return false;
1544 }
1545
1546 $query = array();
1547 \parse_str( $query_string, $query );
1548
1549 if (
1550 is_same_domain( $id ) &&
1551 in_array( 'c', array_keys( $query ), true )
1552 ) {
1553 return true;
1554 }
1555
1556 return false;
1557 }
1558
1559 /**
1560 * Add an object to the outbox.
1561 *
1562 * @param mixed $data The object to add to the outbox.
1563 * @param string|null $activity_type Optional. The type of the Activity or null if `$data` is an Activity. Default null.
1564 * @param integer $user_id Optional. The User-ID. Default 0.
1565 * @param string $content_visibility Optional. The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`. Default null.
1566 *
1567 * @return boolean|int The ID of the outbox item or false on failure.
1568 */
1569 function add_to_outbox( $data, $activity_type = null, $user_id = 0, $content_visibility = null ) {
1570 // If the user is disabled, fall back to the blog user when available.
1571 if ( ! user_can_activitypub( $user_id ) ) {
1572 if ( user_can_activitypub( Actors::BLOG_USER_ID ) ) {
1573 $user_id = Actors::BLOG_USER_ID;
1574 } else {
1575 return false;
1576 }
1577 }
1578
1579 $transformer = Transformer_Factory::get_transformer( $data );
1580
1581 if ( ! $transformer || is_wp_error( $transformer ) ) {
1582 return false;
1583 }
1584
1585 if ( $content_visibility ) {
1586 $transformer->set_content_visibility( $content_visibility );
1587 } else {
1588 $content_visibility = $transformer->get_content_visibility();
1589 }
1590
1591 if ( $activity_type ) {
1592 $activity = $transformer->to_activity( $activity_type );
1593 $activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
1594 } else {
1595 $activity = $transformer->to_object();
1596 }
1597
1598 if ( ! $activity || \is_wp_error( $activity ) ) {
1599 /**
1600 * Action triggered when adding an object to the outbox fails.
1601 *
1602 * @param \WP_Error $activity The error object or false.
1603 * @param mixed $data The object that failed to be added to the outbox.
1604 * @param string|null $activity_type The type of the Activity or null if `$data` is an Activity.
1605 * @param int $user_id The User ID.
1606 * @param string $content_visibility The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`.
1607 */
1608 \do_action( 'activitypub_add_to_outbox_failed', $activity, $data, $activity_type, $user_id, $content_visibility );
1609
1610 return false;
1611 }
1612
1613 $outbox_activity_id = Outbox::add( $activity, $user_id, $content_visibility );
1614
1615 if ( ! $outbox_activity_id || \is_wp_error( $outbox_activity_id ) ) {
1616 /**
1617 * Action triggered when adding an object to the outbox fails.
1618 *
1619 * @param false|\WP_Error $outbox_activity_id The error object or false.
1620 * @param mixed $data The object that failed to be added to the outbox.
1621 * @param string|null $activity_type The type of the Activity or null if `$data` is an Activity.
1622 * @param int $user_id The User ID.
1623 * @param string $content_visibility The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`.
1624 */
1625 \do_action( 'activitypub_add_to_outbox_failed', $outbox_activity_id, $data, $activity_type, $user_id, $content_visibility );
1626
1627 return false;
1628 }
1629
1630 /**
1631 * Action triggered after an object has been added to the outbox.
1632 *
1633 * @param int $outbox_activity_id The ID of the outbox item.
1634 * @param Activity $activity The activity object.
1635 * @param int $user_id The User-ID.
1636 * @param string $content_visibility The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`.
1637 */
1638 \do_action( 'post_activitypub_add_to_outbox', $outbox_activity_id, $activity, $user_id, $content_visibility );
1639
1640 set_wp_object_state( $data, 'federated' );
1641
1642 return $outbox_activity_id;
1643 }
1644
1645 /**
1646 * Follow a user.
1647 *
1648 * @param string|int $remote_actor The Actor URL, WebFinger Resource or Post-ID of the remote Actor.
1649 * @param int $user_id The ID of the WordPress User.
1650 *
1651 * @return int|\WP_Error The Outbox ID on success or a WP_Error on failure.
1652 */
1653 function follow( $remote_actor, $user_id ) {
1654 if ( \is_numeric( $remote_actor ) ) {
1655 return Following::follow( $remote_actor, $user_id );
1656 }
1657
1658 if ( ! \filter_var( $remote_actor, FILTER_VALIDATE_URL ) ) {
1659 $remote_actor = Webfinger::resolve( $remote_actor );
1660 }
1661
1662 if ( \is_wp_error( $remote_actor ) ) {
1663 return $remote_actor;
1664 }
1665
1666 $remote_actor_post = Remote_Actors::fetch_by_uri( $remote_actor );
1667
1668 if ( \is_wp_error( $remote_actor_post ) ) {
1669 return $remote_actor_post;
1670 }
1671
1672 return Following::follow( $remote_actor_post, $user_id );
1673 }
1674
1675 /**
1676 * Unfollow a user.
1677 *
1678 * @param string|int $remote_actor The Actor URL, WebFinger Resource or Post-ID of the remote Actor.
1679 * @param int $user_id The ID of the WordPress User.
1680 *
1681 * @return \WP_Post|\WP_Error The Actor post or a WP_Error.
1682 */
1683 function unfollow( $remote_actor, $user_id ) {
1684 if ( \is_numeric( $remote_actor ) ) {
1685 return Following::unfollow( $remote_actor, $user_id );
1686 }
1687
1688 if ( ! \filter_var( $remote_actor, FILTER_VALIDATE_URL ) ) {
1689 $remote_actor = Webfinger::resolve( $remote_actor );
1690 }
1691
1692 if ( \is_wp_error( $remote_actor ) ) {
1693 return $remote_actor;
1694 }
1695
1696 $remote_actor_post = Remote_Actors::fetch_by_uri( $remote_actor );
1697
1698 if ( \is_wp_error( $remote_actor_post ) ) {
1699 return $remote_actor_post;
1700 }
1701
1702 return Following::unfollow( $remote_actor_post, $user_id );
1703 }
1704
1705 /**
1706 * Check if an `$data` is an Activity.
1707 *
1708 * @see https://www.w3.org/ns/activitystreams#activities
1709 *
1710 * @param array|object|string $data The data to check.
1711 *
1712 * @return boolean True if the `$data` is an Activity, false otherwise.
1713 */
1714 function is_activity( $data ) {
1715 /**
1716 * Filters the activity types.
1717 *
1718 * @param array $types The activity types.
1719 */
1720 $types = apply_filters( 'activitypub_activity_types', Activity::TYPES );
1721
1722 return _is_type_of( $data, $types );
1723 }
1724
1725 /**
1726 * Check if an `$data` is an Activity Object.
1727 *
1728 * @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
1729 *
1730 * @param array|object|string $data The data to check.
1731 *
1732 * @return boolean True if the `$data` is an Activity Object, false otherwise.
1733 */
1734 function is_activity_object( $data ) {
1735 /**
1736 * Filters the activity object types.
1737 *
1738 * @param array $types The activity object types.
1739 */
1740 $types = \apply_filters( 'activitypub_activity_object_types', Base_Object::TYPES );
1741
1742 return _is_type_of( $data, $types );
1743 }
1744
1745 /**
1746 * Check if an `$data` is an Actor.
1747 *
1748 * @see https://www.w3.org/ns/activitystreams#actor
1749 *
1750 * @param array|object|string $data The data to check.
1751 *
1752 * @return boolean True if the `$data` is an Actor, false otherwise.
1753 */
1754 function is_actor( $data ) {
1755 /**
1756 * Filters the actor types.
1757 *
1758 * @param array $types The actor types.
1759 */
1760 $types = apply_filters( 'activitypub_actor_types', Actor::TYPES );
1761
1762 return _is_type_of( $data, $types );
1763 }
1764
1765 /**
1766 * Check if an `$data` is a Collection.
1767 *
1768 * @see https://www.w3.org/ns/activitystreams#collections
1769 *
1770 * @param array|object|string $data The data to check.
1771 *
1772 * @return boolean True if the `$data` is a Collection, false otherwise.
1773 */
1774 function is_collection( $data ) {
1775 /**
1776 * Filters the collection types.
1777 *
1778 * @param array $types The collection types.
1779 */
1780 $types = apply_filters( 'activitypub_collection_types', array( 'Collection', 'OrderedCollection', 'CollectionPage', 'OrderedCollectionPage' ) );
1781
1782 return _is_type_of( $data, $types );
1783 }
1784
1785 /**
1786 * Private helper to check if $data is of a given type set.
1787 *
1788 * @param array|object|string $data The data to check.
1789 * @param array $types The types to check against.
1790 *
1791 * @return boolean True if $data is of one of the types, false otherwise.
1792 */
1793 function _is_type_of( $data, $types ) {
1794 if ( is_string( $data ) ) {
1795 return in_array( $data, $types, true );
1796 }
1797
1798 if ( is_array( $data ) && isset( $data['type'] ) ) {
1799 return in_array( $data['type'], $types, true );
1800 }
1801
1802 if ( $data instanceof Base_Object ) {
1803 return in_array( $data->get_type(), $types, true );
1804 }
1805
1806 return false;
1807 }
1808
1809 /**
1810 * Get an ActivityPub embed HTML for a URL.
1811 *
1812 * @param string $url The URL to get the embed for.
1813 * @param boolean $inline_css Whether to inline CSS. Default true.
1814 *
1815 * @return string|false The embed HTML or false if not found.
1816 */
1817 function get_embed_html( $url, $inline_css = true ) {
1818 return Embed::get_html( $url, $inline_css );
1819 }
1820
1821 /**
1822 * Infer a shortname from the Actor ID or URL. Used only for fallbacks,
1823 * we will try to use what's supplied.
1824 *
1825 * @param string $uri The URI.
1826 *
1827 * @return string Hopefully the name of the Follower.
1828 */
1829 function extract_name_from_uri( $uri ) {
1830 $name = $uri;
1831
1832 if ( \filter_var( $name, FILTER_VALIDATE_URL ) ) {
1833 $name = \rtrim( $name, '/' );
1834 $path = \wp_parse_url( $name, PHP_URL_PATH );
1835 if ( $path && '/' !== $path ) {
1836 if ( \strpos( $name, '@' ) !== false ) {
1837 // Expected: https://example.com/@user (default URL pattern).
1838 $name = \preg_replace( '|^/@?|', '', $path );
1839 } else {
1840 // Expected: https://example.com/users/user (default ID pattern).
1841 $parts = \explode( '/', $path );
1842 $name = \array_pop( $parts );
1843 }
1844 } else {
1845 $name = \wp_parse_url( $name, PHP_URL_HOST );
1846 $name = \str_replace( 'www.', '', $name );
1847 }
1848 } elseif (
1849 \is_email( $name ) ||
1850 \strpos( $name, 'acct' ) === 0 ||
1851 \strpos( $name, '@' ) === 0
1852 ) {
1853 // Expected: user@example.com or acct:user@example (WebFinger).
1854 $name = \ltrim( $name, '@' );
1855 if ( str_starts_with( $name, 'acct:' ) ) {
1856 $name = \substr( $name, 5 );
1857 }
1858 $parts = \explode( '@', $name );
1859 $name = $parts[0];
1860 }
1861
1862 return $name;
1863 }
1864
1865 /**
1866 * Get the authority (scheme + host) from a URL.
1867 *
1868 * @param string $url The URL to parse.
1869 *
1870 * @return string|false The authority, or false on failure.
1871 */
1872 function get_url_authority( $url ) {
1873 $parsed = wp_parse_url( $url );
1874
1875 if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
1876 return false;
1877 }
1878
1879 return $parsed['scheme'] . '://' . $parsed['host'];
1880 }
1881
1882 /**
1883 * Check if a plugin is active, loading plugin.php if necessary.
1884 *
1885 * This is a wrapper around the core is_plugin_active() function that ensures
1886 * the function is available by loading wp-admin/includes/plugin.php if needed.
1887 * This is useful when checking plugin status outside of the admin context.
1888 *
1889 * @param string $plugin Plugin basename (e.g., 'plugin-folder/plugin-file.php').
1890 *
1891 * @return bool True if the plugin is active, false otherwise.
1892 */
1893 function is_plugin_active( $plugin ) {
1894 // Include plugin.php if not already loaded (needed for core is_plugin_active).
1895 if ( ! \function_exists( 'is_plugin_active' ) ) {
1896 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1897 }
1898
1899 return \is_plugin_active( $plugin );
1900 }
1901