23.5 KiB · text 5af55d9
defmodule GitGudWeb.AdminFederationLive.Index do
@moduledoc """
Federation admin console.
Tabs:
* Deliveries — recent inbox activity with status, filterable by status
* Quarantine — quarantined deliveries needing approve/reject
* Blocks — instance + actor block CRUD
* Allowlist — allowlist CRUD; toggle federation mode
* Reports — open user reports queue
"""
use GitGudWeb, :live_view
import Ecto.Query, warn: false
alias GitGud.Federation
alias GitGud.Federation.ActorBlock
alias GitGud.Federation.InboxDelivery
alias GitGud.Federation.InstanceAllowlist
alias GitGud.Federation.InstanceBlock
alias GitGud.Repo
alias GitGud.Reports
@valid_tabs ~w(deliveries quarantine blocks allowlist reports suggested)
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Federation admin")
|> assign(:tab, "deliveries")
|> assign(:delivery_filter, "all")
|> load()}
end
@impl true
def handle_params(params, _uri, socket) do
tab = if params["tab"] in @valid_tabs, do: params["tab"], else: socket.assigns.tab
filter = params["filter"] || socket.assigns.delivery_filter
{:noreply, socket |> assign(:tab, tab) |> assign(:delivery_filter, filter) |> load()}
end
defp load(socket) do
socket
|> assign(:deliveries, list_deliveries(socket.assigns.delivery_filter))
|> assign(:quarantined, list_deliveries("quarantined"))
|> assign(:blocks_inst, Repo.all(from b in InstanceBlock, order_by: [asc: b.host]))
|> assign(:blocks_actor, Repo.all(from b in ActorBlock, order_by: [asc: b.actor_url]))
|> assign(:allowlist, Repo.all(from a in InstanceAllowlist, order_by: [asc: a.host]))
|> assign(:federation_mode, Federation.federation_mode())
|> assign(:reports, list_reports_with_previews())
|> assign(:previews, current_previews())
|> assign(:open_report_count, Reports.open_count())
|> assign(:suggested_blocks, GitGud.Federation.SuggestedBlocks.list_pending(limit: 200))
|> assign(:suggested_count, GitGud.Federation.SuggestedBlocks.pending_count())
end
defp list_deliveries("all"),
do: Repo.all(from d in InboxDelivery, order_by: [desc: d.inserted_at], limit: 100)
defp list_deliveries(status),
do:
Repo.all(
from d in InboxDelivery,
where: d.status == ^status,
order_by: [desc: d.inserted_at],
limit: 100
)
# ── delivery actions ────────────────────────────────────────────────
@impl true
def handle_event("approve_quarantine", %{"id" => id}, socket) do
delivery = Repo.get!(InboxDelivery, id)
{:ok, _} =
delivery
|> Ecto.Changeset.change(status: "accepted", status_reason: "admin_approved")
|> Repo.update()
# Re-enqueue for actual processing.
{:ok, _} =
GitGud.Federation.Workers.ProcessInbox.new_job(delivery.id) |> Oban.insert()
{:noreply, socket |> put_flash(:info, "Approved.") |> load()}
end
def handle_event("reject_quarantine", %{"id" => id}, socket) do
delivery = Repo.get!(InboxDelivery, id)
{:ok, _} =
delivery
|> Ecto.Changeset.change(
status: "rejected",
status_reason: "admin_rejected",
processed_at: DateTime.utc_now(:second)
)
|> Repo.update()
{:noreply, socket |> put_flash(:info, "Rejected.") |> load()}
end
# ── block / allowlist ───────────────────────────────────────────────
def handle_event("block_instance", %{"host" => host} = params, socket) do
case Federation.block_instance(host, reason: params["reason"]) do
{:ok, _} -> {:noreply, socket |> put_flash(:info, "Blocked.") |> load()}
{:error, _} -> {:noreply, put_flash(socket, :error, "Invalid host.")}
end
end
def handle_event("unblock_instance", %{"host" => host}, socket) do
:ok = Federation.unblock_instance(host)
{:noreply, socket |> load()}
end
def handle_event("block_actor", %{"url" => url} = params, socket) do
case Federation.block_actor(url, params["reason"]) do
{:ok, _} -> {:noreply, socket |> put_flash(:info, "Actor blocked.") |> load()}
{:error, _} -> {:noreply, put_flash(socket, :error, "Invalid URL.")}
end
end
def handle_event("unblock_actor", %{"url" => url}, socket) do
:ok = Federation.unblock_actor(url)
{:noreply, socket |> load()}
end
def handle_event("allowlist", %{"host" => host}, socket) do
{:ok, _} = Federation.allowlist_instance(host)
{:noreply, socket |> put_flash(:info, "Allowlisted.") |> load()}
end
def handle_event("unallowlist", %{"host" => host}, socket) do
Repo.delete_all(from a in InstanceAllowlist, where: a.host == ^host)
{:noreply, socket |> load()}
end
# ── reports ──────────────────────────────────────────────────────────
def handle_event("dismiss_report", %{"id" => id}, socket) do
report = Reports.get_report!(id)
{:ok, _} = Reports.dismiss(report, socket.assigns.current_scope.user)
{:noreply, socket |> load()}
end
def handle_event("forward_report", %{"id" => id}, socket) do
report = Reports.get_report!(id)
case Reports.forward_to_source(report, socket.assigns.current_scope.user) do
{:ok, _activity} ->
{:noreply,
socket
|> load()
|> put_flash(:info, "Flag activity queued for delivery.")}
{:error, :not_federated} ->
{:noreply,
put_flash(socket, :error, "This report's target isn't federated content.")}
{:error, reason} ->
{:noreply,
put_flash(socket, :error, "Could not forward: #{inspect(reason)}.")}
end
end
def handle_event("action_report", %{"id" => id}, socket) do
report = Reports.get_report!(id)
{:ok, _} = Reports.action(report, socket.assigns.current_scope.user)
{:noreply, socket |> load()}
end
def handle_event("moderate_content", params, socket) do
report = Reports.get_report!(params["report_id"])
note = params["note"]
user = socket.assigns.current_scope.user
case Reports.moderate_target(report, user, note) do
{:ok, _} ->
# Auto-resolve the report as "actioned" so it leaves the queue.
{:ok, _} = Reports.action(report, user, "content replaced with mod message")
{:noreply, socket |> load() |> put_flash(:info, "Content replaced.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Couldn't moderate: #{inspect(reason)}")}
end
end
def handle_event("restore_content", %{"id" => id}, socket) do
report = Reports.get_report!(id)
case Reports.restore_target(report) do
{:ok, _} ->
{:noreply, socket |> load() |> put_flash(:info, "Original content restored.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Couldn't restore: #{inspect(reason)}")}
end
end
def handle_event("suspend_actor", %{"id" => id}, socket) do
report = Reports.get_report!(id)
case Reports.preview(report) do
%{suspendable_actor_id: aid} when is_integer(aid) ->
case Reports.suspend_actor(aid) do
{:ok, _} ->
{:ok, _} = Reports.action(report, socket.assigns.current_scope.user, "actor suspended")
{:noreply, socket |> load() |> put_flash(:info, "Actor suspended.")}
err ->
{:noreply, put_flash(socket, :error, "Couldn't suspend: #{inspect(err)}")}
end
_ ->
{:noreply, put_flash(socket, :error, "No suspendable actor on this report.")}
end
end
# ── suggested blocks ────────────────────────────────────────────────
def handle_event("approve_suggested", %{"id" => id}, socket) do
sug = GitGud.Federation.SuggestedBlocks.get!(id)
case GitGud.Federation.SuggestedBlocks.approve(sug, socket.assigns.current_scope.user.id) do
{:ok, _} ->
{:noreply, socket |> load() |> put_flash(:info, "Block applied for #{sug.host}.")}
_ ->
{:noreply, put_flash(socket, :error, "Couldn't apply block.")}
end
end
def handle_event("dismiss_suggested", %{"id" => id}, socket) do
sug = GitGud.Federation.SuggestedBlocks.get!(id)
{:ok, _} =
GitGud.Federation.SuggestedBlocks.dismiss(sug, socket.assigns.current_scope.user.id)
{:noreply, socket |> load()}
end
defp list_reports_with_previews, do: Reports.list_open(limit: 100)
defp current_previews do
Reports.list_open(limit: 100)
|> Map.new(fn r -> {r.id, Reports.preview(r)} end)
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-4">
<h1 class="text-2xl font-semibold">Federation admin</h1>
<div role="tablist" class="tabs tabs-bordered">
<.link
:for={tab <- ~w(deliveries quarantine blocks allowlist reports suggested)}
patch={~p"/admin/federation?tab=#{tab}"}
role="tab"
class={["tab", tab == @tab && "tab-active"]}
>
{tab}
<span :if={tab == "quarantine"} class="badge badge-xs ml-1">
{length(@quarantined)}
</span>
<span :if={tab == "reports"} class="badge badge-xs ml-1">{@open_report_count}</span>
<span :if={tab == "suggested"} class="badge badge-xs ml-1">{@suggested_count}</span>
</.link>
</div>
<div class="text-xs opacity-60">
Federation mode: <span class="font-mono">{@federation_mode}</span>
— set via <code>config :git_gud, GitGud.Federation, federation_mode: ...</code>
</div>
<section :if={@tab == "deliveries"} class="space-y-3">
<div class="flex gap-2 text-sm">
<.link
:for={f <- ~w(all accepted rejected dropped_moderation quarantined)}
patch={~p"/admin/federation?tab=deliveries&filter=#{f}"}
class={["btn btn-xs", f == @delivery_filter && "btn-primary"]}
>
{f}
</.link>
</div>
<.deliveries_table deliveries={@deliveries} />
</section>
<section :if={@tab == "quarantine"} class="space-y-3">
<p class="text-sm opacity-70">
Each delivery below was held back because the destination repo's
<code>interaction_policy</code>
is <code>approved</code>
and the
source actor isn't an accepted follower. Approving will re-run
normal processing.
</p>
<ul class="space-y-2">
<li :for={d <- @quarantined} class="border border-base-300 rounded p-3 text-sm">
<div class="flex items-baseline justify-between">
<span class="font-mono">{d.source_actor_url}</span>
<span class="text-xs opacity-60">{format_dt(d.inserted_at)}</span>
</div>
<pre class="text-xs mt-2 max-h-32 overflow-auto" phx-no-curly-interpolation><%= d.raw_body %></pre>
<div class="flex justify-end gap-2 mt-2">
<button phx-click="reject_quarantine" phx-value-id={d.id} class="btn btn-xs">
Reject
</button>
<button
phx-click="approve_quarantine"
phx-value-id={d.id}
class="btn btn-xs btn-primary"
>
Approve
</button>
</div>
</li>
<li :if={@quarantined == []} class="opacity-60 text-sm">Nothing quarantined.</li>
</ul>
</section>
<section :if={@tab == "blocks"} class="space-y-6">
<div>
<h2 class="font-semibold mb-2">Instance blocks</h2>
<form phx-submit="block_instance" class="flex gap-2 mb-3 items-end">
<input
name="host"
placeholder="bad.example"
class="input input-sm input-bordered"
required
/>
<input
name="reason"
placeholder="reason (optional)"
class="input input-sm input-bordered flex-1"
/>
<button type="submit" class="btn btn-sm">Block</button>
</form>
<ul class="divide-y divide-base-300">
<li :for={b <- @blocks_inst} class="flex items-center gap-2 py-2 text-sm">
<span class="font-mono flex-1">{b.host}</span>
<span class="badge badge-xs">{b.severity}</span>
<span class="opacity-60 text-xs">{b.reason}</span>
<button
phx-click="unblock_instance"
phx-value-host={b.host}
class="btn btn-xs btn-ghost"
>
Unblock
</button>
</li>
</ul>
</div>
<div>
<h2 class="font-semibold mb-2">Actor blocks</h2>
<form phx-submit="block_actor" class="flex gap-2 mb-3 items-end">
<input
name="url"
placeholder="https://elsewhere/users/troll"
class="input input-sm input-bordered flex-1"
required
/>
<input name="reason" placeholder="reason" class="input input-sm input-bordered" />
<button type="submit" class="btn btn-sm">Block</button>
</form>
<ul class="divide-y divide-base-300">
<li :for={b <- @blocks_actor} class="flex items-center gap-2 py-2 text-sm">
<span class="font-mono flex-1 break-all">{b.actor_url}</span>
<span class="opacity-60 text-xs">{b.reason}</span>
<button
phx-click="unblock_actor"
phx-value-url={b.actor_url}
class="btn btn-xs btn-ghost"
>
Unblock
</button>
</li>
</ul>
</div>
</section>
<section :if={@tab == "allowlist"} class="space-y-3">
<form phx-submit="allowlist" class="flex gap-2 items-end">
<input
name="host"
placeholder="trusted.example"
class="input input-sm input-bordered flex-1"
required
/>
<button type="submit" class="btn btn-sm btn-primary">Add</button>
</form>
<ul class="divide-y divide-base-300">
<li :for={a <- @allowlist} class="flex items-center gap-2 py-2 text-sm">
<span class="font-mono flex-1">{a.host}</span>
<button phx-click="unallowlist" phx-value-host={a.host} class="btn btn-xs btn-ghost">
Remove
</button>
</li>
<li :if={@allowlist == []} class="opacity-60 text-sm">No allowlist entries.</li>
</ul>
</section>
<section :if={@tab == "reports"} class="space-y-3">
<ul class="space-y-2">
<li :for={r <- @reports} class="border border-base-300 rounded p-3 text-sm space-y-2">
<div class="flex items-baseline justify-between gap-3">
<span class="text-xs opacity-60 font-mono">{r.target_type}#{r.target_id}</span>
<span class="opacity-60 text-xs">
reported by {report_reporter(r)} · {format_dt(r.inserted_at)}
</span>
</div>
<%= case Map.get(@previews, r.id) do %>
<% :missing -> %>
<p class="text-xs italic opacity-60">
Target content has been deleted or is no longer accessible.
</p>
<% nil -> %>
<p class="text-xs italic opacity-60">No preview available.</p>
<% preview -> %>
<div class="bg-base-200 rounded p-2 space-y-1">
<p class="font-medium text-sm">
<.link :if={preview.url} navigate={preview.url} class="link link-hover">
{preview.title}
</.link>
<span :if={!preview.url}>{preview.title}</span>
</p>
<p :if={preview.excerpt != ""} class="text-xs opacity-80 whitespace-pre-wrap">
{preview.excerpt}
</p>
<p class="text-xs opacity-50">by {preview.author}</p>
</div>
<% end %>
<p class="text-xs">
Reason: <strong>{r.reason}</strong>
<span :if={r.details}>{r.details}</span>
</p>
<div class="flex flex-wrap justify-end gap-2">
<button phx-click="dismiss_report" phx-value-id={r.id} class="btn btn-xs">
Dismiss
</button>
<%= with preview when is_map(preview) <- Map.get(@previews, r.id) do %>
<%= cond do %>
<% preview[:moderatable?] and preview[:moderated?] -> %>
<button
phx-click="restore_content"
phx-value-id={r.id}
class="btn btn-xs"
data-confirm="Restore the original content?"
title="Reveal and restore the original body"
>
Restore original
</button>
<% preview[:moderatable?] -> %>
<form
phx-submit="moderate_content"
class="flex items-center gap-1"
>
<input type="hidden" name="report_id" value={r.id} />
<input
type="text"
name="note"
placeholder="Mod note (optional)"
maxlength="500"
class="input input-xs input-bordered w-48"
/>
<button
type="submit"
class="btn btn-xs btn-warning"
data-confirm={"Replace this " <> Atom.to_string(preview.kind) <> " with a moderator message?"}
>
Replace with mod message
</button>
</form>
<% true -> %>
<% end %>
<button
:if={preview[:suspendable_actor_id]}
phx-click="suspend_actor"
phx-value-id={r.id}
class="btn btn-xs btn-warning"
data-confirm="Suspend this remote actor? Their future inbox deliveries will be rejected."
>
Suspend actor
</button>
<% end %>
<button
:if={forwardable?(r)}
phx-click="forward_report"
phx-value-id={r.id}
class="btn btn-xs btn-warning"
data-confirm="Send an AP Flag to the source instance about this content?"
title="Forward to the source instance"
>
Forward to source
</button>
<button
phx-click="action_report"
phx-value-id={r.id}
class="btn btn-xs btn-primary"
>
Mark actioned
</button>
</div>
</li>
<li :if={@reports == []} class="opacity-60 text-sm">No open reports.</li>
</ul>
</section>
<section :if={@tab == "suggested"} class="space-y-3">
<p class="text-sm opacity-80">
Hosts that peer instances we've ingested have blocked.
<strong>Suggestions only</strong> — defederation cascades
are deliberately declined; review and apply each one
individually.
</p>
<p class="text-xs opacity-60">
Feed this queue from the CLI:
<code class="font-mono">mix gitgud.federation.suggest_blocks peer-host=url</code>
</p>
<ul class="space-y-2">
<li
:for={s <- @suggested_blocks}
class="border border-base-300 rounded p-3 text-sm space-y-1"
>
<div class="flex items-baseline justify-between gap-3">
<span class="font-mono">{s.host}</span>
<span class="opacity-60 text-xs">
blocked by {s.peer_count} peer{if s.peer_count == 1, do: "", else: "s"}
</span>
</div>
<p :if={s.reasons} class="text-xs opacity-70 whitespace-pre-line">{s.reasons}</p>
<p class="text-xs opacity-50 font-mono">{Enum.join(s.peers, ", ")}</p>
<div class="flex justify-end gap-2 mt-1">
<button phx-click="dismiss_suggested" phx-value-id={s.id} class="btn btn-xs">
Dismiss
</button>
<button
phx-click="approve_suggested"
phx-value-id={s.id}
class="btn btn-xs btn-error"
data-confirm={"Apply a real block on #{s.host}?"}
>
Apply block
</button>
</div>
</li>
<li :if={@suggested_blocks == []} class="opacity-60 text-sm">
No suggestions queued.
</li>
</ul>
</section>
</div>
</Layouts.app>
"""
end
attr :deliveries, :list, required: true
defp deliveries_table(assigns) do
~H"""
<table class="table table-sm">
<thead>
<tr>
<th>When</th>
<th>Source</th>
<th>Status</th>
<th>Reason</th>
<th>Sig</th>
</tr>
</thead>
<tbody>
<tr :for={d <- @deliveries}>
<td class="text-xs">{format_dt(d.inserted_at)}</td>
<td class="font-mono text-xs break-all">{d.source_actor_url}</td>
<td><span class={["badge badge-xs", status_class(d.status)]}>{d.status}</span></td>
<td class="text-xs opacity-70">{d.status_reason}</td>
<td>
<span class={[
"badge badge-xs",
(d.signature_verified && "badge-success") || "badge-ghost"
]}>
{if d.signature_verified, do: "✓", else: "—"}
</span>
</td>
</tr>
<tr :if={@deliveries == []}>
<td colspan="5" class="opacity-60 text-sm py-4">No deliveries.</td>
</tr>
</tbody>
</table>
"""
end
defp status_class("accepted"), do: "badge-success"
defp status_class("rejected"), do: "badge-error"
defp status_class("dropped_moderation"), do: "badge-neutral"
defp status_class("quarantined"), do: "badge-warning"
defp status_class(_), do: "badge-ghost"
defp report_reporter(%{reporter: %{email: e}}), do: e |> String.split("@") |> hd()
defp report_reporter(_), do: "anonymous"
# Heuristic — the actual eligibility check happens in
# `Reports.forward_to_source/2`; the button only needs to gate on
# target types where forwarding can apply at all.
defp forwardable?(%{target_type: t}) when t in ["pull_request", "pr_comment", "actor"], do: true
defp forwardable?(_), do: false
defp format_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
end