PluginProbe
ActivityPub / 1.3.0
ActivityPub v1.3.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
activitypub / includes / functions.php

functions.php in ActivityPub 1.3.0, at includes/functions.php

701 lines 16.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Activitypub;
3
4 use WP_Error;
5 use WP_Comment_Query;
6 use Activitypub\Http;
7 use Activitypub\Activity\Activity;
8 use Activitypub\Collection\Followers;
9 use Activitypub\Collection\Users;
10
11 /**
12 * Returns the ActivityPub default JSON-context
13 *
14 * @return array the activitypub context
15 */
16 function get_context() {
17 $context = Activity::CONTEXT;
18
19 return \apply_filters( 'activitypub_json_context', $context );
20 }
21
22 function safe_remote_post( $url, $body, $user_id ) {
23 return Http::post( $url, $body, $user_id );
24 }
25
26 function safe_remote_get( $url ) {
27 return Http::get( $url );
28 }
29
30 /**
31 * Returns a users WebFinger "resource"
32 *
33 * @param int $user_id The User-ID.
34 *
35 * @return string The User-Resource.
36 */
37 function get_webfinger_resource( $user_id ) {
38 return Webfinger::get_user_resource( $user_id );
39 }
40
41 /**
42 * Requests the Meta-Data from the Actors profile
43 *
44 * @param string $actor The Actor URL.
45 * @param bool $cached If the result should be cached.
46 *
47 * @return array|WP_Error The Actor profile as array or WP_Error on failure.
48 */
49 function get_remote_metadata_by_actor( $actor, $cached = true ) {
50 $pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
51 if ( $pre ) {
52 return $pre;
53 }
54 if ( preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $actor ) ) {
55 $actor = Webfinger::resolve( $actor );
56 }
57
58 if ( ! $actor ) {
59 return new WP_Error( 'activitypub_no_valid_actor_identifier', \__( 'The "actor" identifier is not valid', 'activitypub' ), array( 'status' => 404, 'actor' => $actor ) );
60 }
61
62 if ( is_wp_error( $actor ) ) {
63 return $actor;
64 }
65
66 $transient_key = 'activitypub_' . $actor;
67
68 // only check the cache if needed.
69 if ( $cached ) {
70 $metadata = \get_transient( $transient_key );
71
72 if ( $metadata ) {
73 return $metadata;
74 }
75 }
76
77 if ( ! \wp_http_validate_url( $actor ) ) {
78 $metadata = new WP_Error( 'activitypub_no_valid_actor_url', \__( 'The "actor" is no valid URL', 'activitypub' ), array( 'status' => 400, 'actor' => $actor ) );
79 return $metadata;
80 }
81
82 $response = Http::get( $actor );
83
84 if ( \is_wp_error( $response ) ) {
85 return $response;
86 }
87
88 $metadata = \wp_remote_retrieve_body( $response );
89 $metadata = \json_decode( $metadata, true );
90
91 if ( ! $metadata ) {
92 $metadata = new WP_Error( 'activitypub_invalid_json', \__( 'No valid JSON data', 'activitypub' ), array( 'status' => 400, 'actor' => $actor ) );
93 return $metadata;
94 }
95
96 \set_transient( $transient_key, $metadata, WEEK_IN_SECONDS );
97
98 return $metadata;
99 }
100
101 /**
102 * Returns the followers of a given user.
103 *
104 * @param int $user_id The User-ID.
105 *
106 * @return array The followers.
107 */
108 function get_followers( $user_id ) {
109 return Followers::get_followers( $user_id );
110 }
111
112 /**
113 * Count the number of followers for a given user.
114 *
115 * @param int $user_id The User-ID.
116 *
117 * @return int The number of followers.
118 */
119 function count_followers( $user_id ) {
120 return Followers::count_followers( $user_id );
121 }
122
123 /**
124 * Examine a url and try to determine the author ID it represents.
125 *
126 * Checks are supposedly from the hosted site blog.
127 *
128 * @param string $url Permalink to check.
129 *
130 * @return int User ID, or 0 on failure.
131 */
132 function url_to_authorid( $url ) {
133 global $wp_rewrite;
134
135 // check if url hase the same host
136 if ( \wp_parse_url( \site_url(), \PHP_URL_HOST ) !== \wp_parse_url( $url, \PHP_URL_HOST ) ) {
137 return 0;
138 }
139
140 // first, check to see if there is a 'author=N' to match against
141 if ( \preg_match( '/[?&]author=(\d+)/i', $url, $values ) ) {
142 $id = \absint( $values[1] );
143 if ( $id ) {
144 return $id;
145 }
146 }
147
148 // check to see if we are using rewrite rules
149 $rewrite = $wp_rewrite->wp_rewrite_rules();
150
151 // not using rewrite rules, and 'author=N' method failed, so we're out of options
152 if ( empty( $rewrite ) ) {
153 return 0;
154 }
155
156 // generate rewrite rule for the author url
157 $author_rewrite = $wp_rewrite->get_author_permastruct();
158 $author_regexp = \str_replace( '%author%', '', $author_rewrite );
159
160 // match the rewrite rule with the passed url
161 if ( \preg_match( '/https?:\/\/(.+)' . \preg_quote( $author_regexp, '/' ) . '([^\/]+)/i', $url, $match ) ) {
162 $user = \get_user_by( 'slug', $match[2] );
163 if ( $user ) {
164 return $user->ID;
165 }
166 }
167
168 return 0;
169 }
170
171 /**
172 * Check for Tombstone Objects
173 *
174 * @see https://www.w3.org/TR/activitypub/#delete-activity-outbox
175 *
176 * @param WP_Error $wp_error A WP_Error-Response of an HTTP-Request
177 *
178 * @return boolean true if HTTP-Code is 410 or 404
179 */
180 function is_tombstone( $wp_error ) {
181 if ( ! is_wp_error( $wp_error ) ) {
182 return false;
183 }
184
185 if ( in_array( (int) $wp_error->get_error_code(), array( 404, 410 ), true ) ) {
186 return true;
187 }
188
189 return false;
190 }
191
192 /**
193 * Get the REST URL relative to this plugin's namespace.
194 *
195 * @param string $path Optional. REST route path. Otherwise this plugin's namespaced root.
196 *
197 * @return string REST URL relative to this plugin's namespace.
198 */
199 function get_rest_url_by_path( $path = '' ) {
200 // we'll handle the leading slash.
201 $path = ltrim( $path, '/' );
202 $namespaced_path = sprintf( '/%s/%s', ACTIVITYPUB_REST_NAMESPACE, $path );
203 return \get_rest_url( null, $namespaced_path );
204 }
205
206 /**
207 * Convert a string from camelCase to snake_case.
208 *
209 * @param string $string The string to convert.
210 *
211 * @return string The converted string.
212 */
213 // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound
214 function camel_to_snake_case( $string ) {
215 return strtolower( preg_replace( '/(?<!^)[A-Z]/', '_$0', $string ) );
216 }
217
218 /**
219 * Convert a string from snake_case to camelCase.
220 *
221 * @param string $string The string to convert.
222 *
223 * @return string The converted string.
224 */
225 // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound
226 function snake_to_camel_case( $string ) {
227 return lcfirst( str_replace( '_', '', ucwords( $string, '_' ) ) );
228 }
229
230 /**
231 * Escapes a Tag, to be used as a hashtag.
232 *
233 * @param string $string The string to escape.
234 *
235 * @return string The escaped hastag.
236 */
237 function esc_hashtag( $string ) {
238
239 $hashtag = \wp_specialchars_decode( $string, ENT_QUOTES );
240 // Remove all characters that are not letters, numbers, or underscores.
241 $hashtag = \preg_replace( '/emoji-regex(*SKIP)(?!)|[^\p{L}\p{Nd}_]+/u', '_', $hashtag );
242
243 // Capitalize every letter that is preceded by an underscore.
244 $hashtag = preg_replace_callback(
245 '/_(.)/',
246 function ( $matches ) {
247 return '' . strtoupper( $matches[1] );
248 },
249 $hashtag
250 );
251
252 // Add a hashtag to the beginning of the string.
253 $hashtag = ltrim( $hashtag, '#' );
254 $hashtag = '#' . $hashtag;
255
256 /**
257 * Allow defining your own custom hashtag generation rules.
258 *
259 * @param string $hashtag The hashtag to be returned.
260 * @param string $string The original string.
261 */
262 $hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $string );
263
264 return esc_html( $hashtag );
265 }
266
267 /**
268 * Check if a request is for an ActivityPub request.
269 *
270 * @return bool False by default.
271 */
272 function is_activitypub_request() {
273 global $wp_query;
274
275 /*
276 * ActivityPub requests are currently only made for
277 * author archives, singular posts, and the homepage.
278 */
279 if ( ! \is_author() && ! \is_singular() && ! \is_home() && ! defined( '\REST_REQUEST' ) ) {
280 return false;
281 }
282
283 // Check if the current post type supports ActivityPub.
284 if ( \is_singular() ) {
285 $queried_object = \get_queried_object();
286 $post_type = \get_post_type( $queried_object );
287
288 if ( ! \post_type_supports( $post_type, 'activitypub' ) ) {
289 return false;
290 }
291 }
292
293 // One can trigger an ActivityPub request by adding ?activitypub to the URL.
294 // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.VariableRedeclaration
295 global $wp_query;
296 if ( isset( $wp_query->query_vars['activitypub'] ) ) {
297 return true;
298 }
299
300 /*
301 * The other (more common) option to make an ActivityPub request
302 * is to send an Accept header.
303 */
304 if ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
305 $accept = sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
306
307 /*
308 * $accept can be a single value, or a comma separated list of values.
309 * We want to support both scenarios,
310 * and return true when the header includes at least one of the following:
311 * - application/activity+json
312 * - application/ld+json
313 * - application/json
314 */
315 if ( preg_match( '/(application\/(ld\+json|activity\+json|json))/i', $accept ) ) {
316 return true;
317 }
318 }
319
320 return false;
321 }
322
323 /**
324 * This function checks if a user is disabled for ActivityPub.
325 *
326 * @param int $user_id The User-ID.
327 *
328 * @return boolean True if the user is disabled, false otherwise.
329 */
330 function is_user_disabled( $user_id ) {
331 $return = false;
332
333 switch ( $user_id ) {
334 // if the user is the application user, it's always enabled.
335 case \Activitypub\Collection\Users::APPLICATION_USER_ID:
336 $return = false;
337 break;
338 // if the user is the blog user, it's only enabled in single-user mode.
339 case \Activitypub\Collection\Users::BLOG_USER_ID:
340 if ( is_user_type_disabled( 'blog' ) ) {
341 $return = true;
342 break;
343 }
344
345 $return = false;
346 break;
347 // if the user is any other user, it's enabled if it can publish posts.
348 default:
349 if ( ! \get_user_by( 'id', $user_id ) ) {
350 $return = true;
351 break;
352 }
353
354 if ( is_user_type_disabled( 'user' ) ) {
355 $return = true;
356 break;
357 }
358
359 if ( ! \user_can( $user_id, 'publish_posts' ) ) {
360 $return = true;
361 break;
362 }
363
364 $return = false;
365 break;
366 }
367
368 return apply_filters( 'activitypub_is_user_disabled', $return, $user_id );
369 }
370
371 /**
372 * Checks if a User-Type is disabled for ActivityPub.
373 *
374 * This function is used to check if the 'blog' or 'user'
375 * type is disabled for ActivityPub.
376 *
377 * @param enum $type Can be 'blog' or 'user'.
378 *
379 * @return boolean True if the user type is disabled, false otherwise.
380 */
381 function is_user_type_disabled( $type ) {
382 switch ( $type ) {
383 case 'blog':
384 if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
385 if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
386 $return = false;
387 break;
388 }
389 }
390
391 if ( \defined( 'ACTIVITYPUB_DISABLE_BLOG_USER' ) ) {
392 $return = ACTIVITYPUB_DISABLE_BLOG_USER;
393 break;
394 }
395
396 if ( '1' !== \get_option( 'activitypub_enable_blog_user', '0' ) ) {
397 $return = true;
398 break;
399 }
400
401 $return = false;
402 break;
403 case 'user':
404 if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
405 if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
406 $return = true;
407 break;
408 }
409 }
410
411 if ( \defined( 'ACTIVITYPUB_DISABLE_USER' ) ) {
412 $return = ACTIVITYPUB_DISABLE_USER;
413 break;
414 }
415
416 if ( '1' !== \get_option( 'activitypub_enable_users', '1' ) ) {
417 $return = true;
418 break;
419 }
420
421 $return = false;
422 break;
423 default:
424 $return = new WP_Error( 'activitypub_wrong_user_type', __( 'Wrong user type', 'activitypub' ), array( 'status' => 400 ) );
425 break;
426 }
427
428 return apply_filters( 'activitypub_is_user_type_disabled', $return, $type );
429 }
430
431 /**
432 * Check if the blog is in single-user mode.
433 *
434 * @return boolean True if the blog is in single-user mode, false otherwise.
435 */
436 function is_single_user() {
437 if (
438 false === is_user_type_disabled( 'blog' ) &&
439 true === is_user_type_disabled( 'user' )
440 ) {
441 return true;
442 }
443
444 return false;
445 }
446
447 /**
448 * Check if a site supports the block editor.
449 *
450 * @return boolean True if the site supports the block editor, false otherwise.
451 */
452 function site_supports_blocks() {
453 if ( \version_compare( \get_bloginfo( 'version' ), '5.9', '<' ) ) {
454 return false;
455 }
456
457 if ( ! \function_exists( 'register_block_type_from_metadata' ) ) {
458 return false;
459 }
460
461 /**
462 * Allow plugins to disable block editor support,
463 * thus disabling blocks registered by the ActivityPub plugin.
464 *
465 * @param boolean $supports_blocks True if the site supports the block editor, false otherwise.
466 */
467 return apply_filters( 'activitypub_site_supports_blocks', true );
468 }
469
470 /**
471 * Check if data is valid JSON.
472 *
473 * @param string $data The data to check.
474 *
475 * @return boolean True if the data is JSON, false otherwise.
476 */
477 function is_json( $data ) {
478 return \is_array( \json_decode( $data, true ) ) ? true : false;
479 }
480
481 /**
482 * Check if a blog is public based on the `blog_public` option
483 *
484 * @return bollean True if public, false if not
485 */
486 function is_blog_public() {
487 return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) );
488 }
489
490 /**
491 * Sanitize a URL
492 *
493 * @param string $value The URL to sanitize
494 *
495 * @return string|null The sanitized URL or null if invalid
496 */
497 function sanitize_url( $value ) {
498 if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) {
499 return null;
500 }
501
502 return esc_url_raw( $value );
503 }
504
505 /**
506 * Extract recipient URLs from Activity object
507 *
508 * @param array $data
509 *
510 * @return array The list of user URLs
511 */
512 function extract_recipients_from_activity( $data ) {
513 $recipient_items = array();
514
515 foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) {
516 if ( array_key_exists( $i, $data ) ) {
517 if ( is_array( $data[ $i ] ) ) {
518 $recipient = $data[ $i ];
519 } else {
520 $recipient = array( $data[ $i ] );
521 }
522 $recipient_items = array_merge( $recipient_items, $recipient );
523 }
524
525 if ( is_array( $data['object'] ) && array_key_exists( $i, $data['object'] ) ) {
526 if ( is_array( $data['object'][ $i ] ) ) {
527 $recipient = $data['object'][ $i ];
528 } else {
529 $recipient = array( $data['object'][ $i ] );
530 }
531 $recipient_items = array_merge( $recipient_items, $recipient );
532 }
533 }
534
535 $recipients = array();
536
537 // flatten array
538 foreach ( $recipient_items as $recipient ) {
539 if ( is_array( $recipient ) ) {
540 // check if recipient is an object
541 if ( array_key_exists( 'id', $recipient ) ) {
542 $recipients[] = $recipient['id'];
543 }
544 } else {
545 $recipients[] = $recipient;
546 }
547 }
548
549 return array_unique( $recipients );
550 }
551
552 /**
553 * Check if passed Activity is Public
554 *
555 * @param array $data The Activity object as array
556 *
557 * @return boolean True if public, false if not
558 */
559 function is_activity_public( $data ) {
560 $recipients = extract_recipients_from_activity( $data );
561
562 return in_array( 'https://www.w3.org/ns/activitystreams#Public', $recipients, true );
563 }
564
565 /**
566 * Get active users based on a given duration
567 *
568 * @param int $duration The duration to check in month(s)
569 *
570 * @return int The number of active users
571 */
572 function get_active_users( $duration = 1 ) {
573
574 $duration = intval( $duration );
575 $transient_key = sprintf( 'monthly_active_users_%d', $duration );
576 $count = get_transient( $transient_key );
577
578 if ( false === $count ) {
579 global $wpdb;
580 $query = "SELECT COUNT( DISTINCT post_author ) FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND post_date <= DATE_SUB( NOW(), INTERVAL %d MONTH )";
581 $query = $wpdb->prepare( $query, $duration );
582 $count = $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
583
584 set_transient( $transient_key, $count, DAY_IN_SECONDS );
585 }
586
587 // if 0 authors where active
588 if ( 0 === $count ) {
589 return 0;
590 }
591
592 // if single user mode
593 if ( is_single_user() ) {
594 return 1;
595 }
596
597 // if blog user is disabled
598 if ( is_user_disabled( Users::BLOG_USER_ID ) ) {
599 return $count;
600 }
601
602 // also count blog user
603 return $count + 1;
604 }
605
606 /**
607 * Get the total number of users
608 *
609 * @return int The total number of users
610 */
611 function get_total_users() {
612 // if single user mode
613 if ( is_single_user() ) {
614 return 1;
615 }
616
617 $users = \get_users(
618 array(
619 'capability__in' => array( 'publish_posts' ),
620 )
621 );
622
623 if ( is_array( $users ) ) {
624 $users = count( $users );
625 } else {
626 $users = 1;
627 }
628
629 // if blog user is disabled
630 if ( is_user_disabled( Users::BLOG_USER_ID ) ) {
631 return $users;
632 }
633
634 return $users + 1;
635 }
636
637 /**
638 * Examine a comment ID and look up an existing comment it represents.
639 *
640 * @param string $id ActivityPub object ID (usually a URL) to check.
641 *
642 * @return int|boolean Comment ID, or false on failure.
643 */
644 function object_id_to_comment( $id ) {
645 $comment_query = new WP_Comment_Query(
646 array(
647 'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
648 'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
649 )
650 );
651
652 if ( ! $comment_query->comments ) {
653 return false;
654 }
655
656 if ( count( $comment_query->comments ) > 1 ) {
657 return false;
658 }
659
660 return $comment_query->comments[0];
661 }
662
663 /**
664 * Verify if URL is a local comment,
665 * Or if it is a previously received remote comment
666 * (For threading comments locally)
667 *
668 * @param string $url The URL to check.
669 *
670 * @return int comment_ID or null if not found
671 */
672 function url_to_commentid( $url ) {
673 if ( ! $url || ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
674 return null;
675 }
676
677 $args = array(
678 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
679 'meta_query' => array(
680 'relation' => 'OR',
681 array(
682 'key' => 'source_url',
683 'value' => $url,
684 ),
685 array(
686 'key' => 'source_id',
687 'value' => $url,
688 ),
689 ),
690 );
691
692 $query = new \WP_Comment_Query();
693 $comments = $query->query( $args );
694
695 if ( $comments && is_array( $comments ) ) {
696 return $comments[0]->comment_ID;
697 }
698
699 return null;
700 }
701