diff --git a/lib/philomena/users/ability.ex b/lib/philomena/users/ability.ex index c3ff4f33..8cdd4876 100644 --- a/lib/philomena/users/ability.ex +++ b/lib/philomena/users/ability.ex @@ -30,6 +30,9 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do # Moderators can... # + # Show details of profiles + def can?(%User{role: "moderator"}, :show_details, %User{}), do: true + # View filters def can?(%User{role: "moderator"}, :show, %Filter{}), do: true diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index e91a52db..6b6c0ac5 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -23,6 +23,9 @@ defmodule Philomena.Users.User do alias Philomena.Users.User alias Philomena.Commissions.Commission alias Philomena.Roles.Role + alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.UserIps.UserIp + alias Philomena.Bans.User, as: UserBan @derive {Phoenix.Param, key: :slug} @@ -35,6 +38,9 @@ defmodule Philomena.Users.User do has_many :unread_notifications, UnreadNotification has_many :notifications, through: [:unread_notifications, :notification] has_many :linked_tags, through: [:verified_links, :tag] + has_many :user_ips, UserIp + has_many :user_fingerprints, UserFingerprint + has_many :bans, UserBan has_one :commission, Commission many_to_many :roles, Role, join_through: "users_roles", on_replace: :delete diff --git a/lib/philomena_web/controllers/profile/alias_controller.ex b/lib/philomena_web/controllers/profile/alias_controller.ex new file mode 100644 index 00000000..372754ed --- /dev/null +++ b/lib/philomena_web/controllers/profile/alias_controller.ex @@ -0,0 +1,62 @@ +defmodule PhilomenaWeb.Profile.AliasController do + use PhilomenaWeb, :controller + + alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.UserIps.UserIp + alias Philomena.Users.User + alias Philomena.Repo + import Ecto.Query + + plug PhilomenaWeb.CanaryMapPlug, index: :show_details + plug :load_and_authorize_resource, model: User, id_field: "slug", id_name: "profile_id", persisted: true + + def index(conn, _params) do + user = conn.assigns.user + + # N.B.: subquery runs faster and is easier to read + # than the equivalent join, but Ecto doesn't support + # that for some reason (and ActiveRecord does??) + + ip_matches = + User + |> join(:inner, [u], _ in assoc(u, :user_ips)) + |> join(:left, [u, ui1], ui2 in UserIp, on: ui1.ip == ui2.ip) + |> where([u, _ui1, ui2], u.id != ^user.id and ui2.user_id == ^user.id) + |> select([u, _ui1, _ui2], u) + |> preload(:bans) + |> Repo.all() + |> Map.new(&{&1.id, &1}) + + fp_matches = + User + |> join(:inner, [u], _ in assoc(u, :user_fingerprints)) + |> join(:left, [u, uf1], uf2 in UserFingerprint, on: uf1.fingerprint == uf2.fingerprint) + |> where([u, _uf1, uf2], u.id != ^user.id and uf2.user_id == ^user.id) + |> select([u, _uf1, _uf2], u) + |> preload(:bans) + |> Repo.all() + |> Map.new(&{&1.id, &1}) + + both_matches = + Map.take(ip_matches, Map.keys(fp_matches)) + + ip_matches = + Map.drop(ip_matches, Map.keys(both_matches)) + + fp_matches = + Map.drop(fp_matches, Map.keys(both_matches)) + + both_matches = Map.values(both_matches) + ip_matches = Map.values(ip_matches) + fp_matches = Map.values(fp_matches) + + render( + conn, + "index.html", + title: "Potential Aliases for `#{user.name}'", + both_matches: both_matches, + ip_matches: ip_matches, + fp_matches: fp_matches + ) + end +end diff --git a/lib/philomena_web/controllers/profile/detail_controller.ex b/lib/philomena_web/controllers/profile/detail_controller.ex new file mode 100644 index 00000000..383368d0 --- /dev/null +++ b/lib/philomena_web/controllers/profile/detail_controller.ex @@ -0,0 +1,39 @@ +defmodule PhilomenaWeb.Profile.DetailController do + use PhilomenaWeb, :controller + + alias Philomena.UserNameChanges.UserNameChange + alias Philomena.ModNotes.ModNote + alias Philomena.Textile.Renderer + alias Philomena.Polymorphic + alias Philomena.Users.User + alias Philomena.Repo + import Ecto.Query + + plug PhilomenaWeb.CanaryMapPlug, index: :show_details + plug :load_and_authorize_resource, model: User, id_field: "slug", id_name: "profile_id", persisted: true + + def index(conn, _params) do + user = conn.assigns.user + + mod_notes = + ModNote + |> where(notable_type: "User", notable_id: ^user.id) + |> order_by(desc: :id) + |> preload(:moderator) + |> Repo.all() + |> Polymorphic.load_polymorphic(notable: [notable_id: :notable_type]) + + mod_notes = + mod_notes + |> Renderer.render_collection(conn) + |> Enum.zip(mod_notes) + + name_changes = + UserNameChange + |> where(user_id: ^user.id) + |> order_by(desc: :id) + |> Repo.all() + + render(conn, "index.html", title: "Profile Details for User `#{user.name}'", mod_notes: mod_notes, name_changes: name_changes) + end +end diff --git a/lib/philomena_web/controllers/profile/fp_history_controller.ex b/lib/philomena_web/controllers/profile/fp_history_controller.ex new file mode 100644 index 00000000..cabb6492 --- /dev/null +++ b/lib/philomena_web/controllers/profile/fp_history_controller.ex @@ -0,0 +1,37 @@ +defmodule PhilomenaWeb.Profile.FpHistoryController do + use PhilomenaWeb, :controller + + alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.Users.User + alias Philomena.Repo + import Ecto.Query + + plug PhilomenaWeb.CanaryMapPlug, index: :show_details + plug :load_and_authorize_resource, model: User, id_field: "slug", id_name: "profile_id", persisted: true + + def index(conn, _params) do + user = conn.assigns.user + + user_fps = + UserFingerprint + |> where(user_id: ^user.id) + |> preload(:user) + |> order_by(desc: :updated_at) + |> Repo.all() + + distinct_fps = + user_fps + |> Enum.map(& &1.fingerprint) + |> Enum.uniq() + + other_users = + UserFingerprint + |> where([u], u.fingerprint in ^distinct_fps) + |> preload(:user) + |> order_by(desc: :updated_at) + |> Repo.all() + |> Enum.group_by(& &1.fingerprint) + + render(conn, "index.html", title: "FP History for `#{user.name}'", user_fps: user_fps, other_users: other_users) + end +end diff --git a/lib/philomena_web/controllers/profile/ip_history_controller.ex b/lib/philomena_web/controllers/profile/ip_history_controller.ex new file mode 100644 index 00000000..45facf9e --- /dev/null +++ b/lib/philomena_web/controllers/profile/ip_history_controller.ex @@ -0,0 +1,37 @@ +defmodule PhilomenaWeb.Profile.IpHistoryController do + use PhilomenaWeb, :controller + + alias Philomena.UserIps.UserIp + alias Philomena.Users.User + alias Philomena.Repo + import Ecto.Query + + plug PhilomenaWeb.CanaryMapPlug, index: :show_details + plug :load_and_authorize_resource, model: User, id_field: "slug", id_name: "profile_id", persisted: true + + def index(conn, _params) do + user = conn.assigns.user + + user_ips = + UserIp + |> where(user_id: ^user.id) + |> preload(:user) + |> order_by(desc: :updated_at) + |> Repo.all() + + distinct_ips = + user_ips + |> Enum.map(& &1.ip) + |> Enum.uniq() + + other_users = + UserIp + |> where([u], u.ip in ^distinct_ips) + |> preload(:user) + |> order_by(desc: :updated_at) + |> Repo.all() + |> Enum.group_by(& &1.ip) + + render(conn, "index.html", title: "IP History for `#{user.name}'", user_ips: user_ips, other_users: other_users) + end +end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index ea0747c8..f37b6702 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -159,6 +159,11 @@ defmodule PhilomenaWeb.Router do resources "/description", Profile.DescriptionController, only: [:edit, :update], singleton: true resources "/user_links", Profile.UserLinkController resources "/awards", Profile.AwardController, except: [:index, :show] + + resources "/details", Profile.DetailController, only: [:index] + resources "/ip_history", Profile.IpHistoryController, only: [:index] + resources "/fp_history", Profile.FpHistoryController, only: [:index] + resources "/aliases", Profile.AliasController, only: [:index] end scope "/filters", Filter, as: :filter do diff --git a/lib/philomena_web/templates/admin/mod_note/_table.html.slime b/lib/philomena_web/templates/admin/mod_note/_table.html.slime new file mode 100644 index 00000000..fa457243 --- /dev/null +++ b/lib/philomena_web/templates/admin/mod_note/_table.html.slime @@ -0,0 +1,27 @@ +table.table + thead + tr + td Object + td Note + td Time + td Moderator + td Actions + tbody + = for {body, note} <- @mod_notes do + tr + td + = link_to_noted_thing(@conn, note.notable) + + td + == body + + td + = pretty_time note.created_at + + td + = link note.moderator.name, to: Routes.profile_path(@conn, :show, note.moderator) + + td + => link "Edit", to: Routes.admin_mod_note_path(@conn, :edit, note) + ' • + => link "Delete", to: Routes.admin_mod_note_path(@conn, :delete, note), data: [confirm: "Are you really, really sure?", method: "delete"] diff --git a/lib/philomena_web/templates/admin/mod_note/index.html.slime b/lib/philomena_web/templates/admin/mod_note/index.html.slime index 80caba1b..5b14f76c 100644 --- a/lib/philomena_web/templates/admin/mod_note/index.html.slime +++ b/lib/philomena_web/templates/admin/mod_note/index.html.slime @@ -9,30 +9,4 @@ h2 Mod Notes = pagination .block__content - table.table - thead - tr - td Object - td Note - td Time - td Moderator - td Actions - tbody - = for {body, note} <- @mod_notes do - tr - td - = link_to_noted_thing(@conn, note.notable) - - td - == body - - td - = pretty_time note.created_at - - td - = link note.moderator.name, to: Routes.profile_path(@conn, :show, note.moderator) - - td - => link "Edit", to: Routes.admin_mod_note_path(@conn, :edit, note) - ' • - => link "Delete", to: Routes.admin_mod_note_path(@conn, :delete, note), data: [confirm: "Are you really, really sure?", method: "delete"] + = render PhilomenaWeb.Admin.ModNoteView, "_table.html", mod_notes: @mod_notes, conn: @conn diff --git a/lib/philomena_web/templates/profile/alias/_aliases.html.slime b/lib/philomena_web/templates/profile/alias/_aliases.html.slime new file mode 100644 index 00000000..53913aad --- /dev/null +++ b/lib/philomena_web/templates/profile/alias/_aliases.html.slime @@ -0,0 +1,37 @@ += for u <- @aliases do + tr + td + = link u.name, to: Routes.profile_path(@conn, :show, u) + + td + = @type + + = cond do + - younger_than_7_days?(u) -> + td.danger + = pretty_time u.created_at + + - younger_than_14_days?(u) -> + td.warning + = pretty_time u.created_at + + - true -> + td.success + = pretty_time u.created_at + + = cond do + - not is_nil(u.deleted_at) -> + td.danger + ' Account Disabled + + - currently_banned?(u) -> + td.danger + ' Currently Banned + + - previously_banned?(u) -> + td.danger + ' Previously banned + + - true -> + td.success + ' Never banned diff --git a/lib/philomena_web/templates/profile/alias/index.html.slime b/lib/philomena_web/templates/profile/alias/index.html.slime new file mode 100644 index 00000000..64c6ee59 --- /dev/null +++ b/lib/philomena_web/templates/profile/alias/index.html.slime @@ -0,0 +1,20 @@ +h2 Potential Aliases +p + ' Remember that aliases, especially fingerprints, aren't infallible by a + em> long + ' margin. Use this data only in supplement to other evidence when considering bans. + +br +br + +table.table + thead + tr + td User + td Method + td Creation Date + td Ban Status + tbody + = render PhilomenaWeb.Profile.AliasView, "_aliases.html", aliases: @both_matches, type: "IP + FP", conn: @conn + = render PhilomenaWeb.Profile.AliasView, "_aliases.html", aliases: @ip_matches, type: "IP", conn: @conn + = render PhilomenaWeb.Profile.AliasView, "_aliases.html", aliases: @fp_matches, type: "FP", conn: @conn diff --git a/lib/philomena_web/templates/profile/detail/index.html.slime b/lib/philomena_web/templates/profile/detail/index.html.slime new file mode 100644 index 00000000..4fda2797 --- /dev/null +++ b/lib/philomena_web/templates/profile/detail/index.html.slime @@ -0,0 +1,24 @@ +h2 + = link @user.name, to: Routes.profile_path(@conn, :show, @user) + | 's User Details + +h4 Mod Notes += render PhilomenaWeb.Admin.ModNoteView, "_table.html", mod_notes: @mod_notes, conn: @conn + +h4 Name History +table.table + thead + tr + th Name + th Changed + tbody + = for nc <- @name_changes do + tr + td = nc.name + td = pretty_time nc.created_at + +h4 More Details +ul + li = link "IP Address Usage History", to: Routes.profile_ip_history_path(@conn, :index, @user) + li = link "Fingerprint Usage History", to: Routes.profile_fp_history_path(@conn, :index, @user) + li = link "Potential Aliases", to: Routes.profile_alias_path(@conn, :index, @user) diff --git a/lib/philomena_web/templates/profile/fp_history/index.html.slime b/lib/philomena_web/templates/profile/fp_history/index.html.slime new file mode 100644 index 00000000..3291ea4c --- /dev/null +++ b/lib/philomena_web/templates/profile/fp_history/index.html.slime @@ -0,0 +1,18 @@ +h2 + ' FP History for + = @user.name + +ul + = for ufp <- @user_fps do + li + = link_to_fingerprint @conn, ufp.fingerprint + + ul + = for u <- @other_users[ufp.fingerprint] do + li + => link u.user.name, Routes.profile_path(@conn, :show, u.user) + | ( + => u.uses + ' uses, last used + = pretty_time(u.updated_at) + ' ) diff --git a/lib/philomena_web/templates/profile/ip_history/index.html.slime b/lib/philomena_web/templates/profile/ip_history/index.html.slime new file mode 100644 index 00000000..61fa257b --- /dev/null +++ b/lib/philomena_web/templates/profile/ip_history/index.html.slime @@ -0,0 +1,18 @@ +h2 + ' IP History for + = @user.name + +ul + = for uip <- @user_ips do + li + = link_to_ip @conn, uip.ip + + ul + = for u <- @other_users[uip.ip] do + li + => link u.user.name, Routes.profile_path(@conn, :show, u.user) + | ( + => u.uses + ' uses, last used + = pretty_time(u.updated_at) + ' ) diff --git a/lib/philomena_web/views/profile/alias_view.ex b/lib/philomena_web/views/profile/alias_view.ex new file mode 100644 index 00000000..d97d71a4 --- /dev/null +++ b/lib/philomena_web/views/profile/alias_view.ex @@ -0,0 +1,24 @@ +defmodule PhilomenaWeb.Profile.AliasView do + use PhilomenaWeb, :view + + def younger_than_7_days?(user), + do: younger_than_time_offset?(user, -7*24*60*60) + + def younger_than_14_days?(user), + do: younger_than_time_offset?(user, -14*24*60*60) + + def currently_banned?(%{bans: bans}) do + now = DateTime.utc_now() + + Enum.any?(bans, &DateTime.diff(&1.valid_until, now) >= 0) + end + + def previously_banned?(%{bans: []}), do: false + def previously_banned?(_user), do: true + + defp younger_than_time_offset?(%{created_at: created_at}, time_offset) do + time_ago = NaiveDateTime.utc_now() |> NaiveDateTime.add(-time_offset, :second) + + NaiveDateTime.diff(created_at, time_ago) >= 0 + end +end diff --git a/lib/philomena_web/views/profile/detail_view.ex b/lib/philomena_web/views/profile/detail_view.ex new file mode 100644 index 00000000..daf3c87f --- /dev/null +++ b/lib/philomena_web/views/profile/detail_view.ex @@ -0,0 +1,3 @@ +defmodule PhilomenaWeb.Profile.DetailView do + use PhilomenaWeb, :view +end diff --git a/lib/philomena_web/views/profile/fp_history_view.ex b/lib/philomena_web/views/profile/fp_history_view.ex new file mode 100644 index 00000000..228843d6 --- /dev/null +++ b/lib/philomena_web/views/profile/fp_history_view.ex @@ -0,0 +1,3 @@ +defmodule PhilomenaWeb.Profile.FpHistoryView do + use PhilomenaWeb, :view +end diff --git a/lib/philomena_web/views/profile/ip_history_view.ex b/lib/philomena_web/views/profile/ip_history_view.ex new file mode 100644 index 00000000..6dcf897e --- /dev/null +++ b/lib/philomena_web/views/profile/ip_history_view.ex @@ -0,0 +1,3 @@ +defmodule PhilomenaWeb.Profile.IpHistoryView do + use PhilomenaWeb, :view +end