Bans logic cleanup

This commit is contained in:
Liam 2024-06-24 20:45:56 -04:00
parent dbbc067679
commit 318ef681de
6 changed files with 157 additions and 130 deletions

View file

@ -4,13 +4,17 @@ defmodule Philomena.Bans do
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Ecto.Multi
alias Philomena.Repo alias Philomena.Repo
alias Philomena.UserIps alias Philomena.Bans.Finder
alias Philomena.Bans.Fingerprint alias Philomena.Bans.Fingerprint
alias Philomena.Bans.SubnetCreator
alias Philomena.Bans.Subnet
alias Philomena.Bans.User
@doc """ @doc """
Returns the list of fingerprint_bans. Returns the list of fingerprint bans.
## Examples ## Examples
@ -23,9 +27,9 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Gets a single fingerprint. Gets a single fingerprint ban.
Raises `Ecto.NoResultsError` if the Fingerprint does not exist. Raises `Ecto.NoResultsError` if the fingerprint ban does not exist.
## Examples ## Examples
@ -39,7 +43,7 @@ defmodule Philomena.Bans do
def get_fingerprint!(id), do: Repo.get!(Fingerprint, id) def get_fingerprint!(id), do: Repo.get!(Fingerprint, id)
@doc """ @doc """
Creates a fingerprint. Creates a fingerprint ban.
## Examples ## Examples
@ -57,7 +61,7 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Updates a fingerprint. Updates a fingerprint ban.
## Examples ## Examples
@ -75,7 +79,7 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Deletes a Fingerprint. Deletes a fingerprint ban.
## Examples ## Examples
@ -91,7 +95,7 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Returns an `%Ecto.Changeset{}` for tracking fingerprint changes. Returns an `%Ecto.Changeset{}` for tracking fingerprint ban changes.
## Examples ## Examples
@ -103,10 +107,8 @@ defmodule Philomena.Bans do
Fingerprint.changeset(fingerprint, %{}) Fingerprint.changeset(fingerprint, %{})
end end
alias Philomena.Bans.Subnet
@doc """ @doc """
Returns the list of subnet_bans. Returns the list of subnet bans.
## Examples ## Examples
@ -119,9 +121,9 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Gets a single subnet. Gets a single subnet ban.
Raises `Ecto.NoResultsError` if the Subnet does not exist. Raises `Ecto.NoResultsError` if the subnet ban does not exist.
## Examples ## Examples
@ -135,7 +137,7 @@ defmodule Philomena.Bans do
def get_subnet!(id), do: Repo.get!(Subnet, id) def get_subnet!(id), do: Repo.get!(Subnet, id)
@doc """ @doc """
Creates a subnet. Creates a subnet ban.
## Examples ## Examples
@ -153,7 +155,7 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Updates a subnet. Updates a subnet ban.
## Examples ## Examples
@ -171,7 +173,7 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Deletes a Subnet. Deletes a subnet ban.
## Examples ## Examples
@ -187,7 +189,7 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Returns an `%Ecto.Changeset{}` for tracking subnet changes. Returns an `%Ecto.Changeset{}` for tracking subnet ban changes.
## Examples ## Examples
@ -199,10 +201,8 @@ defmodule Philomena.Bans do
Subnet.changeset(subnet, %{}) Subnet.changeset(subnet, %{})
end end
alias Philomena.Bans.User
@doc """ @doc """
Returns the list of user_bans. Returns the list of user bans.
## Examples ## Examples
@ -215,9 +215,9 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Gets a single user. Gets a single user ban.
Raises `Ecto.NoResultsError` if the User does not exist. Raises `Ecto.NoResultsError` if the user ban does not exist.
## Examples ## Examples
@ -231,7 +231,7 @@ defmodule Philomena.Bans do
def get_user!(id), do: Repo.get!(User, id) def get_user!(id), do: Repo.get!(User, id)
@doc """ @doc """
Creates a user. Creates a user ban.
## Examples ## Examples
@ -243,31 +243,27 @@ defmodule Philomena.Bans do
""" """
def create_user(creator, attrs \\ %{}) do def create_user(creator, attrs \\ %{}) do
%User{banning_user_id: creator.id} changeset =
|> User.save_changeset(attrs) %User{banning_user_id: creator.id}
|> Repo.insert() |> User.save_changeset(attrs)
Multi.new()
|> Multi.insert(:user_ban, changeset)
|> Multi.run(:subnet_ban, fn _repo, %{user_ban: %{user_id: user_id}} ->
SubnetCreator.create_for_user(creator, user_id, attrs)
end)
|> Repo.transaction()
|> case do |> case do
{:ok, user_ban} -> {:ok, %{user_ban: user_ban}} ->
ip = UserIps.get_ip_for_user(user_ban.user_id)
if ip do
# Automatically create associated IP ban.
ip = UserIps.masked_ip(ip)
%Subnet{banning_user_id: creator.id, specification: ip}
|> Subnet.save_changeset(attrs)
|> Repo.insert()
end
{:ok, user_ban} {:ok, user_ban}
error -> {:error, :user_ban, changeset, _changes} ->
error {:error, changeset}
end end
end end
@doc """ @doc """
Updates a user. Updates a user ban.
## Examples ## Examples
@ -285,7 +281,7 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Deletes a User. Deletes a user ban.
## Examples ## Examples
@ -301,7 +297,7 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Returns an `%Ecto.Changeset{}` for tracking user changes. Returns an `%Ecto.Changeset{}` for tracking user ban changes.
## Examples ## Examples
@ -314,88 +310,9 @@ defmodule Philomena.Bans do
end end
@doc """ @doc """
Returns the first ban, if any, that matches the specified request Returns the first ban, if any, that matches the specified request attributes.
attributes.
""" """
def exists_for?(user, ip, fingerprint) do def find(user, ip, fingerprint) do
now = DateTime.utc_now() Finder.find(user, ip, fingerprint)
queries =
subnet_query(ip, now) ++
fingerprint_query(fingerprint, now) ++
user_query(user, now)
bans =
queries
|> union_all_queries()
|> Repo.all()
# Don't return a ban if the user is currently signed in.
case is_nil(user) do
true -> Enum.at(bans, 0)
false -> user_ban(bans)
end
end
defp fingerprint_query(nil, _now), do: []
defp fingerprint_query(fingerprint, now) do
[
Fingerprint
|> select([f], %{
reason: f.reason,
valid_until: f.valid_until,
generated_ban_id: f.generated_ban_id,
type: ^"FingerprintBan"
})
|> where([f], f.enabled and f.valid_until > ^now)
|> where([f], f.fingerprint == ^fingerprint)
]
end
defp subnet_query(nil, _now), do: []
defp subnet_query(ip, now) do
{:ok, inet} = EctoNetwork.INET.cast(ip)
[
Subnet
|> select([s], %{
reason: s.reason,
valid_until: s.valid_until,
generated_ban_id: s.generated_ban_id,
type: ^"SubnetBan"
})
|> where([s], s.enabled and s.valid_until > ^now)
|> where(fragment("specification >>= ?", ^inet))
]
end
defp user_query(nil, _now), do: []
defp user_query(user, now) do
[
User
|> select([u], %{
reason: u.reason,
valid_until: u.valid_until,
generated_ban_id: u.generated_ban_id,
type: ^"UserBan"
})
|> where([u], u.enabled and u.valid_until > ^now)
|> where([u], u.user_id == ^user.id)
]
end
defp union_all_queries([query]),
do: query
defp union_all_queries([query | rest]),
do: query |> union_all(^union_all_queries(rest))
defp user_ban(bans) do
bans
|> Enum.filter(&(&1.type == "UserBan"))
|> Enum.at(0)
end end
end end

