From bc50ab66f3a44df220a7daa3cb1d917bd02487ba Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
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 <tcit@tcit.fr>
---
 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.
   """