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