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