PluginProbe
ActivityPub / 2.0.0
ActivityPub v2.0.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 2.0.0, at includes/functions.php

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