View file

@ -0,0 +1,86 @@
defmodule Philomena.Bans.Finder do
@moduledoc """
Helper to find a bans associated with a set of request attributes.
"""
import Ecto.Query, warn: false
alias Philomena.Repo
alias Philomena.Bans.Fingerprint
alias Philomena.Bans.Subnet
alias Philomena.Bans.User
@fingerprint "Fingerprint"
@subnet "Subnet"
@user "User"
@doc """
Returns the first ban, if any, that matches the specified request attributes.
"""
def find(user, ip, fingerprint) do
bans =
generate_valid_queries([
{ip, &subnet_query/2},
{fingerprint, &fingerprint_query/2},
{user, &user_query/2}
])
|> union_all_queries()
|> Repo.all()
# Don't return a fingerprint or subnet ban if the user is currently signed in.
case is_nil(user) do
true -> Enum.at(bans, 0)
false -> user_ban(bans)
end
end
defp query_base(schema, name, now) do
from b in schema,
where: b.enabled and b.valid_until > ^now,
select: %{
reason: b.reason,
valid_until: b.valid_until,
generated_ban_id: b.generated_ban_id,
type: type(^name, :string)
}
end
defp fingerprint_query(fingerprint, now) do
Fingerprint
|> query_base(@fingerprint, now)
|> where([f], f.fingerprint == ^fingerprint)
end
defp subnet_query(ip, now) do
{:ok, inet} = EctoNetwork.INET.cast(ip)
Subnet
|> query_base(@subnet, now)
|> where(fragment("specification >>= ?", ^inet))
end
defp user_query(user, now) do
User
|> query_base(@user, now)
|> where([u], u.user_id == ^user.id)
end
defp generate_valid_queries(sources) do
now = DateTime.utc_now()
Enum.flat_map(sources, fn
{nil, _cb} -> []
{source, cb} -> [cb.(source, now)]
end)
end
defp union_all_queries([query | rest]) do
Enum.reduce(rest, query, fn q, acc -> union_all(acc, ^q) end)
end
defp user_ban(bans) do
bans
|> Enum.filter(&(&1.type == @user))
|> Enum.at(0)
end
end

