defmodule GitGud.Federation.SuggestedBlocks do
@moduledoc """
Aggregates "instance X blocked Y for reason Z" rows fed in from peer
instances we trust enough to consider their moderation decisions as
*signals* — never to apply them automatically. The result is a
pending queue an admin reviews and accepts/dismisses individually.
Defederation cascades are
[explicitly declined](../../docs.md#explicitly-declined) — adopting
another instance's moderation list wholesale becomes a coordinated
harassment vector. This is the safe alternative: peer signals turn
into proposals, not enforcement.
Feed it via the `mix gitgud.federation.suggest_blocks` task, or
programmatically via `ingest/3`.
"""
import Ecto.Query, warn: false
alias GitGud.Federation
alias GitGud.Federation.SuggestedInstanceBlock
alias GitGud.Repo
@doc """
Apply a `[{host, reason | nil}, ...]` list as having come from
`peer_host`. For each `host`:
* If a row doesn't exist, create one with `peers: [peer_host]`.
* If one exists, add `peer_host` to `peers` (deduplicated) and
bump `peer_count`.
Hosts already on our `fed_instance_blocks` are filtered out — we
don't propose what's already enforced.
"""
def ingest(entries, peer_host, opts \\ []) when is_list(entries) and is_binary(peer_host) do
now = DateTime.utc_now(:second)
already_blocked =
Repo.all(from b in GitGud.Federation.InstanceBlock, select: b.host) |> MapSet.new()
dry_run? = Keyword.get(opts, :dry_run, false)
entries
|> Enum.uniq_by(fn {host, _} -> host end)
|> Enum.reject(fn {host, _} -> MapSet.member?(already_blocked, host) end)
|> Enum.reduce({0, 0}, fn {host, reason}, {created, updated} ->
case Repo.get_by(SuggestedInstanceBlock, host: host) do
nil ->
unless dry_run? do
%SuggestedInstanceBlock{}
|> SuggestedInstanceBlock.upsert_changeset(%{
host: host,
reasons: reason,
peers: [peer_host],
peer_count: 1,
first_seen_at: now,
last_seen_at: now,
status: "pending"
})
|> Repo.insert!()
end
{created + 1, updated}
existing ->
peers = (existing.peers ++ [peer_host]) |> Enum.uniq()
reasons = merge_reasons(existing.reasons, reason)
unless dry_run? do
existing
|> SuggestedInstanceBlock.upsert_changeset(%{
host: existing.host,
reasons: reasons,
peers: peers,
peer_count: length(peers),
first_seen_at: existing.first_seen_at || now,
last_seen_at: now
})
|> Repo.update!()
end
{created, updated + 1}
end
end)
end
defp merge_reasons(nil, nil), do: nil
defp merge_reasons(nil, b), do: b
defp merge_reasons(a, nil), do: a
defp merge_reasons(a, b) do
parts =
(a <> "\n" <> b)
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.uniq()
Enum.join(parts, "\n")
end
# ── queries ────────────────────────────────────────────────────────
def list_pending(opts \\ []) do
limit = Keyword.get(opts, :limit, 200)
SuggestedInstanceBlock
|> where([s], s.status == "pending")
|> order_by([s], desc: s.peer_count, desc: s.last_seen_at)
|> limit(^limit)
|> Repo.all()
end
def pending_count do
Repo.aggregate(
from(s in SuggestedInstanceBlock, where: s.status == "pending"),
:count
)
end
def get!(id), do: Repo.get!(SuggestedInstanceBlock, id)
# ── resolution ──────────────────────────────────────────────────────
@doc """
Apply a suggestion as a real `InstanceBlock` row, then mark the
suggestion approved.
"""
def approve(%SuggestedInstanceBlock{} = sug, user_id, severity \\ "suspend") do
Repo.transaction(fn ->
{:ok, _} = Federation.block_instance(sug.host, severity: severity, reason: sug.reasons)
sug
|> SuggestedInstanceBlock.resolve_changeset("approved", user_id)
|> Repo.update!()
end)
end
@doc "Mark a suggestion dismissed (won't be re-proposed unless wiped)."
def dismiss(%SuggestedInstanceBlock{} = sug, user_id) do
sug
|> SuggestedInstanceBlock.resolve_changeset("dismissed", user_id)
|> Repo.update()
end
@doc """
Parse a peer list from plain text. Accepts:
* one host per line (lines that look like a hostname)
* `host,reason` CSV (comma followed by free-text reason)
* blank lines + `#` comments ignored
"""
def parse_list(text) when is_binary(text) do
text
|> String.split(["\r\n", "\n"], trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(fn line -> line == "" or String.starts_with?(line, "#") end)
|> Enum.flat_map(fn line ->
case String.split(line, ",", parts: 2) do
[host] -> [{String.trim(host), nil}]
[host, reason] -> [{String.trim(host), String.trim(reason)}]
end
end)
|> Enum.reject(fn {host, _} -> host == "" end)
end
end
5.3 KiB · text
5af55d9