From bc50ab66f3a44df220a7daa3cb1d917bd02487ba Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Mon, 21 Aug 2023 17:51:22 +0200 Subject: [PATCH] feat(cli): allow the mobilizon.users.delete command to delete multiple users by email domain or ip Also allow to delete groups the user's actors are admins of Signed-off-by: Thomas Citharel --- lib/mix/tasks/mobilizon/common.ex | 4 + lib/mix/tasks/mobilizon/users/delete.ex | 187 ++++++++++++++++++++++-- lib/mobilizon/users/users.ex | 15 ++ 3 files changed, 190 insertions(+), 16 deletions(-) diff --git a/lib/mix/tasks/mobilizon/common.ex b/lib/mix/tasks/mobilizon/common.ex index 910dc7e3f..07b8c871b 100644 --- a/lib/mix/tasks/mobilizon/common.ex +++ b/lib/mix/tasks/mobilizon/common.ex @@ -75,6 +75,10 @@ defmodule Mix.Tasks.Mobilizon.Common do IO.puts(:stderr, message) end + shutdown(options) + end + + def shutdown(options \\ []) do if @env != :test do exit({:shutdown, Keyword.get(options, :error_code, 1)}) end diff --git a/lib/mix/tasks/mobilizon/users/delete.ex b/lib/mix/tasks/mobilizon/users/delete.ex index c72cd97a8..73d1e4860 100644 --- a/lib/mix/tasks/mobilizon/users/delete.ex +++ b/lib/mix/tasks/mobilizon/users/delete.ex @@ -3,49 +3,204 @@ defmodule Mix.Tasks.Mobilizon.Users.Delete do Task to delete a user """ use Mix.Task - alias Mobilizon.Users + alias Mobilizon.{Actors, Users} + alias Mobilizon.Federation.ActivityPub.Actions.Delete + alias Mobilizon.Storage.Page alias Mobilizon.Users.User import Mix.Tasks.Mobilizon.Common - @shortdoc "Deletes a Mobilizon user" + @shortdoc "Deletes a Mobilizon user or multiple users matching a pattern" @impl Mix.Task - def run([email | rest]) do - {options, [], []} = + def run(argv) do + {options, args, []} = OptionParser.parse( - rest, + argv, strict: [ assume_yes: :boolean, - keep_email: :boolean + all_matching_email_domain: :boolean, + all_matching_ip: :boolean, + include_groups_where_admin: :boolean, + help: :boolean ], aliases: [ y: :assume_yes, - k: :keep_email + h: :help ] ) - assume_yes? = Keyword.get(options, :assume_yes, false) - keep_email? = Keyword.get(options, :keep_email, false) + if Keyword.get(options, :help, false) do + show_help() + end + + if Enum.empty?(args) do + shell_error("mobilizon.users.delete requires an email as argument") + end + + all_matching_ip? = Keyword.get(options, :all_matching_ip, false) + + if Keyword.get(options, :all_matching_email_domain, false) and all_matching_ip? do + shell_error( + "Can't use options --all_matching_email_domain and --all_matching_ip options at the same time" + ) + end + + input = String.trim(hd(args)) + + if all_matching_ip? and not valid_ip?(input) do + shell_error("Provided IP address is not a valid format") + end start_mobilizon() - with {:ok, %User{} = user} <- Users.get_user_by_email(email), - true <- assume_yes? or shell_yes?("Continue with deleting user #{user.email}?"), - {:ok, %User{} = user} <- - Users.delete_user(user, reserve_email: keep_email?) do + handle_command(input, options) + end + + @spec handle_command(String.t(), Keyword.t()) :: any() + defp handle_command(input, options) do + cond do + Keyword.get(options, :all_matching_email_domain, false) -> + delete_users_matching_email_domain(input, options) + + Keyword.get(options, :all_matching_ip, false) -> + delete_users_matching_ip(input, options) + + String.contains?(input, "@") -> + delete_single_user(input, options) + + true -> + shell_error("Provided input does not seem to be an email address") + end + end + + defp delete_single_user(input, options) do + with {:ok, %User{} = user} <- Users.get_user_by_email(input), + true <- + Keyword.get(options, :assume_yes, false) or + shell_yes?("Continue with deleting user #{user.email}?") do + do_delete_users([user], options) + shell_info(""" The user #{user.email} has been deleted """) else {:error, :user_not_found} -> - shell_error("Error: No such user") + shell_error("No user with the email \"#{input}\" was found") _ -> shell_error("User has not been deleted.") end end - def run(_) do - shell_error("mobilizon.users.delete requires an email as argument") + defp delete_users_matching_email_domain(input, options) do + users = Users.get_users_by_email_domain(input) + nb_users = length(users) + assume_yes? = Keyword.get(options, :assume_yes, false) + + with {:no_users, false} <- {:no_users, nb_users == 0}, + {:confirm, true} <- + {:confirm, assume_yes? or shell_yes?("Continue with deleting #{length(users)} users?")}, + true <- + do_delete_users(users, options) do + shell_info(""" + All users from domain #{input} have been deleted + """) + else + {:no_users, true} -> + shell_error("No users found for this email domain") + + {:confirm, false} -> + shell_error("Users have not been deleted.") + end + end + + defp delete_users_matching_ip(input, options) do + users = Users.get_users_by_ip_address(input) + nb_users = length(users) + assume_yes? = Keyword.get(options, :assume_yes, false) + + with {:no_users, false} <- {:no_users, nb_users == 0}, + {:confirm, true} <- + {:confirm, assume_yes? or shell_yes?("Continue with deleting #{length(users)} users?")}, + true <- + do_delete_users(users, options) do + shell_info(""" + All users using IP address #{input} have been deleted + """) + else + {:no_users, true} -> + shell_error("No users found for this IP address") + + {:confirm, false} -> + shell_error("Users have not been deleted.") + end + end + + defp do_delete_users(users, options) do + Enum.each(users, fn user -> do_delete_user(user, options) end) + true + end + + defp do_delete_user(user, options) do + with actors <- Users.get_actors_for_user(user), + # Detach actors from user + :ok <- Enum.each(actors, fn actor -> Actors.update_actor(actor, %{user_id: nil}) end), + # Launch a background job to delete actors + :ok <- + Enum.each(actors, fn actor -> + # Delete groups the actor is an admin for + if Keyword.get(options, :include_groups_where_admin, false) do + suspend_group_where_admin(actor) + end + + Delete.delete(actor, actor, true) + end) do + # Delete user + Users.delete_user(user, reserve_email: false) + end + end + + defp suspend_group_where_admin(actor) do + %Page{elements: memberships} = Actors.list_memberships_for_user(actor.user_id, nil, 1, 10_000) + + memberships + |> Enum.filter(fn membership -> membership.role === :administrator end) + |> Enum.each(fn membership -> + Delete.delete(membership.parent, actor, true) + end) + end + + @ip_regex ~r/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^(([a-zA-Z]|[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$|^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/ + + @spec valid_ip?(String.t()) :: boolean() + defp valid_ip?(ip) do + String.match?(ip, @ip_regex) + end + + defp show_help do + shell_info(""" + mobilizon.users.delete [-y/--assume-yes] [--all-matching-email-domain] [--all-matching-ip] [--include-groups-where-admin] [-h/--help] [email/domain/ip] + + This command allows to delete a single user or multiple users using their email domain or IP address properties. Actors are suspended as well. + + Options: + + --all-matching-email-domain + Delete all users matching the given input as email domain + + --all-matching-ip + Delete all users matching the given input as email domain + + -h/--help + Show the help + + --include-groups-where-admin + Also suspend groups of which the user's actor profiles were admin of + + -y/--assume-yes + Automatically answer yes for all questions. + """) + + shutdown(error_code: 0) end end diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex index a1fa208b0..3881f525b 100644 --- a/lib/mobilizon/users/users.ex +++ b/lib/mobilizon/users/users.ex @@ -97,6 +97,21 @@ defmodule Mobilizon.Users do end end + @spec get_users_by_email_domain(String.t()) :: list(User.t()) + def get_users_by_email_domain(email_domain) do + User + |> where([u], like(u.email, ^"%#{email_domain}%")) + |> Repo.all() + end + + @spec get_users_by_ip_address(String.t()) :: list(User.t()) + def get_users_by_ip_address(ip_address) do + User + |> where([u], u.current_sign_in_ip == ^ip_address) + |> or_where([u], u.last_sign_in_ip == ^ip_address) + |> Repo.all() + end + @doc """ Get an user by its activation token. """