defmodule GitGud.Reports do
@moduledoc """
User-filed abuse / spam / etc. reports.
Targets are polymorphic via `(target_type, target_id)` — same pattern
as `Labels`. Submission is open to any authenticated user; resolution
is admin-only.
Open reports are unique per `(reporter, target)`; re-reporting is
allowed only after the prior report is `dismissed` or `actioned`.
"""
import Ecto.Query, warn: false
alias GitGud.Accounts.User
alias GitGud.Repo
alias GitGud.Reports.Report
@type target :: {String.t(), integer()}
def list_open(opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
target_type = Keyword.get(opts, :target_type)
Report
|> where([r], r.state == "open")
|> filter_target_type(target_type)
|> order_by([r], desc: r.inserted_at)
|> limit(^limit)
|> preload([:reporter])
|> Repo.all()
end
defp filter_target_type(q, nil), do: q
defp filter_target_type(q, t), do: where(q, [r], r.target_type == ^t)
def get_report!(id), do: Repo.get!(Report, id)
def list_for_target({type, id}) do
Report
|> where([r], r.target_type == ^type and r.target_id == ^id)
|> order_by([r], desc: r.inserted_at)
|> Repo.all()
end
@doc """
Open a report. `reporter` may be nil for anonymous reports (admin
config — currently always requires a user; placeholder for future).
Refuses to open a report whose target is already moderated — the
content has been replaced with a mod message and there's nothing
new to report about it. Returns `{:error, :already_moderated}`.
"""
def open(%User{id: uid}, {target_type, target_id}, reason, details \\ nil) do
if target_already_moderated?(target_type, target_id) do
{:error, :already_moderated}
else
%Report{}
|> Report.open_changeset(%{
reporter_id: uid,
target_type: target_type,
target_id: target_id,
reason: reason,
details: details
})
|> Repo.insert()
end
end
defp target_already_moderated?(type, id) do
case target_schema(type) do
{:ok, schema} ->
case Repo.get(schema, id) do
%{} = target -> schema.moderated?(target)
_ -> false
end
_ ->
false
end
end
@doc "Dismiss a report — no action needed."
def dismiss(%Report{} = report, %User{id: uid}, notes \\ nil) do
report
|> Report.resolve_changeset(%{
state: "dismissed",
resolved_by_id: uid,
resolution_notes: notes
})
|> Repo.update()
end
@doc "Mark a report `actioned` — moderator took some action."
def action(%Report{} = report, %User{id: uid}, notes \\ nil) do
report
|> Report.resolve_changeset(%{
state: "actioned",
resolved_by_id: uid,
resolution_notes: notes
})
|> Repo.update()
end
def open_count, do: Repo.aggregate(from(r in Report, where: r.state == "open"), :count)
@doc """
Forward a report about federated content to the source instance.
Resolves the report target's originating remote actor, builds a `Flag`
activity authored by `admin_user`'s local actor, and queues delivery
to the source's shared inbox (or actor inbox as a fallback).
Returns `{:ok, activity}` if the target is federated and a delivery
was queued; `{:error, :not_federated}` if the target has no remote
origin; `{:error, :no_source_actor}` if we can't resolve the source
actor (e.g. content predates the federation schema fields).
"""
def forward_to_source(%Report{} = report, %User{} = admin_user) do
with {:ok, source_actor, object_urls} <- resolve_forward_target(report),
{:ok, admin_actor} <- GitGud.Federation.ensure_local_actor(admin_user) do
doc =
GitGud.Federation.ActivityBuilders.flag(
admin_actor,
object_urls,
forward_reason(report)
)
{:ok, activity} =
%GitGud.Federation.Activity{}
|> GitGud.Federation.Activity.changeset(%{
activity_url: doc["id"],
type: "Flag",
actor_id: admin_actor.id,
target_actor_id: source_actor.id,
raw: doc,
is_local: true,
published_at: DateTime.utc_now(:second)
})
|> Repo.insert()
inbox = source_actor.shared_inbox_url || source_actor.inbox_url
if is_binary(inbox) and inbox != "" do
{:ok, _job} =
GitGud.Federation.Workers.DeliverOutbound.new_job(activity.id, inbox)
|> Oban.insert()
end
{:ok, activity}
end
end
defp resolve_forward_target(%Report{target_type: "pull_request", target_id: id}) do
case Repo.get(GitGud.PullRequests.PullRequest, id) do
%{source_actor_id: sid, activity_url: url} when is_integer(sid) and is_binary(url) ->
case Repo.get(GitGud.Federation.Actor, sid) do
%GitGud.Federation.Actor{} = actor -> {:ok, actor, [url]}
_ -> {:error, :no_source_actor}
end
_ ->
{:error, :not_federated}
end
end
defp resolve_forward_target(%Report{target_type: "pr_comment", target_id: id}) do
case Repo.get(GitGud.PullRequests.PrComment, id) do
%{source_actor_id: sid, activity_url: url} when is_integer(sid) and is_binary(url) ->
case Repo.get(GitGud.Federation.Actor, sid) do
%GitGud.Federation.Actor{} = actor -> {:ok, actor, [url]}
_ -> {:error, :no_source_actor}
end
_ ->
{:error, :not_federated}
end
end
defp resolve_forward_target(%Report{target_type: "actor", target_id: id}) do
case Repo.get(GitGud.Federation.Actor, id) do
%GitGud.Federation.Actor{kind: "remote", actor_url: url} = actor ->
{:ok, actor, [url]}
_ ->
{:error, :not_federated}
end
end
defp resolve_forward_target(_), do: {:error, :not_federated}
# ── preview + moderation actions ────────────────────────────────────
@doc """
Build a preview map for the admin reports queue: enough context to
decide without navigating away. Returns one of:
%{
title: string, # short one-line label
excerpt: string, # body snippet (≤ 200 chars)
url: string | nil, # link to view the content
author: string, # reporter / commenter display name
kind: atom, # echoes the target type as an atom
moderatable?: boolean, # supports replace-with-mod-message
moderated?: boolean, # already replaced (admin can restore)
suspendable_actor_id: integer | nil
}
or `:missing` if the target row was deleted out from under us.
"""
def preview(%Report{target_type: type, target_id: id}), do: build_preview(type, id)
defp build_preview("issue", id) do
case Repo.get(GitGud.Issues.Issue, id) do
nil ->
:missing
issue ->
repo = repo_for_target_repo_id(issue.repository_id)
author = Repo.get(GitGud.Accounts.User, issue.author_id)
%{
kind: :issue,
title: "##{issue.number} #{issue.title}",
excerpt: excerpt(issue.original_body || issue.body),
url: repo && "/r/#{handle(repo)}/#{repo.name}/issues/#{issue.number}",
author: user_name(author),
moderatable?: true,
moderated?: GitGud.Issues.Issue.moderated?(issue),
suspendable_actor_id: nil
}
end
end
defp build_preview("issue_comment", id) do
case Repo.get(GitGud.Issues.IssueComment, id) do
nil ->
:missing
comment ->
issue = Repo.get(GitGud.Issues.Issue, comment.issue_id)
repo = issue && repo_for_target_repo_id(issue.repository_id)
author = Repo.get(GitGud.Accounts.User, comment.author_id)
%{
kind: :issue_comment,
title:
"comment on ##{issue && issue.number} #{(issue && issue.title) || "(deleted issue)"}",
excerpt: excerpt(comment.original_body || comment.body),
url: repo && issue && "/r/#{handle(repo)}/#{repo.name}/issues/#{issue.number}",
author: user_name(author),
moderatable?: true,
moderated?: GitGud.Issues.IssueComment.moderated?(comment),
suspendable_actor_id: nil
}
end
end
defp build_preview("pull_request", id) do
case Repo.get(GitGud.PullRequests.PullRequest, id) do
nil ->
:missing
pr ->
repo = repo_for_target_repo_id(pr.repository_id)
author = pr.author_id && Repo.get(GitGud.Accounts.User, pr.author_id)
source_actor = pr.source_actor_id && Repo.get(GitGud.Federation.Actor, pr.source_actor_id)
%{
kind: :pull_request,
title: "##{pr.number} #{pr.title}",
excerpt: excerpt(pr.original_body || pr.body),
url: repo && "/r/#{handle(repo)}/#{repo.name}/pulls/#{pr.number}",
author:
cond do
author -> user_name(author)
source_actor -> actor_name(source_actor)
true -> "(unknown)"
end,
moderatable?: true,
moderated?: GitGud.PullRequests.PullRequest.moderated?(pr),
suspendable_actor_id: source_actor && source_actor.kind == "remote" && source_actor.id
}
end
end
defp build_preview("pr_comment", id) do
case Repo.get(GitGud.PullRequests.PrComment, id) do
nil ->
:missing
comment ->
pr = Repo.get(GitGud.PullRequests.PullRequest, comment.pull_request_id)
repo = pr && repo_for_target_repo_id(pr.repository_id)
author = comment.author_id && Repo.get(GitGud.Accounts.User, comment.author_id)
source_actor =
comment.source_actor_id && Repo.get(GitGud.Federation.Actor, comment.source_actor_id)
%{
kind: :pr_comment,
title: "comment on ##{pr && pr.number} #{(pr && pr.title) || "(deleted PR)"}",
excerpt: excerpt(comment.original_body || comment.body),
url: repo && pr && "/r/#{handle(repo)}/#{repo.name}/pulls/#{pr.number}",
author:
cond do
author -> user_name(author)
source_actor -> actor_name(source_actor)
true -> "(unknown)"
end,
moderatable?: true,
moderated?: GitGud.PullRequests.PrComment.moderated?(comment),
suspendable_actor_id: source_actor && source_actor.kind == "remote" && source_actor.id
}
end
end
defp build_preview("actor", id) do
case Repo.get(GitGud.Federation.Actor, id) do
nil ->
:missing
actor ->
%{
kind: :actor,
title: actor_name(actor) <> "@" <> (actor_host(actor) || "?"),
excerpt: actor.summary || actor.actor_url || "",
url: "/actors/#{actor.id}",
author: actor_name(actor),
moderatable?: false,
moderated?: false,
suspendable_actor_id: actor.kind == "remote" && actor.id
}
end
end
defp build_preview("inbox_delivery", id) do
case Repo.get(GitGud.Federation.InboxDelivery, id) do
nil ->
:missing
delivery ->
%{
kind: :inbox_delivery,
title: "inbox delivery from " <> (delivery.source_host || "?"),
excerpt: excerpt(delivery.raw_body),
url: nil,
author: delivery.source_actor_url || "?",
moderatable?: false,
moderated?: false,
suspendable_actor_id: nil
}
end
end
defp build_preview(_, _), do: :missing
defp repo_for_target_repo_id(rid) do
case Repo.get(GitGud.Repositories.Repository, rid) do
nil -> nil
repo -> Repo.preload(repo, [:owner, :organization])
end
end
defp handle(repo), do: GitGud.Repositories.Storage.repo_handle(repo)
defp user_name(nil), do: "(deleted user)"
defp user_name(%{email: e}), do: e |> String.split("@") |> hd()
defp actor_name(nil), do: "(unknown)"
defp actor_name(%{preferred_username: u}) when is_binary(u) and u != "", do: u
defp actor_name(%{name: n}) when is_binary(n) and n != "", do: n
defp actor_name(_), do: "(unknown)"
defp actor_host(%{actor_url: url}) when is_binary(url), do: URI.parse(url).host
defp actor_host(_), do: nil
defp excerpt(nil), do: ""
defp excerpt(body) when is_binary(body) do
body
|> String.split(~r/\r?\n/, trim: true)
|> Enum.join(" ")
|> String.slice(0, 200)
end
# ── moderation actions ──────────────────────────────────────────────
@default_moderation_note "Removed by moderator"
@doc """
Replace the reported content's body with a moderator note. The
original body is preserved on `original_body` and can be revealed to
admins + the original author, or undone via `restore_target/1`.
`note` is optional — falls back to `"Removed by moderator"`. The
report itself is *not* auto-resolved; call `action/3` afterwards.
"""
def moderate_target(report, moderator, note \\ nil)
def moderate_target(%Report{target_type: type, target_id: id}, %User{} = moderator, note) do
mod_note = sanitize_note(note)
with {:ok, schema} <- target_schema(type),
%{} = target <- Repo.get(schema, id) do
target
|> schema.moderate_changeset(moderator, mod_note)
|> Repo.update()
else
nil -> {:error, :not_found}
err -> err
end
end
@doc "Reverse a previous `moderate_target/3`: restore the original body."
def restore_target(%Report{target_type: type, target_id: id}) do
with {:ok, schema} <- target_schema(type),
%{} = target <- Repo.get(schema, id) do
if function_exported?(schema, :moderated?, 1) and schema.moderated?(target) do
target |> schema.restore_changeset() |> Repo.update()
else
{:error, :not_moderated}
end
else
nil -> {:error, :not_found}
err -> err
end
end
defp target_schema("issue"), do: {:ok, GitGud.Issues.Issue}
defp target_schema("issue_comment"), do: {:ok, GitGud.Issues.IssueComment}
defp target_schema("pull_request"), do: {:ok, GitGud.PullRequests.PullRequest}
defp target_schema("pr_comment"), do: {:ok, GitGud.PullRequests.PrComment}
defp target_schema(_), do: {:error, :unmoderatable_target_type}
defp sanitize_note(nil), do: @default_moderation_note
defp sanitize_note(note) when is_binary(note) do
case String.trim(note) do
"" -> @default_moderation_note
s -> String.slice(s, 0, 500)
end
end
@doc """
Suspend a remote actor — flips `actors.status` to `suspended` so
future inbox processing rejects their deliveries. Use on a report
whose preview surfaces `suspendable_actor_id`.
"""
def suspend_actor(actor_id) when is_integer(actor_id) do
case Repo.get(GitGud.Federation.Actor, actor_id) do
nil ->
{:error, :not_found}
actor ->
actor
|> GitGud.Federation.Actor.status_changeset("suspended")
|> Repo.update()
end
end
defp forward_reason(%Report{reason: r, details: d}) when is_binary(d) and d != "",
do: "#{r}: #{d}"
defp forward_reason(%Report{reason: r}), do: r || "abuse"
end
14.9 KiB · text
5af55d9