more admin tools

This commit is contained in:
byte[] 2019-12-17 00:44:24 -05:00
parent c45a2a16d9
commit 8b220775bb
15 changed files with 486 additions and 5 deletions

View file

@ -0,0 +1,72 @@
defmodule Philomena.UserDownvoteWipe do
alias Philomena.Images.Image
alias Philomena.Images
alias Philomena.ImageVotes.ImageVote
alias Philomena.ImageFaves.ImageFave
alias Philomena.Repo
import Ecto.Query
def perform(user, upvotes_and_faves_too \\ false) do
ImageVote
|> where(user_id: ^user.id, up: false)
|> in_batches(fn queryable, image_ids ->
Repo.delete_all(where(queryable, [iv], iv.image_id in ^image_ids))
Repo.update_all(where(Image, [i], i.id in ^image_ids), inc: [downvotes_count: -1, score: 1])
Images.reindex_images(image_ids)
# Allow time for indexing to catch up
:timer.sleep(:timer.seconds(10))
end)
if upvotes_and_faves_too do
ImageVote
|> where(user_id: ^user.id, up: true)
|> in_batches(fn queryable, image_ids ->
Repo.delete_all(where(queryable, [iv], iv.image_id in ^image_ids))
Repo.update_all(where(Image, [i], i.id in ^image_ids), inc: [upvotes_count: -1, score: -1])
Images.reindex_images(image_ids)
:timer.sleep(:timer.seconds(10))
end)
ImageFave
|> where(user_id: ^user.id)
|> in_batches(fn queryable, image_ids ->
Repo.delete_all(where(queryable, [iv], iv.image_id in ^image_ids))
Repo.update_all(where(Image, [i], i.id in ^image_ids), inc: [faves_count: -1])
Images.reindex_images(image_ids)
:timer.sleep(:timer.seconds(10))
end)
end
end
# todo: extract this to its own module somehow
defp in_batches(queryable, mapper) do
queryable = order_by(queryable, asc: :image_id)
ids =
queryable
|> select([q], q.image_id)
|> limit(1000)
|> Repo.all()
queryable
|> in_batches(mapper, 1000, ids)
end
defp in_batches(_queryable, _mapper, _batch_size, []), do: nil
defp in_batches(queryable, mapper, batch_size, ids) do
mapper.(exclude(queryable, :order_by), ids)
ids =
queryable
|> where([q], q.image_id > ^Enum.max(ids))
|> select([q], q.image_id)
|> limit(^batch_size)
|> Repo.all()
in_batches(queryable, mapper, batch_size, ids)
end
end

View file

@ -0,0 +1,68 @@
defmodule Philomena.UserWipe do
@wipe_ip %Postgrex.INET{address: {127, 0, 1, 1}, netmask: 32}
@wipe_fp "ffff"
alias Philomena.Comments.Comment
alias Philomena.Images.Image
alias Philomena.Posts.Post
alias Philomena.Reports.Report
alias Philomena.SourceChanges.SourceChange
alias Philomena.TagChanges.TagChange
alias Philomena.UserIps.UserIp
alias Philomena.UserFingerprints.UserFingerprint
alias Philomena.Users.User
alias Philomena.Repo
import Ecto.Query
def perform(user) do
random_hex = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
for schema <- [Comment, Image, Post, Report, SourceChange, TagChange] do
schema
|> where(user_id: ^user.id)
|> in_batches(&Repo.update_all(&1, set: [ip: @wipe_ip, fingerprint: @wipe_fp]))
end
UserIp
|> where(user_id: ^user.id)
|> Repo.delete_all()
UserFingerprint
|> where(user_id: ^user.id)
|> Repo.delete_all()
User
|> where(id: ^user.id)
|> Repo.update_all(set: [email: "deactivated#{random_hex}@example.com", current_sign_in_ip: @wipe_ip, last_sign_in_ip: @wipe_ip])
end
defp in_batches(queryable, mapper) do
queryable = order_by(queryable, asc: :id)
ids =
queryable
|> select([q], q.id)
|> limit(1000)
|> Repo.all()
in_batches(queryable, mapper, 1000, ids)
end
defp in_batches(_queryable, _mapper, _batch_size, []), do: nil
defp in_batches(queryable, mapper, batch_size, ids) do
queryable
|> where([q], q.id in ^ids)
|> exclude(:order_by)
|> mapper.()
ids =
queryable
|> where([q], q.id > ^Enum.max(ids))
|> select([q], q.id)
|> limit(^batch_size)
|> Repo.all()
in_batches(queryable, mapper, batch_size, ids)
end
end

