diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex index b971f0c9..013b6173 100644 --- a/lib/philomena/users.ex +++ b/lib/philomena/users.ex @@ -18,6 +18,7 @@ defmodule Philomena.Users do alias Philomena.Galleries alias Philomena.Reports alias Philomena.Filters + alias Philomena.UserEraseWorker alias Philomena.UserRenameWorker ## Database getters @@ -683,6 +684,20 @@ defmodule Philomena.Users do |> Repo.update() end + def erase_user(%User{} = user, %User{} = moderator) do + # Deactivate to prevent the user from racing these changes + {:ok, user} = deactivate_user(moderator, user) + + # Rename to prevent usage for brand recognition SEO + random_hex = Base.encode16(:crypto.strong_rand_bytes(16), case: :lower) + {:ok, user} = update_user(user, %{name: "deactivated_#{random_hex}"}) + + # Enqueue a background job to perform the rest of the deletion + Exq.enqueue(Exq, "indexing", UserEraseWorker, [user.id, moderator.id]) + + {:ok, user} + end + defp setup_roles(nil), do: nil defp setup_roles(user) do diff --git a/lib/philomena/users/eraser.ex b/lib/philomena/users/eraser.ex new file mode 100644 index 00000000..d584ac77 --- /dev/null +++ b/lib/philomena/users/eraser.ex @@ -0,0 +1,125 @@ +defmodule Philomena.Users.Eraser do + import Ecto.Query + alias Philomena.Repo + + alias Philomena.Bans + alias Philomena.Comments.Comment + alias Philomena.Comments + alias Philomena.Galleries.Gallery + alias Philomena.Galleries + alias Philomena.Posts.Post + alias Philomena.Posts + alias Philomena.Topics.Topic + alias Philomena.Topics + alias Philomena.Images + alias Philomena.SourceChanges.SourceChange + + alias Philomena.Users + + @reason "Site abuse" + @wipe_ip %Postgrex.INET{address: {127, 0, 1, 1}, netmask: 32} + @wipe_fp "ffff" + + def erase_permanently!(user, moderator) do + # Erase avatar + {:ok, user} = Users.remove_avatar(user) + + # Erase "about me" and personal title + {:ok, user} = Users.update_description(user, %{description: "", personal_title: ""}) + + # Delete all forum posts + Post + |> where(user_id: ^user.id) + |> Repo.all() + |> Enum.each(fn post -> + {:ok, post} = Posts.hide_post(post, %{deletion_reason: @reason}, moderator) + {:ok, _post} = Posts.destroy_post(post) + end) + + # Delete all comments + Comment + |> where(user_id: ^user.id) + |> Repo.all() + |> Enum.each(fn comment -> + {:ok, comment} = Comments.hide_comment(comment, %{deletion_reason: @reason}, moderator) + {:ok, _comment} = Comments.destroy_comment(comment) + end) + + # Delete all galleries + Gallery + |> where(creator_id: ^user.id) + |> Repo.all() + |> Enum.each(fn gallery -> + {:ok, _gallery} = Galleries.delete_gallery(gallery) + end) + + # Delete all posted topics + Topic + |> where(user_id: ^user.id) + |> Repo.all() + |> Enum.each(fn topic -> + {:ok, _topic} = Topics.hide_topic(topic, @reason, moderator) + end) + + # Revert all source changes + SourceChange + |> where(user_id: ^user.id) + |> order_by(desc: :created_at) + |> preload(:image) + |> Repo.all() + |> Enum.each(fn source_change -> + if source_change.added do + revert_added_source_change(source_change, user) + else + revert_removed_source_change(source_change, user) + end + end) + + # Delete all source changes + SourceChange + |> where(user_id: ^user.id) + |> Repo.delete_all() + + # Ban the user + {:ok, _ban} = + Bans.create_user( + moderator, + %{ + "user_id" => user.id, + "reason" => @reason, + "valid_until" => "permanent" + } + ) + + # We succeeded + :ok + end + + defp revert_removed_source_change(source_change, user) do + old_sources = %{} + new_sources = %{"0" => %{"source" => source_change.source_url}} + + revert_source_change(source_change, user, old_sources, new_sources) + end + + defp revert_added_source_change(source_change, user) do + old_sources = %{"0" => %{"source" => source_change.source_url}} + new_sources = %{} + + revert_source_change(source_change, user, old_sources, new_sources) + end + + defp revert_source_change(source_change, user, old_sources, new_sources) do + attrs = %{"old_sources" => old_sources, "sources" => new_sources} + + attribution = [ + user: user, + ip: @wipe_ip, + fingerprint: @wipe_fp, + user_agent: "", + referrer: "" + ] + + {:ok, _} = Images.update_sources(source_change.image, attribution, attrs) + end +end diff --git a/lib/philomena/workers/user_erase_worker.ex b/lib/philomena/workers/user_erase_worker.ex new file mode 100644 index 00000000..32c862d3 --- /dev/null +++ b/lib/philomena/workers/user_erase_worker.ex @@ -0,0 +1,11 @@ +defmodule Philomena.UserEraseWorker do + alias Philomena.Users.Eraser + alias Philomena.Users + + def perform(user_id, moderator_id) do + moderator = Users.get_user!(moderator_id) + user = Users.get_user!(user_id) + + Eraser.erase_permanently!(user, moderator) + end +end diff --git a/lib/philomena_web/controllers/admin/user/erase_controller.ex b/lib/philomena_web/controllers/admin/user/erase_controller.ex new file mode 100644 index 00000000..d101db57 --- /dev/null +++ b/lib/philomena_web/controllers/admin/user/erase_controller.ex @@ -0,0 +1,74 @@ +defmodule PhilomenaWeb.Admin.User.EraseController do + use PhilomenaWeb, :controller + + alias Philomena.Users.User + alias Philomena.Users + + plug :verify_authorized + + plug :load_resource, + model: User, + id_name: "user_id", + id_field: "slug", + persisted: true, + preload: [:roles] + + plug :prevent_deleting_privileged_users + plug :prevent_deleting_verified_users + plug :prevent_deleting_old_users + + def new(conn, _params) do + render(conn, "new.html", title: "Erase user") + end + + def create(conn, _params) do + {:ok, user} = Users.erase_user(conn.assigns.user, conn.assigns.current_user) + + conn + |> put_flash(:info, "User erase started") + |> redirect(to: ~p"/profiles/#{user}") + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, User) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end + + defp prevent_deleting_privileged_users(conn, _opts) do + if conn.assigns.user.role != "user" do + conn + |> put_flash(:error, "Cannot erase a privileged user") + |> redirect(to: ~p"/profiles/#{conn.assigns.user}") + |> Plug.Conn.halt() + else + conn + end + end + + defp prevent_deleting_verified_users(conn, _opts) do + if conn.assigns.user.verified do + conn + |> put_flash(:error, "Cannot erase a verified user") + |> redirect(to: ~p"/profiles/#{conn.assigns.user}") + |> Plug.Conn.halt() + else + conn + end + end + + defp prevent_deleting_old_users(conn, _opts) do + now = DateTime.utc_now(:second) + two_weeks = 1_209_600 + + if DateTime.compare(now, DateTime.add(conn.assigns.user.created_at, two_weeks)) == :gt do + conn + |> put_flash(:error, "Cannot erase a user older than two weeks") + |> redirect(to: ~p"/profiles/#{conn.assigns.user}") + |> Plug.Conn.halt() + else + conn + end + end +end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 31be1073..be9f48e0 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -398,6 +398,7 @@ defmodule PhilomenaWeb.Router do singleton: true resources "/unlock", User.UnlockController, only: [:create], singleton: true + resources "/erase", User.EraseController, only: [:new, :create], singleton: true resources "/api_key", User.ApiKeyController, only: [:delete], singleton: true resources "/downvotes", User.DownvoteController, only: [:delete], singleton: true resources "/votes", User.VoteController, only: [:delete], singleton: true diff --git a/lib/philomena_web/templates/admin/user/erase/new.html.slime b/lib/philomena_web/templates/admin/user/erase/new.html.slime new file mode 100644 index 00000000..643181e7 --- /dev/null +++ b/lib/philomena_web/templates/admin/user/erase/new.html.slime @@ -0,0 +1,16 @@ +h1 + ' Deleting all changes for user + = @user.name + +.block.block--fixed.block--warning + p This is IRREVERSIBLE. + p All user details will be destroyed. + p Are you really sure? + +.field + => button_to "Abort", ~p"/profiles/#{@user}", class: "button" + => button_to "Erase user", ~p"/admin/users/#{@user}/erase", method: "post", class: "button button--state-danger", data: [confirm: "Are you really, really sure?"] + +p + ' This automatically creates user and IP bans but does not create a fingerprint ban. + ' Check to see if one is necessary after erasing. diff --git a/lib/philomena_web/templates/profile/_admin_block.html.slime b/lib/philomena_web/templates/profile/_admin_block.html.slime index 4f827788..9054a457 100644 --- a/lib/philomena_web/templates/profile/_admin_block.html.slime +++ b/lib/philomena_web/templates/profile/_admin_block.html.slime @@ -171,6 +171,12 @@ a.label.label--primary.label--block href="#" data-click-toggle=".js-admin__optio i.fa.fa-fw.fa-arrow-down span.admin__button Remove All Downvotes + = if @user.role == "user" do + li + = link to: ~p"/admin/users/#{@user}/erase/new", data: [confirm: "Are you really, really sure?"] do + i.fa.fa-fw.fa-warning + span.admin__button Erase for spam + = if @user.role == "user" and can?(@conn, :revert, Philomena.TagChanges.TagChange) do li = link to: ~p"/tag_changes/full_revert?#{[user_id: @user.id]}", data: [confirm: "Are you really, really sure?", method: "create"] do diff --git a/lib/philomena_web/views/admin/user/erase_view.ex b/lib/philomena_web/views/admin/user/erase_view.ex new file mode 100644 index 00000000..2c497038 --- /dev/null +++ b/lib/philomena_web/views/admin/user/erase_view.ex @@ -0,0 +1,3 @@ +defmodule PhilomenaWeb.Admin.User.EraseView do + use PhilomenaWeb, :view +end