From 6a4005d389243199a72bcee37cf0535ac7301043 Mon Sep 17 00:00:00 2001 From: Simon Costea Date: Wed, 14 Oct 2026 10:56:27 +0300 Subject: [PATCH 1/2] Make "Delete subscribers" work on multisite It used the site's table prefix for the users and usermeta tables, which on a network are shared: on a subsite the query failed, and on the main site it deleted users who still had roles on other sites. Now subscribers who belong to another site only leave this site, network administrators are left alone, and only a network administrator deletes accounts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-bcc-login-settings.php | 182 ++++++++++++++++-- 1 file changed, 164 insertions(+), 18 deletions(-) diff --git a/plugins/bcc-login/includes/class-bcc-login-settings.php b/plugins/bcc-login/includes/class-bcc-login-settings.php index 9905483..b4dca7d 100644 --- a/plugins/bcc-login/includes/class-bcc-login-settings.php +++ b/plugins/bcc-login/includes/class-bcc-login-settings.php @@ -654,6 +654,9 @@ function render_delete_subscribers_button () {

+ +

+
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); + // 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 ); + // Remove them from the object cache. The cleanup below only finds users that still have meta left, + // which isn't the case if the role was their only meta. BCC_Login_Cleanup::clean_deleted_users_cache( $subscriber_ids ); - if ( false === $subscribers_deleted ) { + if ( ! $subscribers_deleted ) { add_settings_error( 'subscribers_deleted', 'subscribers_not_deleted', @@ -704,8 +727,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 ) { @@ -746,8 +769,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 ) { @@ -770,6 +829,93 @@ 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 ) ); + } + + /** + * Deletes the accounts of the given users. 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 ) { ?>