View file

@ -149,6 +149,24 @@ defmodule Philomena.Users do
|> Repo.isolated_transaction(:serializable)
end
def reactivate_user(%User{} = user) do
user
|> User.reactivate_changeset()
|> Repo.update()
end
def deactivate_user(moderator, %User{} = user) do
user
|> User.deactivate_changeset(moderator)
|> Repo.update()
end
def reset_api_key(%User{} = user) do
user
|> User.api_key_changeset()
|> Repo.update()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.

View file

@ -249,6 +249,20 @@ defmodule Philomena.Users.User do
change(user, watched_tag_ids: watched_tag_ids)
end
def reactivate_changeset(user) do
change(user, deleted_at: nil, deleted_by_user_id: nil)
end
def deactivate_changeset(user, moderator) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
change(user, deleted_at: now, deleted_by_user_id: moderator.id)
end
def api_key_changeset(user) do
put_api_key(user)
end
def create_totp_secret_changeset(user) do
secret = :crypto.strong_rand_bytes(15) |> Base.encode32()
data = Philomena.Users.Encryptor.encrypt_model(secret)

View file

@ -0,0 +1,32 @@
defmodule PhilomenaWeb.Admin.User.ActivationController 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
def create(conn, _params) do
{:ok, user} = Users.reactivate_user(conn.assigns.user)
conn
|> put_flash(:info, "User was reactivated.")
|> redirect(to: Routes.profile_path(conn, :show, user))
end
def delete(conn, _params) do
{:ok, user} = Users.deactivate_user(conn.assigns.current_user, conn.assigns.user)
conn
|> put_flash(:info, "User was deactivated.")
|> redirect(to: Routes.profile_path(conn, :show, 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
end

View file

@ -0,0 +1,24 @@
defmodule PhilomenaWeb.Admin.User.ApiKeyController 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
def delete(conn, _params) do
{:ok, user} = Users.reset_api_key(conn.assigns.user)
conn
|> put_flash(:info, "API token successfully reset.")
|> redirect(to: Routes.profile_path(conn, :show, 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
end

View file

@ -0,0 +1,26 @@
defmodule PhilomenaWeb.Admin.User.DownvoteController do
use PhilomenaWeb, :controller
alias Philomena.UserDownvoteWipe
alias Philomena.Users.User
plug :verify_authorized
plug :load_resource, model: User, id_name: "user_id", id_field: "slug", persisted: true
def delete(conn, _params) do
spawn fn ->
UserDownvoteWipe.perform(conn.assigns.user)
end
conn
|> put_flash(:info, "Downvote wipe started.")
|> redirect(to: Routes.profile_path(conn, :show, conn.assigns.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
end

View file

@ -0,0 +1,26 @@
defmodule PhilomenaWeb.Admin.User.VoteController do
use PhilomenaWeb, :controller
alias Philomena.UserDownvoteWipe
alias Philomena.Users.User
plug :verify_authorized
plug :load_resource, model: User, id_name: "user_id", id_field: "slug", persisted: true
def delete(conn, _params) do
spawn fn ->
UserDownvoteWipe.perform(conn.assigns.user, true)
end
conn
|> put_flash(:info, "Vote and fave wipe started.")
|> redirect(to: Routes.profile_path(conn, :show, conn.assigns.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
end

View file

@ -0,0 +1,26 @@
defmodule PhilomenaWeb.Admin.User.WipeController do
use PhilomenaWeb, :controller
alias Philomena.UserWipe
alias Philomena.Users.User
plug :verify_authorized
plug :load_resource, model: User, id_name: "user_id", id_field: "slug", persisted: true
def create(conn, _params) do
spawn fn ->
UserWipe.perform(conn.assigns.user)
end
conn
|> put_flash(:info, "PII wipe started, please verify and then deactivate the account as necessary.")
|> redirect(to: Routes.profile_path(conn, :show, conn.assigns.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
end

View file

@ -11,12 +11,15 @@ defmodule PhilomenaWeb.ProfileController do
alias Philomena.Comments.Comment
alias Philomena.Interactions
alias Philomena.Tags.Tag
alias Philomena.UserIps.UserIp
alias Philomena.UserFingerprints.UserFingerprint
alias Philomena.Repo
import Ecto.Query
plug :load_and_authorize_resource, model: User, only: :show, id_field: "slug", preload: [
awards: :badge, public_links: :tag, verified_links: :tag, commission: [sheet_image: :tags, items: [example_image: :tags]]
]
plug :set_admin_metadata
def show(conn, _params) do
current_user = conn.assigns.current_user
@ -190,4 +193,33 @@ defmodule PhilomenaWeb.ProfileController do
images
end
defp set_admin_metadata(conn, _opts) do
case Canada.Can.can?(conn.assigns.current_user, :index, User) do
true ->
user = Repo.preload(conn.assigns.user, :current_filter)
filter = user.current_filter
last_ip =
UserIp
|> where(user_id: ^user.id)
|> order_by(desc: :updated_at)
|> limit(1)
|> Repo.one()
last_fp =
UserFingerprint
|> where(user_id: ^user.id)
|> order_by(desc: :updated_at)
|> limit(1)
|> Repo.one()
conn
|> assign(:filter, filter)
|> assign(:last_ip, last_ip)
|> assign(:last_fp, last_fp)
_false ->
conn
end
end
end

View file

@ -6,6 +6,7 @@ defmodule PhilomenaWeb.ReloadUserPlug do
alias Philomena.UserIps.UserIp
alias Philomena.UserFingerprints.UserFingerprint
alias Philomena.Repo
import Ecto.Query
def init(opts), do: opts
@ -33,20 +34,24 @@ defmodule PhilomenaWeb.ReloadUserPlug do
fp = conn.cookies["_ses"]
if ip do
update = update(UserIp, inc: [uses: 1], set: [updated_at: fragment("now()")])
Repo.insert_all(
UserIp,
[%{user_id: user.id, ip: ip, uses: 1}],
conflict_target: [:user_id, :ip],
on_conflict: [inc: [uses: 1]]
on_conflict: update
)
end
if fp do
update = update(UserFingerprint, inc: [uses: 1], set: [updated_at: fragment("now()")])
Repo.insert_all(
UserFingerprint,
[%{user_id: user.id, fingerprint: fp, uses: 1}],
conflict_target: [:user_id, :fingerprint],
on_conflict: [inc: [uses: 1]]
on_conflict: update
)
end
end

View file

@ -224,11 +224,18 @@ defmodule PhilomenaWeb.Router do
resources "/mod_notes", ModNoteController, except: [:show]
resources "/users", UserController, only: [:index, :edit, :update] do
resources "/avatar", User.AvatarController, only: [:delete], singleton: true
resources "/activation", User.ActivationController, only: [:create, :delete], 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
resources "/wipe", User.WipeController, only: [:create], singleton: true
end
resources "/batch/tags", Batch.TagController, only: [:update], singleton: true
scope "/donations", Donation, as: :donation do
resources "/user", UserController, only: [:show]
end
resources "/donations", DonationController, only: [:index, :create]
resources "/donations/user", Donation.UserController, only: [:show]
end
resources "/duplicate_reports", DuplicateReportController, only: [] do

View file

@ -0,0 +1,124 @@
.block--fixed
i.fa.fa-fw.fa-calendar>
' Account created
= @user.created_at
br
i.fa.fa-fw.fa-filter>
' Current Filter:
= if @filter do
= link @filter.name, to: Routes.filter_path(@conn, :show, @filter)
- else
em
' (none)
br
i.far.fa-fw.fa-clock>
' Last seen
= if @last_ip do
=> pretty_time(@last_ip.updated_at)
' from
=> link_to_ip(@conn, @last_ip.ip)
= if @last_fp do
=> link_to_fingerprint(@conn, @last_fp.fingerprint)
- else
em
' (never)
br
' Two factor auth:
strong = enabled_text(@user.otp_required_for_login)
br
a.label.label--primary.label--block href="#" data-click-toggle=".js-admin__options__toggle" title="Toggle Controls"
i.fa.fa-fw.fa-bars
span Toggle Controls
.profile-top__options.js-admin__options__toggle.hidden
ul.profile-admin__options__column
li
= link to: Routes.profile_detail_path(@conn, :index, @user) do
i.fa.fa-fw.fa-eye
span.admin__button View Details
li
= link to: Routes.search_path(@conn, :index, q: "upvoted_by_id:#{@user.id}") do
i.fa.fa-fw.fa-arrow-up
span.admin__button Upvotes
li
= link to: Routes.search_path(@conn, :index, q: "downvoted_by_id:#{@user.id}") do
i.fa.fa-fw.fa-arrow-down
span.admin__button Downvotes
li
= link to: Routes.search_path(@conn, :index, q: "hidden_by_id:#{@user.id}") do
i.fa.fa-fw.fa-eye-slash
span.admin__button Hidden Images
li
= link to: Routes.admin_report_path(@conn, :index, rq: "user_id:#{@user.id}") do
i.fa.fa-fw.fa-exclamation
span.admin__button Reports
li
= link to: Routes.profile_ip_history_path(@conn, :index, @user) do
i.fab.fa-fw.fa-internet-explorer
span.admin__button IP History
li
= link to: Routes.profile_fp_history_path(@conn, :index, @user) do
i.fa.fa-fw.fa-desktop
span.admin__button FP History
li
= link to: Routes.profile_alias_path(@conn, :index, @user) do
i.fa.fa-fw.fa-users
span.admin__button Potential Aliases
ul.profile-admin__options__column
li
= link to: Routes.admin_user_path(@conn, :edit, @user) do
i.fas.fa-fw.fa-edit
span.admin__button Edit User
= if @user.deleted_at do
li
= link to: Routes.admin_user_activation_path(@conn, :create, @user), data: [confirm: "Are you really, really sure?", method: "post"] do
i.fa.fa-fw.fa-check
span.admin__button Reactivate Account
- else
li
= link to: Routes.admin_user_activation_path(@conn, :delete, @user), data: [confirm: "Are you really, really sure?", method: "delete"] do
i.fa.fa-fw.fa-times
span.admin__button Deactivate Account
li
= link to: Routes.admin_donation_user_path(@conn, :show, @user) do
i.fas.fa-fw.fa-dollar-sign
span.admin__button Donations
li
= link to: Routes.profile_user_link_path(@conn, :new, @user) do
i.fa.fa-fw.fa-link
span.admin__button Add User Link
li
= link to: Routes.admin_user_vote_path(@conn, :delete, @user), data: [confirm: "Are you really, really sure?", method: "delete"] do
i.far.fa-fw.fa-file-excel
span.admin__button Remove All Votes/Faves
li
= link to: Routes.admin_user_downvote_path(@conn, :delete, @user), data: [confirm: "Are you really, really sure?", method: "delete"] do
i.fa.fa-fw.fa-arrow-down
span.admin__button Remove All Downvotes
li
= link to: Routes.admin_user_ban_path(@conn, :new, username: @user.name) do
i.fa.fa-fw.fa-ban
span.admin__button Ban this sucker
li
= link to: Routes.admin_user_wipe_path(@conn, :create, @user), data: [confirm: "This is irreversible, destroying all identifying information including email. Are you sure?", method: "post"] do
i.fas.fa-fw.fa-eraser
span.admin__button Wipe PII
li
= link to: Routes.admin_user_api_key_path(@conn, :delete, @user), data: [confirm: "Are you really, really sure?", method: "delete"] do
i.fas.fa-fw.fa-key
span.admin__button Reset API key

View file

@ -22,8 +22,6 @@
li = link("Send message", to: Routes.conversation_path(@conn, :new, recipient: @user.name))
li = link("Our conversations", to: Routes.conversation_path(@conn, :index, with: @user.id))
li = link("Report this user", to: Routes.profile_report_path(@conn, :new, @user))
= if can_ban?(@conn) do
li = link("Ban this sucker", to: Routes.admin_user_ban_path(@conn, :new, username: @user.name))
ul.profile-top__options__column
li = link("Uploads", to: Routes.search_path(@conn, :index, q: "uploader_id:#{@user.id}"))
@ -37,6 +35,9 @@
li = link("Tag changes", to: Routes.profile_tag_change_path(@conn, :index, @user))
li = link("Source changes", to: Routes.profile_source_change_path(@conn, :index, @user))
= if can_index_user?(@conn) do
= render PhilomenaWeb.ProfileView, "_admin_block.html", assigns
= if (current?(@user, @conn.assigns.current_user) or can?(@conn, :index, UserBan)) and Enum.any?(@bans) do
.block
.block__header

View file

@ -56,6 +56,12 @@ defmodule PhilomenaWeb.ProfileView do
def can_ban?(conn),
do: can?(conn, :index, Philomena.Bans.User)
def can_index_user?(conn),
do: can?(conn, :index, Philomena.Users.User)
def enabled_text(true), do: "Enabled"
def enabled_text(_else), do: "Disabled"
def user_abbrv(conn, %{name: name} = user) do
abbrv = String.upcase(initials_abbrv(name) || uppercase_abbrv(name) || first_letters_abbrv(name))
abbrv = "(" <> abbrv <> ")"