diff --git a/activitypub.php b/activitypub.php index 1e0d44b..0db8e51 100644 --- a/activitypub.php +++ b/activitypub.php @@ -136,3 +136,38 @@ function enable_buddypress_features() { Integration\Buddypress::init(); } add_action( 'bp_include', '\Activitypub\enable_buddypress_features' ); + +/** + * `get_plugin_data` wrapper + * + * @return array The plugin metadata array + */ +function get_plugin_meta( $default_headers = array() ) { + if ( ! $default_headers ) { + $default_headers = array( + 'Name' => 'Plugin Name', + 'PluginURI' => 'Plugin URI', + 'Version' => 'Version', + 'Description' => 'Description', + 'Author' => 'Author', + 'AuthorURI' => 'Author URI', + 'TextDomain' => 'Text Domain', + 'DomainPath' => 'Domain Path', + 'Network' => 'Network', + 'RequiresWP' => 'Requires at least', + 'RequiresPHP' => 'Requires PHP', + 'UpdateURI' => 'Update URI', + ); + } + + return \get_file_data( __FILE__, $default_headers, 'plugin' ); +} + +/** + * Plugin Version Number used for caching. + */ +function get_plugin_version() { + $meta = get_plugin_meta( array( 'Version' => 'Version' ) ); + + return $meta['Version']; +} diff --git a/includes/class-activity-dispatcher.php b/includes/class-activity-dispatcher.php index 57fee1a..422240d 100644 --- a/includes/class-activity-dispatcher.php +++ b/includes/class-activity-dispatcher.php @@ -3,6 +3,7 @@ namespace Activitypub; use Activitypub\Model\Post; use Activitypub\Model\Activity; +use Activitypub\Collection\Followers; /** * ActivityPub Activity_Dispatcher Class @@ -60,17 +61,22 @@ class Activity_Dispatcher { * @return void */ public static function send_activity( Post $activitypub_post, $activity_type ) { + // check if a migration is needed before sending new posts + \Activitypub\Migration::maybe_migrate(); + // get latest version of post $user_id = $activitypub_post->get_post_author(); $activitypub_activity = new Activity( $activity_type ); $activitypub_activity->from_post( $activitypub_post ); - $inboxes = \Activitypub\get_follower_inboxes( $user_id, $activitypub_activity->get_cc() ); + $follower_inboxes = Followers::get_inboxes( $user_id ); + $mentioned_inboxes = Mention::get_inboxes( $activitypub_activity->get_cc() ); - foreach ( $inboxes as $inbox => $cc ) { - $cc = array_values( array_unique( $cc ) ); - $activitypub_activity->add_cc( $cc ); + $inboxes = array_merge( $follower_inboxes, $mentioned_inboxes ); + $inboxes = array_unique( $inboxes ); + + foreach ( $inboxes as $inbox ) { $activity = $activitypub_activity->to_json(); \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); diff --git a/includes/class-admin.php b/includes/class-admin.php index 5329d3a..89ec086 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -15,7 +15,8 @@ class Admin { public static function init() { \add_action( 'admin_menu', array( self::class, 'admin_menu' ) ); \add_action( 'admin_init', array( self::class, 'register_settings' ) ); - \add_action( 'show_user_profile', array( self::class, 'add_fediverse_profile' ) ); + \add_action( 'admin_init', array( self::class, 'schedule_migration' ) ); + \add_action( 'show_user_profile', array( self::class, 'add_profile' ) ); \add_action( 'personal_options_update', array( self::class, 'save_user_description' ) ); \add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_scripts' ) ); } @@ -53,8 +54,6 @@ class Admin { switch ( $tab ) { case 'settings': - Post::upgrade_post_content_template(); - \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/settings.php' ); break; case 'welcome': @@ -147,6 +146,12 @@ class Admin { ); } + public static function schedule_migration() { + if ( ! \wp_next_scheduled( 'activitypub_schedule_migration' ) ) { + \wp_schedule_single_event( \time(), 'activitypub_schedule_migration' ); + } + } + public static function add_settings_help_tab() { require_once ACTIVITYPUB_PLUGIN_DIR . 'includes/help.php'; } @@ -155,8 +160,8 @@ class Admin { // todo } - public static function add_fediverse_profile( $user ) { - $description = get_user_meta( $user->ID, 'activitypub_user_description', true ); + public static function add_profile( $user ) { + $description = get_user_meta( $user->ID, ACTIVITYPUB_USER_DESCRIPTION_KEY, true ); \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/user-settings.php', diff --git a/includes/class-http.php b/includes/class-http.php new file mode 100644 index 0000000..798328b --- /dev/null +++ b/includes/class-http.php @@ -0,0 +1,93 @@ + 100, + 'limit_response_size' => 1048576, + 'redirection' => 3, + 'user-agent' => "$user_agent; ActivityPub", + 'headers' => array( + 'Accept' => 'application/activity+json', + 'Content-Type' => 'application/activity+json', + 'Digest' => "SHA-256=$digest", + 'Signature' => $signature, + 'Date' => $date, + ), + 'body' => $body, + ); + + $response = \wp_safe_remote_post( $url, $args ); + $code = \wp_remote_retrieve_response_code( $response ); + + if ( 400 <= $code && 500 >= $code ) { + $response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ) ); + } + + \do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id ); + + return $response; + } + + /** + * Send a GET Request with the needed HTTP Headers + * + * @param string $url The URL endpoint + * @param int $user_id The WordPress User-ID + * + * @return array|WP_Error The GET Response or an WP_ERROR + */ + public static function get( $url, $user_id ) { + $date = \gmdate( 'D, d M Y H:i:s T' ); + $signature = Signature::generate_signature( $user_id, 'get', $url, $date ); + + $wp_version = \get_bloginfo( 'version' ); + $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) ); + $args = array( + 'timeout' => apply_filters( 'activitypub_remote_get_timeout', 100 ), + 'limit_response_size' => 1048576, + 'redirection' => 3, + 'user-agent' => "$user_agent; ActivityPub", + 'headers' => array( + 'Accept' => 'application/activity+json', + 'Content-Type' => 'application/activity+json', + 'Signature' => $signature, + 'Date' => $date, + ), + ); + + $response = \wp_safe_remote_get( $url, $args ); + $code = \wp_remote_retrieve_response_code( $response ); + + if ( 400 <= $code && 500 >= $code ) { + $response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ) ); + } + + \do_action( 'activitypub_safe_remote_get_response', $response, $url, $user_id ); + + return $response; + } +} diff --git a/includes/class-mention.php b/includes/class-mention.php index 9c93d80..49f7860 100644 --- a/includes/class-mention.php +++ b/includes/class-mention.php @@ -1,6 +1,8 @@ 'ID' ) ) as $user_id ) { + $followers = get_user_meta( $user_id, 'activitypub_followers', true ); + + if ( $followers ) { + foreach ( $followers as $follower ) { + $meta = get_remote_metadata_by_actor( $actor ); + + $follower = new Follower( $actor ); + + if ( is_tombstone( $meta ) ) { + continue; + } if ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) { + $follower->set_error( $meta ); + } else { + $follower->from_meta( $meta ); + } + + $follower->upsert(); + + $result = wp_set_object_terms( $user_id, $follower->get_actor(), self::TAXONOMY, true ); + } + } + } + } + + /** + * Updates the custom template to use shortcodes instead of the deprecated templates. + * + * @return void + */ + private static function migrate_from_0_16() { + // Get the custom template. + $old_content = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT ); + + // If the old content exists but is a blank string, we're going to need a flag to updated it even + // after setting it to the default contents. + $need_update = false; + + // If the old contents is blank, use the defaults. + if ( '' === $old_content ) { + $old_content = ACTIVITYPUB_CUSTOM_POST_CONTENT; + $need_update = true; + } + + // Set the new content to be the old content. + $content = $old_content; + + // Convert old templates to shortcodes. + $content = \str_replace( '%title%', '[ap_title]', $content ); + $content = \str_replace( '%excerpt%', '[ap_excerpt]', $content ); + $content = \str_replace( '%content%', '[ap_content]', $content ); + $content = \str_replace( '%permalink%', '[ap_permalink type="html"]', $content ); + $content = \str_replace( '%shortlink%', '[ap_shortlink type="html"]', $content ); + $content = \str_replace( '%hashtags%', '[ap_hashtags]', $content ); + $content = \str_replace( '%tags%', '[ap_hashtags]', $content ); + + // Store the new template if required. + if ( $content !== $old_content || $need_update ) { + \update_option( 'activitypub_custom_post_content', $content ); + } + } +} diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php new file mode 100644 index 0000000..c6c6223 --- /dev/null +++ b/includes/collection/class-followers.php @@ -0,0 +1,407 @@ + array( + 'name' => _x( 'Followers', 'taxonomy general name', 'activitypub' ), + 'singular_name' => _x( 'Followers', 'taxonomy singular name', 'activitypub' ), + 'menu_name' => __( 'Followers', 'activitypub' ), + ), + 'hierarchical' => false, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_nav_menus' => false, + 'show_admin_column' => false, + 'query_var' => false, + 'rewrite' => false, + 'public' => false, + 'capabilities' => array( + 'edit_terms' => null, + ), + ); + + register_taxonomy( self::TAXONOMY, 'user', $args ); + register_taxonomy_for_object_type( self::TAXONOMY, 'user' ); + + register_term_meta( + self::TAXONOMY, + 'name', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => function( $value ) { + return sanitize_user( $value ); + }, + ) + ); + + register_term_meta( + self::TAXONOMY, + 'username', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => function( $value ) { + return sanitize_user( $value, true ); + }, + ) + ); + + register_term_meta( + self::TAXONOMY, + 'avatar', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => function( $value ) { + if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) { + return ''; + } + + return esc_url_raw( $value ); + }, + ) + ); + + register_term_meta( + self::TAXONOMY, + 'inbox', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => function( $value ) { + if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) { + throw new Exception( '"inbox" has to be a valid URL' ); + } + + return esc_url_raw( $value ); + }, + ) + ); + + register_term_meta( + self::TAXONOMY, + 'shared_inbox', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => function( $value ) { + if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) { + return null; + } + + return esc_url_raw( $value ); + }, + ) + ); + + register_term_meta( + self::TAXONOMY, + 'updated_at', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => function( $value ) { + if ( ! is_numeric( $value ) && (int) $value !== $value ) { + $value = strtotime( 'now' ); + } + + return $value; + }, + ) + ); + + register_term_meta( + self::TAXONOMY, + 'errors', + array( + 'type' => 'string', + 'single' => false, + 'sanitize_callback' => function( $value ) { + if ( ! is_string( $value ) ) { + throw new Exception( 'Error message is no valid string' ); + } + + return esc_sql( $value ); + }, + ) + ); + + do_action( 'activitypub_after_register_taxonomy' ); + } + + /** + * Handle the "Follow" Request + * + * @param array $object The JSON "Follow" Activity + * @param int $user_id The ID of the ID of the WordPress User + * + * @return void + */ + public static function handle_follow_request( $object, $user_id ) { + // save follower + $follower = self::add_follower( $user_id, $object['actor'] ); + + do_action( 'activitypub_followers_post_follow', $object['actor'], $object, $user_id, $follower ); + } + + /** + * Handles "Unfollow" requests + * + * @param array $object The JSON "Undo" Activity + * @param int $user_id The ID of the ID of the WordPress User + */ + public static function handle_undo_request( $object, $user_id ) { + if ( + isset( $object['object'] ) && + isset( $object['object']['type'] ) && + 'Follow' === $object['object']['type'] + ) { + self::remove_follower( $user_id, $object['actor'] ); + } + } + + /** + * Add a new Follower + * + * @param int $user_id The ID of the WordPress User + * @param string $actor The Actor URL + * + * @return array|WP_Error The Follower (WP_Term array) or an WP_Error + */ + public static function add_follower( $user_id, $actor ) { + $meta = get_remote_metadata_by_actor( $actor ); + + if ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) { + return $meta; + } + + $follower = new Follower( $actor ); + $follower->from_meta( $meta ); + $follower->upsert(); + + $result = wp_set_object_terms( $user_id, $follower->get_actor(), self::TAXONOMY, true ); + + if ( is_wp_error( $result ) ) { + return $result; + } else { + return $follower; + } + } + + /** + * Remove a Follower + * + * @param int $user_id The ID of the WordPress User + * @param string $actor The Actor URL + * + * @return bool|WP_Error True on success, false or WP_Error on failure. + */ + public static function remove_follower( $user_id, $actor ) { + return wp_remove_object_terms( $user_id, $actor, self::TAXONOMY ); + } + + /** + * Remove a Follower + * + * @param int $user_id The ID of the WordPress User + * @param string $actor The Actor URL + * + * @return \Activitypub\Model\Follower The Follower object + */ + public static function get_follower( $user_id, $actor ) { + $terms = new WP_Term_Query( + array( + 'name' => $actor, + 'taxonomy' => self::TAXONOMY, + 'hide_empty' => false, + 'object_ids' => $user_id, + 'number' => 1, + ) + ); + + $term = $terms->get_terms(); + + if ( is_array( $term ) && ! empty( $term ) ) { + $term = reset( $term ); + return new Follower( $term->name ); + } + + return null; + } + + /** + * Send Accept response + * + * @param string $actor The Actor URL + * @param array $object The Activity object + * @param int $user_id The ID of the WordPress User + * @param Activitypub\Model\Follower $follower The Follower object + * + * @return void + */ + public static function send_follow_response( $actor, $object, $user_id, $follower ) { + if ( is_wp_error( $follower ) ) { + // it is not even possible to send a "Reject" because + // we can not get the Remote-Inbox + return; + } + + if ( isset( $object['user_id'] ) ) { + unset( $object['user_id'] ); + unset( $object['@context'] ); + } + + // get inbox + $inbox = $follower->get_inbox(); + + // send "Accept" activity + $activity = new Activity( 'Accept' ); + $activity->set_object( $object ); + $activity->set_actor( \get_author_posts_url( $user_id ) ); + $activity->set_to( $actor ); + $activity->set_id( \get_author_posts_url( $user_id ) . '#follow-' . \preg_replace( '~^https?://~', '', $actor ) ); + + $activity = $activity->to_simple_json(); + $response = Http::post( $inbox, $activity, $user_id ); + } + + /** + * Get the Followers of a given user + * + * @param int $user_id The ID of the WordPress User + * @param string $output The output format, supported ARRAY_N, OBJECT and ACTIVITYPUB_OBJECT + * @param int $number Limts the result + * @param int $offset Offset + * + * @return array The Term list of Followers, the format depends on $output + */ + public static function get_followers( $user_id, $output = ARRAY_N, $number = null, $offset = null, $args = array() ) { + $defaults = array( + 'taxonomy' => self::TAXONOMY, + 'hide_empty' => false, + 'object_ids' => $user_id, + 'number' => $number, + 'offset' => $offset, + 'orderby' => 'id', + 'order' => 'ASC', + ); + + $args = wp_parse_args( $args, $defaults ); + $terms = new WP_Term_Query( $args ); + + $items = array(); + + // change output format + switch ( $output ) { + case ACTIVITYPUB_OBJECT: + foreach ( $terms->get_terms() as $follower ) { + $items[] = new Follower( $follower->name ); // phpcs:ignore + } + return $items; + case OBJECT: + return $terms->get_terms(); + case ARRAY_N: + default: + foreach ( $terms->get_terms() as $follower ) { + $items[] = $follower->name; // phpcs:ignore + } + return $items; + } + } + + /** + * Count the total number of followers + * + * @param int $user_id The ID of the WordPress User + * + * @return int The number of Followers + */ + public static function count_followers( $user_id ) { + return count( self::get_followers( $user_id ) ); + } + + /** + * Returns all Inboxes fo a Users Followers + * + * @param int $user_id The ID of the WordPress User + * + * @return array The list of Inboxes + */ + public static function get_inboxes( $user_id ) { + // get all Followers of a ID of the WordPress User + $terms = new WP_Term_Query( + array( + 'taxonomy' => self::TAXONOMY, + 'hide_empty' => false, + 'object_ids' => $user_id, + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => 'inbox', + 'compare' => 'EXISTS', + ), + ), + ) + ); + + $terms = $terms->get_terms(); + + if ( ! $terms ) { + return array(); + } + + global $wpdb; + $results = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT meta_value FROM {$wpdb->termmeta} + WHERE term_id IN (" . implode( ', ', array_fill( 0, count( $terms ), '%d' ) ) . ") + AND meta_key = 'shared_inbox' + AND meta_value IS NOT NULL", + $terms + ) + ); + + return array_filter( $results ); + } +} diff --git a/includes/functions.php b/includes/functions.php index e115c70..bd04e6c 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -33,58 +33,11 @@ function get_context() { } function safe_remote_post( $url, $body, $user_id ) { - $date = \gmdate( 'D, d M Y H:i:s T' ); - $digest = \Activitypub\Signature::generate_digest( $body ); - $signature = \Activitypub\Signature::generate_signature( $user_id, 'post', $url, $date, $digest ); - - $wp_version = \get_bloginfo( 'version' ); - $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) ); - $args = array( - 'timeout' => 100, - 'limit_response_size' => 1048576, - 'redirection' => 3, - 'user-agent' => "$user_agent; ActivityPub", - 'headers' => array( - 'Accept' => 'application/activity+json', - 'Content-Type' => 'application/activity+json', - 'Digest' => "SHA-256=$digest", - 'Signature' => $signature, - 'Date' => $date, - ), - 'body' => $body, - ); - - $response = \wp_safe_remote_post( $url, $args ); - - \do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id ); - - return $response; + return \Activitypub\Http::post( $url, $body, $user_id ); } function safe_remote_get( $url, $user_id ) { - $date = \gmdate( 'D, d M Y H:i:s T' ); - $signature = \Activitypub\Signature::generate_signature( $user_id, 'get', $url, $date ); - - $wp_version = \get_bloginfo( 'version' ); - $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) ); - $args = array( - 'timeout' => apply_filters( 'activitypub_remote_get_timeout', 100 ), - 'limit_response_size' => 1048576, - 'redirection' => 3, - 'user-agent' => "$user_agent; ActivityPub", - 'headers' => array( - 'Accept' => 'application/activity+json', - 'Content-Type' => 'application/activity+json', - 'Signature' => $signature, - 'Date' => $date, - ), - ); - - $response = \wp_safe_remote_get( $url, $args ); - - \do_action( 'activitypub_safe_remote_get_response', $response, $url, $user_id ); - - return $response; + return \Activitypub\Http::get( $url, $user_id ); } /** @@ -95,15 +48,15 @@ function safe_remote_get( $url, $user_id ) { * @return string The user-resource */ function get_webfinger_resource( $user_id ) { - return \Activitypub\Webfinger::get_user_resource( $user_id ); + return Webfinger::get_user_resource( $user_id ); } /** - * [get_metadata_by_actor description] + * Requests the Meta-Data from the Actors profile * - * @param string $actor + * @param string $actor The Actor URL * - * @return array + * @return array The Actor profile as array */ function get_remote_metadata_by_actor( $actor ) { $pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor ); @@ -149,7 +102,7 @@ function get_remote_metadata_by_actor( $actor ) { return 3; }; add_filter( 'activitypub_remote_get_timeout', $short_timeout ); - $response = \Activitypub\safe_remote_get( $actor, $user_id ); + $response = Http::get( $actor, $user_id ); 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. @@ -159,102 +112,42 @@ function get_remote_metadata_by_actor( $actor ) { $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' ), $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; } -/** - * [get_inbox_by_actor description] - * @param [type] $actor [description] - * @return [type] [description] - */ -function get_inbox_by_actor( $actor ) { - $metadata = \Activitypub\get_remote_metadata_by_actor( $actor ); - - if ( \is_wp_error( $metadata ) ) { - return $metadata; - } - - if ( isset( $metadata['endpoints'] ) && isset( $metadata['endpoints']['sharedInbox'] ) ) { - return $metadata['endpoints']['sharedInbox']; - } - - if ( \array_key_exists( 'inbox', $metadata ) ) { - return $metadata['inbox']; - } - - return new \WP_Error( 'activitypub_no_inbox', \__( 'No "Inbox" found', 'activitypub' ), $metadata ); -} - -/** - * [get_inbox_by_actor description] - * @param [type] $actor [description] - * @return [type] [description] - */ -function get_publickey_by_actor( $actor, $key_id ) { - $metadata = \Activitypub\get_remote_metadata_by_actor( $actor ); - - if ( \is_wp_error( $metadata ) ) { - return $metadata; - } - - if ( - isset( $metadata['publicKey'] ) && - isset( $metadata['publicKey']['id'] ) && - isset( $metadata['publicKey']['owner'] ) && - isset( $metadata['publicKey']['publicKeyPem'] ) && - $key_id === $metadata['publicKey']['id'] && - $actor === $metadata['publicKey']['owner'] - ) { - return $metadata['publicKey']['publicKeyPem']; - } - - return new \WP_Error( 'activitypub_no_public_key', \__( 'No "Public-Key" found', 'activitypub' ), $metadata ); -} - -function get_follower_inboxes( $user_id, $cc = array() ) { - $followers = \Activitypub\Peer\Followers::get_followers( $user_id ); - $followers = array_merge( $followers, $cc ); - $followers = array_unique( $followers ); - - $inboxes = array(); - - foreach ( $followers as $follower ) { - $inbox = \Activitypub\get_inbox_by_actor( $follower ); - if ( ! $inbox || \is_wp_error( $inbox ) ) { - continue; - } - // init array if empty - if ( ! isset( $inboxes[ $inbox ] ) ) { - $inboxes[ $inbox ] = array(); - } - $inboxes[ $inbox ][] = $follower; - } - - return $inboxes; +function get_identifier_settings( $user_id ) { + ?> +
+ + | +
+
|
+
---|