`, which keeps the * data (ADR-7) and which no one can register. */ final class UserLifecycle { public const TOMBSTONE_PREFIX = '__deleted_'; /** * Login-keyed columns, by table. The activity log is left alone: it is * history, and rewriting it would attribute old actions to someone else. */ private const LOGIN_COLUMNS = [ 'webcal_entry' => [ 'cal_create_by' ], 'webcal_entry_user' => [ 'cal_login' ], 'webcal_categories' => [ 'cat_owner' ], 'webcal_entry_categories' => [ 'cat_owner' ], 'webcal_user_pref' => [ 'cal_login' ], 'webcal_import' => [ 'cal_login' ], 'webcal_import_data' => [ 'cal_login' ], 'webcal_nonuser_cals' => [ 'cal_admin' ], 'webcal_user_layers' => [ 'cal_login', 'cal_layeruser' ], ]; /** * Hook into WordPress. */ public function register(): void { add_action( 'delete_user', [ $this, 'on_delete_user' ], 10, 3 ); add_filter( 'validate_username', [ $this, 'allow_username' ], 10, 2 ); } /** * Move the deleted user's calendar rows to their new owner. * * @param int $user_id User being deleted. * @param int|null $reassign User WordPress reassigns content to, if any. * @param object $user The user being deleted. */ public function on_delete_user( int $user_id, ?int $reassign, object $user ): void { $old = isset( $user->user_login ) && is_string( $user->user_login ) ? $user->user_login : ''; if ( '' === $old ) { return; } $target = null !== $reassign ? get_userdata( $reassign ) : false; $new = $target instanceof \WP_User && '' !== $target->user_login ? $target->user_login : self::TOMBSTONE_PREFIX . $user_id; $this->retire_login( $old, $new ); } /** * Refuse to let anyone register a tombstone login. * * @param bool $valid Whether WordPress considers the name valid. * @param string $username Candidate login. * * @psalm-suppress PossiblyUnusedReturnValue -- A validate_username filter; WordPress uses the return value. */ public function allow_username( bool $valid, string $username ): bool { return $valid && ! str_starts_with( $username, self::TOMBSTONE_PREFIX ); } /** * Rename a login across every calendar table. * * UPDATE IGNORE first, because the new owner may already hold the same * row (both attended an event, both had a preference); those keep the * new owner's copy, and the old owner's duplicates are then deleted. */ private function retire_login( string $old, string $new ): void { global $wpdb; foreach ( self::LOGIN_COLUMNS as $table => $columns ) { foreach ( $columns as $column ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $column is from the constant above. $wpdb->query( $wpdb->prepare( "UPDATE IGNORE %i SET {$column} = %s WHERE {$column} = %s", $wpdb->prefix . $table, $new, $old ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $column is from the constant above. $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE {$column} = %s", $wpdb->prefix . $table, $old ) ); } } } }