diff --git a/activitypub.php b/activitypub.php index 2444589a8..335cf4e4c 100644 --- a/activitypub.php +++ b/activitypub.php @@ -15,6 +15,8 @@ namespace Activitypub; +use WP_CLI; + use function Activitypub\is_blog_public; use function Activitypub\site_supports_blocks; @@ -81,6 +83,7 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Health_Check', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Comment', 'init' ) ); + \add_action( 'init', array( __NAMESPACE__ . '\Http_Gone', 'init' ) ); if ( site_supports_blocks() ) { \add_action( 'init', array( __NAMESPACE__ . '\Blocks', 'init' ) ); @@ -237,3 +240,14 @@ function get_plugin_version() { return $meta['Version']; } + +// Check for CLI env, to add the CLI commands +if ( defined( 'WP_CLI' ) && WP_CLI ) { + WP_CLI::add_command( + 'activitypub', + '\Activitypub\Cli', + array( + 'shortdesc' => __( 'ActivityPub related commands: Meta-Infos and Self-Destruct.', 'activitypub' ), + ) + ); +} diff --git a/includes/class-activity-dispatcher.php b/includes/class-activity-dispatcher.php index 32c91a7bb..9fb7c5b73 100644 --- a/includes/class-activity-dispatcher.php +++ b/includes/class-activity-dispatcher.php @@ -7,7 +7,6 @@ use Activitypub\Collection\Users; use Activitypub\Collection\Followers; use Activitypub\Transformer\Factory; -use Activitypub\Transformer\Post; use Activitypub\Transformer\Comment; use function Activitypub\is_single_user; @@ -33,6 +32,7 @@ public static function init() { \add_action( 'activitypub_send_activity', array( self::class, 'send_activity' ), 10, 2 ); \add_action( 'activitypub_send_activity', array( self::class, 'send_activity_or_announce' ), 10, 2 ); \add_action( 'activitypub_send_update_profile_activity', array( self::class, 'send_profile_update' ), 10, 1 ); + \add_action( 'activitypub_send_actor_delete_activity', array( self::class, 'send_actor_delete_activity' ), 10, 2 ); } /** @@ -186,6 +186,31 @@ private static function send_activity_to_followers( $activity, $user_id, $wp_obj set_wp_object_state( $wp_object, 'federated' ); } + /** + * Send an Activity to all known (shared_)inboxes. + * + * @param WP_User $user The deleted WordPress User. + * + * @return void + */ + public static function send_actor_delete_activity( $user ) { + $url = \get_author_posts_url( null, $user->user_nicename ); + + $activity = new Activity(); + $activity->set_id( $url . '#delete' ); + $activity->set_type( 'Delete' ); + $activity->set_actor( $url ); + $activity->set_object( $url ); + $activity->set_to( 'https://www.w3.org/ns/activitystreams#Public' ); + + $json = $activity->to_json(); + $inboxes = Followers::get_inboxes( Followers::ALL ); + + foreach ( $inboxes as $inbox ) { + safe_remote_post( $inbox, $json, $user ); + } + } + /** * Send a "Create" or "Update" Activity for a WordPress Post. * diff --git a/includes/class-cli.php b/includes/class-cli.php new file mode 100644 index 000000000..b6d17b2e8 --- /dev/null +++ b/includes/class-cli.php @@ -0,0 +1,136 @@ + $value ) { + WP_CLI::line( $key . ': ' . $value ); + } + } + + /** + * Remove the blog from the Fediverse. + * + * ## EXAMPLES + * + * $ wp activitypub self-destruct + * + * @subcommand self-destruct + * + * @param array|null $args The arguments. + * @param array|null $assoc_args The associative arguments. + * + * @return void + */ + public function self_destruct( $args, $assoc_args ) { + $question = __( 'We are in the process of deleting your blog from the Fediverse. This action could be irreversible, so are you sure you want to continue?', 'activitypub' ); + WP_CLI::confirm( WP_CLI::colorize( "%r{$question}%n" ), $assoc_args = array() ); + + WP_CLI::success( __( 'Deleting your Blog from the Fediverse...', 'activitypub' ) ); + + // Deactivate the ActivityPub Plugin after the deletion. + WP_CLI::runcommand( 'plugin deactivate activitypub' ); + } + + /** + * Delete or Update a User. + * + * ## OPTIONS + * + * + * : The action to perform. Either `delete` or `update`. + * --- + * options: + * - delete + * - update + * --- + * + * + * : The id of the registered WordPress user. + * + * ## EXAMPLES + * + * $ wp activitypub user delete 1 + * + * @synopsis + * + * @param array|null $args The arguments. + * @param array|null $assoc_args The associative arguments. + * + * @return void + */ + public function user( $args, $assoc_args ) { + // @todo add code + } +} diff --git a/includes/class-http-gone.php b/includes/class-http-gone.php new file mode 100644 index 000000000..d89a90d0c --- /dev/null +++ b/includes/class-http-gone.php @@ -0,0 +1,71 @@ +query['pagename'] ) ) { + $query = new WP_Query( + array( + 'pagename' => $wp_query->query['pagename'] . '__trashed', + 'post_status' => 'trash', + ) + ); + + if ( $query->get_posts() ) { + $is_410 = true; + } + } elseif ( ! empty( $wp_query->query['name'] ) ) { + $query = new WP_Query( + array( + 'name' => $wp_query->query['name'] . '__trashed', + 'post_status' => 'trash', + ) + ); + + if ( $query->get_posts() ) { + $is_410 = true; + } + } elseif ( ! empty( $wp_query->query['author_name'] ) ) { + // Check if author is deleted + $is_410 = true; + } else { + return; + } + + // Return 410 if trashed post exists + if ( $is_410 ) { + status_header( 410 ); + // check if theme has a 410.php template + $template_410 = get_query_template( 410 ); + // return 410 template + if ( $template_410 ) { + load_template( $template_410 ); + exit; + } + } + } +} diff --git a/includes/class-http.php b/includes/class-http.php index 2a8ce7d0d..f7488897e 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -15,18 +15,18 @@ class Http { /** * Send a POST Request with the needed HTTP Headers * - * @param string $url The URL endpoint - * @param string $body The Post Body - * @param int $user_id The WordPress User-ID + * @param string $url The URL endpoint + * @param string $body The Post Body + * @param WP_User|int $user The WordPress User or ID * * @return array|WP_Error The POST Response or an WP_ERROR */ - public static function post( $url, $body, $user_id ) { - \do_action( 'activitypub_pre_http_post', $url, $body, $user_id ); + public static function post( $url, $body, $user ) { + \do_action( 'activitypub_pre_http_post', $url, $body, $user ); $date = \gmdate( 'D, d M Y H:i:s T' ); $digest = Signature::generate_digest( $body ); - $signature = Signature::generate_signature( $user_id, 'post', $url, $date, $digest ); + $signature = Signature::generate_signature( $user, 'post', $url, $date, $digest ); $wp_version = get_masked_wp_version(); @@ -58,7 +58,7 @@ public static function post( $url, $body, $user_id ) { $response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) ); } - \do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id ); + \do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user ); return $response; } diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 07eb1c3b7..4c9de8e58 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -5,9 +5,11 @@ use Activitypub\Transformer\Post; use Activitypub\Collection\Users; use Activitypub\Collection\Followers; +use Activitypub\Activity\Activity; -use function Activitypub\was_comment_sent; +use function Activitypub\get_private_key_for; use function Activitypub\is_user_type_disabled; +use function Activitypub\was_comment_sent; use function Activitypub\should_comment_be_federated; use function Activitypub\get_remote_metadata_by_actor; @@ -75,6 +77,7 @@ function ( $comment_id ) { // profile updates for user options if ( ! is_user_type_disabled( 'user' ) ) { + \add_action( 'delete_user', array( self::class, 'schedule_actor_delete' ), 10, 3 ); \add_action( 'wp_update_user', array( self::class, 'user_update' ) ); \add_action( 'updated_user_meta', array( self::class, 'user_meta_update' ), 10, 3 ); // @todo figure out a feasible way of updating the header image since it's not unique to any user. @@ -337,4 +340,24 @@ public static function schedule_profile_update( $user_id ) { array( $user_id ) ); } + + /** + * Send an Actor Delete activity. + * + * @param int $id ID of the user to delete. + * @param int|null $reassign ID of the user to reassign posts and links to. + * Default null, for no reassignment. + * @param WP_User $user WP_User object of the user to delete. + */ + public static function schedule_actor_delete( $user_id, $reassign, $deleted_user ) { + $caps = $deleted_user->allcaps; + + // Check if 'activitypub' capability is set. + if ( isset( $caps['activitypub'] ) ) { + // Do not send activities if user is not allowed to publish. + unset( $deleted_user->data->user_pass ); + unset( $deleted_user->allcaps ); + \wp_schedule_single_event( \time(), 'activitypub_send_actor_delete_activity', array( $deleted_user ) ); + } + } } diff --git a/includes/class-signature.php b/includes/class-signature.php index e70875954..2d76b0309 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -18,17 +18,17 @@ class Signature { /** * Return the public key for a given user. * - * @param int $user_id The WordPress User ID. - * @param bool $force Force the generation of a new key pair. + * @param WP_User|int $user_id The WordPress User ID. + * @param bool $force Force the generation of a new key pair. * * @return mixed The public key. */ - public static function get_public_key_for( $user_id, $force = false ) { + public static function get_public_key_for( $user, $force = false ) { if ( $force ) { - self::generate_key_pair_for( $user_id ); + self::generate_key_pair_for( $user ); } - $key_pair = self::get_keypair_for( $user_id ); + $key_pair = self::get_keypair_for( $user ); return $key_pair['public_key']; } @@ -36,17 +36,17 @@ public static function get_public_key_for( $user_id, $force = false ) { /** * Return the private key for a given user. * - * @param int $user_id The WordPress User ID. - * @param bool $force Force the generation of a new key pair. + * @param WP_User|int $user The WordPress User ID. + * @param bool $force Force the generation of a new key pair. * * @return mixed The private key. */ - public static function get_private_key_for( $user_id, $force = false ) { + public static function get_private_key_for( $user, $force = false ) { if ( $force ) { - self::generate_key_pair_for( $user_id ); + self::generate_key_pair_for( $user ); } - $key_pair = self::get_keypair_for( $user_id ); + $key_pair = self::get_keypair_for( $user ); return $key_pair['private_key']; } @@ -54,16 +54,16 @@ public static function get_private_key_for( $user_id, $force = false ) { /** * Return the key pair for a given user. * - * @param int $user_id The WordPress User ID. + * @param WP_User|int $user The WordPress User ID. * * @return array The key pair. */ - public static function get_keypair_for( $user_id ) { - $option_key = self::get_signature_options_key_for( $user_id ); + public static function get_keypair_for( $user ) { + $option_key = self::get_signature_options_key_for( $user ); $key_pair = \get_option( $option_key ); if ( ! $key_pair ) { - $key_pair = self::generate_key_pair_for( $user_id ); + $key_pair = self::generate_key_pair_for( $user ); } return $key_pair; @@ -72,21 +72,13 @@ public static function get_keypair_for( $user_id ) { /** * Generates the pair keys * - * @param int $user_id The WordPress User ID. + * @param WP_User|int $user The WordPress User ID. * * @return array The key pair. */ - protected static function generate_key_pair_for( $user_id ) { - $option_key = self::get_signature_options_key_for( $user_id ); - $key_pair = self::check_legacy_key_pair_for( $user_id ); - - if ( $key_pair ) { - \add_option( $option_key, $key_pair ); - - return $key_pair; - } - - $config = array( + protected static function generate_key_pair_for( $user ) { + $option_key = self::get_signature_options_key_for( $user ); + $config = array( 'digest_alg' => 'sha512', 'private_key_bits' => 2048, 'private_key_type' => \OPENSSL_KEYTYPE_RSA, @@ -124,69 +116,40 @@ protected static function generate_key_pair_for( $user_id ) { /** * Return the option key for a given user. * - * @param int $user_id The WordPress User ID. + * @param WP_User|int $user The WordPress User ID. * * @return string The option key. */ - protected static function get_signature_options_key_for( $user_id ) { - $id = $user_id; - - if ( $user_id > 0 ) { - $user = \get_userdata( $user_id ); - // sanatize username because it could include spaces and special chars - $id = sanitize_title( $user->user_login ); - } - - return 'activitypub_keypair_for_' . $id; - } - - /** - * Check if there is a legacy key pair - * - * @param int $user_id The WordPress User ID. - * - * @return array|bool The key pair or false. - */ - protected static function check_legacy_key_pair_for( $user_id ) { - switch ( $user_id ) { - case 0: - $public_key = \get_option( 'activitypub_blog_user_public_key' ); - $private_key = \get_option( 'activitypub_blog_user_private_key' ); - break; - case -1: - $public_key = \get_option( 'activitypub_application_user_public_key' ); - $private_key = \get_option( 'activitypub_application_user_private_key' ); - break; - default: - $public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true ); - $private_key = \get_user_meta( $user_id, 'magic_sig_private_key', true ); - break; + protected static function get_signature_options_key_for( $user ) { + if ( $user instanceof WP_User ) { + $user_login = $user->user_login; + } elseif ( (int) $user > 0 ) { + $user = \get_userdata( $user ); + $user_login = $user->user_login; + } else { + $user_login = $user; } - if ( ! empty( $public_key ) && is_string( $public_key ) && ! empty( $private_key ) && is_string( $private_key ) ) { - return array( - 'private_key' => $private_key, - 'public_key' => $public_key, - ); - } + // sanatize username because it could include spaces and special chars + $id = sanitize_title( $user_login ); - return false; + return 'activitypub_keypair_for_' . $id; } /** * Generates the Signature for a HTTP Request * - * @param int $user_id The WordPress User ID. - * @param string $http_method The HTTP method. - * @param string $url The URL to send the request to. - * @param string $date The date the request is sent. - * @param string $digest The digest of the request body. + * @param int|WP_User $user The WordPress User or ID. + * @param string $http_method The HTTP method. + * @param string $url The URL to send the request to. + * @param string $date The date the request is sent. + * @param string $digest The digest of the request body. * * @return string The signature. */ - public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) { - $user = Users::get_by_id( $user_id ); - $key = self::get_private_key_for( $user->get__id() ); + public static function generate_signature( $user, $http_method, $url, $date, $digest = null ) { + $key = self::get_private_key_for( $user ); + $key_id = '#main-key'; //$user->get_url() . '#main-key'; $url_parts = \wp_parse_url( $url ); @@ -215,8 +178,6 @@ public static function generate_signature( $user_id, $http_method, $url, $date, \openssl_sign( $signed_string, $signature, $key, \OPENSSL_ALGO_SHA256 ); $signature = \base64_encode( $signature ); // phpcs:ignore - $key_id = $user->get_url() . '#main-key'; - if ( ! empty( $digest ) ) { return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature ); } else { @@ -260,7 +221,11 @@ public static function verify_http_signature( $request ) { } if ( ! isset( $headers['signature'] ) ) { - return new WP_Error( 'activitypub_signature', __( 'Request not signed', 'activitypub' ), array( 'status' => 401 ) ); + return new WP_Error( + 'activitypub_signature', + __( 'Request not signed', 'activitypub' ), + array( 'status' => 401 ) + ); } if ( array_key_exists( 'signature', $headers ) ) { @@ -270,7 +235,14 @@ public static function verify_http_signature( $request ) { } if ( ! isset( $signature_block ) || ! $signature_block ) { - return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 401 ) ); + return new WP_Error( + 'activitypub_signature', + __( + 'Incompatible request signature. keyId and signature are required', + 'activitypub' + ), + array( 'status' => 401 ) + ); } $signed_headers = $signature_block['headers']; @@ -280,12 +252,26 @@ public static function verify_http_signature( $request ) { $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' => 401 ) ); + 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' => 401 ) ); + 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 ) ) { @@ -302,7 +288,14 @@ public static function verify_http_signature( $request ) { } if ( \base64_encode( \hash( $hashalg, $body, true ) ) !== $digest[1] ) { // phpcs:ignore - return new WP_Error( 'activitypub_signature', __( 'Invalid Digest header', 'activitypub' ), array( 'status' => 401 ) ); + return new WP_Error( + 'activitypub_signature', + __( + 'Invalid Digest header', + 'activitypub' + ), + array( 'status' => 401 ) + ); } } @@ -315,7 +308,14 @@ public static function verify_http_signature( $request ) { $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' => 401 ) ); + return new WP_Error( + 'activitypub_signature', + __( + 'Invalid signature', + 'activitypub' + ), + array( 'status' => 401 ) + ); } return $verified; } @@ -357,7 +357,8 @@ public static function get_signature_algorithm( $signature_block ) { if ( $signature_block['algorithm'] ) { switch ( $signature_block['algorithm'] ) { case 'rsa-sha-512': - return 'sha512'; //hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12 + //hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12 + return 'sha512'; default: return 'sha256'; } diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index b51224fd0..6edd09e36 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -19,6 +19,7 @@ class Followers { const POST_TYPE = 'ap_follower'; const CACHE_KEY_INBOXES = 'follower_inboxes_%s'; + const ALL = 'all'; /** * Add new Follower @@ -253,7 +254,7 @@ public static function count_followers( $user_id ) { /** * Returns all Inboxes fo a Users Followers * - * @param int $user_id The ID of the WordPress User + * @param int|string $user_id The ID of the WordPress User or 'all' for all Inboxes * * @return array The list of Inboxes */ @@ -265,32 +266,35 @@ public static function get_inboxes( $user_id ) { return $inboxes; } - // get all Followers of a ID of the WordPress User - $posts = new WP_Query( - array( - 'nopaging' => true, - 'post_type' => self::POST_TYPE, - 'fields' => 'ids', - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - 'relation' => 'AND', - array( - 'key' => 'activitypub_inbox', - 'compare' => 'EXISTS', - ), - array( - 'key' => 'activitypub_user_id', - 'value' => $user_id, - ), - array( - 'key' => 'activitypub_inbox', - 'value' => '', - 'compare' => '!=', - ), + $args = array( + 'nopaging' => true, + 'post_type' => self::POST_TYPE, + 'fields' => 'ids', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => 'activitypub_inbox', + 'compare' => 'EXISTS', ), - ) + array( + 'key' => 'activitypub_inbox', + 'value' => '', + 'compare' => '!=', + ), + ), ); + // Add User-ID to the query + if ( self::ALL !== $user_id ) { + $args['meta_query'][] = array( + 'key' => 'activitypub_user_id', + 'value' => $user_id, + ); + } + + // get all Followers of a ID of the WordPress User + $posts = new WP_Query( $args ); $posts = $posts->get_posts(); if ( ! $posts ) { diff --git a/includes/functions.php b/includes/functions.php index bf923a159..a1e9ba01b 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -20,8 +20,8 @@ function get_context() { return \apply_filters( 'activitypub_json_context', $context ); } -function safe_remote_post( $url, $body, $user_id ) { - return Http::post( $url, $body, $user_id ); +function safe_remote_post( $url, $body, $user ) { + return Http::post( $url, $body, $user ); } function safe_remote_get( $url ) {