Files
parrhesia/lib/parrhesia/storage/adapters/postgres/moderation.ex

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