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