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