From 55bc262aeb02368913fc1ea1551cfab3fc058628 Mon Sep 17 00:00:00 2001 From: Antonius Hegyes Date: Fri, 12 May 2023 15:34:31 +0200 Subject: [PATCH 1/3] Refactor `remove-user` command and output failed sites --- src/commands/remove-user.php | 355 ++++++++++++++++------------ src/helpers/pressable-functions.php | 32 +++ src/helpers/wpcom-functions.php | 18 ++ 3 files changed, 259 insertions(+), 146 deletions(-) diff --git a/src/commands/remove-user.php b/src/commands/remove-user.php index 1e3fb34a..559abb97 100644 --- a/src/commands/remove-user.php +++ b/src/commands/remove-user.php @@ -2,207 +2,270 @@ namespace Team51\Command; -use Team51\Helper\API_Helper; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Helper\ProgressBar; -use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Question\ConfirmationQuestion; use Team51\Helper\WPCOM_API_Helper; +use function Team51\Helper\delete_pressable_site_collaborator_by_id; +use function Team51\Helper\delete_wpcom_site_user_by_id; +use function Team51\Helper\get_email_input; +use function Team51\Helper\get_pressable_collaborators; +use function Team51\Helper\get_wpcom_sites; +use function Team51\Helper\maybe_define_console_verbosity; + +/** + * CLI command to remove a Pressable collaborators and WPCOM user by email. + */ +final class Remove_User extends Command { + // region FIELDS AND CONSTANTS -class Remove_User extends Command { - protected static $defaultName = 'remove-user'; - private $api_helper; - private $output; - - protected function configure() { - $this - ->setDescription( 'Removes a Pressable collaborator and WordPress user based on email.' ) - ->setHelp( 'This command allows you to bulk-delete from all sites a Pressable collaborator and WordPress user via CLI.' ) - ->addOption( 'email', null, InputOption::VALUE_REQUIRED, "The email of the user you'd like to remove access from sites." ) - ->addOption( 'list', null, InputOption::VALUE_NONE, 'List the sites where this email is found.' ); - } + /** + * {@inheritdoc} + */ + protected static $defaultName = 'remove-user'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase - protected function execute( InputInterface $input, OutputInterface $output ) { - $this->api_helper = new API_Helper(); - $this->output = $output; + /** + * The user identifier. Currently, only email is supported. + * + * @var string|null + */ + protected ?string $user = null; - $email = $input->getOption( 'email' ); + /** + * Whether to just list the sites where the user was found. + * + * @var bool|null + */ + protected ?bool $just_list = null; - if ( empty( $email ) ) { - $email = trim( readline( 'Please provide the email of the user you want to remove: ' ) ); - if ( empty( $email ) ) { - $output->writeln( 'Missing collaborator email (--email=user@domain.com).' ); - exit; - } - } + // endregion - $output->writeln( 'Getting collaborator data from Pressable.' ); + // region INHERITED METHODS - // Each site will have a separate collaborator instance/ID for the same user/email. - $collaborator_data = array(); + /** + * {@inheritDoc} + */ + protected function configure(): void { + $this->setDescription( 'Removes a Pressable collaborator and WordPress user by email.' ) + ->setHelp( 'This command allows you to delete in bulk via CLI all Pressable collaborators and WPCOM users registered with the given email.' ); - $collaborators = $this->api_helper->call_pressable_api( - 'collaborators', - 'GET', - array() - ); + $this->addArgument( 'user', InputArgument::REQUIRED, 'The email of the user you\'d like to remove access from sites.' ) + ->addOption( 'list', null, InputOption::VALUE_NONE, 'Instead of removing the user, just list the sites where an account was found.' ); + } - // TODO: This code is duplicated below for the site clone. Should be a function. - if ( empty( $collaborators->data ) ) { - $output->writeln( 'Something has gone wrong while looking up the Pressable collaborators site.' ); - exit; - } + /** + * {@inheritDoc} + */ + protected function initialize( InputInterface $input, OutputInterface $output ): void { + maybe_define_console_verbosity( $output->getVerbosity() ); - foreach ( $collaborators->data as $collaborator ) { - if ( $collaborator->email === $email ) { - $collaborator_data[] = $collaborator; - } - } + $this->user = get_email_input( $input, $output, null, 'user' ); + $this->just_list = (bool) $input->getOption( 'list' ); + } - if ( empty( $collaborator_data ) ) { - $output->writeln( "No collaborators found in Pressable with the email '$email'." ); - } else { - $site_info = new Table( $output ); - $site_info->setStyle( 'box-double' ); - $site_info->setHeaders( array( 'Default Pressable URL', 'Site ID' ) ); + /** + * {@inheritDoc} + */ + protected function execute( InputInterface $input, OutputInterface $output ): int { + $action = $this->just_list ? "Listing all sites where $this->user is found." : "Removing $this->user from all sites."; + $output->writeln( "$action" ); - $collaborator_sites = array(); + // Get collaborators from Pressable + $output->writeln( 'Getting collaborator data from Pressable.' ); - $output->writeln( '' ); - $output->writeln( "$email is a collaborator on the following Pressable sites:" ); - foreach ( $collaborator_data as $collaborator ) { - $collaborator_sites[] = array( $collaborator->siteName . '.mystagingwebsite.com', $collaborator->siteId ); - } + $pressable_collaborators = $this->get_pressable_collaborators(); + if ( ! \is_array( $pressable_collaborators ) ) { + $output->writeln( 'Something has gone wrong while looking up the Pressable collaborators.' ); + return 1; + } - $site_info->setRows( $collaborator_sites ); - $site_info->render(); + if ( empty( $pressable_collaborators ) ) { + $output->writeln( "No collaborators found in Pressable with the email '$this->user'." ); + } else { + $this->output_pressable_collaborators( $output, $pressable_collaborators ); } // Get users from wordpress.com - $wpcom_collaborator_data = $this->get_wpcom_users( $email ); + $output->writeln( 'Getting user data from WordPress.com.' ); - if ( empty( $wpcom_collaborator_data ) ) { - $output->writeln( "No collaborators found in WordPress.com with the email '$email'." ); - } else { - $site_info = new Table( $output ); - $site_info->setStyle( 'box-double' ); - $site_info->setHeaders( array( 'WP URL', 'Site ID', 'WP User ID' ) ); - $wpcom_collaborator_sites = array(); - - $output->writeln( '' ); - $output->writeln( "$email is a user on the following WordPress sites:" ); - foreach ( $wpcom_collaborator_data as $collaborator ) { - $wpcom_collaborator_sites[] = array( $collaborator->siteName, $collaborator->siteId, $collaborator->userId ); - } - $site_info->setRows( $wpcom_collaborator_sites ); - $site_info->render(); + $wpcom_users = $this->get_wpcom_users( $output ); + if ( ! \is_array( $wpcom_users ) ) { + $output->writeln( 'Something has gone wrong while looking up the WordPress.com collaborators.' ); + return 1; } - // Bail here unless the user has asked to remove the collaborator. - if ( $input->getOption( 'list' ) ) { - exit; + if ( empty( $wpcom_users ) ) { + $output->writeln( "No users found on WordPress.com sites with the email '$this->user'." ); + } else { + $this->output_wpcom_collaborators( $output, $wpcom_users ); } // Remove? - if ( ! $input->getOption( 'no-interaction' ) ) { - $confirm_remove = trim( readline( 'Are you sure you want to remove this user from WordPress.com and Pressable? (y/N) ' ) ); - if ( 'y' !== $confirm_remove ) { - exit; + if ( $this->just_list ) { + return 0; + } + if ( $input->isInteractive() ) { + $question = new ConfirmationQuestion( 'Are you sure you want to remove this user on ALL sites listed above? [y/N] ', false ); + if ( true !== $this->getHelper( 'question' )->ask( $input, $output, $question ) ) { + $output->writeln( 'Aborting.' ); + return 1; } } - // Remove from Pressable - foreach ( $collaborator_data as $collaborator ) { - $removed_collaborator = $this->api_helper->call_pressable_api( "/sites/{$collaborator->siteId}/collaborators/{$collaborator->id}", 'DELETE', array() ); - if ( 'Success' === $removed_collaborator->message ) { - $output->writeln( "✓ Removed {$collaborator->email} from {$collaborator->siteName}. (Pressable site)" ); + foreach ( $pressable_collaborators as $collaborator ) { + if ( delete_pressable_site_collaborator_by_id( $collaborator->siteId, $collaborator->id ) ) { + $output->writeln( "✅ Removed $collaborator->email from Pressable site $collaborator->siteName." ); } else { - $output->writeln( "❌ Failed to remove from {$collaborator->email} from Pressable site '{$collaborator->siteName}." ); + $output->writeln( "❌ Failed to remove from $collaborator->email from Pressable site $collaborator->siteName." ); } } - - // Remove from WordPress - foreach ( $wpcom_collaborator_data as $collaborator ) { - $removed_collaborator = $this->api_helper->call_wpcom_api( "rest/v1.1/sites/{$collaborator->siteId}/users/{$collaborator->userId}/delete", array(), 'POST' ); - - if ( isset( $removed_collaborator->success ) && $removed_collaborator->success ) { - $output->writeln( "✓ Removed {$collaborator->email} from {$collaborator->siteName} (WordPress site)." ); + foreach ( $wpcom_users as $user ) { + if ( delete_wpcom_site_user_by_id( $user->siteId, $user->userId ) ) { + $output->writeln( "✅ Removed $user->email from WordPress.com site $user->siteName." ); } else { - $output->writeln( "❌ Failed to remove {$collaborator->email} from WordPress site '{$collaborator->siteName}." ); + $output->writeln( "❌ Failed to remove $user->email from WordPress.com site $user->siteName." ); } } - // TODO: Remove user from Github too? - - $output->writeln( 'All done!' ); + return 0; } + // endregion + + // region HELPERS + /** - * Given an email, return the list of sites owned by that user. + * Returns the Pressable collaborator objects that match the given email. + * + * @return object[]|null */ - private function get_wpcom_users( $email ) { - $exclude_sites = array( - 'https://woocommerce.com', + protected function get_pressable_collaborators(): ?array { + $collaborators = get_pressable_collaborators(); + if ( ! \is_array( $collaborators ) ) { + return null; + } + + return \array_filter( + $collaborators, + fn ( $collaborator ) => $collaborator->email === $this->user, ); + } - $this->output->writeln( 'Fetching list of WordPress.com & Jetpack sites...' ); + /** + * Outputs the Pressable collaborators to the console in tabular form. + * + * @param OutputInterface $output The output object. + * @param object[] $collaborators The collaborators to output. + * + * @return void + */ + protected function output_pressable_collaborators( OutputInterface $output, array $collaborators ): void { + $table = new Table( $output ); - $all_sites = $this->api_helper->call_wpcom_api( 'rest/v1.1/me/sites/?fields=ID,URL', array() ); + $table->setHeaderTitle( "$this->user is a collaborator on the following Pressable sites" ); + $table->setHeaders( array( 'Default Pressable URL', 'Site ID', 'Collaborator ID' ) ); - if ( ! empty( $all_sites->error ) ) { - $this->output->writeln( 'Failed. ' . $all_sites->message . '' ); - exit; + foreach ( $collaborators as $collaborator ) { + $table->addRow( array( $collaborator->siteName . '.mystagingwebsite.com', $collaborator->siteId, $collaborator->id ) ); } - // Filter out sites from exclude list. - $filtered_sites = array_filter( - $all_sites->sites, - function( $site ) use ( $exclude_sites ) { - foreach ( $exclude_sites as $exclude ) { - if ( $exclude === $site->URL ) { - return false; - } - } - return true; - } - ); + $table->setStyle( 'box-double' ); + $table->render(); + } + + /** + * Returns the WordPress.com collaborator objects that match the given email. + * + * @return object[]|null + * @noinspection PhpDocMissingThrowsInspection + */ + protected function get_wpcom_users( OutputInterface $output ): ?array { + // Get sites from WPCOM. + $output->writeln( 'Fetching the list of WordPress.com & Jetpack sites...', OutputInterface::VERBOSITY_VERBOSE ); - $this->output->writeln( "Searching for '$email' across " . count( $filtered_sites ) . ' WordPress.com & Jetpack sites...' ); + $sites = get_wpcom_sites( array( 'fields' => 'ID,URL' ) ); + if ( ! \is_array( $sites ) ) { + return null; + } - $site_users_endpoints = array_map( - static function( $site ) use ( $email ) { - return "sites/$site->ID/users/?search=$email&search_columns=user_email&fields=ID,email,site_ID,URL"; - }, - $filtered_sites + $excluded = array( 'https://woocommerce.com' ); + $sites = \array_filter( + $sites, + static fn ( $site ) => ! \in_array( $site->URL, $excluded, true ), ); - // concurrent call for all endpoints. - $sites_users = WPCOM_API_Helper::call_api_concurrent( $site_users_endpoints ); + // Search for the user on each site. + $output->writeln( "Searching for '$this->user' across " . \count( $sites ) . ' WordPress.com & Jetpack sites...', OutputInterface::VERBOSITY_VERBOSE ); - // clean up data by removing entries were user was not found. - $sites_users = array_filter( - $sites_users, - static function( $user ) { - return ( isset( $user ) && ! isset( $user->error ) && $user->found > 0 ); - } + $collaborators = WPCOM_API_Helper::call_api_concurrent( + \array_map( + fn ( $site ) => "sites/$site->ID/users/?search=$this->user&search_columns=user_email&fields=ID,email,site_ID,URL", + $sites + ) ); + $failed_sites = \array_intersect_key( $sites, \array_filter( $collaborators, static fn ( $collaborator ) => \is_null( $collaborator ) ) ); + $collaborators = \array_filter( $collaborators, static fn ( $collaborator ) => \is_object( $collaborator ) && 0 < $collaborator->found ); + + ! empty( $failed_sites ) && $this->output_wpcom_failed_sites( $output, $failed_sites ); + + return \array_map( + static fn( string $site_id, object $collaborator ) => (object) array( + 'siteId' => $site_id, + 'siteName' => $sites[ $site_id ]->URL, + 'userId' => $collaborator->users[0]->ID, + ), + \array_keys( $collaborators ), + $collaborators, + ); + } - $data = array(); - foreach ( $filtered_sites as $site ) { - foreach ( $site_users_endpoints as $index => $endpoint ) { - if ( isset( $sites_users[ $index ] ) && str_contains( $endpoint, $site->ID ) ) { - $data[] = (object) array( - 'userId' => $sites_users[ $index ]->users[0]->ID, - 'email' => $sites_users[ $index ]->users[0]->email, - 'siteId' => $site->ID, - 'siteName' => $site->URL, - ); - } - } + /** + * Outputs the WordPress.com sites that failed to fetch collaborators from. + * + * @param OutputInterface $output The output object. + * @param array $sites The sites to output. + * + * @return void + */ + protected function output_wpcom_failed_sites( OutputInterface $output, array $sites ): void { + $table = new Table( $output ); + + $table->setHeaderTitle( 'Failed to fetch collaborators from the following WordPress.com sites' ); + $table->setHeaders( array( 'WP URL', 'Site ID' ) ); + + foreach ( $sites as $site ) { + $table->addRow( array( $site->URL, $site->ID ) ); + } + + $table->setStyle( 'box-double' ); + $table->render(); + } + + /** + * Outputs the WordPress.com collaborators to the console in tabular form. + * + * @param OutputInterface $output The output object. + * @param object[] $collaborators The collaborators to output. + * + * @return void + */ + protected function output_wpcom_collaborators( OutputInterface $output, array $collaborators ): void { + $table = new Table( $output ); + + $table->setHeaderTitle( "$this->user is a collaborator on the following WordPress.com sites" ); + $table->setHeaders( array( 'WP URL', 'Site ID', 'WP User ID' ) ); + + foreach ( $collaborators as $collaborator ) { + $table->addRow( array( $collaborator->siteName, $collaborator->siteId, $collaborator->userId ) ); } - return $data; + $table->setStyle( 'box-double' ); + $table->render(); } + + // endregion } diff --git a/src/helpers/pressable-functions.php b/src/helpers/pressable-functions.php index e5b23c92..d81acfa2 100644 --- a/src/helpers/pressable-functions.php +++ b/src/helpers/pressable-functions.php @@ -247,6 +247,21 @@ function reset_pressable_site_sftp_user_password( string $site_id, string $usern return $new_password->data; } +/** + * Get a list of collaborators. This will return all the collaborators that are attached to your sites, + * plus any instances of you being a collaborator on a site. + * + * @return object[]|null + */ +function get_pressable_collaborators(): ?array { + $collaborators = Pressable_API_Helper::call_api( 'collaborators' ); + if ( \is_null( $collaborators ) || empty( $collaborators->data ) ) { + return null; + } + + return $collaborators->data; +} + /** * Get a list of collaborators for the specified site. * @@ -305,6 +320,23 @@ function get_pressable_site_collaborator_by_email( string $site_id, string $coll return null; } +/** + * Delete a collaborator with the specified id from the given site. + * + * @param string $site_id The site ID. + * @param string $collaborator_id The collaborator ID. + * + * @return bool|null + */ +function delete_pressable_site_collaborator_by_id( string $site_id, string $collaborator_id ): ?bool { + $response = Pressable_API_Helper::call_api( "/sites/$site_id/collaborators/$collaborator_id", 'DELETE' ); + if ( \is_null( $response ) || ! \property_exists( $response, 'message' ) ) { + return $response; + } + + return 'Success' === $response->message; +} + /** * Adds a collaborator with the given email address to a given site. We reuse the bulk create endpoint because the single * create endpoint does not support the `roles` parameter. diff --git a/src/helpers/wpcom-functions.php b/src/helpers/wpcom-functions.php index dc7ecf69..38d3cc76 100644 --- a/src/helpers/wpcom-functions.php +++ b/src/helpers/wpcom-functions.php @@ -10,6 +10,7 @@ * the list contains both active and inactive sites. Make sure to read the API documentation for a complete list of defaults and options. * * @param array $params Optional. Additional parameters to pass to the API call. + * It's recommended to pass the `fields` parameter otherwise the response is likely to time out. * * @link https://developer.wordpress.com/docs/api/1.1/get/me/sites/ * @@ -105,6 +106,23 @@ function get_wpcom_site_user_by_email( string $site_id_or_url, string $email ): return null; } +/** + * Deletes or removes a user of a site. + * + * @param string $site_id_or_url The site URL or WordPress.com site ID. + * @param string $user_id The WP user ID. + * + * @return bool|null + */ +function delete_wpcom_site_user_by_id( string $site_id_or_url, string $user_id ): ?bool { + $result = WPCOM_API_Helper::call_api( "sites/$site_id_or_url/users/$user_id/delete", 'POST' ); + if ( \is_null( $result ) || ! \property_exists( $result, 'success' ) ) { + return null; + } + + return $result->success; +} + /** * Resets a given user's password on a site using the Jetpack API. * From 591df3a2e803ab92496cbef1c0ca34036366d0d2 Mon Sep 17 00:00:00 2001 From: Antonius Hegyes Date: Fri, 12 May 2023 18:20:37 +0200 Subject: [PATCH 2/3] UX tweaks --- src/commands/remove-user.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/remove-user.php b/src/commands/remove-user.php index 559abb97..130693a1 100644 --- a/src/commands/remove-user.php +++ b/src/commands/remove-user.php @@ -105,7 +105,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in } // Remove? - if ( $this->just_list ) { + if ( $this->just_list || ( empty( $pressable_collaborators ) && empty( $wpcom_users ) ) ) { return 0; } if ( $input->isInteractive() ) { @@ -224,7 +224,7 @@ protected function get_wpcom_users( OutputInterface $output ): ?array { } /** - * Outputs the WordPress.com sites that failed to fetch collaborators from. + * Outputs the WordPress.com sites that failed to fetch users from. * * @param OutputInterface $output The output object. * @param array $sites The sites to output. @@ -234,7 +234,7 @@ protected function get_wpcom_users( OutputInterface $output ): ?array { protected function output_wpcom_failed_sites( OutputInterface $output, array $sites ): void { $table = new Table( $output ); - $table->setHeaderTitle( 'Failed to fetch collaborators from the following WordPress.com sites' ); + $table->setHeaderTitle( 'Failed to fetch users from the following WordPress.com sites' ); $table->setHeaders( array( 'WP URL', 'Site ID' ) ); foreach ( $sites as $site ) { From b55f05252974fe072cf52b9c19717003e38e0e0b Mon Sep 17 00:00:00 2001 From: Antonius Hegyes Date: Tue, 6 Jun 2023 20:02:33 +0200 Subject: [PATCH 3/3] Tweak text output for UX + notice fix --- src/commands/remove-user.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/commands/remove-user.php b/src/commands/remove-user.php index 130693a1..70d1e6c2 100644 --- a/src/commands/remove-user.php +++ b/src/commands/remove-user.php @@ -94,14 +94,14 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $wpcom_users = $this->get_wpcom_users( $output ); if ( ! \is_array( $wpcom_users ) ) { - $output->writeln( 'Something has gone wrong while looking up the WordPress.com collaborators.' ); + $output->writeln( 'Something has gone wrong while looking up the WordPress.com users.' ); return 1; } if ( empty( $wpcom_users ) ) { $output->writeln( "No users found on WordPress.com sites with the email '$this->user'." ); } else { - $this->output_wpcom_collaborators( $output, $wpcom_users ); + $this->output_wpcom_users( $output, $wpcom_users ); } // Remove? @@ -124,6 +124,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in } } foreach ( $wpcom_users as $user ) { + $user->email = $this->user; // The email is not returned by the API, but the API is filtered by it, so we know what it must be ... if ( delete_wpcom_site_user_by_id( $user->siteId, $user->userId ) ) { $output->writeln( "✅ Removed $user->email from WordPress.com site $user->siteName." ); } else { @@ -203,7 +204,7 @@ protected function get_wpcom_users( OutputInterface $output ): ?array { $collaborators = WPCOM_API_Helper::call_api_concurrent( \array_map( - fn ( $site ) => "sites/$site->ID/users/?search=$this->user&search_columns=user_email&fields=ID,email,site_ID,URL", + fn ( $site ) => "sites/$site->ID/users/?search=$this->user&search_columns=user_email", $sites ) ); @@ -246,20 +247,20 @@ protected function output_wpcom_failed_sites( OutputInterface $output, array $si } /** - * Outputs the WordPress.com collaborators to the console in tabular form. + * Outputs the WordPress.com users to the console in tabular form. * - * @param OutputInterface $output The output object. - * @param object[] $collaborators The collaborators to output. + * @param OutputInterface $output The output object. + * @param object[] $users The users to output. * * @return void */ - protected function output_wpcom_collaborators( OutputInterface $output, array $collaborators ): void { + protected function output_wpcom_users( OutputInterface $output, array $users ): void { $table = new Table( $output ); - $table->setHeaderTitle( "$this->user is a collaborator on the following WordPress.com sites" ); + $table->setHeaderTitle( "$this->user is a user on the following WordPress.com sites" ); $table->setHeaders( array( 'WP URL', 'Site ID', 'WP User ID' ) ); - foreach ( $collaborators as $collaborator ) { + foreach ( $users as $collaborator ) { $table->addRow( array( $collaborator->siteName, $collaborator->siteId, $collaborator->userId ) ); }