diff --git a/README.md b/README.md index 93f3e85..4c41fcd 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ **Contributors:** [automattic](https://profiles.wordpress.org/automattic/), [pfefferle](https://profiles.wordpress.org/pfefferle/), [mediaformat](https://profiles.wordpress.org/mediaformat/), [mattwiebe](https://profiles.wordpress.org/mattwiebe/), [akirk](https://profiles.wordpress.org/akirk/), [jeherve](https://profiles.wordpress.org/jeherve/), [nuriapena](https://profiles.wordpress.org/nuriapena/), [cavalierlife](https://profiles.wordpress.org/cavalierlife/) **Tags:** OStatus, fediverse, activitypub, activitystream **Requires at least:** 4.7 -**Tested up to:** 6.3 -**Stable tag:** 1.0.7 +**Tested up to:** 6.4 +**Stable tag:** 1.1.0 **Requires PHP:** 5.6 **License:** MIT **License URI:** http://opensource.org/licenses/MIT @@ -105,6 +105,36 @@ Where 'blog' is the path to the subdirectory at which your blog resides. Project maintained on GitHub at [automattic/wordpress-activitypub](https://github.com/automattic/wordpress-activitypub). +### 1.1.0 ### + +* Improved: audio and video attachments are now supported! +* Improved: better error messages if remote profile is not accessible +* Improved: PHP 8.1 compatibility +* Fixed: don't try to parse mentions or hashtags for very large (>1MB) posts to prevent timeouts +* Fixed: better handling of ISO-639-1 locale codes +* Improved: more reliable [ap_author], props @uk3 +* Improved: NodeInfo statistics + +### 1.0.10 ### + +* Improved: better error messages if remote profile is not accessible + +### 1.0.9 ### + +* Fixed: broken following endpoint + +### 1.0.8 ### + +* Fixed: blocking of HEAD requests +* Fixed: PHP fatal error +* Fixed: several typos +* Fixed: error codes +* Improved: loading of shortcodes +* Updated: caching of followers +* Updated: Application-User is no longer "indexable" +* Updated: more consistent usage of the `application/activity+json` Content-Type +* Removed: featured tags endpoint + ### 1.0.7 ### * Fixed: broken function call diff --git a/activitypub.php b/activitypub.php index 521a379..380b438 100644 --- a/activitypub.php +++ b/activitypub.php @@ -3,7 +3,7 @@ * Plugin Name: ActivityPub * Plugin URI: https://github.com/pfefferle/wordpress-activitypub/ * Description: The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format. - * Version: 1.0.7 + * Version: 1.1.0 * Author: Matthias Pfefferle & Automattic * Author URI: https://automattic.com/ * License: MIT @@ -69,7 +69,6 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Collection\Followers', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Admin', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Hashtag', 'init' ) ); - \add_action( 'init', array( __NAMESPACE__ . '\Shortcodes', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Health_Check', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) ); diff --git a/includes/class-hashtag.php b/includes/class-hashtag.php index 2d03ac4..6031d1f 100644 --- a/includes/class-hashtag.php +++ b/includes/class-hashtag.php @@ -43,6 +43,10 @@ class Hashtag { * @return string the filtered post-content */ public static function the_content( $the_content ) { + // small protection against execution timeouts: limit to 1 MB + if ( mb_strlen( $the_content ) > MB_IN_BYTES ) { + return $the_content; + } $tag_stack = array(); $protected_tags = array( 'pre', diff --git a/includes/class-mention.php b/includes/class-mention.php index d55e5f2..beb6246 100644 --- a/includes/class-mention.php +++ b/includes/class-mention.php @@ -26,6 +26,10 @@ class Mention { * @return string the filtered post-content */ public static function the_content( $the_content ) { + // small protection against execution timeouts: limit to 1 MB + if ( mb_strlen( $the_content ) > MB_IN_BYTES ) { + return $the_content; + } $tag_stack = array(); $protected_tags = array( 'pre', diff --git a/includes/class-shortcodes.php b/includes/class-shortcodes.php index 43be17b..491a6ad 100644 --- a/includes/class-shortcodes.php +++ b/includes/class-shortcodes.php @@ -5,14 +5,9 @@ use function Activitypub\esc_hashtag; class Shortcodes { /** - * Class constructor, registering WordPress then Shortcodes + * Register the shortcodes */ - public static function init() { - // do not load on admin pages - if ( is_admin() ) { - return; - } - + public static function register() { foreach ( get_class_methods( self::class ) as $shortcode ) { if ( 'init' !== $shortcode ) { add_shortcode( 'ap_' . $shortcode, array( self::class, $shortcode ) ); @@ -20,6 +15,17 @@ class Shortcodes { } } + /** + * Unregister the shortcodes + */ + public static function unregister() { + foreach ( get_class_methods( self::class ) as $shortcode ) { + if ( 'init' !== $shortcode ) { + remove_shortcode( 'ap_' . $shortcode ); + } + } + } + /** * Generates output for the 'ap_hashtags' shortcode * @@ -384,7 +390,8 @@ class Shortcodes { return ''; } - $name = \get_the_author_meta( 'display_name', $item->post_author ); + $author_id = \get_post_field( 'post_author', $item->ID ); + $name = \get_the_author_meta( 'display_name', $author_id ); if ( ! $name ) { return ''; @@ -409,7 +416,8 @@ class Shortcodes { return ''; } - $url = \get_the_author_meta( 'user_url', $item->post_author ); + $author_id = \get_post_field( 'post_author', $item->ID ); + $url = \get_the_author_meta( 'user_url', $author_id ); if ( ! $url ) { return ''; diff --git a/includes/class-signature.php b/includes/class-signature.php index 1789dd6..d021cf0 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -4,6 +4,7 @@ namespace Activitypub; use WP_Error; use DateTime; use DateTimeZone; +use WP_REST_Request; use Activitypub\Collection\Users; /** @@ -226,7 +227,7 @@ class Signature { /** * Verifies the http signatures * - * @param WP_REQUEST|array $request The request object or $_SERVER array. + * @param WP_REST_Request|array $request The request object or $_SERVER array. * * @return mixed A boolean or WP_Error. */ @@ -259,7 +260,7 @@ class Signature { } if ( ! isset( $headers['signature'] ) ) { - return new WP_Error( 'activitypub_signature', __( 'Request not signed', 'activitypub' ), array( 'status' => 403 ) ); + return new WP_Error( 'activitypub_signature', __( 'Request not signed', 'activitypub' ), array( 'status' => 401 ) ); } if ( array_key_exists( 'signature', $headers ) ) { @@ -269,7 +270,7 @@ class Signature { } if ( ! isset( $signature_block ) || ! $signature_block ) { - return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 403 ) ); + return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 401 ) ); } $signed_headers = $signature_block['headers']; @@ -279,12 +280,12 @@ class Signature { $signed_data = self::get_signed_data( $signed_headers, $signature_block, $headers ); if ( ! $signed_data ) { - return new WP_Error( 'activitypub_signature', __( 'Signed request date outside acceptable time window', 'activitypub' ), array( 'status' => 403 ) ); + return new WP_Error( 'activitypub_signature', __( 'Signed request date outside acceptable time window', 'activitypub' ), array( 'status' => 401 ) ); } $algorithm = self::get_signature_algorithm( $signature_block ); if ( ! $algorithm ) { - return new WP_Error( 'activitypub_signature', __( 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)', 'activitypub' ), array( 'status' => 403 ) ); + return new WP_Error( 'activitypub_signature', __( 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)', 'activitypub' ), array( 'status' => 401 ) ); } if ( \in_array( 'digest', $signed_headers, true ) && isset( $body ) ) { @@ -300,7 +301,7 @@ class Signature { } if ( \base64_encode( \hash( $hashalg, $body, true ) ) !== $digest[1] ) { // phpcs:ignore - return new WP_Error( 'activitypub_signature', __( 'Invalid Digest header', 'activitypub' ), array( 'status' => 403 ) ); + return new WP_Error( 'activitypub_signature', __( 'Invalid Digest header', 'activitypub' ), array( 'status' => 401 ) ); } } @@ -313,7 +314,7 @@ class Signature { $verified = \openssl_verify( $signed_data, $signature_block['signature'], $public_key, $algorithm ) > 0; if ( ! $verified ) { - return new WP_Error( 'activitypub_signature', __( 'Invalid signature', 'activitypub' ), array( 'status' => 403 ) ); + return new WP_Error( 'activitypub_signature', __( 'Invalid signature', 'activitypub' ), array( 'status' => 401 ) ); } return $verified; } @@ -323,17 +324,25 @@ class Signature { * * @param string $key_id The URL to the public key. * - * @return WP_Error|string The public key. + * @return WP_Error|string The public key or WP_Error. */ public static function get_remote_key( $key_id ) { // phpcs:ignore $actor = get_remote_metadata_by_actor( strip_fragment_from_url( $key_id ) ); // phpcs:ignore if ( \is_wp_error( $actor ) ) { - return $actor; + return new WP_Error( + 'activitypub_no_remote_profile_found', + __( 'No Profile found or Profile not accessible', 'activitypub' ), + array( 'status' => 401 ) + ); } if ( isset( $actor['publicKey']['publicKeyPem'] ) ) { return \rtrim( $actor['publicKey']['publicKeyPem'] ); // phpcs:ignore } - return new WP_Error( 'activitypub_no_remote_key_found', __( 'No Public-Key found', 'activitypub' ), array( 'status' => 403 ) ); + return new WP_Error( + 'activitypub_no_remote_key_found', + __( 'No Public-Key found', 'activitypub' ), + array( 'status' => 401 ) + ); } /** diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index a9fe298..c2ad01f 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -173,8 +173,6 @@ class Followers { return new WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) ); } - $error = null; - $follower = new Follower(); $follower->from_array( $meta ); @@ -184,14 +182,10 @@ class Followers { return $id; } - $meta = get_post_meta( $id, 'activitypub_user_id' ); - - if ( $error ) { - self::add_error( $id, $error ); - } + $post_meta = get_post_meta( $id, 'activitypub_user_id' ); // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict - if ( is_array( $meta ) && ! in_array( $user_id, $meta ) ) { + if ( is_array( $post_meta ) && ! in_array( $user_id, $post_meta ) ) { add_post_meta( $id, 'activitypub_user_id', $user_id ); wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' ); } diff --git a/includes/compat.php b/includes/compat.php index 4bee640..3dd405c 100644 --- a/includes/compat.php +++ b/includes/compat.php @@ -35,3 +35,15 @@ if ( ! function_exists( 'get_self_link' ) ) { return esc_url( apply_filters( 'self_link', set_url_scheme( 'http://' . $host['host'] . $path ) ) ); } } + +if ( ! function_exists( 'is_countable' ) ) { + /** + * Polyfill for `is_countable()` function added in PHP 7.3. + * + * @param mixed $value The value to check. + * @return bool True if `$value` is countable, otherwise false. + */ + function is_countable( $value ) { + return is_array( $value ) || $value instanceof \Countable; + } +} diff --git a/includes/functions.php b/includes/functions.php index 883cd3f..ecb8765 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -5,6 +5,7 @@ use WP_Error; use Activitypub\Http; use Activitypub\Activity\Activity; use Activitypub\Collection\Followers; +use Activitypub\Collection\Users; /** * Returns the ActivityPub default JSON-context @@ -42,7 +43,7 @@ function get_webfinger_resource( $user_id ) { * @param string $actor The Actor URL. * @param bool $cached If the result should be cached. * - * @return array The Actor profile as array + * @return array|WP_Error The Actor profile as array or WP_Error on failure. */ function get_remote_metadata_by_actor( $actor, $cached = true ) { $pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor ); @@ -74,32 +75,25 @@ function get_remote_metadata_by_actor( $actor, $cached = true ) { if ( ! \wp_http_validate_url( $actor ) ) { $metadata = new WP_Error( 'activitypub_no_valid_actor_url', \__( 'The "actor" is no valid URL', 'activitypub' ), array( 'status' => 400, 'actor' => $actor ) ); - \set_transient( $transient_key, $metadata, HOUR_IN_SECONDS ); // Cache the error for a shorter period. return $metadata; } - $short_timeout = function() { - return 10; - }; - add_filter( 'activitypub_remote_get_timeout', $short_timeout ); $response = Http::get( $actor ); - remove_filter( 'activitypub_remote_get_timeout', $short_timeout ); + if ( \is_wp_error( $response ) ) { - \set_transient( $transient_key, $response, HOUR_IN_SECONDS ); // Cache the error for a shorter period. return $response; } $metadata = \wp_remote_retrieve_body( $response ); $metadata = \json_decode( $metadata, true ); - \set_transient( $transient_key, $metadata, WEEK_IN_SECONDS ); - if ( ! $metadata ) { $metadata = new WP_Error( 'activitypub_invalid_json', \__( 'No valid JSON data', 'activitypub' ), array( 'status' => 400, 'actor' => $actor ) ); - \set_transient( $transient_key, $metadata, HOUR_IN_SECONDS ); // Cache the error for a shorter period. return $metadata; } + \set_transient( $transient_key, $metadata, WEEK_IN_SECONDS ); + return $metadata; } @@ -481,3 +475,75 @@ function is_json( $data ) { function is_blog_public() { return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) ); } + +/** + * Get active users based on a given duration + * + * @param int $duration The duration to check in month(s) + * + * @return int The number of active users + */ +function get_active_users( $duration = 1 ) { + + $duration = intval( $duration ); + $transient_key = sprintf( 'monthly_active_users_%d', $duration ); + $count = get_transient( $transient_key ); + + if ( false === $count ) { + global $wpdb; + $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 )"; + $query = $wpdb->prepare( $query, $duration ); + $count = $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + + set_transient( $transient_key, $count, DAY_IN_SECONDS ); + } + + // if 0 authors where active + if ( 0 === $count ) { + return 0; + } + + // if single user mode + if ( is_single_user() ) { + return 1; + } + + // if blog user is disabled + if ( is_user_disabled( Users::BLOG_USER_ID ) ) { + return $count; + } + + // also count blog user + return $count + 1; +} + +/** + * Get the total number of users + * + * @return int The total number of users + */ +function get_total_users() { + // if single user mode + if ( is_single_user() ) { + return 1; + } + + $users = \get_users( + array( + 'capability__in' => array( 'publish_posts' ), + ) + ); + + if ( is_array( $users ) ) { + $users = count( $users ); + } else { + $users = 1; + } + + // if blog user is disabled + if ( is_user_disabled( Users::BLOG_USER_ID ) ) { + return $users; + } + + return $users + 1; +} diff --git a/includes/model/class-application-user.php b/includes/model/class-application-user.php index 1cfcec0..cf4d9cc 100644 --- a/includes/model/class-application-user.php +++ b/includes/model/class-application-user.php @@ -58,10 +58,6 @@ class Application_User extends Blog_User { return null; } - public function get_featured_tags() { - return null; - } - public function get_featured() { return null; } @@ -69,4 +65,8 @@ class Application_User extends Blog_User { public function get_moderators() { return null; } + + public function get_indexable() { + return false; + } } diff --git a/includes/model/class-user.php b/includes/model/class-user.php index f62772d..95c83d7 100644 --- a/includes/model/class-user.php +++ b/includes/model/class-user.php @@ -18,15 +18,6 @@ class User extends Actor { */ protected $_id; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore - /** - * The Featured-Tags. - * - * @see https://docs.joinmastodon.org/spec/activitypub/#featuredTags - * - * @var string - */ - protected $featured_tags; - /** * The Featured-Posts. * @@ -235,15 +226,6 @@ class User extends Actor { return get_rest_url_by_path( sprintf( 'users/%d/collections/featured', $this->get__id() ) ); } - /** - * Returns the Featured-Tags-API-Endpoint. - * - * @return string The Featured-Tags-Endpoint. - */ - public function get_featured_tags() { - return get_rest_url_by_path( sprintf( 'users/%d/collections/tags', $this->get__id() ) ); - } - /** * Extend the User-Output with Attachments. * diff --git a/includes/rest/class-collection.php b/includes/rest/class-collection.php index 5fa585d..365641c 100644 --- a/includes/rest/class-collection.php +++ b/includes/rest/class-collection.php @@ -105,7 +105,7 @@ class Collection { '@context' => Activity::CONTEXT, 'id' => get_rest_url_by_path( sprintf( 'users/%d/collections/tags', $user->get__id() ) ), 'type' => 'Collection', - 'totalItems' => count( $tags ), + 'totalItems' => is_countable( $tags ) ? count( $tags ) : 0, 'items' => array(), ); @@ -117,7 +117,10 @@ class Collection { ); } - return new WP_REST_Response( $response, 200 ); + $rest_response = new WP_REST_Response( $response, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); + + return $rest_response; } /** @@ -160,7 +163,7 @@ class Collection { '@context' => Activity::CONTEXT, 'id' => get_rest_url_by_path( sprintf( 'users/%d/collections/featured', $user_id ) ), 'type' => 'OrderedCollection', - 'totalItems' => count( $posts ), + 'totalItems' => is_countable( $posts ) ? count( $posts ) : 0, 'orderedItems' => array(), ); @@ -168,7 +171,10 @@ class Collection { $response['orderedItems'][] = Post::transform( $post )->to_object()->to_array(); } - return new WP_REST_Response( $response, 200 ); + $rest_response = new WP_REST_Response( $response, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); + + return $rest_response; } /** @@ -192,7 +198,10 @@ class Collection { $response['orderedItems'][] = $user->get_url(); } - return new WP_REST_Response( $response, 200 ); + $rest_response = new WP_REST_Response( $response, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); + + return $rest_response; } /** diff --git a/includes/rest/class-followers.php b/includes/rest/class-followers.php index b7be9d0..71e4840 100644 --- a/includes/rest/class-followers.php +++ b/includes/rest/class-followers.php @@ -103,10 +103,10 @@ class Followers { $data['followers'] ); - $response = new WP_REST_Response( $json, 200 ); - $response->header( 'Content-Type', 'application/activity+json' ); + $rest_response = new WP_REST_Response( $json, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); - return $response; + return $rest_response; } /** diff --git a/includes/rest/class-following.php b/includes/rest/class-following.php index 33bdf65..58e4375 100644 --- a/includes/rest/class-following.php +++ b/includes/rest/class-following.php @@ -1,6 +1,7 @@ totalItems = count( $items ); // phpcs:ignore + $json->totalItems = is_countable( $items ) ? count( $items ) : 0; // phpcs:ignore $json->orderedItems = $items; // phpcs:ignore $json->first = $json->partOf; // phpcs:ignore - $response = new \WP_REST_Response( $json, 200 ); - $response->header( 'Content-Type', 'application/activity+json' ); + $rest_response = new WP_REST_Response( $json, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); - return $response; + return $rest_response; } /** diff --git a/includes/rest/class-inbox.php b/includes/rest/class-inbox.php index 5d65b9b..9088993 100644 --- a/includes/rest/class-inbox.php +++ b/includes/rest/class-inbox.php @@ -109,11 +109,10 @@ class Inbox { */ \do_action( 'activitypub_inbox_post' ); - $response = new WP_REST_Response( $json, 200 ); + $rest_response = new WP_REST_Response( $json, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); - $response->header( 'Content-Type', 'application/activity+json' ); - - return $response; + return $rest_response; } /** @@ -138,7 +137,10 @@ class Inbox { \do_action( 'activitypub_inbox', $data, $user->get__id(), $type ); \do_action( "activitypub_inbox_{$type}", $data, $user->get__id() ); - return new WP_REST_Response( array(), 202 ); + $rest_response = new WP_REST_Response( array(), 202 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); + + return $rest_response; } /** @@ -158,7 +160,7 @@ class Inbox { 'rest_invalid_param', \__( 'No recipients found', 'activitypub' ), array( - 'status' => 404, + 'status' => 400, 'params' => array( 'to' => \__( 'Please check/validate "to" field', 'activitypub' ), 'bto' => \__( 'Please check/validate "bto" field', 'activitypub' ), @@ -183,7 +185,10 @@ class Inbox { \do_action( "activitypub_inbox_{$type}", $data, $user->ID ); } - return new WP_REST_Response( array(), 202 ); + $rest_response = new WP_REST_Response( array(), 202 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); + + return $rest_response; } /** @@ -416,7 +421,7 @@ class Inbox { $recipient_items = array_merge( $recipient_items, $recipient ); } - if ( array_key_exists( $i, $data['object'] ) ) { + if ( is_array( $data['object'] ) && array_key_exists( $i, $data['object'] ) ) { if ( is_array( $data['object'][ $i ] ) ) { $recipient = $data['object'][ $i ]; } else { diff --git a/includes/rest/class-nodeinfo.php b/includes/rest/class-nodeinfo.php index 1f6277a..62151ff 100644 --- a/includes/rest/class-nodeinfo.php +++ b/includes/rest/class-nodeinfo.php @@ -3,6 +3,8 @@ namespace Activitypub\Rest; use WP_REST_Response; +use function Activitypub\get_total_users; +use function Activitypub\get_active_users; use function Activitypub\get_rest_url_by_path; /** @@ -82,24 +84,14 @@ class Nodeinfo { 'version' => \get_bloginfo( 'version' ), ); - $users = \get_users( - array( - 'capability__in' => array( 'publish_posts' ), - ) - ); - - if ( is_array( $users ) ) { - $users = count( $users ); - } else { - $users = 1; - } - $posts = \wp_count_posts(); $comments = \wp_count_comments(); $nodeinfo['usage'] = array( 'users' => array( - 'total' => $users, + 'total' => get_total_users(), + 'activeMonth' => get_active_users( '1 month ago' ), + 'activeHalfyear' => get_active_users( '6 month ago' ), ), 'localPosts' => (int) $posts->publish, 'localComments' => (int) $comments->approved, @@ -139,24 +131,14 @@ class Nodeinfo { 'version' => \get_bloginfo( 'version' ), ); - $users = \get_users( - array( - 'capability__in' => array( 'publish_posts' ), - ) - ); - - if ( is_array( $users ) ) { - $users = count( $users ); - } else { - $users = 1; - } - $posts = \wp_count_posts(); $comments = \wp_count_comments(); $nodeinfo['usage'] = array( 'users' => array( - 'total' => (int) $users, + 'total' => get_total_users(), + 'activeMonth' => get_active_users( 1 ), + 'activeHalfyear' => get_active_users( 6 ), ), 'localPosts' => (int) $posts->publish, 'localComments' => (int) $comments->approved, diff --git a/includes/rest/class-outbox.php b/includes/rest/class-outbox.php index eb35e86..d640d17 100644 --- a/includes/rest/class-outbox.php +++ b/includes/rest/class-outbox.php @@ -123,11 +123,10 @@ class Outbox { */ \do_action( 'activitypub_outbox_post' ); - $response = new WP_REST_Response( $json, 200 ); + $rest_response = new WP_REST_Response( $json, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); - $response->header( 'Content-Type', 'application/activity+json' ); - - return $response; + return $rest_response; } /** diff --git a/includes/rest/class-server.php b/includes/rest/class-server.php index e1a1037..0e6e4cc 100644 --- a/includes/rest/class-server.php +++ b/includes/rest/class-server.php @@ -2,6 +2,7 @@ namespace Activitypub\Rest; use stdClass; +use WP_Error; use WP_REST_Response; use Activitypub\Signature; use Activitypub\Model\Application_User; @@ -54,11 +55,10 @@ class Server { $json = $user->to_array(); - $response = new WP_REST_Response( $json, 200 ); + $rest_response = new WP_REST_Response( $json, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); - $response->header( 'Content-Type', 'application/activity+json' ); - - return $response; + return $rest_response; } /** @@ -74,6 +74,10 @@ class Server { * @return mixed|WP_Error The response, error, or modified response. */ public static function authorize_activitypub_requests( $response, $handler, $request ) { + if ( 'HEAD' === $request->get_method() ) { + return $response; + } + $route = $request->get_route(); // check if it is an activitypub request and exclude webfinger and nodeinfo endpoints @@ -86,16 +90,16 @@ class Server { } // POST-Requets are always signed - if ( 'get' !== \strtolower( $request->get_method() ) ) { + if ( 'GET' !== $request->get_method() ) { $verified_request = Signature::verify_http_signature( $request ); if ( \is_wp_error( $verified_request ) ) { - return $verified_request; + return new WP_Error( 'activitypub_signature_verification', $verified_request->get_error_message(), array( 'status' => 401 ) ); } - } elseif ( 'get' === \strtolower( $request->get_method() ) ) { // GET-Requests are only signed in secure mode + } elseif ( 'GET' === $request->get_method() ) { // GET-Requests are only signed in secure mode if ( ACTIVITYPUB_AUTHORIZED_FETCH ) { $verified_request = Signature::verify_http_signature( $request ); if ( \is_wp_error( $verified_request ) ) { - return $verified_request; + return new WP_Error( 'activitypub_signature_verification', $verified_request->get_error_message(), array( 'status' => 401 ) ); } } } diff --git a/includes/rest/class-users.php b/includes/rest/class-users.php index 31a92dd..9fb10ba 100644 --- a/includes/rest/class-users.php +++ b/includes/rest/class-users.php @@ -95,10 +95,10 @@ class Users { $json = $user->to_array(); - $response = new WP_REST_Response( $json, 200 ); - $response->header( 'Content-Type', 'application/activity+json' ); + $rest_response = new WP_REST_Response( $json, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); - return $response; + return $rest_response; } diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 6e2f0aa..2117ce7 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -5,6 +5,7 @@ use WP_Post; use Activitypub\Collection\Users; use Activitypub\Model\Blog_User; use Activitypub\Activity\Base_Object; +use Activitypub\Shortcodes; use function Activitypub\esc_hashtag; use function Activitypub\is_single_user; @@ -81,7 +82,7 @@ class Post { $object->set_content( $this->get_content() ); $object->set_content_map( array( - \strstr( \get_locale(), '_', true ) => $this->get_content(), + $this->get_locale() => $this->get_content(), ) ); $path = sprintf( 'users/%d/followers', intval( $wp_post->post_author ) ); @@ -142,78 +143,65 @@ class Post { } /** - * Returns the Image Attachments for this Post, parsed from blocks. - * @param int $max_images The maximum number of images to return. - * @param array $image_ids The image IDs to append new IDs to. + * Generates all Media Attachments for a Post. * - * @return array The image IDs. - */ - protected function get_block_image_ids( $max_images, $image_ids = [] ) { - $blocks = \parse_blocks( $this->wp_post->post_content ); - return self::get_image_ids_from_blocks( $blocks, $image_ids, $max_images ); - } - - /** - * Recursively get image IDs from blocks. - * @param array $blocks The blocks to search for image IDs - * @param array $image_ids The image IDs to append new IDs to - * @param int $max_images The maximum number of images to return. - * - * @return array The image IDs. - */ - protected static function get_image_ids_from_blocks( $blocks, $image_ids, $max_images ) { - foreach ( $blocks as $block ) { - // recurse into inner blocks - if ( ! empty( $block['innerBlocks'] ) ) { - $image_ids = self::get_image_ids_from_blocks( $block['innerBlocks'], $image_ids, $max_images ); - } - - switch ( $block['blockName'] ) { - case 'core/image': - case 'core/cover': - if ( ! empty( $block['attrs']['id'] ) ) { - $image_ids[] = $block['attrs']['id']; - } - break; - case 'jetpack/slideshow': - case 'jetpack/tiled-gallery': - if ( ! empty( $block['attrs']['ids'] ) ) { - $image_ids = array_merge( $image_ids, $block['attrs']['ids'] ); - } - break; - case 'jetpack/image-compare': - if ( ! empty( $block['attrs']['beforeImageId'] ) ) { - $image_ids[] = $block['attrs']['beforeImageId']; - } - if ( ! empty( $block['attrs']['afterImageId'] ) ) { - $image_ids[] = $block['attrs']['afterImageId']; - } - break; - } - - // we could be at or over max, stop unneeded work - if ( count( $image_ids ) >= $max_images ) { - break; - } - } - - // still need to slice it because one gallery could knock us over the limit - return \array_slice( $image_ids, 0, $max_images ); - } - - /** - * Generates all Image Attachments for a Post. - * - * @return array The Image Attachments. + * @return array The Attachments. */ protected function get_attachments() { - $max_images = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) ); + // Once upon a time we only supported images, but we now support audio/video as well. + // We maintain the image-centric naming for backwards compatibility. + $max_media = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) ); - $images = array(); + if ( site_supports_blocks() && \has_blocks( $this->wp_post->post_content ) ) { + return $this->get_block_attachments( $max_media ); + } + return $this->get_classic_editor_images( $max_media ); + } + + /** + * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments. + * + * @param int $max_media The maximum number of attachments to return. + * + * @return array The attachments. + */ + protected function get_block_attachments( $max_media ) { + // max media can't be negative or zero + if ( $max_media <= 0 ) { + return array(); + } + + $id = $this->wp_post->ID; + + $media_ids = array(); + + // list post thumbnail first if this post has one + if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) { + $media_ids[] = \get_post_thumbnail_id( $id ); + } + + if ( $max_media > 0 ) { + $blocks = \parse_blocks( $this->wp_post->post_content ); + $media_ids = self::get_media_ids_from_blocks( $blocks, $media_ids, $max_media ); + } + $media_ids = \array_unique( $media_ids ); + + return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $media_ids ) ); + } + + /** + * Get image attachments from the classic editor. + * Note that audio/video attachments are only supported in the block editor. + * + * @param int $max_images The maximum number of images to return. + * + * @return array The attachments. + */ + protected function get_classic_editor_images( $max_images ) { // max images can't be negative or zero if ( $max_images <= 0 ) { - return $images; + return array(); } $id = $this->wp_post->ID; @@ -227,68 +215,144 @@ class Post { } if ( $max_images > 0 ) { - // first try to get images that are actually in the post content - if ( site_supports_blocks() && \has_blocks( $this->wp_post->post_content ) ) { - $block_image_ids = $this->get_block_image_ids( $max_images, $image_ids ); - $image_ids = \array_merge( $image_ids, $block_image_ids ); - } else { - // fallback to images attached to the post - $query = new \WP_Query( - array( - 'post_parent' => $id, - 'post_status' => 'inherit', - 'post_type' => 'attachment', - 'post_mime_type' => 'image', - 'order' => 'ASC', - 'orderby' => 'menu_order ID', - 'posts_per_page' => $max_images, - ) - ); - foreach ( $query->get_posts() as $attachment ) { - if ( ! \in_array( $attachment->ID, $image_ids, true ) ) { - $image_ids[] = $attachment->ID; - } + $query = new \WP_Query( + array( + 'post_parent' => $id, + 'post_status' => 'inherit', + 'post_type' => 'attachment', + 'post_mime_type' => 'image', + 'order' => 'ASC', + 'orderby' => 'menu_order ID', + 'posts_per_page' => $max_images, + ) + ); + foreach ( $query->get_posts() as $attachment ) { + if ( ! \in_array( $attachment->ID, $image_ids, true ) ) { + $image_ids[] = $attachment->ID; } } } - $image_ids = \array_unique( $image_ids ); - // get URLs for each image - foreach ( $image_ids as $id ) { - $image_size = 'full'; + return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $image_ids ) ); + } - /** - * Filter the image URL returned for each post. - * - * @param array|false $thumbnail The image URL, or false if no image is available. - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'full' by default. - */ - $thumbnail = apply_filters( - 'activitypub_get_image', - $this->get_image( $id, $image_size ), - $id, - $image_size - ); + /** + * Recursively get media IDs from blocks. + * @param array $blocks The blocks to search for media IDs + * @param array $media_ids The media IDs to append new IDs to + * @param int $max_media The maximum number of media to return. + * + * @return array The image IDs. + */ + protected static function get_media_ids_from_blocks( $blocks, $media_ids, $max_media ) { - if ( $thumbnail ) { - $mimetype = \get_post_mime_type( $id ); - $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true ); - $image = array( - 'type' => 'Image', - 'url' => $thumbnail[0], - 'mediaType' => $mimetype, - ); + foreach ( $blocks as $block ) { + // recurse into inner blocks + if ( ! empty( $block['innerBlocks'] ) ) { + $media_ids = self::get_media_ids_from_blocks( $block['innerBlocks'], $media_ids, $max_media ); + } - if ( $alt ) { - $image['name'] = $alt; - } - $images[] = $image; + switch ( $block['blockName'] ) { + case 'core/image': + case 'core/cover': + case 'core/audio': + case 'core/video': + case 'videopress/video': + if ( ! empty( $block['attrs']['id'] ) ) { + $media_ids[] = $block['attrs']['id']; + } + break; + case 'jetpack/slideshow': + case 'jetpack/tiled-gallery': + if ( ! empty( $block['attrs']['ids'] ) ) { + $media_ids = array_merge( $media_ids, $block['attrs']['ids'] ); + } + break; + case 'jetpack/image-compare': + if ( ! empty( $block['attrs']['beforeImageId'] ) ) { + $media_ids[] = $block['attrs']['beforeImageId']; + } + if ( ! empty( $block['attrs']['afterImageId'] ) ) { + $media_ids[] = $block['attrs']['afterImageId']; + } + break; + } + + // stop doing unneeded work + if ( count( $media_ids ) >= $max_media ) { + break; } } - return $images; + // still need to slice it because one gallery could knock us over the limit + return array_slice( $media_ids, 0, $max_media ); + } + + /** + * Converts a WordPress Attachment to an ActivityPub Attachment. + * + * @param int $id The Attachment ID. + * + * @return array The ActivityPub Attachment. + */ + public static function wp_attachment_to_activity_attachment( $id ) { + $attachment = array(); + $mime_type = \get_post_mime_type( $id ); + $mime_type_parts = \explode( '/', $mime_type ); + // switching on image/audio/video + switch ( $mime_type_parts[0] ) { + case 'image': + $image_size = 'full'; + + /** + * Filter the image URL returned for each post. + * + * @param array|false $thumbnail The image URL, or false if no image is available. + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'full' by default. + */ + $thumbnail = apply_filters( + 'activitypub_get_image', + self::get_image( $id, $image_size ), + $id, + $image_size + ); + + if ( $thumbnail ) { + $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true ); + $image = array( + 'type' => 'Image', + 'url' => $thumbnail[0], + 'mediaType' => $mime_type, + ); + + if ( $alt ) { + $image['name'] = $alt; + } + $attachment = $image; + } + break; + + case 'audio': + case 'video': + $attachment = array( + 'type' => 'Document', + 'mediaType' => $mime_type, + 'url' => \wp_get_attachment_url( $id ), + 'name' => \get_the_title( $id ), + ); + $meta = wp_get_attachment_metadata( $id ); + // height and width for videos + if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) { + $attachment['width'] = $meta['width']; + $attachment['height'] = $meta['height']; + } + // @todo: add `icon` support for audio/video attachments. Maybe use post thumbnail? + break; + } + + return \apply_filters( 'activitypub_attachment', $attachment, $id ); } /** @@ -299,7 +363,7 @@ class Post { * * @return array|false Array of image data, or boolean false if no image is available. */ - protected function get_image( $id, $image_size = 'full' ) { + protected static function get_image( $id, $image_size = 'full' ) { /** * Hook into the image retrieval process. Before image retrieval. * @@ -308,7 +372,7 @@ class Post { */ do_action( 'activitypub_get_image_pre', $id, $image_size ); - $thumbnail = \wp_get_attachment_image_src( $id, $image_size ); + $image = \wp_get_attachment_image_src( $id, $image_size ); /** * Hook into the image retrieval process. After image retrieval. @@ -318,7 +382,7 @@ class Post { */ do_action( 'activitypub_get_image_post', $id, $image_size ); - return $thumbnail; + return $image; } /** @@ -334,6 +398,8 @@ class Post { return \ucfirst( \get_option( 'activitypub_object_type', 'note' ) ); } + // Default to Article. + $object_type = 'Article'; $post_type = \get_post_type( $this->wp_post ); switch ( $post_type ) { case 'post': @@ -466,6 +532,8 @@ class Post { $post = $this->wp_post; $content = $this->get_post_content_template(); + // Register our shortcodes just in time. + Shortcodes::register(); // Fill in the shortcodes. setup_postdata( $post ); $content = do_shortcode( $content ); @@ -477,6 +545,9 @@ class Post { $content = \apply_filters( 'activitypub_the_content', $content, $post ); + // Don't need these any more, should never appear in a post. + Shortcodes::unregister(); + return $content; } @@ -509,4 +580,25 @@ class Post { protected function get_mentions() { return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_post->post_content, $this->wp_post ); } + + /** + * Returns the locale of the post. + * + * @return string The locale of the post. + */ + public function get_locale() { + $post_id = $this->wp_post->ID; + $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); + + /** + * Filter the locale of the post. + * + * @param string $lang The locale of the post. + * @param int $post_id The post ID. + * @param WP_Post $post The post object. + * + * @return string The filtered locale of the post. + */ + return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_post ); + } } diff --git a/integration/class-nodeinfo.php b/integration/class-nodeinfo.php index f1e2506..dea6c67 100644 --- a/integration/class-nodeinfo.php +++ b/integration/class-nodeinfo.php @@ -1,6 +1,9 @@ get_total_users(), + 'activeMonth' => get_active_users( '1 month ago' ), + 'activeHalfyear' => get_active_users( '6 month ago' ), + ); + return $nodeinfo; } @@ -44,6 +53,12 @@ class Nodeinfo { public static function add_nodeinfo2_discovery( $nodeinfo ) { $nodeinfo['protocols'][] = 'activitypub'; + $nodeinfo['usage']['users'] = array( + 'total' => get_total_users(), + 'activeMonth' => get_active_users( '1 month ago' ), + 'activeHalfyear' => get_active_users( '6 month ago' ), + ); + return $nodeinfo; } } diff --git a/readme.txt b/readme.txt index 03e9b39..eed006a 100644 --- a/readme.txt +++ b/readme.txt @@ -2,8 +2,8 @@ Contributors: automattic, pfefferle, mediaformat, mattwiebe, akirk, jeherve, nuriapena, cavalierlife Tags: OStatus, fediverse, activitypub, activitystream Requires at least: 4.7 -Tested up to: 6.3 -Stable tag: 1.0.7 +Tested up to: 6.4 +Stable tag: 1.1.0 Requires PHP: 5.6 License: MIT License URI: http://opensource.org/licenses/MIT @@ -105,6 +105,36 @@ Where 'blog' is the path to the subdirectory at which your blog resides. Project maintained on GitHub at [automattic/wordpress-activitypub](https://github.com/automattic/wordpress-activitypub). += 1.1.0 = + +* Improved: audio and video attachments are now supported! +* Improved: better error messages if remote profile is not accessible +* Improved: PHP 8.1 compatibility +* Fixed: don't try to parse mentions or hashtags for very large (>1MB) posts to prevent timeouts +* Fixed: better handling of ISO-639-1 locale codes +* Improved: more reliable [ap_author], props @uk3 +* Improved: NodeInfo statistics + += 1.0.10 = + +* Improved: better error messages if remote profile is not accessible + += 1.0.9 = + +* Fixed: broken following endpoint + += 1.0.8 = + +* Fixed: blocking of HEAD requests +* Fixed: PHP fatal error +* Fixed: several typos +* Fixed: error codes +* Improved: loading of shortcodes +* Updated: caching of followers +* Updated: Application-User is no longer "indexable" +* Updated: more consistent usage of the `application/activity+json` Content-Type +* Removed: featured tags endpoint + = 1.0.7 = * Fixed: broken function call diff --git a/templates/settings.php b/templates/settings.php index fd80145..5ce90e1 100644 --- a/templates/settings.php +++ b/templates/settings.php @@ -138,7 +138,7 @@ - + @@ -147,13 +147,20 @@ echo \wp_kses( \sprintf( // translators: - \__( 'The number of images to attach to posts. Default: %s', 'activitypub' ), + \__( 'The number of media (images, audio, video) to attach to posts. Default: %s', 'activitypub' ), \esc_html( ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ), 'default' ); ?>

+

+ + + +

diff --git a/tests/test-class-activitypub-shortcodes.php b/tests/test-class-activitypub-shortcodes.php index 8637a61..b1413e8 100644 --- a/tests/test-class-activitypub-shortcodes.php +++ b/tests/test-class-activitypub-shortcodes.php @@ -1,6 +1,10 @@ assertEquals( '

hallo

', $content ); + Shortcodes::unregister(); } public function test_password_protected_content() { + Shortcodes::register(); global $post; $post_id = -98; // negative ID, to avoid clash with a valid post @@ -54,5 +60,6 @@ class Test_Activitypub_Shortcodes extends WP_UnitTestCase { wp_reset_postdata(); $this->assertEquals( '', $content ); + Shortcodes::unregister(); } }