View file

@ -0,0 +1,27 @@
defmodule Philomena.Bans.SubnetCreator do
@moduledoc """
Handles automatic creation of subnet bans for an input user ban.
This prevents trivial ban evasion with the creation of a new account from the same address.
The user must work around or wait out the subnet ban first.
"""
alias Philomena.UserIps
alias Philomena.Bans
@doc """
Creates a subnet ban for the given user's last known IP address.
Returns `{:ok, ban}`, `{:ok, nil}`, or `{:error, changeset}`. The return value is
suitable for use as the return value to an `Ecto.Multi.run/3` callback.
"""
def create_for_user(creator, user_id, attrs) do
ip = UserIps.get_ip_for_user(user_id)
if ip do
Bans.create_subnet(creator, Map.put(attrs, "specification", UserIps.masked_ip(ip)))
else
{:ok, nil}
end
end
end

View file

@ -53,9 +53,6 @@ defmodule PhilomenaWeb.Admin.UserBanController do
|> moderation_log(details: &log_details/2, data: user_ban) |> moderation_log(details: &log_details/2, data: user_ban)
|> redirect(to: ~p"/admin/user_bans") |> redirect(to: ~p"/admin/user_bans")
{:error, :user_ban, changeset, _changes} ->
render(conn, "new.html", changeset: changeset)
{:error, changeset} -> {:error, changeset} ->
render(conn, "new.html", changeset: changeset) render(conn, "new.html", changeset: changeset)
end end

View file

@ -22,7 +22,7 @@ defmodule PhilomenaWeb.ApiRequireAuthorizationPlug do
conn conn
|> maybe_unauthorized(user) |> maybe_unauthorized(user)
|> maybe_forbidden(Bans.exists_for?(user, conn.remote_ip, "NOTAPI")) |> maybe_forbidden(Bans.find(user, conn.remote_ip, "NOTAPI"))
end end
defp maybe_unauthorized(conn, nil) do defp maybe_unauthorized(conn, nil) do

View file

@ -20,7 +20,7 @@ defmodule PhilomenaWeb.CurrentBanPlug do
user = conn.assigns.current_user user = conn.assigns.current_user
ip = conn.remote_ip ip = conn.remote_ip
ban = Bans.exists_for?(user, ip, fingerprint) ban = Bans.find(user, ip, fingerprint)
Conn.assign(conn, :current_ban, ban) Conn.assign(conn, :current_ban, ban)
end end