PluginProbe
ActivityPub / 7.7.0
ActivityPub v7.7.0
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
← All changes | includes/functions.php +1625 -143 9.0.27.7.0 View file →
@@ -1,37 +1,242 @@
1 1 <?php
2 2 /**
3 3 * Functions file.
4 4 *
5 - * General utility functions for the ActivityPub plugin.
6 - *
7 5 * @package Activitypub
8 6 */
9 7
10 8 namespace Activitypub;
11 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 +
12 21 /**
13 - * Get the ActivityPub ID for a WordPress object.
22 + * Returns the ActivityPub default JSON-context.
14 23 *
15 - * Returns the canonical ActivityPub URI for a WP_Post or WP_Comment.
24 + * @return array The activitypub context.
16 25 *
17 - * @param \WP_Post|\WP_Comment $wp_object The WordPress post or comment.
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.
18 47 *
19 - * @return string|null The ActivityPub ID (a URL), or null if unsupported type.
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.
20 53 */
21 -function get_object_id( $wp_object ) {
22 - if ( $wp_object instanceof \WP_Post ) {
23 - return get_post_id( $wp_object->ID );
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;
24 106 }
25 107
26 - if ( $wp_object instanceof \WP_Comment ) {
27 - return get_comment_id( $wp_object );
108 + $remote_actor = Remote_Actors::fetch_by_various( $actor );
109 +
110 + if ( is_wp_error( $remote_actor ) ) {
111 + return $remote_actor;
28 112 }
29 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 +
30 182 return null;
31 183 }
32 184
33 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 +/**
34 239 * Convert a string from camelCase to snake_case.
35 240 *
36 241 * @param string $input The string to convert.
37 242 *
@@ -52,43 +257,250 @@
52 257 return lcfirst( str_replace( '_', '', ucwords( $input, '_' ) ) );
53 258 }
54 259
55 260 /**
56 - * Convert seconds to ISO 8601 duration format.
261 + * Escapes a Tag, to be used as a hashtag.
57 262 *
58 - * @param int $seconds The duration in seconds.
263 + * @param string $input The string to escape.
59 264 *
60 - * @return string The duration in ISO 8601 format (e.g., "PT1H23M45S").
265 + * @return string The escaped hashtag.
61 266 */
62 -function seconds_to_iso8601( $seconds ) {
63 - $seconds = (int) $seconds;
267 +function esc_hashtag( $input ) {
64 268
65 - if ( $seconds <= 0 ) {
66 - return 'PT0S';
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 + 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;
67 331 }
68 332
69 - $hours = floor( $seconds / 3600 );
70 - $minutes = floor( ( $seconds % 3600 ) / 60 );
71 - $secs = $seconds % 60;
333 + $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
72 334
73 - $duration = 'PT';
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 + }
74 344
75 - if ( $hours > 0 ) {
76 - $duration .= $hours . 'H';
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;
77 366 }
78 367
79 - if ( $minutes > 0 ) {
80 - $duration .= $minutes . 'M';
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;
81 382 }
82 383
83 - if ( $secs > 0 || ( 0 === $hours && 0 === $minutes ) ) {
84 - $duration .= $secs . 'S';
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' );
85 405 }
86 406
87 - return $duration;
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 );
88 414 }
89 415
90 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 +/**
91 503 * Check if a site supports the block editor.
92 504 *
93 505 * @return boolean True if the site supports the block editor, false otherwise.
94 506 */
@@ -102,8 +514,23 @@
102 514 return apply_filters( 'activitypub_site_supports_blocks', true );
103 515 }
104 516
105 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 +/**
106 533 * Check whether a blog is public based on the `blog_public` option.
107 534 *
108 535 * @return bool True if public, false if not
109 536 */
@@ -116,8 +543,427 @@
116 543 return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) );
117 544 }
118 545
119 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 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 +/**
120 966 * Get the masked WordPress version to only show the major and minor version.
121 967 *
122 968 * @return string The masked version.
123 969 */
@@ -132,45 +978,88 @@
132 978 return implode( '.', $version );
133 979 }
134 980
135 981 /**
136 - * Check if a plugin is active, loading plugin.php if necessary.
982 + * Get the enclosures of a post.
137 983 *
138 - * This is a wrapper around the core is_plugin_active() function that ensures
139 - * the function is available by loading wp-admin/includes/plugin.php if needed.
140 - * This is useful when checking plugin status outside of the admin context.
984 + * @param int $post_id The post ID.
141 985 *
142 - * @param string $plugin Plugin basename (e.g., 'plugin-folder/plugin-file.php').
143 - *
144 - * @return bool True if the plugin is active, false otherwise.
986 + * @return array The enclosures.
145 987 */
146 -function is_plugin_active( $plugin ) {
147 - // Include plugin.php if not already loaded (needed for core is_plugin_active).
148 - if ( ! \function_exists( 'is_plugin_active' ) ) {
149 - require_once ABSPATH . 'wp-admin/includes/plugin.php';
988 +function get_enclosures( $post_id ) {
989 + $enclosures = get_post_meta( $post_id, 'enclosure', false );
990 +
991 + if ( ! $enclosures ) {
992 + return array();
150 993 }
151 994
152 - return \is_plugin_active( $plugin );
995 + $enclosures = array_map(
996 + 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 );
153 1018 }
154 1019
155 1020 /**
156 - * Returns the website hosts allowed to credit this blog.
1021 + * Retrieves the IDs of the ancestors of a comment.
157 1022 *
158 - * @return array|null The attribution domains or null if not found.
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.
159 1030 */
160 -function get_attribution_domains() {
161 - if ( '1' !== \get_option( 'activitypub_use_opengraph', '1' ) ) {
162 - return null;
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();
163 1036 }
164 1037
165 - $domains = \get_option( 'activitypub_attribution_domains', home_host() );
166 - $domains = explode( PHP_EOL, $domains );
1038 + $ancestors = array();
167 1039
168 - if ( ! $domains ) {
169 - $domains = null;
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;
170 1059 }
171 1060
172 - return $domains;
1061 + return $ancestors;
173 1062 }
174 1063
175 1064 /**
176 1065 * Change the display of large numbers on the site.
@@ -208,42 +1097,101 @@
208 1097 }
209 1098 }
210 1099
211 1100 /**
212 - * Escapes a Tag, to be used as a hashtag.
1101 + * Registers a ActivityPub comment type.
213 1102 *
214 - * @param string $input The string to escape.
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.
215 1105 *
216 - * @return string The escaped hashtag.
1106 + * @return array The registered Activitypub comment type.
217 1107 */
218 -function esc_hashtag( $input ) {
219 - $hashtag = \wp_specialchars_decode( $input, ENT_QUOTES );
220 - // Remove all characters that are not letters, numbers, or hyphens.
221 - $hashtag = \preg_replace( '/[^\p{L}\p{Nd}-]+/u', '-', $hashtag );
1108 +function register_comment_type( $comment_type, $args = array() ) {
1109 + global $activitypub_comment_types;
222 1110
223 - // Capitalize every letter that is preceded by a hyphen.
224 - $hashtag = preg_replace_callback(
225 - '/-+(.)/',
226 - static function ( $matches ) {
227 - return strtoupper( $matches[1] );
228 - },
229 - $hashtag
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()
230 1165 );
1166 +}
231 1167
232 - // Add a hashtag to the beginning of the string.
233 - $hashtag = ltrim( $hashtag, '#' );
234 - $hashtag = trim( $hashtag, '-' );
235 - $hashtag = '#' . $hashtag;
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() );
236 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 +
237 1186 /**
238 - * Allow defining your own custom hashtag generation rules.
1187 + * Filters the reply intent URL.
239 1188 *
240 - * @param string $hashtag The hashtag to be returned.
241 - * @param string $input The original string.
1189 + * @param string $url The reply intent URL.
242 1190 */
243 - $hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $input );
1191 + $url = \apply_filters( 'activitypub_reply_intent_url', $url );
244 1192
245 - return esc_html( $hashtag );
1193 + return esc_url_raw( $url );
246 1194 }
247 1195
248 1196 /**
249 1197 * Replace content with links, mentions or hashtags by Regex callback and not affect protected tags.
@@ -311,108 +1259,642 @@
311 1259 return $content_with_links;
312 1260 }
313 1261
314 1262 /**
315 - * Get an ActivityPub embed HTML for a URL.
1263 + * Generate a summary of a post.
316 1264 *
317 - * @param string $url The URL to get the embed for.
318 - * @param boolean $inline_css Whether to inline CSS. Default true.
1265 + * This function generates a summary of a post by extracting:
319 1266 *
320 - * @return string|false The embed HTML or false if not found.
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.
321 1277 */
322 -function get_embed_html( $url, $inline_css = true ) {
323 - return Embed::get_html( $url, $inline_css );
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;
324 1331 }
325 1332
326 1333 /**
327 - * Get the client IP address for rate-limiting purposes.
1334 + * Get the content warning of a post.
328 1335 *
329 - * Walks the ordered list of $_SERVER keys returned by the
330 - * `activitypub_client_ip_sources` filter (default: `['REMOTE_ADDR']`) and
331 - * returns the first value that parses as a valid IP literal, validated via
332 - * `filter_var( ..., FILTER_VALIDATE_IP )`. The result can be overridden
333 - * outright via the `activitypub_client_ip` filter; that filter's output is
334 - * also validated and replaced with `''` when it isn't a valid IP, so a
335 - * misbehaving filter can't collide all callers into the same rate-limit
336 - * bucket.
1336 + * @param int|\WP_Post $post_id The post ID or post object.
337 1337 *
338 - * Trusting any source other than `REMOTE_ADDR` is only safe behind a
339 - * reverse proxy that sets and overwrites the corresponding header — see
340 - * the `activitypub_client_ip_sources` filter docblock for guidance.
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.
341 1356 *
342 - * Callers using the return value as a rate-limit key should treat an
343 - * empty return as "client unidentifiable" and fail closed rather than
344 - * share a single bucket across every such request.
1357 + * Fall back to blog user if in blog mode or if user is not found.
345 1358 *
346 - * @since 8.1.0
1359 + * @param int $id The WordPress User ID.
347 1360 *
348 - * @return string A valid IP address, or '' when no IP could be determined.
1361 + * @return string|false The ActivityPub ID (a URL) of the User or false if not found.
349 1362 */
350 -function get_client_ip() {
351 - // phpcs:disable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
352 - $ip = '';
1363 +function get_user_id( $id ) {
1364 + $mode = \get_option( 'activitypub_actor_mode', 'default' );
353 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 +
354 1447 /**
355 - * Filter the ordered list of $_SERVER keys to consult as a source for the
356 - * client IP. The first key whose value parses as a valid IP wins.
1448 + * Filters the visibility of a post.
357 1449 *
358 - * Default: array( 'REMOTE_ADDR' ) — the actual TCP peer, the only value
359 - * that an HTTP client cannot spoof. Trusting any other $_SERVER key is
360 - * only safe when a reverse proxy in front of the site sets that key and
361 - * overwrites any client-supplied version; otherwise an attacker can spoof
362 - * the value and bypass the per-IP rate limits that depend on it.
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.
363 1496 *
364 - * Common operator overrides:
365 - * array( 'HTTP_CF_CONNECTING_IP' ) on Cloudflare.
366 - * array( 'HTTP_TRUE_CLIENT_IP', 'REMOTE_ADDR' ) Akamai with a fallback.
367 - * array( 'HTTP_X_REAL_IP' ) nginx that strips the client copy.
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.
368 1508 *
369 - * X-Forwarded-For pitfall: even with a trusted proxy, an attacker can
370 - * prepend their own value before the proxy appends the real client IP.
371 - * This helper takes the leftmost entry, which is correct only when the
372 - * trusted proxy fully overwrites the header. If you trust X-Forwarded-For
373 - * end-to-end, prefer to resolve from the right by your known proxy count
374 - * via the activitypub_client_ip filter.
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.
375 1526 *
376 - * @since 8.2.0
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.
377 1632 *
378 - * @param string[] $sources $_SERVER keys to consult, in priority order.
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_*`.
379 1637 */
380 - $sources = \apply_filters( 'activitypub_client_ip_sources', array( 'REMOTE_ADDR' ) );
1638 + \do_action( 'post_activitypub_add_to_outbox', $outbox_activity_id, $activity, $user_id, $content_visibility );
381 1639
382 - if ( ! \is_array( $sources ) ) {
383 - $sources = array( 'REMOTE_ADDR' );
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 );
384 1656 }
385 1657
386 - foreach ( $sources as $source ) {
387 - if ( ! \is_string( $source ) || empty( $_SERVER[ $source ] ) ) {
388 - continue;
389 - }
1658 + if ( ! \filter_var( $remote_actor, FILTER_VALIDATE_URL ) ) {
1659 + $remote_actor = Webfinger::resolve( $remote_actor );
1660 + }
390 1661
391 - // Some headers (e.g. X-Forwarded-For) may contain a comma-separated list; use the first IP.
392 - $ip_list = \sanitize_text_field( \wp_unslash( $_SERVER[ $source ] ) );
393 - $candidate = \trim( \explode( ',', $ip_list )[0] );
1662 + if ( \is_wp_error( $remote_actor ) ) {
1663 + return $remote_actor;
1664 + }
394 1665
395 - if ( \filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
396 - $ip = $candidate;
397 - break;
398 - }
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;
399 1670 }
400 - // phpcs:enable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
401 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 ) {
402 1715 /**
403 - * Filter the client IP address used for rate limiting.
1716 + * Filters the activity types.
404 1717 *
405 - * @since 8.1.0
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.
406 1737 *
407 - * @param string $ip The detected client IP address (empty when none could be determined).
1738 + * @param array $types The activity object types.
408 1739 */
409 - $ip = \apply_filters( 'activitypub_client_ip', $ip );
1740 + $types = \apply_filters( 'activitypub_activity_object_types', Base_Object::TYPES );
410 1741
411 - // Tolerate surrounding whitespace from filter callbacks; FILTER_VALIDATE_IP would otherwise reject it.
412 - if ( \is_string( $ip ) ) {
413 - $ip = \trim( $ip );
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 );
414 1796 }
415 1797
416 - // Re-validate so a misbehaving filter can't return a sentinel string that would collapse all callers into one bucket.
417 - return \is_string( $ip ) && \filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
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 );
418 1900 }