diff --git a/plugins/bcc-login/includes/class-bcc-login-settings.php b/plugins/bcc-login/includes/class-bcc-login-settings.php index fc175f2..b38e599 100644 --- a/plugins/bcc-login/includes/class-bcc-login-settings.php +++ b/plugins/bcc-login/includes/class-bcc-login-settings.php @@ -657,6 +657,11 @@ function render_delete_subscribers_button () {

+ _settings->create_missing_users ) : ?> +

+ +

+
get_col( - "SELECT user_id FROM {$wpdb->prefix}usermeta - WHERE meta_key = '{$wpdb->prefix}capabilities' AND meta_value LIKE 'a:1:{s:10:\"subscriber\";b:1;}'" - ) ); + // Users whose only role on this site is subscriber. On a network the users and usermeta tables are + // shared by all sites, and the role is in this site's capabilities meta. + $capabilities_key = $wpdb->get_blog_prefix() . 'capabilities'; + $subscriber_ids = array_map( 'intval', $wpdb->get_col( $wpdb->prepare( + "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value = %s", + $capabilities_key, + 'a:1:{s:10:"subscriber";b:1;}' + ) ) ); + + // Of those, the ones who also belong to another site of the network. Null when a query failed. + $member_ids = '' === $wpdb->last_error ? array() : null; + if ( is_multisite() && null !== $member_ids ) { + // Network administrators are left alone, whatever their role on this site. + $subscriber_ids = array_values( array_diff( $subscriber_ids, $this->get_network_admin_ids() ) ); + $member_ids = $this->get_other_sites_member_ids( $subscriber_ids, $capabilities_key ); + } - $sql = "DELETE {$wpdb->prefix}users, {$wpdb->prefix}usermeta - FROM {$wpdb->prefix}users - INNER JOIN {$wpdb->prefix}usermeta ON {$wpdb->prefix}users.ID = {$wpdb->prefix}usermeta.user_id - WHERE - meta_key = '{$wpdb->prefix}capabilities' AND - meta_value LIKE 'a:1:{s:10:\"subscriber\";b:1;}'"; + // On a network, subscribers who also belong to another site keep their account and only leave this + // site, and only network administrators may delete accounts. With OIDC_CREATE_USERS on, the login + // can't add an existing account back to a site, so nobody only leaves: they're kept until they can + // be deleted. + $can_delete = ! is_multisite() || current_user_can( 'delete_users' ); + $deleted_ids = array(); + $removed_ids = array(); + if ( null !== $member_ids ) { + $deleted_ids = $can_delete ? array_values( array_diff( $subscriber_ids, $member_ids ) ) : array(); + $removed_ids = $this->_settings->create_missing_users ? array() : array_values( array_diff( $subscriber_ids, $deleted_ids ) ); + } + $kept_ids = array_values( array_diff( $subscriber_ids, $deleted_ids, $removed_ids ) ); - $subscribers_deleted = $wpdb->query($sql); + // Their logins, email addresses and slugs, to clear them from the object cache afterwards. + $cached_users = $this->get_cache_keys_of_users( array_merge( $deleted_ids, $removed_ids ) ); - BCC_Login_Cleanup::clean_deleted_users_cache( $subscriber_ids ); + // Accounts first: if that fails, they're still subscribers, so deleting them again finds them. + // Nothing changes when the users couldn't be looked up. + $subscribers_deleted = null !== $member_ids && $this->delete_users( $deleted_ids ) && $this->remove_users_from_site( $removed_ids ); - if ( false === $subscribers_deleted ) { + $this->clean_users_cache( $cached_users ); + + if ( ! $subscribers_deleted ) { add_settings_error( 'subscribers_deleted', 'subscribers_not_deleted', @@ -707,8 +733,8 @@ function delete_subscribers_handler() { ); } - // The join above only removes the capabilities meta row of each subscriber, - // so remove all remaining meta (e.g. openid-connect-generic-*) of users that no longer exist. + // Deleting an account above leaves its meta, so remove all meta (e.g. openid-connect-generic-*) + // of users that no longer exist. $orphaned_meta_deleted = BCC_Login_Cleanup::delete_orphaned_user_meta(); if ( false === $orphaned_meta_deleted ) { @@ -749,8 +775,44 @@ function delete_subscribers_handler() { // Only report the steps that succeeded, the others have an error notice. $messages = array(); - if ( false !== $subscribers_deleted ) { - $messages[] = __( 'All subscribers were successfully deleted.' ); + if ( $subscribers_deleted ) { + if ( ! is_multisite() ) { + $messages[] = __( 'All subscribers were successfully deleted.' ); + } else { + if ( $can_delete ) { + $messages[] = sprintf( _n( 'Deleted %d subscriber.', 'Deleted %d subscribers.', count( $deleted_ids ), 'bcc-login' ), count( $deleted_ids ) ); + } + + if ( $removed_ids ) { + $messages[] = $can_delete + ? sprintf( + _n( + '%d subscriber belongs to another site of the network, so they were only removed from this site.', + '%d subscribers belong to another site of the network, so they were only removed from this site.', + count( $removed_ids ), + 'bcc-login' + ), + count( $removed_ids ) + ) + : sprintf( _n( 'Removed %d subscriber from this site.', 'Removed %d subscribers from this site.', count( $removed_ids ), 'bcc-login' ), count( $removed_ids ) ); + } + + if ( $kept_ids ) { + $messages[] = sprintf( + _n( + '%d subscriber was kept: with OIDC_CREATE_USERS on, they couldn\'t log in here again after only leaving this site.', + '%d subscribers were kept: with OIDC_CREATE_USERS on, they couldn\'t log in here again after only leaving this site.', + count( $kept_ids ), + 'bcc-login' + ), + count( $kept_ids ) + ); + } + + if ( ! $can_delete ) { + $messages[] = __( 'Only a network administrator can delete user accounts.', 'bcc-login' ); + } + } } if ( false !== $orphaned_meta_deleted ) { @@ -773,6 +835,134 @@ function delete_subscribers_handler() { } } + /** + * The user ids of the network administrators. + */ + private function get_network_admin_ids() { + $ids = array(); + + foreach ( get_super_admins() as $login ) { + $user = get_user_by( 'login', $login ); + if ( $user ) { + $ids[] = $user->ID; + } + } + + return $ids; + } + + /** + * Of the given users, those who belong to another site of the network: they have its capabilities meta, + * with or without a role. Null when a query failed. + */ + private function get_other_sites_member_ids( $user_ids, $capabilities_key ) { + global $wpdb; + + $member_ids = array(); + $any_site_capabilities = '/^' . preg_quote( $wpdb->base_prefix, '/' ) . '(\d+_)?capabilities$/'; + + foreach ( array_chunk( $user_ids, 1000 ) as $chunk ) { + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT user_id, meta_key FROM {$wpdb->usermeta} + WHERE user_id IN (" . implode( ',', $chunk ) . ") AND meta_key LIKE %s AND meta_key != %s", + $wpdb->esc_like( $wpdb->base_prefix ) . '%capabilities', + $capabilities_key + ) ); + + if ( '' !== $wpdb->last_error ) { + return null; + } + + foreach ( $rows as $row ) { + if ( preg_match( $any_site_capabilities, $row->meta_key ) ) { + $member_ids[] = (int) $row->user_id; + } + } + } + + return array_values( array_intersect( $user_ids, $member_ids ) ); + } + + /** + * The ids, logins, slugs and email addresses of the given users, which the object cache keys users by. + */ + private function get_cache_keys_of_users( $user_ids ) { + global $wpdb; + + $users = array(); + + foreach ( array_chunk( $user_ids, 1000 ) as $chunk ) { + $users = array_merge( $users, $wpdb->get_results( + "SELECT ID, user_login, user_nicename, user_email FROM {$wpdb->users} WHERE ID IN (" . implode( ',', $chunk ) . ')' + ) ); + } + + return $users; + } + + /** + * Removes the given users from the object cache, as clean_user_cache() does for one user, which would + * load each user and their meta first. + */ + private function clean_users_cache( $users ) { + foreach ( $users as $user ) { + wp_cache_delete( $user->ID, 'users' ); + wp_cache_delete( $user->ID, 'user_meta' ); + wp_cache_delete( $user->user_login, 'userlogins' ); + wp_cache_delete( $user->user_nicename, 'userslugs' ); + + if ( ! empty( $user->user_email ) ) { + wp_cache_delete( $user->user_email, 'useremail' ); + } + } + + // Cached user queries, e.g. the users list (WordPress 6.3+). + if ( $users && function_exists( 'wp_cache_set_users_last_changed' ) ) { + wp_cache_set_users_last_changed(); + } + } + + /** + * Deletes the accounts of the given users, in bulk: there can be thousands, too many for wp_delete_user() + * or wpmu_delete_user(), which would also delete any posts they wrote. So their posts are kept, and + * WordPress's user deletion hooks don't run (nothing in BCC's plugins uses them). Their meta is removed + * afterwards, with the meta of all users that no longer exist. + */ + private function delete_users( $user_ids ) { + global $wpdb; + + foreach ( array_chunk( $user_ids, 1000 ) as $chunk ) { + if ( false === $wpdb->query( "DELETE FROM {$wpdb->users} WHERE ID IN (" . implode( ',', $chunk ) . ')' ) ) { + return false; + } + } + + return true; + } + + /** + * Removes the given users from this site, as WordPress does: their role and user level on the site. + */ + private function remove_users_from_site( $user_ids ) { + global $wpdb; + + $blog_prefix = $wpdb->get_blog_prefix(); + + foreach ( array_chunk( $user_ids, 1000 ) as $chunk ) { + $removed = $wpdb->query( $wpdb->prepare( + "DELETE FROM {$wpdb->usermeta} WHERE meta_key IN (%s, %s) AND user_id IN (" . implode( ',', $chunk ) . ')', + $blog_prefix . 'capabilities', + $blog_prefix . 'user_level' + ) ); + + if ( false === $removed ) { + return false; + } + } + + return true; + } + function render_group_selector_field( $args ) { ?>