328 lines
8.8 KiB
Elixir
328 lines
8.8 KiB
Elixir
defmodule Parrhesia.Storage.Adapters.Postgres.Moderation do
|
|
@moduledoc """
|
|
PostgreSQL-backed implementation for `Parrhesia.Storage.Moderation`.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Parrhesia.PostgresRepos
|
|
alias Parrhesia.Repo
|
|
|
|
@behaviour Parrhesia.Storage.Moderation
|
|
|
|
@cache_table :parrhesia_moderation_cache
|
|
@cache_scope_sources %{
|
|
banned_pubkeys: {"banned_pubkeys", :pubkey},
|
|
allowed_pubkeys: {"allowed_pubkeys", :pubkey},
|
|
banned_events: {"banned_events", :event_id},
|
|
blocked_ips: {"blocked_ips", :ip}
|
|
}
|
|
|
|
@impl true
|
|
def ban_pubkey(_context, pubkey) do
|
|
with {:ok, normalized_pubkey} <- normalize_hex_or_binary(pubkey, 32, :invalid_pubkey),
|
|
:ok <- upsert_presence_table("banned_pubkeys", :pubkey, normalized_pubkey) do
|
|
cache_put(:banned_pubkeys, normalized_pubkey)
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def unban_pubkey(_context, pubkey) do
|
|
with {:ok, normalized_pubkey} <- normalize_hex_or_binary(pubkey, 32, :invalid_pubkey),
|
|
:ok <- delete_from_table("banned_pubkeys", :pubkey, normalized_pubkey) do
|
|
cache_delete(:banned_pubkeys, normalized_pubkey)
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def pubkey_banned?(_context, pubkey) do
|
|
with {:ok, normalized_pubkey} <- normalize_hex_or_binary(pubkey, 32, :invalid_pubkey) do
|
|
{:ok, exists_in_scope?(:banned_pubkeys, normalized_pubkey)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def allow_pubkey(_context, pubkey) do
|
|
with {:ok, normalized_pubkey} <- normalize_hex_or_binary(pubkey, 32, :invalid_pubkey),
|
|
:ok <- upsert_presence_table("allowed_pubkeys", :pubkey, normalized_pubkey) do
|
|
cache_put(:allowed_pubkeys, normalized_pubkey)
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def disallow_pubkey(_context, pubkey) do
|
|
with {:ok, normalized_pubkey} <- normalize_hex_or_binary(pubkey, 32, :invalid_pubkey),
|
|
:ok <- delete_from_table("allowed_pubkeys", :pubkey, normalized_pubkey) do
|
|
cache_delete(:allowed_pubkeys, normalized_pubkey)
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def pubkey_allowed?(_context, pubkey) do
|
|
with {:ok, normalized_pubkey} <- normalize_hex_or_binary(pubkey, 32, :invalid_pubkey) do
|
|
{:ok, exists_in_scope?(:allowed_pubkeys, normalized_pubkey)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def has_allowed_pubkeys?(_context) do
|
|
{:ok, scope_populated?(:allowed_pubkeys)}
|
|
end
|
|
|
|
@impl true
|
|
def ban_event(_context, event_id) do
|
|
with {:ok, normalized_event_id} <- normalize_hex_or_binary(event_id, 32, :invalid_event_id),
|
|
:ok <- upsert_presence_table("banned_events", :event_id, normalized_event_id) do
|
|
cache_put(:banned_events, normalized_event_id)
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def unban_event(_context, event_id) do
|
|
with {:ok, normalized_event_id} <- normalize_hex_or_binary(event_id, 32, :invalid_event_id),
|
|
:ok <- delete_from_table("banned_events", :event_id, normalized_event_id) do
|
|
cache_delete(:banned_events, normalized_event_id)
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def event_banned?(_context, event_id) do
|
|
with {:ok, normalized_event_id} <- normalize_hex_or_binary(event_id, 32, :invalid_event_id) do
|
|
{:ok, exists_in_scope?(:banned_events, normalized_event_id)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def block_ip(_context, ip_address) do
|
|
with {:ok, normalized_ip} <- normalize_ip(ip_address),
|
|
:ok <- upsert_presence_table("blocked_ips", :ip, normalized_ip) do
|
|
cache_put(:blocked_ips, normalized_ip)
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def unblock_ip(_context, ip_address) do
|
|
with {:ok, normalized_ip} <- normalize_ip(ip_address),
|
|
:ok <- delete_from_table("blocked_ips", :ip, normalized_ip) do
|
|
cache_delete(:blocked_ips, normalized_ip)
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def ip_blocked?(_context, ip_address) do
|
|
with {:ok, normalized_ip} <- normalize_ip(ip_address) do
|
|
{:ok, exists_in_scope?(:blocked_ips, normalized_ip)}
|
|
end
|
|
end
|
|
|
|
defp upsert_presence_table(table, field, value) do
|
|
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
|
|
|
|
{inserted, _result} =
|
|
Repo.insert_all(
|
|
table,
|
|
[
|
|
%{
|
|
field => value,
|
|
inserted_at: now
|
|
}
|
|
],
|
|
on_conflict: :nothing,
|
|
conflict_target: [field]
|
|
)
|
|
|
|
if inserted <= 1 do
|
|
:ok
|
|
else
|
|
{:error, :insert_failed}
|
|
end
|
|
end
|
|
|
|
defp delete_from_table(table, field, value) do
|
|
query = from(record in table, where: field(record, ^field) == ^value)
|
|
{_deleted, _result} = Repo.delete_all(query)
|
|
:ok
|
|
end
|
|
|
|
defp exists_in_scope?(scope, value) do
|
|
{table, field} = cache_scope_source!(scope)
|
|
|
|
if moderation_cache_enabled?() do
|
|
case cache_table_ref() do
|
|
:undefined ->
|
|
exists_in_table_db?(table, field, value)
|
|
|
|
cache_table ->
|
|
ensure_cache_scope_loaded(scope, cache_table)
|
|
:ets.member(cache_table, cache_member_key(scope, value))
|
|
end
|
|
else
|
|
exists_in_table_db?(table, field, value)
|
|
end
|
|
end
|
|
|
|
defp scope_populated?(scope) do
|
|
{table, field} = cache_scope_source!(scope)
|
|
|
|
if moderation_cache_enabled?() do
|
|
case cache_table_ref() do
|
|
:undefined ->
|
|
scope_populated_db?(table, field)
|
|
|
|
cache_table ->
|
|
ensure_cache_scope_loaded(scope, cache_table)
|
|
|
|
:ets.select_count(cache_table, [{{{:member, scope, :_}, true}, [], [true]}]) > 0
|
|
end
|
|
else
|
|
scope_populated_db?(table, field)
|
|
end
|
|
end
|
|
|
|
defp ensure_cache_scope_loaded(scope, table) do
|
|
loaded_key = cache_loaded_key(scope)
|
|
|
|
if :ets.member(table, loaded_key) do
|
|
:ok
|
|
else
|
|
{db_table, db_field} = cache_scope_source!(scope)
|
|
values = load_scope_values(db_table, db_field)
|
|
|
|
entries = Enum.map(values, &{cache_member_key(scope, &1), true})
|
|
|
|
if entries != [] do
|
|
true = :ets.insert(table, entries)
|
|
end
|
|
|
|
true = :ets.insert(table, {loaded_key, true})
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp load_scope_values(table, field) do
|
|
query =
|
|
from(record in table,
|
|
select: field(record, ^field)
|
|
)
|
|
|
|
read_repo()
|
|
|> then(fn repo -> repo.all(query) end)
|
|
end
|
|
|
|
defp cache_put(scope, value) do
|
|
if moderation_cache_enabled?() do
|
|
case cache_table_ref() do
|
|
:undefined -> :ok
|
|
cache_table -> true = :ets.insert(cache_table, {cache_member_key(scope, value), true})
|
|
end
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
defp cache_delete(scope, value) do
|
|
if moderation_cache_enabled?() do
|
|
case cache_table_ref() do
|
|
:undefined -> :ok
|
|
cache_table -> true = :ets.delete(cache_table, cache_member_key(scope, value))
|
|
end
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
defp cache_scope_source!(scope), do: Map.fetch!(@cache_scope_sources, scope)
|
|
|
|
defp cache_loaded_key(scope), do: {:loaded, scope}
|
|
|
|
defp cache_member_key(scope, value), do: {:member, scope, value}
|
|
|
|
defp cache_table_ref do
|
|
case :ets.whereis(@cache_table) do
|
|
:undefined -> :undefined
|
|
_table_ref -> @cache_table
|
|
end
|
|
end
|
|
|
|
defp moderation_cache_enabled? do
|
|
case Application.get_env(:parrhesia, :moderation_cache_enabled, true) do
|
|
true -> true
|
|
false -> false
|
|
_other -> true
|
|
end
|
|
end
|
|
|
|
defp exists_in_table_db?(table, field, value) do
|
|
query =
|
|
from(record in table,
|
|
where: field(record, ^field) == ^value,
|
|
select: 1,
|
|
limit: 1
|
|
)
|
|
|
|
read_repo()
|
|
|> then(fn repo -> repo.one(query) end)
|
|
|> Kernel.==(1)
|
|
end
|
|
|
|
defp scope_populated_db?(table, field) do
|
|
query =
|
|
from(record in table,
|
|
select: field(record, ^field),
|
|
limit: 1
|
|
)
|
|
|
|
read_repo()
|
|
|> then(fn repo -> repo.one(query) end)
|
|
|> is_nil()
|
|
|> Kernel.not()
|
|
end
|
|
|
|
defp normalize_hex_or_binary(value, expected_bytes, _reason)
|
|
when is_binary(value) and byte_size(value) == expected_bytes,
|
|
do: {:ok, value}
|
|
|
|
defp normalize_hex_or_binary(value, expected_bytes, reason) when is_binary(value) do
|
|
if byte_size(value) == expected_bytes * 2 do
|
|
case Base.decode16(value, case: :mixed) do
|
|
{:ok, decoded} -> {:ok, decoded}
|
|
:error -> {:error, reason}
|
|
end
|
|
else
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
defp normalize_hex_or_binary(_value, _expected_bytes, reason), do: {:error, reason}
|
|
|
|
defp normalize_ip({_, _, _, _} = ip_tuple), do: {:ok, to_inet(ip_tuple)}
|
|
defp normalize_ip({_, _, _, _, _, _, _, _} = ip_tuple), do: {:ok, to_inet(ip_tuple)}
|
|
|
|
defp normalize_ip(ip_address) when is_binary(ip_address) do
|
|
ip_address
|
|
|> String.to_charlist()
|
|
|> :inet.parse_address()
|
|
|> case do
|
|
{:ok, normalized_ip} -> {:ok, to_inet(normalized_ip)}
|
|
{:error, _reason} -> {:error, :invalid_ip_address}
|
|
end
|
|
end
|
|
|
|
defp normalize_ip(_ip_address), do: {:error, :invalid_ip_address}
|
|
|
|
defp to_inet({_, _, _, _} = ip_tuple), do: %Postgrex.INET{address: ip_tuple, netmask: 32}
|
|
|
|
defp to_inet({_, _, _, _, _, _, _, _} = ip_tuple),
|
|
do: %Postgrex.INET{address: ip_tuple, netmask: 128}
|
|
|
|
defp read_repo, do: PostgresRepos.read()
|
|
end
|